acdream/src/AcDream.App/Rendering/Shaders/sky.vert
Erik 7faaaa347b feat(render): V6e — the sky's uniforms become a buffer and its texture a table slot
Campaign V slice V6e, last of three. Sky was the hardest of the four pairs
because it was the only one that still worked the way a 2004 shader works: a
dozen loose uniforms pushed one glUniform call at a time, and a texture bound to
unit 0 with a sampler object chosen per submesh. Vulkan GLSL has neither a
default uniform block nor a way to declare a bare sampler, so both had to move —
and the second one had a sting in it.

The uniforms go into a `SkyParams` std140 block at uniform binding 4, the new
pre-authorized constant in GpuBindingModel (1, 2 and 3 are SceneLighting, the
terrain clip block and terrain tiling; the contract test now proves the three
constants and that literal 2 do not collide). Three matrices are 192 bytes on
their own, so the 96-byte push-constant block was never in the running. The
block's member order IS its layout: std140 aligns a vec3 to 16 bytes while using
12, so each of the three lighting vectors is followed by the float that rides in
its pad word, which is why colours and per-surface scalars interleave rather
than grouping by meaning. SkyParamsLayoutTests asserts all twelve offsets and
the 256-byte size, because getting one member wrong would read the sun direction
as a colour with no compile error, no link error and no GL error to say so.

The texture is the interesting half. sky.frag now reads through the shared table
(ACDREAM_SAMPLE_2D), and a bindless handle BAKES its sampler — so the
per-submesh Repeat-versus-ClampToEdge choice, which used to be a glBindSampler
on unit 0, becomes which slot the submesh asks for. SkyRenderer interns one
handle per (texture, wrap) pair, exactly as ManagedGLTextureArray has done since
the world path went bindless, and exactly the shape Vulkan's table has, where an
entry is a combined image sampler. Same two SamplerCache objects, same wrap
behaviour, consulted once at interning instead of once per draw. A pleasant
consequence: the sky no longer touches texture unit 0, so the load-bearing
`BindSampler(0, 0)` restore at the end of the pass — there because the binding
was global state that would otherwise force ClampToEdge on the next renderer —
has nothing left to undo and is gone.

Gates. Release build clean; App tests 4,072 passed / 3 skipped (4,057 baseline,
plus the sentinel guard from the previous commit and fourteen sky-layout
assertions). Offline pixel gate against 95f8c25f: 18 px of 563,200 compared
(3.20e-05), inside the documented 15–23 px band.

That gate masks the sky for determinism, so it proves nothing about this commit
and the sky renderer has no automated pixel coverage at all. What was done
instead: a base-versus-head offline capture at ALL SEVEN day groups, built by
stashing the change and rebuilding so the two runs differ only in this commit.
Every pair matches in gradient, cloud sheet, horizon band and fog — including
day group 2's salmon cloud band and day group 6's green one, which between them
exercise texture sampling, per-vertex tint, blend mode and fog. Then 3/3
RENDERED on the desktop-witness repeat-connected gate.

That bounds the risk; it does not close it. The offline camera is fixed and
looks down, so a thin band of dome is all it ever sees: the sun and moon
(additive, high) and the rain cylinder (the one sky mesh that surrounds the
camera, and the one whose REPEAT wrap is most visible) remain unproven. Recorded
as user-gate debt in §5.1 alongside V2c's and V4e's particles — check it by
standing outside at dawn or dusk, and by standing in rain.

Manifest: 8/9 pairs compile. `terrain_modern` is the last production pair, and
it is blocked on V4d's content rather than on dialect — details in §5.5's slice
table. `mesh` has no consumer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:54:13 +02:00

176 lines
8.7 KiB
GLSL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#version 430 core
// Sky mesh vertex shader — retail-verbatim D3D fixed-function lighting
// ported to per-vertex GLSL. Evidence trail:
//
// docs/research/2026-04-23-sky-material-state.md
// §Q2 — retail FUN_0059da60 writes D3DMATERIAL9 per-mesh:
// Material.Emissive.rgb = (Surface.Luminosity, Lum, Lum, 1)
// Material.Ambient/Diffuse from texture-modulate defaults
// §Q4 — D3DRS_LIGHTING is ON for sky meshes
// §Q6 — fragment formula:
// lit = Emissive
// + material.Ambient × light.Ambient
// + material.Diffuse × light.Diffuse × max(dot(N, -sun), 0)
//
// Our `uAmbientColor` = retail's light.Ambient (AmbColor × AmbBright,
// pre-multiplied by SkyDescLoader). `uSunColor` = retail's light.Diffuse
// (DirColor × DirBright). `uSunDir` is a unit vector FROM surface TO
// sun (so `dot(N, uSunDir)` is the diffuse intensity directly; no
// extra negation needed — see SkyStateProvider.SunDirectionFromKeyframe).
// `uEmissive` is Surface.Luminosity for this submesh.
//
// Phase 2 (2026-04-23) tried the same formula and produced a visible
// east/west "blue-green-yellow sweep" — in hindsight that was CORRECT
// retail behaviour but paired with a wrong DayGroup pick ("Sunny" with
// sharp warm sun when retail rolled "Cloudy" with diffuse overcast).
// After Phase 3g fixed the LCG multiplier so acdream + retail agree on
// the DayGroup, the same formula should now match retail visually.
//
// NOTE: no clamp at the vertex — retail's D3D fixed-function lighting
// can produce lit values > 1.0 and the final clamp happens at the
// framebuffer write. Doing that same "let it overbright" here keeps
// the dome's emissive=1 saturation path intact.
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTex;
// Campaign V slice V6e: everything below used to be a dozen loose uniforms set
// with glUniform* between draws. Vulkan GLSL has no default uniform block, so
// those declarations are not merely unsupported — they are unspellable — and
// three matrices are already twice the whole 96-byte push-constant block. A
// uniform buffer is the only legal home, and the same declaration is legal in
// both dialects (GpuBindingModel.UniformSkyParams).
//
// The member ORDER is the std140 layout and must stay in step with
// SkyRenderer's SkyParams struct: each vec3 is followed by the float that rides
// in its 4-byte pad word, which is why the lighting colours and the per-surface
// scalars interleave. Offsets are asserted by SkyParamsLayoutTests.
//
// uModel 0 uSkyView 64 uSkyProjection 128
// uAmbientColor 192 uEmissive 204
// uSunColor 208 uDiffuseFactor 220
// uSunDir 224 uTransparency 236
// uUvScroll 240 uApplyFog 248 uSurfOpacity 252 (size 256)
layout(std140, ACDREAM_UBO_SET binding = 4) uniform SkyParams {
mat4 uModel;
mat4 uSkyView;
mat4 uSkyProjection;
// Per-frame lighting (from SkyKeyframe):
vec3 uAmbientColor; // AmbColor × AmbBright (retail light.Ambient)
float uEmissive; // per-submesh Surface.Luminosity
vec3 uSunColor; // DirColor × DirBright (retail light.Diffuse)
float uDiffuseFactor;
vec3 uSunDir; // unit vector FROM surface TO sun
float uTransparency; // keyframe transparency: 0 visible, 1 transparent
vec2 uUvScroll;
float uApplyFog; // 1 for foggable layers; raw-additive keeps fog off
float uSurfOpacity; // final surface opacity multiplier from the CPU
};
// Shared SceneLighting UBO — we need uFogParams.xy (fog start/end) to
// compute the vertex fog factor. Must match sky.frag's declaration.
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; // x=fogStart, y=fogEnd, z=flash, w=fogMode
vec4 uFogColor;
vec4 uCameraAndTime;
};
// === Phase W Stage 4: sky/weather portal clip (the OutsideView region) ========
// The sky + weather (rain cylinder) meshes are "the outside seen through a
// doorway" — retail draws them as part of LScape, clipped to the exit-portal
// region (PView::DrawCells @ 0x005a4840). acdream gates them with the SAME
// binding=2 TerrainClip UBO the terrain shader reads (ClipFrame.SetTerrainClip →
// the OutsideView convex planes). The planes are SCREEN-SPACE (NDC) half-spaces
// encoded as clip-space planes (nx, ny, 0, dw) with the test
// dot(plane, gl_Position) >= 0. After the perspective divide that reduces to
// nx*ndcX + ny*ndcY + dw >= 0 — INDEPENDENT of the projection matrix. So the same
// plane set clips the sky correctly even though the sky uses its OWN dome
// projection (uSkyProjection / uSkyView, translation-zeroed) rather than the
// camera view-proj. uTerrainClipCount == 0 (outdoor / no exit portal visible)
// ungates the sky entirely (the second loop sets all 8 distances to +1.0 ⇒
// full-screen sky, bit-identical to pre-Stage-4). Host enables GL_CLIP_DISTANCE0..7
// only around the sky/weather draws.
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
// (mirrors terrain_modern.vert). Sized 8 to match GL_MAX_CLIP_DISTANCES >= 8.
out gl_PerVertex {
vec4 gl_Position;
float gl_ClipDistance[8];
};
out vec2 vTex;
out vec3 vTint;
out float vFogFactor; // 1 = no fog (close), 0 = full fog (far)
void main() {
vTex = aTex + uUvScroll;
gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0);
// uModel for sky is pure rotation (Z then Y) — orthonormal, so
// mat3(uModel) transforms normals correctly without inverse-transpose.
vec3 worldNormal = normalize(mat3(uModel) * aNormal);
// Retail per-vertex fixed-function lighting (AMBIENT=0 globally,
// so the global ambient term drops; only light.Ambient contributes).
// Clamp to [0,1] at the vertex — retail's D3DRS_COLORCLAMP defaults
// to clamping lit vertex colours to 1.0 BEFORE texture modulate.
// Without this, a dome vertex (uEmissive=1) picks up ambient+diff
// on top of already-saturated emissive, producing > 1.5 lit values
// that our framebuffer cap (1.2) lets through as 20% overbright
// vs retail's 1.0-clamped reference. User-observed 2026-04-23.
float diff = max(dot(worldNormal, uSunDir), 0.0);
vec3 lit = vec3(uEmissive) // material.Emissive
+ uAmbientColor // material.Ambient(1) × light.Ambient
+ (uSunColor * uDiffuseFactor) * diff;
vTint = clamp(lit, 0.0, 1.0);
// Retail vertex-fog in 3D-range mode (FOGVERTEXMODE=LINEAR,
// RANGEFOGENABLE=1, FOGTABLEMODE=NONE per device init — never
// toggled per frame). Distance = `|worldPos - cameraPos|`. Since
// our sky view matrix has translation zeroed (sky is camera-
// centered), the post-uModel position IS the camera-relative
// world-space vector, so its length is the 3D range distance.
// See docs/research/2026-04-23-sky-fog.md.
//
// Formula: fogFactor = clamp((fogEnd - dist) / (fogEnd - fogStart), 0, 1)
// 1.0 → no fog contribution (scene color wins)
// 0.0 → full fog color (sky color fades to fog)
//
// Sky meshes have intrinsic radii in the thousands of meters (dome
// / stars / moon are authored at large distances in the dat); at
// typical keyframe FOGEND=2400m, the dome saturates to fogColor at
// its horizon band. THAT is how retail colors the horizon at dusk.
vec4 worldPos = uModel * vec4(aPos, 1.0);
float dist = length(worldPos.xyz);
float fogStart = uFogParams.x;
float fogEnd = uFogParams.y;
float span = max(fogEnd - fogStart, 1e-3);
vFogFactor = clamp((fogEnd - dist) / span, 0.0, 1.0);
// Phase W Stage 4: clip the sky/weather to the OutsideView (doorway) region.
// With uTerrainClipCount == 0 (outdoor / no exit portal in view) the first loop
// is skipped and the second sets all 8 distances to +1.0 ⇒ no clipping ⇒
// full-screen sky. Indoors with an exit portal visible, the OutsideView planes
// confine the sky to the doorway opening — exactly, per-fragment, matching the
// terrain (no scissor approximation). plane.z is 0 (a screen-space slab), so the
// sky's depth / dome radius is irrelevant. gl_Position here is the sky's own
// dome-projected clip position; the NDC-plane test is projection-independent.
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;
}