diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index b84dda24..db25c71e 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -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; } // ============================================================================ diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index 7067f074..1029e342 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -316,7 +316,7 @@ }, { "stage": "frag", - "sourceSha256": "bf5bc353959636b1c80a9f72dc918cffdd33fb6b7b655149de40422030b4f51a", + "sourceSha256": "a6efd09f396b17ae6a19ffc4be400ac9b035056cbad26aee3447459cd98341f0", "compiled": true } ] diff --git a/src/AcDream.App/Rendering/Shaders/spv/sky.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/sky.frag.spv index df2533c5..54f0a569 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/sky.frag.spv and b/src/AcDream.App/Rendering/Shaders/spv/sky.frag.spv differ diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs index 11d417a6..4a1a467a 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs @@ -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", diff --git a/tests/AcDream.App.Tests/Rendering/Sky/EnhancedNightSkyRuleTests.cs b/tests/AcDream.App.Tests/Rendering/Sky/EnhancedNightSkyRuleTests.cs index 0909720c..48ad7a82 100644 --- a/tests/AcDream.App.Tests/Rendering/Sky/EnhancedNightSkyRuleTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Sky/EnhancedNightSkyRuleTests.cs @@ -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]