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

@ -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);