Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 BillboardLightingFullBright 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:

  • .rkc characters — 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, tick advances it every frame, and set_character_world_transform moves it. Author them in Demiurg, the roxlap asset editor (chapter 11); the wire format is roxlap_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 the KfaSprites and calls set_kfa_sprites / update_kfa_poses. See the roxlap-host demo. New projects should prefer .rkc.

Further reading