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
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:
// 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):
createPrimitiveswarns and caps atMAX_MORPH_TARGETS(AssetLoader.cpp:1183-1187);createRenderablesizes its weights vector withstd::min(MAX_MORPH_TARGETS, numMorphTargets)(AssetLoader.cpp:950). - Animator side (unclamped):
// 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
}// 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 -> panicvalidateAnimation 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):
#!/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("<III", 0x46546C67, 2, total)
out += struct.pack("<II", len(js), 0x4E4F534A) + js
out += struct.pack("<II", len(bin_data), 0x004E4942) + bin_data
return out
def morph_glb(ntargets: int):
keyframes = 2
nverts = 3 # one non-indexed triangle; TangentsJob triangleCount = 3/3 = 1
nf_in = keyframes
nf_out = keyframes * ntargets
# anim input, anim output, base POSITION (3 verts), per-target POSITION (3 verts each)
# distinct timestamps so validateAnimation keeps the clip enabled
fdata = [0.0, 1.0] + [0.5] * nf_out
fdata += [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0] # base triangle
for _ in range(ntargets):
fdata += [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
blob = struct.pack("<%df" % len(fdata), *fdata)
off_in = 0
off_out = nf_in * 4
off_pos = (nf_in + nf_out) * 4
off_tpos0 = off_pos + nverts * 3 * 4
accessors = [
{"bufferView": 0, "componentType": 5126, "count": nf_in,
"type": "SCALAR", "byteOffset": off_in},
{"bufferView": 0, "componentType": 5126, "count": nf_out,
"type": "SCALAR", "byteOffset": off_out},
{"bufferView": 0, "componentType": 5126, "count": nverts,
"type": "VEC3", "byteOffset": off_pos,
"min": [0.0, 0.0, 0.0], "max": [1.0, 0.0, 1.0]},
]
for t in range(ntargets):
accessors.append({
"bufferView": 0, "componentType": 5126, "count": nverts,
"type": "VEC3", "byteOffset": off_tpos0 + t * nverts * 3 * 4,
"min": [1.0, 1.0, 1.0], "max": [1.0, 1.0, 1.0]})
targets = [{"POSITION": 3 + t} for t in range(ntargets)]
doc = {
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0}],
"meshes": [{"primitives": [{"attributes": {"POSITION": 2},
"targets": targets}]}],
"buffers": [{"byteLength": len(blob)}],
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": len(blob)}],
"accessors": accessors,
"animations": [{
"samplers": [{"input": 0, "output": 1}],
"channels": [{"sampler": 0, "target": {"node": 0, "path": "weights"}}],
}],
}
return glb(doc, blob)
n = int(sys.argv[1]) if len(sys.argv) > 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_EXITThe 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:
- In
Animator'svalidateAnimation(Animator.cpp:218), clampcomponentstostd::min(targets_count, CONFIG_MAX_MORPH_TARGET_COUNT)— or reject the weights channel whentargets_countexceeds the cap (the function already disables animations it cannot honor). - In the WEIGHTS branch (
Animator.cpp:567,572,581,590), clampnumMorphTargets/valuesPerKeyframethe same way beforeweights.resizeandsetMorphWeights.
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.
Source: google/filament