acdream/src/AcDream.Core/Physics/PhysicsDataCache.cs
Erik ff325abd7b feat(ui): debug overlay + refined input controls
Adds the first on-screen HUD for the dev client plus today's mouse-control
refinements. Also lands yesterday's scenery-alignment changes that were
left uncommitted in the working tree.

Overlay:
- BitmapFont rasterizes a system TTF via StbTrueTypeSharp into a 512x512
  R8 atlas at startup (Consolas on Windows, DejaVu/Menlo fallbacks)
- TextRenderer batches 2D quads in screen-space with ortho projection;
  one shader + two draw calls (rect then text) for panel backgrounds
  under glyphs
- DebugOverlay composes info / stats / compass / help panels on top of
  the 3D scene; toggles via F1/F4/F5/F6; transient toasts for key events
- DebugLineRenderer and its shaders (carried over from the scenery work)
  are properly committed in this commit

Controls:
- Per-mode mouse sensitivity (Chase 0.15, Fly 1.0, Orbit 1.0); F8/F9 to
  adjust the active mode multiplicatively (x1.2)
- Hold RMB to free-orbit the chase camera around the player; release
  stays at the new angle (no snap-back)
- Mouse-wheel zooms chase distance between 2m and 40m
- Chase pitch widened to [-0.7, 1.4] so mouse-Y tilts both ways from
  the default neutral angle

Scenery alignment (carried from yesterday's session):
- ShadowObjectRegistry AllEntriesForDebug + Scale field
- SceneryGenerator uses ACViewer's OnRoad polygon test + baseLoc +
  set_heading rotation
- BSPQuery dispatchers accept localToWorld so normals/offsets transform
  correctly per part
- TransitionTypes.CylinderCollision rewritten with wall-slide + push-out
- PhysicsDataCache caches visual-mesh AABB for scenery that lacks
  physics Setup bounds
2026-04-17 18:45:38 +02:00

290 lines
11 KiB
C#

using System.Collections.Concurrent;
using System.Numerics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Plane = System.Numerics.Plane;
namespace AcDream.Core.Physics;
/// <summary>
/// Thread-safe cache of physics-relevant data extracted from GfxObj and Setup
/// dat objects during streaming. Populated by the streaming worker thread;
/// read by the physics engine on the game/render thread. ConcurrentDictionary
/// makes cross-thread access safe without a global lock.
/// </summary>
public sealed class PhysicsDataCache
{
private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();
private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new();
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
/// <summary>
/// Extract and cache the physics BSP + polygon data from a GfxObj,
/// PLUS always cache a visual AABB from the vertex data regardless of
/// the HasPhysics flag. The visual AABB is used as a collision fallback
/// for entities whose Setup has no retail physics data — it lets the
/// user collide with decorative meshes that don't have a CylSphere or
/// per-part BSP.
/// </summary>
public void CacheGfxObj(uint gfxObjId, GfxObj gfxObj)
{
// Always cache a visual AABB from the mesh vertices — this is cheap
// and fed by the mesh data that's already loaded. It serves as the
// fallback collision shape for pure-visual entities.
if (!_visualBounds.ContainsKey(gfxObjId) && gfxObj.VertexArray != null)
{
_visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray);
}
if (_gfxObj.ContainsKey(gfxObjId)) return;
if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)) return;
if (gfxObj.PhysicsBSP?.Root is null) return;
if (gfxObj.VertexArray is null) return;
_gfxObj[gfxObjId] = new GfxObjPhysics
{
BSP = gfxObj.PhysicsBSP,
PhysicsPolygons = gfxObj.PhysicsPolygons,
BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere,
Vertices = gfxObj.VertexArray,
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
};
}
/// <summary>
/// Get the cached visual AABB for a GfxObj, or null if not cached.
/// </summary>
public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) =>
_visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null;
/// <summary>
/// Compute a tight axis-aligned bounding box over all vertices in the mesh.
/// 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)
{
if (vertexArray.Vertices == null || vertexArray.Vertices.Count == 0)
{
return new GfxObjVisualBounds
{
Min = Vector3.Zero,
Max = Vector3.Zero,
Center = Vector3.Zero,
Radius = 0f,
HalfExtents = Vector3.Zero,
};
}
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
foreach (var kv in vertexArray.Vertices)
{
var p = kv.Value.Origin;
if (p.X < min.X) min.X = p.X;
if (p.Y < min.Y) min.Y = p.Y;
if (p.Z < min.Z) min.Z = p.Z;
if (p.X > max.X) max.X = p.X;
if (p.Y > max.Y) max.Y = p.Y;
if (p.Z > max.Z) max.Z = p.Z;
}
var center = (min + max) * 0.5f;
var halfExt = (max - min) * 0.5f;
float radius = halfExt.Length();
return new GfxObjVisualBounds
{
Min = min,
Max = max,
Center = center,
Radius = radius,
HalfExtents = halfExt,
};
}
/// <summary>
/// Extract and cache the collision shape data from a Setup.
/// No-ops if the id is already cached.
/// </summary>
public void CacheSetup(uint setupId, Setup setup)
{
if (_setup.ContainsKey(setupId)) return;
_setup[setupId] = new SetupPhysics
{
CylSpheres = setup.CylSpheres ?? new(),
Spheres = setup.Spheres ?? new(),
Height = setup.Height,
Radius = setup.Radius,
StepUpHeight = setup.StepUpHeight,
StepDownHeight = setup.StepDownHeight,
};
}
/// <summary>
/// Extract and cache the physics BSP + polygon data from a CellStruct
/// (indoor room geometry). No-ops if the id is already cached or the
/// CellStruct has no physics BSP.
/// </summary>
public void CacheCellStruct(uint envCellId, CellStruct cellStruct,
Matrix4x4 worldTransform)
{
if (_cellStruct.ContainsKey(envCellId)) return;
if (cellStruct.PhysicsBSP?.Root is null) return;
Matrix4x4.Invert(worldTransform, out var inverseTransform);
_cellStruct[envCellId] = new CellPhysics
{
BSP = cellStruct.PhysicsBSP,
PhysicsPolygons = cellStruct.PhysicsPolygons,
Vertices = cellStruct.VertexArray,
WorldTransform = worldTransform,
InverseWorldTransform = inverseTransform,
Resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray),
};
}
/// <summary>
/// Pre-resolve all physics polygons: lookup vertex positions from VertexArray
/// and compute the face plane. Matches ACE's Polygon constructor which calls
/// make_plane() and resolves Vertices from VertexIDs at load time.
/// </summary>
private static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
Dictionary<ushort, DatReaderWriter.Types.Polygon> polys,
VertexArray vertexArray)
{
var resolved = new Dictionary<ushort, ResolvedPolygon>(polys.Count);
foreach (var (id, poly) in polys)
{
int numVerts = poly.VertexIds.Count;
if (numVerts < 3) continue;
var verts = new Vector3[numVerts];
bool valid = true;
for (int i = 0; i < numVerts; i++)
{
ushort vid = (ushort)poly.VertexIds[i];
if (!vertexArray.Vertices.TryGetValue(vid, out var sv))
{ valid = false; break; }
verts[i] = sv.Origin;
}
if (!valid) continue;
// Compute plane normal using ACE's make_plane algorithm:
// fan cross-product accumulation + normalization.
var normal = Vector3.Zero;
for (int i = 1; i < numVerts - 1; i++)
{
var v1 = verts[i] - verts[0];
var v2 = verts[i + 1] - verts[0];
normal += Vector3.Cross(v1, v2);
}
float len = normal.Length();
if (len < 1e-8f) continue;
normal /= len;
// D = -(average dot(normal, vertex))
float dotSum = 0f;
for (int i = 0; i < numVerts; i++)
dotSum += Vector3.Dot(normal, verts[i]);
float d = -(dotSum / numVerts);
resolved[id] = new ResolvedPolygon
{
Vertices = verts,
Plane = new Plane(normal, d),
NumPoints = numVerts,
SidesType = poly.SidesType,
};
}
return resolved;
}
public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null;
public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null;
public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null;
public int GfxObjCount => _gfxObj.Count;
public int SetupCount => _setup.Count;
public int CellStructCount => _cellStruct.Count;
}
/// <summary>
/// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless
/// of whether it has physics data. Used as a collision fallback shape for
/// entities whose Setup has no CylSpheres/Spheres/Radius (pure decorative
/// meshes). Provides an approximate cylinder matching the visible mesh extent.
/// </summary>
public sealed class GfxObjVisualBounds
{
/// <summary>Local-space minimum corner of the mesh AABB.</summary>
public required Vector3 Min { get; init; }
/// <summary>Local-space maximum corner of the mesh AABB.</summary>
public required Vector3 Max { get; init; }
/// <summary>Center of the local-space AABB.</summary>
public required Vector3 Center { get; init; }
/// <summary>Local-space radius (diagonal half-length) — loose bound.</summary>
public required float Radius { get; init; }
/// <summary>Local-space half-extents ((Max - Min) * 0.5).</summary>
public required Vector3 HalfExtents { get; init; }
}
/// <summary>
/// A physics polygon with pre-resolved vertex positions and pre-computed plane.
/// ACE pre-computes these in its Polygon constructor; we do it at cache time
/// to avoid per-collision-test vertex lookups.
/// </summary>
public sealed class ResolvedPolygon
{
public required Vector3[] Vertices { get; init; }
public required Plane Plane { get; init; }
public required int NumPoints { get; init; }
public required CullMode SidesType { get; init; }
}
/// <summary>Cached physics data for a single GfxObj part.</summary>
public sealed class GfxObjPhysics
{
public required PhysicsBSPTree BSP { get; init; }
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
public Sphere? BoundingSphere { get; init; }
public required VertexArray Vertices { get; init; }
/// <summary>
/// Pre-resolved polygon data with vertex positions and computed planes.
/// Populated once at cache time so BSP queries don't pay per-test lookup cost.
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
}
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>
public sealed class SetupPhysics
{
public List<CylSphere> CylSpheres { get; init; } = new();
public List<Sphere> Spheres { get; init; } = new();
public float Height { get; init; }
public float Radius { get; init; }
public float StepUpHeight { get; init; }
public float StepDownHeight { get; init; }
}
/// <summary>
/// Cached physics data for an indoor cell's room geometry (CellStruct).
/// Used for wall/floor/ceiling collision in EnvCells.
/// ACE: EnvCell.find_env_collisions queries CellStructure.PhysicsBSP.
/// </summary>
public sealed class CellPhysics
{
public required PhysicsBSPTree BSP { get; init; }
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
public required VertexArray Vertices { get; init; }
public Matrix4x4 WorldTransform { get; init; }
public Matrix4x4 InverseWorldTransform { get; init; }
/// <summary>
/// Pre-resolved polygon data with vertex positions and computed planes.
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
}