Canvas 3D Guide
Canvas3D renders 3D geometry via wgpu and Phong shading, composited on top of the 2D Vello scene. You describe your scene as a plain JS object and call updateScene() — no shader code required for standard geometry.
Experimental — the scene format may change before v1.0.
Spinning cube
import { Canvas3D } from 'veloxkit'
import { useRef, useEffect } from 'react'
function SpinningCube() {
const c3dRef = useRef(null)
useEffect(() => {
const ctx = c3dRef.current
if (!ctx) return
let angle = 0
const tick = () => {
angle += 0.02
const c = Math.cos(angle), s = Math.sin(angle)
ctx.updateScene({
background: [0.05, 0.05, 0.08, 1.0],
camera: {
position: [0, 1.5, 3.5],
target: [0, 0, 0],
fovDeg: 55,
},
lights: [
{ type: 'ambient', color: [1,1,1,1], intensity: 0.25 },
{ type: 'directional', color: [1,0.95,0.9,1], intensity: 1.1,
direction: [1, -1.5, -1] },
],
meshes: [{
geometry: { type: 'box', width: 1, height: 1, depth: 1 },
transform: [c,0,s,0, 0,1,0,0, -s,0,c,0, 0,0,0,1], // rotate Y
color: [0.48, 0.64, 0.97, 1.0],
}],
})
}
const id = setInterval(tick, 16)
return () => clearInterval(id)
}, [])
return <Canvas3D ref={c3dRef} style={{ width: 400, height: 300 }} />
}Geometry types
| Type | Fields |
|---|---|
'box' | width, height, depth |
'sphere' | radius, rings? (default 20), sectors? (default 20) |
'plane' | width, depth |
'gltf' | path (absolute path to .glb / .gltf) |
Transforms
Every mesh takes a transform — a 16-element row-major 4×4 matrix as a flat number array. If omitted, the identity matrix is used.
// Helpers
const identity = () => [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]
const rotateY = (rad: number) => {
const c = Math.cos(rad), s = Math.sin(rad)
return [c,0,s,0, 0,1,0,0, -s,0,c,0, 0,0,0,1]
}
const translate = (x: number, y: number, z: number) =>
[1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1]
// Scale uniformly
const scale = (s: number) =>
[s,0,0,0, 0,s,0,0, 0,0,s,0, 0,0,0,1]For complex transforms, combine with a math library:
bun add gl-matriximport { mat4, vec3 } from 'gl-matrix'
function computeMVP(angleY: number, position: [number, number, number]) {
const m = mat4.create()
mat4.translate(m, m, position)
mat4.rotateY(m, m, angleY)
return Array.from(m) as number[]
}Lights
Two light types are supported:
// Fills all surfaces equally — prevents fully black shadows
{ type: 'ambient', color: [1,1,1,1], intensity: 0.3 }
// Parallel rays from a direction — the workhorse light
{ type: 'directional', color: [1,1,1,1], intensity: 1.0, direction: [1,-1,-1] }Loading GLTF models
Call loadGltf() at mount to pre-cache the file, then reference it in updateScene():
useEffect(() => {
const ctx = c3dRef.current
if (!ctx) return
// Pre-cache the model
ctx.loadGltf('/assets/models/robot.glb')
let angle = 0
const id = setInterval(() => {
angle += 0.01
ctx.updateScene({
camera: { position: [0, 1, 4], target: [0, 0.5, 0] },
lights: [
{ type: 'ambient', color: [1,1,1,1], intensity: 0.4 },
{ type: 'directional', color: [1,1,1,1], intensity: 1.0,
direction: [1, -1, -0.5] },
],
meshes: [{
geometry: { type: 'gltf', path: '/assets/models/robot.glb' },
transform: rotateY(angle),
color: [1, 1, 1, 1], // tint; [1,1,1,1] = no tint
}],
})
}, 16)
return () => clearInterval(id)
}, [])GLTF loading happens asynchronously on the Rust side. If updateScene references a path that isn't loaded yet it's silently skipped until the load completes — typically within 1–2 frames for small models.
Multi-object scene with product viewer
function ProductViewer({ modelPath }: { modelPath: string }) {
const c3dRef = useRef(null)
const angleRef = useRef(0)
const dragging = useRef(false)
useEffect(() => {
const ctx = c3dRef.current
if (!ctx) return
ctx.loadGltf(modelPath)
const id = setInterval(() => {
if (!dragging.current) angleRef.current += 0.005
ctx.updateScene({
background: [0.06, 0.06, 0.1, 1],
camera: {
position: [0, 1, 4],
target: [0, 0.5, 0],
fovDeg: 50,
},
lights: [
{ type: 'ambient', color: [1,1,1,1], intensity: 0.35 },
{ type: 'directional', color: [1,0.95,0.9,1], intensity: 1.2,
direction: [2, -3, -2] },
{ type: 'directional', color: [0.5,0.6,1,1], intensity: 0.4,
direction: [-2, 1, 2] },
],
meshes: [
// Product model
{
geometry: { type: 'gltf', path: modelPath },
transform: rotateY(angleRef.current),
color: [1, 1, 1, 1],
},
// Shadow plane
{
geometry: { type: 'plane', width: 6, depth: 6 },
transform: translate(0, -0.01, 0),
color: [0.08, 0.08, 0.12, 1],
},
],
})
}, 16)
return () => clearInterval(id)
}, [modelPath])
return (
<Canvas3D
ref={c3dRef}
style={{ flex: 1, minHeight: 300 }}
onDragStart={() => { dragging.current = true }}
onDragMove={({ dx }) => { angleRef.current += dx * 0.01 }}
onDragEnd={() => { dragging.current = false }}
/>
)
}Performance tips
- Call
updateScene()only when the scene changes — not every frame if nothing is moving. - For static scenes (no animation), call
updateScene()once at mount and never again. - Use
rings: 16, sectors: 16for sphere geometry; higher values add GPU cost without visible improvement at typical sizes. - Keep GLTF models under 10 MB for smooth load times.
See also
- Canvas3D component reference — full API
- Canvas (2D) guide — 2D charts and drawing