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);
+}