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 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();
|
||||
|
|
|
|||
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