using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Vfx; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; /// /// Simple billboard-quad particle renderer. One draw call per emitter: /// the CPU streams (position, size, rotation, packed color) into a /// per-instance VBO; a unit quad VBO gets instanced and the vertex /// shader rotates the quad around the camera forward vector so it /// always faces the viewer. /// /// /// Not a retail-perfect port of the D3D7 fixed-function particle pipe; /// good enough for rain, snow, and the basic spell auras we need for /// Phase G.1's weather + E.3's playback. Trails + spot-light /// interactions deferred. /// /// /// /// Emitters tagged with get /// re-anchored to the current camera position each frame so the rain /// volume follows the player (r12 §7). This is the cheap version of /// retail's "IsParentLocal" flag on held emitters. /// /// public sealed unsafe class ParticleRenderer : IDisposable { private readonly GL _gl; private readonly Shader _shader; // Unit-quad vertex buffer (-0.5..+0.5 in XY). 4 verts, 6 indices. private readonly uint _quadVao; private readonly uint _quadVbo; private readonly uint _quadEbo; // Instance buffer — 8 floats per particle: posX,Y,Z, size, colorR,G,B,A. private readonly uint _instanceVbo; private float[] _instanceScratch = new float[256 * 8]; public ParticleRenderer(GL gl, string shadersDir) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); _shader = new Shader(_gl, System.IO.Path.Combine(shadersDir, "particle.vert"), System.IO.Path.Combine(shadersDir, "particle.frag")); // Unit quad around origin (XY plane, Z = 0). The vertex shader // reads this, then offsets into world space using the // per-instance (pos, size) values. float[] quadVerts = new float[] { // pos x,y uv -0.5f, -0.5f, 0f, 0f, 0.5f, -0.5f, 1f, 0f, 0.5f, 0.5f, 1f, 1f, -0.5f, 0.5f, 0f, 1f, }; uint[] quadIdx = new uint[] { 0, 1, 2, 0, 2, 3 }; _quadVao = _gl.GenVertexArray(); _gl.BindVertexArray(_quadVao); _quadVbo = _gl.GenBuffer(); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _quadVbo); fixed (void* p = quadVerts) _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(quadVerts.Length * sizeof(float)), p, BufferUsageARB.StaticDraw); _gl.EnableVertexAttribArray(0); _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)0); _gl.EnableVertexAttribArray(1); _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)(2 * sizeof(float))); _quadEbo = _gl.GenBuffer(); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _quadEbo); fixed (void* p = quadIdx) _gl.BufferData(BufferTargetARB.ElementArrayBuffer, (nuint)(quadIdx.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw); _instanceVbo = _gl.GenBuffer(); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(256 * 8 * sizeof(float)), (void*)0, BufferUsageARB.DynamicDraw); // Per-instance attributes: pos+size at loc 2, color at loc 3. _gl.EnableVertexAttribArray(2); _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)0); _gl.VertexAttribDivisor(2, 1); _gl.EnableVertexAttribArray(3); _gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)(4 * sizeof(float))); _gl.VertexAttribDivisor(3, 1); _gl.BindVertexArray(0); } /// /// Draw every live particle. Splits emitters by blend mode (additive /// vs alpha-blend) but doesn't sort by depth — particles don't /// self-occlude enough for sorting to matter for rain/snow. /// public void Draw(ParticleSystem particles, ICamera camera, Vector3 cameraWorldPos) { if (particles is null || camera is null) return; _shader.Use(); _shader.SetMatrix4("uViewProjection", camera.View * camera.Projection); _shader.SetVec3("uCameraRight", GetCameraRight(camera)); _shader.SetVec3("uCameraUp", GetCameraUp(camera)); _gl.Enable(EnableCap.Blend); _gl.DepthMask(false); _gl.Disable(EnableCap.CullFace); // Group emitters by additive vs alpha-blend so we flip blend state // once per group rather than per-emitter. Simple two-pass split. var alphaGroup = new List(32); var addGroup = new List(32); foreach (var (em, _) in particles.EnumerateLive()) { var list = (em.Desc.Flags & EmitterFlags.Additive) != 0 ? addGroup : alphaGroup; if (list.Count == 0 || !ReferenceEquals(list[^1], em)) list.Add(em); } _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); foreach (var em in alphaGroup) DrawEmitter(em, cameraWorldPos); _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); foreach (var em in addGroup) DrawEmitter(em, cameraWorldPos); _gl.DepthMask(true); _gl.Disable(EnableCap.Blend); _gl.BindVertexArray(0); } private void DrawEmitter(ParticleEmitter em, Vector3 cameraWorldPos) { int liveCount = 0; for (int i = 0; i < em.Particles.Length; i++) if (em.Particles[i].Alive) liveCount++; if (liveCount == 0) return; // Ensure instance buffer is big enough. int needed = liveCount * 8; if (_instanceScratch.Length < needed) _instanceScratch = new float[needed + 256 * 8]; // Anchor adjustment for AttachLocal emitters — re-center the // emission volume on the camera each frame so the rain/snow // follows the viewer. The emitter's AnchorPos stays at the // spawn point, but when writing out world-space particles we // add (camera - emitterAnchor) so they track the camera. bool attachLocal = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0; Vector3 cameraOffset = attachLocal ? (cameraWorldPos - em.AnchorPos) : Vector3.Zero; int idx = 0; for (int i = 0; i < em.Particles.Length; i++) { ref var p = ref em.Particles[i]; if (!p.Alive) continue; Vector3 pos = p.Position + cameraOffset; _instanceScratch[idx * 8 + 0] = pos.X; _instanceScratch[idx * 8 + 1] = pos.Y; _instanceScratch[idx * 8 + 2] = pos.Z; _instanceScratch[idx * 8 + 3] = p.Size; // ARGB → RGBA floats. float a = ((p.ColorArgb >> 24) & 0xFF) / 255f; float r = ((p.ColorArgb >> 16) & 0xFF) / 255f; float g = ((p.ColorArgb >> 8) & 0xFF) / 255f; float b = ( p.ColorArgb & 0xFF) / 255f; _instanceScratch[idx * 8 + 4] = r; _instanceScratch[idx * 8 + 5] = g; _instanceScratch[idx * 8 + 6] = b; _instanceScratch[idx * 8 + 7] = a; idx++; } _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); fixed (void* bp = _instanceScratch) { _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(liveCount * 8 * sizeof(float)), bp, BufferUsageARB.DynamicDraw); } _gl.BindVertexArray(_quadVao); _gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)liveCount); } private static Vector3 GetCameraRight(ICamera camera) { Matrix4x4.Invert(camera.View, out var inv); return Vector3.Normalize(new Vector3(inv.M11, inv.M12, inv.M13)); } private static Vector3 GetCameraUp(ICamera camera) { Matrix4x4.Invert(camera.View, out var inv); return Vector3.Normalize(new Vector3(inv.M21, inv.M22, inv.M23)); } public void Dispose() { _gl.DeleteBuffer(_quadVbo); _gl.DeleteBuffer(_quadEbo); _gl.DeleteBuffer(_instanceVbo); _gl.DeleteVertexArray(_quadVao); _shader.Dispose(); } }