feat(sky): the night sky wheels with the Dereth clock; uniform time fade with a scoped twilight band

Rotation (user-directed): the procedural starfield rotates once per
Dereth day (~2 real hours - constellations visibly wheel through a
night) about a celestial pole ~41 deg above the northern horizon, plus
dayOfYear/360 of seasonal drift so the 360-day year changes the night
sky. One SkyParams float (272-byte block, layout test re-pinned)
carries dayFraction + dayOfYear/360 from the world clock; sky.frag
applies a Rodrigues rotation to the sample direction so stars and
mottle turn together. Impossible with retail's static stretched layer.

Fade rework (the 2026-08-23 two-screenshot gate finding): the
per-vertex vTint signal carried the sun-facing product and blanked
stars across the entire twilight half of the sky. The fade now reads
the UNIFORM ambient term - identical star visibility in every compass
direction, same dusk-to-dawn schedule - with one deliberate exception:
a thin suppression band hugging the low sky toward the sun's azimuth
while the sun term is strong, so stars still wash out inside the
actual twilight glow.

Guards updated (rotation anchor, uniform-fade anchor, 272-byte layout);
both sky SPIR-V hashes re-pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-23 18:21:17 +02:00
parent 91084b9a83
commit 0dac024cdb
10 changed files with 98 additions and 15 deletions

View file

@ -333,6 +333,18 @@ internal sealed class FrameRootCompositionPhase
skyForNightSky.EnhancedNightSkyActive = () =>
nightSkyController.ActiveRuntime?.Descriptor.Id
== AcDream.App.Rendering.Packs.BuiltInAtmosphericRenderPack.Id;
// The starfield wheels with the Dereth clock: one turn per
// day about the celestial pole plus ~1 degree/night of
// seasonal drift (dayOfYear/360) — sky.frag's rotation block.
var nightSkyTime = d.WorldTime;
skyForNightSky.NightSkyRotationTurns = () =>
{
var cal = nightSkyTime.CurrentCalendar;
int dayOfYear = (int)cal.Month
* AcDream.Core.World.DerethDateTime.DaysInAMonth
+ (cal.Day - 1);
return (float)(nightSkyTime.DayFraction + dayOfYear / 360.0);
};
}
}

View file

@ -43,6 +43,10 @@ layout(std140, ACDREAM_UBO_SET binding = 4) uniform SkyParams {
vec2 uUvScroll;
float uApplyFog;
float uSurfOpacity;
float uNightSkyRotationTurns; // enhanced night sky (IA-26): sky rotation
float uNsPadA;
float uNsPadB;
float uNsPadC;
};
struct Light {
@ -186,18 +190,50 @@ vec3 nightSky(vec3 dir, uint seed)
void main() {
if (uParamA > 0.5) {
// The star layer's own retail lighting product (Emissive 0 ->
// ambient + diffuse) is the day/night signal: ~0.1 at deep night,
// ~1.0 at noon. Stars at full strength only under a dark sky.
float dayL = dot(vTint, vec3(0.299, 0.587, 0.114));
float night = 1.0 - smoothstep(0.18, 0.45, dayL);
// Day/night fade from the UNIFORM ambient term only — the first
// gate's vTint signal carried the per-vertex sun-facing product and
// blanked stars across the whole twilight half of the sky
// (2026-08-23 screenshots). Ambient is one value for the sky, so
// star visibility is even everywhere; the twilight glow gets its
// own tightly-scoped suppression below.
float dayL = dot(uAmbientColor, vec3(0.299, 0.587, 0.114));
float night = 1.0 - smoothstep(0.10, 0.32, dayL);
if (night < 0.004) {
// Daytime: the whole lattice would multiply to zero — skip it
// so the enhanced sky costs nothing outside dusk-to-dawn.
fragColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
vec3 sky = nightSky(normalize(vDir), uint(uParamB));
vec3 d = normalize(vDir);
// Subtle twilight suppression: only the low sky (bottom ~19 deg)
// toward the sun's azimuth, and only while the sun term is strong
// (dusk/dawn) — stars near the bright horizon wash out, the rest
// of the sky keeps its full carpet.
vec3 dflat = normalize(vec3(d.xy, 0.0) + vec3(1e-5, 0.0, 0.0));
vec3 sflat = normalize(vec3(uSunDir.xy, 0.0) + vec3(1e-5, 0.0, 0.0));
float glowSide = max(dot(dflat, sflat), 0.0);
float lowSky = 1.0 - clamp(d.z * 3.0, 0.0, 1.0);
float twilight = clamp(
dot(uSunColor, vec3(0.299, 0.587, 0.114)) * uDiffuseFactor,
0.0, 1.0);
night *= 1.0 - 0.85 * twilight * glowSide * glowSide * lowSky;
// The sky wheels: one revolution per Dereth day about a celestial
// pole ~41 deg above the northern horizon (+Y north, +Z up), plus
// ~1 deg/night of seasonal drift folded into the turns value
// (dayFraction + dayOfYear/360). Rodrigues rotation of the sample
// direction; stars and mottle turn together.
const vec3 NS_POLE = vec3(0.0, 0.6598, 0.7514);
float ang = 6.28318530718 * fract(uNightSkyRotationTurns);
float ca = cos(ang);
float sa = sin(ang);
vec3 rdir = d * ca
+ cross(NS_POLE, d) * sa
+ NS_POLE * dot(NS_POLE, d) * (1.0 - ca);
vec3 sky = nightSky(rdir, uint(uParamB));
// Additive pipeline (forced for this draw): rgb adds over the dome.
fragColor = vec4(sky * night, 1.0);
return;

View file

@ -67,6 +67,10 @@ layout(std140, ACDREAM_UBO_SET binding = 4) uniform SkyParams {
vec2 uUvScroll;
float uApplyFog; // 1 for foggable layers; raw-additive keeps fog off
float uSurfOpacity; // final surface opacity multiplier from the CPU
float uNightSkyRotationTurns; // enhanced night sky (IA-26): sky rotation
float uNsPadA;
float uNsPadB;
float uNsPadC;
};
// Shared SceneLighting UBO — we need uFogParams.xy (fog start/end) to

View file

@ -311,12 +311,12 @@
"stages": [
{
"stage": "vert",
"sourceSha256": "9102b156bd4fd667831353640b2feee2ddde66e88579a4e425566c9b67304b64",
"sourceSha256": "80c190d5544433bd6b08810c1e5040cec5090fe91839f436728d5454ed7f3c31",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "e7ccdb4d5fece2ceeb48eb43aaa827424df2b68614987eeb3dfb7880dff8ea83",
"sourceSha256": "76481dcc1a321c65308bf9c081254978ee7f12413166df5d32a1606228bdc768",
"compiled": true
}
]

View file

@ -106,6 +106,13 @@ public sealed partial class SkyRenderer : IDisposable
/// </summary>
internal Func<bool>? EnhancedNightSkyActive { get; set; }
/// <summary>
/// Dereth-clock rotation source for the enhanced night sky, in turns
/// (dayFraction + dayOfYear/360). Composition wires it to the world
/// time service; evaluated once per sky pass.
/// </summary>
internal Func<float>? NightSkyRotationTurns { get; set; }
/// <summary>Deterministic seed for the procedural sky's hash grids —
/// the approved-look generator's seed (gen_starfield2.py, 2026-08-23).</summary>
private const float NightSkySeed = 11f;
@ -209,6 +216,8 @@ public sealed partial class SkyRenderer : IDisposable
// that FOV here, including the near-180-degree teleport transition.
var skyProj = SkyProjection.WithDepthRange(camera.Projection, Near, Far);
_params.NightSkyRotationTurns = NightSkyRotationTurns?.Invoke() ?? 0f;
// View with translation zeroed — keeps the sky at camera origin
// regardless of camera position in the world.
var skyView = camera.View;
@ -726,9 +735,20 @@ public sealed partial class SkyRenderer : IDisposable
public Vector2 UvScroll; // 240
public float ApplyFog; // 248
public float SurfOpacity; // 252
/// <summary>
/// Whole-sky rotation in TURNS for the enhanced night sky (IA-26):
/// dayFraction + dayOfYear/360, so the procedural starfield wheels
/// once per Dereth day about the celestial pole and drifts ~1 degree
/// per night through the 360-day year (seasonal constellations).
/// Zero when the pack is inactive; the retail path never reads it.
/// </summary>
public float NightSkyRotationTurns; // 256
public float NsPadA; // 260
public float NsPadB; // 264
public float NsPadC; // 268
/// <summary>256 — the std140 size of the block, a whole number of vec4s.</summary>
public const int SizeInBytes = 256;
/// <summary>272 — the std140 size of the block, a whole number of vec4s.</summary>
public const int SizeInBytes = 272;
}
private sealed class SubMeshGpu

View file

@ -52,8 +52,8 @@ public sealed class VulkanShaderManifestTests
// override, retail's GameSky::Draw @0x00506FF0 rule (see
// SkyFogRuleTests). A deliberate default-path change, reviewed
// with the world-fog-range fix in the same commit.
["sky.frag.spv"] = "e307a7f3cf84aada86db7c2859c55841a650b46732f12149986562cef0a90ca8",
["sky.vert.spv"] = "3b51945fa4ff1be1604144df92866bdd47aade22f9dd90267591ef36adb28cde",
["sky.frag.spv"] = "83a7b4e265f7df4726d716a812ebaa74ba31ea47036bc31005cd8dc1d8981967",
["sky.vert.spv"] = "988eff4e106422ca483d665ba25818e8394737a247e9bae346db3220ac31fd28",
["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9",
["terrain_modern.vert.spv"] = "9f4cb221ea6aed94a8d23af6cb8e3f3ed96c3cce6e50d135a72d3b55667b1557",
["ui_text.frag.spv"] = "37a281bf80441cb425eaa3ad8e0b3a43cfa21b74b60973ed4201718b9dc102df",

View file

@ -21,7 +21,12 @@ public sealed class EnhancedNightSkyRuleTests
string code = StripLineComments(frag);
Assert.Contains("if (uParamA > 0.5)", code, StringComparison.Ordinal);
Assert.Contains("nightSky(normalize(vDir), uint(uParamB))", code, StringComparison.Ordinal);
Assert.Contains("nightSky(rdir, uint(uParamB))", code, StringComparison.Ordinal);
// The fade must stay UNIFORM across the sky (the first gate's
// per-vertex vTint signal blanked the whole twilight half) and the
// starfield must wheel with the Dereth clock.
Assert.Contains("dot(uAmbientColor, vec3(0.299, 0.587, 0.114))", code, StringComparison.Ordinal);
Assert.Contains("fract(uNightSkyRotationTurns)", code, StringComparison.Ordinal);
// Screen-pixel star sizing is the reason this exists — the stretched
// texture flaw must not creep back in via a fixed-size grid, and the
// cube-face fwidth seams must not return (the 2026-08-23 gate's

View file

@ -8,7 +8,7 @@ namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Campaign V slice V6e: the sky's dozen loose uniforms became one std140
/// uniform block, and this is the test that keeps the CPU struct and the GLSL
/// block describing the same 256 bytes.
/// block describing the same 272 bytes.
///
/// <para>It exists because the failure mode is silent. std140 gives a
/// <c>vec3</c> 16-byte alignment while only using 12, so the block deliberately
@ -45,6 +45,12 @@ public sealed class SkyParamsLayoutTests
[InlineData("UvScroll", 240)]
[InlineData("ApplyFog", 248)]
[InlineData("SurfOpacity", 252)]
// IA-26: the enhanced night sky's rotation turns plus its three pad
// words, filling the seventeenth vec4 exactly.
[InlineData("NightSkyRotationTurns", 256)]
[InlineData("NsPadA", 260)]
[InlineData("NsPadB", 264)]
[InlineData("NsPadC", 268)]
public void EveryMemberSitsWhereStd140PutsIt(string member, int expectedOffset)
{
Assert.Equal(
@ -60,7 +66,7 @@ public sealed class SkyParamsLayoutTests
?.GetRawConstantValue()
?? throw new InvalidOperationException("SkyParams.SizeInBytes is missing."));
Assert.Equal(256, declared);
Assert.Equal(272, declared);
Assert.Equal(declared, Marshal.SizeOf(SkyParamsType));
// std140 rounds a block up to its largest member's alignment (16).
Assert.Equal(0, declared % 16);