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:
parent
25090b6fc9
commit
cffc3ee343
4 changed files with 589 additions and 16 deletions
426
src/AcDream.App/Rendering/CellVisibility.cs
Normal file
426
src/AcDream.App/Rendering/CellVisibility.cs
Normal 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 <= 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue