stride3d / stride3d/stride

[RFC] A safe, blessed way for game systems to draw outside the render pipeline

Open
#3,299 7 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement
Dominant language
C#
Stars
7.8k
Forks
1.2k
Avg merge
2d 17h
Merged PRs (30d)
49

Description

Status: proposal / request for direction · Relates: #1020 (fixed by #3261), #630

Problem

The drawable-component contract omits the one thing a draw method needs — a context to draw with:

  • IGraphicsRendererBase.Draw(RenderDrawContext context) (pipeline side) — injects a valid context.
  • IDrawable.Draw(GameTime gameTime) (IDrawable.cs, implemented by GameSystemBase.cs:46) — injects
    nothing. It passes the incidental thing (time) and withholds the essential thing (the context).

GameTime is XNA legacy (the DrawableGameComponent contract, inherited via SharpDX). It's genuinely used for
time-dependent draws, but it's telling that time — which is really Update's concern and available everywhere —
is on the signature while the context is not. So every drawable system improvises, with two very different
results:

  • Game.GraphicsContext (IGame.cs:83) — the main per-frame command list, reset and begun each frame in
    GameBase.BeginDraw; valid the whole frame. Correct, but you have to know to reach up through Game
    (GameSystemBase.cs:74) — GameSystemBase itself exposes only GraphicsDevice (:93).
  • RenderContext.GetShared(Services).GetThreadContext() (RenderContext.cs:169) — a thread-local
    pipeline-worker context. Its CommandList is Close()d during the compositor's parallel rendering
    (RenderSystem.cs:494) and never re-begun; on a deferred backend (Vulkan) its native command buffer is then
    null, so recording into it segfaults (0xC0000005).

The second path appears fine because on Direct3D11 the thread contexts alias the single main command list via
InternalMainCommandList (Direct3D11/GraphicsDevice.Direct3D11.cs:268) — which Vulkan intentionally does not
set (genuine parallel recording). This is a latent crash class, not a one-off: #1020 was one system hitting
it; any future drawable that reaches for GetThreadContext is broken on Vulkan with no warning.

Who this affects

The production drawables that draw outside the pipeline and improvise a context today:

System What it does How it gets a context
DebugTextSystem overlay text hand-reaches Game.GraphicsContext; carries // TODO where to get command list from? (:96)
GameProfilingSystem profiler overlay hand-reaches Game.GraphicsContext (the #1020 fix)
PhysicsShapesRenderingService physics debug shapes touches Game.GraphicsContext.CommandList from Update (:69) — an even sketchier variant

The count is small because the text systems already paid for the lesson (the TODO; the #1020 crash). The value
is preventing the next one and deleting these hand-rolled reaches. Non-beneficiaries confirm the boundary:
SceneSystem is the context producer (owns/resets the RenderContext, :197/:220); SpriteAnimationSystem
only advances animation in Draw (no context). The wider stack carries dozens of GRAPHICS REFACTOR markers;
full inventory in the usage audit (linked below).

Design space

Four ways to resolve "give me a valid context to draw with," roughly how other engines expose it:

  1. Canonical phase-aware resolver — one accessor that always returns a valid context for the current phase,
    or throws clearly. Foundation; minimal new surface; not itself ergonomic.
  2. Lifecycle draw hooks — register a callback at a named point and receive a guaranteed-valid context.
    Closest to Unity's OnRenderObject / Camera.AddCommandBuffer. Note IDrawable already is this hook
    (BeginDraw/Draw/EndDraw + DrawOrder + Visible) — it just doesn't inject the context and offers only a
    single top-level insertion point, not named sub-pipeline stages.
  3. Explicit command buffer — record an owned buffer object and submit it. Unity's CommandBuffer +
    Graphics.ExecuteCommandBuffer; most flexible; heaviest to make safe on Vulkan.
  4. Custom render pass — a SceneRenderer in the GraphicsCompositor. Exists today; correct and powerful;
    heavyweight for "draw a few lines from my system."

Recommendation

Layered — build the foundation now, leave the ergonomic layer to maintainer appetite.

Layer 1 — build now (low-risk, kills the crash class): fix IDrawable's context omission.

  • Guard the footgun. Make GetThreadContext() / recording into a closed command list fail loudly
    outside a valid recording phase on a deferred backend, instead of a silent access violation. (Or restrict
    GetThreadContext to pipeline-internal use and document it as such.)
  • Give drawables the context. Two ways — this is the headline decision:
    • Non-breaking (accessor): expose the phase-valid context on the base, blessing what DebugTextSystem/
      GameProfilingSystem already hand-roll — protected GraphicsContext GraphicsContext => Game?.GraphicsContext;
      on GameSystemBase.
    • Breaking (signature): evolve the contract to Draw(GameTime, GraphicsContext), mirroring
      IGraphicsRendererBase.Draw — cleanest/LSP-correct, but touches every IDrawable implementor including user
      code.

Layer 2 — open question for maintainers: IDrawable already gives the single top-level draw hook; the real
extension is named sub-pipeline stages (draw between opaque / transparent / post-fx, à la Unity
CameraEvent). Do you want that? We are not proposing to build it here — just checking Layer 1 doesn't
foreclose it.

Layer 3 — document (exists): custom SceneRenderer in the GraphicsCompositor, for a full render pass.

Before / after

// Before — silent Vulkan segfault
var ctx = RenderContext.GetShared(Services).GetThreadContext(); // closed after the compositor runs
ctx.CommandList.Draw(...);                                      // 0xC0000005 on Vulkan

// After — blessed accessor, valid every frame, identical on all backends
GraphicsContext.CommandList.Draw(...);

// After — if the old path is misused, it now throws instead of crashing:
// InvalidOperationException: command list is closed / not in a recording phase.

Impact & migration

Additive and low-risk. The new accessor introduces no behavior change (it returns the same
Game.GraphicsContext systems already use). The guard only affects an already-broken path — turning a
segfault into a clear exception — so no valid code changes behavior. Behavior-preserving on Direct3D11.
GetThreadContext remains available for pipeline-internal use. The editor also runs GameSystems (e.g.
ThumbnailGenerator registers its own GraphicsContext), so it benefits from the same blessed accessor.

Open questions

  • Non-breaking accessor vs. breaking Draw(GameTime, GraphicsContext) signature — the headline call.
  • Sub-pipeline stages — is Layer 2 (named insertion points) wanted, and which stages?
  • Resource/upload context — the audit found one-time setup/upload code (mesh read-back, video-frame uploads,
    physics debug) pinned to the main frame command list. Should there be a distinct resource context separate
    from the per-frame draw context?
  • Guard mechanism — phase check inside GetThreadContext, or throw-on-record-into-closed at the
    CommandList level?

Links

Related work: #1488 [RFC] Stride multithreading | Separate logic and windowing and the #1589 [Ideas]
discussion (parallel rendering with an archetypal ECS) touch the same threading/rendering boundary from other
angles; this RFC is scoped narrowly to context resolution and is complementary to both.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with IDrawable.cs and GameSystemBase.cs:46, then trace GameBase.BeginDraw, RenderContext.cs:169, and RenderSystem.cs:494 to understand context lifetime. Review the named DebugTextSystem, GameProfilingSystem, and PhysicsShapesRenderingService callers and related issue #3261. Done requires a maintainer-selected, scoped Layer 1 direction; the RFC does not identify a concrete test or settled implementation.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
game-dev
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.