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:
parent
07fde88534
commit
3c9fc63af7
4 changed files with 156 additions and 64 deletions
|
|
@ -1189,6 +1189,28 @@ public sealed class GameWindow : IDisposable
|
|||
lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache);
|
||||
_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.
|
||||
// EnsureUploaded is idempotent so duplicates across landblocks are free.
|
||||
if (_staticMesh is not null)
|
||||
|
|
@ -1320,8 +1342,10 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
if (_cameraController is not null)
|
||||
{
|
||||
_terrain?.Draw(_cameraController.Active);
|
||||
_staticMesh?.Draw(_cameraController.Active, _worldState.Entities);
|
||||
var camera = _cameraController.Active;
|
||||
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection);
|
||||
_terrain?.Draw(camera, frustum);
|
||||
_staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,12 +41,24 @@ public sealed unsafe class TerrainRenderer : IDisposable
|
|||
_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
|
||||
{
|
||||
LandblockId = landblockId,
|
||||
Vao = _gl.GenVertexArray(),
|
||||
WorldOrigin = worldOrigin,
|
||||
IndexCount = meshData.Indices.Length,
|
||||
MinZ = zMin,
|
||||
MaxZ = zMax,
|
||||
};
|
||||
|
||||
_gl.BindVertexArray(gpu.Vao);
|
||||
|
|
@ -104,7 +116,7 @@ public sealed unsafe class TerrainRenderer : IDisposable
|
|||
_landblocks.Remove(landblockId);
|
||||
}
|
||||
|
||||
public void Draw(ICamera camera)
|
||||
public void Draw(ICamera camera, FrustumPlanes? frustum = null)
|
||||
{
|
||||
_shader.Use();
|
||||
_shader.SetMatrix4("uView", camera.View);
|
||||
|
|
@ -123,6 +135,14 @@ public sealed unsafe class TerrainRenderer : IDisposable
|
|||
|
||||
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);
|
||||
_shader.SetMatrix4("uModel", model);
|
||||
_gl.BindVertexArray(lb.Vao);
|
||||
|
|
@ -150,5 +170,7 @@ public sealed unsafe class TerrainRenderer : IDisposable
|
|||
public uint Ebo;
|
||||
public int IndexCount;
|
||||
public Vector3 WorldOrigin;
|
||||
public float MinZ;
|
||||
public float MaxZ;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
|
@ -38,6 +39,7 @@ namespace AcDream.App.Streaming;
|
|||
public sealed class GpuWorldState
|
||||
{
|
||||
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
|
||||
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
|
||||
/// <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>
|
||||
/// Total live entities currently parked in the pending bucket waiting
|
||||
/// 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
|
||||
// server will resend them via CreateObject when the player returns.
|
||||
_pendingByLandblock.Remove(landblockId);
|
||||
_aabbs.Remove(landblockId);
|
||||
|
||||
if (_loaded.Remove(landblockId))
|
||||
RebuildFlatView();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue