Skip to main content

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​

  1. EngineCanvas renders a Canvas from react-native-webgpu (Dawn on the phones) and measures it.
  2. Once it has a size, it sets the backing size to layout size times PixelRatio.get() and creates an R3F root with createRoot from @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.
  3. The root's gl is an async factory that calls makeWebGPURenderer({ context }) and awaits renderer.init(), so R3F waits for the GPU before the first frame.
  4. The renderer's render is 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 calls context.present() so the frame reaches the screen.
  5. New children are 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:

PriorityWho
-100The debug frame timer (development only)
-20GameFeel: tweens and the shake
-15Path followers and moving platforms
-11Vehicle input
-10Physics steps the world
0Your 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​

PropTypeDefaultWhat it does
childrenReact.ReactNoderequiredThe 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
backgroundstringnoneA flat scene background colour. Environment draws a sky, so you rarely need it
styleViewProps['style']flex: 1Style of the wrapping view
onReady() => voidnoneCalled once the renderer is ready and the first frame can be drawn
testIDstringnoneOn the wrapping view
debugboolean, 'top-left', 'top-right' or 'bottom-left'offShows 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 }} />
PropTypeDefaultWhat it does
preset'day', 'sunset', 'night', 'overcast', 'neon', 'space''day'The starting config
followa ref to an Object3D, or () => ({ x, y, z })the originWhat the shadow area follows (the player, the car)
skypartial SkyConfigfrom presettype ('procedural', 'gradient', 'color'), turbidity, rayleigh, mieCoefficient, mieDirectionalG, cloudCoverage, cloudDensity, sunDisc, top, bottom, stars
sunpartial SunConfigfrom presetelevation 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
fillpartial FillConfigfrom presetHemisphere sky and ground colours, intensity, flat ambient and ambientColor
fogpartial FogConfiglinear, near 60, far 260type ('linear', 'exp', 'none'), color (a colour or 'sky'), near, far, density
toneMapping'aces', 'agx', 'neutral', 'none''aces'Renderer tone mapping
exposurenumber0.5 (day)Renderer exposure
environmentLightbooleantrueLight PBR materials with the sky
environmentIntensitynumber0.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.

PropTypeDefaultWhat it does
preset'clean', 'glow', 'cinematic', 'retro''glow'The starting config. clean turns bloom and vignette off
enabledbooleantrueTurn the whole chain off at runtime (a setting for older phones)
bloom{ enabled, strength, radius, threshold }true, 0.45, 0.5, 0.85Bright 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.95Darker corners
grade{ saturation, contrast, lift, tint }1.05, 1.03, 0, [1, 1, 1]Colour grade
ssaobooleanfalseGround-truth ambient occlusion (GTAO). Costly: high-end phones only
resolutionScalenumber0.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.

PropTypeDefaultWhat it does
childrenReact.ReactNoderequiredWhat it protects
labelstring'Engine'Shown as "label unavailable" and in the console warning
fallbackReact.ReactNodea small error cardWhat to show instead. Pass null to show nothing

Game types (core/)​

GameDefinition is what src/game/index.tsx default-exports:

FieldTypeWhat it is
idstringURL-safe id
titlestringThe game's name
descriptionstringOne line about the game
badgestring, optionalA short label (not shown by the app)
accentstring, optionalAn accent colour (not shown by the app)
ComponentComponentType<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. Environment sets this for you. Do not change renderer.shadowMap.type.
  • Keep per-frame values out of React state. Read and write them in useFrame on refs and mutable objects; a setState per frame re-renders the scene tree.
  • Phones are the budget. shadowMapSize: 1024 is the phone default, ssao is off by default, PostFX renders 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 / onPointerOut while pressed, and onPointerMissed. EngineCanvas installs 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's TownGestures) 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, setRenderOverride in render/frameHooks.ts) are internal: they are not exported from @/engine. Game feel, the debug probe and PostFX use them.
  • Water (rivers, ponds, a sea) also lives in render/; it is documented with the other TypeGPU effects in GPU effects.