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:
tick(&camera, dt)— advances every facade-owned animation (clips, characters, billboard actors) in one call. Only needed once you use those (chapter 7).render(&mut scene, &camera, &frame)— composites the scene into the backend’s frame buffer. Does not present.- Overlays (optional) —
draw_lines/draw_imagesdraw into the composited frame, using its camera and depth buffer. - Exactly one of
present()orpaint_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
- The render pipeline — the fixed-resolution / SSAA / posterize post stack this chapter’s example already enables.
- Lighting & materials —
FrameParams::lightsand the material palette. - Picking & world queries —
pick,view_ray,pick_depth(also facade methods, same per-frame world).