#10409·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

Author: alencheungCreated Sep 11, 2026Updated Sep 11, 2026

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("<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::createAssetResourceLoader::loadResourcesAnimator::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.