Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 108 additions & 2 deletions apps/docs/content/4.cli/2.gltf.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,92 @@ survives pruning for the same reason:
Nodes no clip mentions are unaffected, so this costs nothing on a model with no animation on it.
Batched nodes keep their names too: see [instancing](#instancing-repeated-meshes).

### Clips from separate files

Mixamo, KayKit and Quaternius all ship the mesh in one file and the clips in others, so a
mesh-only export has nothing to wire. Generating one says so:

```bash
tres gltf public/models/Dummy.glb
# ⚠ This model is skinned but carries no animation clips. Pass --animations <path> to wire in clips exported to separate files.
```

Point `--animations` at the clip files, once per file. A KayKit character, whose 39 clips ship
as three libraries beside the rig:

```bash
tres gltf public/models/Dummy.glb \
--animations public/models/animations/Rig_Medium_General.glb \
--animations public/models/animations/Rig_Medium_MovementBasic.glb \
--animations public/models/animations/Rig_Medium_MovementAdvanced.glb
# ▲ ■ ● Tres gltf Dummy.glb
#
# ✔ Parse 51 named nodes · 8 meshes · 1 material · 37 clips merged 37ms
# ✔ Emit 6 slots 1ms
#
# ✔ src/models/Dummy.gen.vue
# slots Dummy_ArmLeft, Dummy_ArmRight, Dummy_Body, Dummy_Head,
# Dummy_LegLeft, Dummy_LegRight
# clips Death_A, Death_A_Pose, Death_B, Death_B_Pose, Hit_A, Hit_B,
# … 31 more — rerun with --verbose
#
# Done in 87ms
```

The merged names are the ones `ActionName` will carry, so they are printed back; `--verbose`
lists all of them.

The component loads each one and merges the clips into a single array, the model's own first:

```ts
const { nodes, materials, isLoading } = useGLTF<ModelNodes, ModelMaterials>('/models/Dummy.glb')
const { state: rigMediumGeneral } = useGLTF('/models/animations/Rig_Medium_General.glb')
const { state: rigMediumMovementBasic } = useGLTF('/models/animations/Rig_Medium_MovementBasic.glb')
const { state: rigMediumMovementAdvanced } = useGLTF('/models/animations/Rig_Medium_MovementAdvanced.glb')

const animations = computed(() => {
// The mixer resolves every track against a node name in the rendered tree and never
// retries a miss, so the clips must not reach it before the model they drive.
if (isLoading.value) {
return []
}

return [
...(rigMediumGeneral.value?.animations ?? []),
...(rigMediumMovementBasic.value?.animations ?? []),
...(rigMediumMovementAdvanced.value?.animations ?? []),
]
})
```

That guard matters: a clip library is a fraction of the size of the model it drives, so its
files arrive first. Handing a mixer clips before the tree exists binds every track to nothing,
and three caches the miss instead of retrying it.

`ActionName` becomes the union across every file, and the node names the external clips drive
survive pruning exactly like the model's own would. Each file gets its own url, inferred from
`public/` the same way the model's is, and its own `{ draco: true }` when it is compressed.

#### When two files carry the same clip name

Clip libraries overlap — the three above all ship a `T-Pose`. The array decides: a mixer keys
`actions` walking it, so the **last** file passed wins, and an `--animations` clip always
overrides one the model came with. `ActionName` lists the name once. The CLI says which file
won rather than leaving it to be discovered:

```bash
# ⚠ Both Rig_Medium_General.glb and Rig_Medium_MovementBasic.glb carry "T-Pose". Rig_Medium_MovementBasic.glb is merged last, so its clip is the one that plays.
```

Pass the file you want to win last.

::prose-note
The CLI parses the clip files too, so it can compare each clip's track targets against the
model's node names — the one animation failure that is completely silent at runtime. A clip that
drives nodes this rig does not have gets a warning; a clip where **nothing** binds is left out of
`ActionName` entirely, since it could never play.
::

## Where the file is written

By default the component is written next to the model as `<Model>.gen.vue`, with one exception:
Expand Down Expand Up @@ -391,7 +477,26 @@ tres gltf public/models/artificer.glb --dry-run
```

Models with meshes that share a geometry and material also report how many *instancing
candidates* they have, which is what `--instance` would batch.
candidates* they have, which is what `--instance` would batch. With `--animations`, each clip
file is counted on its own line and the merged total below them — the total is not the sum: a
name in two files counts once, and a clip nothing binds counts not at all.

```bash
tres gltf public/models/Dummy.glb \
-a public/models/animations/Rig_Medium_General.glb \
-a public/models/animations/Rig_Medium_MovementBasic.glb \
-a public/models/animations/Rig_Medium_MovementAdvanced.glb \
--dry-run
# ▲ ■ ● Tres gltf Dummy.glb
#
# ✔ Parse 51 named nodes · 8 meshes · 1 material · 37 clips merged 29ms
# 0 animation clips
# + Rig_Medium_General.glb: 15 clips
# + Rig_Medium_MovementBasic.glb: 11 clips
# + Rig_Medium_MovementAdvanced.glb: 13 clips
# 37 clips merged
# run without --dry-run to generate a component
```

`--json` dumps the full parse, and `--console` prints the component to stdout instead of writing
it, which is handy for piping or for a quick look before committing. With `--instance`, both
Expand All @@ -403,6 +508,7 @@ halves are printed, separated by the filename the provider would have been writt
| :--- | :--- | :--- |
| `-o, --output <path>` | `<Model>.gen.vue` | Where to write the component. |
| `-u, --url <url>` | inferred from `public/` | The url the model is served from at runtime. |
| `-a, --animations <path>` | none | A glb/gltf to take animation clips from, merged with the model's own. Repeatable. |
| `-s, --slots <mode>` | `named` | `named`, `all` or `none`. |
| `--shadows` | `false` | Add `cast-shadow` and `receive-shadow` to every mesh. |
| `-K, --keepgroups` | `false` | Keep pass-through groups that carry nothing but nesting. |
Expand All @@ -412,7 +518,7 @@ halves are printed, separated by the filename the provider would have been writt
| `-m, --meta` | `false` | Emit glTF `extras` as `:user-data`. |
| `-c, --console` | `false` | Print the component instead of writing it. |
| `-f, --force` | `false` | Overwrite a file this tool did not generate. |
| `-v, --verbose` | `false` | List every slot name instead of the first few. |
| `-v, --verbose` | `false` | List every slot and clip name instead of the first few. |
| `-T, --transform` | `false` | Optimize the model into a separate `-transformed.glb` and generate against it. |
| `-i, --instance` | `false` | Batch meshes that share a geometry and material into an `InstancedMesh`. Implies `--transform`. |
| `-I, --instanceall` | `false` | Batch every eligible mesh, even the ones that appear once. Implies `--transform`. |
Expand Down
Binary file added apps/playground/public/models/Engineer.glb
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
165 changes: 165 additions & 0 deletions apps/playground/src/models/Engineer.gen.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<script setup lang="ts">
/*
Auto-generated by @tresjs/cli. Do not edit.
Command: tres gltf public/models/Engineer.glb -a public/models/animations/Rig_Medium/Rig_Medium_General.glb -a public/models/animations/Rig_Medium/Rig_Medium_MovementBasic.glb -a public/models/animations/Rig_Medium/Rig_Medium_MovementAdvanced.glb -o src/models --force
Override the named slots from the parent instead; regenerating keeps your overrides.
*/
import type { AnimationClip, Bone, Group, Mesh, MeshStandardMaterial, Object3D, SkinnedMesh } from 'three'
import { useAnimations, useGLTF } from '@tresjs/cientos'
import { computed, ref } from 'vue'

interface ModelNodes {
'Scene': Group
'Engineer_Rig': Object3D
'Engineer_ArmLeft': SkinnedMesh
'Engineer_ArmRight': SkinnedMesh
'Engineer_Body': SkinnedMesh
'Engineer_Head': SkinnedMesh
'Engineer_LegLeft': SkinnedMesh
'Engineer_LegRight': SkinnedMesh
'root': Bone
'hips': Bone
'spine': Bone
'chest': Bone
'upperarml': Bone
'lowerarml': Bone
'wristl': Bone
'handl': Bone
'handslotl': Bone
'upperarmr': Bone
'lowerarmr': Bone
'wristr': Bone
'handr': Bone
'handslotr': Bone
'head': Bone
'Engineer_Goggles': Mesh
'Engineer_Backpack': Mesh
'upperlegl': Bone
'lowerlegl': Bone
'footl': Bone
'toesl': Bone
'upperlegr': Bone
'lowerlegr': Bone
'footr': Bone
'toesr': Bone
'kneeIKl': Bone
'control-toe-rolll': Bone
'control-heel-rolll': Bone
'control-foot-rolll': Bone
'heelIKl': Bone
'IK-footl': Bone
'IK-toel': Bone
'kneeIKr': Bone
'control-toe-rollr': Bone
'control-heel-rollr': Bone
'control-foot-rollr': Bone
'heelIKr': Bone
'IK-footr': Bone
'IK-toer': Bone
'elbowIKl': Bone
'handIKl': Bone
'elbowIKr': Bone
'handIKr': Bone
}

interface ModelMaterials {
engineer_texture: MeshStandardMaterial
}

type ActionName
= | 'Death_A'
| 'Death_A_Pose'
| 'Death_B'
| 'Death_B_Pose'
| 'Hit_A'
| 'Hit_B'
| 'Idle_A'
| 'Idle_B'
| 'Interact'
| 'PickUp'
| 'Spawn_Air'
| 'Spawn_Ground'
| 'T-Pose'
| 'Throw'
| 'Use_Item'
| 'Jump_Full_Long'
| 'Jump_Full_Short'
| 'Jump_Idle'
| 'Jump_Land'
| 'Jump_Start'
| 'Running_A'
| 'Running_B'
| 'Walking_A'
| 'Walking_B'
| 'Walking_C'
| 'Crawling'
| 'Crouching'
| 'Dodge_Backward'
| 'Dodge_Forward'
| 'Dodge_Left'
| 'Dodge_Right'
| 'Running_HoldingBow'
| 'Running_HoldingRifle'
| 'Running_Strafe_Left'
| 'Running_Strafe_Right'
| 'Sneaking'
| 'Walking_Backwards'

defineSlots<{
Engineer_ArmLeft?: (props: { node: SkinnedMesh, material: MeshStandardMaterial }) => any
Engineer_ArmRight?: (props: { node: SkinnedMesh, material: MeshStandardMaterial }) => any
Engineer_Body?: (props: { node: SkinnedMesh, material: MeshStandardMaterial }) => any
Engineer_Head?: (props: { node: SkinnedMesh, material: MeshStandardMaterial }) => any
Engineer_LegLeft?: (props: { node: SkinnedMesh, material: MeshStandardMaterial }) => any
Engineer_LegRight?: (props: { node: SkinnedMesh, material: MeshStandardMaterial }) => any
}>()

const { nodes, materials, isLoading } = useGLTF<ModelNodes, ModelMaterials>('/models/Engineer.glb')
const { state: rigMediumGeneral } = useGLTF('/models/animations/Rig_Medium/Rig_Medium_General.glb')
const { state: rigMediumMovementBasic } = useGLTF('/models/animations/Rig_Medium/Rig_Medium_MovementBasic.glb')
const { state: rigMediumMovementAdvanced } = useGLTF('/models/animations/Rig_Medium/Rig_Medium_MovementAdvanced.glb')

const animations = computed(() => {
// The mixer resolves every track against a node name in the rendered tree and never
// retries a miss, so the clips must not reach it before the model they drive.
if (isLoading.value) {
return []
}

return [
...(rigMediumGeneral.value?.animations ?? []),
...(rigMediumMovementBasic.value?.animations ?? []),
...(rigMediumMovementAdvanced.value?.animations ?? []),
]
})
const modelRef = ref()
const { actions } = useAnimations<AnimationClip, ActionName>(animations, modelRef)

defineExpose({ nodes, materials, actions })
</script>

<template>
<TresGroup ref="modelRef" :dispose="null">
<template v-if="!isLoading">
<slot name="Engineer_ArmLeft" :node="nodes.Engineer_ArmLeft" :material="materials.engineer_texture">
<TresSkinnedMesh :geometry="nodes.Engineer_ArmLeft.geometry" :material="materials.engineer_texture" :skeleton="nodes.Engineer_ArmLeft.skeleton" />
</slot>
<slot name="Engineer_ArmRight" :node="nodes.Engineer_ArmRight" :material="materials.engineer_texture">
<TresSkinnedMesh :geometry="nodes.Engineer_ArmRight.geometry" :material="materials.engineer_texture" :skeleton="nodes.Engineer_ArmRight.skeleton" />
</slot>
<slot name="Engineer_Body" :node="nodes.Engineer_Body" :material="materials.engineer_texture">
<TresSkinnedMesh :geometry="nodes.Engineer_Body.geometry" :material="materials.engineer_texture" :skeleton="nodes.Engineer_Body.skeleton" />
</slot>
<slot name="Engineer_Head" :node="nodes.Engineer_Head" :material="materials.engineer_texture">
<TresSkinnedMesh :geometry="nodes.Engineer_Head.geometry" :material="materials.engineer_texture" :skeleton="nodes.Engineer_Head.skeleton" />
</slot>
<slot name="Engineer_LegLeft" :node="nodes.Engineer_LegLeft" :material="materials.engineer_texture">
<TresSkinnedMesh :geometry="nodes.Engineer_LegLeft.geometry" :material="materials.engineer_texture" :skeleton="nodes.Engineer_LegLeft.skeleton" />
</slot>
<slot name="Engineer_LegRight" :node="nodes.Engineer_LegRight" :material="materials.engineer_texture">
<TresSkinnedMesh :geometry="nodes.Engineer_LegRight.geometry" :material="materials.engineer_texture" :skeleton="nodes.Engineer_LegRight.skeleton" />
</slot>
<primitive :object="nodes.root" />
</template>
</TresGroup>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import { NoToneMapping, SRGBColorSpace } from 'three'
import { computed, ref, shallowRef, watch } from 'vue'
import Engineer from '@/models/Engineer.gen.vue'

// Engineer.glb carries the rig and zero clips; the 39 clips live in three KayKit libraries
// beside it. The generated component loads all four and merges them, so `actions` is typed
// with the union across every file — 37 entries, T-Pose being in all three:
// tres gltf public/models/Engineer.glb -o src/models \
// -a public/models/animations/Rig_Medium/Rig_Medium_General.glb \
// -a public/models/animations/Rig_Medium/Rig_Medium_MovementBasic.glb \
// -a public/models/animations/Rig_Medium/Rig_Medium_MovementAdvanced.glb
const gl = {
clearColor: '#1a1a1a',
alpha: false,
outputColorSpace: SRGBColorSpace,
toneMapping: NoToneMapping,
}

type EngineerAction = keyof NonNullable<InstanceType<typeof Engineer>['actions']>

const engineerRef = shallowRef<InstanceType<typeof Engineer>>()
const clips = computed(() => Object.keys(engineerRef.value?.actions ?? {}) as EngineerAction[])
const clip = ref<EngineerAction>('Idle_A')

watch([clips, clip], ([, name]) => {
const actions = engineerRef.value?.actions
if (!actions?.[name]) { return }
Object.values(actions).forEach(action => action?.stop())
actions[name].reset().fadeIn(0.2).play()
})
</script>

<template>
<div class="overlay">
<p>clips merged from separate files: {{ clips.length }}</p>
<select v-model="clip">
<option v-for="name in clips" :key="name">{{ name }}</option>
</select>
</div>

<TresCanvas v-bind="gl">
<TresPerspectiveCamera :position="[0, 1.5, 4]" :look-at="[0, 1, 0]" />
<OrbitControls />

<Engineer ref="engineerRef" />

<TresAmbientLight :intensity="2" />
<TresDirectionalLight :intensity="2" :position="[5, 10, 5]" />
</TresCanvas>
</template>

<style scoped>
.overlay {
position: fixed;
z-index: 1;
top: 1rem;
left: 1rem;
color: white;
font-family: monospace;
}
</style>
5 changes: 5 additions & 0 deletions apps/playground/src/router/routes/cientos/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export const loadersRoutes = [
name: 'GLTFCodegenSlots',
component: () => import('@/pages/cientos/loaders/gltf-codegen/index.vue'),
},
{
path: '/cientos/loaders/gltf-animations',
name: 'GLTFCodegenAnimations',
component: () => import('@/pages/cientos/loaders/gltf-animations/index.vue'),
},
{
path: '/cientos/loaders/gltf-instancing',
name: 'GLTFCodegenInstancing',
Expand Down
Loading
Loading