@threlte/gltf
Getting Started
A small command-line tool that turns GLTF assets into declarative and re-usable Threlte components.
The GLTF workflow on the web is not ideal.
- GLTF is thrown wholesale into the scene which prevents re-use, in Three.js objects can only be mounted once
- Contents can only be found by traversal which is cumbersome and slow
- Changes to queried nodes are made by mutation, which alters the source data and prevents re-use
- Re-structuring content, making nodes conditional or adding/removing is cumbersome
- Model compression is complex and not easily achieved
- Models often have unnecessary nodes that cause extra work and matrix updates
@threlte/gltf fixes that.
- It creates a virtual graph of all objects and materials. Now you can easily alter contents and re-use.
- The graph gets pruned (empty groups, unnecessary transforms, …) for better performance.
- It will optionally compress your model with up to 70%-90% size reduction.
Usage
npx @threlte/gltf@latest /path/to/Model.glb [options]
pnpm dlx, bunx, or any other package runner.
Options
| Option | Description |
|---|---|
--output, -o | Output file name/path |
--types, -t | Add Typescript definitions |
--keepnames, -k | Keep original names |
--keepgroups, -K | Keep (empty) groups, disable pruning |
--meta, -m | Include metadata (as userData) |
--shadows, -s | Let meshes cast and receive shadows |
--printwidth, -w | Prettier printWidth (default: 120) |
--precision, -p | Number of fractional digits (default: 2) |
--draco, -d | Draco binary path |
--preload -P | Add preload method to module script |
--suspense -u | Make the component suspense-ready |
--isolated, -i | Output as isolated module (no prop spreading) |
--root, -r | Sets directory from which .gltf file is served |
--transform, -T | Transform the asset for the web (draco, prune, resize) |
--resolution, -R | Transform resolution for texture resizing (default: 1024) |
--keepmeshes, -j | Do not join compatible meshes |
--keepmaterials, -M | Do not palette join materials |
--format, -f | Texture format (default: “webp”) |
--simplify, -S | Transform simplification (default: false, experimental) |
--weld | Weld tolerance (default: 0.0001) |
--ratio | Simplifier ratio (default: 0.75) |
--error | Simplifier error threshold (default: 0.001) |
--debug, -D | Debug output |
Example
This example assumes you have your model set up and exported from an application like Blender as a GLTF file.
First, run your model through @threlte/gltf. npx allows you to use npm
packages without installing them.
npx @threlte/gltf@latest model.glb --transform
This will create a Model.svelte file that plots out all of the assets
contents.
<!--
Auto-generated by: https://github.com/threlte/threlte/tree/main/packages/gltf
Command: npx @threlte/gltf@latest ./stacy.glb
-->
<script>
import { Group } from 'three'
import { T } from '@threlte/core'
import { useGltf, useGltfAnimations } from '@threlte/extras'
let { fallback, error, children, ref = $bindable(), ...props } = $props()
ref = new Group()
const gltf = useGltf('/stacy.glb')
export const { actions, mixer } = useGltfAnimations(gltf, ref)
</script>
<T
is={ref}
dispose={false}
{...props}
>
{#await gltf}
{@render fallback?.()}
{:then gltf}
<T.Group
name="Stacy"
rotation={[Math.PI / 2, 0, 0]}
scale={0.01}
>
<T is={gltf.nodes.mixamorigHips} />
<T.SkinnedMesh
name="stacy"
geometry={gltf.nodes.stacy.geometry}
material={gltf.nodes.stacy.material}
skeleton={gltf.nodes.stacy.skeleton}
rotation={[-Math.PI / 2, 0, 0]}
scale={100}
/>
</T.Group>
{:catch err}
{@render error?.({ error: err })}
{/await}
{@render children?.({ ref })}
</T>
Add your model to your /static folder as you would normally do. With the
--transform flag it has created a compressed copy of it (in the above case
model-transformed.glb). Without the flag just copy the original model.
static/
model-transformed.glb
The component can now be dropped into your scene.
<script>
import { Canvas } from '@threlte/core'
import Model from './Model.svelte'
</script>
<Canvas>
<Model />
</Canvas>
You can re-use it, it will re-use geometries and materials out of the box:
<Model position={[0, 0, 0]} />
<Model position={[10, 0, -10]} />
Or make the model dynamic. Change its colors for example:
<T.Mesh
geometry={$gltf.nodes.robot.geometry}
material={$gltf.materials.metal}
material.color="green"
/>
Or exchange materials:
<T.Mesh geometry={$gltf.nodes.robot.geometry}>
<T.MeshPhysicalMaterial color="hotpink" />
</T.Mesh>
Make contents conditional:
{#if condition}
<T.Mesh
geometry={$gltf.nodes.robot.geometry}
material={$gltf.materials.metal}
/>
{/if}
DRACO Compression
You don’t need to do anything if your models are draco compressed, since
useGltf defaults to a draco CDN.
By adding the --draco flag you can refer to local
binaries
which must reside in your /public folder.
Auto-Transform
With the --transform flag it creates a binary-packed, draco-compressed,
texture-resized (1024x1024), webp compressed, deduped and pruned
*.glb ready to be consumed on a web site. It uses
glTF-Transform. This can reduce
the size of an asset by 70%-90%.
It will not alter the original but create a copy and append
[modelname]-transformed.glb.
Type-Safety
Add the --types flag and your component will be typesafe.
<!--
Auto-generated by: https://github.com/threlte/threlte/tree/main/packages/gltf
Command: npx @threlte/gltf@latest ./stacy.glb -t
-->
<script lang="ts">
import type * as THREE from 'three'
import { Group } from 'three'
import type { Snippet } from 'svelte'
import { T, type Props } from '@threlte/core'
import { useGltf, useGltfAnimations } from '@threlte/extras'
type ActionName =
| 'pockets'
| 'rope'
| 'swingdance'
| 'jump'
| 'react'
| 'shrug'
| 'wave'
| 'golf'
| 'idle'
type GLTFResult = {
nodes: {
stacy: THREE.SkinnedMesh
mixamorigHips: THREE.Bone
}
materials: {}
}
let {
fallback,
error,
children,
ref = $bindable(),
...props
}: Props<THREE.Group> & {
ref?: THREE.Group
children?: Snippet<[{ ref: THREE.Group }]>
fallback?: Snippet
error?: Snippet<[{ error: Error }]>
} = $props()
ref = new Group()
const gltf = useGltf<GLTFResult>('/stacy.glb')
export const { actions, mixer } = useGltfAnimations<ActionName>(gltf, ref)
</script>
<T
is={ref}
dispose={false}
{...props}
>
{#await gltf}
{@render fallback?.()}
{:then gltf}
<T.Group
name="Stacy"
rotation={[Math.PI / 2, 0, 0]}
scale={0.01}
>
<T is={gltf.nodes.mixamorigHips} />
<T.SkinnedMesh
name="stacy"
geometry={gltf.nodes.stacy.geometry}
material={gltf.nodes.stacy.material}
skeleton={gltf.nodes.stacy.skeleton}
rotation={[-Math.PI / 2, 0, 0]}
scale={100}
/>
</T.Group>
{:catch err}
{@render error?.({ error: err })}
{/await}
{@render children?.({ ref })}
</T>
Animations
If your GLTF contains animations it will add @threlte/extras’s
useGltfAnimations hook, which
extracts all clips and prepares them as actions:
const gltf = useGltf('/stacy.glb')
export const { actions, mixer } = useGltfAnimations(gltf, ref)
If you want to play an animation you can do so at any time:
const onEvent = () => {
$actions.jump.play()
}
Skinned Meshes
useGltf caches results by URL. This means all instances of a generated
component share the same geometry, materials, skeletons, and bones. For regular
meshes this is ideal — geometry and materials can be shared across instances
without duplicating GPU memory.
However, SkinnedMesh bones and skeletons are Object3D instances that can
only have one parent at a time. If you mount multiple instances of a component
that contains skinned meshes, only the last one will render correctly because it
“steals” the shared bones from previous instances.
@threlte/gltf handles this automatically. When your model contains skinned
meshes, the generated component uses
SkeletonUtils.clone
to clone the bone hierarchy per instance while still sharing geometry and
materials from the cache:
{:then gltf}
{@const clonedNodes = cloneScene(gltf.scene)}
<T is={clonedNodes.Hips} />
<T.SkinnedMesh
geometry={gltf.nodes.Body.geometry}
material={gltf.materials.Skin}
skeleton={clonedNodes.Body.skeleton}
/>
{/then}
This gives each component instance its own skeleton and bones, while geometry and materials remain shared — no duplicated GPU memory.
<script lang="ts">
import { Canvas } from '@threlte/core'
import Scene from './Scene.svelte'
</script>
<div>
<Canvas>
<Scene />
</Canvas>
</div>
<style>
div {
height: 100%;
}
</style>
<!--
Auto-generated by: https://github.com/threlte/threlte/tree/main/packages/gltf
Command: npx @threlte/gltf@3.0.5 apps/docs/public/models/RobotExpressive.glb --types --root /models/ --output apps/docs/src/examples/gltf/skinned-meshes/Robot.svelte
-->
<script lang="ts">
import type * as THREE from 'three'
import { Group } from 'three'
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js'
import type { Snippet } from 'svelte'
import { T, type Props } from '@threlte/core'
import { useGltf, useGltfAnimations } from '@threlte/extras'
type ActionName =
| 'Dance'
| 'Death'
| 'Idle'
| 'Jump'
| 'No'
| 'Punch'
| 'Running'
| 'Sitting'
| 'Standing'
| 'ThumbsUp'
| 'Walking'
| 'WalkJump'
| 'Wave'
| 'Yes'
type GLTFResult = {
nodes: {
FootL_1: THREE.Mesh
LowerLegL_1: THREE.Mesh
LegL: THREE.Mesh
LowerLegR_1: THREE.Mesh
LegR: THREE.Mesh
Head_2: THREE.Mesh
Head_3: THREE.Mesh
Head_4: THREE.Mesh
ArmL: THREE.Mesh
ShoulderL_1: THREE.Mesh
ArmR: THREE.Mesh
ShoulderR_1: THREE.Mesh
Torso_2: THREE.Mesh
Torso_3: THREE.Mesh
FootR_1: THREE.Mesh
HandR_1: THREE.SkinnedMesh
HandR_2: THREE.SkinnedMesh
HandL_1: THREE.SkinnedMesh
HandL_2: THREE.SkinnedMesh
Bone: THREE.Bone
}
materials: {
Grey: THREE.MeshStandardMaterial
Main: THREE.MeshStandardMaterial
Black: THREE.MeshStandardMaterial
}
}
let {
fallback,
error,
children,
action,
ref = $bindable(),
...props
}: Props<THREE.Group> & {
ref?: THREE.Group
action?: ActionName
children?: Snippet<[{ ref: THREE.Group }]>
fallback?: Snippet
error?: Snippet<[{ error: Error }]>
} = $props()
ref = new Group()
const gltf = useGltf<GLTFResult>('/models/RobotExpressive.glb')
function cloneScene(scene: THREE.Group) {
const clone = cloneSkeleton(scene)
const nodes: Record<string, THREE.Object3D> = {}
clone.traverse((child) => {
if (child.name) nodes[child.name] = child
})
return nodes as unknown as GLTFResult['nodes']
}
export const { actions, mixer } = useGltfAnimations<ActionName>(gltf, ref)
$effect(() => {
if (action) $actions?.[action]?.play()
})
</script>
<T
is={ref}
dispose={false}
{...props}
>
{#await gltf}
{@render fallback?.()}
{:then gltf}
{@const clonedNodes = cloneScene(gltf.scene)}
<T.Group name="Root_Scene">
<T.Group name="RootNode">
<T.Group
name="RobotArmature"
rotation={[-Math.PI / 2, 0, 0]}
scale={100}
>
<T is={clonedNodes.Bone} />
</T.Group>
<T.Group
name="HandR"
position={[0, 2.37, -0.02]}
rotation={[-Math.PI / 2, 0, 0]}
scale={100}
>
<T.SkinnedMesh
name="HandR_1"
geometry={gltf.nodes.HandR_1.geometry}
material={gltf.materials.Main}
skeleton={clonedNodes.HandR_1.skeleton}
/>
<T.SkinnedMesh
name="HandR_2"
geometry={gltf.nodes.HandR_2.geometry}
material={gltf.materials.Grey}
skeleton={clonedNodes.HandR_2.skeleton}
/>
</T.Group>
<T.Group
name="HandL"
position={[0, 2.37, -0.02]}
rotation={[-Math.PI / 2, 0, 0]}
scale={100}
>
<T.SkinnedMesh
name="HandL_1"
geometry={gltf.nodes.HandL_1.geometry}
material={gltf.materials.Main}
skeleton={clonedNodes.HandL_1.skeleton}
/>
<T.SkinnedMesh
name="HandL_2"
geometry={gltf.nodes.HandL_2.geometry}
material={gltf.materials.Grey}
skeleton={clonedNodes.HandL_2.skeleton}
/>
</T.Group>
</T.Group>
</T.Group>
{:catch err}
{@render error?.({ error: err })}
{/await}
{@render children?.({ ref })}
</T>
<script lang="ts">
import { T } from '@threlte/core'
import Robot from './Robot.svelte'
import { Grid } from '@threlte/extras'
import { Vector3 } from 'three'
</script>
<T.PerspectiveCamera
makeDefault
position={[0, 4, 15]}
fov={50}
oncreate={(ref) => {
ref.lookAt(0, 1, 0)
}}
/>
<Grid
cellColor="yellow"
sectionColor="orange"
infiniteGrid
fadeDistance={10}
fadeOrigin={new Vector3(0, 0, 0)}
/>
<T.DirectionalLight
position={[5, 5, 5]}
intensity={1.5}
/>
<T.AmbientLight intensity={0.5} />
<Robot
action="Walking"
position={[-4, 0, 0]}
oncreate={(ref) => {
ref.lookAt(0, 0, 0)
}}
/>
<Robot
action="Running"
position={[0, 0, 0]}
/>
<Robot
action="Dance"
position={[4, 0, 0]}
oncreate={(ref) => {
ref.lookAt(0, 0, 0)
}}
/>
Model: RobotExpressive from the Three.js examples
Suspense
If you want to use the component <Suspense>
to suspend the rendering of loading components (and therefore models) and
optionally show a fallback in a parent component, you can do so by
passing the flag --suspense to make the generated Threlte component
suspense-ready:
<script>
import Model from './Model.svelte'
import Fallback from './Fallback.svelte'
import { Suspense } from '@threlte/extras'
</script>
<Suspense>
{#snippet fallback()}
<Fallback />
{/snippet}
<Model />
</Suspense>
Asset Pipeline
In larger projects with a lot of models and assets, it’s recommended to set up
an asset pipeline with tools like
npm-watch and Node.js scripts to
automatically transform models to Threlte components and copy them to the right
place as this makes iterating over models and assets much faster. Here’s an
example script that you can use as a starting point:
import { execSync } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync } from 'node:fs'
import { join, parse, resolve } from 'node:path'
/**
* Transforms GLTF/GLB files into Threlte components using @threlte/gltf.
* Source models are read from sourceDir, and generated Svelte components
* are written directly to targetDir via the --output flag.
*/
const config = {
sourceDir: resolve('static', 'models'),
targetDir: resolve('src', 'lib', 'components', 'models'),
overwrite: true,
root: '/models/',
types: true,
keepnames: true,
meta: false,
shadows: false,
printwidth: 120,
precision: 2,
draco: '',
preload: false,
suspense: false,
isolated: true,
transform: {
enabled: false,
resolution: 1024,
format: 'webp',
keepmeshes: false,
keepmaterials: false,
simplify: {
enabled: false,
weld: 0.0001,
ratio: 0.75,
error: 0.001
}
}
}
mkdirSync(config.targetDir, { recursive: true })
if (!existsSync(config.sourceDir)) {
throw new Error(`Source directory ${config.sourceDir} doesn't exist.`)
}
const gltfFiles = readdirSync(config.sourceDir).filter((file) => {
return (
(file.endsWith('.glb') || file.endsWith('.gltf')) &&
!file.endsWith('-transformed.gltf') &&
!file.endsWith('-transformed.glb')
)
})
if (gltfFiles.length === 0) {
console.log('No gltf or glb files found.')
process.exit()
}
for (const file of gltfFiles) {
const inputPath = join(config.sourceDir, file)
const outputPath = join(config.targetDir, `${parse(file).name}.svelte`)
if (!config.overwrite && existsSync(outputPath)) {
console.log(`${outputPath} already exists, skipping.`)
continue
}
const args: string[] = [`--output ${outputPath}`]
if (config.root) args.push(`--root ${config.root}`)
if (config.types) args.push('--types')
if (config.keepnames) args.push('--keepnames')
if (config.meta) args.push('--meta')
if (config.shadows) args.push('--shadows')
args.push(`--printwidth ${config.printwidth}`)
args.push(`--precision ${config.precision}`)
if (config.draco) args.push(`--draco ${config.draco}`)
if (config.preload) args.push('--preload')
if (config.suspense) args.push('--suspense')
if (config.isolated) args.push('--isolated')
if (config.transform.enabled) {
args.push('--transform')
args.push(`--resolution ${config.transform.resolution}`)
args.push(`--format ${config.transform.format}`)
if (config.transform.keepmeshes) args.push('--keepmeshes')
if (config.transform.keepmaterials) args.push('--keepmaterials')
if (config.transform.simplify.enabled) {
args.push('--simplify')
args.push(`--weld ${config.transform.simplify.weld}`)
args.push(`--ratio ${config.transform.simplify.ratio}`)
args.push(`--error ${config.transform.simplify.error}`)
}
}
const cmd = `npx @threlte/gltf@latest ${inputPath} ${args.join(' ')}`
try {
execSync(cmd, { cwd: config.sourceDir })
console.log(`Generated ${outputPath}`)
} catch (error) {
console.error(`Error transforming ${file}: ${error}`)
}
}
Place this script in scripts/transform-models.ts and run it with npx tsx scripts/transform-models.ts.