From 508cefdeb1b56b89f690923acad769bae14c3ec3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 23 Aug 2026 17:55:20 +0200 Subject: [PATCH] feat(sky): pack-gated procedural night sky replacing the stretched DAT star layer; register IA-26 User-directed enhancement ('I want the night sky to look very good'): retail's star layer is one small texture stretched over a 10-poly dome cap, so stars smear regardless of source-image quality. With the Atmospheric render pack active, sky.frag now renders the star layer (GfxObj 0x010015EF, identical in all 20 Dereth day groups) as a fully procedural sky computed from the view direction: hash-derived stars on a cube-face grid in three density tiers plus sparse diffraction-spiked standouts, sized in SCREEN pixels via derivatives so they stay crisp at any resolution and FOV, over the user-approved 0.4-1.3% cool mottle (gen_starfield2.py seed 11, approved 2026-08-23). The draw is forced additive; the day/night fade rides the star layer's existing retail lighting product so the schedule matches the authored keyframes. Pack inactive = retail look byte-untouched. EnhancedNightSkyRuleTests pins the uParamA gate, the exact star-layer id, the forced-additive draw, and the pack-runtime wiring; sky shader SPIR-V recompiled and re-pinned. Hermetic App suite green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 3 +- .../Composition/FrameRootComposition.cs | 14 ++ src/AcDream.App/Rendering/Shaders/sky.frag | 138 ++++++++++++++++++ src/AcDream.App/Rendering/Shaders/sky.vert | 5 + .../Shaders/spv/shaders.manifest.json | 4 +- .../Rendering/Shaders/spv/sky.frag.spv | Bin 3320 -> 23624 bytes .../Rendering/Shaders/spv/sky.vert.spv | Bin 3864 -> 3912 bytes .../Rendering/Sky/SkyRenderer.Rhi.cs | 14 +- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 25 +++- .../Gpu/Vk/VulkanShaderManifestTests.cs | 4 +- .../Sky/EnhancedNightSkyRuleTests.cs | 106 ++++++++++++++ 11 files changed, 302 insertions(+), 11 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/Sky/EnhancedNightSkyRuleTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 2c5a4b12..6874bb33 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -37,10 +37,11 @@ accepted-divergence entries (#96, #49, #50). --- -## 1. Intentional architecture (IA) — 22 active rows (IA-25 filed 2026-08-22 for Campaign VM VM6's opt-in weather-driven foliage wind — a render-only vertex displacement keyed by the DAT-classified `WeatherKind`, not the raw day-group index, with no authored retail wind direction to read; IA-24 filed 2026-08-22 for Campaign AR's opt-in real-time sun/moon directional shadows; IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default) +## 1. Intentional architecture (IA) — 23 active rows (IA-26 filed 2026-08-23 — the pack-gated procedural night sky replacing the stretched DAT star layer; IA-25 filed 2026-08-22 for Campaign VM VM6's opt-in weather-driven foliage wind — a render-only vertex displacement keyed by the DAT-classified `WeatherKind`, not the raw day-group index, with no authored retail wind direction to read; IA-24 filed 2026-08-22 for Campaign AR's opt-in real-time sun/moon directional shadows; IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| IA-26 | **Filed 2026-08-23, user-directed ("I want the night sky to look very good").** When the Atmospheric render pack is the active runtime, the DAT star layer (GfxObj `0x010015EF`, identical across all 20 Dereth day groups — a small texture stretched over a 10-poly dome cap) renders instead as `sky.frag`'s fully procedural night sky: hash-derived stars on a cube-face grid in three density tiers plus sparse diffraction-spiked standouts, sized in SCREEN pixels via derivatives (crisp at any resolution/FOV — the stretched-texture flaw this exists to remove), over a 0.4–1.3% cool background mottle. The art direction is the user-approved 2026-08-23 generator recipe. The draw is forced onto the additive pipeline; the day/night fade derives from the star layer's own retail lighting product so timing matches the authored keyframes. Retail default (pack inactive) is byte-untouched. | `src/AcDream.App/Rendering/Shaders/sky.frag` (the `uParamA` branch); `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` / `.Rhi.cs`; `src/AcDream.App/Composition/FrameRootComposition.cs` | Pack-gated opt-in like IA-24/IA-25; `EnhancedNightSkyRuleTests` pins the gate, the exact star-layer id, the forced-additive draw, and the pack-runtime wiring. | A future region whose star layer uses a different GfxObj id keeps its retail stars even with the pack active (the swap simply never fires); anyone comparing acdream-with-pack to retail at night will see a deliberately different (denser, crisper) starfield. | `EnhancedNightSkyRuleTests`; user gate 2026-08-23; gen_starfield2.py (scratchpad, seed 11) | | IA-1 | Contact-plane pre-seed on grounded movers (**#96 ACCEPTED** per ISSUES.md) — retail's `CTransition::init` clears `contact_plane_valid`; we seed from the body's previous-frame plane | `src/AcDream.Core/Physics/PhysicsEngine.cs:919` | Removing it broke last-step stair `step_up` (`892019b`, reverted); seed propagates the body's *real current* plane, behavior matched retail in the A6.P3 gates | A stale pre-seeded plane lets `AdjustOffset` project sub-step 1 onto a plane retail wouldn't have yet — wrong slope motion / step-up acceptance right after leaving a surface | `CTransition::init`, pc:272547 family | | IA-2 | Lateral self-heal beyond retail's keep-curr: when no candidate contains the sphere, try `FindVisibleChildCell` over the claim's stab-list before keeping the claim | `src/AcDream.Core/Physics/CellTransit.cs:912` | Reuses the recovery retail's own `AdjustPosition` performs (:280028 stab-list mode), applied at the `find_cell_list` site to heal near-miss claims without a doorway crossing | In containment-gap geometry, membership flips to a neighbouring room where retail keeps curr — wrong render root / collision cell at gap positions | `find_cell_list` keep-curr pc:308788-308825; `find_visible_child_cell` :311444 | | IA-3 | **NARROWED 2026-07-17 — `get_state_velocity` may prefer a nonzero dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant.** Production grounded player/remote translation no longer consumes this value; both use the literal CSequence root Frame. The accessor remains observable for jump launch and headless/test fallbacks | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`get_state_velocity`); grounded owners in `PlayerMovementController` / `RemotePhysicsUpdater` | Installed Humanoid Walk/Run MotionData velocity is zero, so the retail constants remain the jump/fallback result. A nonzero exotic/modded cycle may override them, preserving the earlier adapter contract without affecting ordinary grounded motion | Jump horizontal speed for an exotic MotionTable with nonzero authored velocity can differ from the retail binary's constants | `CMotionInterp::get_state_velocity` 0x00527D50; `CPhysicsObj::UpdatePositionInternal` 0x00512C30 | diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 8f5099c7..bfb78468 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -320,6 +320,20 @@ internal sealed class FrameRootCompositionPhase renderPackDiagnostics.BindOwned(renderPackController)); } atmosphericInputs = new AcDream.App.Rendering.Packs.AtmosphericFrameInputState(); + + // Enhanced night sky (register IA-26, user-directed 2026-08-23): + // while the Atmospheric pack is the active runtime, SkyRenderer + // swaps the DAT star layer for sky.frag's procedural + // view-direction starfield. Same-composition lifetimes on both + // sides, and the delegate re-reads ActiveRuntime per draw, so a + // pack activate/deactivate flips the sky the same frame. + if (live.SkyRenderer is { } skyForNightSky) + { + var nightSkyController = renderPackController; + skyForNightSky.EnhancedNightSkyActive = () => + nightSkyController.ActiveRuntime?.Descriptor.Id + == AcDream.App.Rendering.Packs.BuiltInAtmosphericRenderPack.Id; + } } var renderWeatherFrame = new RenderWeatherFrameController( diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index f02ebbe8..b84dda24 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -4,8 +4,15 @@ in vec2 vTex; in vec3 vTint; in float vFogFactor; // 1 = no fog, 0 = full fog color +in vec3 vDir; // model-space view direction (enhanced night sky) out vec4 fragColor; +// Enhanced night sky (render-pack-gated, register IA-26): uParamA > 0.5 marks +// the star-layer draw as the procedural night sky; uParamB carries its seed. +// Push-constant-backed like uTextureIndexA (see VulkanGlslPreamble.cs). +uniform float uParamA; +uniform float uParamB; + // Campaign V slice V6e: the sky's texture is now read through the shared table // (ACDREAM_SAMPLE_2D, injected by tools/ShaderCompiler/VulkanGlslPreamble.cs) // rather than a `uniform sampler2D` bound to texture unit 0. Vulkan has no @@ -52,7 +59,138 @@ layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; +// ============================== enhanced night sky ========================== +// User-directed enhancement (register IA-26): when the Atmospheric render pack +// is active, the DAT star layer (GfxObj 0x010015EF - a small texture stretched +// over a 10-poly dome cap) is replaced by a fully procedural sky computed from +// the VIEW DIRECTION. Stars are hash-derived on a cube-face grid, sized in +// SCREEN pixels via fwidth, so they stay pixel-crisp at any resolution, FOV, +// and view angle - the "stretched image" flaw this exists to remove. The art +// direction (density tiers, colour distribution, spiked standouts, faint cool +// mottle) is the user-approved 2026-08-23 generator recipe +// (scratchpad gen_starfield2.py); the retail default look is untouched. + +uint nsPcg(uint v) +{ + v = v * 747796405u + 2891336453u; + v = ((v >> ((v >> 28u) + 4u)) ^ v) * 277803737u; + return (v >> 22u) ^ v; +} + +float nsRand(uint s) { return float(s) * (1.0 / 4294967296.0); } + +vec3 nsTint(float r) +{ + if (r < 0.04) return vec3(1.00, 0.55, 0.35); // orange-red + if (r < 0.12) return vec3(1.00, 0.80, 0.60); // warm + if (r < 0.62) return vec3(1.00, 0.97, 0.93); // near-white + if (r < 0.88) return vec3(0.90, 0.95, 1.00); // blue-white + return vec3(0.70, 0.82, 1.00); // hot blue +} + +float nsVnoise(vec3 p, uint seed) +{ + vec3 i = floor(p); + vec3 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float acc = 0.0; + for (int c = 0; c < 8; ++c) + { + vec3 o = vec3(float(c & 1), float((c >> 1) & 1), float((c >> 2) & 1)); + vec3 q = i + o; + uint h = nsPcg(seed + ^ nsPcg(uint(int(q.x) + 512) + ^ nsPcg(uint(int(q.y) + 512) + ^ uint(int(q.z) + 512)))); + vec3 w = mix(1.0 - f, f, o); + acc += nsRand(h) * w.x * w.y * w.z; + } + return acc; +} + +vec3 nsStars(vec2 fuv, uint faceSeed, float cells, float density, + float bMin, float bMax, float sizePx, bool spikes) +{ + 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); + vec3 acc = vec3(0.0); + 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)))); + 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); + 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; + } + 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)); + + // Faint cool background mottle (0.4%..1.3% - the user-approved level). + float n = 0.55 * nsVnoise(dir * 3.0, seed ^ 0x9E3779B9u) + + 0.30 * nsVnoise(dir * 7.0, seed ^ 0x85EBCA6Bu) + + 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); + return rgb; +} +// ============================================================================ + 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); + vec3 sky = nightSky(normalize(vDir), uint(uParamB)); + // Additive pipeline (forced for this draw): rgb adds over the dome. + fragColor = vec4(sky * night, 1.0); + return; + } + vec4 sampled = ACDREAM_SAMPLE_2D(uTextureIndexA, vTex); vec3 rgb = sampled.rgb * vTint; diff --git a/src/AcDream.App/Rendering/Shaders/sky.vert b/src/AcDream.App/Rendering/Shaders/sky.vert index 290436d2..28a94d1a 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.vert +++ b/src/AcDream.App/Rendering/Shaders/sky.vert @@ -115,9 +115,14 @@ out gl_PerVertex { out vec2 vTex; out vec3 vTint; out float vFogFactor; // 1 = no fog (close), 0 = full fog (far) +// Model-space position of the (camera-centred) sky vertex — normalised in the +// fragment stage it is the view DIRECTION. Only the enhanced night-sky branch +// reads it (see sky.frag's uParamA gate). +out vec3 vDir; void main() { vTex = aTex + uUvScroll; + vDir = aPos; gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0); // uModel for sky is pure rotation (Z then Y) — orthonormal, so diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json index a363d5a2..7067f074 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -311,12 +311,12 @@ "stages": [ { "stage": "vert", - "sourceSha256": "a89580f54c8d36b33d50b84b9fb61024861c737bb0b5bd7a4df0704bf010c3c3", + "sourceSha256": "9102b156bd4fd667831353640b2feee2ddde66e88579a4e425566c9b67304b64", "compiled": true }, { "stage": "frag", - "sourceSha256": "b1f3b924af2040c909337c02461ed6795b5320fb04b5bb371ce1546feead567c", + "sourceSha256": "bf5bc353959636b1c80a9f72dc918cffdd33fb6b7b655149de40422030b4f51a", "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 477ff9fe9f1f21e3cd0158e051ded6353cd5a98f..df2533c57dd54426bc2df225cd14e181535fb212 100644 GIT binary patch literal 23624 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 literal 3320 zcmZ9NOLLS}5Qa}OLn0(V5W+=Bn3;$R6H}moD3?qmR-y!m1}}I;E1;H&7w}Se0Yyqy z{tXvy)v{#64{+hmjSE(;T)44BORIdIZ%&6(bE@8S_j|hg^y#n9q}DsupZa>zKw6!y zrp{WIdQ%Uo^1BTk`F)nxvaAg!V)`n-+4;r!$#dtICU@`IX@)haRwXjWTJqMB;?0*{ zJL|?8K$^52On|*$3M_()V2F7A`@t-l{!QQtyM^9c*|l_g*5~k5cdY<({0-Y&LtWe$ zwk!OsSFT-fo*_3=aNbUCuHdX!Zoc5GL2jYo`Z9N-;H*u*rwi`3%sq#kr5{_6-_kVo zt%bgAS+7_99L09oCwr^yy}aWtq|38h!Nt9!Kl;VJ8--sD-B>@e`R_w|pLS)}(r%=2 z=2drjf8g|Eb_&zJ4yan6F<8F6Qe- z!Nq+2UT`bXt>uq`Ta~$+1viknf06dxx9fMX#;{xH_C(uv?HqbnZQppz>3UfCCb#z9 z>KoC#M+0oa-}<%9za_0q)}(EISGU|syfx}KfJ<~tly1eQqj>H@w>Krs zYkl_uXKEX^zYF@xjpFUQmn-AcJ#!Dz`t|WlZQr$A^yL1+_N;v%YJMQ^d5=;4Q}}-T z9)Y-Hw~XEyXB~%-o@u;wXgk+s9c^r9DtdVsoi()quNjo6cebK$+4I;@?@@Gf#{C~h zx98U3x!Q}Fi{75Zjym*v9;9@AD(ZLv>&%(orY6YJLVpqHBiswWCG?aoEyta%>~=fq zejD3w81{SU&RWD@KwtIC*a7?XDY|#^9@eexyO!I_1Y56f$R&3Z#^0-H{yNv2fV1zs zAH>t|x*5nXX50UHa>h9O{@?Sh${S;C{{NHL9ziyMGyfPd_G%0?fGfB=(Hp?NFXW!> zK-bs3CU}Z??{*jP4EK-I9s7GXkQXlVcwcL9{~qAGxk3)@NnnipH0L?94+7`h*>;|d zwch=}y|dWIG1e@F%O~3|A@~F@?ju%7<`ktIdpS5n_p+{5p;ds`-`u1 z1nK&Rv)y|X$ocKmt-<*{2CUt=wg&rj9LPtH7BVOADtqKTPXOmU`u{}ctelfdmp*wdq5Hs-( zx-;SXjXS-IE`OQ&t@RwR26^XF?mRG`dy(sXbaVOkBi9G$@)7qTy0P*uz~;p diff --git a/src/AcDream.App/Rendering/Shaders/spv/sky.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/sky.vert.spv index e85459707d6c9dd79863727aaf2e29bfb3dca6cc..e69c1a43307eb3b22c4da48f0b454f3272a1404c 100644 GIT binary patch literal 3912 zcmZXV-EZDw7{`C@^Rz{~jdgFBdfKNQv#E}48q%#rwFjMbW8TN+tr5Wi65?e~ZA8qP zj3qcQ9g~b8Bsd_#A?Cmz;J_c?zzG>4@%j9o`?5QAC*R)J^}Sy2`?`NmE3Ng8qP?Z) zEEW|%7R_}|(OR@1y4Tn{i}q5Vn7VOl@Uh458{D{QNYBMZ#jUh0Db6nCDt{l|``{xg zXM#?!6r2ZEgE|-oGhiQ>1M^^_sFwac$S!qb)muxwQdUr&#MZ1<6m6y6_MhH?u5A)a z_*l~wah@%YQ(qV-XWS@=IBVx|>I>uKjB9}Cp^~_7GtPU`&pQu4bIbjdaqWrwIpeIU z-!B<=M&f?WIPYJ--!ks3#GT4G?^(aUf%opbdVxKL-iPd+s5?vNQc1dVJw3Nd8S6cG zF7vpo;R?=Na*<;k*&I=0Cew|18r@}|uHf{Si<#>eGpHelpLO&b0-oU+cJFqy)GNia z?9sEbE3YLNwRALZ#FX_jE^5^>F6KQ1XMNv(Z^{{ZU#9zAoPPV&;w^Oh^_&yj+EI2= zm3=6?@_yx_-)2m;n1{2ban@9in%%JSz2vrk%r-4B4}Ij^*t7npgc?c6+j}`PBU;;@syWn=9<^+C~>~Da!<~?_m-?Loqr)*EpvD=g1yfxJQhUM(h z88(0`^aIFoC-!y_Ji2uMRpvW{Y=6PL3Fo^AY-R%1b8arra|*_}jKDhoF5ovm z2J3gU0{HFueTC1;#Lwbm%qn1<{1&qM9m?r%e-{A1CvARr>gHd=ei1PL0MOqzeld^_ zpG%PS@y&T48bQAf*o(RKjd@>>JPtg!{Z0UV z<-5|GokTXDe%if{ZR|GyXEp^Q-}b~G!N>Vb1LNhpxOcgozPMbdEqiwD{zM1;Zuq8eds#|yNi7m>;nU!0pe`WBWnx47m&m6K<4)%vbOzc-Y+A| z86We01=)U_xAub|{0}4RZ+|bP9IqkE8|VGl{}Esg??=0DW&`kk=Ge9W$%$WQ_jl6$ zcivk!`*)xpw1H2+8^F09V}A$C1MQw&|964>r}!Lae-D^L{ttZKWH*QFFLdqd^44zV zZ!b=uzYpetzlY|qhQAdb0Bxavh&&A9yZH!NA9-hRi2Y+w2a(IO%jw&oS9|w-*3l=w z4|`MpjNKmH<9@zJ_H6om13m|F_OFn&`+v-{f5EQZ<=M5X%bQm@`u!R?`Zb6B$~m{_ R*LvF4g1kTT{lmnE!N0Ru@+klS literal 3864 zcmZ9M%Wsxd5QpF1)s}*kn~LT8+Iqoatw@b20&Ue4v~nq4Q52OfSf~q(Mil&@E| z$7vfk0wR~(>^RrXC-R&A7`FcQNDat$u%jan8_Qxexp9S@i(>5q1^b`rXS?q{AGJ;PjVk0_M~& za(eFh*^VVU)|kp{^Sy}eutrC4`pcDPTl%?%Tm^)mHOLJD_pk?PUq^sGFCiWJID(7( z79L#WpOy?Xo^>a*`PT z0(AG{J{BVNHLw0x)OkJ({k5+I_G2mbRY=#C>&EupyDxe3U5#{4^46p6SA>h z*bn3HMp~OOk$)q)BkJ3N?z!qeiPYaZV*Ua2eE-|9{TIgkcJw&Uoyj-$r|tj3_-?%X zSGboPp35FM=gM0$F7 zMz=nB^J)9W<*e;E(zVOln(Ob@KFb^bF4DD60OPfNzjDr>Ou8`(U{5FhOybQQy*!WZ zU0p#n_V7F4c?E*M0Ovgk+{%f#p6BSW_KPr{(;8UsLkDOFqp-f4MZmY_dkUY$iQkWp zF-w4P@@vWJyOT3V71;yKQ32-YMD_yt@VN$EpFR*i*P_cGBaboH0psK&?s|0Nd~XrA z99=%*Za_CyULSjQBk)aaA-89GQ{oW+a)XI$$heymXCF7Sd(UMhFkXM>?e|ggI(G|j zeSNIiw>kv;Us$vK($>#fj9Cq=)v<|_w%*%;yl_~PHAKuBbYtWrW-YqBaLkTb2WO0V zB4z|#UN|CVGhG_S9tGB7?hz3EyaRmIB%kMVFECENgTBjc0_O9qo! z&buf5-vg(>S$s|--v{Q9{~e!qkmhjwiS4|$yla>FTZ=Q;9{}_B0du&9|B4TRb76mk zJ_O?3e2lJ-yk~G2`3dL-k;}cy>06^#Yj=OHqfg!sYtuf5v_@t0=Ua65rq5U4GZ1_K w0^RxZz`cKtbl&0Ko!6E(uX@z`C3@6r4(pZk+@fCBb1v$Q`pow?Cw?3F2jTDTAOHXW diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs index 7741acb1..eeced25f 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.Rhi.cs @@ -209,7 +209,7 @@ public sealed unsafe partial class SkyRenderer /// those binds are what select the descriptor scope the sections must land in /// (plan §5.5.14 item 2). /// - private void DrawSubMeshRhi(SubMeshGpu sub, uint textureSlot) + private void DrawSubMeshRhi(SubMeshGpu sub, uint textureSlot, bool nightSky = false) { if (sub.IndexCount == 0 || sub.VertexBuffer is null || sub.IndexBuffer is null) return; @@ -220,12 +220,16 @@ public sealed unsafe partial class SkyRenderer ?? throw new InvalidOperationException( "SkyRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); - encoder.BindPipeline(sub.IsAdditive ? _additivePipeline! : _alphaPipeline!); + // The enhanced night sky ADDS starlight over the dome, so its draw + // rides the additive pipeline even though the authored star surface + // is alpha-blended (register IA-26). + encoder.BindPipeline(nightSky || sub.IsAdditive ? _additivePipeline! : _alphaPipeline!); var pushConstants = new GpuPushConstants { // uViewProjection is unread by sky.vert — the sky carries its own // camera-anchored view and dome projection in SkyParams — so the - // block's only live member here is the texture slot. + // live members here are the texture slot and the night-sky + // ParamA/ParamB pair (sky.frag's uParamA gate + seed). ViewProjection = System.Numerics.Matrix4x4.Identity, DrawIdOffset = 0, LightingMode = 0, @@ -233,8 +237,8 @@ public sealed unsafe partial class SkyRenderer LightDebug = 0, TextureIndexA = textureSlot, TextureIndexB = 0, - ParamA = 0f, - ParamB = 0f, + ParamA = nightSky ? 1f : 0f, + ParamB = NightSkySeed, }; encoder.SetPushConstants(in pushConstants); encoder.BindVertexBuffer(0, sub.VertexBuffer, 0); diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index a8c2236b..aec455b6 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -89,6 +89,27 @@ public sealed partial class SkyRenderer : IDisposable public float Near { get; set; } = 0.1f; public float Far { get; set; } = 1_000_000f; + /// + /// Dereth's star layer — the one sky GfxObj (verified identical across + /// all 20 day groups) whose draw the enhanced night sky replaces. + /// + private const uint StarLayerGfxObjId = 0x010015EFu; + + /// + /// User-directed enhancement gate (register IA-26): when this returns + /// true, the star layer renders as sky.frag's procedural night sky + /// (view-direction stars, pixel-crisp at any resolution) instead of the + /// authored stretched texture. Wired by composition to "the Atmospheric + /// render pack is active"; null/false keeps the retail look. Evaluated + /// per draw — never capture a stale pre-session snapshot + /// (claude-memory/feedback_resolve_deferred_funcs_per_call.md). + /// + internal Func? EnhancedNightSkyActive { get; set; } + + /// Deterministic seed for the procedural sky's hash grids — + /// the approved-look generator's seed (gen_starfield2.py, 2026-08-23). + private const float NightSkySeed = 11f; + /// /// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds — /// every SkyObject with Properties & 0x04 == 0). @@ -426,7 +447,9 @@ public sealed partial class SkyRenderer : IDisposable || obj.TexVelocityX != 0f || obj.TexVelocityY != 0f; uint slot = TextureTableSlot(sub.SurfaceId, needsRepeat); - DrawSubMeshRhi(sub, slot); + bool nightSky = gfxObjId == StarLayerGfxObjId + && (EnhancedNightSkyActive?.Invoke() ?? false); + DrawSubMeshRhi(sub, slot, nightSky); } } } diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs index 1ca73d65..11d417a6 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs @@ -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"] = "b3b544829f2dd85be04b6b16d78a0490e1fe7884590cb541b95756b7d7511620", - ["sky.vert.spv"] = "77176cf33c761ee4e9730357895c941dbf5949d8e0d28e0bb0dcde87f4d30288", + ["sky.frag.spv"] = "8105984072fc1b9075b5efdffd75d959c4087710d354d50a0e00ee0500462ca7", + ["sky.vert.spv"] = "3b51945fa4ff1be1604144df92866bdd47aade22f9dd90267591ef36adb28cde", ["terrain_modern.frag.spv"] = "7b3cdb01b837ed77ee20559a81c1ce5c9d5395300efcc072560ab0be3c5a1af9", ["terrain_modern.vert.spv"] = "9f4cb221ea6aed94a8d23af6cb8e3f3ed96c3cce6e50d135a72d3b55667b1557", ["ui_text.frag.spv"] = "37a281bf80441cb425eaa3ad8e0b3a43cfa21b74b60973ed4201718b9dc102df", diff --git a/tests/AcDream.App.Tests/Rendering/Sky/EnhancedNightSkyRuleTests.cs b/tests/AcDream.App.Tests/Rendering/Sky/EnhancedNightSkyRuleTests.cs new file mode 100644 index 00000000..0909720c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Sky/EnhancedNightSkyRuleTests.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Sky; + +/// +/// Source guards for the enhanced night sky (register IA-26, user-directed +/// 2026-08-23): a render-pack-gated replacement of the DAT star layer +/// (GfxObj 0x010015EF) with sky.frag's procedural view-direction starfield. +/// The retail look must stay the default: everything here is inert unless the +/// Atmospheric pack is the active runtime. The shader itself needs a GPU, so +/// these pin the wiring the way SkyFogRuleTests pins the fog rules. +/// +public sealed class EnhancedNightSkyRuleTests +{ + [Fact] + public void SkyFragmentShaderGatesTheProceduralSkyOnParamA() + { + string frag = File.ReadAllText(Path.Combine(ShaderRoot(), "sky.frag")); + string code = StripLineComments(frag); + + 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); + } + + [Fact] + public void SkyRendererSwapsExactlyTheStarLayerAndOnlyUnderTheProvider() + { + string source = File.ReadAllText(Path.Combine( + RepositoryRoot(), "src", "AcDream.App", "Rendering", "Sky", "SkyRenderer.cs")); + string code = StripLineComments(source); + + Assert.Contains( + "private const uint StarLayerGfxObjId = 0x010015EFu;", + code, + StringComparison.Ordinal); + Assert.Contains( + "bool nightSky = gfxObjId == StarLayerGfxObjId", + code, + StringComparison.Ordinal); + Assert.Contains( + "(EnhancedNightSkyActive?.Invoke() ?? false)", + code, + StringComparison.Ordinal); + } + + [Fact] + public void NightSkyDrawForcesTheAdditivePipeline() + { + string source = File.ReadAllText(Path.Combine( + RepositoryRoot(), "src", "AcDream.App", "Rendering", "Sky", "SkyRenderer.Rhi.cs")); + string code = StripLineComments(source); + + Assert.Contains( + "nightSky || sub.IsAdditive ? _additivePipeline! : _alphaPipeline!", + code, + StringComparison.Ordinal); + Assert.Contains("ParamA = nightSky ? 1f : 0f,", code, StringComparison.Ordinal); + } + + [Fact] + public void CompositionGatesTheNightSkyOnTheAtmosphericPackRuntime() + { + string source = File.ReadAllText(Path.Combine( + RepositoryRoot(), "src", "AcDream.App", "Composition", "FrameRootComposition.cs")); + string code = StripLineComments(source); + + Assert.Contains("EnhancedNightSkyActive = () =>", code, StringComparison.Ordinal); + Assert.Contains( + "nightSkyController.ActiveRuntime?.Descriptor.Id", + code, + StringComparison.Ordinal); + Assert.Contains( + "BuiltInAtmosphericRenderPack.Id", + code, + StringComparison.Ordinal); + } + + private static string StripLineComments(string source) + { + var lines = source.Split('\n'); + for (int i = 0; i < lines.Length; i++) + { + int idx = lines[i].IndexOf("//", StringComparison.Ordinal); + if (idx >= 0) + lines[i] = lines[i][..idx]; + } + return string.Join('\n', lines); + } + + private static string ShaderRoot() => + Path.Combine(RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders"); + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + directory = directory.Parent; + return directory?.FullName + ?? throw new InvalidOperationException("Could not locate repository root."); + } +}