Skip to content
TutorialGraphics & 3D

A spinning 3D scene with TresJS and useLoop()

One scene, built up in steps: a canvas, a camera and lights, a mesh, a frame loop driven by delta, a slider, a model loaded from a file, and the clean-up that keeps memory flat.

vuejs.school team28 Apr 20266 min read@tresjs/core 5.8 · three 0.184

TresJS lets you write a Three.js scene as Vue components. Three.js is the library that draws 3D graphics in the browser through WebGL. TresJS is a custom renderer for it: where Vue normally creates HTML elements, TresJS creates Three.js objects, so instead of new Mesh(new BoxGeometry()) you write <TresMesh> with a <TresBoxGeometry> inside.

By the end of this post you will have one scene with a box that spins at the same speed on every screen, a camera you can drag around, a slider that changes the speed, a model loaded from a .glb file, and the settings that keep it light on a phone. The code targets @tresjs/core 5.8.0 (30 March), @tresjs/cientos 5.7.0 and three 0.184.0 (16 April), in a Vite project created with npm create vue@latest.

Install TresJS and configure Vite

Install the renderer, Three.js, the helper library cientos, and the Three.js types:

npm install @tresjs/core @tresjs/cientos three
npm install -D @types/three

Left alone, Vue treats TresMesh and TresBoxGeometry as components and warns that it cannot find them. TresJS exports compiler options that mark these tags as elements for its renderer, and the installation guide passes them to the Vue plugin:

vite.config.ts
import { templateCompilerOptions } from '@tresjs/core'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [vue({ ...templateCompilerOptions })],
})

A canvas, a camera and two lights

TresCanvas creates the WebGL canvas, the scene, the renderer and the render loop. Everything inside it becomes part of the scene. By default it takes the size of its parent element, so the parent needs a height:

src/App.vue
<script setup lang="ts">
import { TresCanvas } from '@tresjs/core'
import SpinningScene from './components/SpinningScene.vue'
</script>

<template>
  <div class="stage">
    <TresCanvas clear-color="#1f2a44">
      <TresPerspectiveCamera :position="[4, 3, 5]" :look-at="[0, 0, 0]" />
      <TresAmbientLight :intensity="0.5" />
      <TresDirectionalLight :position="[3, 5, 2]" :intensity="2" />
      <SpinningScene />
    </TresCanvas>
  </div>
</template>

<style scoped>
.stage {
  height: 70vh;
}
</style>

Every Tres element maps to the Three.js class of the same name without the prefix, so TresPerspectiveCamera is a PerspectiveCamera, and its props set properties on that object. SpinningScene is the component you write in the next step.

The camera sits at x 4, y 3, z 5 and looks at the centre; left at its default of [0, 0, 0] it would sit inside whatever you put there and you would see nothing. The ambient light brightens everything a little from all sides. The directional light works like the sun: parallel rays from one direction, which gives the box a lit side and a dark side.

A mesh that spins with useLoop

A mesh is a shape (the geometry) plus a surface (the material). Put the scene in its own component, because useLoop only works in a child of TresCanvas: the canvas provides the loop to its descendants.

src/components/SpinningScene.vue
<script setup lang="ts">
import { useLoop } from '@tresjs/core'
import type { Mesh } from 'three'
import { shallowRef } from 'vue'

const props = withDefaults(defineProps<{ speed?: number }>(), { speed: 1 })

const box = shallowRef<Mesh | null>(null)
const { onBeforeRender } = useLoop()

onBeforeRender(({ delta }) => {
  if (!box.value) return
  box.value.rotation.y += delta * props.speed
})
</script>

<template>
  <TresMesh ref="box" :position="[0, 1, 0]">
    <TresBoxGeometry :args="[1.5, 1.5, 1.5]" />
    <TresMeshStandardMaterial color="#42b883" />
  </TresMesh>
  <TresMesh :rotation-x="-Math.PI / 2">
    <TresPlaneGeometry :args="[10, 10]" />
    <TresMeshStandardMaterial color="#e2e8f0" />
  </TresMesh>
</template>

args passes constructor arguments, so [1.5, 1.5, 1.5] is width, height and depth. MeshStandardMaterial reacts to light, which is why the scene needs the two lights. The second mesh is a floor: a plane stands upright by default, and rotation-x tips it flat by a quarter turn.

onBeforeRender runs once per frame, before the frame is drawn. Its delta is the number of seconds since the previous frame.

The template ref box is a shallowRef so that it holds the mesh as it is. A plain ref would hand you a deeply reactive proxy, and every change in the loop would pass through Vue's reactivity for nothing. The performance guide recommends shallowRef for Three.js objects, and the useLoop reference animates its cube as this component does, by changing the mesh directly through a template ref.

Here is the version most first scenes start with:

SpinningScene.vueBreaks
onBeforeRender(() => {
  if (!box.value) return
  box.value.rotation.y += 0.02
})

It adds a fixed amount per frame, so the speed depends on the screen. At 60 frames per second that is 1.2 radians a second; on a 120 Hz laptop the box spins twice as fast, and on a busy phone that drops frames it slows down. Multiplying by delta turns the number into a speed per second:

SpinningScene.vueWorks
onBeforeRender(({ delta }) => {
  if (!box.value) return
  box.value.rotation.y += delta * props.speed
})

With speed at 1, the box turns one radian per second on every device.

Orbit controls and a speed slider

OrbitControls from cientos lets the user drag to rotate the camera around a target, scroll to zoom and right-drag to pan. The cientos docs warn that the camera must come before it inside the canvas. The slider is an ordinary <input type="range"> outside the canvas, and its value travels into the scene as a prop:

src/App.vue
<script setup lang="ts">
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import { ref } from 'vue'
import SpinningScene from './components/SpinningScene.vue'

const speed = ref(1)
</script>

<template>
  <label>
    Speed: {{ speed }} radians per second
    <input v-model.number="speed" type="range" min="0" max="6" step="0.5">
  </label>
  <div class="stage">
    <TresCanvas clear-color="#1f2a44">
      <TresPerspectiveCamera :position="[4, 3, 5]" :look-at="[0, 0, 0]" />
      <OrbitControls />
      <TresAmbientLight :intensity="0.5" />
      <TresDirectionalLight :position="[3, 5, 2]" :intensity="2" />
      <SpinningScene :speed="speed" />
    </TresCanvas>
  </div>
</template>

v-model.number turns the input's string into a number. The loop reads props.speed on every frame, so the box takes the new speed on the next frame, and nothing in the scene is rebuilt.

Props bound on Tres elements are reactive too: :color="boxColor" on the material, wired to an <input type="color">, repaints the box when the value changes.

Loading a glTF model

Real scenes use models made in Blender or downloaded from a library, usually as glTF, the standard 3D file format (.glb is its single-file binary form). Put a file in public/models/ and load it with useGLTF from cientos:

src/components/LampModel.vue
<script setup lang="ts">
import { useGLTF } from '@tresjs/cientos'
import { dispose } from '@tresjs/core'
import { onUnmounted } from 'vue'

const { state } = useGLTF('/models/lamp.glb')

onUnmounted(() => {
  if (state.value) dispose(state.value.scene)
})
</script>

<template>
  <primitive v-if="state" :object="state.scene" :position="[2.5, 0, 0]" />
</template>

useGLTF returns reactive state straight away, so the component needs no await and no Suspense: state is null until the file arrives, which is what the v-if waits for. It also returns isLoading and nodes, an object of the model's named parts. primitive places an existing Three.js object in the scene. Add <LampModel /> to the canvas next to SpinningScene.

Freeing the model's GPU memory

GPU memory is not freed by the garbage collector: geometries, materials and textures stay on the graphics card until something calls their dispose() method. TresJS does this for you when a component that declared TresMesh and friends unmounts. Objects you hand over through primitive are different: TresJS leaves them alone when they leave the scene, and the performance guide says to dispose of them yourself. That is the job of the onUnmounted hook in LampModel.vue.

The dispose helper from @tresjs/core walks the object and frees every geometry and material in it, plus each material's colour texture (map). Other textures, such as a normalMap, need their own dispose() call.

In 5.8, useGLTF also runs this helper on its model when the component that called it unmounts, so for the lamp the hook is a safety net. Either way, if you show and hide LampModel with v-if, its geometries and materials are freed each time it leaves instead of piling up on the graphics card. For an object you build yourself with new Mesh() and pass to primitive, the hook is the only clean-up.

Performance: pixel ratio, shadows and frame rate

Three settings on TresCanvas do most of the work. The final src/App.vue template below uses all three; its script is the one from the slider step, plus an import of LampModel:

src/App.vue
<template>
  <div class="stage">
    <TresCanvas clear-color="#1f2a44" shadows :dpr="[1, 2]" :fps-limit="30">
      <TresPerspectiveCamera :position="[4, 3, 5]" :look-at="[0, 0, 0]" />
      <OrbitControls />
      <TresAmbientLight :intensity="0.5" />
      <TresDirectionalLight :position="[3, 5, 2]" :intensity="2" cast-shadow />
      <SpinningScene :speed="speed" />
      <LampModel />
    </TresCanvas>
  </div>
</template>

Pixel ratio. A phone with a 3x screen draws nine device pixels for every CSS pixel. :dpr="[1, 2]" keeps the ratio between 1 and 2, so that phone draws four instead of nine, less than half the pixels, and the difference is hard to see on a moving scene.

Shadows. Each light that casts shadows renders the scene a second time, from the light's point of view, into a shadow map. Turn them on only for the objects that need them, with one casting light. Three switches have to agree:

  • shadows on TresCanvas;
  • cast-shadow on the light;
  • cast-shadow on the box's TresMesh and receive-shadow on the floor's, in SpinningScene.vue.

Frame rate. A decorative scene does not need 120 frames a second. :fps-limit="30" caps the loop, and because the rotation is multiplied by delta, the box keeps the same speed at the lower rate.

The same scene in Nuxt

The Nuxt module does the compiler setup for you, renders TresCanvas on the client only, and auto-imports the composables and the cientos components:

npm install three @tresjs/nuxt @tresjs/cientos
nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@tresjs/nuxt'],
})

In a Nuxt 4 project the two components go in app/components/, and the vite.config.ts step disappears. What was in App.vue goes in app/app.vue or a page. Drop its TresCanvas import: the module registers its own client-only version, and an explicit import would replace it.

useLoop, useGLTF and OrbitControls need no imports. The dispose helper is the exception: of the names in @tresjs/core, the module auto-imports those that start with use, so keep import { dispose } from '@tresjs/core' in LampModel.vue.

Check it stuck3 questions from the 3D scenes with TresJS quiz.
Practice now
Was this helpful?

Keep reading