Pure-computation collision engine for ground-based entity movement. Three components: TerrainSurface (outdoor heightmap Z interpolation), CellSurface (indoor EnvCell floor polygon projection), PhysicsEngine (top-level resolver with step-height enforcement, outdoor/indoor cell transitions via CellPortals, and gravity reporting). Uses PhysicsPolygons from CellStruct for walkable surfaces with brute-force polygon iteration (< 20 polys per cell). BSP tree acceleration deferred — same collision fidelity, simpler code. Standalone module with no rendering or networking dependencies. ~15-20 unit tests with fake data covering flat terrain, slopes, stairs, wall rejection, and cell transitions. Integration with the streaming system via ApplyLoadedTerrainLocked. Consumed by Phase B.2 (player movement mode, separate spec). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 KiB
Phase B.3 — Physics Collision Engine Design
Status: Spec, 2026-04-12, brainstormed with user.
Scope: A pure-computation collision engine that resolves entity positions against terrain heightmaps, EnvCell floor polygons, and cell transitions. No rendering, no networking — those live in Phase B.2 (player movement mode, separate spec). This module is the foundation for all future ground-based movement in acdream.
Parent: docs/plans/2026-04-11-roadmap.md — Phase B (Gameplay), sub-piece B.3.
Goals
- Given a position + movement delta, resolve where the entity ends up on the ground.
- Handle outdoor terrain (hills, slopes via heightmap bilinear interpolation).
- Handle indoor floors (buildings, cellars via EnvCell CellStruct floor polygons).
- Handle outdoor↔indoor transitions via CellPortal boundary detection.
- Enforce step-height limits (stairs OK, walls blocked).
- Apply gravity (entity falls when not on a surface).
- All logic is unit-testable without dat files, rendering, or networking.
Non-goals
- Full rigid-body physics (no bouncing, wall-sliding, momentum).
- Projectile collision (server-side in AC).
- NPC pathing (server-driven).
- Water depth / swimming (visual only).
- BSP tree traversal for polygon lookup. Floor polygon counts per cell are small (< 20); brute-force polygon iteration is sufficient for the MVP. If profiling shows this is a bottleneck later, BSP acceleration can be added behind the same API.
Architecture
Three components in src/AcDream.Core/Physics/:
1. TerrainSurface
Outdoor ground resolver for a single landblock.
Construction: takes a LandBlock.Height[] (81 bytes) + float[] heightTable (256 entries, region-global).
API:
public sealed class TerrainSurface
{
public TerrainSurface(byte[] heights, float[] heightTable);
/// <summary>
/// Bilinear-interpolated terrain Z at (localX, localY) in
/// landblock-local coordinates (0..192 range). Clamps to
/// the landblock bounds if the position is outside.
/// </summary>
public float SampleZ(float localX, float localY);
/// <summary>
/// Compute the outdoor cell ID for the given local position.
/// Outdoor cells are a flat 8×8 grid of 24×24 unit cells
/// numbered 0x0001..0x0040. The cell at (0,0) is 0x0001.
/// </summary>
public uint ComputeOutdoorCellId(float localX, float localY);
}
The SampleZ algorithm is the same bilinear interpolation currently in GameWindow.SampleTerrainZ — extract it into this reusable class. Heights are indexed x-major: heights[x * 9 + y] where x and y are vertex indices (0..8), each vertex covers 24 world units.
2. CellSurface
Indoor floor resolver for a single EnvCell.
Construction: takes an EnvCell (for position, orientation, portal list) + CellStruct (for floor polygons + vertex array).
API:
public sealed class CellSurface
{
/// <summary>The EnvCell's dat id (e.g., 0xA9B40100).</summary>
public uint CellId { get; }
/// <summary>The cell's world-space origin (from EnvCell.Position.Origin).</summary>
public Vector3 Origin { get; }
/// <summary>Portal connections to adjacent cells.</summary>
public IReadOnlyList<CellPortalInfo> Portals { get; }
public CellSurface(uint cellId, EnvCell envCell, CellStruct cellStruct);
/// <summary>
/// Project (worldX, worldY) onto this cell's floor polygons and
/// return the Z height at that point. Returns null if the XY
/// doesn't fall within any floor polygon (the entity isn't over
/// this cell's floor).
///
/// <para>
/// Uses PhysicsPolygons (not rendering Polygons) — these define
/// the walkable surfaces. For each polygon, does a point-in-
/// triangle test (after fan triangulation) + barycentric Z
/// interpolation. Brute-force iteration over all polygons;
/// cell polygon counts are small (typically < 20) so BSP
/// acceleration is deferred.
/// </para>
/// </summary>
public float? SampleFloorZ(float worldX, float worldY);
}
/// <summary>
/// Minimal portal-connection info extracted from EnvCell.CellPortals.
/// </summary>
public readonly record struct CellPortalInfo(
uint TargetCellId,
Vector3 PortalOrigin,
Vector3 PortalNormal);
Floor polygon coordinate space: CellStruct.VertexArray positions are in cell-local space. EnvCell.Position (origin + orientation) transforms them to landblock-local space. The SampleFloorZ method transforms the query point into cell-local space, tests against the PhysicsPolygons, then returns the result in world space.
3. PhysicsEngine
Top-level resolver that owns the per-landblock terrain surfaces and per-cell indoor surfaces.
API:
public sealed class PhysicsEngine
{
/// <summary>
/// Register terrain + EnvCell surfaces for a loaded landblock.
/// Called from the render thread in ApplyLoadedTerrain.
/// </summary>
public void AddLandblock(
uint landblockId,
TerrainSurface terrain,
IReadOnlyList<CellSurface> cells);
/// <summary>Remove all surfaces for an unloaded landblock.</summary>
public void RemoveLandblock(uint landblockId);
/// <summary>
/// Resolve a movement step. Given the entity's current world
/// position + cell id + a movement delta + a step-up height limit,
/// return the valid resolved position and the cell id the entity
/// ends up in.
/// </summary>
public ResolveResult Resolve(
Vector3 currentPos,
uint currentCellId,
Vector3 delta,
float stepUpHeight);
}
public readonly record struct ResolveResult(
Vector3 Position,
uint CellId,
bool IsOnGround);
Resolution algorithm:
1. Compute candidatePos = currentPos + delta (horizontal only; Z handled below)
2. Determine which landblock the candidate is in:
landblockX = floor((candidatePos.X + centerOffsetX) / 192)
landblockY = floor((candidatePos.Y + centerOffsetY) / 192)
(The offset accounts for the world origin being the center landblock.)
3. If currently in an OUTDOOR cell (cellId < 0x0100):
a. Sample terrain Z at candidatePos via TerrainSurface.SampleZ
b. Compute candidateZ = terrainZ
c. Step-height check: if |candidateZ - currentPos.Z| > stepUpHeight
AND candidateZ > currentPos.Z (going UP), reject horizontal
movement → keep currentPos.XY, keep currentPos.Z
d. Check if candidatePos crosses into any EnvCell:
for each CellSurface in this landblock:
if CellSurface.SampleFloorZ(candidateX, candidateY) is not null:
→ transition to indoor: use cell's floor Z, set cellId to cell's id
e. If no transition: set Z = candidateZ, compute outdoor cellId
4. If currently in an INDOOR cell (cellId >= 0x0100):
a. Sample floor Z from the current CellSurface
b. If SampleFloorZ returns null (walked off the edge of this cell's floor):
- Check CellPortals for a transition to an adjacent cell
- If a portal connects to another EnvCell: transition to that cell
- If a portal connects to outdoor: transition to outdoor (sample terrain Z)
- If no portal matches: reject movement (can't walk through walls)
c. If SampleFloorZ returns a value:
- Step-height check same as outdoor
- Set Z = floorZ
5. Gravity: if !isOnGround (no surface found under the entity):
candidatePos.Z -= gravity * dt
(Gravity is applied by the caller in Phase B.2; the engine just
reports isOnGround = false when no surface is found.)
6. Return ResolveResult(resolvedPos, resolvedCellId, isOnGround)
Note on gravity: the engine itself is stateless per call — it doesn't track falling velocity. It reports isOnGround = false when no surface is under the entity, and the caller (Phase B.2's movement mode) applies a gravity velocity accumulator between frames. This keeps the engine pure-functional and testable.
Note on world coordinates: the engine works in acdream's world space (center-landblock-relative). TerrainSurface works in landblock-local space (0..192); the engine translates between the two using the landblock's world offset (same math as ApplyLoadedTerrain).
Integration with existing code
Population (streaming → physics)
In GameWindow.ApplyLoadedTerrainLocked, after terrain mesh upload:
// Create physics surfaces for this landblock.
var terrainSurf = new TerrainSurface(lb.Heightmap.Height, _heightTable);
var cellSurfaces = new List<CellSurface>();
// Walk the same EnvCells we walk for rendering (Phase 7.1 / Task 8).
// For each cell with an EnvironmentId: create CellSurface from
// envCell + cellStruct.
// ... (reuse the interior-walker pattern) ...
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurf, cellSurfaces);
In the unload path (when RemoveLandblock fires), also call _physicsEngine.RemoveLandblock(lbId).
Consumption (Phase B.2 — future, not this spec)
The player movement mode will call _physicsEngine.Resolve(...) once per frame with the player's WASD-derived delta, then update the player entity's position and send the result to the server.
Testing strategy
All tests in tests/AcDream.Core.Tests/Physics/. No dat files needed — tests construct minimal fake data.
TerrainSurfaceTests
- Construct a
TerrainSurfacewith a known 81-byte height array + a known 256-float height table. - Flat terrain: all heights = same value →
SampleZreturns that value everywhere. - Slope: heights increase linearly across X →
SampleZat midpoints returns interpolated values. - Corner clamping: positions outside 0..192 are clamped to the boundary.
- Cell ID computation:
(0, 0)→ 0x0001,(23, 0)→ 0x0001,(24, 0)→ 0x0009 (second column),(191, 191)→ 0x0040 (last cell).
CellSurfaceTests
- Construct a minimal
CellStructwith one floor polygon (a flat square at Z=10, vertices at ±5 in X/Y). - Inside polygon:
SampleFloorZ(0, 0)→ 10.0. - Outside polygon:
SampleFloorZ(100, 100)→ null. - Sloped floor: polygon with varying Z → barycentric interpolation returns correct Z at midpoints.
PhysicsEngineTests
- Walk on flat terrain: delta=(1,0,0), terrain flat at Z=50 → resolved position has Z=50.
- Walk up a hill: terrain slopes from Z=50 to Z=60 → resolved Z follows the slope.
- Step up stairs (within height): small Z increase (< stepUpHeight) → accepted.
- Blocked by wall (exceeds height): large Z increase (> stepUpHeight) → horizontal movement rejected.
- Outdoor→indoor transition: walk into an EnvCell's floor area → cellId changes to indoor.
- Indoor→outdoor transition: walk out of an EnvCell → cellId changes to outdoor.
- Not on ground: position over empty space (between floors) → isOnGround = false.
Open questions (resolved during implementation)
- Portal boundary detection: the exact algorithm for "did the entity cross a portal" depends on the
CellPortaldat structure. Implementation will checkEnvCell.CellPortalsfor portal geometry and test the movement vector against it. If the portal geometry is just a reference (not spatial), the fallback is "if SampleFloorZ returns null on the current cell but non-null on an adjacent cell, transition." - Multi-story buildings: if two EnvCells overlap in XY (one above the other), the engine picks the one whose floor Z is closest to the entity's current Z. This handles the Holtburg foundry's upper floor vs ground floor.
- Landblock boundary crossing: when the entity walks from one landblock to another, the engine switches to the neighboring landblock's TerrainSurface. This requires the neighbor to be loaded (it should be — the streaming window keeps a radius around the observer).
Acceptance criteria
Phase B.3 is done when:
TerrainSurface.SampleZmatchesSampleTerrainZ's output for the same inputs.CellSurface.SampleFloorZcorrectly projects XY onto floor polygons and returns the interpolated Z.PhysicsEngine.Resolvehandles all test cases listed above (flat, slope, step, transition, gravity).- All tests pass with fake data — no dat files or rendering needed.
- Integration hook exists in
GameWindow.ApplyLoadedTerrainLockedto populate the engine from streamed landblocks. - Total test count increases by ~15-20.