From b5099e2b210e9ba877e31131ce38bf580c86e4ee Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Apr 2026 18:46:20 +0200 Subject: [PATCH] refactor(rendering): introduce InstancedMeshRenderer with GfxObj grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- src/AcDream.App/Rendering/GameWindow.cs | 4 +- .../Rendering/InstancedMeshRenderer.cs | 367 ++++++++++++++++++ 2 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 src/AcDream.App/Rendering/InstancedMeshRenderer.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4d47b4d..420025a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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(); _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). diff --git a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs new file mode 100644 index 0000000..d14395f --- /dev/null +++ b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs @@ -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> _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> _groups = new(); + + public InstancedMeshRenderer(GL gl, Shader shader, TextureCache textures) + { + _gl = gl; + _shader = shader; + _textures = textures; + } + + // ── Upload ──────────────────────────────────────────────────────────────── + + public void EnsureUploaded(uint gfxObjId, IReadOnlyList subMeshes) + { + if (_gpuByGfxObj.ContainsKey(gfxObjId)) + return; + + var list = new List(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 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 ────────────────────────────────────────────────────────────── + + /// + /// Iterates all visible landblock entries and groups every (entity, meshRef) + /// pair by GfxObjId into . The resulting dict drives + /// both render passes in . Clears the dict before filling. + /// + private void CollectGroups( + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList 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(); + _groups[meshRef.GfxObjId] = group; + } + + group.Add(new InstanceEntry(model, entity, meshRef)); + } + } + } + } + + // ── Texture resolution ──────────────────────────────────────────────────── + + /// + /// Resolves the GL texture id for a sub-mesh, honouring palette and + /// texture overrides carried on the entity and the mesh-ref. + /// + 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; + /// + /// Cached from GfxObjSubMesh.Translucency at upload time. + /// Avoids any per-draw lookup into external state. + /// + public TranslucencyKind Translucency; + } + + /// + /// One entry in a per-GfxObj instance group. Carries the pre-computed + /// model matrix plus the entity/meshRef needed for texture resolution. + /// + 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; + } + } +}