Add ParticleRenderer that draws every live particle from the shared ParticleSystem as a billboarded quad. Unit quad VBO + per-instance (pos, size, color) VBO with glVertexAttribDivisor for one draw call per emitter. Billboards using the camera's basis vectors so quads always face the viewer. Fragment shader does a procedural radial falloff (no texture pipeline needed — raindrops / snowflakes read as soft dots). AttachLocal emitters get re-centred on the camera each frame so the rain volume follows the player per r12 §7. Two-pass render splits additive from alpha-blend emitters so blend state flips once per kind rather than per-emitter. Wired into GameWindow.OnRender after static-mesh draw with depth write off (particles occluded by walls but don't self-occlude). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
8.4 KiB
C#
219 lines
8.4 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Vfx;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Emitters tagged with <see cref="EmitterFlags.AttachLocal"/> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<ParticleEmitter>(32);
|
|
var addGroup = new List<ParticleEmitter>(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();
|
|
}
|
|
}
|