refactor(render): extract world frame preparation

Move camera/root resolution, live settings and listener preview, sky/lighting/fog preparation, animated classification, and building visibility scratch behind a typed WorldRenderFrameBuilder while preserving retail frame order and borrowed lifetimes.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 05:54:00 +02:00
parent bc6f09f987
commit 6d6e5b5fa5
8 changed files with 1463 additions and 583 deletions

View file

@ -16,8 +16,6 @@ public sealed class GameWindow : IDisposable
System.Diagnostics.Stopwatch.GetTimestamp()
/ (double)System.Diagnostics.Stopwatch.Frequency;
private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene);
private readonly AcDream.App.RuntimeOptions _options;
private readonly AnimationPresentationDiagnostics _animationDiagnostics;
private readonly string _datDir;
@ -108,6 +106,9 @@ public sealed class GameWindow : IDisposable
_renderFrameLivePreparation;
private AcDream.App.Rendering.RenderWeatherFrameController?
_renderWeatherFrame;
private AcDream.App.Rendering.WorldRenderFrameBuilder?
_worldRenderFrameBuilder;
private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame;
private ResourceShutdownTransaction? _shutdown;
private readonly DisplayFramePacingController _displayFramePacing;
@ -126,8 +127,18 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Streaming.LocalPlayerTeleportController?
_localPlayerTeleport;
private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback)
private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9)
private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25)
private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange =
new(nearRadius: 4, farRadius: 12);
private int _nearRadius
{
get => _renderRange.NearRadius;
set => _renderRange.NearRadius = value;
}
private int _farRadius
{
get => _renderRange.FarRadius;
set => _renderRange.FarRadius = value;
}
// A.5 T22.5: resolved quality settings (preset + env-var overrides).
// Set once in OnLoad after LoadAndApplyPersistedSettings(); re-set on
// ReapplyQualityPreset(). Default matches QualityPreset.High so the field
@ -226,12 +237,8 @@ public sealed class GameWindow : IDisposable
// U.4 replaces the NoClip() frame with one built from the portal-visibility result.
private ClipFrame? _clipFrame;
// Phase 3 (render unification, 2026-06-07): the synthetic outdoor cell node — the outdoor
// world as a flood-graph cell (spec 2026-06-07-render-unification-outdoor-as-cell). Rebuilt
// each outdoor frame from nearby building-entrance portals. ADDITIVE for now (built but not
// yet rooted; the clipRoot flip + OutsideView terrain integration is the cutover step).
private LoadedCell? _outdoorNode;
private readonly List<LoadedCell> _outdoorNodeBuildingCells = new();
// PView pass-local particle classifications move with the pass executor in
// Checkpoint E; GameWindow still owns them until that cutover.
private readonly HashSet<uint> _outdoorSceneParticleEntityIds = new();
private readonly HashSet<uint> _visibleSceneParticleEntityIds = new();
private readonly HashSet<uint> _noSceneParticleEntityIds = new();
@ -248,13 +255,6 @@ public sealed class GameWindow : IDisposable
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animatedEntities;
private readonly AcDream.App.World.LiveEntityRuntimeSlot _liveEntityRuntimeSlot = new();
// MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys.
// WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically
// (`animatedEntityIds is null || animatedEntityIds.Count == 0`), so this can
// always be a live (possibly empty) HashSet instead of `new`ing one every
// frame or passing null.
private readonly HashSet<uint> _animatedIdsScratch = new();
/// <summary>
/// Tier 1 cache (#53): per-entity classification results for static
/// entities (those NOT in <see cref="_animatedEntities"/>). Conceptually
@ -298,35 +298,6 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new();
// Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but
// never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime.
// Keep the experimental path available for DAT archaeology only.
// Backed by ACDREAM_ENABLE_SKY_PES via RuntimeOptions.EnableSkyPesDebug.
// Diagnostic: hide a specific humanoid part (>=10 parts) at render.
// Backed by ACDREAM_HIDE_PART via RuntimeOptions.HidePartIndex.
// Issue #47 — use retail's close-detail GfxObj selection on
// humanoid setups. When enabled, every per-part GfxObj id (after
// server AnimPartChanges are applied) is replaced with Degrades[0]
// from its DIDDegrade table when present. See GfxObjDegradeResolver
// for the full retail-decomp citation. Default-on after visual
// confirmation; set ACDREAM_RETAIL_CLOSE_DEGRADES=0 only for
// diagnostic before/after comparisons.
// Backed by ACDREAM_RETAIL_CLOSE_DEGRADES via RuntimeOptions.RetailCloseDegrades.
// Issue #48 diagnostic — dump per-scenery-spawn placement evidence
// (rendered gfx id, sample source physics-vs-bilinear, ground/baseLoc/finalZ,
// mesh vertex Z range, DIDDegrade slot 0). One log line per spawn lets
// the user identify a floating tree by its world coordinates and tell
// whether the cause is BaseLoc.Z addition (H1), bilinear-fallback drift
// (H2), or DIDDegrade selection (H3). Diagnostic-first per CLAUDE.md.
// Backed by ACDREAM_DUMP_SCENERY_Z via RuntimeOptions.DumpSceneryZ.
/// <summary>
private readonly HashSet<SkyPesKey> _activeSkyPes = new();
private readonly HashSet<SkyPesKey> _missingSkyPes = new();
// Remote-entity motion inference: tracks when each remote entity last
// moved meaningfully. Used in TickAnimations to swap to Ready when
// position has stalled for >StopIdleMs — retail observer pattern per
@ -467,21 +438,6 @@ public sealed class GameWindow : IDisposable
AcDream.Core.World.SkyStateProvider.Default());
public readonly AcDream.Core.Lighting.LightManager Lighting = new();
// A7.L1 (2026-07-09) — last frame's rendered visible-cell set, fed into next
// frame's Lighting.BuildPointLightSnapshot scoping. One frame of latency
// (~16 ms) instead of a mid-DrawInside callback: DrawableCells only becomes
// known partway through DrawInside (after the flood + look-in merge), and the
// #176 correction already showed that rebuilding the light pool FROM a
// freshly-recomputed frame flood (the deleted RebuildScopedLights callback,
// c500912b) is a flicker mechanism. Reusing last frame's already-drawn set
// (MP-Alloc: reused HashSet, no per-frame alloc) decouples the light-scoping
// change from DrawInside's internals entirely. _lightPoolVisibleCellsValid is
// false until the first indoor frame completes, and is cleared on any
// outdoor-only frame so scoping fails open (unscoped) for one frame on
// re-entry rather than filtering by a stale dungeon's cell ids.
private readonly HashSet<uint> _lightPoolVisibleCells = new();
private bool _lightPoolVisibleCellsValid;
public readonly AcDream.Core.World.WeatherSystem Weather = new();
// Wired into the hook router in OnLoad so SetLightHook fires
// from the animation pipeline flip the matching LightSource.IsLit.
@ -2890,6 +2846,52 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Rendering.RenderWeatherFrameController(
WorldTime,
Weather);
_skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(
_scriptRunner!,
_particleSink!,
_effectPoses,
_entityEffects);
var worldFrameEnvironment =
new AcDream.App.Rendering.RuntimeWorldFrameEnvironmentPreparation(
_options,
WorldTime,
Lighting,
_wbDrawDispatcher,
_envCellRenderer,
_sceneLightingUbo,
_renderRange,
_skyPesFrame);
_worldRenderFrameBuilder =
new AcDream.App.Rendering.WorldRenderFrameBuilder(
new AcDream.App.Rendering.RuntimeWorldFrameCameraSource(
_cameraController!,
_localPlayerTeleport),
new AcDream.App.Rendering.RuntimeWorldFrameVisibilityPreparation(
_retailSelectionScene,
_particleVisibility,
_terrain,
_worldReveal,
_envCellFrustum),
new AcDream.App.Rendering.RuntimeWorldFrameSettingsPreview(
_settingsVm,
_audioEngine,
_cameraController!,
_displayFramePacing),
new AcDream.App.Rendering.RuntimeWorldFrameRootSource(
_physicsEngine,
_cellVisibility,
_localPlayerMode,
_chaseCameraInput,
_playerControllerSlot,
_liveWorldOrigin),
worldFrameEnvironment,
new AcDream.App.Rendering.RuntimeWorldFrameAnimatedEntitySource(
_animatedEntities,
_staticAnimationScheduler,
_equippedChildRenderer),
new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource(
_landblockPresentationPipeline!,
_cellVisibility));
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame,
_worldState,
@ -3268,115 +3270,8 @@ public sealed class GameWindow : IDisposable
session.SendTurbineChatTo(
roomId, chatType, dispatchType, senderGuid, text, cookie),
Log: Console.WriteLine);
private void UpdateSkyPes(
float dayFraction,
AcDream.Core.World.DayGroupData? dayGroup,
System.Numerics.Vector3 cameraWorldPos,
bool suppressSky)
{
if (_scriptRunner is null || _particleSink is null)
return;
var seen = new HashSet<SkyPesKey>();
if (!suppressSky && dayGroup is not null)
{
for (int i = 0; i < dayGroup.SkyObjects.Count; i++)
{
var obj = dayGroup.SkyObjects[i];
if (obj.PesObjectId == 0 || !obj.IsVisible(dayFraction))
continue;
var key = new SkyPesKey(i, obj.PesObjectId, obj.IsPostScene);
seen.Add(key);
uint skyEntityId = SkyPesEntityId(key);
var renderPass = obj.IsPostScene
? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene
: AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene;
_particleSink.SetEntityRenderPass(skyEntityId, renderPass);
var anchor = SkyPesAnchor(obj, cameraWorldPos);
var rotation = SkyPesRotation(obj, dayFraction);
_effectPoses.Publish(
skyEntityId,
System.Numerics.Matrix4x4.CreateFromQuaternion(rotation)
* System.Numerics.Matrix4x4.CreateTranslation(anchor),
System.Array.Empty<System.Numerics.Matrix4x4>(),
cellId: 0u);
if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key))
continue;
_entityEffects?.RegisterSyntheticOwner(skyEntityId);
if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor))
{
_activeSkyPes.Add(key);
}
else
{
_missingSkyPes.Add(key);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.ClearEntityRenderPass(skyEntityId);
_effectPoses.Remove(skyEntityId);
}
}
}
foreach (var key in _activeSkyPes.ToArray())
{
if (seen.Contains(key))
continue;
uint skyEntityId = SkyPesEntityId(key);
_scriptRunner.StopAllForEntity(skyEntityId);
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
_particleSink.StopAllForEntity(skyEntityId, fadeOut: true);
_effectPoses.Remove(skyEntityId);
_activeSkyPes.Remove(key);
}
foreach (var key in _missingSkyPes.ToArray())
{
if (!seen.Contains(key))
_missingSkyPes.Remove(key);
}
}
private static uint SkyPesEntityId(SkyPesKey key)
{
// 0xF0000000 prefix marks synthetic sky-PES entityIds (no real
// server GUID lives in the 0xFxxxxxxx space). Reserve bit
// 0x08000000 for the pre/post-scene flag and the lower 27 bits
// for the object index — keeps the post-scene flag from sliding
// into the index range if a future DayGroup ever ships >65k sky
// objects (current Dereth max is 18, but the constraint is free).
uint postBit = key.PostScene ? 0x08000000u : 0u;
return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu);
}
internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) =>
entity.Id;
private static System.Numerics.Vector3 SkyPesAnchor(
AcDream.Core.World.SkyObjectData obj,
System.Numerics.Vector3 cameraWorldPos)
{
if (obj.IsWeather && (obj.Properties & 0x08u) == 0u)
return cameraWorldPos + new System.Numerics.Vector3(0f, 0f, -120f);
return cameraWorldPos;
}
private static System.Numerics.Quaternion SkyPesRotation(
AcDream.Core.World.SkyObjectData obj,
float dayFraction)
{
float rotationRad = obj.CurrentAngle(dayFraction) * (MathF.PI / 180f);
return System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitY,
-rotationRad);
}
/// <summary>
/// Phase 5d — retail <c>AdminEnvirons</c> (0xEA60) dispatcher.
/// Routes fog presets into the weather system's sticky override
@ -3522,280 +3417,34 @@ public sealed class GameWindow : IDisposable
if (_cameraController is not null && !portalViewportVisible)
{
_retailAlphaQueue.BeginFrame();
var activeCamera = _cameraController.Active;
var camera = _localPlayerTeleport?.ApplyViewPlane(activeCamera)
?? activeCamera;
var worldProjection = camera.Projection;
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
_retailSelectionScene?.SetViewFrustum(frustum);
// Extract camera world position from the inverse of the view
// matrix — needed by the scene-lighting UBO (for fog distance)
// and by the sky renderer (for the camera-centered sky dome).
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
_particleVisibility.BeginFrame(camPos);
_terrain?.BeginVisibilityFrame();
if (!IsLiveModeWaitingForLogin)
{
_particleVisibility.UseWorldView();
_worldReveal?.ObserveWorldViewportVisible();
_worldReveal?.Complete();
}
// L.0 Audio tab: push the SettingsVM's live AudioDraft into the
// engine each frame, so volume sliders preview audibly while
// the user drags. Cancel reverts the draft and the engine
// catches up on the very next frame; Save persists to
// settings.json without changing engine state (already
// applied). Cheap enough to run unconditionally on every
// tick — four float assignments.
if (_audioEngine is not null && _audioEngine.IsAvailable && _settingsVm is not null)
{
var a = _settingsVm.AudioDraft;
_audioEngine.MasterVolume = a.Master;
_audioEngine.MusicVolume = a.Music;
_audioEngine.SfxVolume = a.Sfx;
_audioEngine.AmbientVolume = a.Ambient;
}
// L.0 Display tab: push the live DisplayDraft into the
// active rendering surfaces each frame. FOV is the live-
// preview slider per the brainstorm — dragging it changes
// camera FovY immediately. VSync change-detected to avoid
// spamming the window. Resolution + Fullscreen apply on
// Save (handled by ApplyDisplayWindowState — too jarring
// to live-preview a resize).
if (_settingsVm is not null && _cameraController is not null)
{
var d = _settingsVm.DisplayDraft;
float fovYRad = d.FieldOfView * (MathF.PI / 180f);
_cameraController.Orbit.FovY = fovYRad;
_cameraController.Fly.FovY = fovYRad;
if (_cameraController.Chase is not null)
_cameraController.Chase.FovY = fovYRad;
_displayFramePacing.ApplyPreference(d.VSync);
}
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
// correctly relative to where we're looking.
if (_audioEngine is not null && _audioEngine.IsAvailable)
{
var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33);
var up = new System.Numerics.Vector3( invView.M21, invView.M22, invView.M23);
_audioEngine.SetListener(
camPos.X, camPos.Y, camPos.Z,
fwd.X, fwd.Y, fwd.Z,
up.X, up.Y, up.Z);
}
// Step 4: portal visibility — compute BEFORE the UBO upload so
// the indoor flag drives the sun's intensity to zero for
// dungeons (r13 §13.7).
// Phase W single-viewpoint V1 (2026-06-03): the render keys on ONE viewpoint — the
// collided camera ("viewer") — exactly like retail (RenderNormalMode @ 0x453aa0 →
// DrawInside(viewer_cell) pc:92675; InitCell side-test vs viewer.viewpoint pc:432991).
// The viewer cell is the camera-collision sweep's swept cell
// (RetailChaseCamera.ViewerCellId = retail viewer_cell = sphere_path.curr_cell):
// graph-tracked, deterministic, NO AABB / NO grace frames — so the U.4c flap source
// (stale FindCameraCell over grace frames) is gone WITHOUT splitting viewpoints.
// SEPARATELY, lighting / seen_outside key on the PLAYER cell (CurrCell), matching retail
// CellManager::ChangePosition @ 0x4559B0 — the player's cell, not the camera's, decides
// whether the sun dies (sealed interior). retail player->cell (physics/lighting) vs
// SmartBox->viewer_cell (render); the old per-render player-root + eye-projection split is gone.
// ── Lighting root: the PLAYER cell (CurrCell). ──
LoadedCell? playerRoot = null;
if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell playerCellObj
&& _cellVisibility.TryGetCell(playerCellObj.Id, out var playerRegCell))
playerRoot = playerRegCell;
bool playerSeenOutside = playerRoot?.SeenOutside ?? true;
// ── Render root: the VIEWER (collided camera) cell + eye. ──
// Default (player mode + retail chase cam): the sweep's viewer cell. Fallback for the
// non-default legacy/debug camera paths: the player's registered cell (or none).
uint viewerCellId =
(_playerMode && _retailChaseCamera is not null
&& AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera)
? _retailChaseCamera.ViewerCellId
: (playerRoot?.CellId ?? 0u);
var viewerEyePos = camPos; // the collided eye drives the projection
var playerViewPos = _playerController?.RenderPosition
?? _playerController?.Position
?? camPos;
LoadedCell? viewerRoot = null;
if (viewerCellId != 0u && _cellVisibility.TryGetCell(viewerCellId, out var viewerRegCell))
viewerRoot = viewerRegCell;
// T4 (BR-6): the per-frame ACME BFS (ComputeVisibilityFromRoot) is
// DELETED from the frame — it ran a full second visibility
// computation whose only production consumer was this boolean,
// which is exactly "the viewer root resolved to a loaded interior
// cell" (TryGetCell above already proves cells are loaded). The
// PView flood is the ONE visibility gate (feedback_render_one_gate).
bool cameraInsideCell = viewerRoot is not null;
// Retail render routing is owned by the collided camera/viewer cell.
// The player cell still owns lighting state, but it must not force an
// indoor draw while the camera is outside; that drops the outdoor pass
// and leaves clear color around a floating doorway slice.
bool rootSeenOutside = viewerRoot?.SeenOutside ?? true;
// Phase U.4 (2026-05-30): the [vis] probe moved DOWN to the unified
// gated-draw block (after envCellViewProj exists) where it can report
// the real PortalVisibilityFrame — OutsideView polygon/plane counts and
// per-cell slot plane counts — via RenderingDiagnostics.EmitVis, instead
// of the old camera-state-only spike. See the U.4 ClipFrame assembly
// below (gated on ACDREAM_PROBE_VIS=1, cell-change-throttled).
// Stage 3 (2026-06-02): replace the IsInsideAnyCell AABB scan with the
// seen_outside-derived predicate. Retail CellManager::ChangePosition (0x004559B0)
// gates sun/lighting off seen_outside on the player's current cell, NOT off an
// independent AABB containment scan. playerInsideCell = true (kill sunlight) only
// when the player is inside a SEALED interior (seen_outside=false = dungeon).
// Building interiors with seen_outside=true keep the sun (sky visible through door).
// V1 (2026-06-03): keyed on the PLAYER cell (playerRoot/playerSeenOutside), independent
// of the camera's viewer cell — retail kills the sun off the player's cell, not the eye.
bool playerInsideCell = playerRoot is not null && !playerSeenOutside;
// Phase C.1: tick retail PhysicsScript particle hooks. Named
// retail decomp confirms SkyObject.PesObjectId is copied by
// SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is
// debug-only and disabled for normal retail rendering.
if (_options.EnableSkyPesDebug)
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
// the scene-lighting UBO once per frame. Every shader that
// consumes binding=1 reads the same data for the rest of the
// frame — terrain, static mesh, instanced mesh, sky.
UpdateSunFromSky(kf, playerInsideCell);
// A7 indoor lighting: position retail's viewer fill light at the player
// (SmartBox::set_viewer 0x00452c40) before the snapshot is built. It is
// the primary interior fill (no sun indoors) and is indoor-only via the
// AP-43 gate. playerViewPos is the player render position (or camPos when
// there is no player), matching retail's player/viewer branch.
Lighting.UpdateViewerLight(playerViewPos);
Lighting.Tick(camPos);
// Fix B (A7 #3): build this frame's point-light snapshot and hand it to
// the entity dispatcher for per-OBJECT light selection
// (minimize_object_lighting). Replaces the single global nearest-8-to-
// camera UBO set for point/spot lights so a wall's torches stay tied to
// the wall as the camera moves. The SUN + ambient still flow through the
// SceneLighting UBO built below (binding=1) — terrain/sky read those.
// #176 root cause: the pool is anchored at the PLAYER (retail
// Render::insert_light sorts by Render::player_pos, 0x0054d1b0) and
// collected from ALL registered (=resident-cell) lights — it is a
// function of player position only, so camera rotation cannot change
// it. playerViewPos carries retail's no-player fallback (camPos).
// A7.L1: candidacy is additionally scoped to last frame's rendered
// visible-cell set (see _lightPoolVisibleCells) when available — the
// Town Network starvation fix. Unscoped (null) until the first indoor
// frame completes or after any outdoor-only frame.
Lighting.BuildPointLightSnapshot(
playerViewPos,
_lightPoolVisibleCellsValid ? _lightPoolVisibleCells : null);
_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);
_envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2)
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
Lighting, in atmo, camPos, (float)WorldTime.DayFraction);
// A.5 T22: override fog ramp with N₁/N₂-derived distances so the
// horizon fog masks the N₁ scenery boundary. Sky keyframe fog is
// retail-accurate at normal view distances but far too short for
// the extended N₂=12 (25×25 LB) streaming window.
// FogStart = N₁ × 192m × 0.7 ≈ 538m at defaults (4/12).
// FogEnd = N₂ × 192m × 0.95 ≈ 2188m at defaults.
// Multipliers exposed as env vars for fast iteration at visual gate.
{
const float LandblockSize = 192.0f;
float startMult = ParseEnvFloat("ACDREAM_FOG_START_MULT", 0.7f);
float endMult = ParseEnvFloat("ACDREAM_FOG_END_MULT", 0.95f);
float fogStart = _nearRadius * LandblockSize * startMult;
float fogEnd = _farRadius * LandblockSize * endMult;
// Preserve fog color (xyz), lightning flash (z), and mode (w).
ubo.FogParams = new System.Numerics.Vector4(
fogStart,
fogEnd,
ubo.FogParams.Z, // lightning flash — unchanged
ubo.FogParams.W); // fog mode — unchanged
}
_sceneLightingUbo?.Upload(ubo);
// #133 A7 (2026-06-13): objective dungeon-lighting probe. One
// rate-limited [light] line — insideCell / ambient / sun /
// registered-point-lights / active-slot-count / player cell — so
// the dungeon-dim question is self-verifiable from launch.log
// without a screenshot. RegisteredCount is point/spot lights only
// (the sun lives in LightManager.Sun, never in the _all list);
// ubo.CellAmbient.W is the shader active-slot count, which counts
// the (zeroed) sun slot indoors. Inert unless ACDREAM_PROBE_LIGHT=1.
AcDream.Core.Rendering.RenderingDiagnostics.EmitLight(
insideCell: playerInsideCell,
ambientR: Lighting.CurrentAmbient.AmbientColor.X,
ambientG: Lighting.CurrentAmbient.AmbientColor.Y,
ambientB: Lighting.CurrentAmbient.AmbientColor.Z,
sunIntensity: Lighting.Sun?.Intensity ?? 0f,
registeredLights: Lighting.RegisteredCount,
activeLights: (int)ubo.CellAmbient.W,
playerCellId: playerRoot?.CellId ?? 0u,
lights: Lighting);
AcDream.App.Rendering.WorldRenderFrame worldFrame =
_worldRenderFrameBuilder!.Build(
in foundation,
IsLiveModeWaitingForLogin,
_activeDayGroup);
AcDream.App.Rendering.WorldCameraFrame cameraFrame = worldFrame.Camera;
AcDream.App.Rendering.WorldRootFrame rootFrame = worldFrame.Roots;
var camera = cameraFrame.Camera;
var worldProjection = cameraFrame.Projection;
var frustum = cameraFrame.Frustum;
var camPos = cameraFrame.Position;
// The frame builder resolves retail's player-lighting root and
// collided-viewer render root before any draw decision is made.
LoadedCell? playerRoot = rootFrame.PlayerRoot;
bool playerSeenOutside = rootFrame.PlayerSeenOutside;
uint viewerCellId = rootFrame.ViewerCellId;
var viewerEyePos = rootFrame.ViewerEyePosition;
var playerViewPos = rootFrame.PlayerViewPosition;
LoadedCell? viewerRoot = rootFrame.ViewerRoot;
bool cameraInsideCell = rootFrame.CameraInsideCell;
// Never cull the landblock the player is currently on.
uint? playerLb = null;
if (_playerMode && _playerController is not null)
{
var pp = _playerController.Position;
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
}
uint? playerLb = rootFrame.PlayerLandblockId;
int renderCenterLbX = rootFrame.RenderCenterLandblockX;
int renderCenterLbY = rootFrame.RenderCenterLandblockY;
int renderCenterLbX = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f);
int renderCenterLbY = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
// Phase A8: update EnvCellRenderer's frustum. The per-frame shell snapshot
// is prepared after the portal-visible cell filter is known.
var envCellViewProj = camera.View * worldProjection;
_envCellFrustum?.Update(envCellViewProj);
// MP-Alloc: reuse _animatedIdsScratch instead of `new`ing a
// HashSet<uint> every frame. Downstream consumers (WbDrawDispatcher.
// WalkEntitiesInto) treat null and an empty set identically, so an
// always-non-null (possibly empty) set is behaviorally the same as
// the old null-when-nothing-animated local.
//
// Every entity in _animatedEntities (i.e. every entity with a
// Sequencer) is added UNCONDITIONALLY: membership here is the
// "re-classify me every frame, don't use the Tier-1 static cache"
// flag, and the Tier-1 cache captures an entity's REST pose +
// opacity-1.0 exactly once. Any entity whose rendered state can
// depart that rest — a door held at its final OPEN frame, a wall
// held FADED-OUT by a TransparentPartHook — MUST stay on the
// per-frame path so the live sequencer pose + TranslucencyFadeManager
// opacity are read; otherwise it flips back to the stale cached
// closed/opaque state the instant its animation settles.
//
// DO NOT re-narrow this to "only if the current cycle is multi-frame"
// (the reverted IsEntityCurrentlyMoving gate, 2026-07-09): a settled
// one-shot hold IS pose-stable frame-to-frame, but stable at the
// HELD pose the cache does NOT contain — that is the door/fade
// flip-back. The per-frame re-classification cost that narrowing
// saved was a DEBUG-build artifact; Release is GPU-bound (the CPU
// work hides under GPU time), so the unconditional add is free where
// it matters. If a real Release CPU cost from static-prop
// re-classification is ever measured, gate on "entity is at its
// captured rest state" (default motion AND no active fade), never on
// "is mid-cycle".
_animatedEntities.CopySpatialIdsTo(_animatedIdsScratch);
_staticAnimationScheduler?.CopyAnimatedEntityIdsTo(_animatedIdsScratch);
if (_equippedChildRenderer is not null)
{
foreach (uint id in _equippedChildRenderer.AttachedEntityIds)
_animatedIdsScratch.Add(id);
}
HashSet<uint>? animatedIds = _animatedIdsScratch;
var envCellViewProj = cameraFrame.ViewProjection;
HashSet<uint> animatedIds = worldFrame.AnimatedEntityIds;
// Phase G.1: sky renderer — draws the far-plane-infinity
// celestial meshes FIRST so the rest of the scene z-tests
@ -3814,7 +3463,7 @@ public sealed class GameWindow : IDisposable
// Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky — clipped
// to the doorway via the OutsideView (Stage 4, below).
// Sealed dungeon (cameraInsideCell=true, rootSeenOutside=false): no sky.
bool renderSky = viewerRoot is null || rootSeenOutside;
bool renderSky = rootFrame.RenderSky;
// Phase W Stage 4 (2026-06-02): the sky/weather DRAW moved DOWN to its retail LScape
// position — AFTER the portal-visibility ClipFrame is assembled — so it can be clipped to
// the doorway (OutsideView) by sky.vert's gl_ClipDistance. See the "[Stage 4] sky
@ -3841,72 +3490,20 @@ public sealed class GameWindow : IDisposable
// GPU-fenced frame slot; each renderer re-binds binding=2 defensively.
_clipFrame ??= ClipFrame.NoClip();
// Phase 3 (render unification, additive): build the synthetic outdoor cell node when
// the eye is outdoors (no interior viewerRoot). Stored in _outdoorNode but NOT yet
// rooted — behaviour is unchanged this commit. The nearby-building enumeration mirrors
// the look-in candidate gather in the OUTDOOR branch below (Chebyshev <=1 landblocks);
// OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip +
// OutsideView terrain integration that consumes this is the next (cutover) step.
_outdoorNode = null;
_outdoorNodeBuildingCells.Clear();
if (viewerRoot is not null || viewerCellId != 0u)
{
// T2 (BR-4): draw-driven flood gating. Retail floods a building's
// interior exactly when its shell DRAWS and an aperture survives
// the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck
// → portal-BSP walk → ConstructView's GetClip; NO distance
// constant anywhere on the chain). Port: a per-BUILDING frustum
// pre-gate on the aperture bounds (Building.PortalBounds — the
// tight equivalent of the shell viewconeCheck for FLOOD purposes),
// replacing the old Chebyshev≤1 landblock cell-sweep; the 48 m
// seed cap dies with it (RetailPViewRenderer seeds at ∞). The
// per-portal admission stays BuildFromExterior's screen clip
// (empty clip = no seed) — retail's GetClip-vs-view gate.
// Per-building iteration is also the FPS fix the 2026-06-07
// Chebyshev hack approximated: dozens of AABB tests instead of an
// O(all loaded cells) portal sweep.
// #124: the gather now runs for INTERIOR roots too — retail's
// look-in executes inside LScape::draw for ANY root with a
// non-empty outside view (DrawCells pc:432719). The renderer
// routes interior-root look-ins to its landscape-stage sub-pass
// (DrawBuildingLookIns); the root's own building self-excludes
// via the seed eye-side test.
foreach (var registry in _landblockPresentationPipeline?.BuildingRegistries
?? Array.Empty<AcDream.App.Rendering.Wb.BuildingRegistry>())
{
foreach (var b in registry.All())
{
if (b.HasPortalBounds
&& !AcDream.App.Rendering.FrustumCuller.IsAabbVisible(
frustum, b.PortalBounds.Min, b.PortalBounds.Max))
continue;
foreach (uint cid in b.EnvCellIds)
if (_cellVisibility.TryGetCell(cid, out var bc) && bc is not null)
_outdoorNodeBuildingCells.Add(bc);
}
}
if (viewerRoot is null)
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
}
uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
bool playerIndoorGate = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor(
playerCellId,
playerRoot is not null);
IReadOnlyList<LoadedCell> nearbyBuildingCells =
worldFrame.Buildings.NearbyBuildingCells;
bool playerIndoorGate = rootFrame.PlayerIndoorGate;
uint playerCellId = rootFrame.PlayerCellId;
// Render unification (outdoor-as-cell, 2026-06-07 cutover): ONE render path rooted at the
// VIEWER cell. Eye indoors -> its interior EnvCell (viewerRoot); eye outdoors -> the
// synthetic outdoor node (_outdoorNode, built above from nearby building entrances). The
// frame builder's synthetic outdoor node, built from nearby building entrances. The
// result is null ONLY when neither exists (pre-spawn / login / legacy non-chase camera) ->
// the outdoor LScape block below still runs as the safety path (and login still shows the
// live sky). There is no inside/outside branch to TOGGLE as the chase eye crosses the
// doorway boundary, so the indoor FLAP dies by construction. playerIndoorGate stays
// computed for the [render-sig] probe but no longer selects the path (handoff
// docs/research/2026-06-07-render-unification-cutover-flip-handoff.md section 4 Step B).
var clipRoot = viewerRoot ?? _outdoorNode;
var clipRoot = worldFrame.ClipRoot;
string renderBranch = clipRoot is null
? "OutdoorRoot"
: "RetailPViewInside";
@ -4026,7 +3623,7 @@ public sealed class GameWindow : IDisposable
// R-A2: outdoor root floods each nearby building per-building (not via the root).
// #124: interior roots get the gather too — the renderer routes them to the
// landscape-stage look-in sub-pass instead of the merge.
NearbyBuildingCells = _outdoorNodeBuildingCells,
NearbyBuildingCells = nearbyBuildingCells,
ViewerEyePos = viewerEyePos,
ViewProjection = envCellViewProj,
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
@ -4141,9 +3738,7 @@ public sealed class GameWindow : IDisposable
// pool scoping. DrawableCells is the renderer's own reused scratch set
// (cleared/rebuilt every DrawInside call) — copy it, don't hold a
// reference to it.
_lightPoolVisibleCells.Clear();
_lightPoolVisibleCells.UnionWith(pviewResult.DrawableCells);
_lightPoolVisibleCellsValid = true;
_worldRenderFrameBuilder!.ObserveDrawableCells(pviewResult.DrawableCells);
// Flap root-cause apparatus (2026-06-07): per-frame, the EXACT Build inputs at 6 dp +
// the resulting flood count. The live flap shows flood flipping 2↔6 at an eye/player
@ -4212,7 +3807,7 @@ public sealed class GameWindow : IDisposable
// A7.L1: no indoor draw this frame — fail open (unscoped) rather than
// scoping next frame's light pool by a stale dungeon's cell ids.
_lightPoolVisibleCellsValid = false;
_worldRenderFrameBuilder!.ClearDrawableCells();
}
// Phase U.3: close the world-geometry clip bracket opened above. From here down the
@ -4993,67 +4588,6 @@ public sealed class GameWindow : IDisposable
return true;
}
/// <summary>
/// Derive the current sun (directional light, slot 0 of the UBO)
/// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>,
/// plus the cell ambient. Indoor cells force the sun intensity to
/// zero and substitute a flat 0.2 white ambient — exact retail
/// behavior per <c>CellManager::ChangePosition</c> @ 0x004559B0,
/// which calls <c>SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF)</c>
/// when the player's <c>CObjCell::seen_outside</c> flag is 0.
/// Indoor brightness then comes from per-cell point lights
/// (Setup.Lights on the cell's static objects, registered through
/// <see cref="AcDream.Core.Lighting.LightingHookSink"/>).
/// The trigger is the PLAYER's cell, not the camera's — third-person
/// chase camera enters interiors before the player body does, and
/// retail keys lighting off the player position.
/// </summary>
private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool playerInsideCell)
{
// Sun direction: points FROM the sun TOWARDS the world. Our
// shader does dot(N, -forward) so a positive N·L means the
// surface faces the sun.
var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf);
if (playerInsideCell)
{
// Indoor default — retail's flat 0.2 neutral ambient, sun
// zeroed. See xref to retail decomp in the doc comment above.
Lighting.Sun = new AcDream.Core.Lighting.LightSource
{
Kind = AcDream.Core.Lighting.LightKind.Directional,
WorldForward = sunToWorld,
ColorLinear = System.Numerics.Vector3.Zero,
Intensity = 0f,
Range = 1f,
};
Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState(
AmbientColor: new System.Numerics.Vector3(0.20f, 0.20f, 0.20f),
SunColor: System.Numerics.Vector3.Zero,
SunDirection: sunToWorld);
}
else
{
// Outdoor: full keyframe sun + ambient. The SkyKeyframe stores
// raw DirColor + DirBright (and AmbColor + AmbBright) for
// retail-faithful per-channel keyframe interpolation; the
// computed `kf.SunColor` / `kf.AmbientColor` properties return
// the post-multiplied product the shader expects.
Lighting.Sun = new AcDream.Core.Lighting.LightSource
{
Kind = AcDream.Core.Lighting.LightKind.Directional,
WorldForward = sunToWorld,
ColorLinear = kf.SunColor,
Intensity = 1f,
Range = 1f,
};
Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState(
AmbientColor: kf.AmbientColor,
SunColor: kf.SunColor,
SunDirection: sunToWorld);
}
}
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
//
// The ImGui DebugPanel reads through DebugVM closures that ask
@ -5934,17 +5468,6 @@ public sealed class GameWindow : IDisposable
$"resident={_worldState.Entities.Count} walked={walked}");
}
/// <summary>A.5 T22: parse a float environment variable, returning
/// <paramref name="defaultValue"/> when the variable is absent or unparseable.</summary>
private static float ParseEnvFloat(string name, float defaultValue)
{
var s = System.Environment.GetEnvironmentVariable(name);
if (s is not null && float.TryParse(s, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var v))
return v;
return defaultValue;
}
private void OnClosing()
=> CompleteShutdown();
@ -5989,6 +5512,17 @@ public sealed class GameWindow : IDisposable
_liveSessionController = null;
}),
]),
// Frame composition borrows equipped, effect, audio, and render
// owners. Withdraw the graph before the first borrowed owner closes;
// the later render-frontend stage still disposes the GL owners.
new ResourceShutdownStage("frame borrowers",
[
new("world frame composition", () =>
{
_worldRenderFrameBuilder = null;
_skyPesFrame = null;
}),
]),
new ResourceShutdownStage("session dependents",
[
new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()),