You're viewing the v10 deployment.

Multi-Canvas

Share a single WebGPURenderer across multiple Canvas components for HUD overlays, picture-in-picture, and multi-viewport rendering.

v10 supports sharing a single WebGPURenderer across multiple Canvas components. This enables HUD overlays, picture-in-picture views, and multi-viewport rendering without the overhead of multiple GPU contexts.

import { Canvas, useFrame, useThree } from '@react-three/fiber'

Renderer sharing is WebGPU-only, which every Canvas from @react-three/fiber is (and from the deprecated @react-three/fiber/webgpu). @react-three/fiber/legacy has no primary or share.

Basic Setup

Mark the canvas that owns the renderer primary. Every other canvas shares its renderer automatically:

<Canvas primary renderer={{ shadows: 'soft', toneMapping: THREE.AgXToneMapping }}>
  <Scene />
</Canvas>

<Canvas>
  <SecondaryScene />
</Canvas>

The primary creates the WebGPURenderer; each other canvas borrows it and draws into its own <canvas> element through a three.js CanvasTarget. Renderer-wide settings (everything in renderer: shadows, tone mapping, output color space, constructor options such as antialias) belong on the primary.

With no canvas marked primary, nothing changes: every canvas creates its own renderer, as without multi-canvas.

Who Owns, Who Shares

When a canvas creates its renderer, it shares a primary's if:

  • it names one with share="id", or
  • exactly one <Canvas primary> is mounted (or mounting in the same commit), and the canvas did not opt out.

A canvas owns its renderer instead when:

  • it is the primary
  • it passes share={false}
  • it passes its own renderer instance or factory (renderer={myRenderer}, renderer={(defaults) => ...})
  • it uses the gl prop (WebGL can't share)
  • no primary is mounted

A renderer props bag does not opt out: a canvas sharing the primary's renderer ignores its bag (and warns once in development, "renderer settings on a sharing canvas are ignored; set them on the <Canvas primary>"), because settings on a shared renderer would change every canvas drawing with it. Pass share={false} if that canvas should have its own renderer with those settings. textureColorSpace is the one exception: it is per-canvas texture-loading state, not a renderer setting, so a sharing canvas keeps its own.

primary and share are read when the canvas creates its renderer. Changing them on a mounted canvas has no effect until it remounts.

Several primaries

Give each primary an id, and point the other canvases at one with share:

<Canvas id="left" primary>...</Canvas>
<Canvas id="right" primary>...</Canvas>

<Canvas share="left">...</Canvas>
<Canvas share="right">...</Canvas>

Automatic sharing only works with a single primary: with several mounted, a canvas without share throws, asking you to pick one. Two canvases marked primary without distinct ids throw too; the first keeps the renderer.

share="id" waits for that primary to mount, so it can appear anywhere in the tree or in a later commit. It times out after 5 seconds with an error naming the missing <Canvas id="..." primary>.

Timing

Sharing is decided once, when a canvas creates its renderer, so it matters what is mounted by then:

  • Same commit, any order. A primary announces itself as soon as it mounts, before any canvas in that commit creates a renderer. A canvas placed before its primary in the tree still waits for it and shares.
  • Primary first. A canvas mounting later shares it straight away.
  • Primary later. Canvases that already created their own renderer keep it: they are not adopted after the fact. The primary logs one development warning when this happens. Mount the primary with or before the canvases that share it, or give them share="id" so they wait for it.

StrictMode, Fast Refresh (of your components or of R3F itself) and HMR keep the same renderer and the same sharing; they don't create a second renderer or leave canvases waiting.

When the primary unmounts first, its sharing canvases keep rendering: each holds a lease on the renderer, which is disposed with the last canvas using it. A remounted primary creates a new renderer; canvases that were sharing the old one keep it until they remount, and canvases mounting afterwards share the new one.

Scheduling

A sharing canvas renders after its primary by default. Order it yourself, or cap its frame rate, with the scheduler Canvas prop:

// Limit to 30fps (useful for HUDs that don't need full framerate); still after the primary
<Canvas scheduler={{ fps: 30 }}>

// Render before the primary instead
<Canvas scheduler={{ before: 'main' }}>

before / after take Canvas ids and replace the default ordering. scheduler is a Canvas prop on every entry, not only for multi-canvas; see Frame Loop.

OptionTypeDescription
afterstring | string[]Render after other canvases (by id)
beforestring | string[]Render before other canvases (by id)
ordernumberNumeric root order; lower runs first
fpsnumberLimit this canvas's default render rate

Accessing the Primary Scene (HUD Pattern)

A secondary canvas can render the primary's scene from a different viewpoint using primaryStore from R3F state. This is the basis for HUDs and picture-in-picture.

import { Canvas, useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import * as THREE from 'three/webgpu'

function HudScene() {
  const orthoCamera = useRef<THREE.OrthographicCamera>(null!)

  useEffect(() => {
    const cam = new THREE.OrthographicCamera(-3, 3, 3, -3, 0.1, 100)
    cam.position.set(0, 10, 0) // Above the scene
    cam.lookAt(0, 0, 0)
    cam.updateProjectionMatrix()
    orthoCamera.current = cam
  }, [])

  // Take over rendering to draw the primary's scene with our own camera
  useFrame(
    ({ primaryStore, renderer }) => {
      if (!orthoCamera.current) return
      const primaryState = primaryStore.getState()
      renderer.render(primaryState.scene, orthoCamera.current)
    },
    { phase: 'render' },
  )

  return null
}

function App() {
  return (
    <>
      {/* Main 3D scene: owns the renderer */}
      <Canvas id="main" primary>
        <ambientLight />
        <mesh>
          <boxGeometry />
          <meshStandardMaterial color="orange" />
        </mesh>
      </Canvas>

      {/* HUD overlay - top-down view of the same scene, sharing the renderer */}
      <div style={{ position: 'absolute', top: 10, left: 10, width: 200, height: 200 }}>
        <Canvas scheduler={{ fps: 30 }}>
          <HudScene />
        </Canvas>
      </div>
    </>
  )
}

How It Works

Each canvas maintains its own scene graph and camera, event handling, and state (its own Zustand store).

Secondary canvases share the WebGPURenderer instance and its GPU resources/context. Renderer-wide settings come from the primary, which owns the renderer: a secondary sharing it does not apply its own, so it cannot reset the primary's.

The primaryStore on each canvas's state provides access to the primary's scene/camera, enabling advanced patterns like HUDs. TSL resources (uniforms, nodes, buffers, GPU storage) are shared automatically: they live on the primary's RootState, each secondary holds the same map objects, and portals inherit them from their parent, so state.uniforms (and the @react-three/tsl hooks) read the same set on every canvas and portal.

R3F automatically manages CanvasTarget switching across canvases. A job in the scheduler's start phase sets the correct canvas target before any other jobs run for that root, so user render callbacks render to the right canvas automatically.

WebGL2 Fallback

When navigator.gpu is unavailable, three's WebGPURenderer silently falls back to its WebGL2 backend. A WebGL context is bound to the canvas element it was created on, so a renderer cannot be shared across canvases in that environment. A secondary whose primary fell back therefore creates its own renderer, forced onto the same WebGL2 backend (forceWebGL), and renders independently: scenes stay correct, at the cost of one GPU context per canvas instead of one shared.

Such a fallback secondary owns its renderer, so it is a standalone root: it is not flagged internal.isSecondary (which marks a canvas borrowing another's renderer), it applies its own renderer settings (shadows included), and unmounting it disposes the renderer R3F built for it, whether or not the primary is still mounted. It does not inherit the primary's settings: set them on it too if they should match. internal.sharedRendererFallback records the fallback. GPU resources cannot cross GL contexts, so TSL resources stay local (primaryStore self-references, so the HUD pattern above sees the secondary's own scene), and a fallback secondary is never registered as a shareable primary for further canvases.

API Reference

Canvas props:

PropTypeDescription
primarybooleanOwn the renderer other canvases share. Not on /legacy
idstringCanvas id; the name share="id" and scheduler before/after refer to
sharestring | false"id": share that primary's renderer. false: own a renderer even while a primary is mounted
scheduler{ before, after, order, fps }Ordering and fps cap; a sharing canvas renders after its primary unless before/after is set
rendererprops bag, instance or factoryRenderer settings, honored by the canvas that owns its renderer. An instance or factory opts out of sharing

State properties:

PropertyTypeDescription
primaryStoreRootStoreZustand store of the primary canvas
internal.isSecondarybooleanWhether this canvas borrows another's renderer (not set for a WebGL2-fallback secondary, which owns its renderer)
internal.sharedRendererFallbackbooleanWhether this secondary stopped sharing because the primary fell back to WebGL2
internal.isMultiCanvasbooleanWhether multi-canvas mode is active