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 · heavythreewebgl@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
}