5.8.3

tres gltf

Generate a typed Vue component from a .glb/.gltf model, with a slot for every node.

tres gltf turns a model into a Vue component you can read, diff and type-check, instead of a <primitive :object="scene" /> black box.

tres gltf public/models/robot.glb
# ▲ ■ ● Tres  gltf  robot.glb
#
# ✔ Parse       12 named nodes · 3 meshes · 2 materials    34ms
# ✔ Emit        3 slots                                     1ms
#
# ✔ src/models/Robot.gen.vue
#   slots  Head, Body, Base
#
# Done in 41ms

The slot list is capped at the first six names, since a level with sixty of them would bury the paths above it. --verbose prints them all, and the generated file carries the authoritative list in its defineSlots<{ … }>.

Then use it like any other component:

App.vue
<script setup lang="ts">
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import Robot from '@/models/Robot.gen.vue'
</script>

<template>
  <TresCanvas>
    <TresPerspectiveCamera :position="[3, 2, 5]" />
    <OrbitControls />
    <Robot />
    <TresAmbientLight :intensity="1" />
  </TresCanvas>
</template>

What gets generated

Every node in the model becomes a real element, and every node the artist named becomes a <slot> whose fallback is the generated markup:

src/models/Mug.gen.vue
<script setup lang="ts">
/*
Auto-generated by @tresjs/cli. Do not edit.
Command: tres gltf public/models/Mug.glb --slots all
Override the named slots from the parent instead; regenerating keeps your overrides.
*/
import type { Group, Mesh, MeshStandardMaterial } from 'three'
import { useGLTF } from '@tresjs/cientos'
import { ref, watch } from 'vue'

interface ModelNodes {
  Scene: Group
  Mug: Group
  Mesh: Mesh
  Mesh_1: Mesh
}

interface ModelMaterials {
  '4k-Metal-Worn': MeshStandardMaterial
  'coat': MeshStandardMaterial
}

const emit = defineEmits<{
  ready: [{ nodes: ModelNodes, materials: ModelMaterials }]
}>()

defineSlots<{
  Mesh?: (props: { node: Mesh, material: MeshStandardMaterial }) => any
  Mesh_1?: (props: { node: Mesh, material: MeshStandardMaterial }) => any
  Mug?: (props: { node: Group }) => any
}>()

const { state, nodes, materials, isLoading } = useGLTF<ModelNodes, ModelMaterials>('/models/Mug.glb', { draco: true })

const isReady = ref(false)
watch(
  () => !isLoading.value
    && state.value !== null,
  (ready) => {
    if (!ready) {
      isReady.value = false
      return
    }
    if (isReady.value) { return }
    isReady.value = true
    emit('ready', { nodes: nodes.value, materials: materials.value })
  },
  { flush: 'post', immediate: true },
)

defineExpose({ nodes, materials, isReady })
</script>

<template>
  <TresGroup :dispose="null">
    <template v-if="!isLoading">
      <slot name="Mug" :node="nodes.Mug">
        <TresGroup :position="[0, 1, 0]">
          <slot name="Mesh" :node="nodes.Mesh" :material="materials['4k-Metal-Worn']">
            <TresMesh :geometry="nodes.Mesh.geometry" :material="materials['4k-Metal-Worn']" />
          </slot>
          <slot name="Mesh_1" :node="nodes.Mesh_1" :material="materials.coat">
            <TresMesh :geometry="nodes.Mesh_1.geometry" :material="materials.coat" />
          </slot>
        </TresGroup>
      </slot>
    </template>
  </TresGroup>
</template>

A few things are worth pointing out:

  • ModelNodes and ModelMaterials describe this export, so nodes.Mug is a Group and not an any.
  • Draco is detected, and { draco: true } is passed automatically. A Draco model renders nothing without it.
  • Lights, cameras and bones are emitted as <primitive :object="..." />: their props live on the parsed object, and a bone must be the parsed object for skinning to work.
  • The root TresGroup stays mounted and gates its children with v-if="!isLoading", so refs on it are stable.
  • @ready fires once per load, carrying { nodes, materials }, and isReady exposes the same state as a value. An event that already fired cannot be read after the fact, which is what isReady is for: a parent that binds late, or a state machine that wants to ask later. Refetching the model (useGLTF exposes execute()) puts isReady back to false and arms ready again.
  • Readiness reads state, not just isLoading. isLoading is cleared in a finally, so a 404 clears it exactly like a success would; state is only set when the load produced a scene. Without that term a failed load would call the model ready with empty nodes and materials.

Overriding a node

The generated file is disposable. Anything you want to change lives in the parent, as a slot override:

App.vue
<script setup lang="ts">
import Robot from '@/models/Robot.gen.vue'
import { hologram } from './materials'
</script>

<template>
  <Robot>
    <template #Head="{ node }">
      <TresMesh :geometry="node.geometry" :material="hologram" @click="explode" />
    </template>
  </Robot>
</template>

Re-run tres gltf after the artist re-exports and the override survives, because it never lived in the generated file. If the artist renames Head, the override becomes a type error instead of silently disappearing at runtime.

--slots named (the default) skips exporter noise like Object_12, Sketchfab_model or Armature, since overriding those means nothing. On marketplace assets where every mesh is exporter-named, the CLI says so and points you at --slots all.

Nodes and materials from the parent

The component exposes nodes and materials, so a parent can reach into the model without re-loading it:

<script setup lang="ts">
import { ref } from 'vue'
import Robot from '@/models/Robot.gen.vue'

const robot = ref<InstanceType<typeof Robot>>()

function paintItRed() {
  robot.value?.materials.Body.color.set('#ff0055')
}
</script>

<template>
  <Robot ref="robot" />
</template>
If you only need them the moment the model is ready, @ready="({ nodes, materials }) => …" hands you the same pair without a ref. The exposed nodes and materials are for reaching in at any point after.

Animated models

When the model ships animation clips, the generated component also wires up useAnimations and hands the bound actions to @ready, keyed by a union of the clip names:

type ActionName
  = | 'Idle'
    | 'Walk'
    | 'Death_A'

@ready fires once per load, after the clips have loaded and the mixer has bound its actions, with { nodes, materials, actions }. No template ref, and actions.Idle is there to play:

App.vue
<script setup lang="ts">
import Knight from '@/models/Knight.gen.vue'

// actions.Idle, not actions['Idle'], and a typo is a compile error
</script>

<template>
  <Knight @ready="({ actions }) => actions.Idle?.play()" />
</template>

Prefer the event over reaching through a ref. The mixer binds its actions a flush after the clips land (useAnimations runs flush: 'post' for exactly this), so a handler that reads actions too early sees them undefined. @ready waits for the binding; isReady exposes the same state as a value, false until it holds and reset on any refetch, for a parent that binds late or asks again later.

A mixer resolves every track against a node name, so the CLI keeps the name of every node a clip drives, whatever --slots or --keepnames say. A group that exists only to be animated survives pruning for the same reason:

<TresGroup name="Rotor">
  <TresMesh name="Blade" :geometry="nodes.Blade.geometry" :material="materials.Metal" />
</TresGroup>

Nodes no clip mentions are unaffected, so this costs nothing on a model with no animation on it. Batched nodes keep their names too: see instancing.

Clips from separate files

Mixamo, KayKit and Quaternius all ship the mesh in one file and the clips in others, so a mesh-only export has nothing to wire. Generating one says so:

tres gltf public/models/Dummy.glb
# ⚠ This model is skinned but carries no animation clips. Pass --animations <path> to wire in clips exported to separate files.

Point --animations at the clip files, once per file. A KayKit character, whose 39 clips ship as three libraries beside the rig:

tres gltf public/models/Dummy.glb \
  --animations public/models/animations/Rig_Medium_General.glb \
  --animations public/models/animations/Rig_Medium_MovementBasic.glb \
  --animations public/models/animations/Rig_Medium_MovementAdvanced.glb
# ▲ ■ ● Tres  gltf  Dummy.glb
#
# ✔ Parse       51 named nodes · 8 meshes · 1 material · 37 clips merged   37ms
# ✔ Emit        6 slots                                                     1ms
#
# ✔ src/models/Dummy.gen.vue
#   slots  Dummy_ArmLeft, Dummy_ArmRight, Dummy_Body, Dummy_Head,
#          Dummy_LegLeft, Dummy_LegRight
#   clips  Death_A, Death_A_Pose, Death_B, Death_B_Pose, Hit_A, Hit_B,
#          … 31 more — rerun with --verbose
#
# Done in 87ms

The merged names are the ones ActionName will carry, so they are printed back; --verbose lists all of them.

The component loads each one and merges the clips into a single array, the model's own first:

const { nodes, materials, isLoading } = useGLTF<ModelNodes, ModelMaterials>('/models/Dummy.glb')
const { state: rigMediumGeneral, isLoading: rigMediumGeneralLoading } = useGLTF('/models/animations/Rig_Medium_General.glb')
const { state: rigMediumMovementBasic, isLoading: rigMediumMovementBasicLoading } = useGLTF('/models/animations/Rig_Medium_MovementBasic.glb')
const { state: rigMediumMovementAdvanced, isLoading: rigMediumMovementAdvancedLoading } = useGLTF('/models/animations/Rig_Medium_MovementAdvanced.glb')

const animations = computed(() => {
  // The mixer resolves every track against a node name in the rendered tree and never
  // retries a miss, so the clips must not reach it before the model they drive.
  if (isLoading.value) {
    return []
  }

  return [
    ...(rigMediumGeneral.value?.animations ?? []),
    ...(rigMediumMovementBasic.value?.animations ?? []),
    ...(rigMediumMovementAdvanced.value?.animations ?? []),
  ]
})

That guard matters: a clip library is a fraction of the size of the model it drives, so its files arrive first. Handing a mixer clips before the tree exists binds every track to nothing, and three caches the miss instead of retrying it.

ActionName becomes the union across every file, and the node names the external clips drive survive pruning exactly like the model's own would. Each file gets its own url, inferred from public/ the same way the model's is, and its own { draco: true } when it is compressed.

This is where @ready earns its place over a hand-rolled watch. Each clip file resolves on its own, so actions can populate with the first library's clips and repopulate as the others land. The generated gate names every condition it waits for, and holds until all of them hold:

const isReady = ref(false)
watch(
  () => !isLoading.value
    && !rigMediumGeneralLoading.value
    && !rigMediumMovementBasicLoading.value
    && !rigMediumMovementAdvancedLoading.value
    && state.value !== null
    && Object.keys(actions).length > 0,
  (ready) => {
    if (!ready) {
      isReady.value = false
      return
    }
    // …emit('ready', { nodes: nodes.value, materials: materials.value, actions })
  },
  { flush: 'post', immediate: true },
)

A watch that fired on the first non-empty actions would see only the General clips, and actions.Jump_Start (a MovementBasic clip) would be undefined. @ready waits for the lot.

The state term is doing something less obvious. The actions bind against the root group, which stays mounted whatever the load did, so clips from a --animations file fill actions even when the model itself 404s. Without that term a rig that never arrived would still report ready.

When two files carry the same clip name

Clip libraries overlap — the three above all ship a T-Pose. The array decides: a mixer keys actions walking it, so the last file passed wins, and an --animations clip always overrides one the model came with. ActionName lists the name once. The CLI says which file won rather than leaving it to be discovered:

# ⚠ Both Rig_Medium_General.glb and Rig_Medium_MovementBasic.glb carry "T-Pose". Rig_Medium_MovementBasic.glb is merged last, so its clip is the one that plays.

Pass the file you want to win last.

The CLI parses the clip files too, so it can compare each clip's track targets against the model's node names — the one animation failure that is completely silent at runtime. A clip that drives nodes this rig does not have gets a warning; a clip where nothing binds is left out of ActionName entirely, since it could never play.

Where the file is written

By default the component is written next to the model as <Model>.gen.vue, with one exception: if the model lives under public/, the component goes to src/models/ (or app/models/ on Nuxt) instead. A bundler copies public/ verbatim and never compiles it, so a component written there could never be imported.

tres gltf public/models/robot.glb        # → src/models/Robot.gen.vue
tres gltf src/assets/robot.glb           # → src/assets/Robot.gen.vue
tres gltf public/models/robot.glb -o src/components/Robot.vue

models/ rather than components/ is deliberate: component directories are auto-scanned by unplugin-vue-components and Nuxt, and the dot in .gen.vue produces an invalid identifier in the generated components.d.ts.

The CLI refuses to overwrite a file it did not generate. Pass --force to replace a hand-written file anyway.

The model url

The url baked into useGLTF is inferred from the nearest public/ directory above the model: public/models/robot.glb becomes /models/robot.glb. With no public/ in sight the CLI warns and guesses, so set it yourself:

tres gltf assets/robot.glb --url https://cdn.example.com/robot.glb

Optimizing the model

--transform runs the model through (dedup, weld, texture resize and compression, Draco, and more) before generating. Savings of 70–90% are typical:

tres gltf public/models/robot.glb --transform
# ▲ ■ ● Tres  gltf  robot.glb
#
# ✔ Transform   755KB › 40KB  -95%                        1.2s
# ✔ Parse       12 named nodes · 3 meshes · 2 materials    34ms
# ✔ Emit        3 slots                                     1ms
#
# ✔ src/models/Robot.gen.vue
#   slots  Head, Body, Base
#   useGLTF() now loads robot-transformed.glb
#
# Done in 1.3s

While it runs, the Transform line names the step it is on (compressing textures, encoding draco, and the rest), so a slow model is never a silent one.

The optimized file is written beside the source as <model>-transformed.glb; the original is never touched. The generated useGLTF() points at the optimized file, and since the output is Draco-compressed it gets { draco: true } automatically.

  • --resolution <px> caps texture size (default 1024), and --format <fmt> picks the codec: webp (default), jpeg, png or avif.
  • --simplify reduces geometry with meshoptimizer, tuned by --ratio (fraction of vertices to keep) and --error (error ceiling, as a fraction of mesh radius).
  • --keepmeshes and --keepmaterials switch off mesh joining and material batching, which is what you want when the node structure has to survive.
Joining and simplification change the node graph, so node names (and therefore slot names) can move. Decide on --transformbefore you write slot overrides against them.

Instancing repeated meshes

--instance collapses meshes that share a geometry and a material into a single InstancedMesh. Because one SFC is one component, this restructures the output into two files: a provider that owns the load and the batches, and the model that renders into them.

tres gltf public/models/robot.glb --instance
# ▲ ■ ● Tres  gltf  robot.glb
#
#   instancing needs deduplicated geometry, so --transform is on and --keepmeshes with it
# ✔ Transform   755KB › 48KB  -94%                        1.1s
# ✔ Parse       12 named nodes · 3 meshes · 2 materials    31ms
# ✔ Emit        3 slots                                     1ms
#
# ✔ src/models/Robot.instances.gen.vue   ← owns the load and the batches
# ✔ src/models/Robot.gen.vue             ← renders <Instance> against them

The payoff is across copies, not inside one. Wrap them in the provider and the model is loaded and parsed once, with every copy costing the drawcalls of one:

App.vue
<script setup lang="ts">
import Robot from '@/models/Robot.gen.vue'
import RobotInstances from '@/models/Robot.instances.gen.vue'
</script>

<template>
  <RobotInstances>
    <Robot />
    <Robot :position="[3, 0, 0]" />
    <Robot :position="[-3, 0, 0]" />
  </RobotInstances>
</template>

The provider is found through provide/inject, so the copies can sit anywhere below it. A <Robot> rendered outside its provider throws with a message saying so, rather than rendering nothing.

Each copy still emits its own @ready and exposes isReady. The provider owns the load, so the model reads the injected data instead of an isLoading of its own: ready follows the actions binding when the model is animated, and the nodes populating when it is not.

--instance only batches a geometry that two or more meshes share. --instanceall batches every eligible mesh, including the ones that appear once, which pays off when the whole model is on screen many times. Skinned meshes and meshes with morph targets are never batched: an InstancedMesh has nowhere to put per-mesh skeletons or morph influences.

Instancing turns --transform on. Batching dedupes by geometry identity, and an unoptimized export hands three.js one geometry object per node however identical they are, so instancing without the pipeline finds nothing to batch. --keepmeshes comes with it, since joining would weld the repeats into a single mesh and leave nothing to batch either.
Batching does not cost a clip its target. An <Instance> is a real node with a real name, and the batch re-reads every instance's world matrix each frame, so a mixer drives batched geometry exactly as it drives a mesh. batch is what it joins, name is what it is called: a bucket is keyed after its first mesh, so the two differ as soon as more than one mesh shares a geometry.

Overriding an instanced node

Slots still work, but a batched node has no geometry or material of its own to hand you. It gets the batch's instead, its placement, and batch: the key its InstancedMesh registered under. That key is what an override needs to stay in the batch:

App.vue
<script setup lang="ts">
import { Instance } from '@tresjs/cientos'
import Robot from '@/models/Robot.gen.vue'
</script>

<template>
  <Robot>
    <!-- stays batched: one more instance in the same InstancedMesh -->
    <template #Screw="{ batch, position }">
      <Instance :batch :position="position" color="red" @click="loosen" />
    </template>

    <!-- leaves the batch: the batch's geometry and material, drawn as a mesh of its own -->
    <template #Panel="{ geometry, material, position }">
      <TresMesh :geometry :material :position />
    </template>
  </Robot>
</template>

Leaving the batch is one drawcall traded for control. That is exactly what the geometry and material bindings are for, but it is not free.

Pass batch, never the slot name. A bucket's batch is keyed after its first mesh, so a slot named Screw_3 may belong to the batch Screw_0. An <Instance> whose batch matches nothing registers with nothing and renders nothing, so it warns in the console rather than going quiet.
Lights and cameras are emitted as <primitive :object="..." />, and an Object3D has one parent, so a second copy of the model steals them from the first. The CLI warns when an instanced model contains any and suggests generating from a subtree with --root.

Inspecting a model

--dry-run reports what the parser sees and writes nothing:

tres gltf public/models/artificer.glb --dry-run
# ▲ ■ ● Tres  gltf  artificer.glb
#
# ✔ Parse       51 named nodes · 8 meshes · 1 material    53ms
#   76 animation clips
#   run without --dry-run to generate a component

Models with meshes that share a geometry and material also report how many instancing candidates they have, which is what --instance would batch. With --animations, each clip file is counted on its own line and the merged total below them — the total is not the sum: a name in two files counts once, and a clip nothing binds counts not at all.

tres gltf public/models/Dummy.glb \
  -a public/models/animations/Rig_Medium_General.glb \
  -a public/models/animations/Rig_Medium_MovementBasic.glb \
  -a public/models/animations/Rig_Medium_MovementAdvanced.glb \
  --dry-run
# ▲ ■ ● Tres  gltf  Dummy.glb
#
# ✔ Parse       51 named nodes · 8 meshes · 1 material · 37 clips merged   29ms
#   0 animation clips
#   + Rig_Medium_General.glb: 15 clips
#   + Rig_Medium_MovementBasic.glb: 11 clips
#   + Rig_Medium_MovementAdvanced.glb: 13 clips
#   37 clips merged
#   run without --dry-run to generate a component

--json dumps the full parse, and --console prints the component to stdout instead of writing it, which is handy for piping or for a quick look before committing. With --instance, both halves are printed, separated by the filename the provider would have been written to.

Options

FlagDefaultDescription
-o, --output <path><Model>.gen.vueWhere to write the component.
-u, --url <url>inferred from public/The url the model is served from at runtime.
-a, --animations <path>noneA glb/gltf to take animation clips from, merged with the model's own. Repeatable.
-s, --slots <mode>namednamed, all or none.
--shadowsfalseAdd cast-shadow and receive-shadow to every mesh.
-K, --keepgroupsfalseKeep pass-through groups that carry nothing but nesting.
-k, --keepnamesfalseKeep name attributes on the emitted elements.
-r, --root <name>scene rootGenerate from this subtree only.
-p, --precision <digits>2Fractional digits kept on transforms.
-m, --metafalseEmit glTF extras as :user-data.
-c, --consolefalsePrint the component instead of writing it.
-f, --forcefalseOverwrite a file this tool did not generate.
-v, --verbosefalseList every slot and clip name instead of the first few.
-T, --transformfalseOptimize the model into a separate -transformed.glb and generate against it.
-i, --instancefalseBatch meshes that share a geometry and material into an InstancedMesh. Implies --transform.
-I, --instanceallfalseBatch every eligible mesh, even the ones that appear once. Implies --transform.
--resolution <px>1024Max texture size when transforming.
--format <fmt>webpTexture format when transforming: webp, jpeg, png or avif.
--simplifyfalseReduce geometry with meshoptimizer when transforming.
--ratio <n>0Target fraction of vertices to keep with --simplify. 0 keeps as few as the error allows.
--error <n>0.001Error ceiling with --simplify, as a fraction of mesh radius.
--keepmeshesfalseDo not merge meshes when transforming.
--keepmaterialsfalseDo not batch materials when transforming.
--dry-runfalseReport what the parser sees, generate nothing.
--jsonfalsePrint the full parse as JSON.

Supported models

Both .glb and unpacked .gltf + .bin + textures work, as do Draco-compressed models. Pass the path to the file, not the url it is served from:

tres gltf /models/robot.glb        # ✗ that is the url
tres gltf public/models/robot.glb  # ✓ that is the file
Compare this workflow with useGLTF and GLTFModel in the cientos docs.