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 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]
The CLI supports only npx at this moment.

Options

OptionDescription
--output, -oOutput file name/path
--types, -tAdd Typescript definitions
--keepnames, -kKeep original names
--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 $$restProps usage)
--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)
     --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 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.