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;
}