voxolith

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:

knobwhat it changeshow
quality presetsteps per ray, shadow steps, ambient occlusionrenderer.setQuality("low" | "medium" | "high")
render scalehow many pixels are raymarched, under the device pixel ratiogpu.renderScale, driven by makePerf
pixel ratio capthe most pixels per CSS pixel on high-density screensinitGpu(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 preview

build 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 build

Put 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.

.github/workflows/pages.yml
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@v4

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

On this page