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);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.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)
|
||||||
{
|
{
|
||||||
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;
|
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;
|
continue;
|
||||||
|
|
||||||
var entityRoot =
|
foreach (var meshRef in entity.MeshRefs)
|
||||||
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
|
||||||
Matrix4x4.CreateTranslation(entity.Position);
|
|
||||||
var model = meshRef.PartTransform * entityRoot;
|
|
||||||
_shader.SetMatrix4("uModel", model);
|
|
||||||
|
|
||||||
foreach (var sub in subMeshes)
|
|
||||||
{
|
{
|
||||||
// Skip translucent sub-meshes in the first pass.
|
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
|
||||||
if (sub.Translucency != TranslucencyKind.Opaque &&
|
|
||||||
sub.Translucency != TranslucencyKind.ClipMap)
|
|
||||||
continue;
|
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);
|
foreach (var sub in subMeshes)
|
||||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
{
|
||||||
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
// Skip translucent sub-meshes in the first pass.
|
||||||
|
if (sub.Translucency != TranslucencyKind.Opaque &&
|
||||||
|
sub.Translucency != TranslucencyKind.ClipMap)
|
||||||
|
continue;
|
||||||
|
|
||||||
_gl.BindVertexArray(sub.Vao);
|
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
|
||||||
_gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
|
|
||||||
|
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.CullFace(TriangleFace.Back);
|
||||||
_gl.FrontFace(FrontFaceDirection.Ccw);
|
_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;
|
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;
|
continue;
|
||||||
|
|
||||||
var entityRoot =
|
foreach (var meshRef in entity.MeshRefs)
|
||||||
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
|
||||||
Matrix4x4.CreateTranslation(entity.Position);
|
|
||||||
var model = meshRef.PartTransform * entityRoot;
|
|
||||||
_shader.SetMatrix4("uModel", model);
|
|
||||||
|
|
||||||
foreach (var sub in subMeshes)
|
|
||||||
{
|
{
|
||||||
if (sub.Translucency == TranslucencyKind.Opaque ||
|
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
|
||||||
sub.Translucency == TranslucencyKind.ClipMap)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Set per-draw blend function.
|
var entityRoot =
|
||||||
switch (sub.Translucency)
|
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:
|
if (sub.Translucency == TranslucencyKind.Opaque ||
|
||||||
// src*a + dst — portal swirls, glows
|
sub.Translucency == TranslucencyKind.ClipMap)
|
||||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
|
continue;
|
||||||
break;
|
|
||||||
|
|
||||||
case TranslucencyKind.InvAlpha:
|
// Set per-draw blend function.
|
||||||
// src*(1-a) + dst*a
|
switch (sub.Translucency)
|
||||||
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
|
{
|
||||||
break;
|
case TranslucencyKind.Additive:
|
||||||
|
// src*a + dst — portal swirls, glows
|
||||||
|
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
|
||||||
|
break;
|
||||||
|
|
||||||
default: // AlphaBlend
|
case TranslucencyKind.InvAlpha:
|
||||||
// src*a + dst*(1-a)
|
// src*(1-a) + dst*a
|
||||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
|
||||||
break;
|
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);
|
_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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue