acdream/src/AcDream.Core/Vfx/EmitterDescLoader.cs
Erik ec1bbb4f43 feat(vfx): Phase C.1 — PES particle renderer + post-review fixes
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>
2026-04-28 22:47:11 +02:00

201 lines
6.8 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Numerics;
using DatReaderWriter;
using DatParticleEmitter = DatReaderWriter.DBObjs.ParticleEmitter;
using DatEmitterType = DatReaderWriter.Enums.EmitterType;
using DatParticleType = DatReaderWriter.Enums.ParticleType;
namespace AcDream.Core.Vfx;
/// <summary>
/// Resolves retail <c>ParticleEmitterInfo</c> dat records
/// (<c>0x32xxxxxx</c>) into acdream runtime descriptors.
/// </summary>
public sealed class EmitterDescRegistry
{
private const uint FallbackEmitterId = 0xFFFFFFFFu;
private readonly Func<uint, DatParticleEmitter?>? _resolver;
private readonly ConcurrentDictionary<uint, EmitterDesc> _byId = new();
public EmitterDescRegistry()
: this((Func<uint, DatParticleEmitter?>?)null)
{
}
public EmitterDescRegistry(DatCollection dats)
: this(id => SafeGet(dats, id))
{
}
public EmitterDescRegistry(Func<uint, DatParticleEmitter?>? resolver)
{
_resolver = resolver;
Register(BuildFallback());
}
public void Register(EmitterDesc desc)
{
ArgumentNullException.ThrowIfNull(desc);
_byId[desc.DatId] = desc;
}
public EmitterDesc Get(uint emitterId)
{
if (_byId.TryGetValue(emitterId, out var desc))
return desc;
if (_resolver is not null)
{
var dat = _resolver(emitterId);
if (dat is not null)
{
desc = FromDat(emitterId, dat);
_byId[emitterId] = desc;
return desc;
}
}
if (_byId.TryGetValue(FallbackEmitterId, out var fallback))
return fallback;
throw new InvalidOperationException("No default emitter registered in registry.");
}
public int Count => _byId.Count;
public static EmitterDesc FromDat(uint emitterId, DatParticleEmitter dat)
{
ArgumentNullException.ThrowIfNull(dat);
float birthrate = MathF.Max(0f, (float)dat.Birthrate);
float lifespan = MathF.Max(0f, (float)dat.Lifespan);
float lifespanRand = MathF.Abs((float)dat.LifespanRand);
float lifetimeMin = MathF.Max(0f, lifespan - lifespanRand);
float lifetimeMax = MathF.Max(lifetimeMin, lifespan + lifespanRand);
// ParticleEmitterInfo has no "additive" field; retail derives blend
// state from the particle GfxObj surface material.
var flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera;
if (dat.IsParentLocal)
flags |= EmitterFlags.AttachLocal;
// ParticleEmitterInfo stores translucency, not opacity. Retail feeds
// StartTrans/FinalTrans to PhysicsPart::SetTranslucency; the GL path
// uses the complement as source alpha.
float startOpacity = 1f - Math.Clamp((float)dat.StartTrans, 0f, 1f);
float endOpacity = 1f - Math.Clamp((float)dat.FinalTrans, 0f, 1f);
return new EmitterDesc
{
DatId = emitterId,
Type = MapParticleType(dat.ParticleType),
EmitterKind = MapEmitterKind(dat.EmitterType),
Flags = flags,
GfxObjId = dat.GfxObjId.DataId,
HwGfxObjId = dat.HwGfxObjId.DataId,
Birthrate = birthrate,
EmitRate = dat.EmitterType == DatEmitterType.BirthratePerSec && birthrate > 0f
? 1f / birthrate
: 0f,
MaxParticles = Math.Max(1, dat.MaxParticles),
InitialParticles = Math.Max(0, dat.InitialParticles),
TotalParticles = Math.Max(0, dat.TotalParticles),
TotalDuration = MathF.Max(0f, (float)dat.TotalSeconds),
Lifespan = lifespan,
LifespanRand = lifespanRand,
LifetimeMin = lifetimeMin,
LifetimeMax = lifetimeMax,
OffsetDir = dat.OffsetDir,
MinOffset = dat.MinOffset,
MaxOffset = dat.MaxOffset,
SpawnDiskRadius = dat.MaxOffset,
InitialVelocity = dat.A,
Gravity = dat.B,
A = dat.A,
MinA = dat.MinA,
MaxA = dat.MaxA,
B = dat.B,
MinB = dat.MinB,
MaxB = dat.MaxB,
C = dat.C,
MinC = dat.MinC,
MaxC = dat.MaxC,
StartSize = dat.StartScale,
EndSize = dat.FinalScale,
ScaleRand = dat.ScaleRand,
StartAlpha = startOpacity,
EndAlpha = endOpacity,
TransRand = dat.TransRand,
};
}
private static DatParticleEmitter? SafeGet(DatCollection dats, uint id)
{
if (dats is null)
return null;
try
{
return dats.Get<DatParticleEmitter>(id);
}
catch
{
return null;
}
}
private static EmitterDesc BuildFallback() => new()
{
DatId = FallbackEmitterId,
Type = ParticleType.LocalVelocity,
EmitterKind = ParticleEmitterKind.BirthratePerSec,
Flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera,
Birthrate = 0.1f,
EmitRate = 10f,
MaxParticles = 32,
LifetimeMin = 0.6f,
LifetimeMax = 1.2f,
Lifespan = 0.9f,
LifespanRand = 0.3f,
OffsetDir = new Vector3(0, 0, 1),
MinOffset = 0f,
MaxOffset = 0.1f,
SpawnDiskRadius = 0.1f,
InitialVelocity = new Vector3(0, 0, 0.5f),
VelocityJitter = 0.3f,
A = new Vector3(0, 0, 0.5f),
MinA = 1f,
MaxA = 1f,
B = Vector3.Zero,
C = Vector3.Zero,
StartSize = 0.25f,
EndSize = 0.6f,
StartAlpha = 0.85f,
EndAlpha = 0f,
};
private static ParticleEmitterKind MapEmitterKind(DatEmitterType type) => type switch
{
DatEmitterType.BirthratePerSec => ParticleEmitterKind.BirthratePerSec,
DatEmitterType.BirthratePerMeter => ParticleEmitterKind.BirthratePerMeter,
_ => ParticleEmitterKind.Unknown,
};
private static ParticleType MapParticleType(DatParticleType type) => type switch
{
DatParticleType.Still => ParticleType.Still,
DatParticleType.LocalVelocity => ParticleType.LocalVelocity,
DatParticleType.ParabolicLVGA => ParticleType.ParabolicLVGA,
DatParticleType.ParabolicLVGAGR => ParticleType.ParabolicLVGAGR,
DatParticleType.Swarm => ParticleType.Swarm,
DatParticleType.Explode => ParticleType.Explode,
DatParticleType.Implode => ParticleType.Implode,
DatParticleType.ParabolicLVLA => ParticleType.ParabolicLVLA,
DatParticleType.ParabolicLVLALR => ParticleType.ParabolicLVLALR,
DatParticleType.ParabolicGVGA => ParticleType.ParabolicGVGA,
DatParticleType.ParabolicGVGAGR => ParticleType.ParabolicGVGAGR,
DatParticleType.GlobalVelocity => ParticleType.GlobalVelocity,
_ => ParticleType.Unknown,
};
}