From 5e13b45fae163c461ea7267cc7744f3fca0d69c1 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 10:05:16 +0200 Subject: [PATCH] feat(render): Campaign V slice V6f-1 - converge terrain's view and projection 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 --- .../Rendering/Shaders/spv/shaders.manifest.json | 5 ++--- .../Rendering/Shaders/terrain_modern.vert | 14 +++++++++++--- src/AcDream.App/Rendering/TerrainModernRenderer.cs | 11 +++++++---- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 277e5146..0bd707cb 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -105,9 +105,8 @@ "stages": [ { "stage": "vert", - "sourceSha256": "4de580ce11b8d755d3558dc49bf7ebccec54d307595d91c38b5c5d552d645c7e", - "compiled": false, - "message": "terrain_modern.vert:221: error: \u0027uProjection\u0027 : undeclared identifier" + "sourceSha256": "336880b293e95c9ba13dce7a616a15e941191afec8bdaabe04a4993182eb6811", + "compiled": true }, { "stage": "frag", diff --git a/src/AcDream.App/Rendering/Shaders/terrain_modern.vert b/src/AcDream.App/Rendering/Shaders/terrain_modern.vert index db8224ef..254b948e 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/terrain_modern.vert @@ -13,8 +13,16 @@ 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 V6f-1: uView/uProjection converged into the single +// uViewProjection that GpuPushConstants already carries, so terrain can be +// expressed in Vulkan GLSL at all — two loose mat4 uniforms are 128 bytes and +// cannot both fit the pinned 96-byte push block, and Vulkan GLSL has no default +// uniform block to hold them loose. 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 +173,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 diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs index 643a2d1a..1308248f 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -494,15 +494,18 @@ public sealed unsafe class TerrainModernRenderer : IDisposable // Bind shader + uniforms + atlas handles. // Verified Phase W Stage 4 (T4.2): terrain projects from the camera view-proj; - // no separate landscape viewpoint to sync. Both uView and uProjection derive - // from the ICamera passed into this method — the same camera used for all other + // no separate landscape viewpoint to sync. uViewProjection derives from + // the ICamera passed into this method — the same camera used for all other // renderers in the unified pipeline. Retail's LScape::update_viewpoint // pre-positions terrain to the outdoor landcell, but acdream uses the // 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 V6f-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