diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 722c8a2..de78d13 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -140,6 +140,7 @@ public sealed class GameWindow : IDisposable private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new(); private AcDream.Core.Vfx.ParticleSystem? _particleSystem; private AcDream.Core.Vfx.ParticleHookSink? _particleSink; + private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; // Remote-entity motion inference: tracks when each remote entity last // moved meaningfully. Used in TickAnimations to swap to Ready when @@ -737,6 +738,12 @@ public sealed class GameWindow : IDisposable _skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer( _gl, _dats, skyShader, _textureCache); + // Phase G.1 particle renderer — renders rain / snow / spell auras + // spawned into the shared ParticleSystem as billboard quads. + // Weather uses AttachLocal emitters so the rain volume follows + // the player. + _particleRenderer = new ParticleRenderer(_gl, shadersDir); + // Phase A.1: replace the one-shot 3×3 preload with a streaming controller. // Parse runtime radius from environment (default 2 → 5×5 window). // Values outside [0, 8] fall back to the field default of 2. @@ -3035,6 +3042,13 @@ public sealed class GameWindow : IDisposable neverCullLandblockId: playerLb, visibleCellIds: visibility?.VisibleCellIds); + // Phase G.1 / E.3: draw all live particles after opaque + // scene geometry so alpha blending composites correctly. + // Runs with depth test on (particles occluded by walls) + // but depth write off (no self-occlusion sorting needed). + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos); + // Debug: draw collision shapes as wireframe cylinders around the // player so we can visually verify alignment with scenery meshes. if (_debugCollisionVisible && _debugLines is not null) @@ -3690,6 +3704,7 @@ public sealed class GameWindow : IDisposable _shader?.Dispose(); _sceneLightingUbo?.Dispose(); _skyRenderer?.Dispose(); + _particleRenderer?.Dispose(); _debugLines?.Dispose(); _textRenderer?.Dispose(); _debugFont?.Dispose(); diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs new file mode 100644 index 0000000..7128694 --- /dev/null +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -0,0 +1,219 @@ +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(); + } +} diff --git a/src/AcDream.App/Rendering/Shaders/particle.frag b/src/AcDream.App/Rendering/Shaders/particle.frag new file mode 100644 index 0000000..4633285 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/particle.frag @@ -0,0 +1,18 @@ +#version 430 core + +in vec2 vTex; +in vec4 vColor; +out vec4 fragColor; + +// Procedural rain/snow streak — no texture, just a radial falloff +// centred on the quad so droplets read as small soft circles. Good +// enough for weather + basic spell auras without a texture pipeline. + +void main() { + // Signed distance from quad center (in UV space). + vec2 d = vTex - vec2(0.5, 0.5); + float r = length(d) * 2.0; // 0 at center, 1 at corner + float falloff = smoothstep(1.0, 0.4, r); + if (falloff < 0.02) discard; + fragColor = vec4(vColor.rgb, vColor.a * falloff); +} diff --git a/src/AcDream.App/Rendering/Shaders/particle.vert b/src/AcDream.App/Rendering/Shaders/particle.vert new file mode 100644 index 0000000..7b26dbf --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/particle.vert @@ -0,0 +1,31 @@ +#version 430 core + +// Unit quad vertex (XY -0.5..+0.5) +layout(location = 0) in vec2 aQuad; +layout(location = 1) in vec2 aTex; + +// Per-instance: world-space center + size +layout(location = 2) in vec4 aPosAndSize; +layout(location = 3) in vec4 aColor; + +uniform mat4 uViewProjection; +uniform vec3 uCameraRight; +uniform vec3 uCameraUp; + +out vec2 vTex; +out vec4 vColor; + +void main() { + vec3 center = aPosAndSize.xyz; + float size = aPosAndSize.w; + + // Billboard: offset the quad vertex along the camera's right + up + // basis vectors so it always faces the viewer. + vec3 world = center + + uCameraRight * (aQuad.x * size) + + uCameraUp * (aQuad.y * size); + + vTex = aTex; + vColor = aColor; + gl_Position = uViewProjection * vec4(world, 1.0); +}