This is intended to be a temporary fork of
pmndrs/gltfjsx
. I submitted a large PR#286 with this in hopes of having it merged, but the orginal repository appears neglected and I am unsure if it will be reviewed and accepted.Given the delay, I needed to get published and move forward with our work in using my changes. I still hope this finds a home in the original repository as I appreciate the original authors and contributors.
If my PR is not accepted, I intend to keep this up-to-date for our purposes and accept PRs (that have tests!).
This is an API+CLI tool that turns GLTF assets into declarative and reusable components. At this time we focus on generating react-three-fiber JSX/TSX components, but the API can be used independently to generate code for other frameworks.
- GLTF is thrown whole 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
- 🧑💻 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, ...) and will perform better.
- ⚡️ It will optionally compress your model with up to 70%-90% size reduction.
gltfjsx-preview.mp4
Usage
$ npx @rosskevin/gltfjsx <Model.glb> <options>
Options
--output, -o Output src file name/path (default: Model.(j|t)sx)
--draco, -d Use draco to load file
--types, -t Write as .tsx file with types (default: true)
--keepnames, -k Keep original names
--keepgroups, -K Keep (empty) groups, disable pruning
--bones, -b Lay out bones declaratively (default: false)
--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: 3)
--root, -r Sets directory from which .gltf file is served
--exportdefault, -E Use default export
--console, -c Log JSX to console, won't produce a file
--debug, -D Debug output
The following options apply a series of transformations to the GLTF file via the @gltf-transform libraries:
--instance, -i Instance re-occuring geometry
--instanceall, -I Instance every geometry (for cheaper re-use)
--resolution, -R Resolution for texture resizing (default: 1024)
--keepmeshes, -j Do not join compatible meshes
--keepmaterials, -M Do not palette join materials
--keepattributes, Whether to keep unused vertex attributes, such as UVs without an assigned texture
--format, -f Texture format jpeg | png | webp | avif (default: "webp")
--simplify, -S Mesh simplification (default: false)
--ratio Simplifier ratio (default: 0)
--error Simplifier error threshold (default: 0.0001)
First you run your model through gltfjsx. npx
allows you to use npm packages without installing them.
npx @rosskevin/gltfjsx model.gltf --transform
This will create a Model.jsx
file that plots out all of the assets contents.
/*
Auto-generated by: https://github.com/pmndrs/gltfjsx
*/
import { PerspectiveCamera, useGLTF } from '@react-three/drei'
const modelLoadPath = '/model-transformed.glb'
export function Model(props) {
const { nodes, materials } = useGLTF(modelLoadPath)
return (
<group {...props} dispose={null}>
<PerspectiveCamera name="camera" fov={40} near={10} far={1000} position={[10, 0, 50]} />
<pointLight intensity={10} position={[100, 50, 100]} rotation={[-Math.PI / 2, 0, 0]} />
<group position={[10, -5, 0]}>
<mesh geometry={nodes.robot.geometry} material={materials.metal} />
<mesh geometry={nodes.rocket.geometry} material={materials.wood} />
</group>
</group>
)
}
useGLTF.preload(modelLoadPath)
Add your model to your /public
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.
/public
model-transformed.glb
The component can now be dropped into your scene.
import { Canvas } from '@react-three/fiber'
import { Model } from './Model'
function App() {
return (
<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]} />
<mesh geometry={nodes.robot.geometry} material={materials.metal} material-color="green" />
<mesh geometry={nodes.robot.geometry}>
<meshPhysicalMaterial color="hotpink" />
</mesh>
{
condition ? <mesh geometry={nodes.robot.geometry} material={materials.metal} /> : null
}
<mesh geometry={nodes.robot.geometry} material={materials.metal} onClick={handleClick} />
You don't need to do anything if your models are draco compressed, simply add the --draco
flag.
The asset will be preloaded by default, this makes it quicker to load and reduces time-to-paint. Remove the preloader if you don't need it.
With the --transform
flag it creates:
- a binary-packed
- draco-compressed
- texture-resized (1024x1024)
- webp compressed
- deduped
- instanced
- 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
.
Add the --types
flag and your GLTF will be typesafe and generate a .tsx
file instead of .jsx
with exported named types to match the inferred component name.
interface SpaceGLTF extends GLTF {
nodes: { robot: Mesh; rocket: Mesh }
materials: { metal: MeshStandardMaterial; wood: MeshStandardMaterial }
}
export interface SpaceProps extends GroupProps {}
export function Space(props: SpaceProps) {
const { nodes, materials } = useGLTF<GLTFResult>('/model.gltf')
}
If your GLTF contains animations it will add drei's useAnimations
hook, which extracts all clips and prepares them as actions:
const { nodes, materials, animations } = useGLTF('/model.gltf')
const { actions } = useAnimations(animations, group)
If you want to play an animation you can do so at any time:
<mesh onClick={(e) => actions.jump.play()} />
If you want to blend animations:
const [name, setName] = useState("jump")
...
useEffect(() => {
actions[name].reset().fadeIn(0.5).play()
return () => actions[name].fadeOut(0.5)
}, [name])
Use the --instance
flag and it will look for similar geometry and create instances of them. Look into drei/Instances and drei/Merged components to understand how it works. It does not matter if you instanced the model previously in Blender, it creates instances for each mesh that has a specific geometry and/or material.
--instanceall
will create instances of all the geometry. This allows you to re-use the model with the smallest amount of drawcalls.
Your export will look like something like this:
const context = createContext()
export function PartsInstances({ children, ...props }) {
const { nodes } = useGLTF(modelLoadPath, draco) as PartsGLTF
const instances = useMemo(() => ({ Screw1: nodes['Screw1'], Screw2: nodes['Screw2'] }), [nodes])
return (
<Merged meshes={instances} {...props}>
{(instances) => <context.Provider value={instances} children={children} />}
</Merged>
)
}
export function Parts(props) {
const instances = useContext(context)
return (
<group {...props} dispose={null}>
<instances.Screw1 position={[-0.42, 0.04, -0.08]} rotation={[-Math.PI / 2, 0, 0]} />
<instances.Screw2 position={[-0.42, 0.04, -0.08]} rotation={[-Math.PI / 2, 0, 0]} />
</group>
)
}
Note that similar to --transform
it also has to transform the model. In order to use and re-use the model import both Instances
and Model
. Put all your models into the Instances
component (you can nest).
The following will show the model three times, but you will only have 2 drawcalls tops.
import { Instances, Model } from './Model'
<Instances>
<Model position={[10,0,0]}>
<Model position={[-10,0,0]}>
<Model position={[-10,10,0]}>
</Instance>
This package is split into two entrypoints, one for the CLI, one for the API. You can use parts of, or advanced features of the API that are not easily exposed via command line e.g. exposeProps
in GenerateR3F
.
The API is broken down with the intent of supporting external use cases that generate
code for component systems other than react-three-fiber
. With that said, it is the intent of this project to support react-three-fiber
generation, and efforts for other frameworks may be determined to be out of scope.
- Transform -
gltfTransform
is an opinionated wrapper using glTF-Transform api - Load -
loadGLTF
is a small wrapper using maintained loaders from node-three-gltf - Analyze -
AnalyzedGLTF
deduplicates, prunes, and provides convenient accessors to the GLTF - Generate -
GenerateR3F
uses ts-morph to generate the source file, allowing for external changes or subclassing to customize behavior.
await gltfTransform(modelFile, toTransformedModelFile, options)
import { DRACOLoader } from 'node-three-gltf'
let dracoLoader = null
try {
dracoLoader = new DRACOLoader() // use a single instance for one to many models
const modelGLTF = await loadGLTF(modelFile, dracoLoader)
} finally {
dracoLoader?.dispose()
}
This can be useful to test GLTF assets (see test/loadGLTF.test.ts
):
it('should have a scene with a blue mesh', async () => {
const scene = await loadGLTF(modelFile, dracoLoader)
expect(() => scene.children.length).toEqual(1)
expect(() => scene.children[0].type).toEqual('mesh')
expect(() => scene.children[0].material.color).toEqual('blue')
})
You can subclass to modify behaviors, or provide additional or different pruning strategies.
const a = new AnalyzedGLTF(modelGLTF, { options })
Generate a tsx
or jsx
file. Access ts-morph
primitives directly on the GenerateR3F
class to modify before
formatting/stringifying with the to
methods.
const g = new GenerateR3F(a, genOptions)
// modify
g.src.insertStatements(1, `const foo = 'bar'`)
// stringify with or without types
g.toJsx()
g.toTsx()
Instead of instrumenting code by hand, especially for large models, you can exposeProps
that map the component props to arbitrary jsx
children.
For example, if you want to be able to turn on/off shadows via shadows: true
property, the following options:
exposeProps: {
shadows: {
to: ['castShadow', 'receiveShadow'],
structure: {
type: 'boolean',
hasQuestionToken: true,
},
},
},
would generate code using ts-morph
that will:
- Add all mapped props to the
ModelProps
interface - Destructure variables in the function body with a ...rest
- Change the identifer on the root
<group {...rest} />
element - Set the argument in the function signature
This roughly equates to this output:
export interface FlightHelmetProps extends GroupProps {
shadows?: boolean
}
export function FlightHelmet(props: FlightHelmetProps) {
const { nodes, materials } = useGLTF(modelLoadPath, draco) as FlightHelmetGLTF
const { shadows, ...rest } = props
return (
<group {...rest} dispose={null}>
<mesh castShadow={shadows} receiveShadow={shadows} />
</group>
)
}
NOTE: if the Object3D
property does not exist as part of calculated properties, it cannot be known at generation time that it is safe to add. If you want to force it to be added, use a matcher
(see next section).
A more powerful option is using the match specific nodes and expose those properties. Observe the following pseudo types (truncated for simplicity, see source for full type information):
interface GenerateOptions {
/**
* Expose component prop and propagate to matching Object3D props
* e.g. shadows->[castShadow, receiveShadow]
*/
exposeProps?: Record<string, MappedProp>
}
interface MappedProp {
/**
* Object3D prop(s)
* e.g. castShadow | [castShadow, receiveShadow]
* */
to: string | string[]
/**
* Match a specific type of object.
* If not provided, matches all that have the {to} prop
* */
matcher?: (o: Object3D, a: AnalyzedGLTF) => boolean
/**
* ts-morph prop structure (name is already supplied)
* */
structure: Omit<OptionalKind<PropertySignatureStructure>, 'name'>
}
This allows flexible exposure on arbitrary elements specified by the matcher
. For example, if I wanted to toggle visibility on/off for some named elements:
for (const search of ['base', 'side', 'top']) {
options.exposeProps![search] = {
to: ['visible'],
structure: {
type: 'boolean',
hasQuestionToken: true,
},
matcher: (o, a) =>
// note isGroup doesn't work, because the model may be Object3D that is converted at generation time
(isMesh(o) || getJsxElementName(o, a) == 'group') && o.name?.toLowerCase().includes(search),
}
}
this would map:
export interface FooProps extends GroupProps {
base?: boolean
side?: boolean
top?: boolean
}
to related elements. Because a matcher
is specified, the property will be added to every element matched.
- Nodejs >= 16 must be installed
- The GLTF file has to be present in your projects
/public
folder - three
- @react-three/fiber
- @react-three/drei