diff --git a/apps/docs/content/4.cli/2.gltf.md b/apps/docs/content/4.cli/2.gltf.md index ad6ba294d..ea45bd1ac 100644 --- a/apps/docs/content/4.cli/2.gltf.md +++ b/apps/docs/content/4.cli/2.gltf.md @@ -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 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('/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 `.gen.vue`, with one exception: @@ -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 @@ -403,6 +508,7 @@ halves are printed, separated by the filename the provider would have been writt | :--- | :--- | :--- | | `-o, --output ` | `.gen.vue` | Where to write the component. | | `-u, --url ` | inferred from `public/` | The url the model is served from at runtime. | +| `-a, --animations ` | none | A glb/gltf to take animation clips from, merged with the model's own. Repeatable. | | `-s, --slots ` | `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. | @@ -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`. | diff --git a/apps/playground/public/models/Engineer.glb b/apps/playground/public/models/Engineer.glb new file mode 100644 index 000000000..aeb71ba0e Binary files /dev/null and b/apps/playground/public/models/Engineer.glb differ diff --git a/apps/playground/public/models/animations/Rig_Medium/Rig_Medium_General.glb b/apps/playground/public/models/animations/Rig_Medium/Rig_Medium_General.glb new file mode 100644 index 000000000..5d16cb681 Binary files /dev/null and b/apps/playground/public/models/animations/Rig_Medium/Rig_Medium_General.glb differ diff --git a/apps/playground/public/models/animations/Rig_Medium/Rig_Medium_MovementAdvanced.glb b/apps/playground/public/models/animations/Rig_Medium/Rig_Medium_MovementAdvanced.glb new file mode 100644 index 000000000..f3ea30962 Binary files /dev/null and b/apps/playground/public/models/animations/Rig_Medium/Rig_Medium_MovementAdvanced.glb differ diff --git a/apps/playground/public/models/animations/Rig_Medium/Rig_Medium_MovementBasic.glb b/apps/playground/public/models/animations/Rig_Medium/Rig_Medium_MovementBasic.glb new file mode 100644 index 000000000..98e965e88 Binary files /dev/null and b/apps/playground/public/models/animations/Rig_Medium/Rig_Medium_MovementBasic.glb differ diff --git a/apps/playground/src/models/Engineer.gen.vue b/apps/playground/src/models/Engineer.gen.vue new file mode 100644 index 000000000..f093fa2ed --- /dev/null +++ b/apps/playground/src/models/Engineer.gen.vue @@ -0,0 +1,165 @@ + + + diff --git a/apps/playground/src/pages/cientos/loaders/gltf-animations/index.vue b/apps/playground/src/pages/cientos/loaders/gltf-animations/index.vue new file mode 100644 index 000000000..33baf51f2 --- /dev/null +++ b/apps/playground/src/pages/cientos/loaders/gltf-animations/index.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/apps/playground/src/router/routes/cientos/loaders.ts b/apps/playground/src/router/routes/cientos/loaders.ts index 6809e22f2..ae89ae933 100644 --- a/apps/playground/src/router/routes/cientos/loaders.ts +++ b/apps/playground/src/router/routes/cientos/loaders.ts @@ -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', diff --git a/packages/cli/README.md b/packages/cli/README.md index 09138b51c..7ef5b5e17 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -62,6 +62,7 @@ onMounted(() => robot.value?.actions.Idle?.play()) | --- | --- | | `-o, --output ` | file, or a directory to write `.gen.vue` into (default: beside the model) | | `-u, --url ` | url the model is served from (default: inferred from `public/`) | +| `-a, --animations ` | take clips from another glb, merged with the model's own; repeatable | | `-s, --slots ` | `named` (default), `all`, `none` | | `--shadows` | add `cast-shadow` / `receive-shadow` | | `-K, --keepgroups` | keep pass-through groups | @@ -98,6 +99,20 @@ tres gltf public/models/Dummy.glb -o src/models # ✔ src/models/Dummy.gen.vue ``` +#### `--animations` + +Mixamo, KayKit and Quaternius ship the mesh in one file and the clips in others. +Pass each clip file and they are merged into one array, model first, with +`ActionName` unioned across all of them: + +```bash +tres gltf public/models/Dummy.glb \ + -a public/models/Idle.glb -a public/models/Running_A.glb +``` + +Both files are parsed, so the CLI also checks each clip's track targets against the +model's node names — the one animation failure that is silent at runtime. + #### `--transform` Runs the model through [glTF-Transform](https://github.com/donmccurdy/glTF-Transform) diff --git a/packages/cli/src/commands/gltf.test.ts b/packages/cli/src/commands/gltf.test.ts index 322993560..95b6b4f9f 100644 --- a/packages/cli/src/commands/gltf.test.ts +++ b/packages/cli/src/commands/gltf.test.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { collidingNamesGLB, nestedGLB, repeatedGeometryGLB, simpleGLB } from '../gltf/__fixtures__/scenes' +import { clipOnlyGLB, collidingNamesGLB, nestedGLB, repeatedGeometryGLB, simpleGLB, skinnedNoClipsGLB } from '../gltf/__fixtures__/scenes' import gltf from './gltf' describe('gltf command', () => { @@ -238,6 +238,50 @@ describe('gltf command', () => { expect(chrome()).toContain('3 meshes') }) + describe('--animations', () => { + it('wires clips from separate files into the component', async () => { + const model = await fixture('Dummy.glb', skinnedNoClipsGLB(), 'rig/public/models') + const idle = await fixture('Idle.glb', clipOnlyGLB('Idle'), 'rig/public/clips') + await writeFile(join(dir, 'rig', 'package.json'), '{}') + + await gltf.call({} as any, model, { animations: [idle] }) + + const generated = await readFile(join(dir, 'rig/models/Dummy.gen.vue'), 'utf-8') + expect(generated).toContain(`const { state: idle } = useGLTF('/clips/Idle.glb')`) + expect(generated).toContain(' ...(idle.value?.animations ?? []),') + expect(generated).toContain(`type ActionName\n = | 'Idle'`) + }) + + it('reports what each source carries under --dry-run', async () => { + const model = await fixture('Dummy.glb', skinnedNoClipsGLB(), 'count') + const idle = await fixture('Idle.glb', clipOnlyGLB('Idle'), 'count') + const run = await fixture('Run.glb', clipOnlyGLB('Run'), 'count') + + await gltf.call({} as any, model, { dryRun: true, animations: [idle, run] }) + + expect(chrome()).toContain('0 animation clips') + expect(chrome()).toContain('+ Idle.glb: 1 clip') + expect(chrome()).toContain('+ Run.glb: 1 clip') + expect(chrome()).toContain('2 clips merged') + }) + + it('points a skinned model with nothing to play at the flag', async () => { + const model = await fixture('Dummy.glb', skinnedNoClipsGLB(), 'quiet') + + await gltf.call({} as any, model, { console: true }) + + expect(chrome()).toContain('--animations') + }) + + it('says which animation file is missing rather than a bare ENOENT', async () => { + const model = await fixture('Dummy.glb', skinnedNoClipsGLB(), 'absent') + + await expect(gltf.call({} as any, model, { animations: ['/public/clips/Idle.glb'] })) + .rejects + .toThrow(/\/public\/clips\/Idle\.glb does not exist/) + }) + }) + it('optimizes to a separate -transformed.glb and generates against it', async () => { const path = await fixture('robot.glb', nestedGLB(), 'served/public/models') const out = join(dir, 'served/Robot.gen.vue') diff --git a/packages/cli/src/commands/gltf.ts b/packages/cli/src/commands/gltf.ts index bd64b3ce1..c62bf1ac1 100644 --- a/packages/cli/src/commands/gltf.ts +++ b/packages/cli/src/commands/gltf.ts @@ -1,3 +1,4 @@ +import type { AnimationSourceInput } from '../gltf/build-ir' import type { IRNode } from '../gltf/ir' import type { CommandHandler } from '../registry' import type { TextureFormat } from '../gltf/transform' @@ -17,6 +18,8 @@ import { glyph, green } from '../ui/theme' export interface GLTFOptions { url?: string + /** Files to take animation clips from, merged with whatever the model carries. */ + animations?: string[] output?: string slots?: 'named' | 'all' | 'none' shadows?: boolean @@ -293,18 +296,36 @@ const gltf: CommandHandler = async function (input: string, options: GLTFOptions } } + const animationPaths = options.animations ?? [] + try { const ir = await ui.phase( 'Parse', - () => loadGLTFFile(model) - .catch(async (error) => { + async (task) => { + const loaded = await loadGLTFFile(model).catch(async (error) => { throw await explainMissingFile(model, error) }) - .then(buildIR), + + // One at a time, so the line names the clip library it is on. A rig's animation + // libraries are the same weight as the model, and there can be several of them. + const sources: AnimationSourceInput[] = [] + for (const path of animationPaths) { + task.update(basename(path)) + sources.push({ + path, + gltf: await loadGLTFFile(path).catch(async (error) => { + throw await explainMissingFile(path, error) + }), + }) + } + + return buildIR(loaded, sources) + }, parsed => [ plural(Object.keys(parsed.nodes).length, 'named node'), plural(Object.values(parsed.nodes).filter(node => node.type.endsWith('Mesh')).length, 'mesh', 'es'), plural(Object.keys(parsed.materials).length, 'material'), + ...(parsed.animationSources.length ? [`${plural(parsed.clips.length, 'clip')} merged`] : []), ].join(gray(' · ')), ) @@ -325,6 +346,14 @@ const gltf: CommandHandler = async function (input: string, options: GLTFOptions const colliders = countColliders(ir.root) ui.note(plural(ir.animations.length, 'animation clip')) + for (const source of ir.animationSources) { + ui.note(`+ ${basename(source.path)}: ${plural(source.clips.length, 'clip')}`) + } + if (ir.animationSources.length) { + // The union an ActionName would offer, which is neither the sum nor the model's own: + // a name in two files counts once, and a clip nothing binds counts not at all. + ui.note(`${plural(ir.clips.length, 'clip')} merged`) + } if (candidates) { ui.note(plural(candidates, 'instancing candidate')) } @@ -341,6 +370,17 @@ const gltf: CommandHandler = async function (input: string, options: GLTFOptions ui.note('Pass --url to set it explicitly.') } + // Each clip file is loaded at runtime in its own right, so each needs its own url. + // There is no --animations-url: move the file under public/ instead. + const animationURLs: string[] = [] + for (const source of ir.animationSources) { + const clipAsset = await inferAssetURL(source.path) + if (!clipAsset.inferred) { + ui.warn(`No public/ directory above ${source.path}, so its url is a guess: ${clipAsset.url}`) + } + animationURLs.push(clipAsset.url) + } + // The provider's path is settled before the emit: the consumer imports it by name. const target = options.console ? undefined @@ -352,6 +392,7 @@ const gltf: CommandHandler = async function (input: string, options: GLTFOptions 'Emit', () => emitSFC(ir, { url: asset.url, + animationURLs, name, slots: options.slots, shadows: options.shadows, @@ -402,6 +443,11 @@ const gltf: CommandHandler = async function (input: string, options: GLTFOptions ui.success(bold(path)) } ui.list('slots', slots, options.verbose ? undefined : SLOT_PREVIEW, 'rerun with --verbose') + // The merged names are the ActionName union the consumer will type against, and they + // came from files the user only named on the command line — worth printing back. + if (ir.animationSources.length) { + ui.list('clips', ir.clips, options.verbose ? undefined : SLOT_PREVIEW, 'rerun with --verbose') + } if (wantsTransform) { ui.note(`useGLTF() now loads ${basename(model)}`) } diff --git a/packages/cli/src/emit/compiles.test.ts b/packages/cli/src/emit/compiles.test.ts index f30d4d37f..bcf6c5380 100644 --- a/packages/cli/src/emit/compiles.test.ts +++ b/packages/cli/src/emit/compiles.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest' import { compileScript, compileTemplate, parse } from 'vue/compiler-sfc' import { buildIR } from '../gltf/build-ir' import { loadGLTF } from '../gltf/load' -import { lightAndCameraGLB, mixedInstancingGLB, morphAndMetaGLB, nestedGLB, physicsGLB, sketchfabGLB, skinnedGLB } from '../gltf/__fixtures__/scenes' +import { clipOnlyGLB, lightAndCameraGLB, mixedInstancingGLB, morphAndMetaGLB, nestedGLB, physicsGLB, sketchfabGLB, skinnedGLB, skinnedNoClipsGLB } from '../gltf/__fixtures__/scenes' import { emitSFC } from './sfc' const CASES = { @@ -101,6 +101,44 @@ describe('generated output compiles', () => { expect(template.code).toContain(`batch: 'Rock_0'`) }) + it('compiles a model whose clips all come from other files', async () => { + const ir = buildIR(await loadGLTF(await skinnedNoClipsGLB()), [ + { path: 'clips/Idle.glb', gltf: await loadGLTF(await clipOnlyGLB('Idle')) }, + { path: 'clips/Running_A.glb', gltf: await loadGLTF(await clipOnlyGLB('Running_A')) }, + ]) + const { code } = emitSFC(ir, { + url: '/model.glb', + slots: 'all', + animationURLs: ['/clips/Idle.glb', '/clips/Running_A.glb'], + }) + + const { parseErrors, templateErrors, script } = compile(code, 'model') + + expect(parseErrors).toEqual([]) + expect(templateErrors).toEqual([]) + expect(script.content).toContain('useAnimations') + }) + + it('compiles both halves when the provider owns the clip files too', async () => { + const ir = buildIR(await loadGLTF(await mixedInstancingGLB()), [ + { path: 'clips/Spin.glb', gltf: await loadGLTF(await clipOnlyGLB('Spin', ['Rock_0'])) }, + ]) + const { code, instances } = emitSFC(ir, { + url: '/model.glb', + name: 'Rocks', + slots: 'all', + instance: true, + animationURLs: ['/clips/Spin.glb'], + }) + + for (const [id, source] of [['rocks', code], ['rocks-instances', instances!]] as const) { + const { parseErrors, templateErrors } = compile(source, id) + + expect(parseErrors, id).toEqual([]) + expect(templateErrors, id).toEqual([]) + } + }) + it('keeps bracket-access keys intact through compilation', async () => { const ir = buildIR(await loadGLTF(await nestedGLB())) const { code } = emitSFC(ir, { url: '/model.glb', keepGroups: true }) diff --git a/packages/cli/src/emit/instances.test.ts b/packages/cli/src/emit/instances.test.ts index a64fbe39e..9a5682182 100644 --- a/packages/cli/src/emit/instances.test.ts +++ b/packages/cli/src/emit/instances.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { buildIR } from '../gltf/build-ir' import { loadGLTF } from '../gltf/load' import { + clipOnlyGLB, mixedInstancingGLB, morphAndMetaGLB, nestedGLB, @@ -196,4 +197,51 @@ describe('--instance', () => { expect(code).toContain(`from '../models/Rocks.instances.gen.vue'`) }) + + describe('with --animations', () => { + /** The provider owns every load, so the clip files load there too, not per copy. */ + async function emitWithClips(glb: Promise, paths: { path: string, glb: Promise }[]) { + const ir = buildIR( + await loadGLTF(await glb), + await Promise.all(paths.map(async source => ({ path: source.path, gltf: await loadGLTF(await source.glb) }))), + ) + + return emitSFC(ir, { + url: '/models/rocks.glb', + name: 'Rocks', + instance: true, + animationURLs: paths.map(source => `/clips/${source.path.split('/').pop()}`), + }) + } + + it('loads the clip files in the provider, once for every copy of the model', async () => { + const { code, instances } = await emitWithClips(repeatedGeometryGLB(), [ + { path: 'clips/Spin.glb', glb: clipOnlyGLB('Spin', ['Rock_0']) }, + ]) + + expect(instances).toContain(`const { state: spin } = useGLTF('/clips/Spin.glb')`) + expect(instances).toContain(' ...(spin.value?.animations ?? []),') + expect(code).not.toContain('useGLTF') + }) + + it('provides the merged clips the way it already provides nodes and materials', async () => { + const { code, instances } = await emitWithClips(repeatedGeometryGLB(), [ + { path: 'clips/Spin.glb', glb: clipOnlyGLB('Spin', ['Rock_0']) }, + ]) + + expect(instances).toContain(`provide('tres-gltf:Rocks', { nodes, materials, animations })`) + expect(instances).toContain(`${' '}animations: ComputedRef`) + expect(code).toContain('const { nodes, materials, animations } = context') + expect(code).toContain('const { actions } = useAnimations(animations, modelRef)') + }) + + it('exports the union across every file for the model half to import', async () => { + const { instances } = await emitWithClips(repeatedGeometryGLB(), [ + { path: 'clips/Spin.glb', glb: clipOnlyGLB('Spin', ['Rock_0']) }, + { path: 'clips/Bounce.glb', glb: clipOnlyGLB('Bounce', ['Rock_1']) }, + ]) + + expect(instances).toContain(`export type ActionName\n = | 'Spin'\n | 'Bounce'`) + }) + }) }) diff --git a/packages/cli/src/emit/instances.ts b/packages/cli/src/emit/instances.ts index 323ff53e5..217531350 100644 --- a/packages/cli/src/emit/instances.ts +++ b/packages/cli/src/emit/instances.ts @@ -8,7 +8,7 @@ */ import type { GLTFIR } from '../gltf/ir' import type { InstancePlan } from './instancing' -import { access, declarer, header, INDENT, modelTypes } from './shared' +import { access, clipLoads, clipSources, declarer, header, INDENT, mergedClips, modelTypes } from './shared' export interface EmitInstancesOptions { /** What the component passes to `useGLTF`. */ @@ -20,6 +20,8 @@ export interface EmitInstancesOptions { plan: InstancePlan /** Recorded in the header so regeneration is reproducible. */ command?: string + /** Url per `--animations` file, index-matched to `ir.animationSources`. */ + animationURLs?: string[] } /** @@ -36,7 +38,9 @@ export function contextKey(name: string): string { export function emitInstancesSFC(ir: GLTFIR, options: EmitInstancesOptions): { code: string } { const { url, name = 'Model', shadows = false, plan, command } = options - const hasAnimations = ir.animations.length > 0 + const hasAnimations = ir.clips.length > 0 + const hasOwnClips = ir.animations.length > 0 + const sources = clipSources(ir.animationSources, options.animationURLs) const loaderArgs = ir.draco ? `'${url}', { draco: true }` : `'${url}'` const { lines: types, threeTypes } = modelTypes(ir, true) @@ -54,9 +58,10 @@ export function emitInstancesSFC(ir: GLTFIR, options: EmitInstancesOptions): { c const meshes = plan.batches.map(batch => `${INDENT}${declareBatch(batch.key)}: ${access('nodes.value', batch.key)},`) - const loaded = hasAnimations - ? `const { state, nodes, materials, isLoading } = useGLTF(${loaderArgs})` - : `const { nodes, materials, isLoading } = useGLTF(${loaderArgs})` + const loaded = [ + `const { ${[...(hasOwnClips ? ['state'] : []), 'nodes', 'materials', 'isLoading'].join(', ')} } = useGLTF(${loaderArgs})`, + ...clipLoads(sources), + ] const provided = ['nodes', 'materials', ...(hasAnimations ? ['animations'] : [])] @@ -81,7 +86,7 @@ export function emitInstancesSFC(ir: GLTFIR, options: EmitInstancesOptions): { c `// Initial buffer allocation per batch; the batch grows past it if more instances register.`, `withDefaults(defineProps<{ limit?: number }>(), { limit: 100 })`, '', - loaded, + ...loaded, '', '// One InstancedMesh per entry. Every in the tree joins the batch', '// registered under that key, wherever in the hierarchy it sits.', @@ -89,7 +94,7 @@ export function emitInstancesSFC(ir: GLTFIR, options: EmitInstancesOptions): { c ...meshes, '}))', '', - ...(hasAnimations ? [`const animations = computed(() => state.value?.animations ?? [])`, ''] : []), + ...(hasAnimations ? [...mergedClips(hasOwnClips, sources), ''] : []), `provide('${contextKey(name)}', { ${provided.join(', ')} })`, '', `defineExpose({ nodes, materials })`, diff --git a/packages/cli/src/emit/sfc.test.ts b/packages/cli/src/emit/sfc.test.ts index 4ef864f39..4f77cd354 100644 --- a/packages/cli/src/emit/sfc.test.ts +++ b/packages/cli/src/emit/sfc.test.ts @@ -3,15 +3,18 @@ import { describe, expect, it } from 'vitest' import { buildIR } from '../gltf/build-ir' import { loadGLTF } from '../gltf/load' import { + clipOnlyGLB, collidingNamesGLB, exporterNamedGLB, lightAndCameraGLB, morphAndMetaGLB, nestedGLB, objectAnimatedGLB, + repeatedGeometryGLB, simpleGLB, sketchfabGLB, skinnedGLB, + skinnedNoClipsGLB, } from '../gltf/__fixtures__/scenes' import { emitSFC } from './sfc' @@ -20,6 +23,27 @@ async function emit(glb: Promise, options: Partial = { return emitSFC(ir, { url: '/models/robot.glb', ...options }) } +/** The `--animations` path: the model in one file, the clips in others. */ +async function emitWithClips( + glb: Promise, + sources: { path: string, glb: Promise }[], + options: Partial = {}, +) { + const ir = buildIR( + await loadGLTF(await glb), + await Promise.all(sources.map(async source => ({ path: source.path, gltf: await loadGLTF(await source.glb) }))), + ) + + return { + ir, + ...emitSFC(ir, { + url: '/models/dummy.glb', + animationURLs: sources.map(source => `/clips/${source.path.split('/').pop()}`), + ...options, + }), + } +} + describe('emitSFC', () => { it('loads the model from the given url', async () => { const { code } = await emit(simpleGLB()) @@ -286,6 +310,8 @@ describe('emitSFC', () => { nodes: { Odd: { type: 'MeshWeirdMaterialThing', isVarName: true } }, materials: { Paint: { type: 'ImaginaryMaterial', isVarName: true } }, animations: [], + animationSources: [], + clips: [], animated: [], draco: false, instances: [], @@ -384,4 +410,118 @@ describe('emitSFC', () => { expect(code).toContain(':morph-target-dictionary="nodes.Face.morphTargetDictionary"') expect(code).toContain(':morph-target-influences="nodes.Face.morphTargetInfluences"') }) + + describe('--animations', () => { + it('loads every clip file beside the model', async () => { + const { code } = await emitWithClips(skinnedNoClipsGLB(), [ + { path: 'clips/Idle.glb', glb: clipOnlyGLB('Idle') }, + { path: 'clips/Running_A.glb', glb: clipOnlyGLB('Running_A') }, + ]) + + expect(code).toContain(`const { state: idle } = useGLTF('/clips/Idle.glb')`) + expect(code).toContain(`const { state: runningA } = useGLTF('/clips/Running_A.glb')`) + }) + + // Real clip libraries are named `Rig_Medium_MovementBasic`; lowercasing past the first + // letter of each part would read as `rigMediumMovementbasic`. + it('keeps the casing the filename authored', async () => { + const { code } = await emitWithClips(skinnedNoClipsGLB(), [ + { path: 'clips/Rig_Medium_MovementBasic.glb', glb: clipOnlyGLB('Walking_A') }, + ]) + + expect(code).toContain('const { state: rigMediumMovementBasic } =') + }) + + it('merges the clips in one array, model first', async () => { + const { code } = await emitWithClips(skinnedGLB(), [{ path: 'clips/Run.glb', glb: clipOnlyGLB('Run') }]) + + expect(code).toContain([ + ' return [', + ' ...(state.value?.animations ?? []),', + ' ...(run.value?.animations ?? []),', + ' ]', + ].join('\n')) + }) + + // A clip library is a fraction of the size of the model, so its files land first. A mixer + // handed clips before the tree exists binds every track to nothing and caches the miss. + it('holds the clips back until the model they drive has rendered', async () => { + const { code } = await emitWithClips(skinnedNoClipsGLB(), [{ path: 'clips/Idle.glb', glb: clipOnlyGLB('Idle') }]) + + expect(code).toContain('if (isLoading.value) {') + expect(code).toContain('return []') + }) + + it('never destructures a state the model has no clips to put in', async () => { + const { code } = await emitWithClips(skinnedNoClipsGLB(), [{ path: 'clips/Idle.glb', glb: clipOnlyGLB('Idle') }]) + + // An unused `state` is an error under the consumer's noUnusedLocals. + expect(code).toContain(`const { nodes, materials, isLoading } = useGLTF('/models/dummy.glb')`) + expect(code).toContain(' ...(idle.value?.animations ?? []),') + expect(code).not.toContain('state.value?.animations') + }) + + it('unions the clip names across every file', async () => { + const { code } = await emitWithClips(skinnedGLB(), [ + { path: 'clips/Run.glb', glb: clipOnlyGLB('Run') }, + { path: 'clips/Jump.glb', glb: clipOnlyGLB('Jump') }, + ]) + + expect(code).toContain(`type ActionName\n = | 'Idle'\n | 'Run'\n | 'Jump'`) + }) + + it('wires useAnimations for a model that carries no clips of its own', async () => { + const { code } = await emitWithClips(skinnedNoClipsGLB(), [{ path: 'clips/Idle.glb', glb: clipOnlyGLB('Idle') }]) + + expect(code).toContain(`import { useAnimations, useGLTF } from '@tresjs/cientos'`) + expect(code).toContain('const { actions } = useAnimations(animations, modelRef)') + expect(code).toContain('') + }) + + it('keeps the name of a node only an external clip drives', async () => { + const { code } = await emitWithClips(repeatedGeometryGLB(), [ + { path: 'clips/Spin.glb', glb: clipOnlyGLB('Spin', ['Rock_0']) }, + ]) + + expect(code).toContain(' { + const { ir } = await emitWithClips(skinnedNoClipsGLB(), [{ path: 'clips/Idle.glb', glb: clipOnlyGLB('Idle') }]) + ir.animationSources[0].draco = true + + const { code } = emitSFC(ir, { url: '/models/dummy.glb', animationURLs: ['/clips/Idle.glb'] }) + + expect(code).toContain(`const { state: idle } = useGLTF('/clips/Idle.glb', { draco: true })`) + expect(code).toContain(`useGLTF('/models/dummy.glb')`) + }) + + it('names a file that is not an identifier after its position instead', async () => { + const { code } = await emitWithClips(skinnedNoClipsGLB(), [ + { path: 'clips/1H_Melee_Chop.glb', glb: clipOnlyGLB('1H_Melee_Chop') }, + ]) + + expect(code).toContain(`const { state: clips0 } = useGLTF('/clips/1H_Melee_Chop.glb')`) + }) + + it('never shadows an identifier the generated file already owns', async () => { + const { code } = await emitWithClips(skinnedNoClipsGLB(), [ + { path: 'clips/nodes.glb', glb: clipOnlyGLB('Idle') }, + ]) + + expect(code).not.toContain('const { state: nodes }') + expect(code).toContain('const { state: nodes0 }') + }) + + it('leaves out a file whose clips reach nothing in this model', async () => { + const { code } = await emitWithClips(skinnedGLB(), [ + { path: 'clips/Wrong.glb', glb: clipOnlyGLB('Wrong', ['mixamorigHips']) }, + ]) + + // Loading a file to merge nothing out of it is a request for nothing. + expect(code).not.toContain('/clips/Wrong.glb') + expect(code).toContain('const animations = computed(() => state.value?.animations ?? [])') + }) + }) }) diff --git a/packages/cli/src/emit/sfc.ts b/packages/cli/src/emit/sfc.ts index 4ce878f3b..2999bbb9d 100644 --- a/packages/cli/src/emit/sfc.ts +++ b/packages/cli/src/emit/sfc.ts @@ -4,7 +4,7 @@ import type { InstancePlan } from './instancing' import { contextKey, emitInstancesSFC } from './instances' import { NO_INSTANCING, planInstancing } from './instancing' import { bodyAttributes, colliderOf, colliderProxy, physicsWarnings, RAPIER_IMPORT } from './physics' -import { access, declarer, header, importable, INDENT, modelTypes, round, tuple } from './shared' +import { access, clipLoads, clipSources, declarer, header, importable, INDENT, mergedClips, modelTypes, round, tuple } from './shared' export interface EmitOptions { /** What the component passes to `useGLTF`. */ @@ -32,6 +32,8 @@ export interface EmitOptions { physics?: 'rapier' /** Import specifier for the emitted provider, when instancing. */ instancesModule?: string + /** Url per `--animations` file, index-matched to `ir.animationSources`. */ + animationURLs?: string[] /** Recorded in the header so regeneration is reproducible. */ command?: string } @@ -424,7 +426,10 @@ export function emitSFC(ir: GLTFIR, options: EmitOptions): EmitResult { ) } - const hasAnimations = ir.animations.length > 0 + const hasAnimations = ir.clips.length > 0 + /** Only a model with clips of its own needs `state`, and an unused one trips noUnusedLocals. */ + const hasOwnClips = ir.animations.length > 0 + const sources = clipSources(ir.animationSources, options.animationURLs) const loaderArgs = ir.draco ? `'${url}', { draco: true }` : `'${url}'` const { lines: localTypes, threeTypes: modelThreeTypes } = modelTypes(ir) @@ -499,9 +504,10 @@ export function emitSFC(ir: GLTFIR, options: EmitOptions): EmitResult { ] : hasAnimations ? [ - `const { state, nodes, materials, isLoading } = useGLTF(${loaderArgs})`, + `const { ${[...(hasOwnClips ? ['state'] : []), 'nodes', 'materials', 'isLoading'].join(', ')} } = useGLTF(${loaderArgs})`, + ...clipLoads(sources), '', - `const animations = computed(() => state.value?.animations ?? [])`, + ...mergedClips(hasOwnClips, sources), ...animationSetup, ] : [ @@ -584,7 +590,7 @@ export function emitSFC(ir: GLTFIR, options: EmitOptions): EmitResult { return { code, instances: instanced - ? emitInstancesSFC(ir, { url, name, shadows, plan, command }).code + ? emitInstancesSFC(ir, { url, name, shadows, plan, command, animationURLs: options.animationURLs }).code : undefined, slots, warnings, diff --git a/packages/cli/src/emit/shared.ts b/packages/cli/src/emit/shared.ts index 66290182a..4598e4990 100644 --- a/packages/cli/src/emit/shared.ts +++ b/packages/cli/src/emit/shared.ts @@ -3,7 +3,8 @@ * provider and a consumer file, and the two have to agree on how a key is written * or the consumer's `nodes.Foo` misses the provider's `nodes['Foo']`. */ -import type { GLTFIR, Vector3Tuple } from '../gltf/ir' +import type { GLTFIR, IRAnimationSource, Vector3Tuple } from '../gltf/ir' +import { basename } from 'node:path' import * as THREE from 'three' export const INDENT = ' ' @@ -55,6 +56,109 @@ export function header(command: string | undefined, ...notes: string[]): string[ ].filter(Boolean) } +/** + * Identifiers the generated files already own. A clip file called `nodes.glb` must not + * shadow one of them. + */ +const RESERVED = new Set([ + 'state', + 'nodes', + 'materials', + 'isLoading', + 'animations', + 'modelRef', + 'actions', + 'context', + 'meshes', + 'limit', + 'props', +]) + +/** A `--animations` file as the emitted file sees it: one `useGLTF` call and one spread. */ +export interface ClipSource { + /** What the `state` of its `useGLTF` is renamed to. */ + variable: string + url: string + draco: boolean +} + +/** + * `clips/Running_A.glb` → `runningA`. Empty or digit-led names fall back to the index. + * + * Only the first letter of each part is touched: a clip library is called + * `Rig_Medium_MovementBasic`, and lowercasing the rest would read as `Movementbasic`. + */ +function toVariable(path: string, index: number, taken: Set): string { + const parts = basename(path).replace(/\.[^.]+$/, '').split(/[^a-z0-9]+/i).filter(Boolean) + const camel = parts + .map((part, position) => position === 0 + ? part[0].toLowerCase() + part.slice(1) + : part[0].toUpperCase() + part.slice(1)) + .join('') + + let name = isVarName(camel) ? camel : `clips${index}` + while (taken.has(name)) { + name = `${name}${index}` + } + + return name +} + +/** + * The `--animations` files worth loading at runtime, with the url the command inferred for + * each. A source none of whose clips reach this model is dropped: loading a file to merge + * nothing out of it is a request for nothing. + */ +export function clipSources(sources: IRAnimationSource[], urls: string[] = []): ClipSource[] { + const taken = new Set(RESERVED) + + return sources + .map((source, index) => ({ source, url: urls[index] ?? `/${basename(source.path)}` })) + .filter(({ source }) => source.bound.length > 0) + .map(({ source, url }, index) => { + const variable = toVariable(source.path, index, taken) + taken.add(variable) + return { variable, url, draco: source.draco } + }) +} + +/** One `useGLTF` per clip file. Only its clips are read, so nothing else is destructured. */ +export function clipLoads(sources: ClipSource[]): string[] { + return sources.map(({ variable, url, draco }) => + `const { state: ${variable} } = useGLTF(${draco ? `'${url}', { draco: true }` : `'${url}'`})`) +} + +/** + * The array handed to `useAnimations`. Model first, then each file in the order it was passed: + * a mixer keys `actions` walking the array, so the last clip of a given name is the one that + * plays, and merging a clip library is meant to override what the model came with. + * + * The `isLoading` guard is not decoration. A clip library is a fraction of the size of the + * model it drives, so its files land first; a mixer handed clips before the tree exists binds + * every track to nothing, and three caches that miss rather than retrying it. + */ +export function mergedClips(ownClips: boolean, sources: ClipSource[]): string[] { + if (sources.length === 0) { + return [`const animations = computed(() => state.value?.animations ?? [])`] + } + + const terms = [...(ownClips ? ['state'] : []), ...sources.map(source => source.variable)] + + return [ + 'const animations = computed(() => {', + `${INDENT}// The mixer resolves every track against a node name in the rendered tree and never`, + `${INDENT}// retries a miss, so the clips must not reach it before the model they drive.`, + `${INDENT}if (isLoading.value) {`, + `${INDENT.repeat(2)}return []`, + `${INDENT}}`, + '', + `${INDENT}return [`, + ...terms.map(term => `${INDENT.repeat(2)}...(${term}.value?.animations ?? []),`), + `${INDENT}]`, + '})', + ] +} + /** * The `ModelNodes` / `ModelMaterials` / `ActionName` declarations. They come straight from * the parsed model, so the file describes this export and no other: a re-export that drops @@ -69,7 +173,7 @@ export function modelTypes(ir: GLTFIR, exported = false): { lines: string[], thr ...Object.values(ir.nodes).map(entry => importable(entry.type, 'Object3D')), ...Object.values(ir.materials).map(entry => importable(entry.type, 'Material')), ]) - if (ir.animations.length > 0) { + if (ir.clips.length > 0) { threeTypes.add('AnimationClip') } @@ -85,11 +189,11 @@ export function modelTypes(ir: GLTFIR, exported = false): { lines: string[], thr '}', // `=` leads its line and the members line up under it: `style/operator-linebreak`, // the same shape core writes its own unions in. - ...(ir.animations.length > 0 + ...(ir.clips.length > 0 ? [ '', `${prefix}type ActionName`, - ...ir.animations.map((clip, index) => + ...ir.clips.map((clip, index) => `${index === 0 ? `${INDENT}= ` : INDENT.repeat(2)}| '${clip.replace(/'/g, '\\\'')}'`), ] : []), diff --git a/packages/cli/src/gltf/__fixtures__/scenes.ts b/packages/cli/src/gltf/__fixtures__/scenes.ts index 9db1c2411..d3cff1587 100644 --- a/packages/cli/src/gltf/__fixtures__/scenes.ts +++ b/packages/cli/src/gltf/__fixtures__/scenes.ts @@ -443,8 +443,8 @@ export async function writeUnpackedGLTF(dir: string): Promise { return path } -/** A skinned mesh with one bone and one clip. */ -export function skinnedGLB(): Promise { +/** A skinned mesh with one bone, plus whatever clips the caller wants on it. */ +function skinnedScene(): Group { const scene = new Group() scene.name = 'Scene' @@ -457,9 +457,42 @@ export function skinnedGLB(): Promise { skinned.bind(new Skeleton([bone])) scene.add(skinned) + return scene +} + +/** A skinned mesh with one bone and one clip. */ +export function skinnedGLB(): Promise { const clip = new AnimationClip('Idle', 1, [ new VectorKeyframeTrack('hand.l.position', [0, 1], [0, 0, 0, 0, 1, 0]), ]) + return toGLB(skinnedScene(), [clip]) +} + +/** + * The same rig with no clips at all: a mesh-only export, half of the rig-plus-clip-library + * pipeline `--animations` exists for. + */ +export function skinnedNoClipsGLB(): Promise { + return toGLB(skinnedScene()) +} + +/** + * The other half: a clip and the bones it drives, no mesh. Pass node names the rig does not + * have to fake a bad retarget. + */ +export function clipOnlyGLB(name: string, nodes: string[] = ['hand.l']): Promise { + const scene = new Group() + scene.name = 'Scene' + + for (const node of nodes) { + const bone = new Bone() + bone.name = node + scene.add(bone) + } + + const clip = new AnimationClip(name, 1, nodes.map(node => + new VectorKeyframeTrack(`${node}.position`, [0, 1], [0, 0, 0, 0, 1, 0]))) + return toGLB(scene, [clip]) } diff --git a/packages/cli/src/gltf/build-ir.test.ts b/packages/cli/src/gltf/build-ir.test.ts index 615e48e9b..0271d434c 100644 --- a/packages/cli/src/gltf/build-ir.test.ts +++ b/packages/cli/src/gltf/build-ir.test.ts @@ -1,11 +1,19 @@ import type { IRNode } from './ir' import { describe, expect, it } from 'vitest' -import { collidingNamesGLB, morphAndMetaGLB, multiPrimitiveGLB, nestedGLB, repeatedGeometryGLB, simpleGLB, skinnedGLB } from './__fixtures__/scenes' +import { clipOnlyGLB, collidingNamesGLB, morphAndMetaGLB, multiPrimitiveGLB, nestedGLB, objectAnimatedGLB, repeatedGeometryGLB, simpleGLB, skinnedGLB, skinnedNoClipsGLB } from './__fixtures__/scenes' import { buildIR } from './build-ir' import { loadGLTF } from './load' -async function irOf(glb: Promise) { - return buildIR(await loadGLTF(await glb)) +interface SourceFixture { + path: string + glb: Promise +} + +async function irOf(glb: Promise, sources: SourceFixture[] = []) { + return buildIR( + await loadGLTF(await glb), + await Promise.all(sources.map(async source => ({ path: source.path, gltf: await loadGLTF(await source.glb) }))), + ) } function find(node: IRNode, name: string): IRNode | undefined { @@ -163,4 +171,138 @@ describe('buildIR', () => { expect.objectContaining({ type: 'name-collision', name: 'foobar_1', originalName: 'foobar' }), ]) }) + + describe('clips from separate files', () => { + it('leaves the source list empty without --animations', async () => { + const ir = await irOf(skinnedGLB()) + + expect(ir.animationSources).toEqual([]) + expect(ir.clips).toEqual(['Idle']) + }) + + it('records what each source carries', async () => { + const ir = await irOf(skinnedNoClipsGLB(), [ + { path: 'clips/Idle.glb', glb: clipOnlyGLB('Idle') }, + { path: 'clips/Run.glb', glb: clipOnlyGLB('Run') }, + ]) + + expect(ir.animationSources).toEqual([ + { path: 'clips/Idle.glb', draco: false, clips: ['Idle'], bound: ['Idle'] }, + { path: 'clips/Run.glb', draco: false, clips: ['Run'], bound: ['Run'] }, + ]) + }) + + it('merges the model own clips first, then each source in order', async () => { + const ir = await irOf(skinnedGLB(), [ + { path: 'clips/Run.glb', glb: clipOnlyGLB('Run') }, + { path: 'clips/Jump.glb', glb: clipOnlyGLB('Jump') }, + ]) + + expect(ir.animations).toEqual(['Idle']) + expect(ir.clips).toEqual(['Idle', 'Run', 'Jump']) + }) + + it('lists a name carried by two files once', async () => { + const ir = await irOf(skinnedGLB(), [{ path: 'clips/Idle.glb', glb: clipOnlyGLB('Idle') }]) + + expect(ir.clips).toEqual(['Idle']) + }) + + it('warns that the later file is the one reachable through actions', async () => { + const ir = await irOf(skinnedGLB(), [{ path: 'clips/Idle.glb', glb: clipOnlyGLB('Idle') }]) + + expect(ir.warnings).toContainEqual( + expect.objectContaining({ type: 'clip-collision', name: 'Idle', source: 'clips/Idle.glb' }), + ) + }) + + it('keeps the names an external clip drives alive through pruning', async () => { + const ir = await irOf(skinnedNoClipsGLB(), [{ path: 'clips/Idle.glb', glb: clipOnlyGLB('Idle') }]) + + expect(ir.animated).toContain('handl') + }) + + /** + * The retarget check tests a track's node name against the IR's node keys, so those keys + * have to be the names a mixer resolves and not a form of our own. Three does the work: + * `sanitizeNodeName` runs before we see the object (`hand.l` in the fixture is `handl` + * here), and a track's target is that same `Object3D.name`. Key the IR by anything + * derived and every external clip silently stops binding. + */ + it('keys nodes by the name a mixer resolves, verbatim', async () => { + const loaded = await loadGLTF(await skinnedNoClipsGLB()) + // A set, not a list: two nodes sharing a name collapse to one key, which is a separate + // case with a warning of its own. + const scene = new Set() + loaded.scene.traverse(object => object.name && scene.add(object.name)) + + expect(Object.keys(buildIR(loaded).nodes).sort()).toEqual([...scene].sort()) + expect(scene).toContain('handl') + }) + + it('binds a clip whose node names three had to sanitize', async () => { + const ir = await irOf(skinnedNoClipsGLB(), [ + { path: 'clips/Idle.glb', glb: clipOnlyGLB('Idle', ['hand.l']) }, + ]) + + expect(ir.clips).toEqual(['Idle']) + expect(ir.warnings.filter(warning => warning.type === 'retarget-mismatch')).toEqual([]) + }) + + it('warns when only some of a clip tracks bind, and keeps the clip', async () => { + const ir = await irOf(skinnedNoClipsGLB(), [ + { path: 'clips/Run.glb', glb: clipOnlyGLB('Run', ['hand.l', 'mixamorigHips']) }, + ]) + + expect(ir.clips).toEqual(['Run']) + expect(ir.warnings).toContainEqual( + expect.objectContaining({ type: 'retarget-mismatch', name: 'Run', missing: ['mixamorigHips'], dropped: false }), + ) + }) + + it('drops a clip no track of which binds, so ActionName never offers it', async () => { + const ir = await irOf(skinnedNoClipsGLB(), [ + { path: 'clips/Run.glb', glb: clipOnlyGLB('Run', ['mixamorigHips', 'mixamorigSpine']) }, + ]) + + expect(ir.clips).toEqual([]) + expect(ir.animationSources[0]).toMatchObject({ clips: ['Run'], bound: [] }) + expect(ir.warnings).toContainEqual( + expect.objectContaining({ type: 'retarget-mismatch', name: 'Run', dropped: true }), + ) + }) + + it('never retarget-checks the model own clips', async () => { + const ir = await irOf(skinnedGLB()) + + expect(ir.warnings).toEqual([]) + }) + + it('points a skinned model with no clips at the flag', async () => { + const ir = await irOf(skinnedNoClipsGLB()) + + expect(ir.warnings).toContainEqual( + expect.objectContaining({ type: 'no-clips', message: expect.stringContaining('--animations') }), + ) + }) + + it('stays quiet about a model with no skin and no clips', async () => { + const ir = await irOf(simpleGLB()) + + expect(ir.warnings).toEqual([]) + }) + + it('stays quiet once the clips are wired in', async () => { + const ir = await irOf(skinnedNoClipsGLB(), [{ path: 'clips/Idle.glb', glb: clipOnlyGLB('Idle') }]) + + expect(ir.warnings.filter(warning => warning.type === 'no-clips')).toEqual([]) + }) + + it('carries a source own draco flag, which the model one says nothing about', async () => { + const ir = await irOf(objectAnimatedGLB(), [{ path: 'clips/Idle.glb', glb: clipOnlyGLB('Idle', ['Rock_0']) }]) + + expect(ir.draco).toBe(false) + expect(ir.animationSources[0].draco).toBe(false) + }) + }) }) diff --git a/packages/cli/src/gltf/build-ir.ts b/packages/cli/src/gltf/build-ir.ts index b7b5065dd..510c41d5e 100644 --- a/packages/cli/src/gltf/build-ir.ts +++ b/packages/cli/src/gltf/build-ir.ts @@ -1,6 +1,7 @@ import type { AnimationClip, Material, Mesh, Object3D } from 'three' -import type { GLTFIR, IRInstanceBucket, IRMaterialEntry, IRNode, IRNodeEntry, IRTransform, IRWarning, Vector3Tuple } from './ir' +import type { GLTFIR, IRAnimationSource, IRInstanceBucket, IRMaterialEntry, IRNode, IRNodeEntry, IRTransform, IRWarning, Vector3Tuple } from './ir' import type { LoadedGLTF } from './load' +import { basename } from 'node:path' import { PropertyBinding } from 'three' import { parsePhysics } from './physics' @@ -134,31 +135,33 @@ function toCollisionWarning(object: Object3D): IRWarning | undefined { } /** - * The nodes the clips actually drive. A track name is `.`, and the mixer - * resolves that node name against the rendered tree — so a node named here has to keep its - * name in the output or its track binds to nothing. + * The nodes one clip drives. A track name is `.`, and the mixer resolves that + * node name against the rendered tree — so a node named here has to keep its name in the + * output or its track binds to nothing. */ -function toAnimatedNodes(animations: AnimationClip[]): string[] { +function targetsOf(clip: AnimationClip): string[] { const names = new Set() - for (const clip of animations) { - for (const track of clip.tracks) { - try { - const { nodeName } = PropertyBinding.parseTrackName(track.name) - if (nodeName) { - names.add(nodeName) - } - } - catch { - // `parseTrackName` throws on a name it cannot read, which is a name three would - // never bind either. Nothing to keep, and no reason to fail the whole generate. + for (const track of clip.tracks) { + try { + const { nodeName } = PropertyBinding.parseTrackName(track.name) + if (nodeName) { + names.add(nodeName) } } + catch { + // `parseTrackName` throws on a name it cannot read, which is a name three would + // never bind either. Nothing to keep, and no reason to fail the whole generate. + } } return [...names] } +function toAnimatedNodes(animations: AnimationClip[]): string[] { + return [...new Set(animations.flatMap(targetsOf))] +} + function toInstanceBuckets(scene: Object3D): IRInstanceBucket[] { const buckets = new Map() @@ -181,7 +184,122 @@ function toInstanceBuckets(scene: Object3D): IRInstanceBucket[] { return [...buckets.values()] } -export function buildIR({ scene, animations, draco }: LoadedGLTF): GLTFIR { +/** One `--animations` file, already parsed. Only its clips are ever read. */ +export interface AnimationSourceInput { + /** The path as it was passed on the command line. */ + path: string + gltf: LoadedGLTF +} + +/** How a warning refers to the clips that came out of the model file itself. */ +const MODEL_LABEL = 'the model' + +/** + * What a warning calls a source. The full path stays on the warning itself for anything + * reading `--json`; a message that repeats `public/models/animations/Rig_Medium/…` three + * times is one nobody finishes reading. `MODEL_LABEL` passes through unchanged, having no + * separator in it. + */ +function label(path: string): string { + return basename(path) +} + +/** Enough of a list to recognise the rig, not the whole skeleton. */ +function preview(names: string[], limit = 3): string { + return names.length > limit + ? `${names.slice(0, limit).join(', ')} and ${names.length - limit} more` + : names.join(', ') +} + +interface MergedClips { + sources: IRAnimationSource[] + clips: string[] + /** The clip objects behind `clips`, for working out which node names have to survive. */ + playable: AnimationClip[] + warnings: IRWarning[] +} + +/** + * Merge the model's own clips with every `--animations` file, model first, and check each + * external clip against the rig it is about to be retargeted onto. + * + * A mixer keys `actions` by clip name walking the array, so a later file's clip shadows an + * earlier one of the same name. That is what merging is for, but the shadowed clip becomes + * unreachable, which is worth one line. A clip no track of which binds is worse: it is + * silent at runtime, so it never reaches `ActionName` at all. + */ +function mergeClips( + own: AnimationClip[], + inputs: AnimationSourceInput[], + nodeNames: Set, +): MergedClips { + const warnings: IRWarning[] = [] + const clips: string[] = [] + const playable: AnimationClip[] = [] + /** Clip name → the file it is currently reachable from. */ + const owner = new Map() + + function merge(clip: AnimationClip, source: string): void { + const shadowed = owner.get(clip.name) + if (shadowed !== undefined) { + const winner = label(source) + warnings.push({ + type: 'clip-collision', + name: clip.name, + source, + shadows: shadowed, + message: `Both ${label(shadowed)} and ${winner} carry "${clip.name}". ${winner} is merged last, so its clip is the one that plays.`, + }) + } + else { + clips.push(clip.name) + } + + owner.set(clip.name, source) + playable.push(clip) + } + + for (const clip of own) { + merge(clip, MODEL_LABEL) + } + + const sources = inputs.map(({ path, gltf }): IRAnimationSource => { + const bound: string[] = [] + + for (const clip of gltf.animations) { + const targets = targetsOf(clip) + const missing = targets.filter(target => !nodeNames.has(target)) + const dropped = targets.length > 0 && missing.length === targets.length + + if (missing.length > 0) { + warnings.push({ + type: 'retarget-mismatch', + name: clip.name, + source: path, + missing, + dropped, + message: dropped + ? `"${clip.name}" in ${label(path)} drives ${preview(missing)}, and this model has no node by any of those names — nothing would play, so it is left out of ActionName.` + : `"${clip.name}" in ${label(path)} drives ${preview(missing)}, which this model has no node for. Those tracks bind to nothing.`, + }) + } + + if (!dropped) { + bound.push(clip.name) + merge(clip, path) + } + } + + return { path, draco: gltf.draco, clips: gltf.animations.map(clip => clip.name), bound } + }) + + return { sources, clips, playable, warnings } +} + +export function buildIR( + { scene, animations, draco }: LoadedGLTF, + sources: AnimationSourceInput[] = [], +): GLTFIR { const nodes: Record = {} const materials: Record = {} const warnings: IRWarning[] = [] @@ -203,12 +321,27 @@ export function buildIR({ scene, animations, draco }: LoadedGLTF): GLTFIR { } }) + const merged = mergeClips(animations, sources, new Set(Object.keys(nodes))) + warnings.push(...merged.warnings) + + // A rig with nothing to play is the case this flag exists for, and nothing else in the + // output hints that the clips are simply in another file. + const skinned = Object.values(nodes).some(entry => entry.type === 'SkinnedMesh') + if (skinned && animations.length === 0 && sources.length === 0) { + warnings.push({ + type: 'no-clips', + message: `This model is skinned but carries no animation clips. Pass --animations to wire in clips exported to separate files.`, + }) + } + return { root: toNode(scene), nodes, materials, animations: animations.map(clip => clip.name), - animated: toAnimatedNodes(animations), + animationSources: merged.sources, + clips: merged.clips, + animated: toAnimatedNodes(merged.playable), draco, instances: toInstanceBuckets(scene), warnings, diff --git a/packages/cli/src/gltf/ir.ts b/packages/cli/src/gltf/ir.ts index cc66efa36..14a96e065 100644 --- a/packages/cli/src/gltf/ir.ts +++ b/packages/cli/src/gltf/ir.ts @@ -69,7 +69,25 @@ export interface IRInstanceBucket { nodes: string[] } -export interface IRWarning { +/** + * One `--animations` file. The rig-plus-clip-library pipeline keeps the mesh in one file + * and the clips in others, so a model's clips are not always the model's own. + */ +export interface IRAnimationSource { + /** The path as it was passed on the command line. The emitter turns it into a url. */ + path: string + /** Per file: a compressed clip library beside an uncompressed model is normal. */ + draco: boolean + /** What the file carries, in file order — including clips nothing binds. */ + clips: string[] + /** + * The subset whose tracks reach this model. A source with none of these is not worth + * loading at runtime, so the emitter leaves it out entirely. + */ + bound: string[] +} + +export interface IRNameCollisionWarning { type: 'name-collision' message: string /** The name the node ended up with. */ @@ -78,13 +96,59 @@ export interface IRWarning { originalName: string } +/** Two files carry a clip of the same name, so only one is reachable through `actions`. */ +export interface IRClipCollisionWarning { + type: 'clip-collision' + message: string + /** The clip name both files use. */ + name: string + /** The file that wins, being the last one merged. */ + source: string + /** What it shadows: an earlier `--animations` path, or `the model` for the model's own. */ + shadows: string +} + +/** A clip whose tracks target node names this model does not have: a bad retarget. */ +export interface IRRetargetWarning { + type: 'retarget-mismatch' + message: string + /** The clip name. */ + name: string + /** The file it came from. */ + source: string + /** Node names it drives that the model has none of. */ + missing: string[] + /** True when nothing bound at all, so the clip was left out of `clips`. */ + dropped: boolean +} + +/** A skinned model with nothing to play and no `--animations`: the clips are elsewhere. */ +export interface IRNoClipsWarning { + type: 'no-clips' + message: string +} + +export type IRWarning + = | IRNameCollisionWarning + | IRClipCollisionWarning + | IRRetargetWarning + | IRNoClipsWarning + export interface GLTFIR { root: IRNode /** Every named object, keyed the way `buildGraph` keys `nodes` at runtime. */ nodes: Record /** Every material, keyed the way `buildGraph` keys `materials` at runtime. */ materials: Record + /** Clip names the model file itself carries. */ animations: string[] + /** The `--animations` files, in the order they were passed. */ + animationSources: IRAnimationSource[] + /** + * Every clip an emitted `ActionName` can offer: the model's own plus every source's, + * deduped in merge order, minus the ones no track of which binds to this model. + */ + clips: string[] /** * Names of the nodes the clips' tracks target. A mixer resolves a track against a node * name in the rendered tree, so these are the names the emitter cannot drop. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 69f620420..6e672d6a9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -27,6 +27,11 @@ function parseEngine(value: string): string { return value } +/** `--animations` is repeatable: a per-clip export library is one file per animation. */ +function collect(value: string, previous: string[]): string[] { + return [...previous, value] +} + /** A NaN silently handed to MeshoptSimplifier is worse than a clear rejection up front. */ function parseFraction(name: string, max: number) { return (value: string): number => { @@ -45,6 +50,7 @@ const commands: CommandDefinition[] = [ setup: cmd => cmd .option('-o, --output ', 'where to write the component (default: .gen.vue next to the model)') .option('-u, --url ', 'url the model is served from (default: inferred from public/)') + .option('-a, --animations ', 'glb/gltf file to take animation clips from, repeatable', collect, []) .option('-s, --slots ', 'named | all | none', 'named') .option('--shadows', 'add cast-shadow and receive-shadow to meshes') .option('-K, --keepgroups', 'keep pass-through groups') @@ -54,7 +60,7 @@ const commands: CommandDefinition[] = [ .option('-m, --meta', 'emit glTF extras as :user-data') .option('-c, --console', 'print the component instead of writing it') .option('-f, --force', 'overwrite a file this tool did not generate') - .option('-v, --verbose', 'list every slot name instead of the first few') + .option('-v, --verbose', 'list every slot and clip name instead of the first few') .option('-T, --transform', 'optimize the model into a separate -transformed.glb and generate against it') .option('-i, --instance', 'batch meshes that share a geometry and material into an InstancedMesh (implies --transform)') .option('-I, --instanceall', 'batch every eligible mesh, even the ones that appear once (implies --transform)')