7. Performance and deploying
Rat Hunt, step 7: a quality menu over QUALITY_PRESETS, adaptive render resolution with makePerf, then a GitHub Actions workflow that publishes to Pages.
The game is finished; in this step you make it run well on slow machines and put it online. Voxolith's cost knobs live in the engine, so the game only has to offer them to the player and feed the adaptive controller.
Where the time goes
Every pixel of every frame walks rays through the voxels, so the cost is roughly pixels × steps per ray, plus a shadow ray per shadowed light. Three things control it:
| knob | what it changes | how |
|---|---|---|
| quality preset | steps per ray, shadow steps, ambient occlusion | renderer.setQuality("low" | "medium" | "high") |
| render scale | how many pixels are raymarched, under the device pixel ratio | gpu.renderScale, driven by makePerf |
| pixel ratio cap | the most pixels per CSS pixel on high-density screens | initGpu(canvas, { maxPixelRatio }) |
A quality menu
The #quality select from step 1 lists the three QUALITY_PRESETS.
Replace the setQuality line after createRenderer with a section that reads the player's
choice, remembers it, and starts a software adapter on Low:
import { makePerf, QUALITY_PRESETS, type QualityPreset } from "@voxolith/renderer";
// --- quality --------------------------------------------------------------------
const qualitySelect = document.getElementById("quality") as HTMLSelectElement;
const stored = (() => { try { return localStorage.getItem("rat-hunt-quality"); } catch { return null; } })();
let quality: QualityPreset = stored && stored in QUALITY_PRESETS ? (stored as QualityPreset) : gpu.software ? "low" : "medium";
renderer.setQuality(quality);
qualitySelect.value = quality;
qualitySelect.addEventListener("change", () => {
quality = qualitySelect.value as QualityPreset;
renderer.setQuality(quality);
try { localStorage.setItem("rat-hunt-quality", quality); } catch { /* private mode */ }
});
const warning = gpu.software ? " · software WebGPU adapter, expect a low frame rate" : "";gpu.software is true when the browser gave you a CPU implementation of WebGPU (SwiftShader,
llvmpipe and the like): it works, but slowly, so the game says so and starts cheap. The storage
calls are wrapped in try because private windows can refuse them. Add ${warning} to the end
of the status line so the player sees it.
Changing the preset takes effect on the next frame; nothing is rebuilt.
Adaptive resolution
makePerf watches frame times and lowers the
render scale when frames run long, raising it again when there is room:
const perf = makePerf({ enabled: params.has("perf"), scale: gpu.renderScale, minScale: gpu.software ? 0.25 : 0.4, retryAfterMs: Infinity });In the frame, before resizing, feed it and apply its scale:
if (moved || input.active()) perf.reprobe();
perf.frame(now);
gpu.renderScale = perf.scale();
resizeToDisplay(gpu);and keep whether the player moved:
const moved = !over && movePlayer(dt);Why retryAfterMs: Infinity and reprobe: under vsync, a frame that just fits and a frame with
time to spare both read as 16.7 ms, so the controller can only find the limit by trying a higher
scale and seeing it miss. Each try resamples the image, which is visible when the view is still.
This game renders every frame (the rats and the water move), so it tells the controller never to
retry on its own, and calls perf.reprobe() only while the player is moving or looking, when a
change of resolution goes unnoticed. ?perf on the URL shows the controller's overlay.
initGpu(canvas, { maxPixelRatio: 1.5 }) at the top caps phones with 3x screens at 1.5 device
pixels per CSS pixel, which alone can halve the pixel count.
See Performance for the rest: step caps, shadow steps, instances, and how the examples size their worlds for phones.
Build
bun run --cwd rat-hunt build
bun run --cwd rat-hunt previewbuild type-checks and writes a static site to rat-hunt/dist/. preview serves it on
http://localhost:4173, which is a secure context, so WebGPU works there without HTTPS.
Deploy to GitHub Pages
GitHub Pages serves a repository rat-hunt at https://<you>.github.io/rat-hunt/, so the build
has to know it lives under /rat-hunt/. The vite.config.ts from step 1 already reads that from
BASE_PATH, and the game only ever fetches through import.meta.env.BASE_URL, so nothing in the
code changes:
BASE_PATH=/rat-hunt/ bun run buildPut rat-hunt/ in a repository of its own and add a workflow. Until the Voxolith packages are on
npm, it recreates the workspace in CI: it checks out your game and the three library repos side by
side, writes a workspace package.json, installs and builds.
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
path: app
- uses: actions/checkout@v4
with:
repository: voxolith/renderer
path: renderer
- uses: actions/checkout@v4
with:
repository: voxolith/engine
path: engine
- uses: actions/checkout@v4
with:
repository: voxolith/generators
path: generators
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Create workspace root
run: |
cat > package.json <<'JSON'
{ "name": "pages-workspace", "private": true, "workspaces": ["app", "renderer", "engine", "generators/*"] }
JSON
- run: bun install
- name: Build
working-directory: app
env:
BASE_PATH: /${{ github.event.repository.name }}/
run: bun run build
- uses: actions/upload-pages-artifact@v3
with:
path: app/dist
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4This is the workflow the Voxolith examples deploy with. In the repository's settings, under
Pages, set the source to GitHub Actions. Push to main, and the game appears at
https://<you>.github.io/rat-hunt/ a minute or two later. GitHub Pages is HTTPS, so it works on
phones as it is.
Once the packages are published, replace the four checkouts and the workspace root with a plain
checkout, change the workspace:* dependencies to version ranges, and run bun install in the
repository itself. Deploying covers other hosts and installable PWAs.
Where next
You have used most of Voxolith's layers once. From here:
- The manual takes each topic further: streaming bigger worlds, instances for thousands of models, water, weather and effects.
- The examples are complete programs to read: the world is a whole generated village, the valley the same one at five or ten times the resolution.
- The generators section lists every parameter and preset.
6. Rats
Rat Hunt, step 6: rigged, animated rats from gen-creature, moved with makeCrowd and makeBrickStamper, that flee from you and are caught with a tap.
Manual
Task-by-task guide to building a game on Voxolith, from storing a world and placing models to input, lighting, weather, performance and deployment.