- Add BeginRendering/EndRendering commands with imported/created attachment
views, view plumbing, and aspect-gated depth/stencil slots
- Derive and validate render-pass spans at compile time; contract spans into
single DAG nodes before hazard analysis; hoist cross-boundary barriers to
pass begins with in-pass policy and created-view resolution
- Replace the four-pass ordering pipeline with a single command walk
(build_command_orderings): span derivation, validation, lifetime-only
injection, dependency edges, and Kahn batching in one pass; batches are
ranges into execution_order and RenderPassSpan keeps only begin/end
- Perf: node-collapsed frontier analysis, dense command/resource indexing
(no SipHash on hot paths), inline barrier storage, exact preallocation,
lazy Kahn ready heap, scoped barrier fast paths
- Scope buffer and image alias barriers to the incoming resource per the
Vulkan resource-memory-aliasing rules (availability unscoped, visibility
scoped to the new owner)
- Add criterion compile benchmarks (two_pass/{no_pass,pass}/per_{20,50,100})
34 KiB
Plan: Render-Pass Support (BeginRendering / EndRendering) in renderer_rendergraph
Status: proposed — rev 5 (rev-1 correctness, rev-2 performance, rev-3 adversarial, and rev-4 re-review applied; rev-4 verdict was REJECT, findings resolved here)
Date: 2026-10-27
Scope: crates/renderer_rendergraph
Goal
Add dynamic-rendering render-pass support to the rendergraph: BeginRendering
and EndRendering commands that bracket a span of graph commands, with the
compiler guaranteeing that every command recorded between the two is emitted
inside the rendering instance — i.e. commands that must be in a rendergraph
pass are ordered by the begin/end rendering commands, and no foreign command
may be scheduled between them.
Current state (verified against code)
- Commands implement
Command(declaresside_effects→SubresourceScopeaccesses) +RecordedCommand(preflight/apply), and are pushed viaRenderGraph::pushintoCommandList(commands.rs,recorder.rs). GraphCompiler::dependencies()(graph.rs:820-863) builds hazard edges purely from resource conflicts;topo_batches_and_dependencies()(graph.rs:870-889) Kahn-sorts commands intobatches;record_commands(graph.rs:2500-2554) flattens batches, emitting barriers (keyedbefore_commandviagrouped_barrier_indices) immediately before each command.- There is no notion of a render pass. Nothing prevents the flattener from emitting an unrelated command between two commands the user recorded adjacently: Kahn batching interleaves hazard-free commands freely.
CommandRecorder(recorder.rs:159-164) mapsResourceId → vk::Buffer/vk::Imageonly; there is no image-view plumbing. Descriptor imports readArc<ImageView>bindings at import time (graph.rs:507-513) but retain only the underlying image (StagedImportResource::Texture(view.image().clone()),graph.rs:524-532) — attachment views are new plumbing, not an existing pattern to reuse.CommandListstores type-erasedBox<dyn RecordedCommand>(recorder.rs:97-109); theCommandtrait exposes onlyside_effects(commands.rs:14-16), so there is currently no way to classify a command or introspect its render-pass descriptor.- The graph documents a single-queue submission contract (
graph.rs:8-10); ordinary barriers setqueue_transfer: None(graph.rs:972-982, 1001-1012). - Compile-time cost profile (recomputed independently):
dependencies()isO(Σ_r u_r²)pair checks (plus a per-pair scope cross-product factor);HappensBefore::from_dagallocates oneFixedBitSetrow per node (graph.rs:213-217,O(N²/64)memory) and unions per edge (O(E·N/64)time).ResourceInterference::from_resource_usesalready computes terminal/initial frontier sets once per resource (graph.rs:1348-1356); the remaining recomputations are infirst_fit_placement(graph.rs:1642-1649), theoverlappingloop inplan_placements(graph.rs:2384-2399), andbuild_alias_barriers(graph.rs:1088, 1109-1117). Contraction does not remove those; see D8. - The crate is not yet referenced by
renderer/bevy_vulkan_render; the current per-frame path is the legacyrenderer::render_graph. If this crate replaces it,compile()becomes per-frame, so compile cost matters. dynamic_renderingandsynchronization2are requested/enabled (crates/renderer/src/lib.rs:352-357,crates/renderer_device/src/testing.rs:166-172).- Pre-existing defect (reproduced):
build_barriersrecords only the last conflicting producer's layout (graph.rs:951-982) and never chainsold_layoutthrough earlier barriers. Two same-layout readers of one producer yield two image barriers both claimingoldLayout = producerLayout; the second is stale (Vulkan requiresoldLayoutbe the current layout orUNDEFINED). Fixing this generally (per-subresource current-layout map) is part of Task 3 — the plan's own tests hit it. Image::create_viewreturnsResult<ManuallyDrop<ImageView>>(renderer_resources/src/images.rs:558-621); storing it directly leaks thevk::ImageViewand theArc<Image>(the existing renderer calls.raw()on theManuallyDrop,crates/renderer/src/rendering/mod.rs:332, which leaks today). New code must useManuallyDrop::into_inner.ImageViewholdsArc<Image>(images.rs:1123-1127), so a view cache keeps its image alive; drop-order interaction withCompiledGraphis benign provided theManuallyDropis unwrapped.
Design decisions
D1 — Dynamic rendering, not VkRenderPass
New commands BeginRendering / EndRendering wrapping vkCmdBeginRendering
/ vkCmdEndRendering (Vulkan 1.3 dynamic_rendering). Attachments are graph
Texture handles plus a view specification (see D9 for the created-texture
problem).
D2 — The pass span is an atomic scheduling unit, contracted before hazard-edge construction
The compiler contracts the [begin, end] command span into a single DAG node
("pass node"). This is the mechanism that satisfies the ordering contract:
- No foreign command can ever be scheduled inside the span (Kahn batching would otherwise interleave hazard-free commands into it).
- The whole span is ordered relative to everything else by the begin/end hazard edges.
Contraction happens before dependencies() builds hazard edges. This
removes the O(Σ u_r²) interior-draw edge construction (edges that would be
discarded as self-edges anyway) and shrinks the O(N²/64) closure memory/time
by roughly (N_c/N)².
The contraction mapping used by every consumer:
- Type-safe node indexing: use
NodeId(NonZeroU32)newtypes andOption<NodeId>rather than rawu32sentinels mixed intoCommandIndexspaces. To minimize churn, keepbatches: Vec<Vec<CommandIndex>>with theBeginRenderingcommand index as the documented pass-node representative;spans: Vec<Span>onCompiledGraphrecords{ begin, end, interior }for expansion. Document the semantic change to the publicbatches()/dependency_edges()accessors. Closure rows are indexed0..N_c. - HappensBefore is span-aware internally:
HappensBefore(public,lib.rs:10) stores the command→node map and span slices and translates incontains/happens_before(graph.rs:244-251), so command-space queries keep working and interior indices never silently returnfalsefrom a node-space row lookup. - Intra-span record order is NOT a happens-before fact. Interior commands
map to the pass node and same-node uses are treated as unordered by the
closure used for aliasing/interference. This is a memory-correctness
requirement, not a style choice: if intra-span record order were visible to
resources_alias_in_order, a created attachmentA(single use atBegin) and a created storage imageBused only by an interior command would be judged ordered,first_fit_placementcould placeBoverA's memory, andbuild_alias_barrierswould either emit no barrier or an illegal in-passUNDEFINED → layouttransition — discarding a live render target mid-pass. Intra-span record order is needed only bybuild_barriers(which iterates execution order directly) and by recording; it must not leak into the aliasing/interference closure. - Attachment liveness is modeled through
End.EndRenderingcarries synthetic accesses (the attachment layout,COLOR_ATTACHMENT_WRITE/DEPTH_STENCIL_ATTACHMENT_WRITEat the attachment stage) used only for lifetime/alias analysis — excluded from barrier generation (the begin's merged scope already covers synchronization) and from usage derivation. This makesterminal_uses(attachment) == End, so an attachment is never judged dead before the store. - Barrier analysis:
build_barrierswalks scheduled command indices and reads onlyaccesses[command_index](graph.rs:930-934). Barrier analysis runs over the uncontracted access list in execution order (see D6), with each interior command's scheduling position mapped to the pass node. This preserves, e.g., the barrier for an outside producer consumed only by an interiorDraw. - Lifetimes:
bind_lifetimesindexesbatch_for_commandfor every original access (graph.rs:1258-1275); interior commands map to the pass node's batch index so they are never left atusize::MAX. Placement uses command-level interference, not batch intervals, so placement quality is unaffected; external consumers ofresource_lifetimes()get coarser intervals for interior commands (documented). - Self-edges: intra-span hazard pairs are skipped during edge
construction (they become intra-span execution-order constraints handled by
build_barriers, not DAG edges). Notedependencies()only adds prior→current edges (graph.rs:825-861), so the DAG is acyclic regardless; only a literal self-edge would tripKahn, and skipping intra-span pairs prevents exactly that.
D3 — Access declaration: one merged scope per attachment; interior accesses preserved
BeginRenderingdeclares one access per attachment withaccess = COLOR_ATTACHMENT_WRITE | (LOAD ? COLOR_ATTACHMENT_READ : 0)at the same stage (COLOR_ATTACHMENT_OUTPUT/EARLY|LATE_FRAGMENT_TESTS), subresource range from the view (concrete range rules in D9). Declaring read and write as two separate accesses is a blocker:SideEffectMap::insertdeliberately keeps opposite-polarity scopes separate (recorder.rs:55-82), andbuild_barriersthen emits twoImageMemoryBarrier2entries for the same image/range/layout in onevkCmdPipelineBarrier2(reproduced). A single merged scope is handled correctly bySideEffectMap,conflicts,derive_image_usage(graph.rs:2696-2709), and the barrier builder.EndRendering's synthetic accesses (D2) are tagged so barrier generation and usage derivation skip them.- Interior command accesses are not replaced by the begin declarations; they remain in the access list (see D2 barrier analysis) so the compiler can emit barriers for outside producers consumed inside the pass.
- Because the span is contracted, any later consumer is ordered after the
entire pass node, i.e. after
EndRendering— matching store-visibility semantics (verified: the consumer's barrier takesCOLOR_ATTACHMENT_OUTPUT/COLOR_ATTACHMENT_WRITEas src, and hoisting a barrier beforeBeginRenderingwith those dst stages is sufficient visibility for LOAD reads and in-pass attachment reads). - Intra-span hazard policy (narrowed):
- Allowed inside a span: any barrier with
old_layout == new_layout— memory barriers, buffer barriers, and image barriers with no layout change (a storage-image read/write hazard atGENERALneeds anImageMemoryBarrier2; buffer barriers do not synchronize image accesses). These are legal inside a rendering instance. - Rejected at compile time (
GraphError::UnsupportedIntraPassDependency): intra-span hazards requiring an image layout transition (old != new). The rejection operates on the generated barrier plan (D5), not on an independent declared-layout scan. - Feedback loops: defined as any interior access (read or write)
whose
(aspect, mip, layer)set intersects aBeginattachment subresource, excluding the begin's own load operation. A storage-image write to an attachment subresource is equally a feedback loop; an interior read of a disjoint mip/layer is legal. Genuine feedback loops require splitting. - In-pass barrier emission is part of Task 4, not a follow-up: the recorder emits the barrier between the two interior commands in execution order.
- Allowed inside a span: any barrier with
- Aliasing vs. interior first uses is a placement constraint, not a
validation message. For image resources, add an interference edge (or an
explicit
aliasing_allowedpredicate consulted byplan_placements/first_fit_placement) whenever a required alias transition would land at a command that maps to a pass node — covering interior and boundary (Begin/End) positions. There is no "reject aliasing" hook afterplan_placementscommits offsets, so this must be enforced before placement. Attachments themselves are safe: with D2'sEndmodeling, their first use isBeginand their terminal use isEnd, both boundary commands of the pass node itself.
D4 — Barrier placement: hoist cross-boundary barriers to BeginRendering, with per-subresource layout handling
Barriers whose before_command targets an interior command but whose origin
lies outside the span are re-keyed to the span's BeginRendering command,
after batching when spans are known. Constraints:
- Barrier origin must be explicit.
BarrierResource(graph.rs:84-95) records only masks/layout — no source command and no initial-vs-command origin — so a post-pass hoisting pass cannot distinguish an outside-producer barrier from an initial-state transition. Addsrc_command: Option<CommandIndex>or aBarrierOrigin::{Initial, Command(CommandIndex)}toBarrierResource. Initial-state transitions for interior first uses are hoisted toBeginand are not covered by the intra-span rejection (a transient storage image first used inside a pass is the common case, not an edge case). - Coalesce hoisted image transitions per
(resource, exact range, target_layout)into one barrier with a unioneddst_stage/dst_access(safe: the dst scope is a wait target; unioning only makes all consumers wait). Layout chaining is defined per(aspect, mip, layer), not per barrier: for overlapping-but-unequal ranges, transition only the subresources still in the producer layout; for disjoint ranges, emit independentproducer → layouttransitions. The earlier "chain different target layouts" rule is removed: for same-pass overlapping ranges it is unreachable (D3 rejects the required in-pass transition), and for disjoint ranges chainingproducer → A → Bis simply wrong (the second range is still in the producer layout). - The general
build_barriersfix (per-subresource current-layout map, not "last conflicting access's layout") makes non-hoisted multi-reader cases correct too; D4's rules then only govern the hoisted subset. - After hoisting, rebuild
barrier_indices_by_commandandordinary_barrier_count(O(B)), and apply the same coalescing inrebuild_barriers(graph.rs:2092-2106) for alias barriers. Sequencing: hoisting runs beforebuild_alias_barriers, and hoisted barriers live strictly within the ordinary prefix sorebuild_barriers' truncation toordinary_barrier_count(graph.rs:2098) cannot drop or duplicate them;rebuild_barriersre-runs hoisting after the alias rebuild. - Consumers after the pass keep their own barrier keys; expansion emits them
after
EndRenderingnaturally. No barrier targetsEndRenderingitself (its synthetic accesses are excluded from barrier generation). - In-pass barriers (per D3) are emitted between the two interior commands in execution order; they are not hoisted.
D5 — Compile-time validation on the generated barrier plan, all linear
New GraphError variants for:
- unbalanced begin/end; nested/overlapping spans (dynamic rendering cannot
nest);
EndRenderingbefore itsBeginRenderingin record order; - graphics commands outside any span (
GraphError::DrawOutsideRenderPass), and transfer/clear commands inside a span (CopyBuffer,ClearTexture, and any custom command not marked render-pass-legal —vkCmdCopyBuffer2/vkCmdClearColorImageare illegal inside a rendering instance). Classification uses afn kind(&self) -> CommandKindmethod onRecordedCommand(notCommand— a method onCommandis unreachable afterBox<dyn RecordedCommand>erasure), returningCommandKind::{Begin, End, Graphics, Transfer, Compute, Other}plus afn allowed_in_render_pass(&self) -> bool(default false;Draw→ true; test/custom no-op commands may override so Task 5's in-pass barrier tests can exist). Note: five existing unit tests compile graphs containingDrawwith no pass (pushes atgraph.rs:3563, 3749, 3815, 4329, 4469) and must be updated (wrap in begin/end or make the restriction opt-in) — added to Task 5. - unsupported intra-span dependencies (per D3). The check operates on the
generated barrier plan after hoisting/chaining, not on an independent
declared-layout scan: after hoisting, any barrier whose
before_commandis interior and whoseold_layout != new_layoutis either hoistable (initial/outside origin) or must be rejected. This is exact,O(B), and removes the fragile per-subresource validation machinery whoseprevious-selection (graph.rs:951-982) can diverge from declared layouts (e.g. producerPat layoutP0, outside readerR1transitions toA, interior readerR2reads atA: a declared-layout scan seesA → Aand accepts, while the un-fixed builder would emitP0 → AatR2inside the pass). - attachment consistency: each attachment view must refer to the same image
as its graph
Texture— compare raw handles (Image: PartialEqcompares raw handles,images.rs:484-488;Arc::ptr_eqwould false-positive), consistent with import dedup by raw handle (graph.rs:389-399). Applies to the imported case at compile time and to the created case after view creation (D9). The recorder binds graph images byResourceId(graph.rs:2513-2529); this check closes that gap. - attachment layout legality: the caller-specified layout must be a legal
rendering layout (
COLOR_ATTACHMENT_OPTIMAL,GENERAL, etc.). The previously proposed "layout must match compiler-produced state" rule is vacuous — the barrier'snew_layoutis derived from the begin's own declared access, so no independent state exists. - duplicate attachment subresources: two attachments covering the same
subresource would be silently merged by
SideEffectMap::insert(recorder.rs:55-82), making them invisible fromcommand_accesses; reject via the introspection hook (below), using a per-subresource set (O(covered subresources), not pairwiseO(a²)). - queue ownership: passes cannot cross queues or require ownership
transfers; the single-queue submission contract (
graph.rs:8-10) applies unchanged, and any access requiring a queue transfer inside or across a span is rejected until paired transfer/barrier support exists. - Introspection hook: commands are type-erased, and
CommandKindcarries no data, so Task 2's attachment validations (view/image identity, duplicate subresources, layout legality) need afn render_pass_desc(&self) -> Option<&RenderPassDesc>method onRecordedCommand(or a validation hook called fromcompile(), or an attachment summary stored inCommandList::push, which is generic overC: Command). Without one, these checks are not implementable as written. - Error-path safety:
record_commandsreturning mid-pass must not leave an unclosed rendering instance; track anin_render_passflag in the recorder/compiled graph, end the rendering instance (or discard the command buffer) on the error path, and reset the flag (graph.rs:2540-2553).
D6 — Execution order is precomputed at compile time
Compile an execution_order: Vec<CommandIndex> once: expanded contracted
batches — begin → interiors in original record order → end. Barrier
analysis and recording both iterate it; batches is retained (begin-index
representative) for future parallel recording. This avoids per-frame
expansion branches and keeps recording_steps_cached (graph.rs:2595-2608)
allocation-free. Interior commands are emitted in record order, not topo
order: ordering within the pass is the user's responsibility, matching the
"ordered by begin/end" contract.
Batch-index assertion fix: build_barriers_with_initial_states asserts
batch_indices[previous] < batch_indices[current] (graph.rs:962-966).
With interior commands mapped to the pass node's batch index, an allowed
intra-span hazard gives equal batch indices → debug panic. Use
execution-order positions for that assertion, not batch indices.
D7 — Recording is allocation-free per frame
BeginRendering::applyprecomputesvk::RenderingAttachmentInfos in the command (or usesArrayVec/thread-local scratch, mirroringpipeline_barrier'sBARRIER_SCRATCHpattern,recorder.rs:217-292) so noVecis built per frame. Resolving attachment images per frame isO(a log R)BTreeMaplookups — negligible.- View plumbing into
apply:RecordedCommand::applyreceives onlyCommandRecorder(recorder.rs:108), which cannot seeCompiledGraph's view cache. ExtendCommandRecorderwith a view map (ResourceId → Arc<ImageView>, orResourceId → Arc<Image>plus command-local view cache populated via&mut self) populated byrecord_commands.BeginRendering::applylooks up the view; a missing or unresolved view is a newRecordError::MissingView. Views are created withManuallyDrop::into_inner(create_view(...))— never store theManuallyDropdirectly (leak). - Resolve idempotence: views are created only after a fully successful
placement pass, atomically; a failed view creation must not leave a
partially-populated cache that later makes
Arc::get_mut(graph.rs:2472-2480) fail on a retried placement. Repeatedresolve()calls are idempotent;create_viewfailures map toGraphErrorvariants.
D8 — Frontier reuse for placement and alias barriers (performance amendment, corrected)
ResourceInterference::from_resource_uses already caches frontier sets per
resource (graph.rs:1348-1356); the plan does not claim to change it. The
actual recomputation hot spots contraction does not touch are
first_fit_placement (graph.rs:1642-1649), the overlapping loop in
plan_placements (graph.rs:2384-2399), and build_alias_barriers
(terminal_uses per old resource per new resource at graph.rs:1109-1117;
initial_uses per new at graph.rs:1088). Compute terminal/initial
frontiers once per resource and reuse them across all three sites, so
the performance rationale is honest: contraction removes the dependency-edge
and closure quadratics; frontier reuse removes the placement/alias ones.
D9 — Attachment API for created textures
RenderGraph::create_texture returns only a Texture id (graph.rs:352-354);
the Arc<Image> is created privately in plan_placements/resolve
(graph.rs:2129-2495) and CompiledGraph exposes no accessor, so a caller
can never supply an Arc<ImageView> for a transient render target — the
main use case. Resolution:
ColorAttachment.viewis an enum:AttachmentView::Created(ImageViewDesc) | Imported(Arc<ImageView>).Created(ImageViewDesc)must carry concrete aspect/mip/layer ranges (REMAINING_*sentinels are rejected at validation):side_effectsruns atpushtime (recorder.rs:119-124), beforeresolve()creates any image,image_view_subresource_rangeis private (images.rs:1183), andcreate_viewnormalizes/mutates the desc (set_max_end, format fill-in,images.rs:566-589;MipRangeaddition panics on overflow in debug forbase > 0with aREMAININGcount,images.rs:1319-1333). The declared access range therefore comes from the desc, and is validated against the image's mip/layer counts at compile time (fromGraphTextureDesc/ importedImageDesc).- The crate creates and caches views after
resolve()(Image::create_viewneeds&Arc<Image>,images.rs:558), keyed by(Texture, ImageViewDesc);BeginRendering::applyconsumes the cached view via the D7 recorder view map. - The D5 view/image consistency check applies to the imported case at compile time and to the created case after view creation.
Work items (all in crates/renderer_rendergraph)
Task 1 — New commands in src/commands.rs
BeginRendering { color: Vec<ColorAttachment>, depth: Option<DepthStencilAttachment>, ... }withColorAttachment { texture: Texture, view: AttachmentView (D9), layout: vk::ImageLayout, load_op, store_op, clear_value }(depth/stencil analogous).EndRendering(unit struct).Command::side_effects:BeginRenderinginserts one merged scope per attachment (write + conditional LOAD read, same stage, concrete range from the desc/view — D3, D9).EndRenderinginserts synthetic lifetime-only accesses (D2), tagged to be skipped by barrier generation and usage derivation.- Add
fn kind(&self) -> CommandKindandfn allowed_in_render_pass(&self) -> boolandfn render_pass_desc(&self) -> Option<&RenderPassDesc>toRecordedCommand(D5):Draw→Graphics/allowed; begin/end →Begin/End;CopyBuffer/ClearTexture→Transfer/not allowed; defaultOther/not allowed. RecordedCommand::apply:BeginRenderingbuildsvk::RenderingInfo+ precomputedvk::RenderingAttachmentInfos (resolvingvk::Imageviarecorder.image(...), using the cached/created view via the D7 view map) and callscmd_begin_rendering;EndRenderingcallscmd_end_rendering. Allocation-free per D7.- Export both from
lib.rs.
Task 2 — Span tracking & validation in src/graph.rs
- Derive spans in
compile()by scanning command record order usingCommandKind; buildnode_of_commandandspans. - Validation pass implementing the D5 rules, including the raw-handle view/image consistency check (imported case), layout legality, duplicate attachment subresources (via the introspection hook), and the classification-based inside/outside-pass checks. Linear per D5.
- Update the five existing unit tests that compile
Drawwithout a pass (pushes atgraph.rs:3563, 3749, 3815, 4329, 4469).
Task 3 — Span contraction in the compiler
- Contract spans before
dependencies()per D2: aggregate access scopes per pass node, skip intra-span pairs during edge construction, begin-index node representation, contracteddependency_edges;HappensBeforestores the command→node map and translates in queries. - Fix the pre-existing stale-
old_layoutdefect inbuild_barriers(graph.rs:951-982) with a per-subresource current-layout map (D4). - Add
BarrierOrigintoBarrierResource(D4). - Barrier analysis runs over the uncontracted access list in execution order
with interior positions mapped to the pass node (D2); switch the
batch_indicesdebug assertion to execution-order positions (D6). bind_lifetimes,HappensBefore::from_dag,uses_by_resource, andbuild_alias_barriersthread the span map through per D2; all closure consumers route through the span-aware query; same-node uses are unordered for aliasing/interference (D2 blocker fix).- Precompute
execution_orderper D6. - Frontier reuse per D8 (
first_fit_placement,plan_placementsoverlapping loop,build_alias_barriers).
Task 4 — Barrier hoisting, in-pass barriers & recording
- Hoist cross-boundary barriers per D4, including initial-state transitions
for interior first uses (
BarrierOrigin::Initial); coalesce per(resource, exact range, target_layout)with unioned dst masks; per-(aspect, mip, layer)layout handling for overlapping/disjoint ranges. - Enforce the aliasing placement constraint per D3 (interference edge /
aliasing_allowedpredicate) before placement commits offsets. - Emit in-pass barriers (any
old_layout == new_layout) between interior commands in execution order for allowed intra-span hazards (D3). - Hoisting sequenced before
build_alias_barriers, hoisted barriers within the ordinary prefix,rebuild_barriersre-runs hoisting (D4). recording_steps_cachediteratesexecution_order, expanding pass nodes: barriers (hoisted) →BeginRendering→ interior commands in record order (with in-pass barriers interleaved) →EndRendering.record_commands: expanded steps, D7 view map population, and the D5 error-path safety (in_render_passflag, no unclosed rendering instance on mid-passRecordError).
Task 5 — Tests
- Unit tests in
graph.rs:- span validation errors (unbalanced, nested, draw-outside-pass, transfer-inside-pass, view/image mismatch, layout legality, duplicate attachment subresources, feedback-loop rejection);
- contraction correctness (foreign command never appears between begin and
end in
execution_order; interior accesses still produce barriers; lifetimes resolved for interior commands); - barrier hoisting (barrier for an outside producer consumed by an interior
command is keyed to begin; two interior consumers of one
outside-produced image coalesce into one barrier with unioned dst masks
and correct per-subresource
old_layout— regression test for the reproduced blocker; also cover the non-hoisted two-reader case fixed by the generalbuild_barrierschange); - initial-state hoisting (transient storage image first used inside a pass
gets its
UNDEFINED → GENERALtransition beforeBeginRendering); - in-pass barrier emission (interior storage-buffer and storage-image same-layout hazards produce barriers between the interior commands, no pass split; layout-transition hazard is rejected);
- aliasing safety (regression test for the rev-4 blocker): created
attachment
A+ interior-only storage imageBmust not share memory, and no barrier may target an interior command forA/B; attachmentterminal_uses == End; - ordering of both incoming and outgoing dependencies around the whole span;
- span-aware closure queries (
happens_beforewith interior endpoints; same-node uses unordered for aliasing).
- Integration tests in
tests/graph_validation.rs(following the existingTestVulkanpattern):- skip when
core13.dynamic_rendering != TRUEin addition to the existingsynchronization2check (tests/graph_validation.rs:108-111); - the previously proposed
Draw-based graph is not constructible (CopyBufferaccepts only buffers,commands.rs:30-79;Drawhas no pipeline handle and never binds one,commands.rs:143-153, 226-335). Use a custom no-op command (likeScopedNoop) with a texture read access as the post-Endconsumer:ClearTexture → BeginRendering(color=target) → allowed no-op (texture read) → EndRendering → no-op (texture read)compiles, records, submits, and validates. Adding a realBindPipelinecommand (with a documented sequence edge to the followingDraw, since pipeline binding declares no resources) is an optional separate work item; - a case proving a hazard-free foreign command is emitted after
EndRendering, not inside the pass; - an outside-producer → interior-consumer case asserting the hoisted
barrier appears before
BeginRendering.
- skip when
Task 6 — Existing test/API churn
batches()assertions and helpers that assume command-space batches:graph.rs:3774, 4285, 4388-4393, 4674, 5237, 5245, 5257, 5337, 5343, 5397, 5409, 5414(includingrecording_steps(compiled.batches(), …)at 5343).GraphCompiler::new/build_barrierssignature changes (execution order + span map) break direct unit-test call sites atgraph.rs:3177-3178, 4125, 4242, 4716-4718, 4792-4800, 4846-4854, 4893-4901, 4962-4976, 5051-5052, 5102-5104, 5142-5145, 5171-5173;HappensBefore::from_dagis called directly by a test (graph.rs:4140-4155) and its signature changes.- Wrapping a
Drawin a pass shifts barrier indices in tests such assampled_image_import_sets_usage_and_layout_barrier(assertscompiled.barriers()[0],graph.rs:3837-3845). - Provide compatibility constructors where cheap, or update all listed call
sites;
cargo test -p renderer_rendergraphmust pass at the end.
Sequencing
Tasks 1–2 and 3–4 share types, so execute strictly in order: 1 → 2 → 3 →
4 → 5 → 6, with cargo check -p renderer_rendergraph after each and
cargo test -p renderer_rendergraph at the end. No changes to other crates;
renderer's legacy render_graph module is out of scope.
Performance summary (from rev-2 review, corrected in revs 3–5)
- Contraction before
dependencies()removes the interior-drawO(Σ u_r²)edge construction and shrinks theO(N²/64)closure memory/time by roughly(N_c/N)²(a pass spanning 90% of commands → ~100× less closure work). - Contraction does not touch the placement/alias frontiers; D8's
frontier reuse addresses
first_fit_placement,plan_placements' overlapping loop, andbuild_alias_barriers(interference frontier caching already exists). - Barrier hoisting is cost-neutral to slightly positive provided coalescing follows D4 (per-target-layout, per-subresource handling, unioned dst masks only for identical ranges/layouts).
- In-pass barriers for same-layout hazards avoid the significant tiled-GPU regression of forced pass splits (attachment store/load round-trips); only layout transitions and feedback loops force splits.
- Validation is linear with the barrier-plan-based intra-span check
(
O(B)) and per-subresource-set duplicate detection; the existing layout-conflict precheck grows byO(a²)pair iterations per pass (28 for 8 attachments with the merged single access) — negligible. - Recording stays allocation-free via precomputed attachment infos,
thread-local scratch, precomputed
execution_order, and begin-index node representation.
Risks / open points
- API shape: raw
push(BeginRendering)/push(EndRendering)vs. a scopedrender_pass(desc, |graph| ...)builder that pushes begin, runs the closure, and pushes end while recording span command indices. The scoped builder makes unbalanced spans unrepresentable; raw push is more flexible. Decide before Task 1. - Created-texture views (D9): view creation after
resolve()adds a cache keyed by(Texture, ImageViewDesc);ImageViewholdsArc<Image>so lifetime is safe; resolve idempotence and atomic creation per D7. - Public API semantics:
batches()/dependency_edges()keep command-space indices with the begin index as pass-node representative; document the semantics and consider addingexecution_order()as the primary accessor. BindPipeline: out of scope for this plan; without it, submitted tests cannot include real draws. If added later, it needs a sequence edge to the followingDraw(pipeline binding declares no resources, so the hazard graph will not order it).