fix(sky): seamless night-sky star lattice — no face seams, no flare, round pixel-exact stars

The first night-sky gate (2026-08-23 screenshot) showed three defects:
glowing beams along the cube-face boundaries (fwidth blowup where
adjacent pixels land on different faces lights every neighbourhood star
solid), diffraction-spiked standouts the user rejects ('that is in a
photo only, not in real sky'), and ellipse-stretched stars from scalar
length(fwidth) sizing at oblique view angles.

One rewrite removes all three: stars now live on a seamless 3D lattice
over the unit sphere (no faces, so no seams by construction), each star
resolved through an exact tangent-plane -> screen-pixel 2x2 solve of
the direction derivatives (perfectly round, true pixel sizing at every
view angle, sharper cores), spikes deleted in favour of a soft round
halo on the bright tiers. Guard test updated to pin the new anchors and
forbid both fwidth-face grids and spikes; sky.frag.spv re-pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-23 18:00:28 +02:00
parent 508cefdeb1
commit b8cfee6d08
5 changed files with 58 additions and 46 deletions

View file

@ -108,60 +108,65 @@ float nsVnoise(vec3 p, uint seed)
return acc;
}
vec3 nsStars(vec2 fuv, uint faceSeed, float cells, float density,
float bMin, float bMax, float sizePx, bool spikes)
vec3 nsStarTier(vec3 dir, vec3 ex, vec3 ey, uint seed, float cells,
float density, float bMin, float bMax, float sizePx,
float haloAmp)
{
vec2 g = fuv * cells;
vec2 cellF = floor(g);
// Screen pixels per cell-space unit: the crispness anchor.
float pxPerCell = 1.0 / max(length(fwidth(g)), 1e-6);
// Seamless 3D lattice on the unit sphere: no face boundaries, so no
// seam artefacts by construction (the 2026-08-23 gate screenshot's
// glowing "Y" was cube-face fwidth blowup — do not bring faces back).
vec3 g = dir * cells;
vec3 cellF = floor(g);
// Exact tangent-plane -> screen-pixel solve: 2x2 normal equations of
// the direction derivatives. Stars come out perfectly ROUND and sized
// in true screen pixels at every view angle — a length(fwidth(...))
// scalar stretches them into ellipses at oblique angles.
float a = dot(ex, ex);
float b = dot(ex, ey);
float c = dot(ey, ey);
float det = max(a * c - b * b, 1e-14);
vec3 acc = vec3(0.0);
for (int dz = -1; dz <= 1; ++dz)
for (int dy = -1; dy <= 1; ++dy)
for (int dx = -1; dx <= 1; ++dx)
{
vec2 c = cellF + vec2(float(dx), float(dy));
uint h = nsPcg(faceSeed
^ nsPcg(uint(int(c.x) + 512)
^ nsPcg(uint(int(c.y) + 512)
^ uint(cells))));
vec3 cc = cellF + vec3(float(dx), float(dy), float(dz));
uint h = nsPcg(seed
^ nsPcg(uint(int(cc.x) + 512)
^ nsPcg(uint(int(cc.y) + 512)
^ nsPcg(uint(int(cc.z) + 512) ^ uint(cells)))));
if (nsRand(h) > density) continue;
uint h2 = nsPcg(h);
uint h3 = nsPcg(h2);
uint h4 = nsPcg(h3);
uint h5 = nsPcg(h4);
vec2 pos = c + vec2(nsRand(h2), nsRand(h3));
float t = nsRand(h4);
float b = mix(bMin, bMax, t * t * t);
vec3 tint = nsTint(nsRand(h5));
vec2 dPx = (g - pos) * pxPerCell;
float d2 = dot(dPx, dPx);
uint h6 = nsPcg(h5);
vec3 sdir = normalize(cc + vec3(nsRand(h2), nsRand(h3), nsRand(h4)));
vec3 v = sdir - dir * dot(sdir, dir); // tangent-plane offset
float bx = dot(v, ex);
float by = dot(v, ey);
vec2 sPx = vec2(bx * c - by * b, by * a - bx * b) / det;
float d2 = dot(sPx, sPx);
if (d2 > 400.0) continue; // > 20 px: contributes nothing
float t = nsRand(h5);
float br = mix(bMin, bMax, t * t * t);
vec3 tint = nsTint(nsRand(h6));
// Round gaussian core; optional soft round halo on the bright tiers.
// NO diffraction spikes / lens flare — user-directed 2026-08-23:
// spikes are a photographic artefact, not a naked-eye sky.
float star = exp(-d2 / (2.0 * sizePx * sizePx));
if (spikes)
{
// Axis-aligned diffraction cross + soft halo for the standouts.
float dist = sqrt(d2) + 1e-4;
float fall = exp(-dist / (14.0 * sizePx));
float arm = exp(-dPx.y * dPx.y * 0.8) + exp(-dPx.x * dPx.x * 0.8);
star += 0.35 * fall * arm;
star += 0.10 * exp(-d2 / (18.0 * sizePx * sizePx));
}
acc += b * tint * star;
star += haloAmp * exp(-d2 / (24.0 * sizePx * sizePx));
acc += br * tint * star;
}
return acc;
}
vec3 nightSky(vec3 dir, uint seed)
{
// Cube-face parameterisation: uniform angular density, no pole pinch.
// Face-edge star continuity is not exact (adjacent faces hash their own
// grids); the sub-pixel population makes any seam visually negligible.
vec3 ad = abs(dir);
uint face; vec2 fuv; float ma;
if (ad.z >= ad.x && ad.z >= ad.y) { face = dir.z > 0.0 ? 4u : 5u; ma = ad.z; fuv = dir.xy; }
else if (ad.x >= ad.y) { face = dir.x > 0.0 ? 0u : 1u; ma = ad.x; fuv = dir.yz; }
else { face = dir.y > 0.0 ? 2u : 3u; ma = ad.y; fuv = dir.xz; }
fuv = fuv / ma * 0.5 + 0.5;
uint fs = nsPcg(seed ^ (face * 0x9E3779B9u));
vec3 ex = dFdx(dir);
vec3 ey = dFdy(dir);
// Faint cool background mottle (0.4%..1.3% - the user-approved level).
float n = 0.55 * nsVnoise(dir * 3.0, seed ^ 0x9E3779B9u)
@ -169,11 +174,12 @@ vec3 nightSky(vec3 dir, uint seed)
+ 0.15 * nsVnoise(dir * 15.0, seed ^ 0xC2B2AE35u);
vec3 rgb = (0.004 + 0.009 * n) * vec3(0.85, 0.92, 1.10);
// The star carpet: three density tiers plus sparse spiked standouts.
rgb += nsStars(fuv, fs, 160.0, 0.90, 0.05, 0.35, 0.55, false);
rgb += nsStars(fuv, fs, 64.0, 0.50, 0.20, 0.70, 0.75, false);
rgb += nsStars(fuv, fs, 24.0, 0.35, 0.50, 1.40, 1.05, false);
rgb += nsStars(fuv, fs, 6.0, 0.10, 2.00, 4.00, 1.80, true);
// The star carpet: three density tiers plus sparse bright standouts,
// all crisp round points (see nsStarTier).
rgb += nsStarTier(dir, ex, ey, seed ^ 0x1B873593u, 110.0, 0.85, 0.05, 0.35, 0.50, 0.0);
rgb += nsStarTier(dir, ex, ey, seed ^ 0xCC9E2D51u, 48.0, 0.45, 0.20, 0.70, 0.65, 0.0);
rgb += nsStarTier(dir, ex, ey, seed ^ 0x27D4EB2Fu, 18.0, 0.30, 0.50, 1.40, 0.90, 0.05);
rgb += nsStarTier(dir, ex, ey, seed ^ 0x165667B1u, 6.0, 0.08, 2.00, 4.00, 1.40, 0.10);
return rgb;
}
// ============================================================================

View file

@ -316,7 +316,7 @@
},
{
"stage": "frag",
"sourceSha256": "bf5bc353959636b1c80a9f72dc918cffdd33fb6b7b655149de40422030b4f51a",
"sourceSha256": "a6efd09f396b17ae6a19ffc4be400ac9b035056cbad26aee3447459cd98341f0",
"compiled": true
}
]

View file

@ -52,7 +52,7 @@ 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"] = "8105984072fc1b9075b5efdffd75d959c4087710d354d50a0e00ee0500462ca7",
["sky.frag.spv"] = "2d2de4080c7f4b0885aee4441e37a1725d3f30661b45773bcb39383674b1718b",
["sky.vert.spv"] = "3b51945fa4ff1be1604144df92866bdd47aade22f9dd90267591ef36adb28cde",
["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9",
["terrain_modern.vert.spv"] = "9f4cb221ea6aed94a8d23af6cb8e3f3ed96c3cce6e50d135a72d3b55667b1557",

View file

@ -23,8 +23,14 @@ public sealed class EnhancedNightSkyRuleTests
Assert.Contains("if (uParamA > 0.5)", code, StringComparison.Ordinal);
Assert.Contains("nightSky(normalize(vDir), uint(uParamB))", 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.
Assert.Contains("fwidth(g)", code, StringComparison.Ordinal);
// 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
// glowing "Y"): the crispness anchor is the seamless 3D-lattice
// tangent-plane solve on the direction derivatives.
Assert.Contains("dFdx(dir)", code, StringComparison.Ordinal);
Assert.DoesNotContain("fwidth(g)", code, StringComparison.Ordinal);
// No diffraction spikes — user-directed: flare is photographic.
Assert.DoesNotContain("spike", code, StringComparison.OrdinalIgnoreCase);
}
[Fact]