acdream/src/AcDream.Core/Vfx/VfxModel.cs
Erik 684380d421 fix(render): particles draw unclipped, once, in their retail stage
Retail never clips a particle to a portal view: each emitter's polys
join the ONE alpha list during its owner cell's far-to-near walk turn
(LScape::draw @0x00506330 iterates block_draw_list reversed; DrawBlock
@0x005A17C0 walks cells; ShouldDrawParticles @0x0050FE60 gates by cell
and distance), and occlusion is the depth test at FlushAlphaList
@0x0059D2E0 (its float is a COUNT threshold - 0f = flush all). The
1d2f2f73 architecture instead re-submitted particles once per
OutsideView slice under that slice's hardware clip slot, which cut
effects at aperture boundaries and drew nothing when no outside slice
was in view (the cathedral look-north disappearance).

Now: unattached emitters submit once per frame by owner-cell kind
(outdoor landcells in the landscape stage, interior EnvCells in the
final world scope - new UnattachedEmitterCellScope filter); cell,
shell-route, barrier-static, and late-stage owners submit their
per-slice cone-cull UNION once with clipSlot 0; and particles emit in
the stage matching their PARENT CELL - an interior dynamic whose
sphere straddles an exit-portal plane keeps its mesh in both stages
(#118) but its particles move to the final pass, so the interior
stage can no longer repaint over them (the aperture-band star cut).

Also lands the inert Change-2 primitives for the AP-236 retirement
(candle-behind-door): RetailAlphaQueue.FlushFartherThan drains only
the far prefix without resetting sources, plus the executor
passthrough and the conservative look-in threshold helper - nothing
calls them yet.

User-gated 2026-08-29 round 2 at the Sanctuary Cathedral: spell and
recall stars cover the whole room at every camera direction including
north; waterfall containment holds on retail's depth/seal mechanism;
adjacent-room particles/lights, walls, Holtburg, recall unregressed
(paperdoll remains pre-existing intermittent #443). Register: AP-236
filed for the remaining barrier-order divergence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 12:35:30 +02:00

300 lines
9.8 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Vfx;
/// <summary>
/// Retail particle motion integrators from <c>ParticleType</c> in
/// <c>acclient.h</c>. Values are the retail dat values.
/// </summary>
public enum ParticleType
{
Unknown = 0,
Still = 1,
LocalVelocity = 2,
ParabolicLVGA = 3,
ParabolicLVGAGR = 4,
Swarm = 5,
Explode = 6,
Implode = 7,
ParabolicLVLA = 8,
ParabolicLVLALR = 9,
ParabolicGVGA = 10,
ParabolicGVGAGR = 11,
GlobalVelocity = 12,
NumParticleType = 13,
}
/// <summary>
/// Retail <c>EmitterType</c> from <c>acclient.h</c>.
/// </summary>
public enum ParticleEmitterKind
{
Unknown = 0,
BirthratePerSec = 1,
BirthratePerMeter = 2,
}
/// <summary>
/// Render stage for an active particle emitter.
/// </summary>
public enum ParticleRenderPass
{
Scene = 0,
SkyPreScene = 1,
SkyPostScene = 2,
}
/// <summary>
/// Which unattached emitters a scoped render copy admits, by owner cell kind.
/// Retail draws every particle during its owner CELL's walk turn, so an
/// outdoor-cell emitter renders in the landscape stage and an interior-cell
/// emitter in the final world stage; acdream draws each group once in the
/// matching stage instead of per portal slice.
/// </summary>
public enum UnattachedEmitterCellScope
{
Any = 0,
OutdoorCells = 1,
InteriorCells = 2,
}
/// <summary>
/// Authority used by retail's particle presentation gate. World-owned
/// emitters follow <c>CPhysicsObj::ShouldDrawParticles</c>; examination and
/// dedicated render-pass emitters are explicitly exempt from world PView.
/// </summary>
public enum ParticleVisibilityPolicy
{
World = 0,
Examination = 1,
PassOwned = 2,
}
[Flags]
public enum EmitterFlags : uint
{
None = 0,
Additive = 0x01,
Billboard = 0x02,
FaceCamera = 0x04,
AttachLocal = 0x08,
}
/// <summary>
/// Per-emitter configuration from the retail <c>ParticleEmitterInfo</c>
/// dat object.
/// </summary>
public sealed class EmitterDesc
{
/// <summary>
/// Retail distance at which the owning physics object degrades this
/// emitter out. Sourced from the hardware particle GfxObj's degradation
/// table by <c>CPhysicsPart::GetMaxDegradeDistance</c> (0x0050D510).
/// </summary>
public float MaxDegradeDistance { get; init; } = 100f;
public uint DatId { get; init; }
public ParticleType Type { get; init; }
public ParticleEmitterKind EmitterKind { get; init; } = ParticleEmitterKind.BirthratePerSec;
public EmitterFlags Flags { get; init; }
public uint TextureSurfaceId { get; init; }
public uint GfxObjId { get; init; }
public uint HwGfxObjId { get; init; }
public uint SoundOnSpawn { get; init; }
// Emission behavior.
public float Birthrate { get; init; }
public float EmitRate { get; init; }
public int MaxParticles { get; init; }
public int InitialParticles { get; init; }
public int TotalParticles { get; init; }
public float LifetimeMin { get; init; }
public float LifetimeMax { get; init; }
public float Lifespan { get; init; }
public float LifespanRand { get; init; }
public float StartDelay { get; init; }
public float TotalDuration { get; init; }
// Spawn geometry.
public Vector3 OffsetDir { get; init; } = new(0, 0, 1);
public float MinOffset { get; init; }
public float MaxOffset { get; init; }
public float SpawnDiskRadius { get; init; }
// Kinematics. A/B/C are the retail vector coefficients.
public Vector3 InitialVelocity { get; init; }
public float VelocityJitter { get; init; }
public Vector3 Gravity { get; init; } = new(0, 0, -9.8f);
public Vector3 A { get; init; }
public float MinA { get; init; } = 1f;
public float MaxA { get; init; } = 1f;
public Vector3 B { get; init; }
public float MinB { get; init; } = 1f;
public float MaxB { get; init; } = 1f;
public Vector3 C { get; init; }
public float MinC { get; init; } = 1f;
public float MaxC { get; init; } = 1f;
// Appearance over lifetime.
public uint StartColorArgb { get; init; } = 0xFFFFFFFF;
public uint EndColorArgb { get; init; } = 0xFFFFFFFF;
public float StartAlpha { get; init; } = 1f;
public float EndAlpha { get; init; } = 0f;
public float StartSize { get; init; } = 0.5f;
public float EndSize { get; init; } = 0.5f;
public float ScaleRand { get; init; }
public float TransRand { get; init; }
public float StartRotation { get; init; }
public float EndRotation { get; init; }
}
/// <summary>
/// A PhysicsScript (0x3Axxxxxx range in retail) is a list of hooks to
/// fire at specific start-times. Each hook creates an emitter or plays
/// a sound. Chaining hooks at different times gives "animation".
/// </summary>
public sealed class PhysicsScript
{
public uint ScriptId { get; init; }
public IReadOnlyList<PhysicsScriptHook> Hooks { get; init; } = Array.Empty<PhysicsScriptHook>();
}
public sealed record PhysicsScriptHook(
float StartTime,
PhysicsScriptHookType Type,
uint RefDataId,
int PartIndex,
Vector3 Offset,
bool IsParentLocal);
public enum PhysicsScriptHookType
{
CreateParticle = 18,
DestroyParticle = 19,
PlaySound = 1,
AnimationDone = 2,
}
/// <summary>
/// Individual runtime particle. Owned by the <c>ParticleSystem</c>.
/// </summary>
public struct Particle
{
public Vector3 EmissionOrigin;
public Quaternion SpawnRotation;
public Vector3 Position;
public Vector3 Velocity;
public Vector3 Offset;
public Vector3 A;
public Vector3 B;
public Vector3 C;
public float SpawnedAt;
/// <summary>
/// Emitter-level frozen time at birth. This lets an infinite degraded
/// emitter freeze every particle's age in O(1), equivalent to retail
/// rewriting every live particle birth time to the current game time.
/// </summary>
public float FrozenTimeAtSpawn;
public float Lifetime;
public float Age;
public float StartSize;
public float EndSize;
public float StartAlpha;
public float EndAlpha;
public uint ColorArgb;
public float Size;
public float Rotation;
public bool Alive;
}
/// <summary>
/// One active emitter instance. The <c>ParticleSystem</c> holds a pool
/// of these; each one maintains its own particle array.
/// </summary>
public sealed class ParticleEmitter
{
/// <summary>
/// Stable logical identity assigned by <see cref="ParticleSystem"/>.
/// Render owners use it to keep DAT mesh references balanced with the
/// emitter's logical lifetime; it is not a GPU resource identifier.
/// </summary>
public int Handle { get; init; }
public EmitterDesc Desc { get; init; } = null!;
public Vector3 AnchorPos { get; set; }
/// <summary>
/// Root position of the owning physics object. Retail measures particle
/// degradation from the owner (<c>CPhysicsObj::CYpt</c>), not from an
/// animated hand/part hook offset.
/// </summary>
public Vector3 OwnerPosition { get; set; }
/// <summary>Current owning AC cell for retail <c>IsInView</c> gating.</summary>
public uint OwnerCellId { get; set; }
public Quaternion AnchorRot { get; set; } = Quaternion.Identity;
public uint AttachedObjectId { get; set; }
/// <summary>
/// Explicit presentation context. Retail's examination-object bypass is
/// object state, not an inference from attachment identity.
/// </summary>
public ParticleVisibilityPolicy VisibilityPolicy { get; set; }
/// <summary>
/// Spatial presentation latch. A cell-less owner submits no particles.
/// </summary>
public bool PresentationVisible { get; set; } = true;
/// <summary>
/// Retail cell-membership update gate. Paused emitters retain their exact
/// particles, logical identity, and elapsed-time position until re-entry.
/// </summary>
public bool SimulationEnabled { get; set; } = true;
/// <summary>
/// Result of retail <c>CPhysicsObj::ShouldDrawParticles</c>: authored
/// distance plus the previous completed PView's visibility state.
/// </summary>
public bool ViewEligible { get; set; } = true;
/// <summary>Retail <c>ParticleEmitter::degraded_out</c> latch.</summary>
public bool DegradedOut { get; set; }
public int AttachedPartIndex { get; set; } = -1;
public Particle[] Particles { get; init; } = null!;
public ParticleRenderPass RenderPass { get; init; }
public int ActiveCount;
public float EmittedAccumulator;
public float StartedAt;
public float LastEmitTime;
/// <summary>
/// Total time this infinite emitter spent degraded. Particles subtract
/// the delta since their own birth rather than being rewritten one by one.
/// </summary>
public float FrozenTime;
public Vector3 LastEmitOffset;
public int TotalEmitted;
public bool Finished;
}
/// <summary>
/// Top-level particle orchestrator. App-layer renderer batches these.
/// </summary>
public interface IParticleSystem
{
/// <summary>Spawn an emitter attached to a world position or entity.</summary>
int SpawnEmitter(
EmitterDesc desc,
Vector3 anchor,
Quaternion? rot = null,
uint attachedObjectId = 0,
int attachedPartIndex = -1,
ParticleRenderPass renderPass = ParticleRenderPass.Scene,
ParticleVisibilityPolicy visibilityPolicy = ParticleVisibilityPolicy.World);
/// <summary>Fire a full PhysicsScript at a target.</summary>
void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f);
/// <summary>Advance all active emitters by dt seconds.</summary>
void Tick(float dt);
/// <summary>Stop an emitter early.</summary>
void StopEmitter(int handle, bool fadeOut);
/// <summary>Current active particle count.</summary>
int ActiveParticleCount { get; }
int ActiveEmitterCount { get; }
}