feat(render): portal-based EnvCell visibility (Step 4)

Port ACME's EnvCellManager portal visibility system:

- New CellVisibility class: BFS portal traversal from camera cell,
  portal-side clip-plane test, FindCameraCell with grace period
- LoadedCell data populated during streaming (portals, clip planes,
  world/inverse transforms, local AABB from CellStruct vertices)
- WorldEntity.ParentCellId tags interior entities for filtering
- InstancedMeshRenderer.Draw accepts optional visibleCellIds set —
  interior entities whose parent cell isn't visible are skipped
- Conditional depth clear between terrain and static mesh when
  camera is inside a cell (ACME GameScene.cs pattern)

When camera is outdoors, all interiors render (visibleCellIds=null).
When camera enters a building, only BFS-reachable cells render.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 22:20:52 +02:00
parent 25090b6fc9
commit cffc3ee343
4 changed files with 589 additions and 16 deletions

View file

@ -37,6 +37,9 @@ public sealed class GameWindow : IDisposable
// Phase B.3: physics engine — populated from the streaming pipeline.
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
// Step 4: portal-based interior cell visibility.
private readonly CellVisibility _cellVisibility = new();
// Phase A.1 hotfix: DatCollection is NOT thread-safe. The streaming worker
// thread and the render thread both read dats (BuildLandblockForStreaming
// on the worker; ApplyLoadedTerrain + live-spawn handlers on the render
@ -62,6 +65,10 @@ public sealed class GameWindow : IDisposable
uint, System.Collections.Generic.IReadOnlyList<AcDream.Core.Meshing.GfxObjSubMesh>>
_pendingCellMeshes = new();
// Step 4: pending LoadedCell objects built on the worker thread, drained
// to _cellVisibility on the render thread in ApplyLoadedTerrain.
private readonly System.Collections.Concurrent.ConcurrentBag<LoadedCell> _pendingCells = new();
/// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose
/// MotionTable resolved to a real cycle. The render loop ticks each
@ -397,6 +404,7 @@ public sealed class GameWindow : IDisposable
{
_terrain?.RemoveLandblock(id);
_physicsEngine.RemoveLandblock(id);
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
});
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
@ -1318,21 +1326,18 @@ public sealed class GameWindow : IDisposable
if (envCell is null) continue;
// Phase 7.1: build and register room geometry for this EnvCell.
DatReaderWriter.Types.CellStruct? cellStruct = null;
if (envCell.EnvironmentId != 0)
{
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
if (environment is not null
&& environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct))
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
{
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
if (cellSubMeshes.Count > 0)
{
// Store in the pending dict so ApplyLoadedTerrain can upload on
// the render thread. The key is the EnvCell dat id — same key
// used in the MeshRef below so EnsureUploaded can find it.
_pendingCellMeshes[envCellId] = cellSubMeshes;
// Z lift: 2 cm to avoid depth-fighting with terrain polygon.
var cellOrigin = envCell.Position.Origin + lbOffset
+ new System.Numerics.Vector3(0f, 0f, 0.02f);
var cellTransform =
@ -1348,8 +1353,12 @@ public sealed class GameWindow : IDisposable
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = new[] { cellMeshRef },
ParentCellId = envCellId,
};
result.Add(cellEntity);
// Step 4: build LoadedCell for portal visibility.
BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform);
}
}
}
@ -1398,6 +1407,7 @@ public sealed class GameWindow : IDisposable
Position = worldPos,
Rotation = worldRot,
MeshRefs = meshRefs,
ParentCellId = envCellId,
};
result.Add(hydrated);
}
@ -1428,6 +1438,112 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// Step 4: build a <see cref="LoadedCell"/> for portal visibility and queue it
/// for render-thread registration. Called from the worker thread during
/// <see cref="BuildInteriorEntitiesForStreaming"/>.
/// </summary>
private void BuildLoadedCell(
uint envCellId,
DatReaderWriter.DBObjs.EnvCell envCell,
DatReaderWriter.Types.CellStruct cellStruct,
System.Numerics.Vector3 cellOrigin,
System.Numerics.Matrix4x4 cellTransform)
{
System.Numerics.Matrix4x4.Invert(cellTransform, out var inverse);
// Compute local AABB from CellStruct vertices.
var boundsMin = new System.Numerics.Vector3(float.MaxValue);
var boundsMax = new System.Numerics.Vector3(float.MinValue);
foreach (var kvp in cellStruct.VertexArray.Vertices)
{
var v = kvp.Value;
var pos = new System.Numerics.Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z);
boundsMin = System.Numerics.Vector3.Min(boundsMin, pos);
boundsMax = System.Numerics.Vector3.Max(boundsMax, pos);
}
if (boundsMin.X == float.MaxValue)
{
boundsMin = System.Numerics.Vector3.Zero;
boundsMax = System.Numerics.Vector3.Zero;
}
// Build portal list and clip planes from CellPortals.
var portals = new List<CellPortalInfo>();
var clipPlanes = new List<PortalClipPlane>();
// Compute cell centroid in local space for InsideSide determination.
var centroid = (boundsMin + boundsMax) * 0.5f;
foreach (var portal in envCell.CellPortals)
{
portals.Add(new CellPortalInfo(
portal.OtherCellId,
portal.PolygonId,
(ushort)portal.Flags));
// Build clip plane from the portal polygon.
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
&& poly.VertexIds.Count >= 3)
{
// Get first 3 vertices in local space for the plane.
System.Numerics.Vector3 p0 = System.Numerics.Vector3.Zero,
p1 = System.Numerics.Vector3.Zero,
p2 = System.Numerics.Vector3.Zero;
bool found = true;
if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0))
p0 = new System.Numerics.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 System.Numerics.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 System.Numerics.Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
else found = false;
if (found)
{
var normal = System.Numerics.Vector3.Normalize(
System.Numerics.Vector3.Cross(p1 - p0, p2 - p0));
float d = -System.Numerics.Vector3.Dot(normal, p0);
// Determine InsideSide: which side of the plane the cell centroid is on.
// If centroid dot > 0 → inside is positive half-space (InsideSide=0).
float centroidDot = System.Numerics.Vector3.Dot(normal, centroid) + d;
int insideSide = centroidDot >= 0 ? 0 : 1;
clipPlanes.Add(new PortalClipPlane
{
Normal = normal,
D = d,
InsideSide = insideSide,
});
}
else
{
clipPlanes.Add(default);
}
}
else
{
clipPlanes.Add(default);
}
}
var loaded = new LoadedCell
{
CellId = envCellId,
WorldPosition = cellOrigin,
WorldTransform = cellTransform,
InverseWorldTransform = inverse,
LocalBoundsMin = boundsMin,
LocalBoundsMax = boundsMax,
Portals = portals,
ClipPlanes = clipPlanes,
};
_pendingCells.Add(loaded);
}
private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb)
{
if (_terrain is null || _dats is null || _blendCtx is null
@ -1448,6 +1564,10 @@ public sealed class GameWindow : IDisposable
lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache);
_terrain.AddLandblock(lb.LandblockId, meshData, origin);
// Step 4: drain pending LoadedCells from the worker thread.
while (_pendingCells.TryTake(out var cell))
_cellVisibility.AddCell(cell);
// Compute the per-landblock AABB for frustum culling. XY from the
// landblock's world origin + 192 footprint. Z from the terrain vertex
// range padded +50 above (for trees/buildings) and -10 below (for
@ -1832,8 +1952,24 @@ public sealed class GameWindow : IDisposable
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
}
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
// Step 4: portal visibility — determine which interior cells to render.
// Extract camera world position from the inverse of the view matrix.
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null;
// Conditional depth clear: when camera is inside a building, clear
// depth (not color) so interior geometry writes fresh Z values on top
// of the terrain color buffer. Exit portals show outdoor terrain color
// because we kept the color buffer. Matching ACME GameScene.cs pattern.
if (cameraInsideCell)
_gl!.Clear(ClearBufferMask.DepthBufferBit);
_staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb);
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds);
// Count visible vs total for the perf overlay.
foreach (var entry in _worldState.LandblockEntries)