VFX pipeline
How every skill effect is built — layers, techniques (slash meshes, GPU particles, flipbooks, trails, decals), palettes per build, budgets per tier, the Low→Ultra feel stack (debris, shockwaves, camera kick, screen vibrate, haptics), pooling, readability rules, data format and tests.
Path of Exile-level flash on a phone budget means smart layers, not raw particle counts. Every skill effect is a small set of cheap, well-timed layers that read at a glance and degrade gracefully.
◆Richness and review standard
Follow the per-skill semantic roles and visual self-tests. Richness means readable cue, motion, contact and end, plus footprint/status where relevant; it is not a minimum particle/emitter count. Every movement gem and buff also needs an intentional effect. Profile overdraw/material cost, CPU/GPU time and pools as well as particle counts. The caps below are Zoen planning values, not universal AAA standards. Review both cameras, Low/High, bright/dark terrain, crowd overlap and accessibility settings; never lose threat/party-utility readability. On movement cancel, stop cast-owned future cues/trails/bursts; effects on independent released entities follow their own lifetime. Full contract: cancel rules.
Small details that this page does not cover (impact placement, near-camera fade, overdraw rules, prewarming, what gives way first when a pool is full) are proposed in the AAA detail catalog.
◆Layers of one effect
| Layer | Purpose | Technique | Low tier |
|---|---|---|---|
| 1. Anticipation | "something big is coming" | glow on weapon, charge particles converging, ground glyph | glow only |
| 2. Cast / release | the moment of action | flash sprite, short point light (≤ 2 active), muzzle burst | flash sprite |
| 3. Motion | shape of the attack | slash mesh (arc mesh + scrolling eroded-alpha shader), weapon trail ribbon, projectile with core + trail | slash mesh, short trail |
| 4. Impact | contact feedback per target material | spark/dust/debris burst, hit flash on target, ring shockwave | burst (half count) |
| 5. Aftermath | the world remembers | decals (cracks, scorch, frost), lingering embers/mist | none (decals start at Mid, feel-quality.json) |
| 6. Status | ailment overlays | shader params on target (burn flicker, frost rim, shock crawl) | shader only |
◆Core techniques (Unity URP)
- Slash meshes: pre-modelled arc strips (Blender) with UVs along the swing. A Shader Graph uses a greyscale noise mask, an erosion threshold over time and a gradient map (palette ramp) for colour, so one texture serves every build colour.
- Particles: VFX Graph (GPU, compute) on native High/Ultra; the Particle System (Shuriken, CPU) on Low/Mid and in the Web build (WebGL2 has no compute shaders). Both definitions share timing and footprint.
- Flipbooks: 8×8 sheets from Blender (
ART-VFX-040/041), premultiplied alpha, on billboards or ribbons. - Trails: a Trail Renderer per weapon tip socket, enabled by animation events, width curve stretched by swing speed.
- Decals: URP Decal Projectors (decal renderer feature) with lifetime fade, capped at 64 live, recycled oldest-first. Telegraphs and player ground previews are not in this pool: a recycled telegraph would break the never-cull rule (proposed own renderer: VFX-04).
- Distortion: heat haze/shockwave as a renderer-feature pass only on High+, one at a time.
- Lights: at most 2 short-lived dynamic point lights per frame on High (Forward+; 0 on Low). Fake the rest with emissive sprites.
◆Palettes (from builds.json)
Each build has three palette colours (e.g. Warblade #e2b04a #fff1c1 #6b4a1f). Elements override the tint:
fire #ff7a2a, cold #9fd3ff, lightning #b48cff, spirit fire #ffb347, sonic #5fd1a6, bleed #8a1e1e.
Monster telegraphs are always red-orange #ff5a2a with a pattern fill (colour-blind safe), and player ground previews are jade #4fd1a0.
◆Budgets
| Per effect | Low | Mid | High |
|---|---|---|---|
| Live particles (signature) | 120 | 350 | 900 |
| Live particles (chain skill) | 30 | 80 | 200 |
| Emitters per effect | 2 | 4 | 6 |
| Overdraw (fullscreen layers) | 0.5 | 1.0 | 1.5 |
| Draw calls per effect | 3 | 6 | 10 |
The frame-wide totals and the own/party/others split are in Performance and 1,000 players.
◆Data format
Each skill gets apps/client-unity/Assets/Zoen/Fx/Defs/<skillId>.json (read by the effect player; VFX Graph and Particle System assets referenced by id):
{ "skill": "warblade.horizon_cleave",
"events": [
{ "at": "windup:0.00", "fx": "glint_blade", "socket": "weapon_r", "tier": "all" },
{ "at": "contact", "fx": "slash_arc_160", "palette": "warblade", "scale": 3.2, "tier": "all" },
{ "at": "hit", "fx": "impact_by_material", "tier": "all" },
{ "at": "contact", "fx": "ground_dust_sheet", "tier": "mid+" } ] }
contact and hit are server events. windup:t is relative to the predicted cast start.
◆Pipeline per skill (tasks)
- Read the
presentation.vfxbrief and palette, then write the def JSON with greybox shapes (timing first). - Build or reuse textures (flipbooks/slash meshes). New textures come from Blender scripts and are logged in the art library.
- Tune in the VFX Lab scene (Combat Lab + slow-motion scrubber + Low/High toggle).
- Tests: event alignment vs server contact (±16.7 ms), budget counters per tier, pool return (no leaks after 1,000 casts), telegraphs visible on Low.
- Evidence: 10 s clip at Low and High side by side, sent to the owner for review on signature skills.
◆Quality tiers and the Ultra feel stack
Tiers are data (feel-quality.json), read by the Feel Lab today and by the client settings menu later. Every tier
keeps the same hit shapes, timings, reactions and telegraphs. Tiers only add presentation layers.
| Layer | Low | Mid | High | Ultra |
|---|---|---|---|---|
| Render scale | 0.75 | 1.0 | 1.0 | native DPR (max 2) + MSAA 4× |
| Shadows / glow / bloom | off / off / off | 512 / 0.4 / off | 1024 / 0.55 / 0.22 | 2048 / 0.65 / 0.3 |
| Particles (× base count) | 0.4 | 0.7 | 1.0 (stretched spark streaks) | 1.6 |
| Dynamic lights | 0 | 1 | 1 | 2 (impact flash + lingering glow) |
| Debris chunks / scorch decals | — | 6 / 8 | 14 / 16 | 32 / 32 |
| Ground dust rings, blade glint on windup | — | ✓ | ✓ | ✓ |
| Ambient sand motes | — | — | 40 | 140 |
| Camera: shake scale / FOV punch / directional kick | 0.7 / — / — | 0.85 / 2° / — | 1.0 / 3° / 0.12 m | 1.15 / 4.5° / 0.28 m |
| Screen shockwave (refraction ring), colour-split pulse, impact frame, screen vibrate | — | — | — | ✓ |
| Grain / sharpen | — | — | — | subtle |
- Screen vibrate means two things. The camera gets a 0.22 s high-frequency rattle on massive hits (Ultra), on top of the trauma shake. The device gets haptics on every tier: Vibration API patterns on phones and dual-rumble on gamepads, scaled by impact. Haptics fire only after the player has interacted, at most once per 60 ms.
- Settings: Graphics quality, Screen shake 0–100%, Haptics 0–100% and Reduce flashing. Reduce flashing removes impact frames and colour-split pulses and clamps bloom.
- No white-outs. An impact frame is at most 60 ms, a 420 px burst around the hit and never full screen. The glow layer takes only 20% of a struck target's hit flash, so flashes read as rim light.
- Implementation notes (Feel Lab,
components/feel/engine.ts):- Debris is one thin-instanced mesh driven by typed arrays (one draw call, no per-frame allocation).
- Decals are a fixed pool, recycled oldest first.
- The shockwave is one full-screen post pass that exists only on Ultra.
- One-shot particle bursts never dispose the shared sprite texture.
- Measured (Playwright, desktop, Sunbreaker playing): High and Ultra both hold frame p95 ≈ 16.7 ms
(
logs/evidence/T048/frame-times.json). Clip:feel-ultra.mp4on the Showcase page.
◆Readability rules
- Never hide a telegraph. Player effects render under monster telegraphs.
- Other players' effects default to Reduced (50% particles, no lights, no distortion). Own effects stay full.
- Bloom is capped. Flash-reduction mode clamps peak brightness and removes white-outs.
- The effect footprint must match the hit shape (a 3 m cone looks 3 m). The test overlays the shape on the capture.
Source: zoen/docs/production/VFX.md · 1,402 words · edit the Markdown, not this page.
