docs(render): record V4t at the committed tree, and correct one stale comment

Marks V4t done in the slice table with its two commits and per-commit gate
numbers, adds §5.5.11, and ticks off step 3 of §5.5.9's corrected sequence.

§5.5.11 records four things the world arm needs and one it should not
re-derive: why §5.2's "the device's table is unreachable" argument expired
rather than being worked around (its premise was that V4c would move the world
renderers onto the encoder; §5.5.6 closed that, so the block became
indefinite); that V4t landed narrower than §5.3 sized it, keeping texture
creation and residency with the caches and moving only the table entry, with
`IGpuTexture` creation deferred to the world arm that actually cannot use a GL
handle; why `GroupKey`'s ordering survives a retype of one of its fields; and
the pixel-gate control measurement, because one capture read 5.50e-05 against a
documented 15–23 px band and the honest response to that is a control, not a
paragraph. The decisive number is the 9-px difference between captures at the
two V4t commits — two different commits, fewer differing pixels than the
same-commit control.

`GlBindlessHandleTable`'s own doc comment claimed four renderers own instances.
Three of them no longer do. It now names its one remaining owner, `SkyRenderer`,
says why that one is different (its textures come from the raw GL-name path V4t
did not retype), and names V4f as the slice that deletes both the table and the
class.

`common.glsl`'s binding-9 comment carries the same stale list. It is left alone
deliberately: the file is a shader source with compiled `.spv` artifacts and a
V9 freshness gate, so a comment-only edit there is not free, and what the
comment says about what binding 9 IS remains correct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 12:45:55 +02:00
parent 565c351f93
commit b9ab5890af
2 changed files with 117 additions and 11 deletions

View file

@ -549,7 +549,7 @@ because sample positions are not specified across implementations.
| **V4a** | `TextRenderer` (three fence-buffered VBO sets → ring allocations), `BitmapFont`, `DebugLineRenderer`, the UI RenderSurface upload path, `UiViewport`'s texture handoff. | pixel gate (UI-heavy checkpoints) |
| **V4b** | `GlobalMeshBuffer` + `ObjectMeshManager` onto `IGpuBuffer`; arena, LRU and ledger logic untouched. | pixel gate |
| **V4c****PARKED — §5.5.5** | **The large one.** `WbDrawDispatcher` + `EnvCellRenderer`: per-frame uploads → rings, MDI brackets → pipelines + `MultiDrawIndexedIndirect`, loose uniforms → push constants, timer scopes. `RetailAlphaQueue` and all bucketing untouched. **Narrowed after the V4c scouting report — see §5.3.** | pixel gate at several checkpoints + connected lifecycle |
| **V4t** **re-sequenced — §5.5.5** | **World texture stack** (added 2026-07-27, see §5.3): `TextureCache`, `CompositeTextureArrayCache`, `ManagedGLTextureArray`, `TerrainAtlas` and `ObjectMeshManager`'s material path onto `IGpuTexture`/`IGpuSampler`; retype `GroupKey`, `CachedBatch` and `ObjectRenderBatch` from `ulong` bindless handle to `GpuTextureSlot`; retire the interim per-renderer handle tables for V4c, V4d and V4e at once. | pixel gate |
| **V4t** ✅ | **World texture stack** (added 2026-07-27, see §5.3; reported in §5.5.11). Two commits: **1** the GL device's world-handle seam plus `TerrainAtlas`/`TerrainModernRenderer` (`b8bcaa3e`); **2** `CompositeTextureArrayCache`, the particle arrays, `ObjectMeshManager`'s material path, and the retype of `GroupKey`, `CachedBatch` and `ObjectRenderBatch` from `ulong` bindless handle to `GpuTextureSlot`, retiring the interim tables in `WbDrawDispatcher`, `EnvCellRenderer`, `TerrainModernRenderer` and `ParticleRenderer` (`565c351f`). **Narrowed from the original scope in one way:** the caches still create and own their GL textures and residency — the device owns only the table entry — so `ManagedGLTextureArray` and the raw `Texture2D` upload path are untouched and `IGpuTexture` creation moves with the Vulkan world arm. `SkyRenderer` keeps its own table; see §5.5.11. | pixel gate per commit (3.02e-05; 5.50e-05 and 3.91e-05 on two captures against a 19 px same-commit control), App tests, 3/3 desktop-witness connected run per commit, one validation-layer Vulkan run per commit |
| **V4d****PARKED — §5.5.5** | `TerrainModernRenderer` only — **`TerrainAtlas` belongs to V4t** with the rest of the texture stack. Two sub-commits: first the `uView`/`uProjection``uViewProjection` shader convergence on its own pixel gate (it moves a matrix product from per-vertex GPU to a CPU multiply, so its rounding effect must be attributable alone), then the plumbing. Terrain has no GPU timer to port — its diagnostics use a CPU `Stopwatch`. | pixel gate per sub-commit |
| **V4e****re-sequenced — §5.5.5** | `ParticleRenderer` (after V4c — shared alpha-queue contract). | pixel gate (particle-heavy checkpoint) |
| **V4f****re-sequenced — §5.5.5** | `SkyRenderer` + weather. | pixel gate (dawn/dusk, day group pinned) |
@ -1278,7 +1278,7 @@ than deleting it outright.
with no world renderers. Its gate is an offline Vulkan launch reaching the real
composition with a captured UI frame, a converging ownership ledger at shutdown,
and the strict GL offline gate unmoved.
3. **V4t**, the texture stack, which the world arm cannot be written without.
3. ~~**V4t**, the texture stack~~ — done at `b8bcaa3e`/`565c351f` (§5.5.11).
4. **The world arm** — V4c/V4d's content behind the construction-time backend
selection at the frame-root seam.
@ -1392,6 +1392,108 @@ exercised the moment it exists; and §5.5.8's recorded one-binding-two-buffers
hazard is still unfired, because nothing on the Vulkan arm yet binds the same
storage binding to two buffers in one frame. The world arm will.
#### 5.5.11 V4t (2026-07-28): the world's data model is backend-neutral
§5.5.9's step 3 is done, in two commits — `b8bcaa3e` (terrain) and `565c351f`
(everything else). Every world batch now carries a `GpuTextureSlot` rather than
a raw 64-bit `ARB_bindless_texture` handle, and the four interim
`GlBindlessHandleTable` instances in `WbDrawDispatcher`, `EnvCellRenderer`,
`TerrainModernRenderer` and `ParticleRenderer` are gone. The Vulkan world arm's
prerequisite is met: its batch structs are already the shape it needs.
**§5.2's reason not to reach the device's table expired rather than being
worked around.** That paragraph's argument was the flush — `GlGpuDevice` drains
its dirty table runs inside `FlushBeforeDraw`, which only an encoder-recorded
draw reaches, so a raw-GL renderer would sample a stale table. It assumed V4c
would move those renderers onto the encoder. §5.5.6 closed the GL re-land, so
they stay raw GL through to V10 and "wait for the encoder" became an indefinite
block on the one slice the Vulkan world arm cannot be written without. The
resolution is two internal members — `FlushTextureTable` (the drain, factored
out of `FlushBeforeDraw`) and the already-existing `TextureTableGlName` — which
each raw-GL renderer calls immediately before its own draw, in the exact shape
its private table had. Both are deleted with the raw-GL world path.
**The slice was narrower than §5.3 sized it, in a way worth recording.**
§5.3 wrote "porting the whole texture stack", and the V4t row said "onto
`IGpuTexture`/`IGpuSampler`". What landed keeps texture CREATION and residency
with the caches — `ManagedGLTextureArray`, `CompositeTextureArrayCache`'s GL
backend, `TerrainAtlas` and `TextureCache`'s array upload all still speak raw
GL — and moves only the TABLE ENTRY to the device, keyed 1:1 by the caller's
already-resident handle. That is the whole of what the data model needed, and
it is what let one slice retype nine source and five test files instead of
rewriting three texture caches. Creating world textures through `IGpuTexture`
is real remaining work and it belongs with the Vulkan world arm, which is the
first thing that cannot use a GL handle at all.
**Slot release is stricter than the tables it replaces.** The interim tables
never released — entries accumulated for the renderer's lifetime, by design and
by comment. The device's table is capped at
`GpuBindingModel.TextureTableCapacity` (16,384), so an unreleased entry is now
a leak with an end, and every producer retires its own: the composite backend
and the particle backend at `MakeNonResident`, and `ObjectMeshManager` when a
retiring atlas's physical retirement completes. Teardown deliberately does not
release — the device dies with its callers, and deferring through a
possibly-disposed retirement queue would turn a clean shutdown into a throw.
**The default value became load-bearing.** `BindlessTextureLocation` could
signal "not resolved" with handle 0 because no texture has handle 0. A slot
index has no spare value — `default(GpuTextureSlot)` is real slot 0 — so the
type is now a struct storing its slot one-based, making `default` exactly
`Unresolved`, with a test pinning that a location naming slot 0 is
distinguishable from it. Everywhere else the sentinel was already exact:
`GpuTextureSlot.Unassigned` is `0xFFFFFFFF`, which is `common.glsl`'s
`ACDREAM_TEXTURE_NONE`, so the classify path's readiness test and the particle
billboard's untextured branch kept their meaning unchanged.
**`GroupKey` ordering is preserved because the key never ordered anything.**
Handle→slot is a bijection, so the same (entity, batch) pairs bucket together.
The key reaches equality, hashing and the scene-digest fingerprints, never a
comparator: opaque and translucent groups sort by cull mode then camera
distance, delayed alpha by viewer distance then submission ordinal, and group
enumeration follows the dictionary's insertion order, which a changed hash does
not disturb. Both sides of the render-shadow comparison hash the slot index the
same way, so the digest value moving is invisible to it.
**`SkyRenderer` keeps its `GlBindlessHandleTable`,** which is why that class
still exists. Its textures are minted by the sky renderer itself from
`TextureCache.GetOrUpload`'s raw GL texture names — the one world path this
slice did not retype — so it would be the sole consumer interning handles it
produced, a different shape from the rest of the stack. The offline gate also
masks the sky band, so the only automated instrument available here could not
see a regression in it. V4f owns that renderer and should retire the table and
the class together.
**Gate results.** GL offline pixel gate against `cb2a70b8`: 3.02e-05 at
`b8bcaa3e`, and 5.50e-05 / 3.91e-05 on two captures at `565c351f`. The first of
those is above the documented 1523 px band, so a control was measured rather
than assumed: two same-commit captures at `565c351f` differ by 19 px, and a
capture at `b8bcaa3e` versus one at `565c351f` differs by **9 px** — fewer than
the same-commit control, across two different commits. Maximum channel delta is
4152 in every pair including the controls, so the differing pixels come from
one flickering population rather than from moved geometry. Both commits passed
`tools/run-repeat-connected-gate.ps1 -Runs 3` at 3/3 RENDERED on the desktop
witness and the client capture, and one Vulkan composition-host run each with
`VK_LAYER_KHRONOS_validation` **proven inserted by the loader** — zero errors,
zero warnings, converged ownership ledger. App tests 4,077 / 3 skips and the
complete Release suite 9,140 / 5, both baselines plus the two tests added.
**One connected run died and is filed, not attributed.** The first 3-run
attempt at `b8bcaa3e` lost one run to an unhandled
`OpenGL returned unexpected fence wait status NoError (0x0)` in the render
loop. It did not reproduce in the following three runs at that tree nor in
three interleaved runs at `cb2a70b8`, and V4t creates, deletes and waits on no
fence. `#251` records it with the evidence; it is the same below-the-API shape
§5.5.1§5.5.3 documented four instances of on this driver, but that is a
hypothesis and the issue says so.
**Next is the world arm** — V4c/V4d's content behind the construction-time
backend selection at the frame-root seam. Two things this slice hands it:
§5.5.8's one-binding-two-buffers hazard is still unfired and the world arm is
what will fire it, since `WbDrawDispatcher` and `EnvCellRenderer` each own
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.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

View file

@ -18,16 +18,20 @@ namespace AcDream.App.Rendering;
/// accumulate for the renderer's lifetime, exactly like the world atlas and
/// composite-texture caches it draws handles from.
///
/// This class, plus the per-renderer SSBO it backs, is deleted at
/// V4c/V4d/V4e when each renderer moves onto <c>IGpuDevice</c>'s own
/// retirement-gated table — see the campaign doc's §5.2 for why V2 cannot
/// reach that table yet.
/// <b>Campaign V slice V4t retired every instance but one.</b> The world
/// texture stack now produces <c>GpuTextureSlot</c> directly, so
/// WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer and
/// ParticleRenderer share <c>GlGpuDevice</c>'s single retirement-gated table
/// instead of interning handles themselves — see that class's
/// <c>RegisterWorldTextureHandle</c> and the campaign doc's §5.5.11.
///
/// Each renderer that needs the indirection (WbDrawDispatcher,
/// EnvCellRenderer, TerrainModernRenderer, ParticleRenderer) owns its own
/// instance. Nothing requires index agreement between renderers: each rebinds
/// its own buffer to binding=9 immediately before its own draw call, so two
/// renderers may legitimately assign different slots to the same handle.
/// <b>The remaining owner is <c>SkyRenderer</c></b>, whose textures are minted
/// from <c>TextureCache.GetOrUpload</c>'s raw GL texture names — the one world
/// path V4t did not retype — so it is the sole consumer interning handles it
/// produced itself. Slice V4f retires that table, and this class with it.
/// Nothing requires index agreement between the sky's table and the device's:
/// each rebinds its own buffer to binding=9 immediately before its own draw
/// call, so the same handle may legitimately hold different slots in the two.
/// </summary>
internal sealed class GlBindlessHandleTable
{