diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs
new file mode 100644
index 0000000..f3e0c55
--- /dev/null
+++ b/src/AcDream.App/Rendering/CellVisibility.cs
@@ -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
+// ---------------------------------------------------------------------------
+
+///
+/// A loaded EnvCell with portal connectivity and spatial data, used by
+/// for portal-traversal visibility decisions.
+///
+public sealed class LoadedCell
+{
+ /// Full 32-bit cell ID, e.g. 0xA9B40105.
+ public uint CellId;
+
+ /// Cell origin in world space (used for neighbour distance checks).
+ public Vector3 WorldPosition;
+
+ /// Cell-to-world transform (rotation + translation from EnvCell placement).
+ public Matrix4x4 WorldTransform;
+
+ ///
+ /// Cached inverse of . Pre-computed at load time so
+ /// PointInCell doesn't pay the inversion cost per frame.
+ ///
+ public Matrix4x4 InverseWorldTransform;
+
+ /// Local-space AABB minimum, computed from CellStruct vertices.
+ public Vector3 LocalBoundsMin;
+
+ /// Local-space AABB maximum, computed from CellStruct vertices.
+ public Vector3 LocalBoundsMax;
+
+ ///
+ /// Ordered portal connections. Index i in Portals corresponds to index i in
+ /// (when ClipPlanes.Count > i).
+ ///
+ public List Portals = new();
+
+ ///
+ /// 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.
+ ///
+ public List ClipPlanes = new();
+}
+
+///
+/// Portal connection to a neighbouring cell.
+/// OtherCellId == 0xFFFF indicates an exit portal to the outdoor world.
+///
+public readonly record struct CellPortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags);
+
+///
+/// Clip plane derived from a portal polygon, in cell-local space.
+/// Plane equation: Normal.X*x + Normal.Y*y + Normal.Z*z + D = 0.
+///
+public struct PortalClipPlane
+{
+ /// Plane normal (cell-local space, unit length).
+ public Vector3 Normal;
+
+ /// Plane offset so that Dot(Normal, point) + D = 0 on the plane.
+ public float D;
+
+ ///
+ /// 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 <= 0 (negative half-space is inside)
+ /// Determined from cell centroid position relative to the portal plane.
+ /// Ported from ACME EnvCellManager.cs ~line 404.
+ ///
+ public int InsideSide;
+}
+
+///
+/// Result of a portal-based visibility BFS from the camera cell.
+///
+public sealed class VisibilityResult
+{
+ /// Full cell IDs (e.g. 0x01D90105) that should be rendered this frame.
+ public HashSet VisibleCellIds { get; init; } = new();
+
+ ///
+ /// True when at least one exit portal (OtherCellId == 0xFFFF) was reached during
+ /// traversal. The caller should render outdoor terrain when this is set.
+ ///
+ public bool HasExitPortalVisible { get; set; }
+
+ /// The cell the camera is currently inside.
+ public LoadedCell? CameraCell { get; set; }
+}
+
+// ---------------------------------------------------------------------------
+// CellVisibility
+// ---------------------------------------------------------------------------
+
+///
+/// Pure-logic portal visibility system for EnvCell interior rooms.
+///
+/// Maintains a per-landblock registry of 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.
+///
+public sealed class CellVisibility
+{
+ // ------------------------------------------------------------------
+ // Constants (ACME ground-truth values)
+ // ------------------------------------------------------------------
+
+ ///
+ /// 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.
+ ///
+ private const float PointInCellEpsilon = 0.01f;
+
+ ///
+ /// 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.
+ ///
+ private const int CellSwitchGraceFrameCount = 3;
+
+ // ------------------------------------------------------------------
+ // State
+ // ------------------------------------------------------------------
+
+ /// Per-landblock lists of loaded cells. Key = upper 16 bits of a cell ID.
+ private readonly Dictionary> _cellsByLandblock = new();
+
+ /// Full-ID lookup for O(1) neighbour resolution during BFS.
+ private readonly Dictionary _cellLookup = new();
+
+ /// The cell the camera was in during the last call.
+ private LoadedCell? _lastCameraCell;
+
+ /// Frames remaining in the grace period after the camera left _lastCameraCell.
+ private int _cellSwitchGraceFrames;
+
+ /// The last visibility result produced by .
+ public VisibilityResult? LastVisibilityResult { get; private set; }
+
+ // ------------------------------------------------------------------
+ // Registration
+ // ------------------------------------------------------------------
+
+ ///
+ /// 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.
+ ///
+ public void AddCell(LoadedCell cell)
+ {
+ uint lbId = cell.CellId >> 16;
+
+ if (!_cellsByLandblock.TryGetValue(lbId, out var list))
+ {
+ list = new List();
+ _cellsByLandblock[lbId] = list;
+ }
+
+ list.Add(cell);
+ _cellLookup[cell.CellId] = cell;
+ }
+
+ ///
+ /// Removes all cells belonging to (upper 16 bits of
+ /// the landblock key, e.g. 0xA9B4 for landblock 0xA9B40000). Called when a
+ /// landblock unloads.
+ ///
+ 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
+ // ------------------------------------------------------------------
+
+ ///
+ /// Computes portal-based visibility from and
+ /// caches the result in .
+ ///
+ /// 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).
+ ///
+ public VisibilityResult? ComputeVisibility(Vector3 cameraPos)
+ {
+ if (_cellLookup.Count == 0)
+ {
+ LastVisibilityResult = null;
+ return null;
+ }
+
+ LastVisibilityResult = GetVisibleCells(cameraPos);
+ return LastVisibilityResult;
+ }
+
+ // ------------------------------------------------------------------
+ // FindCameraCell
+ // ------------------------------------------------------------------
+
+ ///
+ /// Finds the 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().
+ ///
+ 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
+ // ------------------------------------------------------------------
+
+ ///
+ /// Returns true when lies inside
+ /// 's local-space AABB (within epsilon).
+ ///
+ /// The point is transformed into cell-local space via the pre-computed
+ /// and then tested against
+ /// / .
+ ///
+ /// Ported from ACME EnvCellManager.cs PointInCell().
+ ///
+ 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)
+ // ------------------------------------------------------------------
+
+ ///
+ /// 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 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().
+ ///
+ public VisibilityResult? GetVisibleCells(Vector3 cameraPos)
+ {
+ var cameraCell = FindCameraCell(cameraPos);
+ if (cameraCell == null)
+ return null;
+
+ var result = new VisibilityResult { CameraCell = cameraCell };
+ var visited = new HashSet();
+ var queue = new Queue();
+
+ 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;
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 6ea8fb7..7d729aa 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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>
_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 _pendingCells = new();
+
///
/// 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(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
}
}
+ ///
+ /// Step 4: build a for portal visibility and queue it
+ /// for render-thread registration. Called from the worker thread during
+ /// .
+ ///
+ 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();
+ var clipPlanes = new List();
+
+ // 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)
diff --git a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs
index 30e346b..45585ed 100644
--- a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs
+++ b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs
@@ -132,26 +132,21 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
public void Draw(ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries,
FrustumPlanes? frustum = null,
- uint? neverCullLandblockId = null)
+ uint? neverCullLandblockId = null,
+ HashSet? 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 Entities)> landblockEntries,
FrustumPlanes? frustum,
- uint? neverCullLandblockId)
+ uint? neverCullLandblockId,
+ HashSet? 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);
diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs
index b98abf5..479f5cd 100644
--- a/src/AcDream.Core/World/WorldEntity.cs
+++ b/src/AcDream.Core/World/WorldEntity.cs
@@ -35,4 +35,12 @@ public sealed class WorldEntity
/// and team colors. Non-palette-indexed textures ignore this field.
///
public PaletteOverride? PaletteOverride { get; init; }
+
+ ///
+ /// 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).
+ ///
+ public uint? ParentCellId { get; init; }
}