Sprites & animation
Everything that moves in a roxlap world is a sprite: a voxel model
(.kv6) instanced into the scene with its own f32 transform. Sprites
are not a separate rendering world — they march through the same
per-pixel DDA, so they get the scene’s lighting, stylized shadows
(cast and receive), and materials for free. On top of static models
sit three animation systems: flipbook voxel clips, skeletal
characters, and Doom-style billboards.
The snippets come from a runnable example — three gems (one plain, one scaled, one spinning), a pulsing animated clip, and the same clip as a camera-facing billboard:
cargo run --release -p roxlap-render --example book_sprites
Models & instances
The unit of registration is a model (one Kv6 volume); the unit
of placement is an instance. Register once, instance many:
// One model, many instances: register the kv6 once, then place
// posed instances. A pose is a position + a local→world basis;
// scaling the basis scales the model — there is no separate
// scale field.
let gem = renderer.add_sprite_model(&gem_kv6());
renderer
.add_sprite_instance_posed(
gem,
DynSpriteTransform {
pos: [-50.0, 0.0, 202.0],
..DynSpriteTransform::default() // identity basis: authored size
},
)
.expect("model just registered");
renderer
.add_sprite_instance_posed(
gem,
DynSpriteTransform {
pos: [0.0, 0.0, 198.0],
right: [2.0, 0.0, 0.0], // basis ×2 ⇒ the gem renders
up: [0.0, 2.0, 0.0], // at twice its authored size
forward: [0.0, 0.0, 2.0],
},
)
.expect("model just registered");
let spinner = renderer
.add_sprite_instance_posed(
gem,
DynSpriteTransform {
pos: [50.0, 0.0, 202.0],
..DynSpriteTransform::default()
},
)
.expect("model just registered");
Where models come from: paint one in
Demiurg (the roxlap asset
editor) or MagicaVoxel (chapter 11), parse a .kv6 file
(roxlap_formats::kv6), or build
one in code —
Kv6::from_fn (surface-only, as here), solid_cube / solid_box,
or from_fn_keep_interior for filled volumes
(chapter 6).
The pose type, DynSpriteTransform, is a position plus a
local→world basis (all f32 — chapter 2’s precision
split). Two things the basis buys you:
- Scale — there is no scale field; scaling the basis vectors is the scale, and non-uniform or skewed bases work too.
- Rotation — any orthogonal basis. For per-frame motion, rewrite the transform; it’s an in-place update, not a re-upload:
// Rewrite the spinner's basis: a yaw rotation about the
// world vertical. Per-frame transform writes are the
// intended path — they update in place, no re-upload.
if let Some(id) = self.spinner {
let (s, c) = (t * 1.5).sin_cos();
let (s, c) = (s as f32, c as f32);
renderer.set_sprite_instance_transform(
id,
DynSpriteTransform {
pos: [50.0, 0.0, 202.0],
right: [c, s, 0.0],
up: [-s, c, 0.0],
forward: [0.0, 0.0, 1.0],
},
);
}
// One call advances everything facade-owned: clip
// players, characters, billboard actors, and the
// camera-facing of plain billboards.
renderer.tick(&camera, dt);
For bulk motion use set_sprite_instance_transforms (plural) — one
batched flush for hundreds of instances (the particle system in
chapter 8 rides exactly that path). Instances are
removed with remove_sprite_instance; models with
remove_sprite_model + compact_sprite_models. Per-instance
appearance knobs: set_sprite_instance_tint / _alpha /
_material / _lighting / _shadow_flags.
Animated voxel clips (.rvc)
A voxel clip is the “GIF for voxels”: a fixed-bbox sequence of
frames stored as keyframes + deltas, with per-frame durations and a
loop mode. Author one from kv6 frames in code (below), import a GIF
(further down), or load a .rvc file:
/// A pulsing orb as an animated **voxel clip** — the "GIF for voxels":
/// four kv6 frames sharing one bounding box, encoded as keyframe +
/// deltas. 150 ms per frame, looping.
fn orb_clip() -> VoxelClip {
const DIM: u32 = 18;
let c = (DIM as f32 - 1.0) * 0.5;
let frames: Vec<Kv6> = [5.0_f32, 6.5, 8.0, 6.5]
.iter()
.map(|&r| {
let r2 = r * r;
Kv6::from_fn(DIM, DIM, DIM, |x, y, z| {
let (dx, dy, dz) = (x as f32 - c, y as f32 - c, z as f32 - c);
(dx * dx + dy * dy + dz * dz <= r2).then_some(ORB)
})
})
.collect();
VoxelClip::from_kv6_frames(&frames, 1.0, LoopMode::Loop, &[], 150, 1)
.expect("frames are non-empty and share dims")
}
(from_kv6_frames_auto picks keyframe-vs-delta per frame by encoded
cost — the turnkey choice for real assets.)
Register the decoded clip and place instances — _playing attaches a
per-instance player that tick advances; frame swaps cost O(changed
frame), not O(volume):
// Register the clip once, then instance it. `_playing` starts
// its per-instance player: `tick` (or `advance_voxel_clips`)
// advances the clock and swaps frames — O(changed frame), not
// O(volume).
let clip = renderer.add_voxel_clip(&orb_clip().decode().expect("self-authored clip"));
renderer
.add_clip_instance_playing(
clip,
DynSpriteTransform {
pos: [0.0, -60.0, 200.0],
..DynSpriteTransform::default()
},
1.0, // playback speed
0, // start phase, ms
)
.expect("clip just registered");
The player is fully scriptable: set_clip_instance_paused /
_speed / _frame (scrub) / _clock_ms, and
set_clip_instance_clip swaps which animation an instance plays —
that swap is how billboard actors change state below. For live
authoring there is update_clip_frame (rewrite one frame in place),
and for long cutscene-grade clips streaming clips
(add_streaming_clip) keep only the current frame resident instead
of the whole timeline.
Billboards: the Doom look
A billboard is a clip instance that auto-faces the camera — the Doom/Build sprite. Because it is still a voxel object (a flat slab of voxels), it casts and receives real shadows and takes materials, no special path:
// The same clip as a Doom-style billboard: a camera-facing
// instance. Cylindrical = yaw-only facing (stays vertical, the
// Build-engine look); Spherical also pitches with the view.
renderer
.add_billboard_instance(clip, [0.0, 60.0, 200.0], BillboardMode::Cylindrical)
.expect("clip just registered");
Cylindrical rotates about the vertical only (the classic look — and
its cast shadow stays a sane vertical card); Spherical also pitches
with the view. tick re-faces every billboard each frame.
Importing 2D art: with the gif / png cargo features,
gif_import / png_import turn an animated GIF or a PNG sequence
into a voxel clip — each frame a 1-voxel-thick cutout slab, palette
and timing preserved. That’s the whole asset pipeline for Doom-style
monsters: draw (or rip) sprite sheets, import, place.
Billboard actors (add_billboard_actor) are the high-level layer
for such monsters: a BillboardActorDef holds named states
(“walk”, “attack”, …), each with N-way directional clips (8-way
in the demo); every tick the renderer picks the clip from the
camera’s bearing versus the actor’s facing_yaw, faces it, and
advances its animation. Drive gameplay with set_actor_state /
set_actor_transform / set_actor_tint. Lighting per actor or
instance via BillboardLighting — FullBright for pickups and
projectiles that shouldn’t darken in shadow.
BillboardActorDef::scale sets world units per slab voxel (1.0 =
the classic 1:1) — the knob for a giant boss or a half-height imp
from the same sheet; it composes with the clip’s own
voxel_world_size and behaves identically on both backends. The
Doom demo scene is the worked example (it synthesises its GIFs
at startup, so there is no binary asset to squint at).
Characters (.rkc) and KFA rigs
For articulated models roxlap has two systems, both asset-driven — the book shows no code because the code is three calls; the substance is in the assets:
.rkccharacters — the modern container: meshes + a bone skeleton + animation clips, where a bone attachment is either a static mesh or a voxel clip (a flickering torch in a hand). The whole lifecycle is four calls:roxlap_formats::character::parse(&bytes)loads the container,add_character(&ch, clip)spawns one playing the chosen clip,tickadvances it every frame, andset_character_world_transformmoves it. Author them in Demiurg, the roxlap asset editor (chapter 11); the wire format isroxlap_formats::character, and the Animation demo scene is the loading example.- KFA rigs — Ken Silverman’s original animated-sprite format
(
.kfa), supported for asset compatibility: the host owns theKfaSprites and callsset_kfa_sprites/update_kfa_poses. See theroxlap-hostdemo. New projects should prefer.rkc.
Further reading
- Demo scenes: Sprites (instancing + transforms), Animation (clips + characters), Doom (billboards + actors).
PORTING-SPRITE-API.md,PORTING-VOXEL-CLIP.md,PORTING-BILLBOARD.md— the design histories (why clips are keyframe+delta, why a billboard is a 1-voxel slab and not a textured quad).