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>
291 lines
12 KiB
C#
291 lines
12 KiB
C#
// src/AcDream.App/Rendering/StaticMeshRenderer.cs
|
|
using System.Numerics;
|
|
using AcDream.Core.Meshing;
|
|
using AcDream.Core.Terrain;
|
|
using AcDream.Core.World;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
public sealed unsafe class StaticMeshRenderer : IDisposable
|
|
{
|
|
private readonly GL _gl;
|
|
private readonly Shader _shader;
|
|
private readonly TextureCache _textures;
|
|
|
|
// One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes.
|
|
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
|
|
|
|
public StaticMeshRenderer(GL gl, Shader shader, TextureCache textures)
|
|
{
|
|
_gl = gl;
|
|
_shader = shader;
|
|
_textures = textures;
|
|
}
|
|
|
|
public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
|
|
{
|
|
if (_gpuByGfxObj.ContainsKey(gfxObjId))
|
|
return;
|
|
|
|
var list = new List<SubMeshGpu>(subMeshes.Count);
|
|
foreach (var sm in subMeshes)
|
|
list.Add(UploadSubMesh(sm));
|
|
_gpuByGfxObj[gfxObjId] = list;
|
|
}
|
|
|
|
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
|
|
{
|
|
uint vao = _gl.GenVertexArray();
|
|
_gl.BindVertexArray(vao);
|
|
|
|
uint vbo = _gl.GenBuffer();
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
|
|
fixed (void* p = sm.Vertices)
|
|
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
|
(nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
|
|
|
|
uint ebo = _gl.GenBuffer();
|
|
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
|
|
fixed (void* p = sm.Indices)
|
|
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
|
|
(nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
|
|
|
|
uint stride = (uint)sizeof(Vertex);
|
|
_gl.EnableVertexAttribArray(0);
|
|
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
|
|
_gl.EnableVertexAttribArray(1);
|
|
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
|
|
_gl.EnableVertexAttribArray(2);
|
|
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
|
|
_gl.EnableVertexAttribArray(3);
|
|
_gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));
|
|
|
|
_gl.BindVertexArray(0);
|
|
|
|
return new SubMeshGpu
|
|
{
|
|
Vao = vao,
|
|
Vbo = vbo,
|
|
Ebo = ebo,
|
|
IndexCount = sm.Indices.Length,
|
|
SurfaceId = sm.SurfaceId,
|
|
// Capture translucency at upload time so the draw loop never
|
|
// has to look it up from external state.
|
|
Translucency = sm.Translucency,
|
|
};
|
|
}
|
|
|
|
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);
|
|
|
|
// ── 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 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)
|
|
continue;
|
|
|
|
foreach (var meshRef in entity.MeshRefs)
|
|
{
|
|
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
|
|
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)
|
|
{
|
|
// Skip translucent sub-meshes in the first pass.
|
|
if (sub.Translucency != TranslucencyKind.Opaque &&
|
|
sub.Translucency != TranslucencyKind.ClipMap)
|
|
continue;
|
|
|
|
_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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
|
|
// Depth test on so translucents composite correctly behind opaque geometry.
|
|
// Depth write OFF so translucents don't occlude each other or downstream
|
|
// opaque draws. Blend function is set per-draw based on TranslucencyKind.
|
|
//
|
|
// NOTE: translucent draws are NOT sorted by depth — overlapping translucent
|
|
// surfaces can composite in the wrong order. Portal-sized billboards don't
|
|
// overlap in practice so this is acceptable and avoids a larger refactor.
|
|
_gl.Enable(EnableCap.Blend);
|
|
_gl.DepthMask(false);
|
|
|
|
// Phase 9.2: enable back-face culling for the translucent pass so
|
|
// closed-shell translucents (lifestone crystal, glow gems, any
|
|
// convex blended mesh) don't draw their back faces over their
|
|
// front faces in arbitrary iteration order. Without this, the
|
|
// 58 triangles of the lifestone crystal composited with an
|
|
// "inside-out" look where the user saw through one face into
|
|
// the hollow interior. With back-face culling on, back faces are
|
|
// dropped at rasterization time, front faces composite as-is,
|
|
// and depth ordering within the front-facing subset is a
|
|
// non-issue for closed convex-ish shells. Matches WorldBuilder's
|
|
// per-batch CullMode handling in
|
|
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/
|
|
// BaseObjectRenderManager.cs:361-365.
|
|
//
|
|
// Our fan triangulation emits pos-side polygons as
|
|
// (0, i, i+1) which is CCW in standard OpenGL conventions, so
|
|
// GL_BACK + CCW front is the correct state. Neg-side polygons
|
|
// (if any) use reversed winding and get culled here — that's a
|
|
// known limitation and matches the opaque-pass behavior since
|
|
// neg-side polys are virtually never translucent in AC content.
|
|
_gl.Enable(EnableCap.CullFace);
|
|
_gl.CullFace(TriangleFace.Back);
|
|
_gl.FrontFace(FrontFaceDirection.Ccw);
|
|
|
|
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)
|
|
continue;
|
|
|
|
foreach (var meshRef in entity.MeshRefs)
|
|
{
|
|
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
|
|
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)
|
|
{
|
|
if (sub.Translucency == TranslucencyKind.Opaque ||
|
|
sub.Translucency == TranslucencyKind.ClipMap)
|
|
continue;
|
|
|
|
// Set per-draw blend function.
|
|
switch (sub.Translucency)
|
|
{
|
|
case TranslucencyKind.Additive:
|
|
// src*a + dst — portal swirls, glows
|
|
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore default GL state for subsequent renderers (terrain etc.).
|
|
_gl.DepthMask(true);
|
|
_gl.Disable(EnableCap.Blend);
|
|
_gl.Disable(EnableCap.CullFace);
|
|
|
|
_gl.BindVertexArray(0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the GL texture id for a sub-mesh, honouring palette and
|
|
/// texture overrides carried on the entity and the mesh-ref.
|
|
/// </summary>
|
|
private uint ResolveTex(WorldEntity entity, MeshRef meshRef, SubMeshGpu sub)
|
|
{
|
|
uint overrideOrigTex = 0;
|
|
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
|
|
&& meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out overrideOrigTex);
|
|
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
|
|
|
|
if (entity.PaletteOverride is not null)
|
|
{
|
|
return _textures.GetOrUploadWithPaletteOverride(
|
|
sub.SurfaceId, origTexOverride, entity.PaletteOverride);
|
|
}
|
|
else if (hasOrigTexOverride)
|
|
{
|
|
return _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex);
|
|
}
|
|
else
|
|
{
|
|
return _textures.GetOrUpload(sub.SurfaceId);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var subs in _gpuByGfxObj.Values)
|
|
{
|
|
foreach (var sub in subs)
|
|
{
|
|
_gl.DeleteBuffer(sub.Vbo);
|
|
_gl.DeleteBuffer(sub.Ebo);
|
|
_gl.DeleteVertexArray(sub.Vao);
|
|
}
|
|
}
|
|
_gpuByGfxObj.Clear();
|
|
}
|
|
|
|
private sealed class SubMeshGpu
|
|
{
|
|
public uint Vao;
|
|
public uint Vbo;
|
|
public uint Ebo;
|
|
public int IndexCount;
|
|
public uint SurfaceId;
|
|
/// <summary>
|
|
/// Cached from GfxObjSubMesh.Translucency at upload time.
|
|
/// Avoids any per-draw lookup into external state.
|
|
/// </summary>
|
|
public TranslucencyKind Translucency;
|
|
}
|
|
}
|