Skip to content

feat(fbx): embed all referenced textures (normal/PBR slots) in Video.Content on export, not just the eager-loaded diffuse #508

Description

@fernandotonon

What was observed

Slice G's `qtmesh optimize Rumba.fbx -o out.fbx` produced an FBX that, when re-imported via File→Open in the editor, renders without the normal map. The diffuse texture loads (because it sits next to the file on disk, and Ogre re-resolves it through the resource group), but the normal map shows up as flat-lit geometry.

Same behaviour from any tool that round-trips FBX through our exporter:

  • `qtmesh convert`
  • `qtmesh fix`
  • `qtmesh anim --simplify` (slice C4 fix path)
  • `qtmesh optimize` (slice G)

Root cause

`FBXExporter::exportFBX` writes texture filenames into the FBX properties (`Maya|TEX_normal_map`, `NormalMap`, etc.) AND attempts to embed the texture bytes into a `Video.Content` block — but it only succeeds when Ogre's `TextureManager` has the texture loaded in-memory at export time.

The diffuse texture gets loaded eagerly by Assimp's importer because it's bound to the FFP `diffuse_map` texture-unit-state, which Ogre's renderer touches on the first frame. The normal map is wired through our `RTShaderHelper` / Cook-Torrance path, which records the texture name string in the material but never forces an explicit `Ogre::TextureManager::getSingleton().load(name)` on it. So on a CLI-headless export the normal map is just a name with no bytes in the pool, and `FBXExporter` writes a name-only reference.

When the resulting FBX is later opened from a directory that doesn't contain the normal map file by that name, the normal map disappears.

Acceptance criteria

  • `FBXExporter::exportFBX` walks every `TextureUnitState` referenced by every `Pass` of every `SubEntity`'s `Material` (including the RTSS-managed slots: albedo / metallic / roughness / ao / emissive / normal). For each, force a `TextureManager::load()` if the texture isn't already in the pool, then embed it via `Video.Content` exactly like the diffuse path does today.
  • When a referenced texture name can't be resolved to a file on the resource-group search path, fall back to the current name-only reference but emit a Sentry breadcrumb / log line so the operator knows the file will be missing on re-import.
  • Round-trip test: `Rumba Dancing.fbx` → `qtmesh optimize` → re-import → normal map renders correctly when the user moves the output FBX to a directory that doesn't contain `Boss_normal.png` next to it.
  • Round-trip test for `qtmesh fix` and the slice C4 `scan --fix` (redundant-keyframe rewrite) so we catch the same regression in those flows.

Workarounds today

Until this is fixed:

  • Run optimize / fix / convert with the texture files in the same directory as both the input and the output, OR
  • Manually copy the `*.png` files next to the output FBX after export.

Related

  • Slice G PR feat(opt): batch optimize pipeline — qtmesh optimize + optimize_mesh (Phase 6 slice G) #507 — surfaced this via the optimize → re-import test the reporter ran. Slice G is shippable as-is because the geometry / animation / material-reference data is intact; only the texture embed is incomplete.
  • CLAUDE.md mentions `FBXExporter` "embeds textures (Video.Content) so avoid emitting sidecar .material" — that description is accurate only for the diffuse channel today.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions