acdream/src/AcDream.App/Rendering/Shaders/sky.vert
Erik ce2edad66a feat(render): Phase W Stage 4 — sky/weather portal-clip seal (LScape through the doorway)
The sky + weather (rain cylinder) are retail's LScape — 'the outside seen through the exit portal.' Retail PView::DrawCells (pseudo_c:432709) draws LScape clipped to the OutsideView when outside_view.view_count>0, then does a conditional Z-buffer-ONLY clear (432731) before the indoor cells. acdream now does the same:

- sky.vert writes gl_ClipDistance against the SAME binding=2 TerrainClip UBO the terrain reads. The OutsideView planes are screen-space (NDC) half-spaces encoded as clip-space planes (nx,ny,0,dw); the test dot(plane,gl_Position)>=0 reduces after perspective divide to nx*ndcX+ny*ndcY+dw>=0 — projection-INDEPENDENT — so the same plane set clips the sky EXACTLY despite its separate dome projection. count==0 (outdoor) → all distances +1 → full-screen, bit-identical. Lighting/fog math untouched.

- GameWindow: relocated the sky pre-scene + weather post-scene draws to their retail LScape positions, each in a local 8-plane clip bracket so sky.vert confines them to the doorway indoors / full-screen outdoors. Added the conditional doorway depth-ONLY Z-clear (no color → no blue hole), scissored to the OutsideView AABB. drawSkyThisFrame = seen_outside policy AND (outdoor OR exit-portal-in-view) — a sealed interior with no exit portal in view draws no sky (kills the full-screen-sky interim regression). Sky pre/post particle passes (particle.vert has no gl_ClipDistance) scissored to the doorway bbox.

- ClipFrameAssembly gains HasOutsideView + OutsideViewNdcAabb (the doorway NDC AABB, computed for BOTH Planes and Scissor terrain modes — unlike TerrainScissorNdcAabb which is Scissor-only).

- The pre-login goto SkipWorldGeometry moved BELOW the sky draw so the live sky still renders during the EnterWorld handshake (clipAssembly is null/no-clip pre-login → full-screen).

Build green; App tests 160/160. Stage 4 tests + verify-annotations follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:15:08 +02:00

156 lines
7.4 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;
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;
}