Plan §5.5.12 finding 2, measured on the committed SPIR-V rather than inferred:
terrain_modern.vert declared
layout(std140, binding = 2) uniform TerrainClip { ... }
with no ACDREAM_UBO_SET, so under the Vulkan dialect the block landed in set 0
binding 2 — which set 0's layout declares as a STORAGE buffer. Any terrain
pipeline built against the shared pipeline layout was therefore malformed.
Nothing had caught it: GL expands the macro to nothing and keeps its UBO and
SSBO namespaces separate, the shader compiled cleanly for both backends, and no
terrain pipeline has ever been created on Vulkan. sky.vert declares the SAME
block correctly and is the precedent, so this is a one-word omission, not a
numbering question.
spirv-dis on spv/terrain_modern.vert.spv, before and after:
before %372 = OpVariable %_ptr_Uniform__struct_370 Uniform
OpDecorate %372 DescriptorSet 0 / Binding 2
after OpDecorate %372 DescriptorSet 1 / Binding 2
with %_struct_370 = OpTypeStruct %int %_arr_v4float_uint_8 — TerrainClip's
{ int uTerrainClipCount; vec4 uTerrainClipPlanes[8]; } — in both.
The same commit closes §5.5.8's second recorded gap. Set 1's layout declared
only bindings 1 and 3, so it was missing BOTH the terrain clip block and
UniformSkyParams at binding 4, which sky.vert and sky.frag have compiled to
SPIR-V since V6e. Both are now declared, all four dynamic, which is half
Vulkan's guaranteed maxDescriptorSetUniformBuffersDynamic of 8 and is asserted
by the capability gate as before.
Membership and ORDER now come from one predicate — IsDeclaredUniformBinding —
that the layout, the descriptor writes and vkCmdBindDescriptorSets's
dynamic-offset array are all built from, the same shape V6g gave set 0. The
three had been restated separately, which is exactly how a fifth binding would
have gone wrong the same way.
Both gaps were found by hand, months apart, and neither could fail on the
shipping backend. VulkanShaderDescriptorContractTests reads the committed .spv
and asserts the partition instead: every uniform block at a declared set-1
binding, every storage block inside set 0's declared range, every sampled
resource in the one texture table. Checked out against the pre-fix .spv, two of
its four tests fail.
Gates: Release build; App tests 4,090 / 3 skips (4,086 baseline plus four);
strict GL offline pixel gate vs 0ca802cd 3.02e-05 (17 px of 563,200, inside the
documented 9–31 px control band, 33x under threshold) — expected, since GL
executes not one changed statement; one Vulkan composition-host run with
VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors, zero
warnings and no [shutdown] diagnostic on either stream.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
terrain_modern.vert combined two loose mat4 uniforms per vertex:
gl_Position = uProjection * uView * vec4(terrainPos, 1.0);
Vulkan GLSL cannot express that. There is no default uniform block, so a loose
`uniform mat4` is unspellable however it is written, and the two matrices are
128 bytes against a pinned 96-byte push-constant block (and against Vulkan's
guaranteed 128-byte ceiling). GpuPushConstants already carries exactly one
uViewProjection, which is the shape every other ported shader reads. So the
product moves to the CPU and the shader reads the single matrix.
The transform is identical. System.Numerics uses row-vector convention and
Shader.SetMatrix4 uploads untransposed, so GLSL reads each uploaded matrix as
its transpose. The old expression evaluated Proj^T * View^T; the new one
evaluates (View*Proj)^T, and those are the same matrix. The renderer already had
that product in hand - `viewProjection` at line 422, computed for the visibility
pass - so nothing new is multiplied. It is multiplied once per frame on the CPU
instead of once per vertex on the GPU.
That last sentence is the whole reason this is its own commit. Moving a float
product from GPU to CPU is a real numeric change: different hardware, possibly
different fused-multiply-add behaviour, certainly a different rounding order.
The plan's V4d row requires its pixel effect be attributable alone rather than
folded into a plumbing change, and terrain fills most of the offline gate's
scene, so this is the strongest measurement that gate can make.
Gates. Release build clean. App tests 4,072 passed / 3 skipped, matching the
baseline exactly. Offline pixel gate against 7faaaa34: 22 differing pixels of
563,200 compared (fraction 3.91e-05, maximumChannelDelta 52). A same-commit
control captured immediately afterwards: 15 pixels (2.66e-05, maximumChannelDelta
46). Both sit inside the documented 15-23 pixel noise band and ~26x under the
0.001 threshold, and the change and its own control are drawn from the same
distribution - which is what "no systematic shift" looks like at this
instrument's resolution. The gate run's client log has zero exceptions and an
empty stderr.
The shader manifest is regenerated in the same commit, as its freshness test
requires. terrain_modern.vert now compiles to SPIR-V for the first time; the
pair stays vulkanReady:false and emits no .spv because terrain_modern.frag still
declares `uniform float uTexTiling[36]`, which is the other half of this
shader's port and lands next as the UniformTerrainTiling buffer that
GpuBindingModel already reserves binding 3 for. Per-pair the count is unchanged
at 7/9; per-stage it is 15/18.
No divergence-register row: the transform is identical and no retail-facing
behaviour changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
terrain_modern.vert took uView and uProjection as two mat4 uniforms and formed
`uProjection * uView` per vertex. GpuPushConstants carries one ViewProjection,
so terrain could not reach the pinned push-constant block until these became
one uniform. This does that change and nothing else.
The transform is unchanged. System.Numerics is row-vector and stores row-major;
uploaded untransposed, GLSL reads those bytes as column-major, which is the
transpose. So the CPU's camera.View * camera.Projection arrives in the shader as
(View*Proj)^T = Proj^T * View^T - exactly the uProjection * uView it replaces.
The product it now uses is the same one Draw already computed for the per-cell
visibility pass, so no new work is done either.
What genuinely changes is where the multiply happens: per-vertex on the GPU
before, once on the CPU now. Two float32 matrix products with different
association and rounding are not bit-identical, and terrain fills most of the
gate's frame, so this was split into its own commit to make that effect
attributable rather than buried in the V4d-2 plumbing diff.
It is not measurable. The gate run against c7f5f251 reported 32 differing
pixels of 563,200 (fraction 5.68e-05, 17.6x under the 0.001 threshold). Because
32 sits just above the plan's recorded 8-29 noise band, the difference was
characterised rather than accepted: three captures were taken at this commit and
compared every way.
cross-commit (c7f5f251 vs here): 32, 26, 26
same-commit (here vs here): 28, 27, 8
The distributions are the same distribution. Two cross-commit pairs (26, 26)
differ by LESS than two same-commit pairs (27, 28), and a systematic shift
cannot produce that - it would floor every cross-commit comparison above every
same-commit one. maximumChannelDelta is 48-49 in all six comparisons, including
the pure same-commit controls, so the few large-delta pixels are a property of
the capture, not of this change. The 32 was the high draw of a noise
distribution whose floor today spans roughly 8 to 32; a same-commit control
measured 17 at cb0182a0 earlier in the session, so the band drifted on its own,
with an unchanged binary, by more than this change moved anything.
Gates. Release build green with TreatWarningsAsErrors. App tests 3,846 passed /
3 skipped over two consecutive clean runs. A third run failed only
UiDatFontTests.InstanceMeasureWidth_ReusesGlyphTableWithoutAllocating, which is
the known issue #250 flake on an unchanged tree. Two files changed, CRLF and
UTF-8 preserved.
No divergence-register row: the transform is identical and no retail-facing
behaviour changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Continues the V2a mesh-path conversion onto TerrainModernRenderer: its two
per-pass bindless texture handles (the terrain atlas and the alpha-mask
atlas) now travel as table indices instead of raw 64-bit
ARB_bindless_texture handles, with zero pixel change.
Terrain differs structurally from the mesh path: it has no per-batch SSBO at
all, just two handles set once per draw as plain uniforms
(terrain_modern.frag's uTerrainHandle/uAlphaHandle, reconstructed via the
sampler2DArray(handle) macros uTerrain/uAlpha). So instead of a BatchData
struct field, the two uniforms became uTextureIndexA/uTextureIndexB - named
to match the pinned GpuPushConstants.TextureIndexA/B fields (campaign doc
section 3.4) so V4d's eventual move to push constants is a rename, not a
redesign. There is no push-constant plumbing yet, so these stay plain
uniforms for now, set via ProgramUniform1 instead of ProgramUniform2.
TerrainModernRenderer owns its own GlBindlessHandleTable and binding=9 SSBO
(the same GL-only handle-table emulation V2a introduced), independent of
WbDrawDispatcher's and EnvCellRenderer's - nothing requires index agreement
between renderers, and terrain only ever registers two handles per draw
(the atlas's terrain/alpha textures), so its table is dirty only once, on
first draw. Unlike WbDrawDispatcher/EnvCellRenderer, TerrainModernRenderer
already eagerly creates its other GL resources in the constructor with a
ResourceCleanupGroup rollback, so the texture-table SSBO is created there
too rather than lazily.
TerrainAtlas needed no change: GetBindlessHandles() keeps returning the raw
(ulong terrain, ulong alpha) pair unchanged - the table lookup is entirely a
TerrainModernRenderer-side concern, added at the one draw-call site that
already converts those handles into shader state.
Shader-side: terrain_modern.frag's uTerrain/uAlpha macros now expand through
common.glsl's ACDREAM_TEXTURE_HANDLE(idx) lookup; both terrain_modern.vert
and .frag opted into the common.glsl preamble (Shader's
includeCommonPreamble, introduced at V2a) so their SceneLighting UBO
declarations could also pick up the ACDREAM_UBO_SET scaffolding macro -
terrain_modern.vert doesn't touch the texture table itself, but sharing the
same preamble across both stages of a technique is simpler to reason about
than deciding per-stage.
Gate: dotnet build -c Release green, dotnet test tests/AcDream.App.Tests
-c Release green (3843 passed / 3 skipped, matching V2a), and
tools/run-offline-pixel-gate.ps1 passed against the V2a commit's build with
a 2.49e-05 differing-pixel fraction - within the documented ~33x same-commit
noise margin. No divergence-register row: this introduces no retail
behavior deviation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the GPU mechanism to clip drawing to a per-cell screen-space convex
region via gl_ClipDistance, consumed by the mesh + terrain vertex shaders.
This is the MECHANISM only — every instance defaults to slot 0 (no-clip /
pass-all) and terrain to count 0, so the running game renders IDENTICALLY to
pre-U.3 (verified: offline launch compiles both shaders and reaches steady
state; no GL errors). U.4 populates real clip data from portal visibility.
Binding contract (define once, both sides obey):
- mesh_modern.vert: SSBO binding=2 CellClip[] (shared per-frame regions, slot 0
reserved no-clip) + SSBO binding=3 uint[] per-instance slot, indexed by the
IDENTICAL gl_BaseInstanceARB+gl_InstanceID used for binding=0. binding=0/1
untouched.
- terrain_modern.vert: UBO binding=2 TerrainClip { int count; vec4 planes[8]; }
for the single OutsideView region (UBO namespace; SceneLighting is UBO
binding=1, so binding=2 is free and does not collide with the mesh SSBO
binding=2). count 0 = ungated.
- Both redeclare out gl_PerVertex { vec4 gl_Position; float gl_ClipDistance[8]; }
and set unused planes (i >= count) to +1.0 so they pass everything.
CellClip std430 layout (144 bytes/slot): count@0, 3 pad uints@4/8/12,
planes[8]@16 (vec4 stride 16). Terrain UBO std140: count@0 (padded to 16),
planes[8]@16 → 144 bytes. Verified by ClipFrameLayoutTests (8 new tests).
Pieces:
- ClipFrame: per-frame container + uploader for the SHARED clip data (binding=2
SSBO + terrain UBO). NoClip() = slot 0 + terrain count 0. AppendSlot /
SetTerrainClip pack std430/std140 bytes for U.4. UploadShared binds both.
- WbDrawDispatcher + EnvCellRenderer: each owns its binding=3 zero buffer
(all-zeros sized to its instance count → slot 0), re-binds binding=2 from the
shared ClipFrame id (or an internal no-clip fallback if unwired) before MDI.
gl_ClipDistance is per-vertex, so the single glMultiDrawElementsIndirect per
group is preserved — no draw splitting.
- TerrainModernRenderer: binds the terrain clip UBO (shared or no-clip fallback)
before its draw.
- GameWindow: glEnable(GL_CLIP_DISTANCE0..7) once at init (unused planes pass-all
so always-on avoids per-draw thrash); per frame builds ClipFrame.NoClip(),
UploadShared, and hands the buffer ids to the three renderers (tiny diff; U.4
swaps NoClip() for the real portal-visibility frame).
Gate: dotnet build green; App suite 134/134; offline launch confirms both
shaders compile + link with no GL errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Subtract 0.01 from every terrain vertex Z in the modern terrain vertex
shader, matching retail's per-draw nudge applied inside
ACRender::landPolysDraw(arg2=2). Coplanar building floors now always win
the depth test against the rendered terrain, so the visual "ground at
the building floor" reads as the building's floor, not as Z-fighting.
Constant 0.01f bit-equals retail's float literal 0.00999999978 when
rounded to single precision.
Render-only — physics reads the un-nudged heightmap via
TerrainSurface.SampleZ / SampleZFromHeightmap. The same render-vs-
physics split is already established for EnvCell render lift
(+0.02m at GameWindow.cs around the cell-mesh draw).
Retail anchors:
docs/research/named-retail/acclient_2013_pseudo_c.txt:1120769
docs/research/named-retail/acclient_2013_pseudo_c.txt:702254
Cross-ref:
docs/research/2026-05-25-issue-100-terrain-cutout-handoff.md
docs/superpowers/plans/2026-05-25-issue-100-terrain-cutout.md
Followed by Task 2 (delete the hiddenTerrainCells / BuildingTerrainCells
plumbing). Visible result of this commit alone: building floors stop
Z-fighting, but the 24m x 24m transparent rectangles persist until the
plumbing is removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deletes:
- TerrainChunkRenderer.cs (454 lines, replaced by TerrainModernRenderer)
- TerrainRenderer.cs (247 lines, older sibling, no production users)
- terrain.vert / terrain.frag (replaced by terrain_modern.{vert,frag})
Removes the temporary Task 8 perf-benchmark toggle (ACDREAM_LEGACY_TERRAIN
env var, _useLegacyTerrain field, parallel _terrainLegacy renderer
instance, [TERRAIN-DIAG/modern|legacy] label suffix). The modern path
is now the only path. Mirror N.5's mandatory-modern amendment: missing
GL_ARB_bindless_texture throws NotSupportedException at startup
(already in place via the BindlessSupport.TryCreate gate).
Three load-bearing research comments preserved verbatim from terrain.vert
into terrain_modern.vert before deletion: the MIN_FACTOR = 0.0 N-dot-L
floor block (cross-ref Lambert brightness split), the aPacked3 bit
layout, the gl_VertexID corner-table 2026-04-21 ConstructPolygons fix.
Also retires the now-orphaned _shader field (legacy terrain pipeline
was its only user).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vertex shader for the modern terrain dispatcher. Bit-identical math
to today's terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes
lighting). The only structural change is the version + bindless
extension preamble — sampler access stays a regular sampler2DArray
uniform; bindless-ness is invisible at the GLSL level.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>