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

@ -0,0 +1,426 @@
// CellVisibility.cs — portal-based interior cell visibility system.
//
// Ported from ACME EnvCellManager.cs (WorldBuilder-ACME-Edition).
// Key methods: FindCameraCell, PointInCell, GetVisibleCells.
// Constants: PointInCellEpsilon = 0.01f, CellSwitchGraceFrameCount = 3
// (ACME values; the original spec suggested 0.1f / 5 but ACME is ground truth).
//
// This file is intentionally free of GL / rendering types. It depends only on
// System.Numerics so it can be unit-tested without a GPU context.
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
// ---------------------------------------------------------------------------
// Data structures
// ---------------------------------------------------------------------------
/// <summary>
/// A loaded EnvCell with portal connectivity and spatial data, used by
/// <see cref="CellVisibility"/> for portal-traversal visibility decisions.
/// </summary>
public sealed class LoadedCell
{
/// <summary>Full 32-bit cell ID, e.g. 0xA9B40105.</summary>
public uint CellId;
/// <summary>Cell origin in world space (used for neighbour distance checks).</summary>
public Vector3 WorldPosition;
/// <summary>Cell-to-world transform (rotation + translation from EnvCell placement).</summary>
public Matrix4x4 WorldTransform;
/// <summary>
/// Cached inverse of <see cref="WorldTransform"/>. Pre-computed at load time so
/// PointInCell doesn't pay the inversion cost per frame.
/// </summary>
public Matrix4x4 InverseWorldTransform;
/// <summary>Local-space AABB minimum, computed from CellStruct vertices.</summary>
public Vector3 LocalBoundsMin;
/// <summary>Local-space AABB maximum, computed from CellStruct vertices.</summary>
public Vector3 LocalBoundsMax;
/// <summary>
/// Ordered portal connections. Index i in Portals corresponds to index i in
/// <see cref="ClipPlanes"/> (when ClipPlanes.Count > i).
/// </summary>
public List<CellPortalInfo> Portals = new();
/// <summary>
/// One clip plane per portal polygon, in cell-local space. Used by the
/// portal-side test to decide whether the camera can see through a portal.
/// Derived from portal polygon geometry during cell preparation.
/// </summary>
public List<PortalClipPlane> ClipPlanes = new();
}
/// <summary>
/// Portal connection to a neighbouring cell.
/// OtherCellId == 0xFFFF indicates an exit portal to the outdoor world.
/// </summary>
public readonly record struct CellPortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags);
/// <summary>
/// Clip plane derived from a portal polygon, in cell-local space.
/// Plane equation: Normal.X*x + Normal.Y*y + Normal.Z*z + D = 0.
/// </summary>
public struct PortalClipPlane
{
/// <summary>Plane normal (cell-local space, unit length).</summary>
public Vector3 Normal;
/// <summary>Plane offset so that Dot(Normal, point) + D = 0 on the plane.</summary>
public float D;
/// <summary>
/// Which half-space is "inside" this cell (the side from which you look outward
/// through the portal):
/// 0 → camera dot-product must be >= 0 (positive half-space is inside)
/// 1 → camera dot-product must be &lt;= 0 (negative half-space is inside)
/// Determined from cell centroid position relative to the portal plane.
/// Ported from ACME EnvCellManager.cs ~line 404.
/// </summary>
public int InsideSide;
}
/// <summary>
/// Result of a portal-based visibility BFS from the camera cell.
/// </summary>
public sealed class VisibilityResult
{
/// <summary>Full cell IDs (e.g. 0x01D90105) that should be rendered this frame.</summary>
public HashSet<uint> VisibleCellIds { get; init; } = new();
/// <summary>
/// True when at least one exit portal (OtherCellId == 0xFFFF) was reached during
/// traversal. The caller should render outdoor terrain when this is set.
/// </summary>
public bool HasExitPortalVisible { get; set; }
/// <summary>The cell the camera is currently inside.</summary>
public LoadedCell? CameraCell { get; set; }
}
// ---------------------------------------------------------------------------
// CellVisibility
// ---------------------------------------------------------------------------
/// <summary>
/// Pure-logic portal visibility system for EnvCell interior rooms.
///
/// Maintains a per-landblock registry of <see cref="LoadedCell"/> objects and
/// performs a BFS through portal connections each frame to determine which cells
/// should be rendered given the current camera position.
///
/// Ported faithfully from ACME's EnvCellManager.cs portal-visibility region.
/// Constants and control flow match the ACME implementation.
/// </summary>
public sealed class CellVisibility
{
// ------------------------------------------------------------------
// Constants (ACME ground-truth values)
// ------------------------------------------------------------------
/// <summary>
/// Epsilon applied to AABB containment tests so that a camera sitting
/// exactly on a cell wall is still considered inside.
/// Source: ACME EnvCellManager.cs PointInCellEpsilon = 0.01f.
/// </summary>
private const float PointInCellEpsilon = 0.01f;
/// <summary>
/// Number of frames to keep the previous camera cell alive after the camera
/// leaves it (prevents one-frame pop-in when crossing cell boundaries).
/// Source: ACME EnvCellManager.cs CellSwitchGraceFrameCount = 3.
/// </summary>
private const int CellSwitchGraceFrameCount = 3;
// ------------------------------------------------------------------
// State
// ------------------------------------------------------------------
/// <summary>Per-landblock lists of loaded cells. Key = upper 16 bits of a cell ID.</summary>
private readonly Dictionary<uint, List<LoadedCell>> _cellsByLandblock = new();
/// <summary>Full-ID lookup for O(1) neighbour resolution during BFS.</summary>
private readonly Dictionary<uint, LoadedCell> _cellLookup = new();
/// <summary>The cell the camera was in during the last <see cref="ComputeVisibility"/> call.</summary>
private LoadedCell? _lastCameraCell;
/// <summary>Frames remaining in the grace period after the camera left _lastCameraCell.</summary>
private int _cellSwitchGraceFrames;
/// <summary>The last visibility result produced by <see cref="ComputeVisibility"/>.</summary>
public VisibilityResult? LastVisibilityResult { get; private set; }
// ------------------------------------------------------------------
// Registration
// ------------------------------------------------------------------
/// <summary>
/// Registers a newly-loaded cell. Called from the streaming loader after
/// CPU preparation (transforms, clip planes, bounds) is complete.
/// Thread-safety: caller must not call this concurrently with rendering.
/// </summary>
public void AddCell(LoadedCell cell)
{
uint lbId = cell.CellId >> 16;
if (!_cellsByLandblock.TryGetValue(lbId, out var list))
{
list = new List<LoadedCell>();
_cellsByLandblock[lbId] = list;
}
list.Add(cell);
_cellLookup[cell.CellId] = cell;
}
/// <summary>
/// Removes all cells belonging to <paramref name="lbId"/> (upper 16 bits of
/// the landblock key, e.g. 0xA9B4 for landblock 0xA9B40000). Called when a
/// landblock unloads.
/// </summary>
public void RemoveLandblock(uint lbId)
{
if (!_cellsByLandblock.TryGetValue(lbId, out var list))
return;
foreach (var cell in list)
{
_cellLookup.Remove(cell.CellId);
// If the evicted cell was cached, clear the cache so FindCameraCell
// does a fresh brute-force scan next frame.
if (_lastCameraCell?.CellId == cell.CellId)
{
_lastCameraCell = null;
_cellSwitchGraceFrames = 0;
}
}
_cellsByLandblock.Remove(lbId);
}
// ------------------------------------------------------------------
// Per-frame entry point
// ------------------------------------------------------------------
/// <summary>
/// Computes portal-based visibility from <paramref name="cameraPos"/> and
/// caches the result in <see cref="LastVisibilityResult"/>.
///
/// Call once per frame, before the render pass. Returns null when the camera
/// is outside all loaded cells (outdoor — caller should fall back to frustum
/// culling of terrain).
/// </summary>
public VisibilityResult? ComputeVisibility(Vector3 cameraPos)
{
if (_cellLookup.Count == 0)
{
LastVisibilityResult = null;
return null;
}
LastVisibilityResult = GetVisibleCells(cameraPos);
return LastVisibilityResult;
}
// ------------------------------------------------------------------
// FindCameraCell
// ------------------------------------------------------------------
/// <summary>
/// Finds the <see cref="LoadedCell"/> the camera is currently inside, with
/// a short hysteresis window to prevent flicker at cell boundaries.
///
/// Search order:
/// 1. Cached cell fast path.
/// 2. Immediate portal neighbours of the cached cell.
/// 3. Brute-force scan of all loaded cells.
/// 4. Grace period — return the previous cell for a few frames.
/// 5. Return null (camera is outdoors).
///
/// Ported from ACME EnvCellManager.cs FindCameraCell().
/// </summary>
public LoadedCell? FindCameraCell(Vector3 cameraPos)
{
// 1. Fast path: cached cell.
if (_lastCameraCell != null && PointInCell(cameraPos, _lastCameraCell))
return _lastCameraCell;
// 2. One-hop neighbours of the cached cell.
if (_lastCameraCell != null)
{
uint lbMask = _lastCameraCell.CellId & 0xFFFF0000u;
foreach (var portal in _lastCameraCell.Portals)
{
if (portal.OtherCellId == 0xFFFF)
continue;
uint neighbourId = lbMask | portal.OtherCellId;
if (_cellLookup.TryGetValue(neighbourId, out var neighbour) &&
PointInCell(cameraPos, neighbour))
{
_lastCameraCell = neighbour;
_cellSwitchGraceFrames = CellSwitchGraceFrameCount;
return neighbour;
}
}
}
// 3. Brute-force scan.
foreach (var kvp in _cellsByLandblock)
{
foreach (var cell in kvp.Value)
{
if (PointInCell(cameraPos, cell))
{
_lastCameraCell = cell;
_cellSwitchGraceFrames = CellSwitchGraceFrameCount;
return cell;
}
}
}
// 4. Grace period: keep the previous cell alive for a few frames.
if (_lastCameraCell != null && _cellSwitchGraceFrames > 0)
{
_cellSwitchGraceFrames--;
return _lastCameraCell;
}
// 5. Camera is outside all cells.
_lastCameraCell = null;
return null;
}
// ------------------------------------------------------------------
// PointInCell
// ------------------------------------------------------------------
/// <summary>
/// Returns true when <paramref name="worldPoint"/> lies inside
/// <paramref name="cell"/>'s local-space AABB (within epsilon).
///
/// The point is transformed into cell-local space via the pre-computed
/// <see cref="LoadedCell.InverseWorldTransform"/> and then tested against
/// <see cref="LoadedCell.LocalBoundsMin"/> / <see cref="LoadedCell.LocalBoundsMax"/>.
///
/// Ported from ACME EnvCellManager.cs PointInCell().
/// </summary>
public static bool PointInCell(Vector3 worldPoint, LoadedCell cell)
{
// Degenerate cell (no geometry baked yet).
if (cell.LocalBoundsMin.X >= cell.LocalBoundsMax.X)
return false;
var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform);
return local.X >= cell.LocalBoundsMin.X - PointInCellEpsilon &&
local.X <= cell.LocalBoundsMax.X + PointInCellEpsilon &&
local.Y >= cell.LocalBoundsMin.Y - PointInCellEpsilon &&
local.Y <= cell.LocalBoundsMax.Y + PointInCellEpsilon &&
local.Z >= cell.LocalBoundsMin.Z - PointInCellEpsilon &&
local.Z <= cell.LocalBoundsMax.Z + PointInCellEpsilon;
}
// ------------------------------------------------------------------
// GetVisibleCells (BFS)
// ------------------------------------------------------------------
/// <summary>
/// Performs portal-based BFS visibility traversal starting from the camera
/// cell. Returns null when the camera is outside all loaded cells.
///
/// Algorithm:
/// • Start with the camera cell in the visited set and the work queue.
/// • For each dequeued cell, iterate its portals:
/// OtherCellId == 0xFFFF → exit portal, set HasExitPortalVisible.
/// Already visited → skip.
/// Not loaded → skip.
/// Portal-side test: transform camera to cell-local space, dot with
/// clip plane; skip if camera is on the wrong side.
/// Enqueue neighbour and add to VisibleCellIds.
///
/// Note: ACME also applies a frustum test after the portal-side test. That
/// test is omitted here because <see cref="CellVisibility"/> is a pure-logic
/// class. Callers that have a frustum can post-filter VisibleCellIds.
///
/// The landblock mask for neighbour resolution is taken from the camera
/// cell's CellId (upper 16 bits). All portals in a dungeon are assumed to
/// connect cells within the same landblock.
///
/// Ported from ACME EnvCellManager.cs GetVisibleCells().
/// </summary>
public VisibilityResult? GetVisibleCells(Vector3 cameraPos)
{
var cameraCell = FindCameraCell(cameraPos);
if (cameraCell == null)
return null;
var result = new VisibilityResult { CameraCell = cameraCell };
var visited = new HashSet<uint>();
var queue = new Queue<LoadedCell>();
visited.Add(cameraCell.CellId);
result.VisibleCellIds.Add(cameraCell.CellId);
queue.Enqueue(cameraCell);
// All portals in a dungeon connect cells in the same landblock.
uint lbMask = cameraCell.CellId & 0xFFFF0000u;
while (queue.Count > 0)
{
var cell = queue.Dequeue();
for (int i = 0; i < cell.Portals.Count; i++)
{
var portal = cell.Portals[i];
// Exit portal → outdoor terrain should be visible.
if (portal.OtherCellId == 0xFFFF)
{
result.HasExitPortalVisible = true;
continue;
}
uint neighbourId = lbMask | portal.OtherCellId;
if (visited.Contains(neighbourId))
continue;
if (!_cellLookup.TryGetValue(neighbourId, out var neighbour))
continue;
// Portal-side test: camera must be on the interior side of the
// portal clip plane to see through into the neighbouring cell.
if (i < cell.ClipPlanes.Count)
{
var plane = cell.ClipPlanes[i];
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
// InsideSide == 0 → inside is positive half-space; reject if dot < -ε.
// InsideSide == 1 → inside is negative half-space; reject if dot > ε.
// Source: ACME EnvCellManager.cs lines 1458-1459.
if (plane.InsideSide == 0 && dot < -PointInCellEpsilon)
continue;
if (plane.InsideSide == 1 && dot > PointInCellEpsilon)
continue;
}
visited.Add(neighbourId);
result.VisibleCellIds.Add(neighbourId);
queue.Enqueue(neighbour);
}
}
return result;
}
}

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)

View file

@ -132,26 +132,21 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
public void Draw(ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null)
uint? neverCullLandblockId = null,
HashSet<uint>? visibleCellIds = null)
{
_shader.Use();
// Compute combined view-projection once. System.Numerics uses row-major
// convention; multiplying View * Projection gives the correct combined
// matrix that maps world → clip space when applied as M*v in the shader.
var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp);
// Lighting uniforms matching ACME StaticObject.vert:
// LightingFactor = max(dot(Normal, -uLightDirection), 0.0) + uAmbientIntensity
// LightDirection (0.5, 0.3, -0.3) from ACME GameScene.cs:238.
// AmbientLightIntensity 0.45 from ACME LandscapeEditorSettings.cs:108.
// Lighting uniforms matching ACME StaticObject.vert.
var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f));
_shader.SetVec3("uLightDirection", lightDir);
_shader.SetFloat("uAmbientIntensity", 0.45f);
// ── Collect and group instances ───────────────────────────────────────
CollectGroups(landblockEntries, frustum, neverCullLandblockId);
CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds);
// ── Build and upload the instance buffer ──────────────────────────────
// Count total instances.
@ -327,7 +322,8 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
private void CollectGroups(
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId)
uint? neverCullLandblockId,
HashSet<uint>? visibleCellIds)
{
foreach (var grp in _groups.Values)
grp.Entries.Clear();
@ -344,6 +340,13 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
if (entity.MeshRefs.Count == 0)
continue;
// Step 4: portal visibility filter. If we have a visible cell set,
// skip interior entities whose parent cell isn't visible.
// visibleCellIds == null means camera is outdoors → show all interiors.
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
continue;
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);

View file

@ -35,4 +35,12 @@ public sealed class WorldEntity
/// and team colors. Non-palette-indexed textures ignore this field.
/// </summary>
public PaletteOverride? PaletteOverride { get; init; }
/// <summary>
/// EnvCell ID that owns this entity (room geometry or static object inside
/// the cell). Used by portal visibility to filter interior entities — only
/// entities whose ParentCellId appears in the visible set are rendered.
/// Null for outdoor entities (stabs, scenery, live server spawns).
/// </summary>
public uint? ParentCellId { get; init; }
}