🚧 VeloxKit is pre-release software. APIs may change before v1.0. Get started →
Documentation
Guides
Canvas 3D

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

TypeFields
'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-matrix
import { 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: 16 for 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