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
- 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
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