google / google/filament

gltfio: Animator never clamps morph-weight counts to the 256-target limit — spec-conforming GLB with >256 morph targets and a weights channel aborts in setMorphWeights

Open
#10,409 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
20.5k
Forks
2.3k
Avg merge
2d 16h
Merged PRs (30d)
74

Description

## Summary

The glTF 2.0 specification imposes no limit on the number of morph targets
a mesh may declare. Filament's engine limit is 256
(`CONFIG_MAX_MORPH_TARGET_COUNT`, `EngineEnums.h:184`), and gltfio honors
it everywhere — except the Animator. `Animator::applyAnimation`'s WEIGHTS
branch computes the per-keyframe weight count straight from the animation
sampler (`Animator.cpp:563`), which the constructor's validation sized from
the raw, JSON-controlled `primitives[0].targets_count` (`Animator.cpp:218`),
and passes that count unclamped to
`RenderableManager::setMorphWeights` (`Animator.cpp:590`). That entry point
carries an always-on fatal precondition:

```cpp
// filament/src/components/RenderableManager.cpp:1080-1085
void FRenderableManager::setMorphWeights(Instance const instance, float const* weights,
size_t const count, size_t const offset) {
if (instance) {
FILAMENT_CHECK_PRECONDITION(count + offset <= CONFIG_MAX_MORPH_TARGET_COUNT)
<< "Only " << CONFIG_MAX_MORPH_TARGET_COUNT
<< " morph targets are supported (count=" << count << ", offset=" << offset << ")";
```

So a GLB that declares 257 morph targets on its mesh and a weights
animation channel passes all of gltfio's own validation (loading and
renderable creation clamp to 256 with a warning — `AssetLoader.cpp:1183-1187`
and `:950`), then aborts the host process with
`utils::PreconditionPanic` ("Only 256 morph targets are supported
(count=257, offset=0)") on the first animation tick. Confirmed at runtime
against `b073ca02` (observed 2026-09-08): the transcript below shows the
loader's clamp warning, a clean load, then the abort with exactly that
message; the generator + GLB below reproduce it and a pipeline harness is
attached.

Impact class: availability — a third-party asset that gltfio accepts
deterministically kills the application at first `applyAnimation`. Not a
memory-safety claim.

## Mechanism

The two consumers of the morph-target count disagree:

- Loader side (clamped): `createPrimitives` warns and caps at
`MAX_MORPH_TARGETS` (`AssetLoader.cpp:1183-1187`);
`createRenderable` sizes its weights vector with
`std::min(MAX_MORPH_TARGETS, numMorphTargets)` (`AssetLoader.cpp:950`).
- Animator side (unclamped):

```cpp
// libs/gltfio/src/Animator.cpp:214-218 (validation, constructor)
cgltf_size components = 1;
if (channel.target_path == cgltf_animation_path_type_weights) {
if (!channel.target_node->mesh || !channel.target_node->mesh->primitives_count) {
return false;
}
components = channel.target_node->mesh->primitives[0].targets_count; // raw JSON count
}
```

```cpp
// libs/gltfio/src/Animator.cpp:560-591 (applyAnimation, WEIGHTS branch)
case Channel::WEIGHTS: {
...
const int valuesPerKeyframe = (int)(sampler->values.size() / sampler->inputCount); // :563
if (sampler->interpolation == Sampler::CUBIC) {
const int numMorphTargets = valuesPerKeyframe / 3; // :567 (same shape)
...
weights.resize(numMorphTargets); // :572
} else {
weights.resize(valuesPerKeyframe); // :581 -> 257
...
}
auto ci = renderableManager->getInstance(channel.targetEntity);
renderableManager->setMorphWeights(ci, weights.data(), weights.size()); // :590 -> panic
```

`validateAnimation` only checks divisibility of the sampler output by
`components` (`Animator.cpp:244-246`), so a consistent 257-weight channel
validates clean; nothing between it and `setMorphWeights` applies the
engine's 256 cap.

## Reproduction

Generator (Python 3, stdlib only; writes `morph257tri.glb` plus
`morph2tri.glb`, a 2-target control that animates cleanly):

```python
#!/usr/bin/env python3
"""Morph GLB generator whose base mesh survives gltfio's tangent-generation
path (3 vertices -> non-indexed triangleCount = 1), so the asset reaches
Animator::applyAnimation. ntargets=257 exceeds the engine's 256 cap;
ntargets=2 is the animated control."""
import json
import struct
import sys

def glb(json_obj, bin_data: bytes) -> bytes:
js = json.dumps(json_obj, separators=(",", ":")).encode()
while len(js) % 4:
js += b"\x00"
while len(bin_data) % 4:
bin_data += b"\x00"
total = 12 + 8 + len(js) + 8 + len(bin_data)
out = struct.pack(" 1 else 257
name = sys.argv[2] if len(sys.argv) > 2 else ("morph257tri.glb" if n > 256 else "morph2tri.glb")
with open(name, "wb") as f:
f.write(morph_glb(n))
print("wrote", name)
```

Two repro-shape constraints are load-bearing (both noted inside the
generator): the mesh is a 3-vertex non-indexed triangle — tangent
generation requires a nonzero triangle count, and point-only meshes abort
earlier in `loadResources` (`SurfaceOrientation::Builder::build()`:
"Triangle count is required.") without ever reaching the Animator — and
the two input keyframes use distinct timestamps, since equal timestamps
make the Animator disable the clip and the WEIGHTS branch never runs.

Run through any gltfio pipeline: `AssetLoader::createAsset` →
`ResourceLoader::loadResources` → `Animator::applyAnimation(0, t)` (a
fork-isolated harness covering all three stages is attached; Noop
backend). Observed at `b073ca02` (2026-09-08, Debug build):

```
WARNING: Exceeded max morph target count of 256
morph257tri.glb CREATED
morph257tri.glb LOAD_RESOURCES=OK
morph257tri.glb ANIMATOR_READY
morph257tri.glb PANIC_AT_ANIMATE: Precondition
in void filament::FRenderableManager::setMorphWeights(const Instance, const float *, const size_t, const size_t):1083
in file filament/filament/src/components/RenderableManager.cpp
reason: Only 256 morph targets are supported (count=257, offset=0)
```

The panic is raised on the caller thread inside `Animator::applyAnimation`;
a consumer without a terminate handler aborts the process here. Control
run, same generator with `n=2` (`morph2tri.glb`, live weights channel —
the only delta is the target count):

```
morph2tri.glb CREATED
morph2tri.glb LOAD_RESOURCES=OK
morph2tri.glb ANIMATOR_READY
morph2tri.glb ANIMATED
morph2tri.glb CLEAN_EXIT
```

The repository's own `AnimatedMorphCube.glb` sample asset animates clean
through the same harness, so the abort is specific to the >256-target
count.

## Proposed fix

Apply the same cap the loader already applies, on both sides so they stay
consistent:

1. In `Animator`'s `validateAnimation` (`Animator.cpp:218`), clamp
`components` to `std::min(targets_count, CONFIG_MAX_MORPH_TARGET_COUNT)`
— or reject the weights channel when `targets_count` exceeds the cap
(the function already disables animations it cannot honor).
2. In the WEIGHTS branch (`Animator.cpp:567,572,581,590`), clamp
`numMorphTargets`/`valuesPerKeyframe` the same way before
`weights.resize` and `setMorphWeights`.

Either closes the abort; doing both keeps validation and application in
agreement. `createRenderable`'s `std::min` (`AssetLoader.cpp:950`) is the
in-repo precedent to copy.

Contributor guide

Open the contributing guide

Research direction

Start with libs/gltfio/src/Animator.cpp, especially validateAnimation and the WEIGHTS branch of applyAnimation, then compare their count handling with AssetLoader.cpp:950 and :1183-1187. Use the supplied Python generator and isolated gltfio pipeline with morph257tri.glb; done means the over-limit asset no longer reaches setMorphWeights with an invalid count or aborts, while morph2tri.glb still animates cleanly.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
computer-graphics
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.