// 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; } } }