Canvas and rendering
EngineCanvas is the engine's 3D surface. It puts a WebGPU canvas on screen, creates a three.js WebGPURenderer on it and runs a react-three-fiber (R3F) root inside, so everything you place in it uses the normal R3F API: JSX meshes, useFrame, useThree. Use it for any 3D game. A flat game uses GameCanvas2D instead (The 2D layer).
The code lives in src/engine/render/ and src/engine/core/.
Smallest working example
A spinning box in the sun, with the engine's sky, shadows and bloom:
import React, { useRef } from 'react';
import { View } from 'react-native';
import { useFrame } from '@react-three/fiber';
import type * as THREE from 'three';
import { EngineCanvas, EngineErrorBoundary, Environment, PostFX, type GameDefinition, type GameScreenProps } from '@/engine';
const SpinningBox = () => {
const mesh = useRef<THREE.Mesh>(null);
useFrame((_, delta) => {
if (mesh.current) mesh.current.rotation.y += delta;
});
return (
<mesh ref={mesh} position={[0, 1, 0]} castShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#E8ECF4" />
</mesh>
);
};
const Screen = (_props: GameScreenProps) => (
<View style={{ flex: 1 }}>
<EngineErrorBoundary label="Renderer">
<EngineCanvas camera={{ position: [0, 3, 6], fov: 50 }}>
<Environment preset="sunset" />
<PostFX preset="glow" />
<SpinningBox />
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[40, 40]} />
<meshStandardMaterial color="#3A4A3A" />
</mesh>
</EngineCanvas>
</EngineErrorBoundary>
</View>
);
const game: GameDefinition = { id: 'spin', title: 'Spin', description: 'A box that spins.', Component: Screen };
export default game;
Put HUD and controls next to the canvas as React Native views (absolutely positioned on top), not inside it. Everything inside EngineCanvas is the 3D scene.
How it works
EngineCanvasrenders aCanvasfromreact-native-webgpu(Dawn on the phones) and measures it.- Once it has a size, it sets the backing size to layout size times
PixelRatio.get()and creates an R3F root withcreateRootfrom@react-three/fiber, once. A later size change (a browser window, split screen, a foldable, rotation) reconfigures the same root with the new size: R3F resizes the renderer and the camera's aspect, and the renderer, scene and camera are kept. - The root's
glis an async factory that callsmakeWebGPURenderer({ context })and awaitsrenderer.init(), so R3F waits for the GPU before the first frame. - The renderer's
renderis wrapped: it runs the engine's before-render hooks (the camera shake, the debug probe), draws (or runs the post-processing override), runs the after-render hooks, then callscontext.present()so the frame reaches the screen. - New
childrenare pushed into the existing root. The root is rebuilt only when the canvas size changes.
The frame loop is R3F's own (frameloop: 'always'). Every useFrame callback runs once per frame, in priority order (lower first). The engine uses a few fixed priorities so systems run in a sensible order:
| Priority | Who |
|---|---|
-100 | The debug frame timer (development only) |
-20 | GameFeel: tweens and the shake |
-15 | Path followers and moving platforms |
-11 | Vehicle input |
-10 | Physics steps the world |
0 | Your useFrame callbacks (the default) |
When GameFeel is mounted, the delta every useFrame receives is scaled by slow motion and hit-stop (Game feel).
Why three is the WebGPU build
The template's metro.config.js resolves three and three/webgpu to three/build/three.webgpu.js, three/tsl to three/build/three.tsl.js, and three/addons/* to three/examples/jsm/*. There is exactly one three.js instance in the bundle, and WebGPURenderer and the node materials are always available. Import the renderer and TSL from their entry points:
import { WebGPURenderer, MeshStandardNodeMaterial } from 'three/webgpu';
import { uniform, positionWorld } from 'three/tsl';
The same config points @react-three/fiber at its web build (dist/react-three-fiber.esm.js): the package's React Native entry targets expo-gl, which the engine does not use. EngineCanvas drives the reconciler itself. It also calls extend(THREE), so every three class is a JSX element.
There is no WebGL fallback on any platform. A browser without WebGPU gets a message instead of the game (Platform layer).
EngineCanvas
| Prop | Type | Default | What it does |
|---|---|---|---|
children | React.ReactNode | required | The R3F scene |
camera | { position?, fov?, near?, far? } | fov: 55, near: 0.1, far: 600, position: [0, 4, 10] | The default perspective camera. Your values are merged over the defaults |
background | string | none | A flat scene background colour. Environment draws a sky, so you rarely need it |
style | ViewProps['style'] | flex: 1 | Style of the wrapping view |
onReady | () => void | none | Called once the renderer is ready and the first frame can be drawn |
testID | string | none | On the wrapping view |
debug | boolean, 'top-left', 'top-right' or 'bottom-left' | off | Shows the development readout over the canvas. Never shown in production builds (Utilities) |
Use one EngineCanvas per screen. To restart a level from scratch, change its key: the whole scene remounts.
Paused when off screen
EngineCanvas listens to onShellActive (Platform layer). While the game is not on screen it sets the frame loop to 'never' (no useFrame, no physics, no particles) and redraws the loaded scene twice a second. When the game comes back, the clock skips the paused time. Outside the Moonspell app the game is always active, so this never triggers.
Environment
The scene's light and air in one component: a sky (three's procedural SkyMesh with clouds, a gradient dome with stars, or a flat colour), a sun with shadows that follow a target, hemisphere and ambient fill, fog, tone mapping and exposure on the renderer, and an environment map of the sky for PBR materials. Mount it once inside the canvas and do not add your own lights or <fog> for the same job.
<Environment preset="night" follow={() => body.current} fill={{ ground: '#2A2A3A' }} fog={{ near: 30, far: 90 }} />
| Prop | Type | Default | What it does |
|---|---|---|---|
preset | 'day', 'sunset', 'night', 'overcast', 'neon', 'space' | 'day' | The starting config |
follow | a ref to an Object3D, or () => ({ x, y, z }) | the origin | What the shadow area follows (the player, the car) |
sky | partial SkyConfig | from preset | type ('procedural', 'gradient', 'color'), turbidity, rayleigh, mieCoefficient, mieDirectionalG, cloudCoverage, cloudDensity, sunDisc, top, bottom, stars |
sun | partial SunConfig | from preset | elevation and azimuth (degrees), intensity, color, shadows, shadowSize (half-extent in metres, 28), shadowMapSize (1024 or 2048), shadowBias, shadowNormalBias, shadowRadius, shadowStyle ('soft' or 'crisp'), shadowIntensity (0 to 1), distance |
fill | partial FillConfig | from preset | Hemisphere sky and ground colours, intensity, flat ambient and ambientColor |
fog | partial FogConfig | linear, near 60, far 260 | type ('linear', 'exp', 'none'), color (a colour or 'sky'), near, far, density |
toneMapping | 'aces', 'agx', 'neutral', 'none' | 'aces' | Renderer tone mapping |
exposure | number | 0.5 (day) | Renderer exposure |
environmentLight | boolean | true | Light PBR materials with the sky |
environmentIntensity | number | 0.3 (day) | Strength of that light |
Every override merges deeply over the preset. Helpers exported with it: resolveEnvironment(preset, overrides) returns the full EnvironmentConfig, ENVIRONMENT_PRESETS holds the preset overrides, sunDirection(elevation, azimuth) returns the unit vector toward the sun, and presetForTimeOfDay('day' | 'sunset' | 'night') maps the racing module's time of day.
For hard shadows that read as a shape (painted or inked looks), use sun={{ shadowStyle: 'crisp', shadowMapSize: 2048, shadowSize: 15 }}. Keep the area small: sharp edges show the shadow map's resolution.
PostFX
Post-processing on three's WebGPU RenderPipeline: the scene renders at resolutionScale, then bloom, a colour grade and a vignette. Tone mapping and colour space are applied at the end from the renderer's settings. The HUD is React Native views and is never touched. Mount it once inside the canvas.
| Prop | Type | Default | What it does |
|---|---|---|---|
preset | 'clean', 'glow', 'cinematic', 'retro' | 'glow' | The starting config. clean turns bloom and vignette off |
enabled | boolean | true | Turn the whole chain off at runtime (a setting for older phones) |
bloom | { enabled, strength, radius, threshold } | true, 0.45, 0.5, 0.85 | Bright pixels glow. The threshold is divided by the exposure each frame, so it means brightness on screen |
vignette | { enabled, strength, inner, outer } | true, 0.35, 0.45, 0.95 | Darker corners |
grade | { saturation, contrast, lift, tint } | 1.05, 1.03, 0, [1, 1, 1] | Colour grade |
ssao | boolean | false | Ground-truth ambient occlusion (GTAO). Costly: high-end phones only |
resolutionScale | number | 0.75 (retro: 0.5) | The scene renders at this fraction of the canvas and is upscaled. Clamped to 0.25 to 1 |
Defaults above are the base before the preset applies (glow raises bloom to 0.7, 0.6, 0.8). resolvePostFX(preset, overrides) and POSTFX_PRESETS are exported. When the config changes nothing (no bloom, no vignette, neutral grade, full scale), the component renders plainly and costs nothing. If the chain fails to build or a frame throws, it logs [engine/postfx] and falls back to plain rendering.
glow(material, color?, strength = 1.5)
Makes a standard or physical material emissive so it blooms: color sets the emissive colour (default: the material's own colour), strength the intensity (1 bright, 3 neon). Takes one material or an array.
import { MeshStandardMaterial } from 'three';
import { glow } from '@/engine';
const lampMaterial = new MeshStandardMaterial({ color: '#FFF4E0' });
glow(lampMaterial, '#FFD27A', 2);
EngineErrorBoundary
A React error boundary for GPU failures (no adapter, a shader that does not compile). Wrap the canvas in it so one failure does not take down the whole screen.
| Prop | Type | Default | What it does |
|---|---|---|---|
children | React.ReactNode | required | What it protects |
label | string | 'Engine' | Shown as "label unavailable" and in the console warning |
fallback | React.ReactNode | a small error card | What to show instead. Pass null to show nothing |
Game types (core/)
GameDefinition is what src/game/index.tsx default-exports:
| Field | Type | What it is |
|---|---|---|
id | string | URL-safe id |
title | string | The game's name |
description | string | One line about the game |
badge | string, optional | A short label (not shown by the app) |
accent | string, optional | An accent colour (not shown by the app) |
Component | ComponentType<GameScreenProps> | The full-screen game |
GameScreenProps is { onExit: () => void }. Call onExit from your exit or pause button. Module factories such as defineRaceGame return a GameDefinition for you.
Renderer helpers
makeWebGPURenderer({ context, device?, antialias = true }) creates the WebGPURenderer for a GPUCanvasContext, and disposeWebGPURenderer(renderer) tears one down (stops three's animation loop, disposes, and clears shared listeners so the GPU memory can be reclaimed). EngineCanvas calls both. You only need them if you build your own canvas.
Gotchas
- Shadows always use VSM: the other shadow filters use a comparison sampler that loses the GPU device on react-native-webgpu's Metal backend.
Environmentsets this for you. Do not changerenderer.shadowMap.type. - Keep per-frame values out of React state. Read and write them in
useFrameon refs and mutable objects; asetStateper frame re-renders the scene tree. - Phones are the budget.
shadowMapSize: 1024is the phone default,ssaois off by default,PostFXrenders at 0.75 scale. Use<EngineCanvas debug>in development to watch fps and draw calls. - Mesh pointer events work on web, iOS and Android:
onClick,onPointerDown,onPointerUp,onPointerMove,onPointerOver/onPointerOutwhile pressed, andonPointerMissed.EngineCanvasinstalls R3F's event manager without its DOM listeners and feeds it from its own view's touch responder (render/pointerFeed.ts), the same code on every platform. A touch reaches a mesh only where nothing above the canvas takes it: HUD buttons, touch controls and gesture layers (for example the Kingdom town'sTownGestures) keep their touches. The canvas only takes touches while the scene has a mesh with a pointer handler, so a game without any handles touches exactly as before. One finger at a time; there is no hover without a press. Verified in Chrome; not run on a phone. - A size change resizes the renderer instead of rebuilding it, so resizing is cheap, but do not put the canvas inside a view whose size animates every frame.
- The engine's render hooks (
onBeforeRender,onAfterRender,setRenderOverrideinrender/frameHooks.ts) are internal: they are not exported from@/engine. Game feel, the debug probe andPostFXuse them. Water(rivers, ponds, a sea) also lives inrender/; it is documented with the other TypeGPU effects in GPU effects.