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()),

View file

@ -0,0 +1,135 @@
using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Vfx;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Owns the optional DAT-archaeology sky-PES experiment. Named retail shows
/// GameSky does not consume SkyObject.PesObjectId, so production invokes this
/// owner only when the explicit startup diagnostic is enabled.
/// </summary>
internal sealed class SkyPesFrameController
{
private readonly record struct SkyPesKey(
int ObjectIndex,
uint PesObjectId,
bool PostScene);
private readonly PhysicsScriptRunner _scripts;
private readonly ParticleHookSink _particles;
private readonly EntityEffectPoseRegistry _poses;
private readonly EntityEffectController? _effects;
private readonly HashSet<SkyPesKey> _active = [];
private readonly HashSet<SkyPesKey> _missing = [];
public SkyPesFrameController(
PhysicsScriptRunner scripts,
ParticleHookSink particles,
EntityEffectPoseRegistry poses,
EntityEffectController? effects)
{
_scripts = scripts ?? throw new ArgumentNullException(nameof(scripts));
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_effects = effects;
}
public void Update(
float dayFraction,
DayGroupData? dayGroup,
Vector3 cameraWorldPosition,
bool suppressSky)
{
var seen = new HashSet<SkyPesKey>();
if (!suppressSky && dayGroup is not null)
{
for (int index = 0; index < dayGroup.SkyObjects.Count; index++)
{
SkyObjectData skyObject = dayGroup.SkyObjects[index];
if (skyObject.PesObjectId == 0 || !skyObject.IsVisible(dayFraction))
continue;
var key = new SkyPesKey(
index,
skyObject.PesObjectId,
skyObject.IsPostScene);
seen.Add(key);
uint ownerId = EntityId(key);
ParticleRenderPass renderPass = skyObject.IsPostScene
? ParticleRenderPass.SkyPostScene
: ParticleRenderPass.SkyPreScene;
_particles.SetEntityRenderPass(ownerId, renderPass);
Vector3 anchor = Anchor(skyObject, cameraWorldPosition);
Quaternion rotation = Rotation(skyObject, dayFraction);
_poses.Publish(
ownerId,
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(anchor),
Array.Empty<Matrix4x4>(),
cellId: 0u);
if (_active.Contains(key) || _missing.Contains(key))
continue;
_effects?.RegisterSyntheticOwner(ownerId);
if (_scripts.Play(skyObject.PesObjectId, ownerId, anchor))
{
_active.Add(key);
}
else
{
_missing.Add(key);
_effects?.UnregisterSyntheticOwner(ownerId);
_particles.ClearEntityRenderPass(ownerId);
_poses.Remove(ownerId);
}
}
}
foreach (SkyPesKey key in _active.ToArray())
{
if (seen.Contains(key))
continue;
uint ownerId = EntityId(key);
_scripts.StopAllForEntity(ownerId);
_effects?.UnregisterSyntheticOwner(ownerId);
_particles.StopAllForEntity(ownerId, fadeOut: true);
_poses.Remove(ownerId);
_active.Remove(key);
}
foreach (SkyPesKey key in _missing.ToArray())
{
if (!seen.Contains(key))
_missing.Remove(key);
}
}
private static uint EntityId(SkyPesKey key)
{
uint postScene = key.PostScene ? 0x08000000u : 0u;
return 0xF0000000u
| postScene
| ((uint)key.ObjectIndex & 0x07FFFFFFu);
}
private static Vector3 Anchor(
SkyObjectData skyObject,
Vector3 cameraWorldPosition)
{
if (skyObject.IsWeather && (skyObject.Properties & 0x08u) == 0u)
return cameraWorldPosition + new Vector3(0f, 0f, -120f);
return cameraWorldPosition;
}
private static Quaternion Rotation(
SkyObjectData skyObject,
float dayFraction)
{
float radians = skyObject.CurrentAngle(dayFraction) * (MathF.PI / 180f);
return Quaternion.CreateFromAxisAngle(Vector3.UnitY, -radians);
}
}

View file

@ -0,0 +1,645 @@
using System.Numerics;
using AcDream.App.Audio;
using AcDream.App.Input;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
using AcDream.Core.World;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Rendering;
/// <summary>Camera facts borrowed for one world render preparation.</summary>
internal readonly record struct WorldCameraFrame(
ICamera Camera,
Matrix4x4 Projection,
Matrix4x4 ViewProjection,
FrustumPlanes Frustum,
Matrix4x4 InverseView,
Vector3 Position);
/// <summary>Player-lighting and collided-viewer roots for one frame.</summary>
internal readonly record struct WorldRootFrame(
LoadedCell? PlayerRoot,
bool PlayerSeenOutside,
uint ViewerCellId,
Vector3 ViewerEyePosition,
Vector3 PlayerViewPosition,
LoadedCell? ViewerRoot,
bool CameraInsideCell,
bool RootSeenOutside,
bool PlayerInsideCell,
uint? PlayerLandblockId,
int RenderCenterLandblockX,
int RenderCenterLandblockY,
uint PlayerCellId,
bool PlayerIndoorGate)
{
public bool RenderSky => ViewerRoot is null || RootSeenOutside;
}
/// <summary>Borrowed building scratch, valid only until the next build.</summary>
internal readonly record struct WorldBuildingFrame(
LoadedCell? OutdoorNode,
IReadOnlyList<LoadedCell> NearbyBuildingCells);
/// <summary>
/// Immutable one-frame world facts. Collection members borrow reusable owner
/// scratch and must not be retained beyond the next <see cref="WorldRenderFrameBuilder.Build"/>.
/// </summary>
internal readonly record struct WorldRenderFrame(
WorldCameraFrame Camera,
WorldRootFrame Roots,
WorldBuildingFrame Buildings,
HashSet<uint> AnimatedEntityIds)
{
public LoadedCell? ClipRoot => Roots.ViewerRoot ?? Buildings.OutdoorNode;
}
internal interface IWorldFrameCameraSource
{
WorldCameraFrame Resolve();
}
internal interface IWorldFrameRootSource
{
WorldRootFrame Resolve(in WorldCameraFrame camera);
}
internal interface IWorldFrameVisibilityPreparation
{
void Begin(in WorldCameraFrame camera, bool waitingForLogin);
void PublishViewProjection(in WorldCameraFrame camera);
}
internal interface IWorldFrameSettingsPreview
{
void Apply(in WorldCameraFrame camera);
}
internal interface IWorldFrameEnvironmentPreparation
{
void Prepare(
in WorldCameraFrame camera,
in WorldRootFrame roots,
in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup);
void ObserveDrawableCells(IReadOnlySet<uint> drawableCells);
void ClearDrawableCells();
}
internal interface IWorldFrameAnimatedEntitySource
{
HashSet<uint> Capture();
}
internal interface IWorldFrameBuildingSource
{
WorldBuildingFrame Gather(
LoadedCell? viewerRoot,
uint viewerCellId,
in FrustumPlanes frustum);
}
/// <summary>
/// Orders typed world-frame fact sources without owning their borrowed render
/// resources. It makes no draw decision and retains no result from a prior call.
/// </summary>
internal sealed class WorldRenderFrameBuilder
{
private readonly IWorldFrameCameraSource _camera;
private readonly IWorldFrameVisibilityPreparation _visibility;
private readonly IWorldFrameSettingsPreview _settings;
private readonly IWorldFrameRootSource _roots;
private readonly IWorldFrameEnvironmentPreparation _environment;
private readonly IWorldFrameAnimatedEntitySource _animated;
private readonly IWorldFrameBuildingSource _buildings;
public WorldRenderFrameBuilder(
IWorldFrameCameraSource camera,
IWorldFrameVisibilityPreparation visibility,
IWorldFrameSettingsPreview settings,
IWorldFrameRootSource roots,
IWorldFrameEnvironmentPreparation environment,
IWorldFrameAnimatedEntitySource animated,
IWorldFrameBuildingSource buildings)
{
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_visibility = visibility ?? throw new ArgumentNullException(nameof(visibility));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_roots = roots ?? throw new ArgumentNullException(nameof(roots));
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_animated = animated ?? throw new ArgumentNullException(nameof(animated));
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
}
public WorldRenderFrame Build(
in RenderFrameFoundation foundation,
bool waitingForLogin,
DayGroupData? activeDayGroup)
{
WorldCameraFrame camera = _camera.Resolve();
_visibility.Begin(in camera, waitingForLogin);
_settings.Apply(in camera);
WorldRootFrame roots = _roots.Resolve(in camera);
_environment.Prepare(in camera, in roots, in foundation, activeDayGroup);
_visibility.PublishViewProjection(in camera);
HashSet<uint> animated = _animated.Capture();
FrustumPlanes frustum = camera.Frustum;
WorldBuildingFrame buildings = _buildings.Gather(
roots.ViewerRoot,
roots.ViewerCellId,
in frustum);
return new WorldRenderFrame(camera, roots, buildings, animated);
}
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) =>
_environment.ObserveDrawableCells(drawableCells);
public void ClearDrawableCells() => _environment.ClearDrawableCells();
}
internal sealed class RuntimeWorldFrameCameraSource : IWorldFrameCameraSource
{
private readonly CameraController _cameras;
private readonly LocalPlayerTeleportController _teleport;
public RuntimeWorldFrameCameraSource(
CameraController cameras,
LocalPlayerTeleportController teleport)
{
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
_teleport = teleport ?? throw new ArgumentNullException(nameof(teleport));
}
public WorldCameraFrame Resolve()
{
ICamera camera = _teleport.ApplyViewPlane(_cameras.Active);
Matrix4x4 projection = camera.Projection;
Matrix4x4 viewProjection = camera.View * projection;
FrustumPlanes frustum = FrustumPlanes.FromViewProjection(viewProjection);
Matrix4x4.Invert(camera.View, out Matrix4x4 inverseView);
var position = new Vector3(inverseView.M41, inverseView.M42, inverseView.M43);
return new WorldCameraFrame(
camera,
projection,
viewProjection,
frustum,
inverseView,
position);
}
}
internal sealed class RuntimeWorldFrameRootSource : IWorldFrameRootSource
{
private const float LandblockSize = 192f;
private readonly PhysicsEngine _physics;
private readonly CellVisibility _cells;
private readonly ILocalPlayerModeSource _mode;
private readonly IChaseCameraSource _chase;
private readonly ILocalPlayerControllerSource _player;
private readonly LiveWorldOriginState _origin;
public RuntimeWorldFrameRootSource(
PhysicsEngine physics,
CellVisibility cells,
ILocalPlayerModeSource mode,
IChaseCameraSource chase,
ILocalPlayerControllerSource player,
LiveWorldOriginState origin)
{
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
_cells = cells ?? throw new ArgumentNullException(nameof(cells));
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
_player = player ?? throw new ArgumentNullException(nameof(player));
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
}
public WorldRootFrame Resolve(in WorldCameraFrame camera)
{
// Retail keeps these as two related but distinct positions:
// CellManager::ChangePosition @ 0x004559B0 uses the player's current
// cell for seen_outside/lighting, while RenderNormalMode @ 0x00453AA0
// passes the collided camera's viewer_cell to DrawInside.
LoadedCell? playerRoot = null;
if (_physics.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell playerCell
&& _cells.TryGetCell(playerCell.Id, out LoadedCell? registeredPlayer))
{
playerRoot = registeredPlayer;
}
bool playerSeenOutside = playerRoot?.SeenOutside ?? true;
uint viewerCellId = _mode.IsPlayerMode
&& _chase.Retail is { } retailChase
&& CameraDiagnostics.UseRetailChaseCamera
? retailChase.ViewerCellId
: playerRoot?.CellId ?? 0u;
LoadedCell? viewerRoot = null;
if (viewerCellId != 0u
&& _cells.TryGetCell(viewerCellId, out LoadedCell? registeredViewer))
{
viewerRoot = registeredViewer;
}
var player = _player.Controller;
Vector3 playerViewPosition = player?.RenderPosition
?? player?.Position
?? camera.Position;
bool cameraInsideCell = viewerRoot is not null;
bool rootSeenOutside = viewerRoot?.SeenOutside ?? true;
bool playerInsideCell = playerRoot is not null && !playerSeenOutside;
uint? playerLandblockId = null;
if (_mode.IsPlayerMode && player is not null)
{
int playerX = _origin.CenterX + (int)Math.Floor(player.Position.X / LandblockSize);
int playerY = _origin.CenterY + (int)Math.Floor(player.Position.Y / LandblockSize);
playerLandblockId = (uint)((playerX << 24) | (playerY << 16) | 0xFFFF);
}
int renderCenterX = _origin.CenterX
+ (int)Math.Floor(camera.Position.X / LandblockSize);
int renderCenterY = _origin.CenterY
+ (int)Math.Floor(camera.Position.Y / LandblockSize);
uint playerCellId = _physics.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
bool playerIndoorGate = RenderingDiagnostics.ShouldRenderIndoor(
playerCellId,
playerRoot is not null);
return new WorldRootFrame(
playerRoot,
playerSeenOutside,
viewerCellId,
camera.Position,
playerViewPosition,
viewerRoot,
cameraInsideCell,
rootSeenOutside,
playerInsideCell,
playerLandblockId,
renderCenterX,
renderCenterY,
playerCellId,
playerIndoorGate);
}
}
internal sealed class RuntimeWorldFrameVisibilityPreparation
: IWorldFrameVisibilityPreparation
{
private readonly RetailSelectionScene? _selection;
private readonly ParticleVisibilityController _particles;
private readonly TerrainModernRenderer? _terrain;
private readonly WorldRevealCoordinator? _reveal;
private readonly WbFrustum? _environmentFrustum;
public RuntimeWorldFrameVisibilityPreparation(
RetailSelectionScene? selection,
ParticleVisibilityController particles,
TerrainModernRenderer? terrain,
WorldRevealCoordinator? reveal,
WbFrustum? environmentFrustum)
{
_selection = selection;
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
_terrain = terrain;
_reveal = reveal;
_environmentFrustum = environmentFrustum;
}
public void Begin(in WorldCameraFrame camera, bool waitingForLogin)
{
_selection?.SetViewFrustum(camera.Frustum);
_particles.BeginFrame(camera.Position);
_terrain?.BeginVisibilityFrame();
if (waitingForLogin)
return;
_particles.UseWorldView();
_reveal?.ObserveWorldViewportVisible();
_reveal?.Complete();
}
public void PublishViewProjection(in WorldCameraFrame camera) =>
_environmentFrustum?.Update(camera.ViewProjection);
}
internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPreview
{
private readonly SettingsVM? _settings;
private readonly OpenAlAudioEngine? _audio;
private readonly CameraController _cameras;
private readonly DisplayFramePacingController _pacing;
public RuntimeWorldFrameSettingsPreview(
SettingsVM? settings,
OpenAlAudioEngine? audio,
CameraController cameras,
DisplayFramePacingController pacing)
{
_settings = settings;
_audio = audio;
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
_pacing = pacing ?? throw new ArgumentNullException(nameof(pacing));
}
public void Apply(in WorldCameraFrame camera)
{
if (_audio is { IsAvailable: true } && _settings is not null)
{
var audio = _settings.AudioDraft;
_audio.MasterVolume = audio.Master;
_audio.MusicVolume = audio.Music;
_audio.SfxVolume = audio.Sfx;
_audio.AmbientVolume = audio.Ambient;
}
if (_settings is not null)
{
var display = _settings.DisplayDraft;
float fieldOfView = display.FieldOfView * (MathF.PI / 180f);
_cameras.Orbit.FovY = fieldOfView;
_cameras.Fly.FovY = fieldOfView;
if (_cameras.Chase is not null)
_cameras.Chase.FovY = fieldOfView;
_pacing.ApplyPreference(display.VSync);
}
if (_audio is not { IsAvailable: true })
return;
Matrix4x4 inverse = camera.InverseView;
var forward = new Vector3(-inverse.M31, -inverse.M32, -inverse.M33);
var up = new Vector3(inverse.M21, inverse.M22, inverse.M23);
Vector3 position = camera.Position;
_audio.SetListener(
position.X, position.Y, position.Z,
forward.X, forward.Y, forward.Z,
up.X, up.Y, up.Z);
}
}
internal interface IWorldRenderRangeSource
{
int NearRadius { get; }
int FarRadius { get; }
}
internal sealed class WorldRenderRangeState : IWorldRenderRangeSource
{
public WorldRenderRangeState(int nearRadius, int farRadius)
{
NearRadius = nearRadius;
FarRadius = farRadius;
}
public int NearRadius { get; set; }
public int FarRadius { get; set; }
}
internal sealed class RuntimeWorldFrameEnvironmentPreparation
: IWorldFrameEnvironmentPreparation
{
private const float LandblockSize = 192f;
private readonly RuntimeOptions _options;
private readonly WorldTimeService _worldTime;
private readonly LightManager _lighting;
private readonly WbDrawDispatcher? _dispatcher;
private readonly EnvCellRenderer? _environmentCells;
private readonly SceneLightingUboBinding? _lightingUbo;
private readonly IWorldRenderRangeSource _ranges;
private readonly SkyPesFrameController? _skyPes;
private readonly HashSet<uint> _visibleCells = [];
private bool _visibleCellsValid;
public RuntimeWorldFrameEnvironmentPreparation(
RuntimeOptions options,
WorldTimeService worldTime,
LightManager lighting,
WbDrawDispatcher? dispatcher,
EnvCellRenderer? environmentCells,
SceneLightingUboBinding? lightingUbo,
IWorldRenderRangeSource ranges,
SkyPesFrameController? skyPes)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
_lighting = lighting ?? throw new ArgumentNullException(nameof(lighting));
_dispatcher = dispatcher;
_environmentCells = environmentCells;
_lightingUbo = lightingUbo;
_ranges = ranges ?? throw new ArgumentNullException(nameof(ranges));
_skyPes = skyPes;
}
public void Prepare(
in WorldCameraFrame camera,
in WorldRootFrame roots,
in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup)
{
if (_options.EnableSkyPesDebug)
{
_skyPes?.Update(
(float)_worldTime.DayFraction,
activeDayGroup,
camera.Position,
roots.CameraInsideCell);
}
UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);
_lighting.UpdateViewerLight(roots.PlayerViewPosition);
_lighting.Tick(camera.Position);
_lighting.BuildPointLightSnapshot(
roots.PlayerViewPosition,
_visibleCellsValid ? _visibleCells : null);
_dispatcher?.SetSceneLights(_lighting.PointSnapshot);
_environmentCells?.SetPointSnapshot(_lighting.PointSnapshot);
AtmosphereSnapshot atmosphere = foundation.Atmosphere;
SceneLightingUbo ubo = SceneLightingUbo.Build(
_lighting,
in atmosphere,
camera.Position,
(float)_worldTime.DayFraction);
float fogStart = _ranges.NearRadius
* LandblockSize
* _options.FogStartMultiplier;
float fogEnd = _ranges.FarRadius
* LandblockSize
* _options.FogEndMultiplier;
ubo.FogParams = new Vector4(
fogStart,
fogEnd,
ubo.FogParams.Z,
ubo.FogParams.W);
_lightingUbo?.Upload(ubo);
RenderingDiagnostics.EmitLight(
insideCell: roots.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: roots.PlayerRoot?.CellId ?? 0u,
lights: _lighting);
}
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells)
{
ArgumentNullException.ThrowIfNull(drawableCells);
_visibleCells.Clear();
_visibleCells.UnionWith(drawableCells);
_visibleCellsValid = true;
}
public void ClearDrawableCells() => _visibleCellsValid = false;
private void UpdateSunFromSky(SkyKeyframe keyframe, bool playerInsideCell)
{
// CellManager::ChangePosition @ 0x004559B0 keys this on the player
// cell's seen_outside flag and installs neutral 0.2 ambient indoors.
Vector3 sunToWorld = -SkyStateProvider.SunDirectionFromKeyframe(keyframe);
if (playerInsideCell)
{
_lighting.Sun = new LightSource
{
Kind = LightKind.Directional,
WorldForward = sunToWorld,
ColorLinear = Vector3.Zero,
Intensity = 0f,
Range = 1f,
};
_lighting.CurrentAmbient = new CellAmbientState(
new Vector3(0.20f, 0.20f, 0.20f),
Vector3.Zero,
sunToWorld);
return;
}
_lighting.Sun = new LightSource
{
Kind = LightKind.Directional,
WorldForward = sunToWorld,
ColorLinear = keyframe.SunColor,
Intensity = 1f,
Range = 1f,
};
_lighting.CurrentAmbient = new CellAmbientState(
keyframe.AmbientColor,
keyframe.SunColor,
sunToWorld);
}
}
internal sealed class RuntimeWorldFrameAnimatedEntitySource
: IWorldFrameAnimatedEntitySource
{
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _live;
private readonly RetailStaticAnimatingObjectScheduler? _statics;
private readonly EquippedChildRenderController? _equipped;
private readonly HashSet<uint> _scratch = [];
public RuntimeWorldFrameAnimatedEntitySource(
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> live,
RetailStaticAnimatingObjectScheduler? statics,
EquippedChildRenderController? equipped)
{
_live = live ?? throw new ArgumentNullException(nameof(live));
_statics = statics;
_equipped = equipped;
}
public HashSet<uint> Capture()
{
// Membership means "classify from the current pose this frame," not
// merely "animation is advancing." A settled door or translucency
// hook can differ permanently from its cached rest pose, so every
// sequenced/attached entity remains on the live path.
_live.CopySpatialIdsTo(_scratch);
_statics?.CopyAnimatedEntityIdsTo(_scratch);
if (_equipped is not null)
{
foreach (uint entityId in _equipped.AttachedEntityIds)
_scratch.Add(entityId);
}
return _scratch;
}
}
internal sealed class RuntimeWorldFrameBuildingSource : IWorldFrameBuildingSource
{
private readonly LandblockPresentationPipeline _presentation;
private readonly CellVisibility _cells;
private readonly List<LoadedCell> _scratch = [];
public RuntimeWorldFrameBuildingSource(
LandblockPresentationPipeline presentation,
CellVisibility cells)
{
_presentation = presentation
?? throw new ArgumentNullException(nameof(presentation));
_cells = cells ?? throw new ArgumentNullException(nameof(cells));
}
public WorldBuildingFrame Gather(
LoadedCell? viewerRoot,
uint viewerCellId,
in FrustumPlanes frustum)
{
_scratch.Clear();
LoadedCell? outdoorNode = null;
if (viewerRoot is null && viewerCellId == 0u)
return new WorldBuildingFrame(null, _scratch);
// DrawBuilding @ 0x0059F2A0 gates each building by its visible aperture
// before walking its portal BSP. The portal-bounds frustum test is the
// corresponding coarse admission gate; there is no distance cutoff.
foreach (BuildingRegistry registry in _presentation.BuildingRegistries)
{
foreach (Building building in registry.All())
{
if (building.HasPortalBounds
&& !FrustumCuller.IsAabbVisible(
frustum,
building.PortalBounds.Min,
building.PortalBounds.Max))
{
continue;
}
foreach (uint cellId in building.EnvCellIds)
{
if (_cells.TryGetCell(cellId, out LoadedCell? cell) && cell is not null)
_scratch.Add(cell);
}
}
}
if (viewerRoot is null)
outdoorNode = OutdoorCellNode.Build(viewerCellId);
if (RenderingDiagnostics.ProbeFlapEnabled)
{
Console.WriteLine(FormattableString.Invariant(
$"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_scratch.Count} (T2 frustum-gated per-building floods)"));
}
return new WorldBuildingFrame(outdoorNode, _scratch);
}
}

View file

@ -46,7 +46,9 @@ public sealed record RuntimeOptions(
string? AcDir,
bool UiProbeDump,
string? UiProbeScript,
string? AutomationArtifactDirectory)
string? AutomationArtifactDirectory,
float FogStartMultiplier,
float FogEndMultiplier)
{
/// <summary>
/// Build options from the process environment. Used by
@ -99,7 +101,9 @@ public sealed record RuntimeOptions(
UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")),
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")),
AutomationArtifactDirectory:
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")));
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")),
FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f);
}
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
@ -120,4 +124,9 @@ public sealed record RuntimeOptions(
private static int? TryParseNonNegativeInt(string? s)
=> TryParseInt(s) is { } v && v >= 0 ? v : null;
private static float? TryParseFloat(string? s)
=> float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)
? value
: null;
}