// 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> _gpuByGfxObj = new(); public StaticMeshRenderer(GL gl, Shader shader, TextureCache textures) { _gl = gl; _shader = shader; _textures = textures; } 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, // 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 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); } /// /// 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); } } 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; /// /// Cached from GfxObjSubMesh.Translucency at upload time. /// Avoids any per-draw lookup into external state. /// public TranslucencyKind Translucency; } }