#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; uniform mat4 uModel; uniform mat4 uSkyView; uniform mat4 uSkyProjection; uniform vec2 uUvScroll; // Per-frame lighting (from SkyKeyframe): uniform vec3 uAmbientColor; // AmbColor × AmbBright (retail light.Ambient) uniform vec3 uSunColor; // DirColor × DirBright (retail light.Diffuse) uniform vec3 uSunDir; // unit vector FROM surface TO sun // Per-submesh (from Surface.Luminosity float): uniform float uEmissive; uniform float uDiffuseFactor; // 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, 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, 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; }