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,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Numerics;
|
||||
using System.Threading;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
|
|
@ -62,10 +63,30 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
|||
// key ("the smoke trail I spawned 2 seconds ago"), so we track by
|
||||
// (entity, emitterId).
|
||||
private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
|
||||
// entityId → set of live emitter handles. Dictionary-as-set so we can
|
||||
// remove individual handles when their emitter dies (M4 fix —
|
||||
// ConcurrentBag couldn't drop entries, so handles for naturally-expired
|
||||
// emitters used to leak).
|
||||
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<int, byte>> _handlesByEntity = new();
|
||||
// Reverse lookup: handle → (entity, key) for O(1) cleanup on EmitterDied.
|
||||
private readonly ConcurrentDictionary<int, (uint EntityId, uint KeyId)> _trackingByHandle = new();
|
||||
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
|
||||
private readonly ConcurrentDictionary<uint, Quaternion> _rotationByEntity = new();
|
||||
private int _anonymousEmitterSerial;
|
||||
|
||||
public ParticleHookSink(ParticleSystem system)
|
||||
{
|
||||
_system = system ?? throw new ArgumentNullException(nameof(system));
|
||||
_system.EmitterDied += OnEmitterDied;
|
||||
}
|
||||
|
||||
private void OnEmitterDied(int handle)
|
||||
{
|
||||
if (!_trackingByHandle.TryRemove(handle, out var t))
|
||||
return;
|
||||
_handlesByKey.TryRemove((t.EntityId, t.KeyId), out _);
|
||||
if (_handlesByEntity.TryGetValue(t.EntityId, out var bag))
|
||||
bag.TryRemove(handle, out _);
|
||||
}
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
|
|
@ -104,6 +125,54 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
|||
}
|
||||
}
|
||||
|
||||
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass)
|
||||
=> _renderPassByEntity[entityId] = renderPass;
|
||||
|
||||
public void SetEntityRotation(uint entityId, Quaternion rotation)
|
||||
=> _rotationByEntity[entityId] = rotation;
|
||||
|
||||
public void ClearEntityRenderPass(uint entityId)
|
||||
=> _renderPassByEntity.TryRemove(entityId, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Refresh every live emitter on this entity to a new world anchor +
|
||||
/// rotation. The owning subsystem (sky-PES driver, animation tick)
|
||||
/// drives this each frame for AttachLocal emitters so they track their
|
||||
/// moving parent — retail-faithful via
|
||||
/// <c>ParticleEmitter::UpdateParticles</c> at <c>0x0051d2d4</c>, which
|
||||
/// re-reads the parent frame each tick when <c>is_parent_local != 0</c>.
|
||||
/// Safe to call for entities with no live emitters (no-op).
|
||||
/// </summary>
|
||||
public void UpdateEntityAnchor(uint entityId, Vector3 anchor, Quaternion rotation)
|
||||
{
|
||||
_rotationByEntity[entityId] = rotation;
|
||||
if (!_handlesByEntity.TryGetValue(entityId, out var bag))
|
||||
return;
|
||||
foreach (var handle in bag.Keys)
|
||||
_system.UpdateEmitterAnchor(handle, anchor, rotation);
|
||||
}
|
||||
|
||||
public void StopAllForEntity(uint entityId, bool fadeOut)
|
||||
{
|
||||
if (_handlesByEntity.TryRemove(entityId, out var handles))
|
||||
{
|
||||
foreach (var handle in handles.Keys)
|
||||
{
|
||||
_system.StopEmitter(handle, fadeOut);
|
||||
_trackingByHandle.TryRemove(handle, out _);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in _handlesByKey.Keys)
|
||||
{
|
||||
if (key.EntityId == entityId)
|
||||
_handlesByKey.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
ClearEntityRenderPass(entityId);
|
||||
_rotationByEntity.TryRemove(entityId, out _);
|
||||
}
|
||||
|
||||
private void SpawnFromHook(
|
||||
uint entityId,
|
||||
Vector3 worldPos,
|
||||
|
|
@ -115,15 +184,35 @@ public sealed class ParticleHookSink : IAnimationHookSink
|
|||
// Spawn position: entity pose + hook offset. PartIndex will be
|
||||
// used when the renderer passes per-part transforms through; for
|
||||
// now, fold it into the root pos.
|
||||
var anchor = worldPos + offset;
|
||||
var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
|
||||
? rot
|
||||
: Quaternion.Identity;
|
||||
var anchor = worldPos + Vector3.Transform(offset, rotation);
|
||||
var renderPass = _renderPassByEntity.TryGetValue(entityId, out var pass)
|
||||
? pass
|
||||
: ParticleRenderPass.Scene;
|
||||
|
||||
int handle = _system.SpawnEmitterById(
|
||||
emitterId: emitterInfoId,
|
||||
anchor: anchor,
|
||||
rot: Quaternion.Identity,
|
||||
rot: rotation,
|
||||
attachedObjectId: entityId,
|
||||
attachedPartIndex: partIndex);
|
||||
attachedPartIndex: partIndex,
|
||||
renderPass: renderPass);
|
||||
|
||||
_handlesByKey[(entityId, logicalId)] = handle;
|
||||
uint keyId = logicalId != 0
|
||||
? logicalId
|
||||
: 0x80000000u | (uint)Interlocked.Increment(ref _anonymousEmitterSerial);
|
||||
if (logicalId != 0 && _handlesByKey.TryRemove((entityId, keyId), out var oldHandle))
|
||||
{
|
||||
_system.StopEmitter(oldHandle, fadeOut: false);
|
||||
_trackingByHandle.TryRemove(oldHandle, out _);
|
||||
}
|
||||
|
||||
_handlesByKey[(entityId, keyId)] = handle;
|
||||
_handlesByEntity
|
||||
.GetOrAdd(entityId, _ => new ConcurrentDictionary<int, byte>())
|
||||
.TryAdd(handle, 0);
|
||||
_trackingByHandle[handle] = (entityId, keyId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue