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

Rendering & backends

Your game draws through one type: SceneRenderer, the facade over roxlap’s two renderers. You build a Scene, advance your game, and call the same four or five methods every frame — which backend does the marching is a construction-time choice, not an architectural one.

The snippets in this chapter and the next come from a runnable example — a foggy pillar avenue rendered through the full retro pipeline with an overlay gizmo:

cargo run --release -p roxlap-render --example book_pipeline

The two backends

Both are per-pixel voxel ray-marchers with the same retro look, and both derive their projection from the same FrameParams, so a scene frames identically on either:

  • CPU — a clean-room per-pixel 3D-DDA over a brickmap, rayon- parallel across row strips, presented via a software framebuffer (softbuffer on native, a WebGL2 blit on wasm). Zero GPU requirements: it runs in a VM, over remote desktop, on a machine with broken drivers. Design history: PORTING-DDA.md.
  • GPU — a WGPU/WGSL compute-shader marcher (two-level chunk + voxel DDA with chunk-occupancy skip and distance-based mip LOD), presented via a wgpu swapchain. Same image, several times the frame rate, and the CPU budget goes back to your game. Empty space costs next to nothing: rays cross provably-empty regions via a per-grid occupancy pyramid (< 40 B/grid, maintained live on edits), so sparse worlds — a floating ship over distant terrain — don’t pay per-chunk for the air between (measured −30% frame time on empty-gap-dominated views; byte-stable by construction). Design history: PORTING-GPU.md.

The facade keeps them in lockstep: scene edits, sprites, materials and lighting are tracked once and pushed to whichever backend is live.

Choosing a backend

RenderOptions::backend takes a BackendPreference:

  • Cpu — the software renderer, unconditionally (the default).
  • PreferGpu — try WGPU; on failure, fall back to the CPU renderer with a warning through the [log] facade. The right choice for games: one binary runs everywhere.
  • RequireGpu — GPU or an error. Use it when a silent software fallback would lie to you: benchmark rigs, GPU CI.
        // PreferGpu tries WGPU and falls back to the CPU renderer with
        // a `log::warn` when init fails; RequireGpu turns that failure
        // into an error instead (benchmark rigs must not silently
        // measure a software render). Plain Cpu never touches WGPU.
        let opts = RenderOptions {
            backend: if want_gpu {
                BackendPreference::PreferGpu
            } else {
                BackendPreference::Cpu
            },
            clear_sky: SKY,
            ..RenderOptions::default()
        };
        let mut renderer =
            match SceneRenderer::try_new(window.clone(), (size.width, size.height), &opts) {
                Ok(r) => r,
                Err(e) => {
                    // Even the last-resort CPU software surface failed —
                    // there is nothing to draw on.
                    eprintln!("cannot create a render surface: {e}");
                    event_loop.exit();
                    return;
                }
            };

SceneRenderer::new is the panicking convenience form of try_new (the quickstart uses it); real games call try_new and show their own error UI. Diagnostics — including why a machine fell back to software rendering — go through log, so install a logger (env_logger in the examples) or that warning is invisible.

The window parameter is anything raw-window-handle in an Arc — winit, SDL, GLFW, your own. On wasm, construct with new_from_canvas_async over an HTML canvas instead (WebGPU, falling back to the CPU path presented through WebGL2 — chapter 12).

Capability parity: supports()

A few features exist on one backend only — sky panoramas and sprite carving are GPU-only, free per-frame depth picks are CPU-only, and so on. The methods involved stay callable everywhere and degrade to documented no-ops (or documented costs); supports(Feature::..) is the queryable form of that parity table, so you can pick a strategy at startup instead of discovering a no-op visually:

        // Backend capabilities differ below the parity line; methods on
        // the unsupported side degrade to documented no-ops. Query once
        // at startup and pick a strategy — don't guess per frame.
        match renderer.backend() {
            Backend::Gpu => log::info!(
                "GPU backend: {}",
                renderer.adapter_info().unwrap_or("unknown adapter")
            ),
            Backend::Cpu => log::info!("CPU backend (software per-pixel DDA)"),
        }
        if !renderer.supports(Feature::FreePickDepth) {
            // GPU depth reads block on a device poll: fine per click,
            // wrong per frame — so pick a reticle strategy up front.
            log::info!("per-frame depth picks are expensive here; use view_ray + raycast");
        }

The authoritative feature-by-feature table lives on the Feature enum’s rustdoc — the book deliberately doesn’t copy it (it changes as parity gaps close).

The frame protocol

One frame, in order:

  1. tick(&camera, dt) — advances every facade-owned animation (clips, characters, billboard actors) in one call. Only needed once you use those (chapter 7).
  2. render(&mut scene, &camera, &frame) — composites the scene into the backend’s frame buffer. Does not present.
  3. Overlays (optional) — draw_lines / draw_images draw into the composited frame, using its camera and depth buffer.
  4. Exactly one of present() or paint_egui(..) — finishes and shows the frame.

At shutdown, call wait_idle() before the window is torn down — otherwise the GPU backend’s in-flight work can leave the compositor showing stale buffers (the “leftover triangles on exit” symptom).

FrameParams

The per-frame parameter block. It is #[non_exhaustive] — always construct with FrameParams::new(&settings) and override fields, so engine upgrades that add parameters don’t break your build:

                // FrameParams::new gives working defaults for everything
                // but `settings`; override what differs. Both backends
                // derive their projection from `settings`, so CPU and
                // GPU show the same field of view.
                let settings =
                    OpticastSettings::for_oracle_framebuffer(size.width.max(1), size.height.max(1));
                let mut frame = FrameParams::new(&settings);
                frame.sky_color = SKY;
                // CPU fog: distant voxels fade into the sky colour,
                // fully fogged at 700 voxels (0 = fog off).
                frame.fog_color = SKY;
                frame.fog_max_scan_dist = 700;

What lives here: the shared OpticastSettings (framebuffer geometry, projection, scan distances — both backends’ field of view is derived from it, 2·atan(yres/2 / hz)), sky colour and optional sky, CPU fog (colour + full-fog distance), per-face side_shades, the lights rig (chapter 6), and the draw_sprites switch. Settings are cheap to rebuild per frame from the current window size.

Overlay lines

draw_lines renders world-space segments over the frame — editor gizmos, debug paths, hover wireframes. Note the colour type: Line3.color is an OverlayColor — the one packing with a real alpha byte (chapter 2’s colour family). Depth-tested lines are occluded by nearer voxels; non-depth-tested ones draw on top (hover highlights).

/// The 12 edges of an axis-aligned box as depth-tested overlay lines
/// — the shape of most editor gizmos.
fn wire_box(lo: [f64; 3], hi: [f64; 3], color: OverlayColor) -> Vec<Line3> {
    let p = [
        [lo[0], lo[1], lo[2]],
        [hi[0], lo[1], lo[2]],
        [lo[0], hi[1], lo[2]],
        [hi[0], hi[1], lo[2]],
        [lo[0], lo[1], hi[2]],
        [hi[0], lo[1], hi[2]],
        [lo[0], hi[1], hi[2]],
        [hi[0], hi[1], hi[2]],
    ];
    const EDGES: [(usize, usize); 12] = [
        (0, 1),
        (1, 3),
        (3, 2),
        (2, 0), // z = lo face (the top — +z is down)
        (4, 5),
        (5, 7),
        (7, 6),
        (6, 4), // z = hi face (the bottom)
        (0, 4),
        (1, 5),
        (2, 6),
        (3, 7), // vertical edges
    ];
    EDGES
        .map(|(a, b)| Line3 {
            a: p[a],
            b: p[b],
            color, // 0xAARRGGBB — high byte is alpha here, not shading
            width_px: 1.5,
            depth_test: true, // occluded by nearer voxels
        })
        .to_vec()
}
                renderer.render(scene, &camera, &frame);
                // Overlays go between render and present: they draw into
                // the composited frame using its camera, projection and
                // depth buffer — here a gizmo box around the dome.
                let gizmo = wire_box(
                    [-32.0, -32.0, 173.0],
                    [32.0, 32.0, 237.0],
                    OverlayColor(0xff_ff_d0_40),
                );
                renderer.draw_lines(&camera, &gizmo);
                // Exactly one of present() / paint_egui(..) finishes it.
                renderer.present();

For textured quads (world-fixed or billboarded) there is the upload_image / draw_images pair — same slot in the protocol, see the docs.rs entries.

Where next