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>
201 lines
6.8 KiB
C#
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,
|
|
};
|
|
}
|