diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 01:53:24 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 01:53:24 +0800 |
| commit | 37953a4b58dfc5f3b8aedf10ad6f1474c2960767 (patch) | |
| tree | 96b1d86ce3cfe33d8d9493ba7a68575f54c4f4b8 | |
| parent | a67e9262caca67f1078f77ed2f274a46e284c6c8 (diff) | |
| download | living-village-37953a4b58dfc5f3b8aedf10ad6f1474c2960767.tar.gz | |
feat(desktop): P18 黄昏暖色真实渲染(加性暖色 veil + 双窗口光照 + 时段标签对齐)
| -rw-r--r-- | docs/维护说明.md | 31 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P16UiTests.fs | 4 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 35 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 8 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/M6Presentation.fs | 44 |
5 files changed, 99 insertions, 23 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md index 3e055c3..14d11f6 100644 --- a/docs/维护说明.md +++ b/docs/维护说明.md @@ -201,6 +201,29 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll 回归:`StartTickResolutionHonoursEvidenceHourThenDaylightThenMidnight`(Desktop 107→108), 实测 `tick`:昼 12.0→2,592,000、黄昏 20.75→4,482,000、夜 0→0,`sample.mp4` 为黄昏 20:45。 以上新文件名(`p17-day-12h-*` / `p17-dusk-20h45-*` / `p17-night-00h-*`)为准,旧名作废。 + (P18 复核确认:这批帧起时虽已正确为 20:45,但画面仍为夜蓝冷调,原因是暖色渲染链缺失, + 见 §P18;P17 黄昏/夜间帧的起时有效、但暖色未生效,最终以 P18 证据为准。) + +- 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` + 一致、`performance_determinism=PASS`。 + +## P18 批验:黄昏暖色调真实渲染整改 + +- 根因(渲染调用链):①`lightingBlend` 黄昏暗化窗口 19:30–22:00,使 20:45 的 `blend=0.5`,`Mode` 直接落到 + Night;②暖色仅经 `worldTintAtWarmth` 的**乘性** tint(R+10 / G−6 / B−26)施加于世界,而乘性通道无法把 + 低 R 的绿色植被染成橙色,地面依旧偏绿;③`timePeriodLabel` 20:00 起即为「夜晚」,与暖色窗口错位。 +- 修复:黄昏**暗化**窗口改为 20:00→22:00(20:45 `blend=0.375`,仍 Day),暖色 bump 独立为 19:15–21:45 + (峰值 20:30);`backgroundAtWarmth` 暖移加强(R+120 / G+20 / B−90),`worldTintAtWarmth`(R+70 / G−10 / + B−75);新增纯函数 `goldenVeilAlpha`(0..150,峰 150),`Game.DrawWorldView` 在世界与角色绘制后叠加 + **预乘暖色整屏 veil**(`premultiply 255 150 70 veilAlpha`,预乘按自身 alpha 缩放、不会饱和成白块), + 补上「加性暖光」这一缺失环节;`LanternGlow = max blend (warmth * 0.8)` 让黄昏灯笼同步暖亮; + `timePeriodLabel` 黄昏延到 21:00。 +- 回归:更新光照锚点/单调/确定性用例并新增 veil 锚点(正午/午夜 0、20:30 峰 150、19:00 前 0), + Desktop 108 / Kernel 89(Kernel 未动)。 +- 证据:`/tmp/opencode/lv-p18-art/`(`p18-dusk-20h45-0{1,2}.png`、`p18-night-deep-01.png`、 + `p18-before-old-dusk-blue.png` vs `p18-after-new-dusk-warm.png`、`sample-dusk.mp4` 13.2 s)。 + 像素复验:旧黄昏画面均值 (55,81,54)(R−B≈1,冷蓝)→ 新黄昏 (153,115,57)(R−B≈95,暖橙),深夜 + (34,60,56) 仍冷;`check-no-white-blocks.py` 0 命中。 - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 一致、`performance_determinism=PASS`。 @@ -242,6 +265,12 @@ for pair in day:12.0 dusk:20.75 night:0.0; do name=${pair%%:*}; hour=${pair##*:} LV_AUTOPLAY_START_HOUR=$hour LV_AUTOPLAY_RECORD=1 \ LV_RECORD_DIR=/tmp/opencode/lv-p17-art/$name LV_RECORD_EVERY=2 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done +# P18 黄昏暖色取证(20:45 真实帧;深夜把 20.75 换成 0) +timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ + LV_AUTOPLAY_START_HOUR=20.75 LV_AUTOPLAY_RECORD=1 \ + LV_RECORD_DIR=/tmp/opencode/lv-p18-art/dusk LV_RECORD_EVERY=2 \ + dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll + # 街边细节图集再生成(确定性,二次运行 byte-identical) python3 scripts/make-jiangnan-art.py @@ -275,6 +304,8 @@ python3 scripts/check-no-white-blocks.py evidence/night-run - `/tmp/opencode/lv-p13/evidence/`(P13:`walk-idle-frames.png`、`water-3phase.png`、 `smoke-3phase.png`、`water-2frame.png`、`smoke-2frame.png`、`day-panorama.png`、 `night-lantern.png`、`day-night-compare.png`、`walk-real-run.png`、`day-run/`、`night-run/`) +- `/tmp/opencode/lv-p18-art/`(P18:`p18-dusk-20h45-0{1,2}.png`、`p18-night-deep-01.png`、 + `p18-before-old-dusk-blue.png`/`p18-after-new-dusk-warm.png`、`sample-dusk.mp4`;同一份拷入 `evidence/`) - `/tmp/opencode/lv-p17-art/`(P17:`p17-day-12h-0{1,2}.png`、`p17-dusk-20h45-0{1,2}.png`、 `p17-night-00h-0{1,2}.png`、`p17-diff-before-dawn.png`/`p17-diff-after-dawn.png`、`sample.mp4`; 同一份拷入 `evidence/`) diff --git a/src/LivingVillage.Desktop.Tests/P16UiTests.fs b/src/LivingVillage.Desktop.Tests/P16UiTests.fs index b071dfa..2e474ac 100644 --- a/src/LivingVillage.Desktop.Tests/P16UiTests.fs +++ b/src/LivingVillage.Desktop.Tests/P16UiTests.fs @@ -111,6 +111,10 @@ type P16HudTests () = Assert.AreEqual<string>("夜晚", M6Presentation.timePeriodLabel 0L) Assert.AreEqual<string>("清晨", M6Presentation.timePeriodLabel (6L * ticksPerHour)) Assert.AreEqual<string>("黄昏", M6Presentation.timePeriodLabel (19L * ticksPerHour)) + // P18: 黄昏 covers the golden hour through 20:59; 21:00 onwards is 夜晚. + Assert.AreEqual<string>("黄昏", M6Presentation.timePeriodLabel (20L * ticksPerHour)) + Assert.AreEqual<string>("黄昏", M6Presentation.timePeriodLabel (int64 20 * ticksPerHour + ticksPerHour * 3L / 4L)) + Assert.AreEqual<string>("夜晚", M6Presentation.timePeriodLabel (21L * ticksPerHour)) [<TestMethod>] member _.HudNightPaletteLeansCoolAndLabelsAreAtlasBacked () = diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index b250824..895dff8 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -865,37 +865,54 @@ type PrototypeTests () = Assert.AreEqual<float32>(0.5f, (M6Presentation.lightingAtTick dawnMidTick).Blend) Assert.AreEqual<float32>(0.0f, (M6Presentation.lightingAtTick dawnEndTick).Blend) - // Dusk ramp 19:30 -> 22:00 goes day -> night monotonically. - let duskStartTick = int64 19 * ticksPerHour + ticksPerHour / 2L // 19:30 - let duskMidTick = int64 20 * ticksPerHour + ticksPerHour * 3L / 4L // 20:45 + // P18: dusk darkening ramp is 20:00 -> 22:00 (day -> night, monotonic). + let duskStartTick = int64 20 * ticksPerHour // 20:00 + let duskMidTick = int64 21 * ticksPerHour // 21:00 Assert.AreEqual<float32>(0.0f, (M6Presentation.lightingAtTick duskStartTick).Blend) Assert.AreEqual<float32>(0.5f, (M6Presentation.lightingAtTick duskMidTick).Blend) Assert.AreEqual<float32>(1.0f, (atHour 22).Blend) - // Blue hour: the transition lingers past 6:00 and starts before 20:00. + // Blue hour: the dawn lingers past 6:00; dusk stays light while it warms. Assert.IsTrue((atHour 6).Blend > 0.0f && (atHour 6).Blend < 0.5f) - Assert.IsTrue((atHour 20).Blend > 0.0f && (atHour 20).Blend < 0.5f) + let duskGlowTick = int64 20 * ticksPerHour + ticksPerHour / 2L // 20:30 + Assert.IsTrue((M6Presentation.lightingAtTick duskGlowTick).Blend > 0.0f) + Assert.IsTrue((M6Presentation.lightingAtTick duskGlowTick).Blend < 0.5f, "20:30 must still be Day mode for a visible golden dusk") + Assert.AreEqual<M6DayNight>(Day, (M6Presentation.lightingAtTick duskGlowTick).Mode) - // Golden-hour warmth peaks mid-dawn/mid-dusk and vanishes at noon/midnight. + // Golden-hour warmth peaks mid-dawn (5:15) / mid-dusk (20:30) and vanishes at noon/midnight. + let duskWarmthMidTick = int64 20 * ticksPerHour + ticksPerHour / 2L // 20:30 Assert.AreEqual<float32>(0.0f, (atHour 12).SkyWarmth) Assert.AreEqual<float32>(0.0f, (atHour 0).SkyWarmth) Assert.AreEqual<float32>(1.0f, (M6Presentation.lightingAtTick dawnMidTick).SkyWarmth) - Assert.AreEqual<float32>(1.0f, (M6Presentation.lightingAtTick duskMidTick).SkyWarmth) + Assert.AreEqual<float32>(1.0f, (M6Presentation.lightingAtTick duskWarmthMidTick).SkyWarmth) Assert.IsTrue((atHour 5).SkyWarmth > 0.5f) Assert.IsTrue((atHour 21).SkyWarmth > 0.5f) - // The warm shift pushes the sky redder and less blue than the plain blend. + // The warm shift pushes sky and world tint redder and less blue than the plain blend; + // this is what made the old dusk frame still read blue-grey. let warmDawn = atHour 5 + let warmDusk = M6Presentation.lightingAtTick duskGlowTick Assert.IsTrue(warmDawn.Background.R > (M6Presentation.backgroundAtBlend warmDawn.Blend).R) Assert.IsTrue(warmDawn.Background.B < (M6Presentation.backgroundAtBlend warmDawn.Blend).B) + Assert.IsTrue(warmDusk.Background.R > (M6Presentation.backgroundAtBlend warmDusk.Blend).R) + Assert.IsTrue(warmDusk.Background.B < (M6Presentation.backgroundAtBlend warmDusk.Blend).B) + Assert.IsTrue(warmDusk.WorldTint.R > (M6Presentation.worldTintAtBlend warmDusk.Blend).R) + Assert.IsTrue(warmDusk.WorldTint.B < (M6Presentation.worldTintAtBlend warmDusk.Blend).B) + // Additive veil: positive and strongest at the golden peaks, zero at noon/midnight. + Assert.AreEqual<int>(0, M6Presentation.goldenVeilAlpha (int64 12 * ticksPerHour)) + Assert.AreEqual<int>(0, M6Presentation.goldenVeilAlpha 0L) + Assert.AreEqual<int>(150, M6Presentation.goldenVeilAlpha duskWarmthMidTick) + Assert.IsTrue(M6Presentation.goldenVeilAlpha duskGlowTick > 100) + Assert.IsTrue(M6Presentation.goldenVeilAlpha (int64 19 * ticksPerHour) = 0, "veil must start after 19:15, not bleed into afternoon") // Determinism without randomness. let once = atHour 13 let twice = atHour 13 Assert.AreEqual<M6RenderProfile>(once, twice) - // Lantern glow: warm halo only rises with night blend. + // Lantern glow now also lifts through the golden hour, not only at deep night. Assert.AreEqual<float32>(0.0f, (atHour 13).LanternGlow) Assert.AreEqual<float32>(1.0f, (atHour 0).LanternGlow) + Assert.IsTrue(warmDusk.LanternGlow > 0.5f) // Evidence hook: fractional start hours map exactly onto sim ticks. Assert.AreEqual<int64>(int64 20 * ticksPerHour + ticksPerHour * 3L / 4L, M6Presentation.startTickForHour 20.75) diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 765cea8..e48888e 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -893,6 +893,14 @@ type LivingVillageGame() as this = VillageArt.drawGroundShadow spriteBatch pixel camera npc.Pos let spec = VillageArt.npcSpriteSpecAtTarget npc.Id npc.Pos npc.Mind.Target world.Tick VillageArt.drawCharacter spriteBatch artTextures camera npc.Pos spec profile.WorldTint + + // Golden-hour wash: the multiplicative WorldTint can only darken, so a warm + // premultiplied veil is layered over the whole scene. Because rgb is premultiplied + // by its own low alpha it adds warm light without ever saturating to white; alpha + // is a pure tick function, so noon/midnight get none and dawn/dusk peak warm. + let veilAlpha = M6Presentation.goldenVeilAlpha world.Tick + if veilAlpha > 0 then + spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, viewport.Height), VillageArt.premultiply 255 150 70 veilAlpha) spriteBatch.End() // Contextual floating prompt over the unique target; same CJK atlas font path as the HUD. diff --git a/src/LivingVillage.Desktop/M6Presentation.fs b/src/LivingVillage.Desktop/M6Presentation.fs index 752034a..25737d7 100644 --- a/src/LivingVillage.Desktop/M6Presentation.fs +++ b/src/LivingVillage.Desktop/M6Presentation.fs @@ -23,13 +23,17 @@ module M6Presentation = | Day -> "白天" | Night -> "夜晚" - // Transition windows in float hours. Dawn lingers past 6:00 and dusk begins before - // 20:00 so the blue hour is longer and the sky warms before it darkens; the anchors - // still agree with Sim.isNightTick (night >= 22:00 or < 6:00) once fully dark. + // Transition windows in float hours. Dawn lingers past 6:00 so the blue hour is long. + // Dusk keeps two independent ramps: the darkening ramp 20:00 -> 22:00 (still agrees with + // Sim.isNightTick at 22:00), and a wider golden-hour warmth bump peaking at 20:30. Keeping + // them separate is what makes the orange dusk visible: at 20:45 the scene is still only + // ~0.37 dark (Day mode) while warmth is high. let dawnStartHour = 4.0f let dawnEndHour = 6.5f - let duskStartHour = 19.5f + let duskStartHour = 20.0f let duskEndHour = 22.0f + let duskWarmthStartHour = 19.25f + let duskWarmthEndHour = 21.75f /// Continuous light blend: 0 = full day, 1 = full night. Deterministic pure function /// of the simulation tick, parameterised by the dawn/dusk windows above. @@ -53,23 +57,32 @@ module M6Presentation = let mid = (startHour + endHour) * 0.5f let half = (endHour - startHour) * 0.5f 1.0f - abs (hour - mid) / half - max (bump dawnStartHour dawnEndHour) (bump duskStartHour duskEndHour) + max (bump dawnStartHour dawnEndHour) (bump duskWarmthStartHour duskWarmthEndHour) + + /// Additive golden-hour veil alpha (0 at noon/midnight, peaks ~150 at dawn/dusk mid). + /// Multiplicative WorldTint alone cannot warm green foliage, so the renderer layers this + /// premultiplied orange wash over the finished world scene. + let goldenVeilAlpha (tick: int64) : int = + int (150.0f * transitionWarmth tick) let private lerp (a: int) (b: int) (blend: float32) : int = a + int (float32 (b - a) * blend) - /// Blended sky/world colour plus the golden-hour warmth shift (warm channels up, blue down). + /// Blended sky colour plus a strong golden-hour shift: at dusk this reads as a warm orange + /// horizon (red up hard, blue down hard) rather than the old muted blue-grey twilight. let backgroundAtWarmth (blend: float32) (warmth: float32) : Color = Color( - lerp 100 18 blend + int (52.0f * warmth), - lerp 160 25 blend + int (14.0f * warmth), - lerp 210 55 blend - int (34.0f * warmth)) + lerp 100 18 blend + int (120.0f * warmth), + lerp 160 25 blend + int (20.0f * warmth), + lerp 210 55 blend - int (90.0f * warmth)) + /// World tint also warms (red up, green slightly down, blue down) so tiles, props and + /// characters all lean golden before the additive veil is layered on top. let worldTintAtWarmth (blend: float32) (warmth: float32) : Color = Color( - lerp 255 118 blend + int (10.0f * warmth), - lerp 248 136 blend - int (6.0f * warmth), - lerp 224 184 blend - int (26.0f * warmth)) + lerp 255 118 blend + int (70.0f * warmth), + lerp 248 136 blend - int (10.0f * warmth), + lerp 224 184 blend - int (75.0f * warmth)) /// Warmth-free base colours (kept for callers that only have a blend value). let backgroundAtBlend (blend: float32) : Color = backgroundAtWarmth blend 0.0f @@ -83,7 +96,9 @@ module M6Presentation = Background = backgroundAtWarmth blend warmth Blend = blend WorldTint = worldTintAtWarmth blend warmth - LanternGlow = blend + // Lanterns glow through the golden hour too, so dusk reads as warm lanterns at a + // warm sunset rather than only a local night point-light. + LanternGlow = max blend (warmth * 0.8f) SkyWarmth = warmth } let profileAtTick (tick: int64) : M6RenderProfile = lightingAtTick tick @@ -122,10 +137,11 @@ module M6Presentation = sprintf "%02d:%02d" hour minute /// 时段标签:清晨/白天/黄昏/夜晚,与实际 tick 一致(纯函数)。 + /// 黄昏覆盖到 21:00,与 dusk 暖色窗口(峰值 20:30)对齐;22:00 起才是夜晚。 let timePeriodLabel (tick: int64) : string = let hour = hourOf tick if hour < 5 then "夜晚" elif hour < 8 then "清晨" elif hour < 17 then "白天" - elif hour < 20 then "黄昏" + elif hour < 21 then "黄昏" else "夜晚" |
