Retail draws cell geometry at the dat EnvCell origin verbatim; the 0.02 m lift was our z-fight stand-in (register row AP-32, deleted in this commit). With the walk owning retail draw ORDER under WorldDepthContract Less (first-drawn-wins: DrawBlock terrain-then- objects per cell, DrawCells shells-then-contents), the coplanar tie-breaks the lift compensated for are now resolved the way retail resolves them. Deleted at every site: the PortalVisibilityBuilder const + the drawLiftZ Build parameter and its lifted exit-portal projection branch (gate and drawn geometry now share ONE space); the seal/punch fan lifts (DrawPortalDepthWrite + the walk's DrawWalkPunchFan); the LandblockBuildFactory drawn-cell-transform lift (render and physics share the one verbatim transform). The #130 proof flipped exactly as its own doc predicted: UnliftedGate_LeavesTheStripAtTheDrawnTopEdge is deleted (its premise - gate space != drawn space - no longer exists), and the renamed ExitDoorTopEdge_GateCoversTheDrawnApertureWithinPixelTolerance sweep (147 eye/gaze combos at the Holtburg corner door) passes with both in the same unlifted space (worst plane gap <= 1.2 px, scissor <= 0.15 px - unchanged tolerances). Ten more replay-test call sites swept to the new Build signature. Suites: full Release build 0 warnings; hermetic 6,750/0; the 21 affected InstalledDat replay tests green; Walk conformance 40/1 untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
363 lines
14 KiB
C#
363 lines
14 KiB
C#
using System.Collections.Immutable;
|
||
using System.Numerics;
|
||
using AcDream.App.Rendering.Walk;
|
||
using AcDream.Core.Rendering.Wb;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Enums;
|
||
using DatReaderWriter.Types;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// One drawable EnvCell shell prepared by the streaming worker. This is CPU-only
|
||
/// placement data; the render thread schedules mesh preparation when it commits
|
||
/// the containing <see cref="EnvCellLandblockBuild"/>.
|
||
/// </summary>
|
||
public sealed record EnvCellShellPlacement(
|
||
uint CellId,
|
||
ulong GeometryId,
|
||
uint EnvironmentId,
|
||
ushort CellStructure,
|
||
ImmutableArray<ushort> Surfaces,
|
||
Vector3 WorldPosition,
|
||
Quaternion Rotation,
|
||
Matrix4x4 Transform,
|
||
WbBoundingBox LocalBounds,
|
||
WbBoundingBox WorldBounds);
|
||
|
||
/// <summary>
|
||
/// Complete, immutable worker output for one landblock's indoor cells. It owns
|
||
/// both portal-visibility cells and drawable shell placements so neither can be
|
||
/// drained by, or mixed with, another streaming completion.
|
||
/// </summary>
|
||
public sealed class EnvCellLandblockBuild
|
||
{
|
||
public EnvCellLandblockBuild(
|
||
uint landblockId,
|
||
IEnumerable<LoadedCell> visibilityCells,
|
||
IEnumerable<EnvCellShellPlacement> shells,
|
||
IEnumerable<WalkBuildingFactory.Entry>? walkBuildings = null,
|
||
float walkMaxZ = 0f,
|
||
float walkMinZ = 0f)
|
||
{
|
||
LandblockId = landblockId;
|
||
VisibilityCells = visibilityCells.ToImmutableArray();
|
||
Shells = shells.ToImmutableArray();
|
||
WalkBuildings = (walkBuildings ?? Enumerable.Empty<WalkBuildingFactory.Entry>()).ToImmutableArray();
|
||
WalkMaxZ = walkMaxZ;
|
||
WalkMinZ = walkMinZ;
|
||
|
||
uint expectedPrefix = landblockId & 0xFFFF0000u;
|
||
if (VisibilityCells.Any(cell => (cell.CellId & 0xFFFF0000u) != expectedPrefix))
|
||
throw new ArgumentException("A visibility cell belongs to a different landblock.", nameof(visibilityCells));
|
||
if (Shells.Any(shell => (shell.CellId & 0xFFFF0000u) != expectedPrefix))
|
||
throw new ArgumentException("A render shell belongs to a different landblock.", nameof(shells));
|
||
if (WalkBuildings.Any(entry => (entry.Building.PositionCellId & 0xFFFF0000u) != expectedPrefix))
|
||
throw new ArgumentException("A walk building belongs to a different landblock.", nameof(walkBuildings));
|
||
}
|
||
|
||
public uint LandblockId { get; }
|
||
public ImmutableArray<LoadedCell> VisibilityCells { get; }
|
||
public ImmutableArray<EnvCellShellPlacement> Shells { get; }
|
||
|
||
/// <summary>Campaign FW3.1: this landblock's walk building placements
|
||
/// (<c>WalkBuildingFactory.Build</c>, computed worker-side alongside
|
||
/// <see cref="VisibilityCells"/>). Committed atomically with the rest of
|
||
/// this transaction — see <c>WalkBuildingRegistry.Publish</c> at
|
||
/// <c>LandblockRenderPublisher.AdvanceCompleteOne</c>.</summary>
|
||
public ImmutableArray<WalkBuildingFactory.Entry> WalkBuildings { get; }
|
||
|
||
/// <summary>Campaign FW3.1: this landblock's retail z-slab
|
||
/// (<c>heightTable[maxByte] + 200</c>) — the walk landscape's per-block
|
||
/// visibility bound (<c>WalkLandscapeAssembler.PublishLandblock</c>).
|
||
/// Computed once, worker-side, from the SAME heightmap bytes + height
|
||
/// table the terrain mesh build already reads — no extra DAT access.</summary>
|
||
public float WalkMaxZ { get; }
|
||
|
||
/// <summary>Campaign FW3.1: this landblock's retail z-slab
|
||
/// (<c>heightTable[minByte] − 1</c>). See <see cref="WalkMaxZ"/>.</summary>
|
||
public float WalkMinZ { get; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Transaction-local builder used only by one streaming job. Unlike the former
|
||
/// global pending bags, instances of this class are never shared between jobs or
|
||
/// observed by the render thread before <see cref="Build"/> returns.
|
||
/// </summary>
|
||
public sealed class EnvCellLandblockBuildBuilder
|
||
{
|
||
private readonly uint _landblockId;
|
||
private readonly List<LoadedCell> _visibilityCells = new();
|
||
private readonly List<EnvCellShellPlacement> _shells = new();
|
||
private readonly List<WalkBuildingFactory.Entry> _walkBuildings = new();
|
||
private float _walkMaxZ;
|
||
private float _walkMinZ;
|
||
private bool _built;
|
||
|
||
public EnvCellLandblockBuildBuilder(uint landblockId)
|
||
{
|
||
_landblockId = landblockId;
|
||
}
|
||
|
||
/// <summary>Campaign FW3.1: registers this landblock's walk building
|
||
/// placements (<c>WalkBuildingFactory.Build</c>'s output). Additive to
|
||
/// the existing cell/shell accumulation — called once per landblock from
|
||
/// <c>LandblockBuildFactory.BuildInteriorEntitiesForStreaming</c>, where
|
||
/// <c>LandBlockInfo</c> is already in hand.</summary>
|
||
public void AddWalkBuildings(IEnumerable<WalkBuildingFactory.Entry> entries)
|
||
{
|
||
if (_built)
|
||
throw new InvalidOperationException("This landblock cell build is already complete.");
|
||
_walkBuildings.AddRange(entries);
|
||
}
|
||
|
||
/// <summary>Campaign FW3.1: registers this landblock's retail z-slab
|
||
/// (<see cref="EnvCellLandblockBuild.WalkMaxZ"/>/<see cref="EnvCellLandblockBuild.WalkMinZ"/>).
|
||
/// Independent of interior-cell/building presence — every near-tier
|
||
/// landblock has outdoor terrain heights, so
|
||
/// <c>LandblockBuildFactory.BuildLocked</c> calls this unconditionally.</summary>
|
||
public void SetWalkZSlab(float maxZ, float minZ)
|
||
{
|
||
if (_built)
|
||
throw new InvalidOperationException("This landblock cell build is already complete.");
|
||
_walkMaxZ = maxZ;
|
||
_walkMinZ = minZ;
|
||
}
|
||
|
||
public void AddCell(
|
||
uint envCellId,
|
||
EnvCell envCell,
|
||
CellStruct cellStruct,
|
||
Vector3 physicsCellOrigin,
|
||
Matrix4x4 physicsCellTransform,
|
||
Vector3 shellWorldPosition,
|
||
Matrix4x4 shellTransform,
|
||
bool hasDrawableGeometry)
|
||
{
|
||
if (_built)
|
||
throw new InvalidOperationException("This landblock cell build is already complete.");
|
||
|
||
var localBounds = ComputeLocalBounds(cellStruct);
|
||
_visibilityCells.Add(BuildVisibilityCell(
|
||
envCellId,
|
||
envCell,
|
||
cellStruct,
|
||
physicsCellOrigin,
|
||
physicsCellTransform,
|
||
localBounds));
|
||
|
||
if (!hasDrawableGeometry)
|
||
return;
|
||
|
||
ulong geometryId = ComputeGeometryId(
|
||
envCell.EnvironmentId,
|
||
envCell.CellStructure,
|
||
envCell.Surfaces);
|
||
_shells.Add(new EnvCellShellPlacement(
|
||
envCellId,
|
||
geometryId,
|
||
envCell.EnvironmentId,
|
||
envCell.CellStructure,
|
||
envCell.Surfaces.ToImmutableArray(),
|
||
shellWorldPosition,
|
||
envCell.Position.Orientation,
|
||
shellTransform,
|
||
localBounds,
|
||
TransformBoundingBox(localBounds, shellTransform)));
|
||
}
|
||
|
||
public EnvCellLandblockBuild Build()
|
||
{
|
||
if (_built)
|
||
throw new InvalidOperationException("This landblock cell build is already complete.");
|
||
_built = true;
|
||
return new EnvCellLandblockBuild(
|
||
_landblockId, _visibilityCells, _shells, _walkBuildings, _walkMaxZ, _walkMinZ);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Collision-resistant successor to WB EnvCellRenderManager.cs:94-103.
|
||
/// The legacy polynomial and its installed-DAT collision remain covered by
|
||
/// Core conformance tests.
|
||
/// </summary>
|
||
public static ulong ComputeGeometryId(
|
||
uint environmentId,
|
||
ushort cellStructure,
|
||
IReadOnlyList<ushort> surfaces)
|
||
=> EnvCellGeometryIdentity.Compute(environmentId, cellStructure, surfaces);
|
||
|
||
private static WbBoundingBox ComputeLocalBounds(CellStruct cellStruct)
|
||
{
|
||
var min = new Vector3(float.MaxValue);
|
||
var max = new Vector3(float.MinValue);
|
||
foreach (var vertex in cellStruct.VertexArray.Vertices.Values)
|
||
{
|
||
var position = new Vector3(vertex.Origin.X, vertex.Origin.Y, vertex.Origin.Z);
|
||
min = Vector3.Min(min, position);
|
||
max = Vector3.Max(max, position);
|
||
}
|
||
|
||
return min.X == float.MaxValue
|
||
? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
|
||
: new WbBoundingBox(min, max);
|
||
}
|
||
|
||
private static WbBoundingBox TransformBoundingBox(WbBoundingBox box, Matrix4x4 transform)
|
||
{
|
||
Span<Vector3> corners = stackalloc Vector3[8]
|
||
{
|
||
new(box.Min.X, box.Min.Y, box.Min.Z),
|
||
new(box.Max.X, box.Min.Y, box.Min.Z),
|
||
new(box.Min.X, box.Max.Y, box.Min.Z),
|
||
new(box.Max.X, box.Max.Y, box.Min.Z),
|
||
new(box.Min.X, box.Min.Y, box.Max.Z),
|
||
new(box.Max.X, box.Min.Y, box.Max.Z),
|
||
new(box.Min.X, box.Max.Y, box.Max.Z),
|
||
new(box.Max.X, box.Max.Y, box.Max.Z),
|
||
};
|
||
|
||
var min = new Vector3(float.MaxValue);
|
||
var max = new Vector3(float.MinValue);
|
||
foreach (var corner in corners)
|
||
{
|
||
var transformed = Vector3.Transform(corner, transform);
|
||
min = Vector3.Min(min, transformed);
|
||
max = Vector3.Max(max, transformed);
|
||
}
|
||
|
||
return new WbBoundingBox(min, max);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mechanical extraction of the former GameWindow.BuildLoadedCell. Portal-side behavior
|
||
/// is unchanged: retail PView::InitCell (0x005a4b70) uses the dat PortalSide
|
||
/// bit, and the reciprocal portal index follows the named retail path at
|
||
/// pseudo-C line 433557.
|
||
/// </summary>
|
||
private static LoadedCell BuildVisibilityCell(
|
||
uint envCellId,
|
||
EnvCell envCell,
|
||
CellStruct cellStruct,
|
||
Vector3 cellOrigin,
|
||
Matrix4x4 cellTransform,
|
||
WbBoundingBox localBounds)
|
||
{
|
||
Matrix4x4.Invert(cellTransform, out var inverse);
|
||
|
||
var portals = new List<CellPortalInfo>();
|
||
var clipPlanes = new List<PortalClipPlane>();
|
||
var portalPolygons = new List<Vector3[]>();
|
||
|
||
foreach (var portal in envCell.CellPortals)
|
||
{
|
||
portals.Add(new CellPortalInfo(
|
||
portal.OtherCellId,
|
||
portal.PolygonId,
|
||
(ushort)portal.Flags,
|
||
portal.OtherPortalId));
|
||
|
||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
|
||
&& poly.VertexIds.Count >= 3)
|
||
{
|
||
Vector3 p0 = Vector3.Zero, p1 = Vector3.Zero, p2 = Vector3.Zero;
|
||
bool found = true;
|
||
if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0))
|
||
p0 = new Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z);
|
||
else
|
||
found = false;
|
||
if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1))
|
||
p1 = new Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z);
|
||
else
|
||
found = false;
|
||
if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2))
|
||
p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
|
||
else
|
||
found = false;
|
||
|
||
if (found)
|
||
{
|
||
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
|
||
float d = -Vector3.Dot(normal, p0);
|
||
int insideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0;
|
||
clipPlanes.Add(new PortalClipPlane
|
||
{
|
||
Normal = normal,
|
||
D = d,
|
||
InsideSide = insideSide,
|
||
});
|
||
}
|
||
else
|
||
{
|
||
clipPlanes.Add(default);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
clipPlanes.Add(default);
|
||
}
|
||
|
||
Vector3[] polygonVertices = Array.Empty<Vector3>();
|
||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPolygon)
|
||
&& portalPolygon.VertexIds.Count >= 3)
|
||
{
|
||
polygonVertices = new Vector3[portalPolygon.VertexIds.Count];
|
||
bool allResolved = true;
|
||
for (int vertexIndex = 0; vertexIndex < portalPolygon.VertexIds.Count; vertexIndex++)
|
||
{
|
||
if (cellStruct.VertexArray.Vertices.TryGetValue(
|
||
(ushort)portalPolygon.VertexIds[vertexIndex], out var vertex))
|
||
{
|
||
polygonVertices[vertexIndex] = new Vector3(
|
||
vertex.Origin.X,
|
||
vertex.Origin.Y,
|
||
vertex.Origin.Z);
|
||
}
|
||
else
|
||
{
|
||
allResolved = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!allResolved)
|
||
polygonVertices = Array.Empty<Vector3>();
|
||
}
|
||
portalPolygons.Add(polygonVertices);
|
||
}
|
||
|
||
uint landblockPrefix = envCellId & 0xFFFF0000u;
|
||
var visibleCells = new List<uint>();
|
||
if (envCell.VisibleCells is not null)
|
||
{
|
||
foreach (var lowId in envCell.VisibleCells)
|
||
visibleCells.Add(landblockPrefix | lowId);
|
||
}
|
||
|
||
return new LoadedCell
|
||
{
|
||
CellId = envCellId,
|
||
WorldPosition = cellOrigin,
|
||
WorldTransform = cellTransform,
|
||
InverseWorldTransform = inverse,
|
||
LocalBoundsMin = localBounds.Min,
|
||
LocalBoundsMax = localBounds.Max,
|
||
Portals = portals,
|
||
ClipPlanes = clipPlanes,
|
||
PortalPolygons = portalPolygons,
|
||
VisibleCells = visibleCells,
|
||
SeenOutside = envCell.Flags.HasFlag(EnvCellFlags.SeenOutside),
|
||
// Campaign FW3.1: the walk's model of this cell, built from the
|
||
// SAME EnvCell/CellStruct/transform this LoadedCell was just
|
||
// built from — WalkCellFactory.FromParsed does its own portal
|
||
// pass (independent of the clipPlanes/portalPolygons above; the
|
||
// two consumers decode the same dat fields for different needs)
|
||
// so it stays a direct, auditable port of the FW1 test adapter
|
||
// rather than reshaping LoadedCell's own list layout around it.
|
||
// cellTransform/inverse are the dat EnvCell origin verbatim —
|
||
// since FW3.3 retired ShellDrawLiftZ, render and physics share
|
||
// this one transform (retail draws at the origin; the walk's
|
||
// retail draw order owns the coplanar tie-breaks).
|
||
Walk = WalkCellFactory.FromParsed(envCellId, envCell, cellStruct, cellTransform, inverse),
|
||
};
|
||
}
|
||
}
|