tres gltf
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:
<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:
<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:
ModelNodesandModelMaterialsdescribe this export, sonodes.Mugis aGroupand not anany.- 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
TresGroupstays mounted and gates its children withv-if="!isLoading", so refs on it are stable. @readyfires once per load, carrying{ nodes, materials }, andisReadyexposes the same state as a value. An event that already fired cannot be read after the fact, which is whatisReadyis for: a parent that binds late, or a state machine that wants to ask later. Refetching the model (useGLTFexposesexecute()) putsisReadyback tofalseand armsreadyagain.- Readiness reads
state, not justisLoading.isLoadingis cleared in afinally, so a 404 clears it exactly like a success would;stateis only set when the load produced a scene. Without that term a failed load would call the model ready with emptynodesandmaterials.
Overriding a node
The generated file is disposable. Anything you want to change lives in the parent, as a slot override:
<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>
@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:
<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.
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.
--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 (default1024), and--format <fmt>picks the codec:webp(default),jpeg,pngoravif.--simplifyreduces geometry with meshoptimizer, tuned by--ratio(fraction of vertices to keep) and--error(error ceiling, as a fraction of mesh radius).--keepmeshesand--keepmaterialsswitch off mesh joining and material batching, which is what you want when the node structure has to survive.
--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:
<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.
--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.<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:
<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.
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.<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
| Flag | Default | Description |
|---|---|---|
-o, --output <path> | <Model>.gen.vue | Where to write the component. |
-u, --url <url> | inferred from public/ | The url the model is served from at runtime. |
-a, --animations <path> | none | A glb/gltf to take animation clips from, merged with the model's own. Repeatable. |
-s, --slots <mode> | named | named, all or none. |
--shadows | false | Add cast-shadow and receive-shadow to every mesh. |
-K, --keepgroups | false | Keep pass-through groups that carry nothing but nesting. |
-k, --keepnames | false | Keep name attributes on the emitted elements. |
-r, --root <name> | scene root | Generate from this subtree only. |
-p, --precision <digits> | 2 | Fractional digits kept on transforms. |
-m, --meta | false | Emit glTF extras as :user-data. |
-c, --console | false | Print the component instead of writing it. |
-f, --force | false | Overwrite a file this tool did not generate. |
-v, --verbose | false | List every slot and clip name instead of the first few. |
-T, --transform | false | Optimize the model into a separate -transformed.glb and generate against it. |
-i, --instance | false | Batch meshes that share a geometry and material into an InstancedMesh. Implies --transform. |
-I, --instanceall | false | Batch every eligible mesh, even the ones that appear once. Implies --transform. |
--resolution <px> | 1024 | Max texture size when transforming. |
--format <fmt> | webp | Texture format when transforming: webp, jpeg, png or avif. |
--simplify | false | Reduce geometry with meshoptimizer when transforming. |
--ratio <n> | 0 | Target fraction of vertices to keep with --simplify. 0 keeps as few as the error allows. |
--error <n> | 0.001 | Error ceiling with --simplify, as a fraction of mesh radius. |
--keepmeshes | false | Do not merge meshes when transforming. |
--keepmaterials | false | Do not batch materials when transforming. |
--dry-run | false | Report what the parser sees, generate nothing. |
--json | false | Print 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