Ports retail's ParticleEmitterInfo / Particle::Init / Particle::Update
(0x005170d0..0x0051d400) and PhysicsScript runtime to a C# data-layer
plus a Silk.NET billboard renderer. Sky-PES path is debug-only behind
ACDREAM_ENABLE_SKY_PES because named-retail decomp confirms GameSky
copies SkyObject.pes_id but never reads it (CreateDeletePhysicsObjects
0x005073c0, MakeObject 0x00506ee0, UseTime 0x005075b0).
Post-review fixes folded into this commit:
H1: AttachLocal (is_parent_local=1) follows live parent each frame.
ParticleSystem.UpdateEmitterAnchor + ParticleHookSink.UpdateEntityAnchor
let the owning subsystem refresh AnchorPos every tick — matches
ParticleEmitter::UpdateParticles 0x0051d2d4 which re-reads the live
parent frame when is_parent_local != 0. Drops the renderer-side
cameraOffset hack that only worked when the parent was the camera.
H3: Strip the long stale comment in GfxObjMesh.cs that contradicted the
retail-faithful (1 - translucency) opacity formula. The code was
right; the comment was a leftover from an earlier hypothesis and
would have invited a wrong "fix".
M1: SkyRenderer tracks textures whose wrap mode it set to ClampToEdge
and restores them to Repeat at end-of-pass, so non-sky renderers
that share the GL handle can't silently inherit clamped wrap state.
M2: Post-scene Z-offset (-120m) only fires when the SkyObject is
weather-flagged AND bit 0x08 is clear, matching retail
GameSky::UpdatePosition 0x00506dd0. The old code applied it to
every post-scene object — a no-op today (every Dereth post-scene
entry happens to be weather-flagged) but a future post-scene-only
sun rim would have been pushed below the camera.
M4: ParticleSystem.EmitterDied event lets ParticleHookSink prune dead
handles from the per-entity tracking dictionaries, fixing a slow
leak where naturally-expired emitters' handles stayed in the
ConcurrentBag forever during long sessions.
M5: SkyPesEntityId moves the post-scene flag bit to 0x08000000 so it
can't ever overlap the object-index range. Synthetic IDs stay in
the reserved 0xFxxxxxxx space.
New tests (ParticleSystemTests + ParticleHookSinkTests):
- UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
- UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
- EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
- Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed (retail-faithful
single-emit-per-frame behavior)
- UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
- EmitterDied_PrunesPerEntityHandleTracking
dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(up from 1325).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
464 lines
16 KiB
C#
464 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Vfx;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Enums;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// Instanced renderer for retail particle emitters.
|
|
/// </summary>
|
|
public sealed unsafe class ParticleRenderer : IDisposable
|
|
{
|
|
private readonly record struct BatchKey(uint TextureHandle, bool UseTexture, bool Additive);
|
|
private readonly record struct ParticleDraw(BatchKey Key, ParticleInstance Instance);
|
|
|
|
private readonly struct ParticleInstance
|
|
{
|
|
public readonly Vector3 Position;
|
|
public readonly Vector3 AxisX;
|
|
public readonly Vector3 AxisY;
|
|
public readonly uint ColorArgb;
|
|
public readonly float DistanceSq;
|
|
|
|
public ParticleInstance(Vector3 position, Vector3 axisX, Vector3 axisY, uint colorArgb, float distanceSq)
|
|
{
|
|
Position = position;
|
|
AxisX = axisX;
|
|
AxisY = axisY;
|
|
ColorArgb = colorArgb;
|
|
DistanceSq = distanceSq;
|
|
}
|
|
}
|
|
|
|
private readonly GL _gl;
|
|
private readonly Shader _shader;
|
|
private readonly TextureCache? _textures;
|
|
private readonly DatCollection? _dats;
|
|
private readonly Dictionary<uint, ParticleGfxInfo> _particleGfxInfoByGfxObj = new();
|
|
|
|
private readonly uint _quadVao;
|
|
private readonly uint _quadVbo;
|
|
private readonly uint _quadEbo;
|
|
private readonly uint _instanceVbo;
|
|
|
|
private float[] _instanceScratch = new float[256 * 16];
|
|
|
|
public ParticleRenderer(GL gl, string shadersDir, TextureCache? textures = null, DatCollection? dats = null)
|
|
{
|
|
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
|
_textures = textures;
|
|
_dats = dats;
|
|
_shader = new Shader(_gl,
|
|
System.IO.Path.Combine(shadersDir, "particle.vert"),
|
|
System.IO.Path.Combine(shadersDir, "particle.frag"));
|
|
|
|
float[] quadVerts =
|
|
{
|
|
-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 = { 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 * 16 * sizeof(float)), (void*)0, BufferUsageARB.DynamicDraw);
|
|
|
|
_gl.EnableVertexAttribArray(2);
|
|
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)0);
|
|
_gl.VertexAttribDivisor(2, 1);
|
|
_gl.EnableVertexAttribArray(3);
|
|
_gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(4 * sizeof(float)));
|
|
_gl.VertexAttribDivisor(3, 1);
|
|
_gl.EnableVertexAttribArray(4);
|
|
_gl.VertexAttribPointer(4, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(8 * sizeof(float)));
|
|
_gl.VertexAttribDivisor(4, 1);
|
|
_gl.EnableVertexAttribArray(5);
|
|
_gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(12 * sizeof(float)));
|
|
_gl.VertexAttribDivisor(5, 1);
|
|
|
|
_gl.BindVertexArray(0);
|
|
}
|
|
|
|
public void Draw(
|
|
ParticleSystem particles,
|
|
ICamera camera,
|
|
Vector3 cameraWorldPos,
|
|
ParticleRenderPass renderPass = ParticleRenderPass.Scene)
|
|
{
|
|
if (particles is null || camera is null)
|
|
return;
|
|
|
|
Matrix4x4.Invert(camera.View, out var invView);
|
|
Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13));
|
|
Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23));
|
|
var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp);
|
|
if (draws.Count == 0)
|
|
return;
|
|
draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq));
|
|
|
|
_shader.Use();
|
|
_shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
|
|
_shader.SetInt("uParticleTexture", 0);
|
|
|
|
_gl.Enable(EnableCap.DepthTest);
|
|
_gl.Enable(EnableCap.Blend);
|
|
_gl.DepthMask(false);
|
|
_gl.Disable(EnableCap.CullFace);
|
|
_gl.ActiveTexture(TextureUnit.Texture0);
|
|
|
|
var run = new List<ParticleInstance>(64);
|
|
for (int i = 0; i < draws.Count;)
|
|
{
|
|
var key = draws[i].Key;
|
|
run.Clear();
|
|
do
|
|
{
|
|
run.Add(draws[i].Instance);
|
|
i++;
|
|
}
|
|
while (i < draws.Count && draws[i].Key == key);
|
|
|
|
_gl.BlendFunc(
|
|
BlendingFactor.SrcAlpha,
|
|
key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha);
|
|
_shader.SetInt("uUseTexture", key.UseTexture ? 1 : 0);
|
|
_gl.BindTexture(TextureTarget.Texture2D, key.UseTexture ? key.TextureHandle : 0);
|
|
DrawInstances(run);
|
|
}
|
|
|
|
_gl.BindTexture(TextureTarget.Texture2D, 0);
|
|
_gl.BindVertexArray(0);
|
|
_gl.DepthMask(true);
|
|
_gl.Disable(EnableCap.Blend);
|
|
}
|
|
|
|
private List<ParticleDraw> BuildDrawList(
|
|
ParticleSystem particles,
|
|
Vector3 cameraWorldPos,
|
|
ParticleRenderPass renderPass,
|
|
Vector3 cameraRight,
|
|
Vector3 cameraUp)
|
|
{
|
|
var draws = new List<ParticleDraw>(Math.Max(64, particles.ActiveParticleCount));
|
|
foreach (var (em, idx) in particles.EnumerateLive())
|
|
{
|
|
if (em.RenderPass != renderPass)
|
|
continue;
|
|
|
|
ref var p = ref em.Particles[idx];
|
|
// `p.Position` is already in world coordinates: AttachLocal
|
|
// emitters get their AnchorPos refreshed each frame by the
|
|
// owning subsystem (sky-PES driver, animation tick, etc.) which
|
|
// mirrors retail's live-parent-frame read at
|
|
// ParticleEmitter::UpdateParticles 0x0051d2d4 for is_parent_local=1.
|
|
Vector3 pos = p.Position;
|
|
float distSq = Vector3.DistanceSquared(pos, cameraWorldPos);
|
|
var gfxInfo = ResolveParticleGfxInfo(em.Desc);
|
|
uint texture = gfxInfo.TextureHandle;
|
|
bool useTexture = texture != 0;
|
|
bool additive = gfxInfo.HasMaterial
|
|
? gfxInfo.Additive
|
|
: (em.Desc.Flags & EmitterFlags.Additive) != 0;
|
|
var key = new BatchKey(texture, useTexture, additive);
|
|
Vector3 axisX;
|
|
Vector3 axisY;
|
|
if (gfxInfo.IsBillboard)
|
|
{
|
|
pos += Vector3.UnitZ * (gfxInfo.CenterOffset.Z * p.Size);
|
|
axisX = cameraRight * (gfxInfo.Size.X * p.Size);
|
|
axisY = cameraUp * (gfxInfo.Size.Y * p.Size);
|
|
}
|
|
else
|
|
{
|
|
Quaternion orientation = ParticleOrientation(em, p);
|
|
pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation);
|
|
axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size);
|
|
axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size);
|
|
}
|
|
|
|
draws.Add(new ParticleDraw(key, new ParticleInstance(pos, axisX, axisY, p.ColorArgb, distSq)));
|
|
}
|
|
|
|
return draws;
|
|
}
|
|
|
|
private void DrawInstances(List<ParticleInstance> instances)
|
|
{
|
|
if (instances.Count == 0)
|
|
return;
|
|
|
|
int needed = instances.Count * 16;
|
|
if (_instanceScratch.Length < needed)
|
|
_instanceScratch = new float[needed + 256 * 16];
|
|
|
|
for (int i = 0; i < instances.Count; i++)
|
|
{
|
|
var p = instances[i];
|
|
int o = i * 16;
|
|
_instanceScratch[o + 0] = p.Position.X;
|
|
_instanceScratch[o + 1] = p.Position.Y;
|
|
_instanceScratch[o + 2] = p.Position.Z;
|
|
_instanceScratch[o + 3] = 0f;
|
|
|
|
_instanceScratch[o + 4] = p.AxisX.X;
|
|
_instanceScratch[o + 5] = p.AxisX.Y;
|
|
_instanceScratch[o + 6] = p.AxisX.Z;
|
|
_instanceScratch[o + 7] = 0f;
|
|
|
|
_instanceScratch[o + 8] = p.AxisY.X;
|
|
_instanceScratch[o + 9] = p.AxisY.Y;
|
|
_instanceScratch[o + 10] = p.AxisY.Z;
|
|
_instanceScratch[o + 11] = 0f;
|
|
|
|
_instanceScratch[o + 12] = ((p.ColorArgb >> 16) & 0xFF) / 255f;
|
|
_instanceScratch[o + 13] = ((p.ColorArgb >> 8) & 0xFF) / 255f;
|
|
_instanceScratch[o + 14] = (p.ColorArgb & 0xFF) / 255f;
|
|
_instanceScratch[o + 15] = ((p.ColorArgb >> 24) & 0xFF) / 255f;
|
|
}
|
|
|
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
|
|
fixed (void* bp = _instanceScratch)
|
|
{
|
|
_gl.BufferData(
|
|
BufferTargetARB.ArrayBuffer,
|
|
(nuint)(instances.Count * 16 * sizeof(float)),
|
|
bp,
|
|
BufferUsageARB.DynamicDraw);
|
|
}
|
|
|
|
_gl.BindVertexArray(_quadVao);
|
|
_gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count);
|
|
}
|
|
|
|
private ParticleGfxInfo ResolveParticleGfxInfo(EmitterDesc desc)
|
|
{
|
|
if (_textures is null)
|
|
return ParticleGfxInfo.Default;
|
|
|
|
if (desc.TextureSurfaceId != 0)
|
|
return ParticleGfxInfo.Billboard(
|
|
_textures.GetOrUpload(desc.TextureSurfaceId),
|
|
Vector2.One,
|
|
Vector3.Zero,
|
|
additive: (desc.Flags & EmitterFlags.Additive) != 0,
|
|
hasMaterial: false);
|
|
|
|
uint gfxObjId = desc.HwGfxObjId != 0 ? desc.HwGfxObjId : desc.GfxObjId;
|
|
if (gfxObjId == 0 || _dats is null)
|
|
return ParticleGfxInfo.Default;
|
|
|
|
if (!_particleGfxInfoByGfxObj.TryGetValue(gfxObjId, out var info))
|
|
{
|
|
info = ReadParticleGfxInfo(gfxObjId);
|
|
_particleGfxInfoByGfxObj[gfxObjId] = info;
|
|
}
|
|
|
|
return info.TextureHandle != 0 ? info : ParticleGfxInfo.Default;
|
|
}
|
|
|
|
private ParticleGfxInfo ReadParticleGfxInfo(uint gfxObjId)
|
|
{
|
|
try
|
|
{
|
|
var gfx = _dats?.Get<GfxObj>(gfxObjId);
|
|
if (gfx is null)
|
|
return ParticleGfxInfo.Default;
|
|
|
|
uint surfaceId = gfx.Surfaces.Count > 0 ? gfx.Surfaces[0].DataId : 0u;
|
|
uint texture = surfaceId != 0 && _textures is not null ? _textures.GetOrUpload(surfaceId) : 0u;
|
|
bool additive = false;
|
|
if (surfaceId != 0)
|
|
{
|
|
var surface = _dats?.Get<Surface>(surfaceId);
|
|
additive = surface is not null && surface.Type.HasFlag(SurfaceType.Additive);
|
|
}
|
|
return AuthoredParticleGfxInfo(gfx, texture, additive, surfaceId != 0);
|
|
}
|
|
catch
|
|
{
|
|
return ParticleGfxInfo.Default;
|
|
}
|
|
}
|
|
|
|
private ParticleGfxInfo AuthoredParticleGfxInfo(GfxObj gfx, uint texture, bool additive, bool hasMaterial)
|
|
{
|
|
if (gfx.VertexArray.Vertices.Count == 0)
|
|
return ParticleGfxInfo.Billboard(texture, Vector2.One, Vector3.Zero, additive, hasMaterial);
|
|
|
|
var min = new Vector3(float.PositiveInfinity);
|
|
var max = new Vector3(float.NegativeInfinity);
|
|
foreach (var (_, v) in gfx.VertexArray.Vertices)
|
|
{
|
|
min = Vector3.Min(min, v.Origin);
|
|
max = Vector3.Max(max, v.Origin);
|
|
}
|
|
|
|
var size = max - min;
|
|
var center = (min + max) * 0.5f;
|
|
if (IsPointSprite(gfx))
|
|
{
|
|
float sx = FallbackParticleExtent(size.X) * 0.9f;
|
|
float sy = FallbackParticleExtent(size.Z) * 0.9f;
|
|
return ParticleGfxInfo.Billboard(texture, new Vector2(sx, sy), center, additive, hasMaterial);
|
|
}
|
|
|
|
Vector3 axisX;
|
|
Vector3 axisY;
|
|
Vector2 planeSize;
|
|
if (size.Y > size.X && size.Y > size.Z)
|
|
{
|
|
if (size.X > size.Z)
|
|
{
|
|
axisX = Vector3.UnitX;
|
|
axisY = Vector3.UnitY;
|
|
planeSize = new Vector2(size.X, size.Y);
|
|
}
|
|
else
|
|
{
|
|
axisX = Vector3.UnitY;
|
|
axisY = Vector3.UnitZ;
|
|
planeSize = new Vector2(size.Y, size.Z);
|
|
}
|
|
}
|
|
else if (size.X > size.Y && size.X > size.Z)
|
|
{
|
|
if (size.Z > size.Y)
|
|
{
|
|
axisX = Vector3.UnitX;
|
|
axisY = Vector3.UnitZ;
|
|
planeSize = new Vector2(size.X, size.Z);
|
|
}
|
|
else
|
|
{
|
|
axisX = Vector3.UnitX;
|
|
axisY = Vector3.UnitY;
|
|
planeSize = new Vector2(size.X, size.Y);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (size.X > size.Y)
|
|
{
|
|
axisX = Vector3.UnitX;
|
|
axisY = Vector3.UnitZ;
|
|
planeSize = new Vector2(size.X, size.Z);
|
|
}
|
|
else
|
|
{
|
|
axisX = Vector3.UnitY;
|
|
axisY = Vector3.UnitZ;
|
|
planeSize = new Vector2(size.Y, size.Z);
|
|
}
|
|
}
|
|
|
|
planeSize.X = FallbackParticleExtent(planeSize.X);
|
|
planeSize.Y = FallbackParticleExtent(planeSize.Y);
|
|
return new ParticleGfxInfo(texture, planeSize, axisX, axisY, center, false, additive, hasMaterial);
|
|
}
|
|
|
|
private bool IsPointSprite(GfxObj gfx)
|
|
{
|
|
if (!gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) || gfx.DIDDegrade == 0 || _dats is null)
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var degrade = _dats.Get<GfxObjDegradeInfo>(gfx.DIDDegrade);
|
|
return degrade?.Degrades.Count > 0 && degrade.Degrades[0].DegradeMode == 2;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static float FallbackParticleExtent(float value)
|
|
=> value > 1e-4f ? Math.Clamp(value, 1e-4f, 10_000f) : 1f;
|
|
|
|
private static Quaternion ParticleOrientation(AcDream.Core.Vfx.ParticleEmitter em, Particle p)
|
|
{
|
|
Quaternion orientation = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0
|
|
? em.AnchorRot
|
|
: p.SpawnRotation;
|
|
|
|
if (em.Desc.Type is AcDream.Core.Vfx.ParticleType.ParabolicLVGAGR
|
|
or AcDream.Core.Vfx.ParticleType.ParabolicLVLALR
|
|
or AcDream.Core.Vfx.ParticleType.ParabolicGVGAGR)
|
|
{
|
|
Vector3 angular = p.C * p.Age;
|
|
float radians = angular.Length();
|
|
if (radians > 1e-6f)
|
|
orientation = Quaternion.Normalize(orientation * Quaternion.CreateFromAxisAngle(angular / radians, radians));
|
|
}
|
|
|
|
return orientation;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_gl.DeleteBuffer(_quadVbo);
|
|
_gl.DeleteBuffer(_quadEbo);
|
|
_gl.DeleteBuffer(_instanceVbo);
|
|
_gl.DeleteVertexArray(_quadVao);
|
|
_shader.Dispose();
|
|
}
|
|
|
|
private readonly record struct ParticleGfxInfo(
|
|
uint TextureHandle,
|
|
Vector2 Size,
|
|
Vector3 AxisX,
|
|
Vector3 AxisY,
|
|
Vector3 CenterOffset,
|
|
bool IsBillboard,
|
|
bool Additive,
|
|
bool HasMaterial)
|
|
{
|
|
public static ParticleGfxInfo Default { get; } =
|
|
Billboard(0u, Vector2.One, Vector3.Zero, additive: false, hasMaterial: false);
|
|
|
|
public static ParticleGfxInfo Billboard(
|
|
uint textureHandle,
|
|
Vector2 size,
|
|
Vector3 centerOffset,
|
|
bool additive,
|
|
bool hasMaterial) =>
|
|
new(textureHandle, size, Vector3.UnitX, Vector3.UnitY, centerOffset, true, additive, hasMaterial);
|
|
}
|
|
}
|