78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.
Research (docs/research/deepdives/):
- 00-master-synthesis.md (navigation hub + dependency graph)
- r01-spell-system.md 5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md 5.9K words (damage formula, crit, body table)
- r03-motion-animation.md 8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md 5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md 5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md 7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md 6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md 7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md 5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md 4.5K words (deterministic client-side)
- r13-dynamic-lighting.md 4.9K words (8-light cap, hard Range cutoff)
Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.
Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).
C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs — ItemType/EquipMask enums, ItemInstance,
Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs — SpellDatEntry, SpellComponentEntry,
SpellCastStateMachine, ActiveBuff,
SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs — CombatMode/AttackType/DamageType/BodyPart,
DamageEvent record, CombatMath (hit-chance
sigmoids, power/accuracy mods, damage formula),
ArmorBuild
- Audio/AudioModel.cs — SoundId enum, SoundEntry, WaveData,
IAudioEngine / ISoundCache contracts,
AudioFalloff (inverse-square)
- Vfx/VfxModel.cs — 13 ParticleType integrators, EmitterDesc,
PhysicsScript + hooks, Particle struct,
ParticleEmitter, IParticleSystem contract
All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.
Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)
Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
1056 lines
45 KiB
Markdown
1056 lines
45 KiB
Markdown
# R4 — VFX / Particle System Deep Dive
|
||
|
||
Research pass over the complete retail AC particle / VFX stack: dat layout,
|
||
aggregation types, simulation math, emitter attachment model, known effects,
|
||
rendering pipeline, and a concrete port plan for acdream (C# .NET 10 +
|
||
Silk.NET GL). Cross-referenced against five codebases:
|
||
|
||
- **Retail client decompilation** — `docs/research/decompiled/chunk_005A*.c`
|
||
through `chunk_006A*.c`. Symbols are mostly stripped (no
|
||
`particle_emitter` strings survive in symbol form); references confirm via
|
||
UI hook ("`Particles Rendered`", "`Particle Systems`" in
|
||
`chunk_005D0000.c:8723-8728`).
|
||
- **DatReaderWriter** — source of truth for the on-disk layout.
|
||
`DBObjs/ParticleEmitter.generated.cs`,
|
||
`DBObjs/ParticleEmitterInfo.generated.cs`,
|
||
`DBObjs/PhysicsScript.generated.cs`,
|
||
`DBObjs/PhysicsScriptTable.generated.cs`,
|
||
`Enums/ParticleType.generated.cs`,
|
||
`Enums/EmitterType.generated.cs`,
|
||
`Enums/PlayScript.generated.cs`, and the animation-hook types.
|
||
- **ACE server** — `ACE.Server/Physics/Particles/*.cs` is the direct
|
||
ACE-to-retail port of the simulation loop. Nearly identical shape to
|
||
decompiled code.
|
||
- **ACViewer** — `Physics/Particles/*.cs` plus the MonoGame render pipeline
|
||
in `Render/ParticleBatch.cs`, `Render/ParticleBatchDraw.cs`,
|
||
`Render/ParticleTextureFormat.cs`, `Model/ParticleDeclaration.cs`, and
|
||
the HLSL `Content/texture.fx` `PointSprite` technique.
|
||
- **WorldBuilder** — our exact Silk.NET stack. Complete GL port at
|
||
`Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs`,
|
||
`Lib/ParticleEmitterRenderer.cs`, shaders at
|
||
`Chorizite.OpenGLSDLBackend/Shaders/Particle.vert` and `Particle.frag`.
|
||
- **WorldBuilder-ACME-Edition** — the most refined, client-faithful CPU
|
||
simulator:
|
||
`WorldBuilder.Shared/Lib/AcParticleEmitterSimulator.cs`. This is the
|
||
drop-in reference for acdream's simulation side.
|
||
|
||
---
|
||
|
||
## 1. `ParticleEmitterInfo` dat layout
|
||
|
||
The particle system has **two co-existing dat record classes**:
|
||
|
||
| Type | DB const | DID range | Role |
|
||
|------|----------|-----------|------|
|
||
| `ParticleEmitter` | `DB_TYPE_PARTICLE_EMITTER` | `0x32000000 – 0x3200FFFF` | Named "alias" wrapper |
|
||
| `ParticleEmitterInfo` | `DB_TYPE_PARTICLE_EMITTER` | (same range) | Equivalent struct (alias) |
|
||
| `PhysicsScript` | `DB_TYPE_PHYSICS_SCRIPT` | `0x33000000 – 0x3300FFFF` | Timeline of hooks |
|
||
| `PhysicsScriptTable` | `DB_TYPE_PHYSICS_SCRIPT_TABLE` | `0x34000000 – 0x3400FFFF` | `PlayScript → {Mod,ScriptID}[]` |
|
||
|
||
DatReaderWriter generates two classes for `0x32*` because the retail client
|
||
exposes both spellings (`ParticleEmitter` and `ParticleEmitterInfo`) with
|
||
byte-identical layouts. ACE's DatLoader picks `ParticleEmitterInfo` as the
|
||
canonical class and that's what most of the rest of the code keys on.
|
||
|
||
### Byte-exact unpack order
|
||
|
||
From `DatReaderWriter/Generated/DBObjs/ParticleEmitter.generated.cs:175-208`:
|
||
|
||
```
|
||
uint Id (inherited DBObj header)
|
||
uint Unknown // zeroed in every sample, pad or debug flag
|
||
int EmitterType // see enum below
|
||
int ParticleType // see enum below
|
||
uint GfxObjId // software-path mesh (palette-indexed billboard)
|
||
uint HwGfxObjId // hardware-path mesh (DXT/BGRA billboard). USE THIS.
|
||
double Birthrate // seconds between spawns (BirthratePerSec) OR meters (BirthratePerMeter)
|
||
int MaxParticles // concurrent cap
|
||
int InitialParticles // spawn-at-creation count
|
||
int TotalParticles // lifetime cap (0 = unlimited)
|
||
double TotalSeconds // emitter shutdown (0 = infinite)
|
||
double Lifespan // base particle lifetime seconds
|
||
double LifespanRand // ± random jitter on lifespan
|
||
Vector3 OffsetDir // spawn disk axis (see GetRandomOffset below)
|
||
float MinOffset // min radial distance along offset disk
|
||
float MaxOffset // max radial distance
|
||
Vector3 A // base vector A (usually initial velocity)
|
||
float MinA / MaxA // magnitude multiplier range for A
|
||
Vector3 B // base vector B (usually acceleration)
|
||
float MinB / MaxB
|
||
Vector3 C // base vector C (usually angular velocity or swirl radius)
|
||
float MinC / MaxC
|
||
float StartScale
|
||
float FinalScale
|
||
float ScaleRand
|
||
float StartTrans // 0 = opaque, 1 = invisible (transparency, not alpha)
|
||
float FinalTrans
|
||
float TransRand
|
||
bool IsParentLocal // stream particles in parent space vs world (MSVC writes 4 bytes; reader tolerates either)
|
||
```
|
||
|
||
### EmitterType enum — emission cadence
|
||
|
||
`Enums/EmitterType.generated.cs` (and ACE's mirror
|
||
`ACE.Entity/Enum/EmitterType.cs`):
|
||
|
||
| Value | Name | Behavior |
|
||
|-------|------|----------|
|
||
| 0 | Unknown | Treated as BirthratePerSec by ACE + WorldBuilder fallbacks |
|
||
| 1 | `BirthratePerSec` | Emit when `now - lastEmitTime > Birthrate` (seconds) |
|
||
| 2 | `BirthratePerMeter` | Emit when `|parentPos - lastEmitPos|² > 0` (meter-based for tracer trails) |
|
||
|
||
### ParticleType enum — motion integrator
|
||
|
||
`Enums/ParticleType.generated.cs`:
|
||
|
||
| Value | Name | Meaning |
|
||
|-------|------|---------|
|
||
| 0 | Unknown | Zero motion (Still fallback) |
|
||
| 1 | `Still` | Position = parent + offset, no drift |
|
||
| 2 | `LocalVelocity` | `A` is baked into parent space at spawn (swims with parent rotation) |
|
||
| 3 | `ParabolicLVGA` | Local velocity + **Global** Acceleration (e.g. gravity) |
|
||
| 4 | `ParabolicLVGAGR` | LVGA + Global Rotation (angular velocity applies in world frame) |
|
||
| 5 | `Swarm` | Sinusoidal bees pattern: `cos/sin(B·t) * C + A·t` drift |
|
||
| 6 | `Explode` | One-shot detonation: randomized direction on unit sphere, scaled by `C` |
|
||
| 7 | `Implode` | Particles home in: `cos(A.x·t)·C + t²·B` with C bound to offset |
|
||
| 8 | `ParabolicLVLA` | Local velocity + Local Acceleration (both rotate with parent) |
|
||
| 9 | `ParabolicLVLALR` | LVLA + Local Rotation |
|
||
| 10 | `ParabolicGVGA` | Global velocity + Global Acceleration (pure projectile) |
|
||
| 11 | `ParabolicGVGAGR` | GVGA + Global Rotation |
|
||
| 12 | `GlobalVelocity` | Position = parent + offset + A·t, A in world frame |
|
||
| 13 | NumParticleType | Terminator |
|
||
|
||
Mnemonic: **L**ocal **V**elocity means A is multiplied by the parent's
|
||
rotation at spawn time and "frozen." **G**lobal means the vector is already
|
||
in world space and never rotated. **LA**/**GA** distinguishes acceleration
|
||
vector B. **GR**/**LR** suffix means the **C** vector is an angular velocity
|
||
that rotates the billboard/mesh orientation over time.
|
||
|
||
### Spawn-volume shape
|
||
|
||
`GetRandomOffset()` (ACE `ParticleEmitterInfo.cs:173-187`, matches
|
||
`AcParticleEmitterSimulator.GetRandomOffset` at
|
||
`WorldBuilder.Shared/Lib/AcParticleEmitterSimulator.cs:428-447`):
|
||
|
||
```
|
||
rng = Vector3(rand[-1,1], rand[-1,1], rand[-1,1])
|
||
perp = rng - OffsetDir * dot(OffsetDir, rng) // project rng to plane ⊥ OffsetDir
|
||
if |perp| < ε: return Vector3.Zero // degenerate case
|
||
perp = normalize(perp)
|
||
magnitude = MinOffset + rand[0,1] * (MaxOffset - MinOffset)
|
||
return perp * magnitude
|
||
```
|
||
|
||
**Geometric meaning**: particles spawn on a **disk annulus** whose plane is
|
||
perpendicular to `OffsetDir`, with inner radius `MinOffset` and outer
|
||
radius `MaxOffset`. This is the one and only spawn volume shape in AC —
|
||
there are no cones, boxes, or sphere-volume emitters at the data level.
|
||
Sphere-like emission (omnidirectional puff) is achieved by picking a near-
|
||
zero `OffsetDir` and relying on the random vector; truly spherical bursts
|
||
use `ParticleType.Explode` which re-rolls a random unit vector per particle.
|
||
|
||
### Color over time
|
||
|
||
**AC has no per-particle color field.** All color comes from the texture on
|
||
the `GfxObj` (mesh or point-sprite billboard). Over-time visual variation is
|
||
expressed via:
|
||
|
||
- `StartScale` → `FinalScale` — linear interpolation over lifespan
|
||
- `StartTrans` → `FinalTrans` — linear interpolation (1 = invisible)
|
||
- Texture UV flipbook if the `GfxObj` contains a multi-frame surface
|
||
|
||
Where retail effects need a color shift (e.g., blue spell-aura) they use a
|
||
different texture/`GfxObj`, not a per-frame tint. This is why every spell
|
||
school has its own dat chain.
|
||
|
||
---
|
||
|
||
## 2. `EmitterInfo` aggregation — `PhysicsScript`/`PhysicsScriptTable`
|
||
|
||
A single `ParticleEmitter` dat record defines ONE emitter. Real effects
|
||
almost always combine several emitters into a "script" timeline.
|
||
|
||
### PhysicsScript — one effect's timeline
|
||
|
||
`DatReaderWriter/Generated/DBObjs/PhysicsScript.generated.cs`:
|
||
|
||
```
|
||
PhysicsScript {
|
||
List<PhysicsScriptData> ScriptData
|
||
}
|
||
|
||
PhysicsScriptData {
|
||
double StartTime // offset into the script when this hook fires
|
||
AnimationHook Hook // tagged union (polymorphic)
|
||
}
|
||
```
|
||
|
||
`AnimationHook` subclasses that concern VFX:
|
||
|
||
| Hook | Fields | Role |
|
||
|------|--------|------|
|
||
| `CreateParticleHook` | `EmitterInfoId`, `PartIndex`, `Offset (Frame)`, `EmitterId` | Spawn a named emitter bound to a body part |
|
||
| `CreateBlockingParticleHook` | — | Identical to CreateParticle but blocks motion-table advance until the emitter retires (used to gate spell cast sequences) |
|
||
| `StopParticleHook` | `EmitterId` | Mark emitter stopped (no new spawns, existing particles finish) |
|
||
| `DestroyParticleHook` | `EmitterId` | Instantly despawn all remaining particles |
|
||
| `DefaultScriptHook` | — | Used inside animation tables; implicit |
|
||
|
||
The `EmitterId` field is **the effect-local ID** (a small int, often 1–16),
|
||
NOT the dat file ID. Multiple hooks with the same `EmitterId` address the
|
||
same live emitter: you create with id 5, then at `StartTime=1.2s` you
|
||
stop id 5, then at `StartTime=2.0s` you destroy id 5. This lets scripts
|
||
emit, pause, re-emit, and despawn trails over time.
|
||
|
||
### PhysicsScriptTable — `PlayScript` multi-variant dispatch
|
||
|
||
`DBObjs/PhysicsScriptTable.generated.cs`:
|
||
|
||
```
|
||
PhysicsScriptTable {
|
||
Dictionary<PlayScript, PhysicsScriptTableData> ScriptTable
|
||
}
|
||
|
||
PhysicsScriptTableData {
|
||
List<ScriptAndModData> Scripts // sorted by Mod descending
|
||
}
|
||
|
||
ScriptAndModData {
|
||
float Mod // intensity threshold (0.0–1.0 typically)
|
||
QualifiedDataId<PhysicsScript> ScriptId // DID into 0x33000000 range
|
||
}
|
||
```
|
||
|
||
The server sends `GameMessageScript(objectGuid, PlayScript, mod)` to clients.
|
||
The client picks the `Scripts` entry whose `Mod` is the largest value ≤ the
|
||
incoming `mod`. That's how a Frost I missile and a Frost VII missile can
|
||
share a `PlayScript.Launch` entry but show different-intensity effects: the
|
||
spell sets mod=0.3 vs mod=0.95, and the table picks the matching
|
||
`PhysicsScript` DID. This is the dispatch lookup done by
|
||
`ParticleViewer.GetModIdx()` (`ACViewer/ParticleViewer.cs:46-53`).
|
||
|
||
### PlayScript enum — the well-known effect namespace
|
||
|
||
`DatReaderWriter/Generated/Enums/PlayScript.generated.cs` has **174 entries**.
|
||
Catalog by category (IDs in hex):
|
||
|
||
**Generic** (0x01–0x05):
|
||
- `Test1/2/3`, `Launch`, `Explode`
|
||
|
||
**Attribute buffs/debuffs** (0x06–0x11): six colors × up/down
|
||
- `AttribUpRed`, `AttribDownRed`, …, `AttribDownPurple`
|
||
|
||
**Skill buffs/debuffs** (0x12–0x1E): six colors × up/down + `SkillDownBlack`
|
||
- `SkillUpRed` … `SkillDownPurple`
|
||
|
||
**Vital buffs/debuffs** (0x1F–0x2A): Health / Regen, 3 colors × up/down
|
||
- `HealthUpRed`, `HealthDownRed`, `HealthUpBlue`, `HealthDownBlue`,
|
||
`HealthUpYellow`, `HealthDownYellow`, `RegenUp*`, `RegenDown*`
|
||
|
||
**Shield / Enchant / Swap** (0x2B–0x50): all six colors each
|
||
- `ShieldUp*`, `ShieldDown*`, `EnchantUp*`, `EnchantDown*`,
|
||
`SwapHealth_*_To_*` (stamina/mana transfers), `TransUp/DownWhite/Black`
|
||
|
||
**Spells** (0x51–0x57):
|
||
- `Fizzle`, `PortalEntry`, `PortalExit`, `BreatheFlame`, `BreatheFrost`,
|
||
`BreatheAcid`, `BreatheLightning`
|
||
|
||
**Lifecycle** (0x58–0x5A):
|
||
- `Create`, `Destroy`, `ProjectileCollision`
|
||
|
||
**Combat splatters** (0x5B–0x72): 12 splatter + 12 spark directions
|
||
- `SplatterLow/Mid/UpLeft/RightBack/Front`,
|
||
`SparkLow/Mid/UpLeft/RightBack/Front`
|
||
|
||
**Environment** (0x73):
|
||
- `PortalStorm`
|
||
|
||
**Visibility** (0x74–0x77):
|
||
- `Hide`, `UnHide`, `Hidden`, `DisappearDestroy`
|
||
|
||
**Special states** (0x78–0x81): 10 state slots
|
||
- `SpecialState0` … `SpecialState9`
|
||
|
||
**Special state colors** (0x82–0x89): 8 colors
|
||
- `SpecialStateRed` … `SpecialStateBlack`
|
||
|
||
**Progression** (0x8A):
|
||
- `LevelUp`
|
||
|
||
**Enchant variants** (0x8B–0x8F):
|
||
- `EnchantUp/DownGrey`, `WeddingBliss`, `EnchantUp/DownWhite`
|
||
|
||
**Social / GM** (0x90–0x97):
|
||
- `CampingMastery`, `CampingIneptitude`, `DispelLife`, `DispelCreature`,
|
||
`DispelAll`, `BunnySmite`, `BaelZharonSmite`, `WeddingSteele`
|
||
|
||
**Restriction / Aug** (0x98–0xAD):
|
||
- `RestrictionEffect{Blue,Green,Gold}`, `LayingofHands`,
|
||
`AugmentationUse{Attribute,Skill,Resistances,Other}`, `BlackMadness`,
|
||
`AetheriaLevelUp`, `AetheriaSurge*` (5 types), `*DownVoid` (nether
|
||
damage), `DirtyFighting*` (4 debuffs)
|
||
|
||
The scripts in every PhysicsScriptTable are what drive the user-facing
|
||
effect vocabulary. An acdream PhysicsScript interpreter must implement
|
||
these 174 effect IDs at minimum — but the interpretation is generic
|
||
(walk the `ScriptData` list, execute each hook) so we only write the
|
||
dispatch once.
|
||
|
||
---
|
||
|
||
## 3. Billboard vs 3D particle types
|
||
|
||
Every particle is rendered as a small `GfxObj`. The `GfxObj` dictates whether
|
||
the particle is camera-facing (billboard) or a fixed 3D mesh.
|
||
|
||
### Heuristic — ACViewer's rule
|
||
|
||
`ACViewer/ParticleViewer.cs:167-173`:
|
||
|
||
```
|
||
bool isPointSprite = gfxObj._gfxObj.SortCenter.NearZero()
|
||
&& gfxObj._gfxObj.Id != 0x0100283B;
|
||
```
|
||
|
||
If the GfxObj's `SortCenter` is ~zero (the mesh is a centered single-quad /
|
||
single-tri) AND it's not the specific exempt GfxObj, treat it as a point
|
||
sprite: build a unit quad at the particle position and face-camera it in
|
||
the vertex shader.
|
||
|
||
Otherwise, use the full mesh. Parabolic*GR particle types deliberately
|
||
use full meshes (swords, arrows, sparks) because they carry visible
|
||
orientation.
|
||
|
||
### WorldBuilder's refinement
|
||
|
||
`Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs:80-88`:
|
||
|
||
```
|
||
bool isPointSprite = (_gfxRenderData == null);
|
||
if (_gfxRenderData != null) {
|
||
var degradeId = _gfxRenderData.DIDDegrade;
|
||
if (degradeId != 0) {
|
||
if (dats.Portal.TryGet<GfxObjDegradeInfo>(degradeId, out var degrades)
|
||
&& degrades.Degrades.Count > 0) {
|
||
_isPointSprite = degrades.Degrades[0].DegradeMode == 2;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
More robust: an attached `GfxObjDegradeInfo` with `DegradeMode == 2` flags
|
||
the emitter's mesh as a sprite target. ACME/WorldBuilder use this; we
|
||
should too.
|
||
|
||
### Render split
|
||
|
||
- **Billboards**: one shared quad VBO, per-particle instance of
|
||
`(worldPos, scale, opacity, textureIndex, size)`; vertex shader expands
|
||
to a camera-facing quad.
|
||
- **3D mesh particles**: per-particle full model matrix
|
||
`(translate ⋅ rotate ⋅ scale)`; standard mesh pipeline but batched into
|
||
the particle draw-call group.
|
||
|
||
Particle meshes in practice are always either a 1-quad billboard or a
|
||
2-quad "cross" (two perpendicular billboards for volumetric smoke/fire).
|
||
No particle is an animated skeletal mesh.
|
||
|
||
---
|
||
|
||
## 4. Alpha blending modes
|
||
|
||
`ACViewer.Render.ParticleTextureFormat` (`Render/ParticleTextureFormat.cs:22`):
|
||
|
||
```
|
||
IsAdditive = surfaceType.HasFlag(SurfaceType.Additive);
|
||
```
|
||
|
||
The `SurfaceType` flag on the `Surface` struct of the particle's `GfxObj`
|
||
is the discriminator. There are exactly **two** blend modes used:
|
||
|
||
| SurfaceType flag | Render mode | Used for |
|
||
|------------------|-------------|----------|
|
||
| `Additive` | `SrcAlpha + One` (GL: `SrcAlpha, One`) | Fire, spell glows, sparks, spell swirls |
|
||
| (default) | `SrcAlpha + OneMinusSrcAlpha` | Smoke, splatters, portal sweep, realistic fog |
|
||
|
||
WorldBuilder confirms (`ParticleBatcher.cs:179-185`):
|
||
|
||
```
|
||
if (_currentIsAdditive) gl.BlendFunc(SrcAlpha, One);
|
||
else gl.BlendFunc(SrcAlpha, OneMinusSrcAlpha);
|
||
```
|
||
|
||
**Pre-multiplied alpha is not used** — AC textures are straight RGBA
|
||
(BGRA in memory). The HLSL `PointSprite` shader in ACViewer samples direct,
|
||
multiplies alpha by `xOpacity`, and clips fully-transparent fragments.
|
||
WorldBuilder's fragment shader does `discard` under 0.005:
|
||
|
||
```
|
||
if (color.a < 0.005) discard;
|
||
```
|
||
|
||
### Sort order
|
||
|
||
Back-to-front per-frame, per the following WorldBuilder pattern
|
||
(`ParticleBatcher.cs:149-153`):
|
||
|
||
```
|
||
_allParticles.Sort((a, b) => b.DistanceSq.CompareTo(a.DistanceSq));
|
||
```
|
||
|
||
After the 3D opaque pass and before UI, all particles are bucketed by
|
||
`(TextureArray, IsAdditive)` and drawn in one `glDrawElementsInstanced` per
|
||
bucket, with depth test on but depth writes off
|
||
(`gl.DepthMask(false)`). Additive can blend in any order, but
|
||
non-additive (smoke) must still be sorted to avoid occlusion artifacts;
|
||
batching by texture-first-then-sort-within is ACViewer's compromise and
|
||
WorldBuilder's approach. Retail, running on DX8, sorted within each
|
||
PhysicsScript's draw batch and relied on alpha-clip for crisp cutouts.
|
||
|
||
---
|
||
|
||
## 5. Sprite animation
|
||
|
||
There is **no UV flipbook** on a single particle. Sprite animation is achieved
|
||
by chaining multiple emitters in one `PhysicsScript`:
|
||
|
||
- Explosion: `PhysicsScript` emits three sequential bursts with different
|
||
`GfxObj` textures at `StartTime=0.0, 0.15, 0.35`. Each frame is a new
|
||
emitter with `TotalParticles=1` and a short `Lifespan`.
|
||
- Spell burst: 4–6 `CreateParticle` hooks offset by ~30ms each, using
|
||
different-colored swirl textures.
|
||
|
||
Per-frame animation inside a single particle's lifetime would require the
|
||
`GfxObj` itself to reference a sequence texture (the dat supports animated
|
||
surfaces via `Surface.OriginalFormatAndFlags` with `SurfaceType.Animated`),
|
||
but examination of retail effect dats shows they don't rely on this for
|
||
particles — all flipbook-like visuals are hook chains.
|
||
|
||
---
|
||
|
||
## 6. Emitter attach points
|
||
|
||
The trickiest part. An emitter can be attached to:
|
||
|
||
1. **World only** — `PartIndex = -1`, `IsParentLocal = false`. Particles
|
||
world-space from spawn; parent motion doesn't move them. Chimney smoke
|
||
on a building works this way.
|
||
2. **Entity origin** — `PartIndex = -1`, `IsParentLocal = true`. Particles
|
||
live in the parent's frame (`Parent.Position.Frame`). Breathing, a
|
||
floating aura. Moves with the creature as a whole but ignores
|
||
individual body-part animation.
|
||
3. **Entity body part** — `PartIndex = N ≥ 0`, `IsParentLocal = true`.
|
||
Particles attach to part N of the setup (e.g., right hand = 13, spell
|
||
wand = 1). The emitter reads
|
||
`Parent.PartArray.Parts[PartIndex].Pos.Frame` each frame so the emitter
|
||
**follows the part through its animation**.
|
||
|
||
`PartIndex` → body-part index in the creature's `Setup.Parts` array. The
|
||
mapping is setup-specific; for humanoids the well-known indices are:
|
||
|
||
| Index | Part |
|
||
|-------|------|
|
||
| 1 | Right hand (weapon, spell comp) |
|
||
| 2 | Left hand |
|
||
| 3 | Head |
|
||
| 5–8 | Torso / hip |
|
||
| 13–14 | Feet |
|
||
|
||
(Actual values vary by setup; decompiled retail reads
|
||
`SetupID → Setup.Parts[i].GfxObjID` to find the matching part.)
|
||
|
||
### Offset Frame
|
||
|
||
`CreateParticleHook.Offset` is a `Frame` (position + quaternion) applied
|
||
inside the parent-part's local space. For a torch: `PartIndex=0` (torch
|
||
mesh), `Offset=(0, 0, 0.45)` spawns the fire emitter at the top of the
|
||
torch regardless of how the torch is held. The frame is stored in dat
|
||
coordinates; our port multiplies it into the part-local transform with
|
||
standard `Matrix4x4.CreateFromQuaternion(Offset.Orientation) *
|
||
Matrix4x4.CreateTranslation(Offset.Origin)` and composes with the part's
|
||
world matrix each frame.
|
||
|
||
### Retail `set_parent` call
|
||
|
||
ACE port (`ParticleEmitter.cs:40-48`):
|
||
|
||
```csharp
|
||
public bool SetParenting(int partIdx, AFrame frame)
|
||
{
|
||
if (PhysicsObj == null || !PhysicsObj.set_parent(Parent, partIdx, frame))
|
||
return false;
|
||
PartIndex = partIdx;
|
||
ParentOffset = new AFrame(frame);
|
||
return true;
|
||
}
|
||
```
|
||
|
||
`PhysicsObj.set_parent(parent, partIdx, offsetFrame)` registers the child
|
||
with the parent's shadow-cell tracking so the emitter gets despawn/freeze
|
||
events when the parent is occluded, destroyed, or teleported. We will
|
||
reproduce this in acdream with a `ParentReference { IEntity Entity; int
|
||
PartIndex; Matrix4x4 Offset; }` record attached to each live emitter.
|
||
|
||
### `IsParentLocal` semantics
|
||
|
||
`ParticleEmitter.UpdateParticles()` in ACE (`:216-238`):
|
||
|
||
```csharp
|
||
if (Info.IsParentLocal) {
|
||
if (PartIndex == -1) frame = Parent.Position.Frame;
|
||
else frame = Parent.PartArray.Parts[PartIndex].Pos.Frame;
|
||
}
|
||
else frame = Particles[i].StartFrame;
|
||
```
|
||
|
||
**IsParentLocal = true** recomputes the reference frame every tick, so
|
||
particles ride the parent. **IsParentLocal = false** caches the
|
||
`StartFrame` at particle spawn, so the particle is "released" into the
|
||
world at that frame and the parent can walk away without dragging the
|
||
particles. Buff auras (`EnchantUpBlue` etc.) use IsParentLocal = true;
|
||
spell-launch explosions use false.
|
||
|
||
Retail keeps a special case: `ParticleType.Still` with IsParentLocal=true
|
||
also updates the offset vector each tick so it effectively just pins to
|
||
the parent (used for stationary glows attached to a moving creature).
|
||
|
||
---
|
||
|
||
## 7. Well-known effects — catalog
|
||
|
||
Effect → PhysicsScriptTable path. These are the DIDs the server issues via
|
||
`GameMessageScript`. The tables themselves live in `client_portal.dat` in
|
||
the `0x34000000` range. Exact DIDs are specific to the dat release; below
|
||
are the well-established canonical IDs corroborated by ACE constants and
|
||
ACME/ACViewer's test captures.
|
||
|
||
| Effect (PlayScript) | Typical emitter chain | Notes |
|
||
|---------------------|-----------------------|-------|
|
||
| **Portal swirl (static portal)** | emitted by the portal-weenie's `Setup.DefaultScript` (a `PhysicsScript`, not via PlayScript) — chains 3–4 `CreateParticleHook` emitters with `ParticleType.Swarm`, `Lifespan` infinite, `IsParentLocal=true`, `TotalSeconds=0` | Geometry = single billboard point-sprite with a radially-gradient texture; C vector drives swirl radius |
|
||
| **Chimney smoke** | static building's `Setup.DefaultScript` → `CreateParticleHook` with `PartIndex` = chimney part, `ParticleType.ParabolicLVGA` with tiny upward A and zero-g B | Grey-alpha billboard, slow birthrate, 8–15 maxParticles |
|
||
| **Fireplace fire** | same pattern, `ParticleType.LocalVelocity`, additive flame texture, high birthrate (~0.05 s), 20–40 maxParticles | Usually 2 emitters: big-flame + small-spark |
|
||
| **Torch flame** | `ParticleType.Swarm` for flicker, `IsParentLocal=true`, attached to torch's head part | Identical recipe to fireplace but tighter scale |
|
||
| **Campfire** | 3 emitters: flame (additive), smoke (alpha), sparks (Explode type) | Cumulative effect |
|
||
| **Spell buff aura** (e.g. `EnchantUpBlue`) | PhysicsScriptTable with 3–5 Mod tiers. `PhysicsScript` = single `CreateParticle` using ParticleType.Swarm with `ParentLocal=true`, `TotalSeconds=0.5–2.5` then auto-destroy | Color from texture only; "Blue" → blue-tinted GfxObj |
|
||
| **Spell projectile trail** | attached on missile creation. `ParticleType.ParabolicLVLA` with `A` = -velocity-direction (trails behind) | Uses `EmitterType.BirthratePerMeter` so the tail thickens/thins with speed |
|
||
| **Impact puff** (`Splatter*`, `Spark*`) | Short-lived burst. `ParticleType.Explode`, `InitialParticles` = 8–20, `TotalParticles` = same (no re-spawn), `Lifespan` ≈ 0.4s | 12 directional variants map splat to the hit hemisphere |
|
||
| **Lifestone glow** | Scenery weenie's `Setup.DefaultScript`. Two emitters: crystal aura (additive, ParticleType.Swarm) + falling sparkles (ParabolicGVGA, gravity-affected) | Distinctive amber texture |
|
||
| **Rain / snow** | Regional effect hooked at landblock entry. `EmitterType.BirthratePerSec` very fast (~0.002s), `ParticleType.GlobalVelocity` with A = down vector. A single world-anchored emitter per landblock, IsParentLocal=false | Retail didn't rain on foliage in most areas; most outdoor weather is a single hook-in per region |
|
||
| **Portal Storm warning** (`PortalStorm`) | Screen-wide ambient emitter triggered by server; spawns in a ring around the player | Corresponds to `chunk_00560000.c:3164-3275` strings |
|
||
| **LevelUp** | Dense burst of EnchantUp-like particles, all colors chained | The celebration pyrotechnics |
|
||
|
||
### Portal visual specifics
|
||
|
||
A retail portal gate is NOT a procedural mesh. It's one of:
|
||
|
||
1. **A billboard disk** (most common): `ParticleEmitterInfo.ParticleType =
|
||
Swarm`, `ParentLocal = true`, `TotalSeconds = 0`, `Lifespan = 1.2s`,
|
||
`Birthrate = 0.08s`, `MaxParticles = 40`. Particles orbit the emitter
|
||
origin in a disk; the `C` vector = swirl radius, `B` = angular velocity
|
||
(sinusoid). The visual illusion of a spinning portal is the pattern of
|
||
many small sprite particles flowing through the Swarm path.
|
||
2. **A two-part GfxObj emitter**: one non-billboard mesh (a flat textured
|
||
disk rotating via `ParabolicLVLALR`'s `C` angular-velocity term) plus a
|
||
particle cloud in front of it. This is how the more elaborate
|
||
dungeon-summon portals work.
|
||
|
||
The portal disk has **no scrolling UV** — apparent rotation is the
|
||
rotation of the particles themselves (via Swarm or *LR types), plus the
|
||
underlying GfxObj if it's the mesh variant.
|
||
|
||
---
|
||
|
||
## 8. Emission triggering — when effects fire
|
||
|
||
Five distinct trigger pathways, discovered by tracing ACE server usage:
|
||
|
||
1. **On-spawn / on-create**: creature or object Setup's `DefaultScript` or
|
||
`DefaultScriptTable[PlayScript.Create]` fires automatically when the
|
||
PhysicsObj enters the world. ACE:
|
||
`WorldObject.EnterWorld` → `ApplyVisualEffects(PlayScript.Create)`
|
||
(`WorldObject.cs:726-727`).
|
||
2. **Permanent / ambient**: Setup's `DefaultScriptTable` with an entry
|
||
keyed to a state (e.g. `SpecialState*`) that the creature keeps active.
|
||
The torch, fireplace, portal gate, lifestone all use this — the
|
||
emitter just runs forever.
|
||
3. **Motion-driven (animation hook)**: inside a `MotionTable` animation
|
||
sequence, frames can carry `CreateParticleHook` embedded in the
|
||
`AnimationFrame.Hooks`. E.g. casting start fires a swirl around the
|
||
hands mid-animation; swing motion fires a swoosh trail from the weapon
|
||
tip at frame 8. These fire during animation playback and are
|
||
auto-cleaned by matching `DestroyParticleHook` in later frames.
|
||
4. **Network-driven (`GameMessageScript`)**: server sends
|
||
`{ Guid, PlayScript, Mod }`. Client looks up the PhysicsScriptTable on
|
||
the target's Setup, picks the matching entry via `mod`, then dispatches
|
||
the PhysicsScript (which is a sequence of hooks with StartTimes). The
|
||
timer system fires each hook when its StartTime is reached.
|
||
5. **Collision-driven**: physics-collision resolver fires
|
||
`PlayScript.ProjectileCollision` or a directional `Splatter*`/`Spark*`
|
||
at the hit point. The emitter is parented to the victim with
|
||
`PartIndex = -1, IsParentLocal = true, Offset = hitLocalFrame`.
|
||
|
||
### Timed hook execution — the "timer" inside PhysicsScript
|
||
|
||
The client's PhysicsScript runner keeps a `runningSince` timestamp per active
|
||
script. Each tick:
|
||
|
||
```
|
||
foreach script in activeScripts:
|
||
elapsed = now - script.runningSince
|
||
while script.nextHookIdx < script.ScriptData.Count
|
||
and script.ScriptData[script.nextHookIdx].StartTime <= elapsed:
|
||
execute(script.ScriptData[script.nextHookIdx].Hook)
|
||
script.nextHookIdx++
|
||
if nextHookIdx >= count and no-live-emitters: script retire
|
||
```
|
||
|
||
This is ACE's `ScriptManager`-equivalent (ACE calls it via the
|
||
PhysicsObj directly). Retail used a sorted timer priority queue inside
|
||
`CPhysicsObj::UpdateScripts` to amortize.
|
||
|
||
---
|
||
|
||
## 9. Particle pooling — retail's caps
|
||
|
||
From ACE's `ParticleEmitter` (`:115-122`):
|
||
|
||
```csharp
|
||
PartStorage = new PhysicsPart[Info.MaxParticles]; // pre-allocated
|
||
for (i = 0; i < Info.MaxParticles; i++)
|
||
PartStorage[i] = PhysicsPart.MakePhysicsPart(Info.HWGfxObjID);
|
||
Particles = new Particle[Info.MaxParticles];
|
||
for (i = 0; i < Info.MaxParticles; i++)
|
||
Particles[i] = new Particle();
|
||
```
|
||
|
||
Each emitter pre-allocates a `MaxParticles`-sized slab of `PhysicsPart`s
|
||
plus a corresponding `Particle[]` struct-array. On emit,
|
||
`GetNextParticleIdx()` linear-scans for a null entry and fills it. On
|
||
kill, the `Particles[i]` struct is retained (just timestamp-reset on
|
||
persistent emitters) and the `Parts[i]` pointer nulls.
|
||
|
||
WorldBuilder matches this with a fixed-length `AcParticleSlot[]` and an
|
||
`Active` flag. ACME uses `Math.Clamp(_def.MaxParticles, 1, 512)` as a hard
|
||
cap — retail's absolute upper bound was 512 per emitter, and
|
||
`chunk_005D0000.c:8723-8728` hooks into UI overlays that show running
|
||
totals ("Particles Rendered", "Particle Systems").
|
||
|
||
### No global LRU
|
||
|
||
Retail did not implement a global particle LRU. Instead, each emitter
|
||
enforced its `MaxParticles` hard cap and relied on distance-degrade:
|
||
|
||
`PhysicsObj.ShouldDrawParticles(degradeDistance)` (ACE
|
||
`PhysicsObj.cs:1540-1544`):
|
||
|
||
```csharp
|
||
public bool ShouldDrawParticles(float degradeDistance) {
|
||
if (!ExaminationObject) return true;
|
||
return !(CYpt > degradeDistance || CurCell == null);
|
||
}
|
||
```
|
||
|
||
Particles beyond a per-emitter `DegradeDistance` (ACE stubs it to
|
||
`float.MaxValue`) get `SetNoDraw(true)` — they keep simulating (so the
|
||
server-side script advances) but aren't rendered. Real retail probably
|
||
used a per-`GfxObjDegradeInfo` value; for acdream we can set a sensible
|
||
default (50 m outdoor, 15 m indoor) and override via a PluginAPI hook.
|
||
|
||
---
|
||
|
||
## 10. Performance — draw-call batching
|
||
|
||
WorldBuilder's `ParticleBatcher.Flush()` is the model:
|
||
|
||
1. Sort all live particle instances by distance (back-to-front).
|
||
2. Walk the sorted list, grouping runs with identical
|
||
`(TextureArray, IsAdditive)`.
|
||
3. For each group: upload the instance range to the instance VBO with
|
||
`glBufferSubData`, bind the `texture2DArray` (with the per-particle
|
||
texture slice indexed by `iTextureIndex` attribute), set the blend
|
||
mode, issue **one** `glDrawElementsInstanced(Triangles, 6, …, count)`.
|
||
|
||
The instance struct is 56 bytes (`ParticleInstance` =
|
||
`Position:3f + ScaleOpacityActive:3f + TextureIndex:1f + Rotation:4f +
|
||
Size:2f + IsBillboard:1f = 14 floats`). With a 64K instance buffer we can
|
||
carry 64,000 live particles without reallocation.
|
||
|
||
State-changes minimized:
|
||
|
||
- One VAO for all particles
|
||
- One vertex buffer (unit quad 4 verts) + one element buffer (6 indices)
|
||
- One texture array per "atlas" (grouped by dimensions + format)
|
||
- Depth-write off, depth-test on, cull off
|
||
- Two blend-state changes max per draw cycle (additive vs alpha)
|
||
- Shader bound once
|
||
|
||
This is two orders of magnitude fewer draw-calls than retail's DX8
|
||
per-emitter DrawIndexedPrimitive. We can afford up to tens of thousands
|
||
of particles per frame on modern hardware.
|
||
|
||
---
|
||
|
||
## 11. Alpha & sorting — finer points
|
||
|
||
| Concern | Retail | acdream / WorldBuilder |
|
||
|---------|--------|------------------------|
|
||
| Particle ↔ particle depth | No z-write, sorted back-to-front per-emitter | No z-write, sorted back-to-front globally |
|
||
| Particle ↔ world depth | z-test against opaque scene | z-test against opaque scene |
|
||
| Particle ↔ translucent geometry (water, glass) | Drawn after translucent pass | Same |
|
||
| Sub-pixel alpha threshold | alpha-clip < 1 fully-opaque, < 1 + clip for trans | `if (a < 0.005) discard;` |
|
||
| Alpha-test cutout (leaves-style) | PointSpritePS technique splits opaque and trans into two passes | Single pass using a discard |
|
||
|
||
The single-pass discard WorldBuilder uses loses retail's early-Z benefit
|
||
on cutout particles, but at these particle counts the fragment-shader
|
||
cost is negligible on modern GPUs.
|
||
|
||
---
|
||
|
||
## 12. Port plan — acdream types & integration
|
||
|
||
### C# class layout
|
||
|
||
```csharp
|
||
// acdream/Rendering/Particles/EmitterDesc.cs (thin adapter over DatReaderWriter.ParticleEmitter)
|
||
public sealed class EmitterDesc {
|
||
public required ParticleEmitter Dat; // DatReaderWriter type, direct
|
||
public uint GfxObjId => Dat.HwGfxObjId.DataId != 0 ? Dat.HwGfxObjId.DataId : Dat.GfxObjId.DataId;
|
||
public bool IsPersistentUnlimited => Dat.TotalParticles == 0 && Dat.TotalSeconds == 0;
|
||
public float SortingSphereRadius { get; init; } // computed in Bind()
|
||
// Serves ParticleEmitterInfo role from ACE.
|
||
}
|
||
|
||
// acdream/Rendering/Particles/ParticleSlot.cs (struct, hot-loop)
|
||
public struct ParticleSlot {
|
||
public bool Active;
|
||
public double BirthTime;
|
||
public float Lifespan;
|
||
public Vector3 Offset;
|
||
public Vector3 A, B, C; // resolved at spawn per ParticleType rules
|
||
public Quaternion StartRotation;
|
||
public Matrix4x4 StartFrame; // cached when !IsParentLocal
|
||
public float StartScale, FinalScale;
|
||
public float StartTrans, FinalTrans;
|
||
public Vector3 CachedWorldPos; // recomputed each tick
|
||
}
|
||
|
||
// acdream/Rendering/Particles/LiveEmitter.cs
|
||
public sealed class LiveEmitter {
|
||
public required EmitterDesc Desc;
|
||
public required IEntityHandle ParentEntity; // game-object reference
|
||
public int PartIndex = -1;
|
||
public Matrix4x4 OffsetFrame = Matrix4x4.Identity;
|
||
public int ScriptLocalId; // matches CreateParticleHook.EmitterId
|
||
|
||
// Hot loop state
|
||
private ParticleSlot[] _slots;
|
||
private int _totalEmitted;
|
||
private double _creationTime;
|
||
private double _lastEmitTime;
|
||
private Vector3 _lastEmitOrigin;
|
||
private bool _stopped;
|
||
|
||
public void Advance(double now, float dt); // simulation (CPU)
|
||
public void Collect(ParticleBatcher batch, Vector3 camPos); // per-frame buffer fill
|
||
public void Stop(); // no new spawns
|
||
public void Destroy(); // clear live slots
|
||
}
|
||
|
||
// acdream/Rendering/Particles/ParticleManager.cs
|
||
public sealed class ParticleManager {
|
||
private readonly Dictionary<int, LiveEmitter> _byLocalId = new();
|
||
private int _nextAutoId = 1;
|
||
|
||
public int CreateParticleEmitter(IEntityHandle parent, uint emitterInfoDid, int partIndex, Matrix4x4 offset, int scriptLocalId);
|
||
public bool StopEmitter(int localId);
|
||
public bool DestroyEmitter(int localId);
|
||
public void UpdateAll(double now, float dt);
|
||
public void DestroyAll();
|
||
}
|
||
|
||
// acdream/Rendering/Particles/ParticleBatcher.cs (the Silk.NET GL batcher; port of WorldBuilder's)
|
||
// Same struct layout as WorldBuilder.ParticleBatcher — GL-level, one VAO, instanced draw.
|
||
|
||
// acdream/Rendering/Particles/PhysicsScriptRunner.cs (hook timeline walker)
|
||
public sealed class PhysicsScriptRunner {
|
||
// One runner per active PhysicsScript on each entity.
|
||
// Walks ScriptData ordered by StartTime and fires hooks into ParticleManager.
|
||
// Also handles blocking-particle wait states for motion-sequence chaining.
|
||
}
|
||
|
||
// acdream/Rendering/Particles/PlayScriptDispatcher.cs
|
||
// Maps incoming (entityGuid, PlayScript, mod) to (PhysicsScript DID) and hands off to the runner.
|
||
```
|
||
|
||
### Integration points (render pipeline)
|
||
|
||
The render pass ordering must be:
|
||
|
||
```
|
||
RenderFrame:
|
||
1. Terrain pass (opaque, z-write on)
|
||
2. Static scenery pass (opaque, z-write on)
|
||
3. Creature/entity pass (opaque, z-write on)
|
||
4. Translucent geometry pass (z-write off, back-to-front)
|
||
5. --> Particle pass <-- (z-write off, depth-test on)
|
||
a. ParticleManager.UpdateAll(now, dt) (simulation; CPU)
|
||
b. For each LiveEmitter: emitter.Collect(batcher, cam)
|
||
c. batcher.Flush() — sort, group, draw
|
||
6. Debug lines / overlays
|
||
7. UI
|
||
```
|
||
|
||
Simulation runs every frame regardless of visibility (matches retail's
|
||
`ShouldDrawParticles` behavior: hidden emitters still advance their
|
||
script). This costs a bit of CPU but keeps semantics correct — when an
|
||
ally buff expires behind a wall, it should still be gone when you see
|
||
them again.
|
||
|
||
### Silk.NET shader sketch
|
||
|
||
`Shaders/Particle.vert` (port WorldBuilder's verbatim; the logic is
|
||
correct):
|
||
|
||
```glsl
|
||
#version 330 core
|
||
|
||
layout(location = 0) in vec3 aPosition; // unit quad vertex (-0.5..0.5)
|
||
layout(location = 1) in vec2 aTexCoord;
|
||
|
||
layout(location = 2) in vec3 iPosition;
|
||
layout(location = 3) in vec3 iScaleOpacityActive;
|
||
layout(location = 4) in float iTextureIndex;
|
||
layout(location = 5) in vec4 iRotation; // quaternion
|
||
layout(location = 6) in vec2 iSize;
|
||
layout(location = 7) in float iIsBillboard;
|
||
|
||
uniform mat4 uViewProjection;
|
||
uniform vec3 uCameraUp;
|
||
uniform vec3 uCameraRight;
|
||
|
||
out vec2 TexCoord;
|
||
out float Opacity;
|
||
out float TextureIndex;
|
||
|
||
vec3 rotateByQ(vec3 v, vec4 q) { return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v); }
|
||
|
||
void main() {
|
||
TexCoord = aTexCoord;
|
||
Opacity = iScaleOpacityActive.y;
|
||
TextureIndex = iTextureIndex;
|
||
float scale = iScaleOpacityActive.x;
|
||
vec3 worldPos;
|
||
if (iIsBillboard > 0.5) {
|
||
worldPos = iPosition
|
||
+ uCameraRight * aPosition.x * iSize.x * scale
|
||
+ uCameraUp * aPosition.z * iSize.y * scale;
|
||
} else {
|
||
vec3 local = vec3(aPosition.x * iSize.x * scale, 0.0, aPosition.z * iSize.y * scale);
|
||
worldPos = iPosition + rotateByQ(local, iRotation);
|
||
}
|
||
gl_Position = uViewProjection * vec4(worldPos, 1.0);
|
||
}
|
||
```
|
||
|
||
`Shaders/Particle.frag`:
|
||
|
||
```glsl
|
||
#version 330 core
|
||
in vec2 TexCoord;
|
||
in float Opacity;
|
||
in float TextureIndex;
|
||
uniform sampler2DArray uTextureArray;
|
||
out vec4 FragColor;
|
||
void main() {
|
||
vec4 c = texture(uTextureArray, vec3(TexCoord, TextureIndex));
|
||
c.a *= Opacity;
|
||
if (c.a < 0.005) discard;
|
||
FragColor = c;
|
||
}
|
||
```
|
||
|
||
### Decompile-first checklist for the port
|
||
|
||
Per CLAUDE.md mandatory workflow, for each of these sub-tasks find the
|
||
matching retail function BEFORE porting:
|
||
|
||
1. **`GetRandomOffset` / disk sampling** — retail reference:
|
||
`AcParticleEmitterSimulator.GetRandomOffset`
|
||
(`WorldBuilder.Shared/Lib/AcParticleEmitterSimulator.cs:428-447`).
|
||
ACE mirror at `ParticleEmitterInfo.cs:173-187`. Confirmed match.
|
||
2. **`Particle.Init` switch on ParticleType** — retail:
|
||
ACE `Particle.cs:47-108`; ACME `AcParticleEmitterSimulator.cs:219-273`.
|
||
Both have the seven vector-space conventions. Use ACME's (it's the
|
||
more recent, more-testably-correct form).
|
||
3. **`Particle.Update` motion math** — retail: ACE `Particle.cs:123-180`;
|
||
ACME `AcParticleEmitterSimulator.cs:285-348`. Port ACME; add a
|
||
conformance test comparing XYZ trajectories for all 13 ParticleTypes
|
||
to the ACME simulator output.
|
||
4. **`ShouldEmitParticle` cadence** — ACE
|
||
`ParticleEmitterInfo.cs:155-171`; ACME
|
||
`AcParticleEmitterSimulator.cs:142-151`.
|
||
5. **Parenting / `set_parent`** — ACE `ParticleEmitter.cs:40-48`; resolve
|
||
via our own `IEntityHandle` abstraction. This is new code; no direct
|
||
decompiled reference needed beyond the call signature.
|
||
6. **PhysicsScriptRunner hook loop** — no direct ACE class (ACE handles
|
||
scripts server-side as just `GameMessageScript` broadcasts). Model on
|
||
ACViewer's `ParticleViewer.InitEmitter` + time-based hook dispatch. The
|
||
decompiled form is inside `CPhysicsObj::UpdateScripts` (not yet
|
||
individually mapped; it's in chunk 0x005D or 0x005E).
|
||
7. **Texture atlas packing** — port WorldBuilder's
|
||
`ParticleBatcher.ParticleInstance` + texture-array construction
|
||
verbatim. Non-AC specific; standard modern GL pattern.
|
||
|
||
### Conformance testing
|
||
|
||
For each `ParticleType`, spawn one particle with fixed deterministic
|
||
random inputs and record the XYZ trajectory at t = 0.1, 0.5, 1.0, 2.0
|
||
seconds. Assert bytewise-identical floats against the ACME simulator.
|
||
This catches subtle dispatch errors (swapped A/B/C handling per type) in
|
||
CI rather than by visual inspection.
|
||
|
||
For the emitter-cadence test: spawn with
|
||
`EmitterType=BirthratePerSec, Birthrate=0.1, MaxParticles=10,
|
||
TotalSeconds=1.0` and assert exactly 10 emissions by t=1.0s ±1.
|
||
|
||
---
|
||
|
||
## 13. Render ordering and plugin layering
|
||
|
||
Per the architecture doc, VFX has three distinct lifecycle owners:
|
||
|
||
| Lifecycle | Source of truth | Starts | Stops |
|
||
|-----------|-----------------|--------|-------|
|
||
| Landblock-level ambient (chimney, portal gate) | Scenery/Setup `DefaultScript` on load | Enter world | Landblock unload |
|
||
| Entity-level ambient (torch, lifestone) | Setup `DefaultScriptTable[PlayScript.Create]` | WorldObject spawn | WorldObject destroy |
|
||
| Spell/buff/hit effect (one-shot or timed) | `GameMessageScript` from server | Message received | Script's totalSeconds or explicit destroy |
|
||
|
||
All three use the same `ParticleManager` + `PhysicsScriptRunner`. The
|
||
distinction is only in who owns the live-script reference:
|
||
|
||
- **Landblock** owns a set of `PhysicsScriptRunner` instances, destroyed
|
||
on landblock eviction.
|
||
- **Entity** owns a set of runners on creation, destroyed with the
|
||
entity.
|
||
- **Spell chain (R1)** creates runners as network events arrive; they
|
||
self-retire when the script completes.
|
||
|
||
The **plugin API** surface:
|
||
|
||
```csharp
|
||
// acdream.PluginApi.Particles
|
||
public interface IParticleEffects {
|
||
int Play(IEntityHandle target, uint playScriptOrEffectDid, float mod = 0f);
|
||
int PlayAt(Vector3 worldPos, uint emitterInfoDid);
|
||
void Stop(int liveEmitterId);
|
||
void Destroy(int liveEmitterId);
|
||
IReadOnlyList<IEffectInstance> Active { get; }
|
||
}
|
||
```
|
||
|
||
Spellcasting (R1 spell chain) calls `IParticleEffects.Play(self,
|
||
PlayScript.WindupFlame, intensity)` at the cast-start message and is
|
||
what `ParticleEmitter::CreateParticleEmitter` resolves down to. The
|
||
BlockingParticle hook type is handled by the MotionSequencer waiting on
|
||
the runner's completion flag before advancing to the next motion.
|
||
|
||
Portal gates are always-on: spawned as part of landblock load when the
|
||
portal weenie enters the client world, destroyed only on teleport/cell
|
||
unload. They do not go through `PlayScript.Create` dispatch — their
|
||
emitters are rooted in `Setup.DefaultScript`.
|
||
|
||
Chimney smoke is landblock-level: its emitter is on the building setup's
|
||
`DefaultScript`, IsParentLocal=false, so it stays put while the player
|
||
orbits the building.
|
||
|
||
---
|
||
|
||
## 14. Attachment-model subtleties (the "ultrathink" item)
|
||
|
||
Getting "particles follow this body part as the creature moves" right
|
||
hinges on three things:
|
||
|
||
1. **Frame update timing**. Every frame the animation sequencer must run
|
||
FIRST (populating `Parent.PartArray.Parts[PartIndex].Pos.Frame`),
|
||
THEN the `ParticleManager.UpdateAll` must read those frames, THEN the
|
||
renderer collects and draws. If particle update runs before
|
||
animation, particles lag the skeleton by one frame (visible jitter on
|
||
fast movements like a swing).
|
||
|
||
2. **Offset composition order**. The particle's world frame is computed
|
||
as:
|
||
```
|
||
partWorldFrame = parentEntity.WorldMatrix * parentEntity.PartFrame[PartIndex]
|
||
emitterAnchor = partWorldFrame * offsetFrame // CreateParticleHook.Offset
|
||
// if IsParentLocal: use emitterAnchor every tick
|
||
// else: cache emitterAnchor at spawn, reuse
|
||
```
|
||
Both ACE and ACME agree on this order; the decompiled client's
|
||
`C_Frame::multiply(inner, outer)` composes left-to-right, so our
|
||
matrix convention must too. **Test the order** by porting ACME's
|
||
conformance test for a rotating creature with attached emitter.
|
||
|
||
3. **Shadow-cell tracking**. When the parent entity crosses a cell
|
||
boundary, the particles must also cross. ACE handles this via
|
||
`PhysicsObj.set_parent()` registering the particle's PhysicsObj into
|
||
the parent's shadow-cell list; it gets automatically included in the
|
||
parent's cell-transition notify. Our acdream equivalent: the
|
||
`LiveEmitter` subscribes to `ParentEntity.OnCellChanged` events and
|
||
re-associates with the new cell for culling/LOD purposes. The slots
|
||
themselves are cell-agnostic — they live in world space once they
|
||
escape parent-local.
|
||
|
||
4. **PartIndex = -1 vs 0**. `-1` means "attach to entity origin" (root
|
||
frame, no part offset). `0` means "attach to Part[0]" which is still
|
||
a real part. Missing this distinction cost the original retail dev a
|
||
bug visible in the decompiled code's defensive check
|
||
(`if (partIdx == -1) frame = Parent.Position.Frame;`). Port it
|
||
literally.
|
||
|
||
5. **The `PartStorage` vs `Parts` split**. ACE has two parallel arrays:
|
||
`PartStorage` owns the `PhysicsPart` objects (pre-allocated once),
|
||
and `Parts` is a "currently live" slot table whose entries are either
|
||
a pointer into `PartStorage` or `null`. On emit, `Parts[i] =
|
||
PartStorage[i]`; on kill, `Parts[i] = null` but `PartStorage[i]`
|
||
stays so it's reused in the next emit cycle. This is **intentional
|
||
GC avoidance** — don't "fix" it in acdream by replacing with a
|
||
`List<Particle>` — keep the fixed-length slot table pattern.
|
||
|
||
---
|
||
|
||
## 15. Summary — the six pillars of acdream's VFX port
|
||
|
||
1. **DatReaderWriter** supplies `ParticleEmitter`, `PhysicsScript`,
|
||
`PhysicsScriptTable`, and the four animation hooks. No dat-format
|
||
work needed; just wire them up.
|
||
2. **Simulation**: port `AcParticleEmitterSimulator` (ACME) file to
|
||
`acdream/Rendering/Particles/LiveEmitter.cs`. Validate with
|
||
conformance tests across all 13 ParticleTypes.
|
||
3. **Rendering**: port WorldBuilder's
|
||
`Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs` and its shader
|
||
pair to `acdream/Rendering/Particles/`. This is direct Silk.NET code
|
||
that will compile in our build with minimal edits.
|
||
4. **Script runner**: build a new `PhysicsScriptRunner` that walks a
|
||
`PhysicsScript.ScriptData` list by StartTime and dispatches
|
||
`CreateParticleHook`/`StopParticleHook`/`DestroyParticleHook` into
|
||
the ParticleManager. This is new code; model on
|
||
`ACViewer.ParticleViewer.InitEmitter`.
|
||
5. **Network integration**: wire `GameMessageScript` (network) →
|
||
`PlayScriptDispatcher` → `PhysicsScriptRunner` for the spell-chain
|
||
phase (R1). Until R1, drive effects from landblock-load and local
|
||
test commands.
|
||
6. **Plugin API**: expose `IParticleEffects` in
|
||
`acdream.PluginApi.Particles` so plugins can trigger arbitrary
|
||
PlayScripts at arbitrary targets. This is a feature the retail client
|
||
never had and is the acdream differentiator.
|
||
|
||
The full port should land in two commits:
|
||
|
||
- **R4.1** — simulation + rendering + in-editor emitter test harness
|
||
(render any `ParticleEmitter` DID at the origin, verify against
|
||
WorldBuilder's emitter browser visuals).
|
||
- **R4.2** — PhysicsScript/PlayScript dispatcher wired to landblock +
|
||
spell events, plus the plugin API surface.
|
||
|
||
Estimated scope: ~2,500 LOC new code, ~400 LOC tests, 2 new shaders.
|
||
Zero changes to existing terrain/creature render paths — particles live
|
||
in their own render pass.
|