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

@ -76,50 +76,57 @@ 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.SetMatrix4("uView", camera.View);
_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 ──────────────────────────────────────────
// Depth write on (default). No blending. ClipMap surfaces use the
// alpha-discard path in the fragment shader (uTranslucencyKind == 1).
foreach (var entity in entityList)
foreach (var entry in landblockEntries)
{
if (entity.MeshRefs.Count == 0)
// 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 meshRef in entity.MeshRefs)
foreach (var entity in entry.Entities)
{
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
if (entity.MeshRefs.Count == 0)
continue;
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
var model = meshRef.PartTransform * entityRoot;
_shader.SetMatrix4("uModel", model);
foreach (var sub in subMeshes)
foreach (var meshRef in entity.MeshRefs)
{
// Skip translucent sub-meshes in the first pass.
if (sub.Translucency != TranslucencyKind.Opaque &&
sub.Translucency != TranslucencyKind.ClipMap)
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
continue;
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
var model = meshRef.PartTransform * entityRoot;
_shader.SetMatrix4("uModel", model);
uint tex = ResolveTex(entity, meshRef, sub);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
foreach (var sub in subMeshes)
{
// Skip translucent sub-meshes in the first pass.
if (sub.Translucency != TranslucencyKind.Opaque &&
sub.Translucency != TranslucencyKind.ClipMap)
continue;
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
uint tex = ResolveTex(entity, meshRef, sub);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
}
}
}
}
@ -159,55 +166,63 @@ public sealed unsafe class StaticMeshRenderer : IDisposable
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
foreach (var entity in entityList)
foreach (var entry in landblockEntries)
{
if (entity.MeshRefs.Count == 0)
// 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 meshRef in entity.MeshRefs)
foreach (var entity in entry.Entities)
{
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
if (entity.MeshRefs.Count == 0)
continue;
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
var model = meshRef.PartTransform * entityRoot;
_shader.SetMatrix4("uModel", model);
foreach (var sub in subMeshes)
foreach (var meshRef in entity.MeshRefs)
{
if (sub.Translucency == TranslucencyKind.Opaque ||
sub.Translucency == TranslucencyKind.ClipMap)
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
continue;
// Set per-draw blend function.
switch (sub.Translucency)
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
var model = meshRef.PartTransform * entityRoot;
_shader.SetMatrix4("uModel", model);
foreach (var sub in subMeshes)
{
case TranslucencyKind.Additive:
// src*a + dst — portal swirls, glows
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
break;
if (sub.Translucency == TranslucencyKind.Opaque ||
sub.Translucency == TranslucencyKind.ClipMap)
continue;
case TranslucencyKind.InvAlpha:
// src*(1-a) + dst*a
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
break;
// Set per-draw blend function.
switch (sub.Translucency)
{
case TranslucencyKind.Additive:
// src*a + dst — portal swirls, glows
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
break;
default: // AlphaBlend
// src*a + dst*(1-a)
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
break;
case TranslucencyKind.InvAlpha:
// src*(1-a) + dst*a
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
break;
default: // AlphaBlend
// src*a + dst*(1-a)
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
break;
}
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
uint tex = ResolveTex(entity, meshRef, sub);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
}
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
uint tex = ResolveTex(entity, meshRef, sub);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
}
}
}