3D scenes with TresJS
Write a Three.js scene as Vue components with TresJS: a sized TresCanvas, a camera, lights and a mesh, motion from useLoop() on a shallowRef, set up in Vite or with the Nuxt module.
What you'll learn
When you finish, you can do these.- 1Set up TresJS in a Vite project or a Nuxt app
- 2Build a 3D scene from a camera, lights and a mesh
- 3Turn a mesh every frame with useLoop() and a shallowRef
- 4Fix a mesh that shows up black and a canvas with no height
A shoe you can turn around on a product page is a 3D scene. The browser draws it with WebGL, its built-in 3D drawing on a <canvas>, usually through Three.js, a JavaScript library. In plain Three.js you build the camera, lights and shoe in JavaScript, outside any Vue template, so a button that recolours the shoe needs its own code to find it and change it.
TresJS lets you write those objects as Vue components, with props for their properties. You need one idea from Canvas and animation loops, where dots drift around a box: an animation loop, a function that runs before every frame the browser paints. The Code tab holds a stand-in for the shoe: a donut-shaped ring that turns slowly, with Blue and Amber buttons that change its colour. It comes as a Vite project in src/ and as a Nuxt app in app/.
Set it up once, in Vite or in Nuxt
Once per project, you tell Vue's template compiler, which turns templates into code, to leave TresJS's tags alone. TresJS writes each Three.js class as a tag with Tres in front, such as <TresPerspectiveCamera> for the camera class PerspectiveCamera, and turns those tags into objects itself.
In a Vue and Vite project, install three and @tresjs/core, plus @types/three for TypeScript, and spread templateCompilerOptions into the Vue plugin in vite.config.ts, Vite's settings file:
import { templateCompilerOptions } from '@tresjs/core'
export default defineConfig({
plugins: [vue({ ...templateCompilerOptions })],
})
Without it the scene still draws, but every tag logs a warning, the camera's first: [Vue warn]: Failed to resolve component: TresPerspectiveCamera.
In Nuxt 4, a Vue framework that renders pages on a server first, install three and the @tresjs/nuxt module, then list it in nuxt.config.ts, Nuxt's settings file:
export default defineNuxtConfig({
modules: ['@tresjs/nuxt'],
})
The module sets up the compiler and imports TresJS's components and functions for you. It also renders the canvas only in the browser: the server has no WebGL, so it sends an empty placeholder, and you need no <ClientOnly>, Nuxt's wrapper for browser-only parts.
Canvas, camera, lights and a mesh
Every scene starts with <TresCanvas>, which holds a camera, lights and the objects you see, called meshes. It creates three things: a renderer, which draws with WebGL onto a <canvas>; a scene, the root that holds every object; and a loop that draws the scene on every frame. It takes its parent's size, so the parent needs a height.
- TresCanvasrenderer, scene
- Camera and lights
- Meshgeometry + material
- useLoop
- Frame
In src/App.vue, the canvas sits in a stage div 320 pixels tall and holds a camera, two lights and the ring, a component of its own: SpinningDonut.vue.
<div class="stage">
<TresCanvas :clear-color="colours.background" role="img" aria-label="A ring turning slowly">
<TresPerspectiveCamera :position="[3, 3, 4]" :look-at="[0, 0, 0]" />
<TresAmbientLight :intensity="0.6" />
<TresDirectionalLight :position="[2, 4, 3]" :intensity="2" />
<SpinningDonut :colour="donutColour" />
</TresCanvas>
</div>
Props on a tag set its object's properties: :position="[3, 3, 4]" puts the camera 3 units right, 3 up and 4 towards you, and :look-at turns it to face the centre. role and aria-label name the canvas for screen readers.
A mesh is a visible object: a geometry, its shape, plus a material, how its surface looks. SpinningDonut.vue builds the ring from one of each:
<TresMesh ref="donut">
<TresTorusGeometry :args="[1, 0.4, 32, 64]" />
<TresMeshStandardMaterial :color="colour" :roughness="0.4" />
</TresMesh>
:args passes the arguments that create the object. For TorusGeometry, the donut shape, they are the ring's radius, the tube's thickness and how finely to cut it into triangles.
MeshStandardMaterial reacts to light like a real surface, so the scene needs lights. Ambient light brightens everything evenly; a directional light shines from one side like the sun and gives the ring its shading.
Props stay reactive. The Blue and Amber buttons, plain HTML outside the canvas, set donutColour, a ref in App.vue that reaches SpinningDonut as its colour prop. TresJS then recolours the existing material instead of building a new mesh.
Animate with useLoop()
To turn the ring, you change its rotation a little before every frame. useLoop() gives you the loop that <TresCanvas> runs, and its onBeforeRender calls your function before each frame with delta, the seconds since the last frame. In SpinningDonut.vue, that function turns the mesh:
const donut = shallowRef<Mesh | null>(null)
const { onBeforeRender } = useLoop()
onBeforeRender(({ delta }) => {
if (!donut.value) return
donut.value.rotation.x += delta * 0.5
donut.value.rotation.y += delta * 0.8
})
Multiplying by delta turns the ring at the same speed on any screen.
useLoop() works only inside the canvas, so the ring is a component of its own. Called in the component that renders <TresCanvas>, it throws useTresContext must be used together with useTresContextProvider.
The mesh is held in donut, a shallowRef: a ref that does not track what is inside its value. Its name matches ref="donut" on <TresMesh>, so Vue puts the Three.js object itself in it. A plain ref() would wrap the mesh and all its parts in proxies, Vue's tracking wrappers, so every frame would pay for tracking that nothing uses.
What goes wrong
Three mistakes leave the scene black, empty or still. The first keeps the standard material and forgets the lights.
<TresCanvas :clear-color="colours.background">
<TresPerspectiveCamera :position="[3, 3, 4]" :look-at="[0, 0, 0]" />
<SpinningDonut :colour="donutColour" />
</TresCanvas>
The ring appears as a black silhouette on the pale background, and nothing warns: a standard material shows only the light it reflects, and there is none. Add the two lights:
<TresCanvas :clear-color="colours.background">
<TresPerspectiveCamera :position="[3, 3, 4]" :look-at="[0, 0, 0]" />
<TresAmbientLight :intensity="0.6" />
<TresDirectionalLight :position="[2, 4, 3]" :intensity="2" />
<SpinningDonut :colour="donutColour" />
</TresCanvas>
Lit, the ring is blue and shaded again.
The second puts the canvas in a parent with no height.
<template>
<div>
<TresCanvas :clear-color="colours.background">
<!-- the camera, the two lights and SpinningDonut -->
</TresCanvas>
</div>
</template>
Nothing shows. The canvas is 0 pixels tall, and three seconds later the console says TresCanvas: The canvas has no area, so nothing can be rendered. Set it manually on the parent element or use the prop windowSize. Give the parent a height, or pass window-size to fill the window.
<template>
<div class="stage">
<TresCanvas :clear-color="colours.background">
<!-- the camera, the two lights and SpinningDonut -->
</TresCanvas>
</div>
</template>
<style scoped>
.stage { height: 320px; }
</style>
With a height, the ring appears.
The third reads the mesh with useTemplateRef(), the usual way to reach what a ref attribute names.
const donut = useTemplateRef<Mesh>('donut')
The ring stands still and the console repeats [Vue warn] Set operation on key "x" failed: target is readonly. In development useTemplateRef() returns a read-only ref, and a Three.js object read through it comes back read-only too. HTML elements are not wrapped, so useTemplateRef() still works for a <canvas>.
const donut = shallowRef<Mesh | null>(null)
With a shallowRef, the ring turns again.
What to remember
- Vite needs
templateCompilerOptionsso the compiler leavesTrestags alone; in Nuxt,@tresjs/nuxtsets that up and keeps the canvas out of server rendering. - A scene is a
<TresCanvas>holding a camera, lights and meshes, each aTrestag; a mesh is a geometry plus a material. Props set properties, and:argsthe arguments that create the object. - Animate in
useLoop().onBeforeRenderwithdelta, in a component inside the canvas, on a mesh held in ashallowRefnamed like itsrefattribute. MeshStandardMaterialneeds lights and renders black without them.<TresCanvas>takes its parent's size, so the parent needs a height.
In your project
Do these in a real project. Each one says how to confirm it worked.- In
src/App.vue, delete both light tags. The ring turns into a black silhouette, with no warning. InSpinningDonut.vue, changeTresMeshStandardMaterialtoTresMeshBasicMaterial: the ring shows in flat blue, without shading. Put the lights and the standard material back. - Add a wireframe switch. In
App.vue, addconst wireframe = ref(false)and a button that toggles it, and pass:wireframe="wireframe"to<SpinningDonut>. Declare the prop there and bind:wireframe="wireframe"on the material. Each click shows the ring's triangles as lines or fills them again, and the ring keeps turning. - Load the scene only when someone asks for it. Move the
<TresCanvas>block, with its imports and the colour as a prop, intosrc/components/DonutScene.vue. InApp.vue, import it withdefineAsyncComponent(() => import('./components/DonutScene.vue'))and render<DonutScene>in the stage behind a Show 3D button withv-if. Runnpm run build: instead of one chunk of about 900 kB and the warning(!) Some chunks are larger than 500 kB after minification., Vite lists a small main chunk and a separate DonutScene chunk holding Three.js. In the DevTools Network panel, that chunk loads only after the click.