acdream/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs
Erik 1955a0d5f1 fix(render) Campaign FW3.2b-2: scenery buckets need the streaming recenter
The owner watched the first passing lifecycle run and reported most
outdoor scenery not rendering (terrain/buildings/interiors fine, no
seams). Root cause: retained-scene record positions are RENDER-ORIGIN-
RELATIVE (each landblock carries (lbX - CenterX)*192 offsets), but the
walk world data bucketed outdoor statics by treating them as absolute -
garbage landblock bytes, so the walk's landscape-cell turns read empty
buckets and trees/rocks/fences never drew. LandscapeCellId now adds the
frame's RenderCenterLbX/Y back to the relative block index, producing
the TRUE cell ids the walk's OnLandscapeCellTurn emits. Buildings and
cell statics were unaffected (id-keyed, not position-bucketed).

The same run confirmed the campaign thesis live: NO SEAMS at the
doorway class under walk order + retail depth, with the lift hack
still present (its retirement is FW3.3).

Suites: hermetic 6,750/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:54:06 +02:00

207 lines
9.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.App.Rendering.Scene;
namespace AcDream.App.Rendering.Walk;
/// <summary>
/// Campaign FW3.2b-2: the production <see cref="IWalkFrameWorldData"/> over
/// the retained scene (<see cref="RenderSceneQuery"/>) and the FW3.1
/// <see cref="WalkBuildingRegistry"/>. Rebuilt facts per frame via
/// <see cref="BeginFrame"/>:
///
/// <list type="bullet">
/// <item>Cell statics — <see cref="RenderSceneQuery.CopyCellStaticsTo"/> on
/// demand, one pooled array per distinct cell per frame (a cell can be
/// visited once by the root flood OR once per admitting look-in portal; the
/// per-frame cache keeps the copy single).</item>
/// <item>Outdoor statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/>
/// sweep bucketed by landscape cell id
/// (<c>(lb &amp; 0xFFFF0000) | (cellX*8 + cellY + 1)</c> from the record's
/// world position — the same encoding <see cref="IWalkEventSink.OnLandscapeCellTurn"/>
/// computes), EXCLUDING building shells (they draw at their building's own
/// shell turn, retail <c>CPhysicsPart::Draw(parts, 0)</c> @0x0059f331, not
/// at the cell's <c>DrawObjCell</c> turn).</item>
/// <item>Building shells — the same sweep's <c>IsBuildingShell</c> records
/// bucketed by <c>Source.BuildingShellAnchorCellId</c>; a
/// <see cref="WalkBuilding"/> maps to its anchor via its first
/// non-exit portal's destination (the SAME rule
/// <c>LandblockLoader</c> used to author the anchor).</item>
/// </list>
///
/// The tuple landblock id handed to the classifier is the frame's player
/// landblock — the packed path's own convention for every
/// <c>RenderFrameEntityDrawRequest</c>.
/// </summary>
internal sealed class WalkProductionWorldData : IWalkFrameWorldData
{
private readonly WalkBuildingRegistry _buildings;
private RenderSceneQuery _scene;
private uint _tupleLandblockId;
private int _renderCenterLbX;
private int _renderCenterLbY;
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new();
private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024];
private RenderProjectionRecord[] _cellScratch = new RenderProjectionRecord[256];
internal WalkProductionWorldData(WalkBuildingRegistry buildings)
{
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
}
/// <summary>Rebuilds the frame's outdoor/shell buckets and clears the
/// per-cell cache. Call once per frame before the driver runs.
/// <paramref name="renderCenterLbX"/>/<paramref name="renderCenterLbY"/>
/// are the streaming recenter origin: record positions are
/// RENDER-ORIGIN-RELATIVE (each landblock's entities carry
/// <c>(lbX CenterX)·192</c> offsets), so mapping a position back to
/// its TRUE landblock byte needs the center added back — the first
/// connected gate of the FW3.2b-2 cutover shipped without this and most
/// outdoor scenery landed in garbage buckets no walk turn ever reads.</summary>
internal void BeginFrame(
RenderSceneQuery scene,
uint tupleLandblockId,
int renderCenterLbX,
int renderCenterLbY)
{
_scene = scene;
_tupleLandblockId = tupleLandblockId;
_renderCenterLbX = renderCenterLbX;
_renderCenterLbY = renderCenterLbY;
_cellCache.Clear();
_outdoorMaterialized.Clear();
_shellMaterialized.Clear();
foreach (List<RenderProjectionRecord> bucket in _outdoorByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values)
bucket.Clear();
// CopyIndexTo THROWS on an undersized destination (ArchRenderScene
// validates up front — the first connected gate run of the FW3.2b-2
// cutover crashed on exactly this at Aerlinthe's 5,040 outdoor
// statics), so presize from the query's own index counts.
int required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorStatic);
if (required > _sweepScratch.Length)
{
_sweepScratch = new RenderProjectionRecord[
Math.Max(required, _sweepScratch.Length * 2)];
}
int count = _scene.CopyIndexTo(RenderSceneIndex.OutdoorStatic, _sweepScratch);
for (int i = 0; i < count; i++)
{
ref readonly RenderProjectionRecord record = ref _sweepScratch[i];
if (record.EntityPayload.IsBuildingShell)
{
uint anchor = record.Source.BuildingShellAnchorCellId;
if (!_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells))
_shellsByAnchor[anchor] = shells = new List<RenderProjectionRecord>();
shells.Add(record);
continue;
}
uint cellId = LandscapeCellId(
record.Transform.Position, _renderCenterLbX, _renderCenterLbY);
if (!_outdoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket))
_outdoorByCell[cellId] = bucket = new List<RenderProjectionRecord>();
bucket.Add(record);
}
}
/// <summary>The landscape cell owning a RENDER-ORIGIN-RELATIVE position
/// — retail's 24 m cell grid inside the 192 m landblock, producing the
/// same TRUE <c>(lb &amp; 0xFFFF0000) | (cellX*8 + cellY + 1)</c> encoding
/// the walk's landscape turn emits: the relative block index
/// (<c>floor(p/192)</c>) plus the streaming center recovers the true
/// landblock byte, because entity positions carry
/// <c>(lbX CenterX)·192</c> world offsets
/// (<c>LandblockBuildFactory</c>'s <c>worldOffset</c>).</summary>
internal static uint LandscapeCellId(
Vector3 relativePosition, int renderCenterLbX, int renderCenterLbY)
{
int relBlockX = (int)MathF.Floor(relativePosition.X / 192f);
int relBlockY = (int)MathF.Floor(relativePosition.Y / 192f);
float localX = relativePosition.X - relBlockX * 192f;
float localY = relativePosition.Y - relBlockY * 192f;
int cellX = Math.Clamp((int)(localX / 24f), 0, 7);
int cellY = Math.Clamp((int)(localY / 24f), 0, 7);
uint landblock =
((uint)(byte)(renderCenterLbX + relBlockX) << 24)
| ((uint)(byte)(renderCenterLbY + relBlockY) << 16);
return landblock | (uint)(cellX * 8 + cellY + 1);
}
public WalkFrameStaticRecords GetCellStatics(uint cellId)
{
if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached;
// Same up-front-validation contract as CopyIndexTo: presize from the
// query's own count rather than probing with an undersized span.
int required = _scene.GetCellStaticCount(cellId);
if (required > _cellScratch.Length)
{
_cellScratch = new RenderProjectionRecord[
Math.Max(required, _cellScratch.Length * 2)];
}
int count = _scene.CopyCellStaticsTo(cellId, _cellScratch);
WalkFrameStaticRecords records = count == 0
? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }
: new WalkFrameStaticRecords(_cellScratch[..count], _tupleLandblockId);
_cellCache[cellId] = records;
return records;
}
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId)
{
if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached))
return cached;
WalkFrameStaticRecords records =
_outdoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket)
&& bucket.Count > 0
? new WalkFrameStaticRecords([.. bucket], _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
_outdoorMaterialized[cellId] = records;
return records;
}
public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building)
{
uint anchor = AnchorCellId(building);
if (anchor == 0)
return WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
if (_shellMaterialized.TryGetValue(anchor, out WalkFrameStaticRecords cached))
return cached;
WalkFrameStaticRecords records =
_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells)
&& shells.Count > 0
? new WalkFrameStaticRecords([.. shells], _tupleLandblockId)
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
_shellMaterialized[anchor] = records;
return records;
}
/// <summary>The building's authored shell anchor: its first non-exit
/// portal's destination cell — the SAME rule <c>LandblockLoader</c> used
/// when it stamped <c>BuildingShellAnchorCellId</c> on the shell entity.</summary>
internal static uint AnchorCellId(WalkBuilding building)
{
foreach (ref readonly WalkBldPortal portal in building.Portals.AsSpan())
{
if (portal.OtherCellId != 0xFFFFFFFFu)
return portal.OtherCellId;
}
return 0;
}
public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building)
{
if (!_buildings.TryGetEntry(building, out WalkBuildingFactory.Entry? entry))
{
throw new InvalidOperationException(
$"walk building 0x{building.PositionCellId:X8} has no committed registry entry");
}
return entry.WorldTransform;
}
}