feat(render): Phase G.1 — billboard particle renderer for weather + spells
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>
This commit is contained in:
parent
9957070cab
commit
9618c66813
4 changed files with 283 additions and 0 deletions
|
|
@ -140,6 +140,7 @@ public sealed class GameWindow : IDisposable
|
||||||
private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new();
|
private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new();
|
||||||
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
|
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
|
||||||
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
|
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
|
||||||
|
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
|
||||||
|
|
||||||
// Remote-entity motion inference: tracks when each remote entity last
|
// Remote-entity motion inference: tracks when each remote entity last
|
||||||
// moved meaningfully. Used in TickAnimations to swap to Ready when
|
// 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(
|
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
|
||||||
_gl, _dats, skyShader, _textureCache);
|
_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.
|
// Phase A.1: replace the one-shot 3×3 preload with a streaming controller.
|
||||||
// Parse runtime radius from environment (default 2 → 5×5 window).
|
// Parse runtime radius from environment (default 2 → 5×5 window).
|
||||||
// Values outside [0, 8] fall back to the field default of 2.
|
// Values outside [0, 8] fall back to the field default of 2.
|
||||||
|
|
@ -3035,6 +3042,13 @@ public sealed class GameWindow : IDisposable
|
||||||
neverCullLandblockId: playerLb,
|
neverCullLandblockId: playerLb,
|
||||||
visibleCellIds: visibility?.VisibleCellIds);
|
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
|
// Debug: draw collision shapes as wireframe cylinders around the
|
||||||
// player so we can visually verify alignment with scenery meshes.
|
// player so we can visually verify alignment with scenery meshes.
|
||||||
if (_debugCollisionVisible && _debugLines is not null)
|
if (_debugCollisionVisible && _debugLines is not null)
|
||||||
|
|
@ -3690,6 +3704,7 @@ public sealed class GameWindow : IDisposable
|
||||||
_shader?.Dispose();
|
_shader?.Dispose();
|
||||||
_sceneLightingUbo?.Dispose();
|
_sceneLightingUbo?.Dispose();
|
||||||
_skyRenderer?.Dispose();
|
_skyRenderer?.Dispose();
|
||||||
|
_particleRenderer?.Dispose();
|
||||||
_debugLines?.Dispose();
|
_debugLines?.Dispose();
|
||||||
_textRenderer?.Dispose();
|
_textRenderer?.Dispose();
|
||||||
_debugFont?.Dispose();
|
_debugFont?.Dispose();
|
||||||
|
|
|
||||||
219
src/AcDream.App/Rendering/ParticleRenderer.cs
Normal file
219
src/AcDream.App/Rendering/ParticleRenderer.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/AcDream.App/Rendering/Shaders/particle.frag
Normal file
18
src/AcDream.App/Rendering/Shaders/particle.frag
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
31
src/AcDream.App/Rendering/Shaders/particle.vert
Normal file
31
src/AcDream.App/Rendering/Shaders/particle.vert
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue