From cffc3ee343a8afb66e192a6dd1f621c6cabf8f54 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Apr 2026 22:20:52 +0200 Subject: [PATCH] feat(render): portal-based EnvCell visibility (Step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/CellVisibility.cs | 426 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 148 +++++- .../Rendering/InstancedMeshRenderer.cs | 23 +- src/AcDream.Core/World/WorldEntity.cs | 8 + 4 files changed, 589 insertions(+), 16 deletions(-) create mode 100644 src/AcDream.App/Rendering/CellVisibility.cs 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; } }