diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index 6eb16ae..2bd2225 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -1,24 +1,28 @@ #version 430 core -// Sky mesh fragment shader — sample the object's diffuse texture with -// the scrolled UVs from the vertex stage. Unlit: sky meshes ARE the -// gradient (r12 §2.2), not a surface lit by the sun. +// Sky mesh fragment shader — retail-verbatim composite: // -// The per-keyframe replace override can dim the mesh (Transparent) or -// brighten it (Luminosity); those two floats arrive as uTransparency / -// uLuminosity uniforms. +// fragment.rgb = texture.rgb * vTint * uLuminosity + lightning_flash +// fragment.a = texture.a * (1 - uTransparency) +// +// vTint arrives from the vertex shader with retail's per-vertex lighting +// baked in (emissive + ambient + diffuse × sun, clamped to [0,1]). +// uLuminosity is the per-keyframe SkyObjectReplace override (0..1 +// fraction after the /100 scale in SkyDescLoader) — NOT to be confused +// with the Surface.Luminosity that feeds uEmissive in the vertex shader. +// uTransparency is the per-keyframe SkyObjectReplace alpha-fade. +// +// See docs/research/2026-04-23-sky-retail-verbatim.md §6 + §7. in vec2 vTex; +in vec3 vTint; out vec4 fragColor; uniform sampler2D uDiffuse; -uniform float uTransparency; // 0 = fully visible, 1 = invisible -uniform float uLuminosity; // 1 = normal, >1 makes the mesh glow -uniform vec4 uTint; // per-object color tint (default white) +uniform float uTransparency; // 0 = fully visible, 1 = fully transparent +uniform float uLuminosity; // 1.0 = normal; <1 dims per SkyObjectReplace -// Shared SceneLighting UBO — we only need the fog parameters to let the -// horizon band of the sky blend smoothly into the scene's fog color at -// the far edge, and the lightning flash to give storms their signature -// strobe. +// Shared SceneLighting UBO — only need fog/flash channel for the +// client-driven lightning strobe. Sun/ambient already baked into vTint. struct Light { vec4 posAndKind; vec4 dirAndRange; @@ -36,24 +40,20 @@ layout(std140, binding = 1) uniform SceneLighting { void main() { vec4 sampled = texture(uDiffuse, vTex); - // Apply tint + luminosity. Retail's SkyObjReplace.Luminosity can push - // above 1 to make the sun mesh brighter than its texture; r12 §2.3. - vec3 rgb = sampled.rgb * uTint.rgb * uLuminosity; + // Composite: texture × per-vertex lighting × per-keyframe dim. + vec3 rgb = sampled.rgb * vTint * uLuminosity; - // Lightning additive bump — makes the sky itself flash during storms. - // Retail's lightning is a near-white strobe; a dim grey bump doesn't - // read as lightning. Keep a faint blue tint so it still feels electric - // rather than pure-white daylight. + // Lightning additive bump (client-side during storm keyframes). float flash = uFogParams.z; rgb += flash * vec3(1.5, 1.5, 1.8); - // Soft clamp to let Luminosity/flash slightly over-bright. During a - // lightning flash, raise the ceiling so the strobe actually blows out - // instead of getting capped mid-rise. + // Soft clamp. Normal frame: cap at 1.2 so emissive meshes have a + // small highlight margin. During a flash the ceiling relaxes so the + // strobe actually blows out instead of getting pinned mid-rise. float cap = mix(1.2, 3.0, clamp(flash, 0.0, 1.0)); rgb = min(rgb, vec3(cap)); - float a = sampled.a * (1.0 - uTransparency) * uTint.a; + float a = sampled.a * (1.0 - uTransparency); if (a < 0.01) discard; fragColor = vec4(rgb, a); } diff --git a/src/AcDream.App/Rendering/Shaders/sky.vert b/src/AcDream.App/Rendering/Shaders/sky.vert index 35df9ca..8cc57e9 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.vert +++ b/src/AcDream.App/Rendering/Shaders/sky.vert @@ -1,22 +1,57 @@ #version 430 core -// Sky mesh vertex shader — each celestial object is a GfxObj mesh -// (sun billboard, cloud sheet, moon, star dome) rendered at large -// distance with depth writes disabled. The view matrix has its -// translation zeroed so the sky stays camera-centered; the projection -// matrix has a huge far plane so 1e6-metre-away sky meshes never clip. +// Sky mesh vertex shader — computes the per-vertex lighting tint that +// gives retail its time-of-day variation: +// +// tint = clamp(emissive + ambient + max(dot(N, -sunDir), 0) * sunColor, +// 0.0, 1.0) +// +// This is the retail-verbatim AdjustPlanes formula ported from the +// decompiled D3D fixed-function lighting. The `emissive` scalar is the +// Surface.Luminosity FLOAT field (NOT the SurfaceType.Luminous flag bit) — +// for Dereth's sky meshes, the DOME + SUN/MOON have emissive=1.0 +// (texture-passthrough regardless of lighting), while CLOUDS have +// emissive=0.0 (lit normally, so they pick up the ambient tint that +// produces retail's purple-haze night / warm-tan dusk / pale-cool noon). +// +// See docs/research/2026-04-23-sky-retail-verbatim.md §6 for the full +// decompile trail and field citations. layout(location = 0) in vec3 aPos; layout(location = 1) in vec3 aNormal; layout(location = 2) in vec2 aTex; -uniform mat4 uModel; // per-object arc transform (r12 §2.1) +uniform mat4 uModel; // per-object arc transform uniform mat4 uSkyView; // camera view with M41..M43 = 0 uniform mat4 uSkyProjection; // near=0.1, far=1e6 uniform vec2 uUvScroll; // cumulative TexVelocityX/Y * time +// Per-frame lighting — keyframe-interpolated values pushed before each +// Render() call. AmbColor × AmbBright and DirColor × DirBright are +// pre-multiplied by SkyDescLoader so the shader feeds them straight in. +uniform vec3 uAmbientColor; // AmbColor × AmbBright +uniform vec3 uSunColor; // DirColor × DirBright +uniform vec3 uSunDir; // unit vector FROM surface TO sun + +// Per-submesh: Surface.Luminosity (0..1 self-illumination scalar). +uniform float uEmissive; + out vec2 vTex; +out vec3 vTint; void main() { vTex = aTex + uUvScroll; gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0); + + // uModel for sky is scale * RotZ(-heading) * RotY(-rotation) — pure + // orthonormal rotation, so mat3(uModel) correctly transforms normals + // without needing transpose(inverse(...)). + vec3 worldNormal = normalize(mat3(uModel) * aNormal); + + // Per-vertex lighting. `emissive` is broadcast scalar → vec3. For + // emissive=1.0 the clamp saturates to white regardless of ambient/sun + // (the retail "unlit" mesh). For emissive=0.0 only the ambient + sun + // term drives `tint` (the retail "lit" mesh, e.g. clouds). + float diff = max(dot(worldNormal, uSunDir), 0.0); + vec3 lit = vec3(uEmissive) + uAmbientColor + diff * uSunColor; + vTint = clamp(lit, 0.0, 1.0); } diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index cdc26fb..31ae73b 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -74,18 +74,21 @@ public sealed unsafe class SkyRenderer : IDisposable /// terrain / meshes / debug lines / overlay land on top. /// /// - /// is accepted for forward-compatibility - /// with the retail-verbatim per-vertex lighting path (see - /// docs/research/2026-04-23-sky-retail-verbatim.md). It is - /// NOT currently consumed by the shader — sky meshes render at - /// uTint = white (texture passthrough). A prior experiment - /// multiplied alpha-blended submeshes by keyframe.AmbientColor - /// to tint clouds; this dimmed the sky dome's baked gradient - /// (user-verified regression) and was reverted. Retail actually - /// routes sky meshes through the normal mesh pipeline with - /// Surface.Type.Luminous controlling lit-vs-unlit per submesh; the - /// correct port lives downstream in Phase 2 once we have the live - /// Surface flags dumped. + /// Each submesh renders with retail's per-vertex lighting formula: + /// tint = clamp(emissive + ambient + max(dot(N, -sunDir), 0) * sunColor, 0, 1) + /// where emissive is the submesh's Surface.Luminosity + /// float (1.0 for dome + sun + moon → texture passthrough via + /// saturation; 0.0 for clouds → get the full time-of-day tint). + /// supplies the AmbientColor and SunColor + /// already pre-multiplied by AmbBright / DirBright (loader-side). + /// + /// + /// See docs/research/2026-04-23-sky-retail-verbatim.md §6 for + /// the full decompile citation. The empirical Dereth dump ( + /// ACDREAM_DUMP_SKY=1, logged 2026-04-23) confirmed the + /// SurfaceType.Luminous flag bit is NOT set on any Dereth sky + /// mesh — the differentiator is the Surface.Luminosity FLOAT + /// field. /// /// public void Render( @@ -116,23 +119,24 @@ public sealed unsafe class SkyRenderer : IDisposable _shader.SetMatrix4("uSkyView", skyView); _shader.SetMatrix4("uSkyProjection", skyProj); + // Retail per-vertex lighting inputs (AdjustPlanes formula). + // AmbColor/SunColor are already × AmbBright/DirBright from + // SkyDescLoader. SunDir is the unit vector FROM surface TO sun + // derived from the keyframe's DirHeading/DirPitch. + _shader.SetVec3("uAmbientColor", keyframe.AmbientColor); + _shader.SetVec3("uSunColor", keyframe.SunColor); + _shader.SetVec3("uSunDir", + AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(keyframe)); + // Save + override GL state. _gl.DepthMask(false); _gl.Disable(EnableCap.DepthTest); _gl.Disable(EnableCap.CullFace); _gl.Enable(EnableCap.Blend); - // Default blend — overridden per-submesh inside the inner loop based - // on the Surface's TranslucencyKind + Luminous flag. Sun/moon/stars - // in retail use Additive (their texture has a black background and a - // bright body painted on top; additive blending ignores the black and - // lets the body glow over the sky gradient). Clouds use AlphaBlend. - // Without per-object blend, sun renders as "black square with sun in - // it" because our default alpha-blend treats the black background as - // opaque. See SurfaceType enum (DatReaderWriter.Enums.SurfaceType): - // Additive = 0x10000 → GL_ONE / GL_ONE - // Luminous = 0x40 → additive for sky purposes - // Alpha = 0x100 / Translucent = 0x10 → GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA - // Base1ClipMap = 0x04 → alpha with shader discard + // Default blend — overridden per-submesh inside the inner loop. + // Additive surfaces (sun/moon/stars via SurfaceType.Additive = + // 0x10000) get GL_SRC_ALPHA / GL_ONE; alpha-blended (clouds, dome + // with Alpha flag) get GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA. _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); // Look up the keyframe's override list so we can apply @@ -183,17 +187,6 @@ public sealed unsafe class SkyRenderer : IDisposable _shader.SetVec2("uUvScroll", new Vector2(uOffset, vOffset)); _shader.SetFloat("uTransparency", transparent); _shader.SetFloat("uLuminosity", luminosity); - // uTint stays white: retail renders sky meshes as texture - // passthrough (the gradient lives in the mesh texture, not in - // a shader ambient multiply). D3DRS_AMBIENT is set to 0 once - // at retail device-init and never changes per-frame — verified - // in chunk_005A0000.c (state 0x8b = 139, only external caller - // is the default-reset at line 704). The "cloud tint" effect - // comes from per-vertex lighting on non-Luminous submeshes - // routed through the normal mesh pipeline. That path is - // Phase 2 — see docs/research/2026-04-23-sky-retail-verbatim.md - // §6 + §10 and the hunt-B finding at 2026-04-23-sky-decompile-hunt-B.md. - _shader.SetVec4("uTint", Vector4.One); EnsureMeshUploaded(gfxObjId); if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue; @@ -202,20 +195,25 @@ public sealed unsafe class SkyRenderer : IDisposable { // Per-submesh blend mode: sun/moon/stars are Additive // (SurfaceType.Additive = 0x10000), clouds are AlphaBlend, - // sky dome is either Opaque or AlphaBlend depending on the - // dat. We map Opaque to a passthrough SrcAlpha/InvSrcAlpha - // with alpha=1, which is equivalent to not blending. This - // split is architecturally correct (sun's additive blend - // stops its black texture background from occluding the - // sky dome behind it) but is NOT how retail does it — - // retail routes sky meshes through the normal mesh pipe - // where Surface flags dictate blend state per primitive. - // See FUN_00508010 (chunk_00500000.c:7535). + // sky dome is Base1Image (Opaque, mapped to + // SrcAlpha/InvSrcAlpha for a no-op blend at alpha=1). + // See FUN_00508010 (chunk_00500000.c:7535) for the retail + // pattern — retail routes sky meshes through the normal + // mesh pipeline where Surface flags dictate state. if (sub.IsAdditive) _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); else _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + // Per-submesh emissive (Surface.Luminosity FLOAT field — + // 1.0 for dome + sun + moon, 0.0 for clouds). The vertex + // shader saturates the lighting math when emissive=1.0 so + // self-illuminated meshes render at full texture brightness + // regardless of time of day; emissive=0.0 meshes get the + // full `ambient + diffuse × sun` tint (producing retail's + // purple night clouds / warm dusk clouds / pale noon clouds). + _shader.SetFloat("uEmissive", sub.SurfLuminosity); + uint tex = _textures.GetOrUpload(sub.SurfaceId); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, tex); @@ -403,6 +401,7 @@ public sealed unsafe class SkyRenderer : IDisposable IndexCount = sm.Indices.Length, SurfaceId = sm.SurfaceId, IsAdditive = isAdditive, + SurfLuminosity = sm.Luminosity, }; } @@ -428,10 +427,19 @@ public sealed unsafe class SkyRenderer : IDisposable public int IndexCount; public uint SurfaceId; /// - /// True if the Surface's flags indicate additive blending should be - /// used (SurfaceType.Additive OR SurfaceType.Luminous). Computed - /// once at upload; avoids a per-frame dat lookup. + /// True if the Surface's SurfaceType.Additive flag (0x10000) + /// is set. Drives the blend func switch (GL_ONE vs GL_ONE_MINUS_SRC_ALPHA). + /// Computed once at upload; avoids a per-frame dat lookup. /// public bool IsAdditive; + /// + /// Surface.Luminosity float (0..1 — NOT the SurfaceType.Luminous + /// flag bit). Passed to the sky fragment shader as uEmissive; + /// when 1.0 it saturates the lighting math so the mesh renders at + /// full texture brightness (dome, sun). When 0.0 the mesh picks up + /// the time-of-day ambient+diffuse tint (clouds). See + /// docs/research/2026-04-23-sky-retail-verbatim.md §6. + /// + public float SurfLuminosity; } } diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index fea8a52..f468dae 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -192,13 +192,22 @@ public static class GfxObjMesh // Resolve Surface.Type flags when a DatCollection is available // so the renderer can split the draw into opaque and translucent - // passes. + // passes. Also capture Surface.Luminosity (self-illumination + // coefficient — the FLOAT field, NOT the SurfaceType.Luminous + // flag bit). This is the retail signal used to make the sky + // dome / sun / moon texture-passthrough while clouds pick up + // the time-of-day ambient tint (see + // docs/research/2026-04-23-sky-retail-verbatim.md §6). var translucency = TranslucencyKind.Opaque; + var luminosity = 0f; if (dats is not null) { var surface = dats.Get(surfaceId); if (surface is not null) + { translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type); + luminosity = surface.Luminosity; + } } result.Add(new GfxObjSubMesh( @@ -207,6 +216,7 @@ public static class GfxObjMesh Indices: kvp.Value.Indices.ToArray()) { Translucency = translucency, + Luminosity = luminosity, }); } return result; diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs index ff9966a..d6a9cd0 100644 --- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs @@ -18,4 +18,25 @@ public sealed record GfxObjSubMesh( /// that don't supply dat access compile and pass unchanged. /// public TranslucencyKind Translucency { get; init; } = TranslucencyKind.Opaque; + + /// + /// Self-illumination strength of the Surface (Surface.Luminosity + /// field, 0..1 fraction — NOT the SurfaceType.Luminous flag bit). + /// Retail uses this as an emissive coefficient in the per-vertex + /// lighting formula: + /// + /// tint = clamp(vec3(Luminosity) + AmbColor + diffuse * DirColor, 0, 1) + /// fragment = texture * tint + /// + /// For Dereth's sky meshes, the DOME (0x010015EE) and SUN/MOON + /// (0x01001348) have Luminosity=1.0 (self-illuminated — emissive + /// saturates the lighting math so the baked texture always renders + /// at full brightness). CLOUDS (0x010015EF, 0x01004C36) have + /// Luminosity=0.0 (lit by ambient+diffuse — pick up the + /// time-of-day tint). See + /// docs/research/2026-04-23-sky-retail-verbatim.md §6. + /// Defaults to 0.0 (fully lit) so non-sky meshes render through the + /// normal lighting path without change. + /// + public float Luminosity { get; init; } = 0f; }