acdream/src/AcDream.App/Rendering/Shaders/terrain_modern.vert
Erik f7344758f8 fix(render): Campaign V slice V6i-2 commit 1 — the terrain clip block reaches set 1
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>
2026-07-28 13:31:13 +02:00

191 lines
8.6 KiB
GLSL

#version 460 core
#extension GL_ARB_bindless_texture : require
// Phase N.5b: terrain shader on the modern bindless dispatcher.
// Math identical to terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes
// lighting). The only structural change is the version + bindless extension
// — sampler access in the fragment stage is unchanged at the GLSL level.
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in uvec4 aPacked0;
layout(location = 3) in uvec4 aPacked1;
layout(location = 4) in uvec4 aPacked2;
layout(location = 5) in uvec4 aPacked3;
// 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;
vec4 dirAndRange;
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;
vec4 uFogColor;
vec4 uCameraAndTime;
};
// === Phase U.3: terrain screen-space clip gate (OutsideView region) ===========
// Terrain is a single global region (the OutsideView), so it needs one set of
// clip planes, not a per-instance slot table like the mesh shader. A std140 UBO
// at binding=2 carries it. The UBO binding namespace is distinct from the SSBO
// binding namespace, so this does NOT collide with the mesh shader's SSBO
// binding=2 — and within THIS shader binding=1 (SceneLighting) is the only other
// UBO, leaving binding=2 free. uTerrainClipCount == 0 (the U.3 default) ungates
// terrain entirely (the second loop sets all 8 distances to +1.0). Uploaded by
// ClipFrame.UploadShared each frame; TerrainModernRenderer binds it before draw.
//
// Campaign V slice V6i-2: ACDREAM_UBO_SET is what puts this in set 1 under the
// Vulkan dialect and expands to nothing under GL. Omitting it left the block at
// set 0 binding 2, which the storage layout declares as a STORAGE buffer — see
// plan §5.5.12 finding 2, measured on the committed SPIR-V rather than inferred.
// sky.vert declares the SAME block correctly and is the precedent.
layout(std140, ACDREAM_UBO_SET binding = 2) uniform TerrainClip {
int uTerrainClipCount;
vec4 uTerrainClipPlanes[8];
};
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal.
// Sized 8 to match GL_MAX_CLIP_DISTANCES >= 8. Host enables GL_CLIP_DISTANCE0..7
// once at startup; unused planes are set to +1.0 below so they pass everything.
out gl_PerVertex {
vec4 gl_Position;
float gl_ClipDistance[8];
};
out vec2 vBaseUV;
out vec3 vWorldNormal;
out vec3 vWorldPos;
out vec3 vLightingRGB;
out vec4 vOverlay0;
out vec4 vOverlay1;
out vec4 vOverlay2;
out vec4 vRoad0;
out vec4 vRoad1;
flat out float vBaseTexIdx;
// Retail's N·L floor from FUN_00532440 lines 2119/2138/2157/2176 at
// chunk_00530000.c (AdjustPlanes). The decompile reads:
// if (fVar3 < DAT_00796344) fVar3 = DAT_00796344;
// applied to the clamped Lambert result BEFORE it's multiplied into
// dirColor. DAT_00796344's exact literal isn't pinned by the decompile
// but every other "floor" use in retail clamps negatives to zero (the
// physically-correct Lambert half-space). Our previous 0.08 was a
// defensive guess from early acdream days that made back-lit terrain
// visibly brighter than retail (user-observed 2026-04-24 "acdream
// warmer / less blue than retail"). Reverting to 0.0 matches retail
// per the decompile and lets ambient fill in the back side.
// Cross-ref: docs/research/2026-04-24-lambert-brightness-split.md.
const float MIN_FACTOR = 0.0;
vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV) {
float texIdx = float(texIdxU);
float alphaIdx = float(alphaIdxU);
if (texIdx >= 254.0) texIdx = -1.0;
if (alphaIdx >= 254.0) alphaIdx = -1.0;
vec2 rotatedUV = baseUV;
if (rotIdx == 1u) rotatedUV = vec2(1.0 - baseUV.y, baseUV.x);
else if (rotIdx == 2u) rotatedUV = vec2(1.0 - baseUV.x, 1.0 - baseUV.y);
else if (rotIdx == 3u) rotatedUV = vec2( baseUV.y, 1.0 - baseUV.x);
return vec4(rotatedUV.x, rotatedUV.y, texIdx, alphaIdx);
}
void main() {
// Unpack rotation fields from aPacked3. Bit layout (data3):
// .x (byte 0): bits 0-1 rotBase (unused), 2-3 rotOvl0, 4-5 rotOvl1, 6-7 rotOvl2
// .y (byte 1): bits 0-1 rotRd0 (= data3 bit 8-9),
// bits 2-3 rotRd1 (= data3 bit 10-11),
// bit 4 splitDir (= data3 bit 12)
uint rotOvl0 = (aPacked3.x >> 2u) & 3u;
uint rotOvl1 = (aPacked3.x >> 4u) & 3u;
uint rotOvl2 = (aPacked3.x >> 6u) & 3u;
uint rotRd0 = aPacked3.y & 3u;
uint rotRd1 = (aPacked3.y >> 2u) & 3u;
uint splitDir= (aPacked3.y >> 4u) & 1u;
// Derive which of the 4 cell corners this vertex represents from
// gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a
// specific order for each split direction; the tables below must stay
// in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches.
// 2026-04-21 fix: geometry re-derived to match ACE's ConstructPolygons
// convention. SWtoNE (cut BL→TR, y=x diagonal) now maps to the {BL,BR,TR}
// + {BL,TR,TL} triangle pair; SEtoNW (cut BR→TL, x+y=1 diagonal) maps to
// {BL,BR,TL} + {BR,TR,TL}.
int vIdx = gl_VertexID % 6;
int corner = 0;
if (splitDir == 0u) {
// SWtoNE order: BL, BR, TR, BL, TR, TL → corners 0, 1, 2, 0, 2, 3
if (vIdx == 0) corner = 0;
else if (vIdx == 1) corner = 1;
else if (vIdx == 2) corner = 2;
else if (vIdx == 3) corner = 0;
else if (vIdx == 4) corner = 2;
else corner = 3;
} else {
// SEtoNW order: BL, BR, TL, BR, TR, TL → corners 0, 1, 3, 1, 2, 3
if (vIdx == 0) corner = 0;
else if (vIdx == 1) corner = 1;
else if (vIdx == 2) corner = 3;
else if (vIdx == 3) corner = 1;
else if (vIdx == 4) corner = 2;
else corner = 3;
}
vec2 baseUV;
if (corner == 0) baseUV = vec2(0.0, 1.0);
else if (corner == 1) baseUV = vec2(1.0, 1.0);
else if (corner == 2) baseUV = vec2(1.0, 0.0);
else baseUV = vec2(0.0, 0.0);
vBaseUV = baseUV;
vWorldPos = aPos;
vWorldNormal = normalize(aNormal);
// Retail AdjustPlanes bake (terrain.vert:124-134 — identical math).
vec3 sunDir = uLights[0].dirAndRange.xyz;
vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
float L = max(dot(vWorldNormal, -sunDir), MIN_FACTOR);
vLightingRGB = sunCol * L + uCellAmbient.xyz;
float baseTex = float(aPacked0.x);
if (baseTex >= 254.0) baseTex = -1.0;
vBaseTexIdx = baseTex;
vOverlay0 = unpackOverlayLayer(aPacked0.z, aPacked0.w, rotOvl0, baseUV);
vOverlay1 = unpackOverlayLayer(aPacked1.x, aPacked1.y, rotOvl1, baseUV);
vOverlay2 = unpackOverlayLayer(aPacked1.z, aPacked1.w, rotOvl2, baseUV);
vRoad0 = unpackOverlayLayer(aPacked2.x, aPacked2.y, rotRd0, baseUV);
vRoad1 = unpackOverlayLayer(aPacked2.z, aPacked2.w, rotRd1, baseUV);
// Retail zFightTerrainAdjust (acclient_2013_pseudo_c.txt:1120769 = 0.00999999978,
// applied per terrain vertex inside ACRender::landPolysDraw at line 702254,
// address 006b6402). Render terrain 1 cm below its physical Z so coplanar
// building floors win the depth test. Physics path is unaffected — it reads
// 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 = 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
// second sets all 8 distances to +1.0 ⇒ no clipping ⇒ identical terrain.
for (int i = 0; i < uTerrainClipCount; ++i)
gl_ClipDistance[i] = dot(uTerrainClipPlanes[i], gl_Position);
for (int i = uTerrainClipCount; i < 8; ++i)
gl_ClipDistance[i] = 1.0;
}