acdream/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
Erik 51178f7c77 fix(render): VM3 review fixes - pin the filmic decodes, re-derive vignette for linear, name the authored sun colour
Fix round from Opus review of 87677f9c (APPROVE WITH FIXES):

1. (A1/A3) Hardened the atmospheric_filmic.frag / atmospheric_bloom_
   downsample.frag shader-source pinning test: asserts the exact decode
   call count (6 - three in lowFusedScene, three in main's non-fused
   branch), that the 2.2 display-gamma exponent and 1/2.2 inverse in
   atmospheric_common.glsl are formatted FROM AtmosphericColorPipeline
   .DisplayGamma (so shader literal and CPU-tested value cannot drift),
   that the 0.18 contrast pivot in atmospheric_filmic.frag is formatted
   from AtmosphericColorPipeline.LinearMidGrey, and pins
   AtmosphericPostProcessGraph.BloomKneeLinear/BloomThresholdLinear as
   exact literals (0.73f / 1f). Also removed a stray trailing "()" from
   an existing comment in atmospheric_filmic.frag that was inflating the
   decode-call count to 7.

2. (B1) Re-derived the "vignette-strength" default for the linear-light
   post stack. The vignette multiply now happens on linear colour before
   the final encode, so a corner factor of (1 - strength) displays as
   (1 - strength)^(1/2.2), not (1 - strength) directly. The accepted
   look was strength 0.12 under the OLD gamma-space pipeline: a 12%
   on-screen corner darkening. Under strength 0.12 in the new linear
   pipeline that same 0.88 corner multiplier would only display as
   0.88^(1/2.2) ~= 0.9435 (5.6% darkening - visibly weaker). Solving
   (1 - strength)^(1/2.2) = 0.88 gives strength = 1 - 0.88^2.2 ~= 0.245,
   which reproduces the accepted 12% corner darkening. Since
   RenderPackSettingValueCodec requires every declared default to be
   step-aligned from the minimum and 0.245 is not a multiple of the old
   0.01 step, the step also moves to 0.005 (a finer slider, not
   coarser) so the exact derived default is a valid grid point -
   verified by running the ExternalTierTwoPackCanRenameEveryOwnedId
   AndShaderAsset validation test, which failed with "invalid default
   value" before this correction. Also updated the matching fallback in
   AtmosphericPostProcessGraph.FromDescriptor (0.12f -> 0.245f) for
   consistency, and added
   AtmosphericColorPipelineTests.VignetteDefaultReproducesTheAccepted
   TwelvePercentCornerDarkening pinning encode(1-0.245) ~= 0.88.

3. (A4) Renamed VolumetricShaftFrameParameters.LinearSunColor ->
   AuthoredSunColor in VolumetricShaftQuality.cs (internal, 2 references,
   both in that file - safe). Left LightSource.ColorLinear unrenamed:
   grep shows 13 files depend on it (GlobalLightPacker, SceneLightingUbo,
   LightBake, LightManager, EnvCellRenderer, RenderingDiagnostics, and
   several Core tests) across the shared retail default-path lighting
   UBO pipeline - renaming it is out of VM3's pack-only scope and would
   touch the mandatory-unchanged default path. Added a pointer comment
   on the field in LightSource.cs (and a one-line note at its
   WorldRenderFrameBuilder.cs call site) documenting the same
   display-space-not-linear fact and explaining why the rename is
   deferred to its own default-path colour-space pass.

4. (B4) Added a citation beside acesFitted in both atmospheric_filmic
   .frag and its C# mirror (AtmosphericColorPipeline.AcesFitted):
   Krzysztof Narkowicz, "ACES Filmic Tone Mapping Curve" (2016). The fit
   takes linear scene light in and returns linear display light in
   [0,1] - it does not itself gamma-encode. Evidence: acesFitted(0.80 *
   decode(0.46)) = 0.2064 un-encoded versus the accepted 0.51 on screen.

5. (B2) Rewrote the VM3 section of docs/plans/2026-08-22-visualmaster-
   campaign.md with the shipped truth in place of the pre-implementation
   guess: exposure stays 0.80 (at exposure 1.0 the linear pipeline maps
   gamma-0.5 to 0.6017, essentially the same 0.6163 the owner called too
   bright), bloom threshold stays 1.0 (a fixed point of both exponents),
   knee moves 0.45 -> 0.73, vignette-strength moves 0.12 -> 0.245. Added
   the old-vs-new curve table at exposure 0.80 across ten gamma inputs.
   Replaced the acceptance criteria's "new automated test on the
   recording RHI" with the CPU mirror + shader-source pins actually
   used, and recorded that the real-frame masked capture WAS run
   (retail vs High-with-every-effect-neutral, artifacts/vm3):
   independently re-verified by re-running the pixel diff against the
   checked-in screenshots - 110,561 px at |delta|=1 and exactly 95
   pixels at |delta|>=5, confined to foliage-canopy silhouette edges
   against sky with nothing on any ground/building/water surface. Noted
   the Stage-1 luminance table re-capture is still owed at the owner
   gate.

6. (B3) Corrected docs/plans/2026-08-21-atmospheric-rendering.md's VM3
   summary sentence: the bloom intermediate is already linear after
   extraction (no separate "bloom read" decode), and the neutral-preset
   claim is now phrased as a measured numerical identity (<=1 LSB on a
   real frame) rather than an unqualified "is" statement.

7. (A5) Corrected toolchain attribution: tools/compile-shaders.ps1 used
   the managed Silk.NET.Shaderc path (shaderc_shared.dll) to compile in
   both this round and the original VM3 commit - a Vulkan SDK glslc was
   detected and its path recorded, but the managed compiler is what
   actually ran. Regenerating this round only changed the atmospheric_
   filmic frag stage's manifest hash (comment-only edits); the compiled
   .spv bytes are unchanged, and every retail-oracle shader
   (mesh_modern, terrain_modern, mesh_detail, etc.) remains untouched.

8. Replaced an invented motive in the atmospheric_filmic.frag contrast-
   pivot comment ("rounded up for a stronger gamma-space contrast
   feel") with the actual reason: the previous 0.5 was simply the [0,1]
   midpoint of the standard contrast formula, not a deliberately chosen
   value; in linear the perceptual mid-grey is 0.18.

Verify: Release build 0 warnings / 0 errors. App hermetic-filter tests:
5970 passed / 0 failed / 0 skipped. VulkanShaderManifestTests: 7/7 pass
(retail-oracle SPIR-V byte-identical; only the atmospheric_filmic frag
manifest hash changed, no .spv bytes changed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 23:40:48 +02:00

674 lines
24 KiB
C#

using System.Numerics;
using AcDream.App.Audio;
using AcDream.App.Input;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Audio;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Core.Rendering;
using AcDream.Core.World;
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;
/// <summary>
/// Pack-on-only borrowed publication fact. Retail does not read it. The
/// world scene attaches it after the ordinary frame has resolved the exact
/// render centre, so shadow fitting cannot infer reach from a configured
/// radius or touch streaming ownership.
/// </summary>
public ResidentStreamingWindowFact ResidentStreamingWindow { get; init; }
/// <summary>
/// Pack-on-only authored celestial direction. The authoritative retail
/// lighting path ignores this fact; it exists solely for the opt-in
/// directional-shadow prepass.
/// </summary>
public AuthoredCelestialShadowSource CelestialShadowSource { get; init; }
}
internal interface IWorldRenderFrameBuilder
{
WorldRenderFrame Build(
in RenderFrameFoundation foundation,
bool waitingForLogin,
DayGroupData? activeDayGroup);
void ObserveDrawableCells(IReadOnlySet<uint> drawableCells);
void ClearDrawableCells();
}
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 : IWorldRenderFrameBuilder
{
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 IRuntimeLocalPlayerControllerSource _player;
private readonly LiveWorldOriginState _origin;
public RuntimeWorldFrameRootSource(
PhysicsEngine physics,
CellVisibility cells,
ILocalPlayerModeSource mode,
IChaseCameraSource chase,
IRuntimeLocalPlayerControllerSource 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();
}
public void PublishViewProjection(in WorldCameraFrame camera) =>
_environmentFrustum?.Update(camera.ViewProjection);
}
internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPreview
{
private readonly IRuntimeSettingsPreviewSource _settings;
private readonly OpenAlAudioEngine? _audio;
private readonly CameraController _cameras;
private readonly DisplayFramePacingController _pacing;
public RuntimeWorldFrameSettingsPreview(
IRuntimeSettingsPreviewSource settings,
OpenAlAudioEngine? audio,
CameraController cameras,
DisplayFramePacingController pacing)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_audio = audio;
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
_pacing = pacing ?? throw new ArgumentNullException(nameof(pacing));
}
public void Apply(in WorldCameraFrame camera)
{
if (_settings.HasDraftPreview)
{
RuntimeSettingsStartupTargets.ApplyAudio(
_audio,
_settings.AudioPreview);
var display = _settings.DisplayPreview;
RuntimeSettingsStartupTargets.ApplyFieldOfView(
_cameras,
display.FieldOfView);
_pacing.ApplyPreference(display.VSync);
}
if (_audio is not { IsAvailable: true })
return;
// Retail's audio listener IS the camera viewer, refreshed per rendered
// frame (`SmartBox::set_viewer` @ 0x00452D36 hands the same collided
// camera Position to SoundManager, the sky, and the camera setup), so
// the position source here is already faithful. What retail reads off it
// is only the origin and `Frame::get_heading` — a compass bearing, not a
// basis: pan comes from that heading alone, which is why retail has no
// front/back or elevation cue to reproduce.
Matrix4x4 inverse = camera.InverseView;
var forward = new Vector3(-inverse.M31, -inverse.M32, -inverse.M33);
Vector3 position = camera.Position;
_audio.SetListener(
position.X, position.Y, position.Z,
RetailSoundMixer.CompassHeadingDegrees(Vector3.Zero, forward));
}
}
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,
// keyframe.SunColor is retail's authored display-space colour;
// LightSource.ColorLinear is misleadingly named — see the note
// on that field (Campaign VM VM3 review item A4) for why it is
// left unrenamed here.
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);
}
}