Effect sprites (VFX sheets)
Pixel-art effects of the build system (#147–#150): projectiles, hit bursts, ground zones, states
over mobiles, reactions, escapes, triggers, auras and formations. They are painted with the pxl
skill (.agents/skills/pxl-pixel-art), packed into flipbook sheets in assets/vfx/, and played by
the client's VfxPlugin (crates/uo-client/src/vfx.rs) as quads in the 3D scene. This page is the
contract of docs/builds-implementation.md § 3.3 in detail: streams V1 and V2 paint in it, stream U
plays sheets on combat events.
Files
tools/pixel/vfx/_palette.px.js the shared palette (included by every source; `// glow` marks tones that give light)
tools/pixel/vfx/*.px.js V1's sources (one anim per sheet, `// @vfx` lines give the sheet's options)
tools/pixel/vfx2/*.px.js V2's sources, same rules
tools/pixel/vfx/pack.py builds the sources with pxl and writes the sheets and the index
assets/vfx/<name>.png the sheet: RGBA, frames left to right, one row per facing variant
assets/vfx/<name>.json its options (below)
assets/vfx/vfx.json the index: every sheet's options by name (generated, never edited)
Build: PXL=<path to pxl.js> python3 tools/pixel/vfx/pack.py (all sources of tools/pixel/vfx*/),
--only proj_fire,hit_fire for some sheets, --index to rewrite only vfx.json from the
per-sheet JSONs (after a merge, when two streams both changed the index: never merge vfx.json by
hand). Without PXL the script looks for the skill in the repository and its worktrees.
Sheet options (<name>.json)
{ "frames": 8, "fps": 16, "w": 16, "h": 16, "anchor": [8, 8], "loop": true, "emissive": 2.5,
"plane": "billboard", "rows": 2, "glow": ["#fff4d0", "#ffd27a"], "nudge": 0.4, "mount": "body" }
| Field | Meaning |
|---|---|
frames | frames per row, left to right, each w x h px |
fps | playback rate |
w, h | one frame, in game texels: 1 tile = 32 px wide on the ground (#34), 1 unit (1 m) up = 22.6 px on screen |
anchor | the pixel of a frame that sits on the spawn point (x right, y down, from the frame's top-left corner) |
loop | true loops until the effect is despawned; false plays once and despawns itself |
emissive | 0..4: how strongly the glowing pixels give light into the HDR frame (0 = lit like a prop, never glows) |
plane | billboard: a camera-facing quad (image plane), drawn 1:1; ground: lying on the ground, drawn 1:1 too (the oblique view shows the ground without foreshortening) |
rows | optional, 1 or 2. 2 = the sheet can face 8 ways: row 0 is drawn heading screen-right, row 1 heading screen up-right (45°); the client turns them by quarter turns (frames must be square). Without it the sheet never turns |
glow | optional: the colours that glow (sRGB hex). Absent = every pixel glows (when emissive > 0). pack.py fills it from the palette's // glow tones found in the sheet |
nudge | optional, units towards the camera (default 0.35 billboard, 0.04 ground): drawn in front of the mobile or ground it sits on without moving on screen |
mount | optional hint for whoever spawns it: feet (the mobile's ground point), body (its chest, ≈ 1.2 units up on a human), head (above its head, ≈ 2 units up on a human), ground (a tile or zone centre), hand (a projectile's start) |
with | optional: other sheets spawned together with this one at the same point (a zone's standing part) |
radius | optional, zones: the tile radius the sheet is painted for (1: 3x3 tiles ≈ 96 px wide, 2: 5x5 ≈ 160 px) |
Pixels are opaque (alpha 255) or clear; partly clear pixels are allowed (thin mist, ice) and blend
over the scene. Non-glowing pixels are shaded like props (toon bands, sun, night); glowing pixels
add colour x emissive on top, so fire and holy light read at night.
Names
proj_<element>, hit_<element>, zone_<kind> (radius 1) and zone_<kind>_r2 (radius 2), each
with an optional _air companion (the standing part, a billboard, listed in with),
state_<state> (a loop over a mobile), and for V2 react_<reaction>, esc_<escape kind or key>,
trig_<trigger>, aura_<type>, form_<formation>, impact_<physical type>. Elements are the
catalogue's (docs/builds/catalog.py TAGS): slash, pierce, crush (Physical), fire, cold,
lightning, poison, holy, arcane. States are the catalogue's STATES keys.
Writing a source
include("_palette.px.js")
// @vfx proj_fire {"emissive": 2.5, "rows": 2}
anim("proj_fire", { w: 16, h: 16, frames: 8, fps: 16 }, (c) => { ... heading right ... })
anim("proj_fire__r1", { w: 16, h: 16, frames: 8, fps: 16 }, (c) => { ... heading up-right ... })
- One
animper sheet named like the sheet; the second row of arows: 2sheet is the anim<name>__r1with the same size and frame count. Helper parts start with_and are not sheets. // @vfx <name> {json}on its own line declares a sheet; the JSON overrides the defaults by prefix:proj_billboard loop, centre anchor, mounthand;hit_billboard once, centre anchor, mountbody;zone_ground loop, centre anchor, mountground(_air: billboard, bottom anchor at the zone's front edge, nudge radius + 1);state_billboard loop, bottom-centre anchor, mountfeet; others billboard once, centre anchor.frames,fps,w,hcome from the anim.- Use the palette's keys only; add a tone to
_palette.px.js(with// glowif it gives light) rather than a hex colour in a source, so the element colours stay one set (§ Palette). - Size in texels: a human is about 41 px tall (1.8 m), a tile 32 px wide; keep loops over a mobile within its silhouette's width (≈ 40 px) and overhead marks small (≤ 24 px) so a crowd stays readable.
Palette
The calm warm look (#62): 2–4 tones per material, the hue turning as it darkens, light cores near white but tinted, no pure black. Element colours (core → rim):
| Element | Tones | Glows |
|---|---|---|
| Fire | #fff4d0 #ffd27a #ec8a3a #b04a2c #6e2a26 | yes |
| Smoke | #a89ca4 #7a6e80 #4e4658 #322c3a | no |
| Cold | #f2fbff #b6e2f0 #78afd6 #4a6ea6 #2e4470 | faintly |
| Lightning | #fdfcff #dfe6ff #9cb0ff #6464c8 | yes |
| Poison | #e6f2a0 #a8cc58 #6c963c #3e5e34 | no |
| Holy | #fffbe8 #ffeaa0 #f0c460 #b8883c | yes |
| Arcane | #f4e4ff #cfa0f0 #9a66d2 #5c3c94 | yes |
| Steel (slash, pierce) | #f4f0e6 #c8c2b8 #8c8680 #4e4a4c | sparks only |
| Earth (crush) | #e2cc9e #b8946a #86684a #584232 | no |
| Blood | #d65040 #94282c #5a1a22 | no |
| Water | #e4f4fc #a6d4ec #5e9cc4 #36648a | no |
| Nature | #a6c66a #6a9444 #3e6234 | no |
| Dark | #3c2e52 #241c32 (glints arcane) | glints |
The exact keys are in tools/pixel/vfx/_palette.px.js; icons (§ 3.4) take their element colours
from this table.
Playing sheets (client)
VfxPlugin loads assets/vfx/vfx.json at start; a sheet's texture and frame meshes are made the
first time it is spawned.
fn system(mut vfx: Vfx) {
// game space: x east, y south, z up (like SpriteView::pos)
let e = vfx.spawn("hit_fire", at, Vec3::ZERO, 1.0, None);
let p = vfx.spawn("proj_cold", from, to - from, 1.0, None); // move it: VfxInstance::at
let s = vfx.spawn("state_burning", Vec3::ZERO, Vec3::ZERO, 1.0, Some(mobile)); // `at` = offset
vfx.despawn(s);
}
at: game-space point of the anchor, or withfollowan offset from the followed entity (itsSpriteViewposition, else its transform).facing: a game-space direction (zero = as drawn);rows: 2sheets snap it to the nearest of 8 screen directions, others ignore it.scale: 1 draws texel for texel; use whole numbers (2 for a big creature) to keep pixels square.follow: the effect moves with that entity and despawns with it.- Loops play until
despawn; one-shots despawn after their last frame.VfxInstance::at,facingandscalemay be changed at any time (a flying projectile).
Rendering: the toon mesh material (#38/#62) with the sheet as base colour and the glow mask as emission, alpha blended in the transparent pass (depth-tested, no depth write, no shadows, no outline), nearest sampling; each quad is snapped to whole screen texels.
cargo run --release -p uo-client -- --vfx-gallery lays every sheet out on the demo ground with
its name: states over a figure, zones on the ground, projectiles turning through their 8 facings,
hits replayed; --time 23 shows the night. UO_VFX_FRAME=n holds every sheet at frame n
(captures).
3D effects (decision #152)
The pixel sheets above are being replaced by 3D effects: bold meshes built in the world, so
the texel-resolution render and its nearest upscale give them the game's pixel look, lit by the
same toon bands, with coloured light thrown on the ground and the characters. The user picked
the look from concept paint-overs over a real capture (docs/concepts/kie/vfx3d/, git-ignored:
bold, 3–4 flat tones per element from a white-hot core, glowing cores, smooth translucency, no
dithering).
Code: crates/uo-client/src/vfx3d/ (mod.rs runtime and API, defs.rs descriptors,
shapes.rs built-in meshes and the shape registry, shapes_v1.rs stream V1's custom shapes,
coverage.rs which catalogue keys still lack an effect, stage.rs the gallery). Data: every
assets/vfx3d/*.json is loaded and merged (V2's hand-tuned prototypes effects.json and its
generated v2.json, V1's effects_v1.json; a key
defined twice keeps the first file's, with a warning; UO_VFX3D=path reads another folder or
one file).
Splitting the work between streams: each stream keeps its own JSON file and its own shapes
file. A layer whose kind isn't built in is a custom shape: its builder is looked up by name in
the ShapeRegistry, filled by shapes_v1::register (r.add("orb", orb); a builder gets the
layer's JSON and the frame's Build and returns a Geo built with shapes.rs's public helpers).
Materials: both are the toon mesh material (mesh.rs) with vertex colours.
- Glow layers are alpha-blended, depth-tested and don't write depth. They emit their vertex
colour (
emission, HDR units), cast no shadows and stay out of the outline. - Solid layers (
"solid": true: ice spikes, debris, shards) are opaque and toon-lit. They are tagged as objects in the normal prepass, so the sel-out outline draws round them like props.
Lights: each effect may spawn short-lived point lights on the lit layers. These are dynamic, not GI. Under the toon bands they give the stepped pools of light that sell an impact.
An effect is a palette (four tones, core to rim, linear) and a list of layers and lights:
| Field | Meaning |
|---|---|
shape | arc (swept blade trail), bolt (jagged tube with forks), star (camera-facing burst), disc (flash), ring (ground ring with a lip), pillar (beam from the sky), flames (flickering tongues), crystal (a closed block or a ring of shards), rays (ground light rays), particles (sparks, debris, motes: cubes with speed, gravity, bounce), streak (a projectile or trail along a hop, lobbed), puffs (billows: smoke, dust, steam; solid puffs are outlined toon smoke), helix (two strands winding up), slash (camera-facing cut marks), dome (wards, shields, nets: ribs, rings and a see-through shell, any span), wedge (a ground sector: cones, telegraphs), strip (a ground band: lines, fissures) |
at | origin (the caster), target (the first target), targets (one copy each), chain (one copy per hop: caster → target 1 → target 2 …), fan (a hop from the caster to every target: volleys), hop (the caster to the first target only: a dash's path), rest (every target after the first) |
height, forward | units over the anchor's ground point and towards the facing (the first target) |
start, stagger, life | seconds; stagger delays each further copy (chain hops, targets) |
scale, alpha, progress | curves [[t, v], …] over the layer's life (t 0..1); progress sweeps arcs, grows bolts, drops pillars; flat_scale scales across only |
spin, reshape | degrees a second round the vertical; seconds between re-shapes (new lightning) |
stay | stays where it was spawned; otherwise a layer follows its mobile (caster or target) every frame, chain hops stretch with them |
palette, solid, emission | the layer's own palette, look and glow |
| lights | at, tone (0 core .. 3 rim), height, start, stagger, life, range, intensity (lumens curve), flicker |
An effect may also have:
| Field | Meaning |
|---|---|
like | another effect it starts from: the base's layers then its own layers, the base's lights unless it has its own, the base's palette unless it names one (one family's look reused by its abilities) |
size, speed | size over the base (multiplies every spawn's scale) and how many times faster it plays (every start, stagger, life and beat divided by it) |
hitstop | { "at": s, "secs": s }: game time freezes for weight (combat::melee_fx::Hitstop) |
shake | { "at": s, "secs": s, "amp": px }: a camera kick (melee_fx::Kick), grown with the spawn's scale |
repeat | above 0, the effect starts over every repeat s until its root is despawned (auras, formations, state loops) |
An effect without a target faces the way its caster's SpriteView faces (stances, wards).
Units are tiles (1 = 1 m; a human is 1.8 tall, chest ≈ 1.0, a hand ≈ 1.1).
Coverage: the gallery logs, per stream and group, how many of the catalogue's keys already
have an effect and which are missing (vfx3d coverage: lines): the physical families'
abilities, the escapes, reactions, clothing and jewel triggers, keystones, auras, formations and
impacts and the armour D and R abilities (V2), the staff families' spells and the state loops
(V1).
API (stream U):
fn system(mut vfx: vfx3d::Vfx3d) {
use vfx3d::{Place, VfxOpts};
// from a caster to targets (entities with a SpriteView, or game-space points), in order
vfx.spawn("react_electrocute", Place::On(caster), &[Place::On(a), Place::On(b)], VfxOpts::default());
vfx.spawn("cleave", Place::On(hero), &[Place::On(foe)], VfxOpts { delay: 0.55, ..default() });
vfx.spawn("react_freeze", Place::At(tile), &[], VfxOpts { scale: 1.5, palette: Some("frost".into()), ..default() });
}
spawn returns the effect's root entity: despawn it to cut it short. Layers on entities follow
them while the effect plays (unless stay). VfxOpts.delay syncs the
effect with a clip's impact frame (assets/models/characters/ability_clips.json); scale
grows it, for tiers and big creatures; palette swaps the element. Positions are read once,
at spawn.
Stream V2's effects (230 keys: the 11 physical families' abilities, the 12 boots escapes, the
20 helmet D and body armour R abilities, the 10 reactions, 53 clothing and jewel triggers, 10
keystones, 10 cloak auras, 6 formations and 7 impacts) are generated by tools/vfx3d/v2_effects.py into assets/vfx3d/v2.json (--check
fails when the file is stale). The script builds them from a few blocks (swings, slashes,
thrusts, slams, projectiles, stances, shouts, buffs, marks) so each family keeps one look:
swords steel, axes crimson with blood, daggers violet shadow, spears long azure thrusts, maces
gold impacts and stun stars, hammers earth slams and rubble, fighting staves jade, polearms wide
steel arcs, fists amber, bows wooden arrows, crossbows iron bolts. Tests
(coverage::every_v2_key_has_an_effect, coverage::every_ability_has_an_effect: all 187
abilities, staves included) keep every key covered.
How the game spawns them (t = 0 is the clip's impact frame or the moment the shot leaves;
pass the clip's impact time as delay):
| Group | from | to |
|---|---|---|
| weapon abilities, reactions, impacts | the user | the struck, in order (chains hop through them; Ally abilities: the ally) |
| armour D and R abilities | the wearer | challenge, blind: the target; rally, war_cry, guardians_oath: the allies reached; insight: the enemies revealed; the rest nothing |
| moving abilities (dash, leap, escapes, weapon escapes) | the mover (Place::On: layers follow it, trails stay) | the landing tile (Place::At) first, then the enemies it strikes or marks |
| clothing and jewel triggers | the wearer | whom the trigger touches (the attacker, the struck, the healed ally) |
| keystones | the wearer | allies or enemies it touches, if any |
auras (aura_<cloak>), formations (form_<drill>) | each wearer (formations: every wearer in it) | nothing; they loop (repeat) until the root is despawned |
Prototype (four effects, the first use of the engine): cleave (a swept arc, crit burst,
sparks, ground slam ring, debris, light), react_electrocute (a chain through every target, splash
rings, sparks, flickering light), react_freeze (a frost ring, ice spikes and a see-through
faceted block, shattering at 1.5 s), react_holy_fire (a pillar from the sky, flames, ground
rays, a ring, rising motes, a golden light).
cargo run --release -p uo-client -- --vfx3d-gallery
cargo run --release -p uo-client -- --vfx3d-gallery weapon:hammer
cargo run --release -p uo-client -- --vfx3d-gallery trigger,page:3 --record trig3.mp4 --seconds 9 --zoom 3
The gallery plays effects in a loop on real characters and creature models, on a levelled
field south of the village, nine to a screen, each named. What to show: effect keys, prefix*,
groups (weapon, weapon:<family>, escape, reaction, trigger, keystone, aura,
formation, impact, spell, spell:<family>, state, armour), page:N; nothing shows the
four prototypes. Each vignette is built from the catalogue: a weapon ability's user holds its
family's first weapon in leathers, plays the ability's clip and the effect starts on its impact
frame; targets stand where the ability's shape reaches and flinch; escapes really dash, leap,
blink or swap; auras and formations loop. Plays start with a recording's first frame.
--record out.mp4 --seconds 9 films a page, --time 22 at night.
Staff spells, staff bolts and states (stream V1)
Every spell of the seven staff families (fire, frost, arcane, holy, nature, curse, earth: 63,
keyed by the catalogue's ability keys), each staff's auto-attack bolt (bolt_<family>) and the
twenty state loops (state_<state>). The data is assets/vfx3d/effects_v1.json, generated by
tools/vfx3d/gen_effects_v1.py (edit the script: shared builders keep the families alike, e.g.
every projectile leaves the hand with a flash, flies with a trail and lands with its element's
burst). It adds the palettes venom, nature, leaf, curse, plague, drain, stone, dusk, ash, mana,
ember and rot.
Shapes (vfx3d/shapes_v1.rs, custom kinds): orb (a projectile head: banded discs with a
wobbling rim and a tail), shard (a faceted spike in flight), sphere (a bubble, clear in the
middle and bright at the rim, squashed in height so it reads round in the oblique view; dome,
bands), cloud (see-through puffs lit on top), spiral (a vortex on the ground), slabs
(wall pieces across the way or in a ring, rising with progress), sigil (a rune circle on the
ground or upright), rain (streaks or flakes), swirl (motes or leafs winding up, or
inward), blaze (flame tongues over a circle, line or cone), cracks (glowing fissures,
radiating or in a line), wings, tether (a hop with pulses running back to the caster),
spikes (ice or rock out of the ground, in a ring or a line), rock (a tumbling boulder, or
count of them rolling out), plates (shards circling a body).
Motion (vfx3d/motion_v1.rs), read from a custom layer's shape JSON:
| Field | Meaning |
|---|---|
travel | curve: the layer flies from where it stands to the first target over its life (projectiles) |
arc | lob height at mid-flight |
lift | curve scaling its height over the ground (a meteor falling from 9 up) |
repeat | on any layer: the effect loops (every layer and light starts over) until the game despawns it: states |
kind trail | no mesh; sheds rate bits a second where it is (sparks behind a bolt, embers off a burning body) |
kind tint | no mesh; the struck mobile's own materials take a palette tone (tone, amount), glow (emission) and may turn see-through (alpha) while it plays: Frozen goes icy, Hidden half-transparent |
kind loop | no mesh; a marker carrying repeat |
For the game (stream U): spells spawn from the caster to their targets on the clip's impact
frame (ability_clips.json); projectiles fly a fixed 0.45 s whatever the distance. States spawn
once with the mobile as both caster and target and are despawned when the state ends; marks over
the head sit at 2.2 units, scale them for big creatures. Zone effects take the zone's centre as
the target.
Gallery: the catalogue gallery above shows them: --vfx3d-gallery spell:fire (or another
family), state, bolt_*:
cargo run --release -p uo-client -- --vfx3d-gallery spell:fire --no-hud --record fire.mp4 --seconds 9 --zoom 2