feat(app): Phase A.2 — wire frustum culling into terrain + static-mesh renderers

Per-landblock AABB culling against the view frustum. Each loaded
landblock has a 192×192 XY footprint + a Z range derived from the
terrain vertex min/max (padded +50 above / -10 below for entities
on top and basements). One AABB test per landblock per frame;
landblocks fully outside the frustum skip ALL their terrain draws
and entity draws (both opaque and translucent passes).

GpuWorldState gains SetLandblockAabb + LandblockEntries (per-landblock
iteration with AABB data). TerrainRenderer.Draw and
StaticMeshRenderer.Draw both accept an optional FrustumPlanes and
skip culled landblocks. GameWindow.OnRender extracts FrustumPlanes
from camera.View * camera.Projection and passes to both.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 08:53:18 +02:00
parent 07fde88534
commit 3c9fc63af7
4 changed files with 156 additions and 64 deletions

View file

@ -1189,6 +1189,28 @@ public sealed class GameWindow : IDisposable
lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache); lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache);
_terrain.AddLandblock(lb.LandblockId, meshData, origin); _terrain.AddLandblock(lb.LandblockId, meshData, origin);
// 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
// basements). TerrainRenderer already scans vertices internally; we
// replicate here so GpuWorldState has the same bounds for the static
// mesh renderer's culling pass.
{
float zMin = float.MaxValue, zMax = float.MinValue;
foreach (var v in meshData.Vertices)
{
float z = v.Position.Z;
if (z < zMin) zMin = z;
if (z > zMax) zMax = z;
}
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
zMax += 50f; // generous pad for trees and buildings
zMin -= 10f; // below-ground buffer for basements/cellars
var aabbMin = new System.Numerics.Vector3(origin.X, origin.Y, zMin);
var aabbMax = new System.Numerics.Vector3(origin.X + 192f, origin.Y + 192f, zMax);
_worldState.SetLandblockAabb(lb.LandblockId, aabbMin, aabbMax);
}
// Upload every GfxObj referenced by this landblock's entities. // Upload every GfxObj referenced by this landblock's entities.
// EnsureUploaded is idempotent so duplicates across landblocks are free. // EnsureUploaded is idempotent so duplicates across landblocks are free.
if (_staticMesh is not null) if (_staticMesh is not null)
@ -1320,8 +1342,10 @@ public sealed class GameWindow : IDisposable
if (_cameraController is not null) if (_cameraController is not null)
{ {
_terrain?.Draw(_cameraController.Active); var camera = _cameraController.Active;
_staticMesh?.Draw(_cameraController.Active, _worldState.Entities); var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection);
_terrain?.Draw(camera, frustum);
_staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum);
} }
} }

View file

@ -76,20 +76,26 @@ public sealed unsafe class StaticMeshRenderer : IDisposable
}; };
} }
public void Draw(ICamera camera, IEnumerable<WorldEntity> entities) public void Draw(ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum = null)
{ {
_shader.Use(); _shader.Use();
_shader.SetMatrix4("uView", camera.View); _shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection); _shader.SetMatrix4("uProjection", camera.Projection);
// Snapshot entity list once so we iterate it twice (opaque then translucent)
// without re-evaluating lazy enumerables.
var entityList = entities as IReadOnlyList<WorldEntity> ?? entities.ToList();
// ── Pass 1: Opaque + ClipMap ────────────────────────────────────────── // ── Pass 1: Opaque + ClipMap ──────────────────────────────────────────
// Depth write on (default). No blending. ClipMap surfaces use the // Depth write on (default). No blending. ClipMap surfaces use the
// alpha-discard path in the fragment shader (uTranslucencyKind == 1). // alpha-discard path in the fragment shader (uTranslucencyKind == 1).
foreach (var entity in entityList) foreach (var entry in landblockEntries)
{
// Per-landblock frustum cull: one AABB test skips all entities in
// this landblock if it is fully outside the view frustum.
if (frustum is not null &&
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
continue;
foreach (var entity in entry.Entities)
{ {
if (entity.MeshRefs.Count == 0) if (entity.MeshRefs.Count == 0)
continue; continue;
@ -123,6 +129,7 @@ public sealed unsafe class StaticMeshRenderer : IDisposable
} }
} }
} }
}
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ───────────── // ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
// Depth test on so translucents composite correctly behind opaque geometry. // Depth test on so translucents composite correctly behind opaque geometry.
@ -159,7 +166,14 @@ public sealed unsafe class StaticMeshRenderer : IDisposable
_gl.CullFace(TriangleFace.Back); _gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw); _gl.FrontFace(FrontFaceDirection.Ccw);
foreach (var entity in entityList) foreach (var entry in landblockEntries)
{
// Same per-landblock frustum cull for the translucent pass.
if (frustum is not null &&
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
continue;
foreach (var entity in entry.Entities)
{ {
if (entity.MeshRefs.Count == 0) if (entity.MeshRefs.Count == 0)
continue; continue;
@ -211,6 +225,7 @@ public sealed unsafe class StaticMeshRenderer : IDisposable
} }
} }
} }
}
// Restore default GL state for subsequent renderers (terrain etc.). // Restore default GL state for subsequent renderers (terrain etc.).
_gl.DepthMask(true); _gl.DepthMask(true);

View file

@ -41,12 +41,24 @@ public sealed unsafe class TerrainRenderer : IDisposable
_landblocks.Remove(landblockId); _landblocks.Remove(landblockId);
} }
float zMin = float.MaxValue, zMax = float.MinValue;
foreach (var v in meshData.Vertices)
{
float z = v.Position.Z;
if (z < zMin) zMin = z;
if (z > zMax) zMax = z;
}
// Fall back to zero if no vertices (shouldn't happen in practice).
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
var gpu = new LandblockGpu var gpu = new LandblockGpu
{ {
LandblockId = landblockId, LandblockId = landblockId,
Vao = _gl.GenVertexArray(), Vao = _gl.GenVertexArray(),
WorldOrigin = worldOrigin, WorldOrigin = worldOrigin,
IndexCount = meshData.Indices.Length, IndexCount = meshData.Indices.Length,
MinZ = zMin,
MaxZ = zMax,
}; };
_gl.BindVertexArray(gpu.Vao); _gl.BindVertexArray(gpu.Vao);
@ -104,7 +116,7 @@ public sealed unsafe class TerrainRenderer : IDisposable
_landblocks.Remove(landblockId); _landblocks.Remove(landblockId);
} }
public void Draw(ICamera camera) public void Draw(ICamera camera, FrustumPlanes? frustum = null)
{ {
_shader.Use(); _shader.Use();
_shader.SetMatrix4("uView", camera.View); _shader.SetMatrix4("uView", camera.View);
@ -123,6 +135,14 @@ public sealed unsafe class TerrainRenderer : IDisposable
foreach (var lb in _landblocks.Values) foreach (var lb in _landblocks.Values)
{ {
if (frustum is not null)
{
var aabbMin = new Vector3(lb.WorldOrigin.X, lb.WorldOrigin.Y, lb.MinZ);
var aabbMax = new Vector3(lb.WorldOrigin.X + 192f, lb.WorldOrigin.Y + 192f, lb.MaxZ);
if (!FrustumCuller.IsAabbVisible(frustum.Value, aabbMin, aabbMax))
continue;
}
var model = Matrix4x4.CreateTranslation(lb.WorldOrigin); var model = Matrix4x4.CreateTranslation(lb.WorldOrigin);
_shader.SetMatrix4("uModel", model); _shader.SetMatrix4("uModel", model);
_gl.BindVertexArray(lb.Vao); _gl.BindVertexArray(lb.Vao);
@ -150,5 +170,7 @@ public sealed unsafe class TerrainRenderer : IDisposable
public uint Ebo; public uint Ebo;
public int IndexCount; public int IndexCount;
public Vector3 WorldOrigin; public Vector3 WorldOrigin;
public float MinZ;
public float MaxZ;
} }
} }

View file

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Numerics;
using AcDream.Core.World; using AcDream.Core.World;
namespace AcDream.App.Streaming; namespace AcDream.App.Streaming;
@ -38,6 +39,7 @@ namespace AcDream.App.Streaming;
public sealed class GpuWorldState public sealed class GpuWorldState
{ {
private readonly Dictionary<uint, LoadedLandblock> _loaded = new(); private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
/// <summary> /// <summary>
/// Per-landblock buffer of live entities awaiting their landblock's /// Per-landblock buffer of live entities awaiting their landblock's
@ -56,6 +58,34 @@ public sealed class GpuWorldState
public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
/// <summary>
/// Store the axis-aligned bounding box for a loaded landblock. Called from
/// the render thread after the terrain mesh is built and uploaded.
/// </summary>
public void SetLandblockAabb(uint landblockId, Vector3 min, Vector3 max)
{
_aabbs[landblockId] = (min, max);
}
/// <summary>
/// Per-landblock iteration with AABB data for use by the frustum-culling
/// draw path. Landblocks without a stored AABB yield <see cref="Vector3.Zero"/>
/// for both corners, which the culler will conservatively treat as visible.
/// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> LandblockEntries
{
get
{
foreach (var kvp in _loaded)
{
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities);
else
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities);
}
}
}
/// <summary> /// <summary>
/// Total live entities currently parked in the pending bucket waiting /// Total live entities currently parked in the pending bucket waiting
/// for their landblock to arrive. Useful diagnostic for verifying the /// for their landblock to arrive. Useful diagnostic for verifying the
@ -89,6 +119,7 @@ public sealed class GpuWorldState
// pending spawns that arrived for it are no longer relevant. The // pending spawns that arrived for it are no longer relevant. The
// server will resend them via CreateObject when the player returns. // server will resend them via CreateObject when the player returns.
_pendingByLandblock.Remove(landblockId); _pendingByLandblock.Remove(landblockId);
_aabbs.Remove(landblockId);
if (_loaded.Remove(landblockId)) if (_loaded.Remove(landblockId))
RebuildFlatView(); RebuildFlatView();