# Canvas and rendering

> EngineCanvas, the three.js WebGPU renderer it drives, how react-three-fiber runs inside it, the frame loop, Environment for sky and light, PostFX, glow and the error boundary.

`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](https://docs.moonspell.app/engine/2d.md)).

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:

```tsx
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:

| 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](https://docs.moonspell.app/engine/game-feel.md)).

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

```ts
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](https://docs.moonspell.app/engine/platform-layer.md)).

## `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](https://docs.moonspell.app/engine/utilities.md)) |

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](https://docs.moonspell.app/engine/platform-layer.md)). 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.

```tsx
<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.

```ts
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. `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](https://docs.moonspell.app/engine/gpu-effects.md).
