refactor(rendering): introduce InstancedMeshRenderer with GfxObj grouping

Groups all (entity, meshRef) pairs by GfxObjId before drawing so each
GfxObj's sub-meshes are processed as a contiguous batch.  Still uses
per-entity uniform uModel — visual output is identical to the old
StaticMeshRenderer — but the _groups dict is the structural prerequisite
for swapping to DrawElementsInstanced in the follow-up commit.

Key changes:
- New InstancedMeshRenderer.cs with CollectGroups() that fills
  _groups[gfxObjId] = List<InstanceEntry> each frame, reusing the
  inner List<> objects to avoid per-frame allocation.
- Same two-pass (opaque+clipmap first, translucent second) draw logic
  from StaticMeshRenderer, now iterating over groups rather than raw
  entity/meshRef pairs.
- GameWindow.cs: field and constructor swapped from StaticMeshRenderer
  to InstancedMeshRenderer — public API is identical.
- 431 tests green, 0 warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 18:46:20 +02:00
parent 08e309c357
commit b5099e2b21
2 changed files with 369 additions and 2 deletions

View file

@ -23,7 +23,7 @@ public sealed class GameWindow : IDisposable
private DatCollection? _dats;
private float _lastMouseX;
private float _lastMouseY;
private StaticMeshRenderer? _staticMesh;
private InstancedMeshRenderer? _staticMesh;
private Shader? _meshShader;
private TextureCache? _textureCache;
@ -370,7 +370,7 @@ public sealed class GameWindow : IDisposable
_surfaceCache = new Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
_textureCache = new TextureCache(_gl, _dats);
_staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache);
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache);
// Phase A.1: replace the one-shot 3×3 preload with a streaming controller.
// Parse runtime radius from environment (default 2 → 5×5 window).

View file

@ -0,0 +1,367 @@
// src/AcDream.App/Rendering/InstancedMeshRenderer.cs
//
// Step 1 of instanced static-object rendering:
// Groups entities by GfxObjId so each group is drawn contiguously.
// Still uses per-entity uniform uModel — visual output is identical to
// StaticMeshRenderer. The grouping is the prerequisite for true
// DrawElementsInstanced in the follow-up commit.
//
// Architecture note: this class has the same public API as StaticMeshRenderer
// so GameWindow only needs to swap the type name at the call sites.
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 InstancedMeshRenderer : 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();
// ── Instance grouping scratch buffer ─────────────────────────────────────
// Reused every frame to avoid per-frame allocation. Key = GfxObjId.
// Value = list of (model matrix, entity, meshRef) tuples for that GfxObj.
private readonly Dictionary<uint, List<InstanceEntry>> _groups = new();
public InstancedMeshRenderer(GL gl, Shader shader, TextureCache textures)
{
_gl = gl;
_shader = shader;
_textures = textures;
}
// ── Upload ────────────────────────────────────────────────────────────────
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,
Translucency = sm.Translucency,
};
}
// ── Draw ──────────────────────────────────────────────────────────────────
public void Draw(ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null)
{
_shader.Use();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
// ── Collect and group instances ───────────────────────────────────────
// Two-pass collection: opaque+clipmap first, translucent second.
// We collect all landblock entries into the grouping dict, then draw
// each group contiguously. This is the structural change that makes
// true DrawElementsInstanced a one-commit follow-up.
CollectGroups(landblockEntries, frustum, neverCullLandblockId);
// ── 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 (gfxObjId, instances) in _groups)
{
if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes))
continue;
// Check if this GfxObj has any opaque/clipmap sub-meshes at all.
bool hasOpaqueSubMesh = false;
foreach (var sub in subMeshes)
{
if (sub.Translucency == TranslucencyKind.Opaque ||
sub.Translucency == TranslucencyKind.ClipMap)
{
hasOpaqueSubMesh = true;
break;
}
}
if (!hasOpaqueSubMesh) continue;
foreach (var inst in instances)
{
_shader.SetMatrix4("uModel", inst.Model);
foreach (var sub in subMeshes)
{
if (sub.Translucency != TranslucencyKind.Opaque &&
sub.Translucency != TranslucencyKind.ClipMap)
continue;
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
uint tex = ResolveTex(inst.Entity, inst.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);
// 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. Matches WorldBuilder's per-batch CullMode handling in
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/
// BaseObjectRenderManager.cs:361-365.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
foreach (var (gfxObjId, instances) in _groups)
{
if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes))
continue;
bool hasTranslucentSubMesh = false;
foreach (var sub in subMeshes)
{
if (sub.Translucency != TranslucencyKind.Opaque &&
sub.Translucency != TranslucencyKind.ClipMap)
{
hasTranslucentSubMesh = true;
break;
}
}
if (!hasTranslucentSubMesh) continue;
foreach (var inst in instances)
{
_shader.SetMatrix4("uModel", inst.Model);
foreach (var sub in subMeshes)
{
if (sub.Translucency == TranslucencyKind.Opaque ||
sub.Translucency == TranslucencyKind.ClipMap)
continue;
switch (sub.Translucency)
{
case TranslucencyKind.Additive:
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
break;
case TranslucencyKind.InvAlpha:
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
break;
default: // AlphaBlend
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
break;
}
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
uint tex = ResolveTex(inst.Entity, inst.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);
}
// ── Grouping ──────────────────────────────────────────────────────────────
/// <summary>
/// Iterates all visible landblock entries and groups every (entity, meshRef)
/// pair by GfxObjId into <see cref="_groups"/>. The resulting dict drives
/// both render passes in <see cref="Draw"/>. Clears the dict before filling.
/// </summary>
private void CollectGroups(
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId)
{
// Clear previous frame's groups but keep the per-group List<> objects
// so they can be reused (avoids re-allocating inner lists every frame).
foreach (var list in _groups.Values)
list.Clear();
foreach (var entry in landblockEntries)
{
// Per-landblock frustum cull. Never cull the player's landblock.
if (frustum is not null &&
entry.LandblockId != neverCullLandblockId &&
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
continue;
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0)
continue;
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
foreach (var meshRef in entity.MeshRefs)
{
if (!_gpuByGfxObj.ContainsKey(meshRef.GfxObjId))
continue;
var model = meshRef.PartTransform * entityRoot;
if (!_groups.TryGetValue(meshRef.GfxObjId, out var group))
{
group = new List<InstanceEntry>();
_groups[meshRef.GfxObjId] = group;
}
group.Add(new InstanceEntry(model, entity, meshRef));
}
}
}
}
// ── Texture resolution ────────────────────────────────────────────────────
/// <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);
}
}
// ── Disposal ──────────────────────────────────────────────────────────────
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();
_groups.Clear();
}
// ── Private types ─────────────────────────────────────────────────────────
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;
}
/// <summary>
/// One entry in a per-GfxObj instance group. Carries the pre-computed
/// model matrix plus the entity/meshRef needed for texture resolution.
/// </summary>
private readonly struct InstanceEntry
{
public readonly Matrix4x4 Model;
public readonly WorldEntity Entity;
public readonly MeshRef MeshRef;
public InstanceEntry(Matrix4x4 model, WorldEntity entity, MeshRef meshRef)
{
Model = model;
Entity = entity;
MeshRef = meshRef;
}
}
}