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:
parent
08e309c357
commit
b5099e2b21
2 changed files with 369 additions and 2 deletions
|
|
@ -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).
|
||||
|
|
|
|||
367
src/AcDream.App/Rendering/InstancedMeshRenderer.cs
Normal file
367
src/AcDream.App/Rendering/InstancedMeshRenderer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue