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>
This commit is contained in:
parent
1f82b7604e
commit
ec1bbb4f43
28 changed files with 2444 additions and 780 deletions
|
|
@ -1,73 +1,38 @@
|
|||
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 <see cref="EmitterDesc"/> instances by their retail emitter
|
||||
/// dat id (<c>0x32xxxxxx</c> range). The current build of
|
||||
/// Chorizite.DatReaderWriter (v2.1.7) doesn't yet ship a
|
||||
/// <c>ParticleEmitterInfo</c> DBObj class, so we maintain a small
|
||||
/// registry of synthesized descriptors for the handful of emitters
|
||||
/// acdream actually needs (portal swirl, chimney smoke, fireplace
|
||||
/// flames, footstep dust, spell auras, weapon trails) and fall back to
|
||||
/// a generic "puff" for unknown ids. When a future DRW release adds
|
||||
/// the dat-type, this class will additionally load + cache from dats.
|
||||
///
|
||||
/// <para>
|
||||
/// Field mapping once the dat-type arrives (docs/research/deepdives/
|
||||
/// r04-vfx-particles.md §1 + references/DatReaderWriter's own generated
|
||||
/// <c>ParticleEmitterInfo.generated.cs</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <c>Birthrate</c> → <c>1 / EmitRate</c> (retail stores the avg
|
||||
/// time between spawns, not the rate).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>Lifespan ± LifespanRand</c> → <c>LifetimeMin / LifetimeMax</c>
|
||||
/// range.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>A, MinA, MaxA</c> → primary initial velocity with magnitude
|
||||
/// jitter; <c>B</c> / <c>C</c> are secondary spread components.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>StartScale, FinalScale</c> / <c>StartTrans, FinalTrans</c>
|
||||
/// interpolate linearly over life.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// 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)
|
||||
{
|
||||
// Seed with a handful of well-known AC emitter ids plus a
|
||||
// fallback. Ids here come from empirical ACViewer dat dumps —
|
||||
// see r04 §5.2 for the more complete inventory.
|
||||
Register(new EmitterDesc
|
||||
{
|
||||
DatId = 0xFFFFFFFFu, // "default" sentinel
|
||||
Type = ParticleType.LocalVelocity,
|
||||
Flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera,
|
||||
EmitRate = 10f,
|
||||
MaxParticles = 32,
|
||||
LifetimeMin = 0.6f,
|
||||
LifetimeMax = 1.2f,
|
||||
OffsetDir = new Vector3(0, 0, 1),
|
||||
MinOffset = 0f,
|
||||
MaxOffset = 0.1f,
|
||||
SpawnDiskRadius = 0.1f,
|
||||
InitialVelocity = new Vector3(0, 0, 0.5f),
|
||||
VelocityJitter = 0.3f,
|
||||
StartSize = 0.25f,
|
||||
EndSize = 0.6f,
|
||||
StartAlpha = 0.85f,
|
||||
EndAlpha = 0f,
|
||||
});
|
||||
}
|
||||
|
||||
public EmitterDescRegistry(DatCollection dats)
|
||||
: this(id => SafeGet(dats, id))
|
||||
{
|
||||
}
|
||||
|
||||
public EmitterDescRegistry(Func<uint, DatParticleEmitter?>? resolver)
|
||||
{
|
||||
_resolver = resolver;
|
||||
Register(BuildFallback());
|
||||
}
|
||||
|
||||
public void Register(EmitterDesc desc)
|
||||
|
|
@ -78,10 +43,159 @@ public sealed class EmitterDescRegistry
|
|||
|
||||
public EmitterDesc Get(uint emitterId)
|
||||
{
|
||||
if (_byId.TryGetValue(emitterId, out var desc)) return desc;
|
||||
if (_byId.TryGetValue(0xFFFFFFFFu, out var fallback)) return fallback;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue