feat(render): Campaign V slice V4d-1 - converge terrain's view and projection

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>
This commit is contained in:
Erik 2026-07-27 21:17:04 +02:00
parent c7f5f251f8
commit 0cb1059765
2 changed files with 14 additions and 5 deletions

View file

@ -13,8 +13,14 @@ layout(location = 3) in uvec4 aPacked1;
layout(location = 4) in uvec4 aPacked2;
layout(location = 5) in uvec4 aPacked3;
uniform mat4 uView;
uniform mat4 uProjection;
// Campaign V slice V4d-1 (2026-07-27): uView/uProjection converged into the
// single uViewProjection that GpuPushConstants carries, so terrain can move
// onto the pinned push-constant block at V4d-2. The product is now formed on
// the CPU (camera.View * camera.Projection) instead of per vertex here; the
// two are the same transform, and System.Numerics' row-vector layout uploaded
// untransposed reads in GLSL as the transpose, so (View*Proj)^T == Proj^T *
// View^T is exactly the uProjection * uView this replaced.
uniform mat4 uViewProjection;
struct Light {
vec4 posAndKind;
@ -165,7 +171,7 @@ void main() {
// the un-nudged heightmap via TerrainSurface.SampleZ.
// Closes issue #100; supersedes the hiddenTerrainCells cell-collapse hack.
vec3 terrainPos = vec3(aPos.xy, aPos.z - 0.01);
gl_Position = uProjection * uView * vec4(terrainPos, 1.0);
gl_Position = uViewProjection * vec4(terrainPos, 1.0);
// Phase U.3: terrain clip gate against the single OutsideView region. With
// uTerrainClipCount == 0 (U.3 default) the first loop is skipped and the

View file

@ -501,8 +501,11 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// unified camera matrix everywhere, so no separate viewpoint divergence can occur.
_shader.Use();
UploadTextureTilingOnce();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
// Campaign V slice V4d-1: one uViewProjection, matching the field
// GpuPushConstants already carries, instead of the separate uView and
// uProjection the shader used to combine per vertex. viewProjection is
// the same product the visibility pass above already computed.
_shader.SetMatrix4("uViewProjection", viewProjection);
var (terrainHandle, alphaHandle) = _atlas.GetBindlessHandles();
// Campaign V slice V2b: pass each handle's binding=9 table slot