feat(sky): split SkyRenderer into pre-/post-scene passes + retail -120m weather Z offset

Bug A (foreground rain) from docs/research/2026-04-26-sky-investigation-handoff.md:
rain mesh was only visible at horizon, not in the air between camera and
character. Two retail mechanisms ported here:

1. **Render order split.** Retail's `LScape::draw` at 0x00506330 calls
   `GameSky::Draw(0)` BEFORE the landblock DrawBlock loop and
   `GameSky::Draw(1)` AFTER — i.e. weather meshes render after scene
   geometry so additive rain streaks paint on top of terrain and entities.
   Acdream was rendering both passes pre-scene, so terrain immediately
   painted over the rain.

   Refactored `SkyRenderer.Render` into `RenderSky` (filter !IsWeather)
   and `RenderWeather` (filter IsWeather) sharing a private `RenderPass`
   core that takes a `weatherPass` bool. Partition is per-SkyObject by
   `Properties & 0x04` (the WEATHER_BIT, mirroring tools/WeatherEnumerator).
   Added `SkyObjectData.IsWeather` getter for the partition.

   `GameWindow.OnRender` now calls `RenderSky` before terrain/static-mesh/
   particles (line ~4322) and `RenderWeather` after particles (line ~4368).

2. **Weather Z offset.** Retail `GameSky::UpdatePosition` at 0x00506dd0,
   lines 0x506e96..0x506e98:

       if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0))
           int32_t var_4_1 = 0xc2f00000;   // 0xc2f00000 == -120.0f

   Weather objects (property bit 0x04 set, bit 0x08 unset) get their frame
   origin set to player_pos + (0, 0, -120m). The rain cylinder GfxObjs
   0x01004C42/0x01004C44 have local Z range 0.11..814.90 (815m tall, 113m
   radius). Without the offset the cylinder bottom sat just above the
   camera; with -120m the cylinder spans (camera-119.89)..(camera+694.90)
   so the camera is inside.

   `SkyRenderer.RenderPass` applies the -120m model translation when
   `weatherPass` is true (line ~253-254).

3. **Legacy camera-attached emitter gated.** `UpdateWeatherParticles` —
   the pre-research workaround that emitted camera-attached rain particles
   (broken alpha fade, fixed disk around camera) — is now gated behind
   `ACDREAM_FAKE_RAIN_PARTICLES=1`. Default off; the retail-faithful
   world-space mesh is the default path.

User-verified: rain is now visible in foreground from many perspectives,
but the cylinder's open-top rim is still visible when looking straight up.
That rim issue is a separate brightness-excess bug filed for follow-up
(Translucency float not plumbed to shader; surface.Translucency=0.5 ignored
so streaks render at 2× retail intensity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-27 08:49:42 +02:00
parent 9567597814
commit 3e0da496e0
3 changed files with 130 additions and 5 deletions

View file

@ -4232,7 +4232,17 @@ public sealed class GameWindow : IDisposable
// Update the rain/snow particle emitters when the weather kind // Update the rain/snow particle emitters when the weather kind
// changes. Keep the emitters fed by the ParticleSystem tick so // changes. Keep the emitters fed by the ParticleSystem tick so
// visuals stay alive frame-over-frame. // visuals stay alive frame-over-frame.
UpdateWeatherParticles(atmo); //
// Bug A note (2026-04-26): retail rain is the world-space mesh
// 0x01004C42/0x01004C44 rendered in SkyRenderer.RenderWeather
// AFTER the scene — see docs/research/2026-04-26-sky-investigation-handoff.md.
// The camera-attached emitter path here is acdream's old
// pre-research workaround (broken alpha fade, fixed disk around
// camera). It's kept gated behind ACDREAM_FAKE_RAIN_PARTICLES=1
// so the retail-faithful path can be A/B-tested against it
// without uninstalling the legacy code outright.
if (System.Environment.GetEnvironmentVariable("ACDREAM_FAKE_RAIN_PARTICLES") == "1")
UpdateWeatherParticles(atmo);
// Phase E.3: advance live particle emitters AFTER animation tick // Phase E.3: advance live particle emitters AFTER animation tick
// so emitters spawned by hooks fired this frame get integrated. // so emitters spawned by hooks fired this frame get integrated.
@ -4299,9 +4309,17 @@ public sealed class GameWindow : IDisposable
// celestial meshes FIRST so the rest of the scene z-tests // celestial meshes FIRST so the rest of the scene z-tests
// on top of them (depth mask off, no depth writes). Skipped // on top of them (depth mask off, no depth writes). Skipped
// when indoors; dungeons fully block sky visibility. // when indoors; dungeons fully block sky visibility.
//
// Mirrors retail's LScape::draw at 0x00506330 which calls
// GameSky::Draw(0) (sky pass) BEFORE the landblock DrawBlock
// loop and GameSky::Draw(1) (weather pass) AFTER. The split
// matters because weather meshes (the 815m-tall rain
// cylinder 0x01004C42/0x01004C44) need to overlay terrain
// and entities to look volumetric — see the post-scene
// RenderWeather call further below.
if (!cameraInsideCell) if (!cameraInsideCell)
{ {
_skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction, _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf); _activeDayGroup, kf);
} }
@ -4337,6 +4355,20 @@ public sealed class GameWindow : IDisposable
if (_particleSystem is not null && _particleRenderer is not null) if (_particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(_particleSystem, camera, camPos); _particleRenderer.Draw(_particleSystem, camera, camPos);
// Bug A fix (post-#26 worktree, 2026-04-26): weather sky
// meshes (Properties & 0x04, e.g. the 815m-tall rain
// cylinder 0x01004C42/0x01004C44) render AFTER the scene so
// the additive rain streaks overlay terrain and entities
// instead of being painted over by them. This is the second
// half of retail's LScape::draw split — GameSky::Draw(1)
// fires after the DrawBlock loop. Same indoor gate as the
// sky pass: weather is suppressed inside cells.
if (!cameraInsideCell)
{
_skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf);
}
// Debug: draw collision shapes as wireframe cylinders around the // Debug: draw collision shapes as wireframe cylinders around the
// player so we can visually verify alignment with scenery meshes. // player so we can visually verify alignment with scenery meshes.
if (_debugCollisionVisible && _debugLines is not null) if (_debugCollisionVisible && _debugLines is not null)

View file

@ -70,8 +70,18 @@ public sealed unsafe class SkyRenderer : IDisposable
} }
/// <summary> /// <summary>
/// Draw the sky for this frame. Called FIRST in the render loop — /// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds —
/// terrain / meshes / debug lines / overlay land on top. /// every <c>SkyObject</c> with <c>Properties &amp; 0x04 == 0</c>).
/// Called BEFORE the scene; terrain / meshes / debug lines / overlay
/// land on top via depth-test.
///
/// <para>
/// Mirrors the first half of retail's <c>LScape::draw</c> at
/// <c>0x00506330</c>: that function calls <c>GameSky::Draw(0)</c>
/// (sky pass) before the landblock loop, then <c>GameSky::Draw(1)</c>
/// (weather pass) after. acdream splits the same way — see
/// <see cref="RenderWeather"/> for the post-scene companion.
/// </para>
/// ///
/// <para> /// <para>
/// Each submesh renders with retail's per-vertex lighting formula: /// Each submesh renders with retail's per-vertex lighting formula:
@ -91,12 +101,50 @@ public sealed unsafe class SkyRenderer : IDisposable
/// field. /// field.
/// </para> /// </para>
/// </summary> /// </summary>
public void Render( public void RenderSky(
ICamera camera, ICamera camera,
Vector3 cameraWorldPos, Vector3 cameraWorldPos,
float dayFraction, float dayFraction,
DayGroupData? group, DayGroupData? group,
SkyKeyframe keyframe) SkyKeyframe keyframe)
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, weatherPass: false);
/// <summary>
/// Draw the WEATHER sky objects (the foreground rain mesh
/// <c>0x01004C42</c>/<c>0x01004C44</c> on Rainy DayGroups, plus the
/// per-storm 5cm flash dummies — every <c>SkyObject</c> with
/// <c>Properties &amp; 0x04 != 0</c>). Called AFTER the scene so the
/// rain meshes paint on top of terrain and entities — that's the
/// retail-faithful order from <c>LScape::draw</c> at
/// <c>0x00506330</c>, where <c>GameSky::Draw(1)</c> fires after the
/// <c>DrawBlock</c> loop. With depth-test disabled and additive blend
/// (the rain Surface flag <c>0x080000C5</c> includes Additive), the
/// 815m-tall rain cylinder's bright streak texels add over the scene
/// — making rain appear in the air between camera and character
/// instead of only at the horizon.
/// </summary>
public void RenderWeather(
ICamera camera,
Vector3 cameraWorldPos,
float dayFraction,
DayGroupData? group,
SkyKeyframe keyframe)
=> RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, weatherPass: true);
/// <summary>
/// Shared pass for <see cref="RenderSky"/> and <see cref="RenderWeather"/>.
/// Sets up the same GL state for both (depth-test off, additive +
/// alpha-blend per submesh, camera-anchored translation) and iterates
/// only the SkyObjects matching the requested partition by
/// <see cref="SkyObjectData.IsWeather"/>.
/// </summary>
private void RenderPass(
ICamera camera,
Vector3 cameraWorldPos,
float dayFraction,
DayGroupData? group,
SkyKeyframe keyframe,
bool weatherPass)
{ {
if (group is null || group.SkyObjects.Count == 0) return; if (group is null || group.SkyObjects.Count == 0) return;
@ -149,6 +197,14 @@ public sealed unsafe class SkyRenderer : IDisposable
for (int i = 0; i < group.SkyObjects.Count; i++) for (int i = 0; i < group.SkyObjects.Count; i++)
{ {
var obj = group.SkyObjects[i]; var obj = group.SkyObjects[i];
// Partition by weather flag — the caller chose either the
// pre-scene sky pass (non-weather) or the post-scene weather
// pass (weather only). Mirrors retail GameSky::Draw at
// 0x00506ff0 where arg2==0 iterates non-weather sky_obj
// entries (filtered by property bit 0x04 == 0 inside the
// loop) and arg2==1 draws after_sky_cell which only contains
// weather objects.
if (obj.IsWeather != weatherPass) continue;
if (!obj.IsVisible(dayFraction)) continue; if (!obj.IsVisible(dayFraction)) continue;
// Apply per-keyframe replace overrides. // Apply per-keyframe replace overrides.
@ -177,6 +233,26 @@ public sealed unsafe class SkyRenderer : IDisposable
* Matrix4x4.CreateRotationZ(-headingRad) * Matrix4x4.CreateRotationZ(-headingRad)
* Matrix4x4.CreateRotationY(-rotationRad); * Matrix4x4.CreateRotationY(-rotationRad);
// Retail weather Z-offset (GameSky::UpdatePosition at
// 0x00506dd0, decomp lines 0x506e96..0x506e98):
//
// if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0))
// int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f
//
// Weather objects (property bit 0x04 set, bit 0x08 unset)
// have their frame origin set to player_pos + (0, 0, -120m).
// The rain cylinder GfxObjs 0x01004C42/0x01004C44 have local
// Z range 0.11..814.90 (815m tall, 113m radius). Without the
// offset the cylinder bottom sits at z=0.11 ABOVE the camera
// (skyView translation is zeroed so model-origin == camera);
// looking horizontally shows nothing, looking up shows a
// distant cylinder. With -120m the cylinder spans z =
// (camera-119.89)..(camera+694.90) in view space — camera
// is inside, looking in any direction shows surrounding
// walls — the volumetric foreground-rain look retail has.
if (weatherPass)
model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f);
_shader.SetMatrix4("uModel", model); _shader.SetMatrix4("uModel", model);
// UV scroll accumulates real-time × velocity. Wrap to [0, 1] // UV scroll accumulates real-time × velocity. Wrap to [0, 1]

View file

@ -36,6 +36,23 @@ public sealed class SkyObjectData
public uint GfxObjId; public uint GfxObjId;
public uint Properties; public uint Properties;
/// <summary>
/// True when this SkyObject is flagged as weather (Properties bit
/// <c>0x04</c>). Per the named retail decomp,
/// <c>GameSky::CreateDeletePhysicsObjects</c> at <c>0x005073c0</c>
/// passes <c>Properties &amp; 0x04</c> as <c>arg5</c> of
/// <c>GameSky::MakeObject</c> (<c>0x00506ee0</c>) — when set, the
/// CPhysicsObj is added to <c>after_sky_cell</c> instead of
/// <c>before_sky_cell</c>, and <c>GameSky::Draw(arg2=1)</c> at
/// <c>0x00506ff0</c> draws that cell <i>after</i> the scene. acdream
/// uses this flag to split the sky pass: non-weather objects render
/// pre-scene (so terrain and entities z-test on top), weather meshes
/// (e.g. the 815m-tall rain cylinders <c>0x01004C42</c>/<c>0x01004C44</c>)
/// render post-scene with depth-test off so they overlay foreground
/// geometry — matching retail's volumetric foreground-rain look.
/// </summary>
public bool IsWeather => (Properties & 0x04u) != 0u;
/// <summary>Object is visible at day-fraction <paramref name="t"/> /// <summary>Object is visible at day-fraction <paramref name="t"/>
/// by retail's begin/end semantics (r12 §2). Three cases: /// by retail's begin/end semantics (r12 §2). Three cases:
/// <list type="bullet"> /// <list type="bullet">