fix(render): Phase A8 RR7.3 — dat-driven BFS in BuildingLoader
RR7.2 fix made the indoor branch fire (119K frames vs 0), but visual verification showed missing interior textures — the inn's floor + lower wall sections rendered as fog-color clear instead of cell-mesh polygons. Root cause: BFS short-circuited at registry-build time on intermediate cells that hadn't yet streamed in. The Holtburg Inn has 2 entry portals + 209 interior leaves; if any intermediate cell wasn't loaded when lbInfo arrived, BFS stopped, EnvCellIds was a tiny subset of the building's true cells, camCellIds at the gate excluded most inn cells, and IndoorPass skipped their mesh entities → flat fog-color floor. Fix: walk the dat directly in BFS via `dats.Get<EnvCell>(cellId) .CellPortals` (matches WB PortalService.cs:67-79). BFS now completes deterministically at registry-build time regardless of cell load ordering. Exit-portal polygon collection (Step C) also gets a dat fallback so the stencil mask is complete on first indoor frame. BuildingLoader.Build signature gains two optional params: - dats: DatCollection? — null in unit tests preserves old behavior - landblockOrigin: Vector3 — translation for dat-side polygons Tests: 11/11 pass (unit-test path unchanged via dats == null). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
efe35201fc
commit
56673e1b1e
2 changed files with 154 additions and 47 deletions
|
|
@ -5895,7 +5895,17 @@ public sealed class GameWindow : IDisposable
|
||||||
uint lbRegistryKey = lb.LandblockId & 0xFFFF0000u;
|
uint lbRegistryKey = lb.LandblockId & 0xFFFF0000u;
|
||||||
_buildingRegistries[lbRegistryKey] =
|
_buildingRegistries[lbRegistryKey] =
|
||||||
AcDream.App.Rendering.Wb.BuildingLoader.Build(
|
AcDream.App.Rendering.Wb.BuildingLoader.Build(
|
||||||
lbInfo, lb.LandblockId, _cellVisibility.AllLoadedCells);
|
lbInfo,
|
||||||
|
lb.LandblockId,
|
||||||
|
_cellVisibility.AllLoadedCells,
|
||||||
|
// RR7.3: dat-driven BFS — completes regardless of which
|
||||||
|
// cells have streamed into _cellVisibility by the time
|
||||||
|
// lbInfo arrives. Without this, large multi-room
|
||||||
|
// buildings (Holtburg Inn = 209 leaves, 2 entry portals)
|
||||||
|
// had EnvCellIds short of the building's actual cell
|
||||||
|
// set when intermediate cells weren't yet loaded.
|
||||||
|
dats: _dats,
|
||||||
|
landblockOrigin: origin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
|
using DatReaderWriter;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering.Wb;
|
namespace AcDream.App.Rendering.Wb;
|
||||||
|
|
||||||
|
|
@ -14,20 +16,21 @@ namespace AcDream.App.Rendering.Wb;
|
||||||
/// <c>WorldBuilder.Shared/Services/PortalService.cs:43-97</c>):</para>
|
/// <c>WorldBuilder.Shared/Services/PortalService.cs:43-97</c>):</para>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item>Step A — seed the cell set from <c>BuildingInfo.Portals</c> entry portals.</item>
|
/// <item>Step A — seed the cell set from <c>BuildingInfo.Portals</c> entry portals.</item>
|
||||||
/// <item>Step B — BFS through <see cref="LoadedCell.Portals"/> to discover all
|
/// <item>Step B — BFS through <see cref="EnvCell.CellPortals"/> to discover all
|
||||||
/// interior cells reachable from the entry portals (interior portals only;
|
/// interior cells reachable from the entry portals (interior portals only;
|
||||||
/// exit portals — <c>OtherCellId == 0xFFFF</c> — terminate each BFS branch).</item>
|
/// exit portals — <c>OtherCellId == 0xFFFF</c> — terminate each BFS branch).
|
||||||
|
/// Walks the dat directly so BFS completes regardless of which cells happen
|
||||||
|
/// to be pre-loaded into <paramref name="cellsByCellId"/> (RR7.3 fix —
|
||||||
|
/// prior versions short-circuited on unloaded cells, missing large multi-
|
||||||
|
/// room buildings like the Holtburg Inn).</item>
|
||||||
/// <item>Step C — collect exit portal polygons in world space for the stencil
|
/// <item>Step C — collect exit portal polygons in world space for the stencil
|
||||||
/// pipeline (Phase A8 Steps 1+2, RR7 scope).</item>
|
/// pipeline (Phase A8 Steps 1+2, RR7 scope). Uses pre-loaded
|
||||||
|
/// <see cref="LoadedCell"/> entries for the world transform when available;
|
||||||
|
/// falls back to the dat-side <c>envCell.Position</c> when not.</item>
|
||||||
|
/// <item>Step D — stamp <see cref="LoadedCell.BuildingId"/> on pre-loaded cells.
|
||||||
|
/// Cells loaded later get stamped by the drain hook in <c>GameWindow</c>.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// <para>Cells whose <c>LoadedCell</c> entries are missing from
|
|
||||||
/// <paramref name="cellsByCellId"/> are silently skipped (BFS bails at the
|
|
||||||
/// unloaded cell). In production, streaming loads all cells for a landblock
|
|
||||||
/// before <see cref="Build"/> runs, so the dict is always complete.</para>
|
|
||||||
///
|
|
||||||
/// <para><c>LoadedCell.BuildingId</c> is stamped here in RR4 after <c>reg.Add(building)</c>.</para>
|
|
||||||
///
|
|
||||||
/// <para>Retail references:
|
/// <para>Retail references:
|
||||||
/// <c>docs/research/named-retail/acclient.h:32035</c> (<c>BuildInfo</c>) and
|
/// <c>docs/research/named-retail/acclient.h:32035</c> (<c>BuildInfo</c>) and
|
||||||
/// <c>:32094</c> (<c>CBldPortal</c>).</para>
|
/// <c>:32094</c> (<c>CBldPortal</c>).</para>
|
||||||
|
|
@ -36,21 +39,28 @@ public static class BuildingLoader
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a <see cref="BuildingRegistry"/> from the supplied landblock data.
|
/// Builds a <see cref="BuildingRegistry"/> from the supplied landblock data.
|
||||||
/// Building IDs are allocated sequentially starting at 1 (0 is reserved for
|
/// Building IDs are allocated sequentially starting at 1.
|
||||||
/// "no building" semantics used by <c>LoadedCell.BuildingId</c> in RR4).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="info">The dat-loaded <see cref="LandBlockInfo"/> for this landblock.</param>
|
/// <param name="info">The dat-loaded <see cref="LandBlockInfo"/> for this landblock.</param>
|
||||||
/// <param name="landblockId">The 32-bit landblock id (e.g. <c>0xA9B40000</c>).
|
/// <param name="landblockId">The 32-bit landblock id (e.g. <c>0xA9B40000</c>).
|
||||||
/// The high 16 bits are ORed with each 16-bit <c>OtherCellId</c> to produce
|
/// The high 16 bits are ORed with each 16-bit <c>OtherCellId</c> to produce
|
||||||
/// the full cell id.</param>
|
/// the full cell id.</param>
|
||||||
/// <param name="cellsByCellId">Pre-loaded cells keyed by full 32-bit cell id.
|
/// <param name="cellsByCellId">Pre-loaded cells keyed by full 32-bit cell id.
|
||||||
/// Used for BFS extension (Step B) and exit-polygon collection (Step C).
|
/// Used for stamping (Step D) and as a fast path for exit-polygon collection.
|
||||||
/// An empty dict is valid for unit tests; Step B and C are skipped per cell.</param>
|
/// Cells not in the dict are looked up from <paramref name="dats"/> if non-null.</param>
|
||||||
|
/// <param name="dats">Dat collection for dat-driven BFS + fallback polygon
|
||||||
|
/// resolution. May be null for unit tests (Step B short-circuits to the
|
||||||
|
/// dict-only walk, matching the pre-RR7.3 behavior).</param>
|
||||||
|
/// <param name="landblockOrigin">World-space origin of the landblock. Used
|
||||||
|
/// to translate dat-side cell positions for the polygon-fallback path.
|
||||||
|
/// Unused when <paramref name="dats"/> is null.</param>
|
||||||
/// <returns>A fully populated <see cref="BuildingRegistry"/>; never null.</returns>
|
/// <returns>A fully populated <see cref="BuildingRegistry"/>; never null.</returns>
|
||||||
public static BuildingRegistry Build(
|
public static BuildingRegistry Build(
|
||||||
LandBlockInfo info,
|
LandBlockInfo info,
|
||||||
uint landblockId,
|
uint landblockId,
|
||||||
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId)
|
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId,
|
||||||
|
DatCollection? dats = null,
|
||||||
|
Vector3 landblockOrigin = default)
|
||||||
{
|
{
|
||||||
var reg = new BuildingRegistry();
|
var reg = new BuildingRegistry();
|
||||||
if (info.Buildings is null || info.Buildings.Count == 0)
|
if (info.Buildings is null || info.Buildings.Count == 0)
|
||||||
|
|
@ -65,10 +75,6 @@ public static class BuildingLoader
|
||||||
var exitPortalPolys = new List<Vector3[]>();
|
var exitPortalPolys = new List<Vector3[]>();
|
||||||
|
|
||||||
// Step A: seed the cell set from BuildingInfo.Portals (entry portals).
|
// Step A: seed the cell set from BuildingInfo.Portals (entry portals).
|
||||||
// Each BuildingPortal.OtherCellId is a 16-bit cell-local id; OR with
|
|
||||||
// the landblock prefix for the full id.
|
|
||||||
// Defensive: skip OtherCellId == 0xFFFF (exit-portal sentinel in
|
|
||||||
// BuildingInfo — rare but WB guards against it too at PortalService.cs:58).
|
|
||||||
if (bInfo.Portals is not null)
|
if (bInfo.Portals is not null)
|
||||||
{
|
{
|
||||||
foreach (var portal in bInfo.Portals)
|
foreach (var portal in bInfo.Portals)
|
||||||
|
|
@ -78,46 +84,42 @@ public static class BuildingLoader
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step B: BFS through interior CellPortals to find the full cell set.
|
// Step B: BFS — dat-driven (RR7.3). Falls back to LoadedCell.Portals
|
||||||
// Uses pre-loaded LoadedCell.Portals (avoids a duplicate dat fetch per
|
// when dats is null (unit-test path).
|
||||||
// BFS step). Mirrors WB PortalService.cs:67-79.
|
|
||||||
// When cellsByCellId is empty (unit-test path), BFS immediately exits.
|
|
||||||
var queue = new Queue<uint>(envCellIds);
|
var queue = new Queue<uint>(envCellIds);
|
||||||
while (queue.Count > 0)
|
while (queue.Count > 0)
|
||||||
{
|
{
|
||||||
var current = queue.Dequeue();
|
var current = queue.Dequeue();
|
||||||
if (!cellsByCellId.TryGetValue(current, out var cell)) continue;
|
IReadOnlyList<(ushort OtherCellId, ushort PolygonId)>? neighbours =
|
||||||
foreach (var p in cell.Portals)
|
LookupNeighbours(current, cellsByCellId, dats);
|
||||||
|
if (neighbours is null) continue;
|
||||||
|
|
||||||
|
foreach (var (otherCellId, _) in neighbours)
|
||||||
{
|
{
|
||||||
if (p.OtherCellId == 0xFFFF) continue; // exit portal — stop BFS here
|
if (otherCellId == 0xFFFF) continue;
|
||||||
uint neighbourId = lbMask | p.OtherCellId;
|
uint neighbourId = lbMask | otherCellId;
|
||||||
if (envCellIds.Add(neighbourId))
|
if (envCellIds.Add(neighbourId))
|
||||||
queue.Enqueue(neighbourId);
|
queue.Enqueue(neighbourId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step C: collect exit portal polygons in world space.
|
// Step C: collect exit portal polygons in world space.
|
||||||
// For each interior cell, iterate its portals; for each exit portal
|
// Fast path: use pre-loaded LoadedCell when available (already has
|
||||||
// (OtherCellId == 0xFFFF), transform the portal polygon vertices from
|
// WorldTransform + resolved PortalPolygons).
|
||||||
// cell-local space to world space via WorldTransform.
|
// Fallback path: walk the dat for cells not yet loaded so the
|
||||||
// Mirrors WB PortalService.cs:81-86 (GetPortalsForCell return path).
|
// stencil mask is complete on first render after registry build.
|
||||||
foreach (var cellId in envCellIds)
|
foreach (var cellId in envCellIds)
|
||||||
{
|
{
|
||||||
if (!cellsByCellId.TryGetValue(cellId, out var cell)) continue;
|
if (cellsByCellId.TryGetValue(cellId, out var cell))
|
||||||
for (int pi = 0; pi < cell.Portals.Count; pi++)
|
|
||||||
{
|
{
|
||||||
if (cell.Portals[pi].OtherCellId != 0xFFFF) continue;
|
CollectExitPolygonsFromLoadedCell(cell, exitPortalPolys);
|
||||||
if (pi >= cell.PortalPolygons.Count) continue;
|
}
|
||||||
var localPoly = cell.PortalPolygons[pi];
|
else if (dats is not null)
|
||||||
if (localPoly.Length < 3) continue;
|
{
|
||||||
var worldPoly = new Vector3[localPoly.Length];
|
CollectExitPolygonsFromDat(cellId, dats, landblockOrigin, exitPortalPolys);
|
||||||
for (int v = 0; v < localPoly.Length; v++)
|
|
||||||
worldPoly[v] = Vector3.Transform(localPoly[v], cell.WorldTransform);
|
|
||||||
exitPortalPolys.Add(worldPoly);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WB PortalService.cs:89: skip buildings with no interior cells.
|
|
||||||
if (envCellIds.Count == 0) continue;
|
if (envCellIds.Count == 0) continue;
|
||||||
|
|
||||||
var building = new Building
|
var building = new Building
|
||||||
|
|
@ -128,9 +130,9 @@ public static class BuildingLoader
|
||||||
};
|
};
|
||||||
reg.Add(building);
|
reg.Add(building);
|
||||||
|
|
||||||
// Step 4: stamp BuildingId on each cell (Option C — both directions
|
// Step D: stamp BuildingId on each pre-loaded cell. Cells loaded
|
||||||
// O(1)). The internal setter on LoadedCell.BuildingId is accessible
|
// later are stamped by the drain hook in GameWindow.cs that runs
|
||||||
// because this class lives in the same assembly (AcDream.App).
|
// _buildingRegistries.TryGetValue + GetBuildingsContainingCell.
|
||||||
foreach (var cellId in envCellIds)
|
foreach (var cellId in envCellIds)
|
||||||
{
|
{
|
||||||
if (cellsByCellId.TryGetValue(cellId, out var cell))
|
if (cellsByCellId.TryGetValue(cellId, out var cell))
|
||||||
|
|
@ -140,4 +142,99 @@ public static class BuildingLoader
|
||||||
|
|
||||||
return reg;
|
return reg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RR7.3: dat-or-loaded neighbour lookup. Returns the cell's portal list as
|
||||||
|
/// (OtherCellId, PolygonId) tuples so the BFS body can stay agnostic about
|
||||||
|
/// the source.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<(ushort OtherCellId, ushort PolygonId)>? LookupNeighbours(
|
||||||
|
uint cellId,
|
||||||
|
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId,
|
||||||
|
DatCollection? dats)
|
||||||
|
{
|
||||||
|
if (cellsByCellId.TryGetValue(cellId, out var loaded))
|
||||||
|
{
|
||||||
|
var result = new (ushort, ushort)[loaded.Portals.Count];
|
||||||
|
for (int i = 0; i < loaded.Portals.Count; i++)
|
||||||
|
result[i] = (loaded.Portals[i].OtherCellId, loaded.Portals[i].PolygonId);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dats is null) return null;
|
||||||
|
|
||||||
|
var envCell = dats.Get<EnvCell>(cellId);
|
||||||
|
if (envCell is null) return null;
|
||||||
|
|
||||||
|
var fromDat = new (ushort, ushort)[envCell.CellPortals.Count];
|
||||||
|
for (int i = 0; i < envCell.CellPortals.Count; i++)
|
||||||
|
fromDat[i] = (envCell.CellPortals[i].OtherCellId, envCell.CellPortals[i].PolygonId);
|
||||||
|
return fromDat;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectExitPolygonsFromLoadedCell(LoadedCell cell, List<Vector3[]> sink)
|
||||||
|
{
|
||||||
|
for (int pi = 0; pi < cell.Portals.Count; pi++)
|
||||||
|
{
|
||||||
|
if (cell.Portals[pi].OtherCellId != 0xFFFF) continue;
|
||||||
|
if (pi >= cell.PortalPolygons.Count) continue;
|
||||||
|
var localPoly = cell.PortalPolygons[pi];
|
||||||
|
if (localPoly.Length < 3) continue;
|
||||||
|
var worldPoly = new Vector3[localPoly.Length];
|
||||||
|
for (int v = 0; v < localPoly.Length; v++)
|
||||||
|
worldPoly[v] = Vector3.Transform(localPoly[v], cell.WorldTransform);
|
||||||
|
sink.Add(worldPoly);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RR7.3 fallback: resolve a cell's exit portal polygons directly from the
|
||||||
|
/// dat when the cell hasn't yet streamed into <see cref="CellVisibility"/>.
|
||||||
|
/// Mirrors <c>PortalService.GetPortalsForCell</c> at
|
||||||
|
/// <c>WorldBuilder.Shared/Services/PortalService.cs:99-139</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static void CollectExitPolygonsFromDat(
|
||||||
|
uint cellId,
|
||||||
|
DatCollection dats,
|
||||||
|
Vector3 landblockOrigin,
|
||||||
|
List<Vector3[]> sink)
|
||||||
|
{
|
||||||
|
var envCell = dats.Get<EnvCell>(cellId);
|
||||||
|
if (envCell is null) return;
|
||||||
|
if (envCell.EnvironmentId == 0) return;
|
||||||
|
|
||||||
|
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||||||
|
if (environment is null) return;
|
||||||
|
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) return;
|
||||||
|
|
||||||
|
var cellOriginWorld = envCell.Position.Origin + landblockOrigin;
|
||||||
|
var cellTransform =
|
||||||
|
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||||
|
Matrix4x4.CreateTranslation(cellOriginWorld);
|
||||||
|
|
||||||
|
foreach (var portal in envCell.CellPortals)
|
||||||
|
{
|
||||||
|
if (portal.OtherCellId != 0xFFFF) continue;
|
||||||
|
if (!cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)) continue;
|
||||||
|
if (poly.VertexIds.Count < 3) continue;
|
||||||
|
|
||||||
|
var localPoly = new Vector3[poly.VertexIds.Count];
|
||||||
|
bool allFound = true;
|
||||||
|
for (int v = 0; v < poly.VertexIds.Count; v++)
|
||||||
|
{
|
||||||
|
if (!cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[v], out var vtx))
|
||||||
|
{
|
||||||
|
allFound = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
localPoly[v] = vtx.Origin;
|
||||||
|
}
|
||||||
|
if (!allFound) continue;
|
||||||
|
|
||||||
|
var worldPoly = new Vector3[localPoly.Length];
|
||||||
|
for (int v = 0; v < localPoly.Length; v++)
|
||||||
|
worldPoly[v] = Vector3.Transform(localPoly[v], cellTransform);
|
||||||
|
sink.Add(worldPoly);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue