shadimage
joyco
componentdebug-canvassource: ok · hit

r3f Canvas

An enhanced R3F Canvas with built-in debug helpers — orbit/fly controls (mutually exclusive), grid, camera monitor, and performance stats — all toggled via a shared tweakpane folder.

npx shadcn add "https://registry.joyco.studio/r/debug-canvas.json"
free

Where it goes

stats

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · heavythree
webgl@react-three/drei

Registry dependencies

@joyco/debug

Files6 · source cached 10/14/2026, 11:41:26 AM

registry/components/debug-canvas/index.ts
export { DebugCanvas } from './debug-canvas'
export { DebugControls } from './debug-controls'
export { DebugGridHelper } from './debug-grid-helper'
export { DebugCameraMonitor } from './debug-camera-monitor'
export { DebugPerfMonitor } from './debug-perf-monitor'
registry/components/debug-canvas/debug-canvas.tsx
'use client'

import { Canvas, type CanvasProps } from '@react-three/fiber'
import { type ReactNode } from 'react'
import { DebugControls } from './debug-controls'
import { DebugGridHelper } from './debug-grid-helper'
import { DebugCameraMonitor } from './debug-camera-monitor'
import { DebugPerfMonitor } from './debug-perf-monitor'

interface DebugCanvasProps extends CanvasProps {
  children?: ReactNode
}

export function DebugCanvas({ children, ...props }: DebugCanvasProps) {
  return (
    <Canvas {...props}>
      <DebugControls />
      <DebugGridHelper />
      <DebugCameraMonitor />
      <DebugPerfMonitor />
      {children}
    </Canvas>
  )
}
registry/components/debug-canvas/debug-controls.tsx
'use client'
/* eslint-disable react-hooks/immutability */

import { OrbitControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useEffect, useRef, useState } from 'react'
import { Euler, Vector3 } from 'three'
import { useDebugBindings, useDebugState } from '@/registry/lib/debug'

function ActiveOrbitControls() {
  const camera = useThree((s) => s.camera)
  const gl = useThree((s) => s.gl)

  const [target] = useState(() => {
    if (camera.userData.target instanceof Vector3) {
      return new Vector3().copy(camera.userData.target)
    }
    return new Vector3(0, 0, 0)
  })

  useEffect(() => {
    const canvas = gl.domElement
    canvas.style.pointerEvents = 'auto'
    return () => {
      canvas.style.pointerEvents = ''
    }
  }, [gl])

  return <OrbitControls target={target} makeDefault />
}

const HALF_PI = Math.PI / 2
const _forward = new Vector3()
const _right = new Vector3()

function ActiveFlyControls() {
  const camera = useThree((s) => s.camera)
  const gl = useThree((s) => s.gl)
  const yawRef = useRef(0)
  const pitchRef = useRef(0)
  const keysRef = useRef({
    w: false,
    a: false,
    s: false,
    d: false,
    space: false,
    shift: false,
  })
  const speed = 3
  const sprintMultiplier = 5
  const sensitivity = 0.002

  useEffect(() => {
    const euler = new Euler()
    euler.setFromQuaternion(camera.quaternion, 'YXZ')
    yawRef.current = euler.y
    pitchRef.current = euler.x
  }, [camera])

  useEffect(() => {
    const canvas = gl.domElement
    canvas.style.pointerEvents = 'auto'

    const onClick = () => canvas.requestPointerLock()

    const onMouseMove = (e: MouseEvent) => {
      if (document.pointerLockElement !== canvas) return
      yawRef.current -= e.movementX * sensitivity
      pitchRef.current -= e.movementY * sensitivity
      pitchRef.current = Math.max(-HALF_PI, Math.min(HALF_PI, pitchRef.current))
    }

    canvas.addEventListener('click', onClick)
    document.addEventListener('mousemove', onMouseMove)

    return () => {
      canvas.removeEventListener('click', onClick)
      document.removeEventListener('mousemove', onMouseMove)
      if (document.pointerLockElement === canvas) document.exitPointerLock()
      canvas.style.pointerEvents = ''
    }
  }, [gl])

  useEffect(() => {
    const keys = keysRef.current

    const set = (e: KeyboardEvent, pressed: boolean) => {
      switch (e.code) {
        case 'KeyW':
          keys.w = pressed
          break
        case 'KeyA':
          keys.a = pressed
          break
        case 'KeyS':
          keys.s = pressed
          break
        case 'KeyD':
          keys.d = pressed
          break
        case 'Space':
          e.preventDefault()
          keys.space = pressed
          break
        case 'ShiftLeft':
        case 'ShiftRight':
          keys.shift = pressed
          break
      }
    }

    const onDown = (e: KeyboardEvent) => set(e, true)
    const onUp = (e: KeyboardEvent) => set(e, false)

    window.addEventListener('keydown', onDown)
    window.addEventListener('keyup', onUp)
    return () => {
      window.removeEventListener('keydown', onDown)
      window.removeEventListener('keyup', onUp)
    }
  }, [])

  useFrame((_, delta) => {
    const keys = keysRef.current

    camera.rotation.set(pitchRef.current, yawRef.current, 0, 'YXZ')

    camera.getWorldDirection(_forward)
    _right.crossVectors(_forward, camera.up).normalize()

    const step = speed * (keys.shift ? sprintMultiplier : 1) * delta
    if (keys.w) camera.position.addScaledVector(_forward, step)
    if (keys.s) camera.position.addScaledVector(_forward, -step)
    if (keys.a) camera.position.addScaledVector(_right, -step)
    if (keys.d) camera.position.addScaledVector(_right, step)
    if (keys.space) camera.position.y += step
  })

  return null
}

export function DebugControls() {
  const [targetRef, , store] = useDebugBindings('Canvas', {
    orbitControls: true,
    flyControls: false,
  })

  // Mutual exclusion: enabling one disables the other
  useEffect(() => {
    return store.subscribe((state, prev) => {
      if (state.orbitControls && !prev.orbitControls && state.flyControls) {
        targetRef.current.flyControls = false
      } else if (
        state.flyControls &&
        !prev.flyControls &&
        state.orbitControls
      ) {
        targetRef.current.orbitControls = false
      }
    })
  }, [store, targetRef])

  // Alt+F keyboard toggle for fly controls
  useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.altKey && e.code === 'KeyF') {
        e.preventDefault()
        targetRef.current.flyControls = !targetRef.current.flyControls
      }
    }
    window.addEventListener('keydown', onKeyDown)
    return () => window.removeEventListener('keydown', onKeyDown)
  }, [targetRef])

  const orbit = useDebugState(store, (s) => s.orbitControls)
  const fly = useDebugState(store, (s) => s.flyControls)

  return (
    <>
      {orbit && <ActiveOrbitControls />}
      {fly && <ActiveFlyControls />}
    </>
  )
}
registry/components/debug-canvas/debug-grid-helper.tsx
'use client'

import { useDebugBindings, useDebugState } from '@/registry/lib/debug'

export function DebugGridHelper() {
  const [, , store] = useDebugBindings('Canvas', {
    gridHelper: true,
  })
  const gridHelperEnabled = useDebugState(store, (s) => s.gridHelper)

  if (!gridHelperEnabled) return null

  return <gridHelper args={[100, 100]} />
}
registry/components/debug-canvas/debug-camera-monitor.tsx
'use client'

import { useFrame, useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { Euler } from 'three'
import { useDebugFolder } from '@/registry/lib/debug'

const RAD2DEG = 180 / Math.PI
const _euler = new Euler()

export function DebugCameraMonitor() {
  const folder = useDebugFolder('Canvas')
  const camera = useThree((s) => s.camera)
  const paramsRef = useRef({
    position: { x: 0, y: 0, z: 0 },
    rotation: { x: 0, y: 0, z: 0 },
  })
  const bindingsRef = useRef<{ dispose(): void }[]>([])

  useEffect(() => {
    if (!folder) return

    const fmt = (v: number) => v.toFixed(2)
    const pos = folder.addBinding(paramsRef.current, 'position', {
      label: 'cam pos',
      x: { format: fmt },
      y: { format: fmt },
      z: { format: fmt },
    })
    const rot = folder.addBinding(paramsRef.current, 'rotation', {
      label: 'cam rot',
      x: { format: fmt },
      y: { format: fmt },
      z: { format: fmt },
    })

    bindingsRef.current = [pos, rot]

    return () => {
      pos.dispose()
      rot.dispose()
      bindingsRef.current = []
    }
  }, [folder])

  useFrame(() => {
    const p = paramsRef.current.position
    const r = paramsRef.current.rotation

    p.x = camera.position.x
    p.y = camera.position.y
    p.z = camera.position.z

    _euler.setFromQuaternion(camera.quaternion, 'YXZ')
    r.x = _euler.x * RAD2DEG
    r.y = _euler.y * RAD2DEG
    r.z = _euler.z * RAD2DEG

    for (const b of bindingsRef.current) {
      ;(b as unknown as { refresh(): void }).refresh()
    }
  })

  return null
}
registry/components/debug-canvas/debug-perf-monitor.tsx
'use client'

import { useEffect, useRef } from 'react'
import { useDebugBindings, useDebugState } from '@/registry/lib/debug'
import Stats from 'stats-gl'
import { useFrame, useThree } from '@react-three/fiber'

type Panel = InstanceType<typeof Stats.Panel>

export function DebugPerfMonitor() {
  const [, , store] = useDebugBindings('Canvas', { perfMonitor: false })
  const enabled = useDebugState(store, (s) => s.perfMonitor)

  if (!enabled) return null

  return <PerfMonitorGL />
}

function PerfMonitorGL() {
  const gl = useThree((s) => s.gl)
  const statsRef = useRef<Stats | null>(null)
  const trianglesPanelRef = useRef<Panel | null>(null)
  const callsPanelRef = useRef<Panel | null>(null)
  const maxTrianglesRef = useRef(0)
  const maxCallsRef = useRef(0)
  const prevTrianglesRef = useRef(0)
  const prevCallsRef = useRef(0)

  useEffect(() => {
    const stats = new Stats({
      trackHz: true,
      trackFPS: true,
    })

    let cancelled = false

    const setup = async () => {
      await stats.init(gl)
      if (cancelled) return

      const trianglesPanel = stats.addPanel(new Stats.Panel('TRI', '#f80', '#210'))
      const callsPanel = stats.addPanel(new Stats.Panel('CALL', '#e08', '#201'))

      stats.dom.style.cssText = `
        position: fixed;
        bottom: 0;
        right: 0;
        z-index: 10000;
        opacity: 0.9;
        display: flex;
        pointer-events: auto;
      `

      const vsyncCanvas = (stats as unknown as { vsyncPanel?: { canvas: HTMLElement } }).vsyncPanel?.canvas

      const applyFlexLayout = () => {
        for (const child of Array.from(stats.dom.children) as HTMLElement[]) {
          if (child === vsyncCanvas) continue
          child.style.position = 'relative'
          child.style.top = 'auto'
          child.style.left = 'auto'
        }
      }

      applyFlexLayout()
      window.addEventListener('resize', applyFlexLayout)

      document.body.appendChild(stats.dom)
      statsRef.current = stats
      trianglesPanelRef.current = trianglesPanel
      callsPanelRef.current = callsPanel

      return applyFlexLayout
    }

    const cleanupP = setup()

    return () => {
      cancelled = true
      statsRef.current = null
      trianglesPanelRef.current = null
      callsPanelRef.current = null
      cleanupP.then((applyFlexLayout) => {
        if (applyFlexLayout) window.removeEventListener('resize', applyFlexLayout)
        stats.dom.remove()
        stats.dispose()
      }).catch(() => {
        // init failed — nothing was appended, nothing to clean up
      })
    }
  }, [gl])

  useFrame(() => {
    const info = gl.info
    prevTrianglesRef.current = info?.render?.triangles ?? 0
    prevCallsRef.current = info?.render?.calls ?? 0

    statsRef.current?.begin()
  }, -Infinity)

  useFrame(() => {
    const info = gl.info
    const triangles = (info?.render?.triangles ?? 0) - prevTrianglesRef.current
    const calls = (info?.render?.calls ?? 0) - prevCallsRef.current

    maxTrianglesRef.current = Math.max(maxTrianglesRef.current, triangles)
    maxCallsRef.current = Math.max(maxCallsRef.current, calls)

    trianglesPanelRef.current?.update(triangles, maxTrianglesRef.current)
    trianglesPanelRef.current?.updateGraph(triangles, maxTrianglesRef.current)

    callsPanelRef.current?.update(calls, maxCallsRef.current)
    callsPanelRef.current?.updateGraph(calls, maxCallsRef.current)

    statsRef.current?.end()
    statsRef.current?.update()
  }, Infinity)

  return null
}

More from joyco

Browse all components in this library →