feat(render): Campaign V slice V6i-1 - one descriptor set per renderer scope
Plan section 5.5.8 recorded, and deliberately did not fix, that pointing one
binding at a second buffer within a frame silently corrupts the draws already
recorded against it: the backend rewrote the descriptor in place, and a
descriptor set's contents are read when the command buffer EXECUTES, not when it
was recorded. Nothing fired it while the Vulkan frame held only the retained UI.
Section 5.5.11 handed it forward as the first thing the world arm would hit,
because WbDrawDispatcher, EnvCellRenderer and TerrainModernRenderer each own
their own instance, batch and indirect buffers and all three bind set 0 in one
frame.
It is closed here, as its own commit and BEFORE the world arm, so that a blank or
corrupt first Vulkan world frame cannot be this defect wearing another face. That
sequencing is the point: sections 5.5.1 to 5.5.3 cost this campaign three days
because an instrument that was "usually right" sat underneath the thing being
measured.
What changed. There is no longer one (set 0, set 1) pair per flight slot; there
is an arena of them. VulkanBindingScopeArena - pure bookkeeping, no Vulkan
handles, nine unit tests - answers two questions per bind: which pair, and do its
descriptors need writing. VulkanFrameBindings keeps the Vulkan half: allocating
pairs from a growable pool list and writing the twelve descriptors when told to.
The scope key is the descriptor state itself - the ten storage buffer identities
and ranges, the plain bindings' offsets, and the two uniform buffer identities
and ranges. Deriving it is a decision, not an economy. The pinned contract has
nowhere to name a scope: BindStorageBuffer takes a buffer, an offset and a size,
and section 3.3 is frozen. Deriving also gives two properties a declared scope
would not: a renderer cannot forget to declare one, and two renderers that
genuinely share every buffer correctly share one pair rather than being told to
differ. A renderer's buffers are stable for its lifetime, so "distinct descriptor
state" is exactly "renderer scope".
Dynamic offsets stay free. A ring allocation moving between draws rides
vkCmdBindDescriptorSets's dynamic-offset array, so it costs neither a new pair
nor a descriptor write - section 4.4's "zero descriptor writes per frame"
property survives a frame having more than one binding state in it. Entries are
not invalidated at BeginFrame either, because the slot's previous submission has
retired and its descriptors still say what this frame is about to say; a steady
frame therefore rewrites nothing at all. An entry matched from the previous frame
is swapped below the live cursor so the rest of the frame cannot take it for a
different state - the ordering property the sixth test pins, where two renderers
swap submission order between frames.
What this does NOT do is draw a world. The captured Vulkan frame is still V6h's
retained UI over the fog clear, so the arena's multi-scope path is exercised by
its tests and not yet by a frame. That is recorded in the plan rather than
implied.
The plan's section 5.5.12 also records two blockers measured while scoping the
world arm and not fixed here: terrain_modern.vert declares TerrainClip without
ACDREAM_UBO_SET, so under the Vulkan dialect it lands at set 0 binding 2 where
the layout declares a storage buffer - the same class of gap 5.5.8 recorded for
UniformSkyParams, invisible until a terrain pipeline is created; and the offline
gate's scene takes the retail PView path rather than the flat safety path,
because ClipRoot falls back to Buildings.OutdoorNode, which puts
RetailPViewPassExecutor on the critical path to the first Vulkan Dereth frame and
makes the "terrain only" intermediate no cheaper than the whole arm.
Gates. Strict GL offline pixel gate against b9ab5890: 1.60e-05, 9 differing
pixels of 563,200, at the low end of the documented 9-31 px band and 62x under
the threshold - expected, since no GL file is touched. GL connected
run-repeat-connected-gate.ps1 -Runs 3: 3/3 RENDERED on the desktop witness and
3/3 on the client capture. One offline Vulkan run with VK_LAYER_KHRONOS_validation
proven inserted by the loader: zero errors, zero warnings, captured frame, no
[shutdown] diagnostic on either stream. App tests 4,086 / 3 skips (baseline 4,077
plus nine); complete Release suite 9,149 / 5. Issue #250's
SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing failed once in a
whole-suite run and passed run alone, as that issue documents.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b9ab5890af
commit
df6e2a7918
6 changed files with 759 additions and 100 deletions
|
|
@ -1494,6 +1494,109 @@ instance and batch buffers and both bind bindings 0, 1, 3, 4 and 5 in one
|
|||
frame; and the world path's own texture creation still has to reach
|
||||
`IGpuTexture`, because a Vulkan draw cannot sample a GL handle.
|
||||
|
||||
#### 5.5.12 V6i-1 (2026-07-28): the descriptor-set hazard is closed before the world arm fires it
|
||||
|
||||
§5.5.8 recorded, and deliberately did not fix, that **a binding pointed at two
|
||||
different buffers within one frame silently corrupts the earlier draws**: the
|
||||
backend rewrote the descriptor in place, and a descriptor set's contents are read
|
||||
when the command buffer EXECUTES, not when it was recorded. Nothing fired it
|
||||
while the Vulkan frame held only the retained UI. §5.5.11 handed it forward as
|
||||
the first thing the world arm would hit, because `WbDrawDispatcher`,
|
||||
`EnvCellRenderer` and `TerrainModernRenderer` each own their own instance, batch
|
||||
and indirect buffers and all three bind set 0 in one frame.
|
||||
|
||||
It is now closed, and it was closed *first* rather than alongside the world arm,
|
||||
so that a blank or corrupt Vulkan world frame cannot be this defect wearing
|
||||
another face.
|
||||
|
||||
**One set pair per renderer scope, derived rather than declared.** There is no
|
||||
longer a single (set 0, set 1) pair per flight slot; there is an arena of them.
|
||||
`VulkanBindingScopeArena` — pure bookkeeping, no Vulkan handles, unit-tested —
|
||||
decides which pair a bind belongs to and whether its descriptors must be written.
|
||||
The scope key is the descriptor state itself: the ten storage buffer identities
|
||||
and ranges, the plain (non-dynamic) bindings' offsets, and the two uniform buffer
|
||||
identities and ranges.
|
||||
|
||||
Deriving the scope is a decision, not an economy. **The pinned contract has no
|
||||
place to name one** — `BindStorageBuffer` takes a buffer, an offset and a size,
|
||||
and §3.3 is frozen. Deriving it from state also gives two properties a declared
|
||||
scope would not: a renderer cannot forget to declare one, and two renderers that
|
||||
genuinely share every buffer correctly share one pair instead of being told to
|
||||
differ. A renderer's buffers are stable for its lifetime, so "distinct descriptor
|
||||
state" *is* "renderer scope".
|
||||
|
||||
**Dynamic offsets stay free.** A ring allocation moving between draws rides
|
||||
`vkCmdBindDescriptorSets`'s dynamic-offset array, so it costs neither a new pair
|
||||
nor a descriptor write — §4.4's "zero descriptor writes per frame" property
|
||||
survives a frame now having more than one binding state in it. A steady frame
|
||||
rewrites nothing at all: entries are not invalidated at `BeginFrame`, because the
|
||||
slot's previous submission has retired and its descriptors still say exactly what
|
||||
this frame is about to say. An entry matched from the previous frame is swapped
|
||||
below the live cursor, so the rest of the frame cannot take it for a different
|
||||
state — the property nine tests pin, including the ordering case where two
|
||||
renderers swap their submission order between frames.
|
||||
|
||||
**Two things the world arm still needs, unchanged from §5.5.11's handoff**, plus
|
||||
one this slice measured:
|
||||
|
||||
1. **World texture CREATION must reach `IGpuTexture`.** V4t moved the table
|
||||
ENTRY to the device and left creation with `TerrainAtlas`,
|
||||
`CompositeTextureArrayCache`, `ManagedGLTextureArray` and `TextureCache`'s
|
||||
array path. `TerrainAtlas` is the smallest of these and the only one terrain
|
||||
needs; `TextureAtlasManager`/`ManagedGLTextureArray` is the largest, and it is
|
||||
what statics, scenery and EnvCell shells sample through.
|
||||
`ICompositeTextureArrayBackend` is already a seam and takes an RHI backend
|
||||
directly; `TextureAtlasManager` is not, and reaches `OpenGLGraphicsDevice`
|
||||
through `CreateTextureArrayInternal`. `BlockCompressionCodec` and
|
||||
`BlockCompressionMipChain` (V6b) already supply the BC mip chains that path
|
||||
needs, so the missing piece is an `ITextureArray` implementation over
|
||||
`IGpuTexture`, not a codec.
|
||||
|
||||
2. **`terrain_modern.vert`'s `TerrainClip` block is in the wrong set for Vulkan,
|
||||
and it is the same class of gap §5.5.8 recorded for `UniformSkyParams`.** It
|
||||
is declared `layout(std140, binding = 2) uniform TerrainClip` with **no
|
||||
`ACDREAM_UBO_SET`**, so under the Vulkan dialect it lands at set 0 binding 2 —
|
||||
which set 0's layout declares as a STORAGE buffer. GL is unaffected (the macro
|
||||
expands to nothing and the UBO namespace is separate), and nothing has caught
|
||||
it because no terrain pipeline has ever been created on Vulkan. Whoever draws
|
||||
terrain must add the macro, declare binding 2 in the uniform set layout, and
|
||||
raise `DynamicUniformBindingCount` to 3 — still far under the guaranteed 8.
|
||||
Worth auditing every remaining shader for the same omission in the same pass.
|
||||
|
||||
3. **The flat-versus-PView question decides how much of the world arm is one
|
||||
slice.** `WorldSceneRenderer` is already backend-neutral — it takes
|
||||
`IWorldScenePassExecutor` and `IWorldScenePViewRenderer` as interfaces — so
|
||||
the Vulkan arm reuses it whole. But `WorldRenderFrame.ClipRoot` is
|
||||
`Roots.ViewerRoot ?? Buildings.OutdoorNode`, and the offline gate's scene has
|
||||
buildings, so the offline capture takes the **PView** path, not the flat
|
||||
safety path. A Vulkan world arm that implements only `WorldScenePassExecutor`
|
||||
therefore renders nothing in the very scene the pixel gate captures.
|
||||
`RetailPViewPassExecutor` (685 lines) is on the critical path to the first
|
||||
Vulkan Dereth PNG, and the "terrain only" intermediate §5.5.7 suggested is
|
||||
consequently NOT cheaper than it looks.
|
||||
|
||||
**Gate results.** Strict GL offline pixel gate against `b9ab5890`: **1.60e-05**
|
||||
(9 differing pixels of 563,200), at the low end of the documented 9–31 px band
|
||||
and 62x under the 0.001 threshold — expected, since no GL file is touched.
|
||||
GL connected `tools/run-repeat-connected-gate.ps1 -Runs 3`: **3/3 RENDERED** on
|
||||
the desktop witness and 3/3 on the client capture. One offline Vulkan run with
|
||||
`VK_LAYER_KHRONOS_validation` **proven inserted by the loader** (`Insert instance
|
||||
layer "VK_LAYER_KHRONOS_validation"` from `VK_LOADER_DEBUG=layer`): **zero
|
||||
validation errors and zero warnings**, a captured retained-UI frame, and no
|
||||
`[shutdown]` diagnostic on either stream. App tests 4,086 / 3 skips (the 4,077
|
||||
baseline plus nine); complete Release suite 9,149 / 5. `#250`'s
|
||||
`SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing` failed once in a
|
||||
whole-suite run and passed run alone, exactly as that issue documents.
|
||||
|
||||
**What this slice deliberately does NOT do**: draw a world. The captured Vulkan
|
||||
frame is V6h's — the retained UI over the atmosphere fog clear — because no world
|
||||
renderer exists on the Vulkan arm yet. The arena's multi-scope path is therefore
|
||||
exercised only by its unit tests; the live Vulkan frame binds one scope and
|
||||
re-matches it every frame, which is the "rewrites nothing" case. That is the
|
||||
honest state and it is why the hazard was closed as its own commit: when the
|
||||
world arm lands and a frame first holds three renderers' buffers, a blank or
|
||||
corrupt result cannot be this defect.
|
||||
|
||||
### 5.4 The null-target `BeginPass` divergence (V4c) — must be undone at V6
|
||||
|
||||
V4c had to stop GL's `BeginPass` from binding framebuffer 0 when a pass declares
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue