feat(content): bake and read flat collision assets

Append strict collision/topology payloads to the existing prepared package so later physics cutover can drop parsed DAT graphs without adding a second mapping or changing traversal behavior. The full 2,232,170-key catalog is deterministic across worker counts, exact-byte aliased, corruption-isolated, and cancellation-safe.
This commit is contained in:
Erik 2026-07-25 15:22:08 +02:00
parent d9300c7854
commit 9cd42417a8
29 changed files with 2868 additions and 63 deletions

View file

@ -1,6 +1,8 @@
using System.Collections.Immutable;
using System.Numerics;
using System.Runtime.CompilerServices;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
@ -15,6 +17,13 @@ namespace AcDream.Core.Physics;
/// </summary>
public static class FlatCollisionAssetBuilder
{
private static FlatPhysicsBsp EmptyPhysicsBsp() =>
new(
-1,
ImmutableArray<FlatPhysicsBspNode>.Empty,
ImmutableArray<int>.Empty,
FlatPolygonTable.Empty);
public static FlatPhysicsBsp FlattenPhysicsBsp(
PhysicsBSPNode? root,
IReadOnlyDictionary<ushort, ResolvedPolygon> resolved)
@ -270,6 +279,53 @@ public static class FlatCollisionAssetBuilder
source.StepDownHeight);
}
/// <summary>
/// Prepares the exact Setup collision fields used by
/// <see cref="PhysicsDataCache.CacheSetup"/> without retaining the parsed
/// DAT object. Source list order and every float bit are preserved.
/// </summary>
public static FlatSetupCollision FlattenSetup(Setup source)
{
ArgumentNullException.ThrowIfNull(source);
var cylinders = ImmutableArray.CreateBuilder<FlatCollisionCylinder>(
source.CylSpheres?.Count ?? 0);
if (source.CylSpheres is not null)
{
foreach (CylSphere cylinder in source.CylSpheres)
{
if (cylinder is null)
throw new InvalidDataException("Setup cylinder row is null.");
cylinders.Add(new FlatCollisionCylinder(
cylinder.Origin,
cylinder.Radius,
cylinder.Height));
}
}
var spheres = ImmutableArray.CreateBuilder<FlatCollisionSphere>(
source.Spheres?.Count ?? 0);
if (source.Spheres is not null)
{
foreach (Sphere sphere in source.Spheres)
{
if (sphere is null)
throw new InvalidDataException("Setup sphere row is null.");
spheres.Add(new FlatCollisionSphere(
sphere.Origin,
sphere.Radius));
}
}
return new FlatSetupCollision(
cylinders.MoveToImmutable(),
spheres.MoveToImmutable(),
source.Height,
source.Radius,
source.StepUpHeight,
source.StepDownHeight);
}
public static FlatGfxObjCollisionAsset FlattenGfxObj(
GfxObjPhysics source,
GfxObjVisualBounds? visualBounds = null)
@ -297,6 +353,139 @@ public static class FlatCollisionAssetBuilder
flatVisualBounds);
}
/// <summary>
/// Prepares one parsed GfxObj and drops the source graph. Objects without
/// a retail physics BSP still retain their visual bounds because the
/// current runtime uses those bounds as its collision fallback.
/// </summary>
public static FlatGfxObjCollisionAsset FlattenGfxObj(GfxObj source)
{
ArgumentNullException.ThrowIfNull(source);
GfxObjVisualBounds? visualBounds = source.VertexArray is null
? null
: PhysicsDataCache.ComputeVisualBounds(source.VertexArray);
FlatGfxObjVisualBounds? flatVisualBounds = visualBounds is null
? null
: new FlatGfxObjVisualBounds(
visualBounds.Min,
visualBounds.Max,
visualBounds.Center,
visualBounds.Radius,
visualBounds.HalfExtents);
bool hasPhysics =
source.Flags.HasFlag(GfxObjFlags.HasPhysics)
&& source.PhysicsBSP?.Root is not null
&& source.VertexArray is not null;
if (!hasPhysics)
{
return new FlatGfxObjCollisionAsset(
EmptyPhysicsBsp(),
null,
flatVisualBounds);
}
Dictionary<ushort, ResolvedPolygon> resolved =
PhysicsDataCache.ResolvePolygons(
source.PhysicsPolygons,
source.VertexArray!);
Sphere boundingSphere = source.PhysicsBSP!.Root!.BoundingSphere
?? throw new InvalidDataException(
"GfxObj physics BSP root has no bounding sphere.");
return new FlatGfxObjCollisionAsset(
FlattenPhysicsBsp(source.PhysicsBSP.Root, resolved),
new FlatCollisionSphere(
boundingSphere.Origin,
boundingSphere.Radius),
flatVisualBounds);
}
/// <summary>
/// Prepares reusable CellStruct collision geometry independently of any
/// EnvCell placement or topology.
/// </summary>
public static FlatCellStructureCollisionAsset FlattenCellStructure(
CellStruct source)
{
ArgumentNullException.ThrowIfNull(source);
FlatPhysicsBsp physicsBsp;
if (source.PhysicsBSP?.Root is null)
{
physicsBsp = EmptyPhysicsBsp();
}
else
{
Dictionary<ushort, ResolvedPolygon> resolved =
PhysicsDataCache.ResolvePolygons(
source.PhysicsPolygons,
source.VertexArray);
physicsBsp = FlattenPhysicsBsp(
source.PhysicsBSP.Root,
resolved);
}
Dictionary<ushort, ResolvedPolygon> portalResolved =
PhysicsDataCache.ResolvePolygons(
source.Polygons,
source.VertexArray);
return new FlatCellStructureCollisionAsset(
physicsBsp,
FlattenCellContainmentBsp(source.CellBSP?.Root),
FlattenPolygonTable(portalResolved));
}
/// <summary>
/// Prepares the per-cell portion of collision publication. Other-cell IDs
/// are expanded with the owning landblock exactly as
/// <see cref="PhysicsDataCache.CacheCellStruct"/> does.
/// </summary>
public static FlatEnvCellTopology FlattenEnvCellTopology(
uint envCellId,
EnvCell source,
FlatPolygonTable portalPolygons)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(portalPolygons);
var portals = ImmutableArray.CreateBuilder<FlatEnvCellPortal>(
source.CellPortals.Count);
foreach (CellPortal portal in source.CellPortals)
{
ushort polygonId = portal.PolygonId;
if (!portalPolygons.TryFindPolygonIndex(
polygonId,
out int polygonIndex))
{
throw new InvalidDataException(
$"EnvCell 0x{envCellId:X8} portal references missing " +
$"polygon 0x{polygonId:X4}.");
}
portals.Add(new FlatEnvCellPortal(
portal.OtherCellId,
polygonId,
(ushort)portal.Flags,
polygonIndex));
}
uint prefix = envCellId & 0xFFFF_0000u;
uint[] visibleIds = source.VisibleCells is null
? []
: source.VisibleCells
.Select(id => prefix | id)
.Distinct()
.Order()
.ToArray();
return new FlatEnvCellTopology(
portals.MoveToImmutable(),
ImmutableArray.Create(visibleIds),
source.Flags.HasFlag(EnvCellFlags.SeenOutside));
}
public static FlatCellCollisionAsset FlattenCell(CellPhysics source)
{
ArgumentNullException.ThrowIfNull(source);
@ -310,9 +499,31 @@ public static class FlatCollisionAssetBuilder
source.PortalPolygons
?? new Dictionary<ushort, ResolvedPolygon>());
var portals = ImmutableArray.CreateBuilder<FlatEnvCellPortal>(
source.Portals.Count);
foreach (PortalInfo portal in source.Portals)
FlatEnvCellTopology topology = FlattenEnvCellTopology(
source.Portals,
source.VisibleCellIds,
source.SeenOutside,
portalPolygons);
var structure = new FlatCellStructureCollisionAsset(
physicsBsp,
containmentBsp,
portalPolygons);
return new FlatCellCollisionAsset(structure, topology);
}
public static FlatEnvCellTopology FlattenEnvCellTopology(
IReadOnlyList<PortalInfo> portals,
IEnumerable<uint> visibleCellIds,
bool seenOutside,
FlatPolygonTable portalPolygons)
{
ArgumentNullException.ThrowIfNull(portals);
ArgumentNullException.ThrowIfNull(visibleCellIds);
ArgumentNullException.ThrowIfNull(portalPolygons);
var flatPortals = ImmutableArray.CreateBuilder<FlatEnvCellPortal>(
portals.Count);
foreach (PortalInfo portal in portals)
{
if (!portalPolygons.TryFindPolygonIndex(
portal.PolygonId,
@ -323,24 +534,21 @@ public static class FlatCollisionAssetBuilder
$"0x{portal.PolygonId:X4}.");
}
portals.Add(new FlatEnvCellPortal(
flatPortals.Add(new FlatEnvCellPortal(
portal.OtherCellId,
portal.PolygonId,
portal.Flags,
polygonIndex));
}
uint[] visibleIds = source.VisibleCellIds.ToArray();
Array.Sort(visibleIds);
var topology = new FlatEnvCellTopology(
portals.MoveToImmutable(),
uint[] visibleIds = visibleCellIds
.Distinct()
.Order()
.ToArray();
return new FlatEnvCellTopology(
flatPortals.MoveToImmutable(),
ImmutableArray.Create(visibleIds),
source.SeenOutside);
var structure = new FlatCellStructureCollisionAsset(
physicsBsp,
containmentBsp,
portalPolygons);
return new FlatCellCollisionAsset(structure, topology);
seenOutside);
}
private static void AttachChild(

View file

@ -104,7 +104,7 @@ public sealed class PhysicsDataCache
/// Used as a fallback collision shape for entities whose Setup has no
/// physics data — we approximate collision using the visual extent.
/// </summary>
private static GfxObjVisualBounds ComputeVisualBounds(VertexArray vertexArray)
internal static GfxObjVisualBounds ComputeVisualBounds(VertexArray vertexArray)
{
if (vertexArray.Vertices == null || vertexArray.Vertices.Count == 0)
{