threlte logo
@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]
You can also use pnpm dlx, bunx, or any other package runner.

Options

OptionDescription
--output, -oOutput file name/path
--types, -tAdd Typescript definitions
--keepnames, -kKeep original names
--keepgroups, -KKeep (empty) groups, disable pruning
--meta, -mInclude metadata (as userData)
--shadows, -sLet meshes cast and receive shadows
--printwidth, -wPrettier printWidth (default: 120)
--precision, -pNumber of fractional digits (default: 2)
--draco, -dDraco binary path
--preload -PAdd preload method to module script
--suspense -uMake the component suspense-ready
--isolated, -iOutput as isolated module (no prop spreading)
--root, -rSets directory from which .gltf file is served
--transform, -TTransform the asset for the web (draco, prune, resize)
     --resolution, -RTransform resolution for texture resizing (default: 1024)
     --keepmeshes, -jDo not join compatible meshes
     --keepmaterials, -MDo not palette join materials
     --format, -fTexture format (default: “webp”)
     --simplify, -STransform simplification (default: false, experimental)
          --weldWeld tolerance (default: 0.0001)
          --ratioSimplifier ratio (default: 0.75)
          --errorSimplifier error threshold (default: 0.001)
--debug, -DDebug 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.