@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 threejs 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]
npx
at this moment. Options
Option | Description |
---|---|
--output, -o | Output file name/path |
--types, -t | Add Typescript definitions |
--keepnames, -k | Keep original names |
--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 $$restProps usage) |
--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) |
--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 you run your model through @threlte/gltf
. npx
allows you to use npm
packages without installing them.
npx @threlte/gltf@latest model.gltf --transform
This will create a Model.svelte
file that plots out all of the assets
contents.
<!--
Auto-generated by: https://github.com/pmndrs/gltfjsx
Command: npx gltfjsx@0.0.1 ./stacy.glb
-->
<script>
import { Group } from 'three'
import { T } from '@threlte/core'
import { useGltf, useGltfAnimations } from '@threlte/extras'
let {
ref = $bindable(),
actions = $bindable(),
mixer = $bindable(),
children,
...props
} = $props()
const gltf = useGltf('/stacy.glb')
const animations = useGltfAnimations(gltf, ref)
actions = animations.actions
mixer = animations.mixer
</script>
{#if $gltf}
<T.Group
bind:ref
{...props}
>
<T.Group name="Scene">
<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>
</T.Group>
{@render children?.({ ref })}
</T.Group>
{/if}
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, instanced 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/pmndrs/gltfjsx
Command: npx gltfjsx@0.0.1 ./stacy.glb -t
-->
<script lang="ts">
import type * as THREE from 'three'
import { Group } from 'three'
import { T, type Props } from '@threlte/core'
import { useGltf, useGltfAnimations } from '@threlte/extras'
import type { Snippet } from 'svelte'
type Props = {
ref?: Group
actions?: ReturnType<typeof useGltfAnimations>['actions']
mixer?: ReturnType<typeof useGltfAnimations>['mixer']
children?: Snippet<[{ ref: Group }]>
}
let {
ref = $bindable(),
actions = $bindable(),
mixer = $bindable(),
children,
...props
}: Props = $props()
const group = new Group()
type ActionName =
| 'pockets'
| 'rope'
| 'swingdance'
| 'jump'
| 'react'
| 'shrug'
| 'wave'
| 'golf'
| 'idle'
type GLTFResult = {
nodes: {
stacy: THREE.SkinnedMesh
mixamorigHips: THREE.Bone
}
materials: {}
}
const { actions, mixer } = useGltfAnimations<ActionName>(gltf, ref)
const gltf = useGltf<GLTFResult>('/stacy.glb')
actions = animations.actions
mixer = animations.mixer
</script>
{#if $gltf}
<T
bind:ref
is={group}
{...props}
>
<T.Group name="Scene">
<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>
</T.Group>
{@render children?.({ ref })}
</T>
{/if}
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()
}
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 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 { copyFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { exit } from 'node:process'
/**
* This script is used to transform gltf and glb files into Threlte components.
* It uses the `@threlte/gltf` package to do so.
* It works in two steps:
* 1. Transform the gltf/glb files located in the sourceDir directory
* 2. Move the Threlte components to the targetDir directory
*/
const configuration = {
sourceDir: resolve(join('static', 'models')),
targetDir: resolve(join('src', 'lib', 'components', 'models')),
overwrite: true,
root: '/models/',
types: true,
keepnames: true,
meta: false,
shadows: false,
printwidth: 120,
precision: 2,
draco: null,
preload: false,
suspense: false,
isolated: true,
transform: {
enabled: false,
resolution: 1024,
simplify: {
enabled: false,
weld: 0.0001,
ratio: 0.75,
error: 0.001
}
}
} as const
// if the target directory doesn't exist, create it
mkdirSync(configuration.targetDir, { recursive: true })
// throw error if source directory doesn't exist
if (!existsSync(configuration.sourceDir)) {
throw new Error(`Source directory ${configuration.sourceDir} doesn't exist.`)
}
// read the directory, filter for .glb and .gltf files and files *not* ending
// with -transformed.gltf or -transformed.glb as these should not be transformed
// again.
const gltfFiles = readdirSync(configuration.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.')
exit()
}
const filteredGltfFiles = gltfFiles.filter((file) => {
if (!configuration.overwrite) {
const componentFilename = file.split('.').slice(0, -1).join('.') + '.svelte'
const componentPath = join(configuration.targetDir, componentFilename)
if (existsSync(componentPath)) {
console.error(`File ${componentPath} already exists, skipping.`)
return false
}
}
return true
})
if (filteredGltfFiles.length === 0) {
console.log('No gltf or glb files to process.')
exit()
}
filteredGltfFiles.forEach((file) => {
// run the gltf transform command on every file
const path = join(configuration.sourceDir, file)
// parse the configuration
const args: string[] = []
if (configuration.root) args.push(`--root ${configuration.root}`)
if (configuration.types) args.push('--types')
if (configuration.keepnames) args.push('--keepnames')
if (configuration.meta) args.push('--meta')
if (configuration.shadows) args.push('--shadows')
args.push(`--printwidth ${configuration.printwidth}`)
args.push(`--precision ${configuration.precision}`)
if (configuration.draco) args.push(`--draco ${configuration.draco}`)
if (configuration.preload) args.push('--preload')
if (configuration.suspense) args.push('--suspense')
if (configuration.isolated) args.push('--isolated')
if (configuration.transform.enabled) {
args.push(`--transform`)
args.push(`--resolution ${configuration.transform.resolution}`)
if (configuration.transform.simplify.enabled) {
args.push(`--simplify`)
args.push(`--weld ${configuration.transform.simplify.weld}`)
args.push(`--ratio ${configuration.transform.simplify.ratio}`)
args.push(`--error ${configuration.transform.simplify.error}`)
}
}
const formattedArgs = args.join(' ')
// run the command
const cmd = `npx @threlte/gltf@next ${path} ${formattedArgs}`
try {
execSync(cmd, {
cwd: configuration.sourceDir
})
} catch (error) {
console.error(`Error transforming model: ${error}`)
}
})
// read dir again, but search for .svelte files only.
const svelteFiles = readdirSync(configuration.sourceDir).filter((file) => file.endsWith('.svelte'))
svelteFiles.forEach((file) => {
// now move every file to /src/components/models
const path = join(configuration.sourceDir, file)
const newPath = join(configuration.targetDir, file)
copyFile: try {
// Sanity check, we checked earlier if the file exists. Still, the CLI takes
// a while, so who knows what happens in the meantime.
if (!configuration.overwrite) {
// check if file already exists
if (existsSync(newPath)) {
console.error(`File ${newPath} already exists, skipping.`)
break copyFile
}
}
copyFileSync(path, newPath)
} catch (error) {
console.error(`Error copying file: ${error}`)
}
// remove the file from /static/models
try {
unlinkSync(path)
} catch (error) {
console.error(`Error removing file: ${error}`)
}
})
Place this script in scripts/transform-models.ts
and run it with npx tsx scripts/model-pipeline.ts
.