Skip to content

feat(vat): Unreal demo project — Python-driven asset bootstrap#652

Open
fernandotonon wants to merge 45 commits into
masterfrom
feat/unreal-vat-demo
Open

feat(vat): Unreal demo project — Python-driven asset bootstrap#652
fernandotonon wants to merge 45 commits into
masterfrom
feat/unreal-vat-demo

Conversation

@fernandotonon
Copy link
Copy Markdown
Owner

@fernandotonon fernandotonon commented May 20, 2026

Summary

Companion to `tools/godot-vat-demo/` — gives Unreal users a one-import path to play the Rumba bake without wiring materials by hand. The folder is structured as a real `.uproject` the user opens in UE 5.3+; running `build_vat_demo.py` from the Python console then creates the Material, configures the position-texture import settings, and scaffolds the demo Blueprint.

```
tools/unreal-vat-demo/
├── QtMeshVAT.uproject ← UE 5.3+ project descriptor
├── README.md ← step-by-step setup
├── .gitignore ← excludes Binaries/Saved/cooked .uassets
└── Content/
├── Rumba/ ← bake artifacts (same as Godot demo)
│ ├── source.gltf + .bin
│ ├── Boss_diffuse.png
│ ├── mixamo.com_pos.png
│ ├── mixamo.com-remap_info.json
│ ├── mixamo.com_ogre_bind.bin
│ └── openvat.usf ← Custom-node body
└── Python/build_vat_demo.py ← run once to bootstrap
```

Why a Python bootstrap instead of pre-built .uassets

Unreal's `.uasset` is a proprietary binary that re-cooks per engine version — committing pre-built ones would re-break on every UE upgrade and bloat the repo. All the data we ship (bake + shader + sidecar) is in text/binary standard formats; only the engine-specific glue (Material, Texture import settings, BP skeleton) is engine-version-dependent, and that's exactly what `build_vat_demo.py` builds when the user runs it once after opening the project.

Limitations (documented in README)

  • UV2 bake step is engine-version-dependent. Geometry Script plugin in 5.4+ has clean Python access; 5.3 needs a small C++ helper. The script logs the per-vertex matching plan either way.
  • Bind pose assumed at the actor origin. True for Mixamo characters (the demo asset); documented as a caveat for other rigs that need a `WPO = custom_output - bind_position` correction.
  • No HierarchicalInstancedStaticMesh perf variant yet. The 1000-instance MultiMesh equivalent ships in `tools/godot-vat-demo/scripts/PerfSpawnerVAT.gd` and can be ported as a follow-up if there's interest.
  • No Unreal-web demo. UE's HTML5 export was deprecated and removed.

Website docs

The `qtmesh vat` reference section gains a pointer to the Unreal sample project next to the existing Godot live-demo link.

Test plan

  • All bake artifacts are byte-identical to the Godot demo's `Content/Rumba/` so consumers get the same data
  • `QtMeshVAT.uproject` is valid JSON, enables the right plugins (PythonScriptPlugin, EditorScriptingUtilities, GLTFImporter)
  • `build_vat_demo.py` syntax-checks with Python 3
  • Open in UE 5.3 / 5.4 / 5.5 (one of each), run the bootstrap, verify the dancer animates
  • UE 5.4+ Geometry Script UV2 bake path
  • 5.3 fallback path

Related

Follows the v3.3.0 VAT MVP release (#648), the `--include-shaders` flag (#649), and the inspector UI polish (#651).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added an Unreal Engine OpenVAT demo with an automated editor bootstrap that imports baked assets, builds/recompiles the VAT material, and spawns an idempotent demo actor for previewing vertex-animation textures.
    • Included a reusable VAT shader snippet supporting UV2-driven frame sampling and frame blending.
  • Chores
    • Added project descriptor and .gitignore entries to keep generated Unreal/IDE artifacts out of version control.
  • Documentation
    • Added a comprehensive README and updated website docs to link both Godot web demo and the new Unreal sample.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a self-contained Unreal Engine 5.3 OpenVAT demo: project config and .gitignore, remap metadata and HLSL Custom node, Python bootstrap to import/validate assets and build a material graph, editor helpers to spawn an idempotent OpenVAT_Dancer actor, README, and a docs link update.

Changes

Unreal OpenVAT Demo

Layer / File(s) Summary
Project Setup & Configuration
tools/unreal-vat-demo/.gitignore, tools/unreal-vat-demo/QtMeshVAT.uproject
Unreal project descriptor targets 5.3, enables Python/editor scripting, and .gitignore excludes build/IDE/cooked/demo outputs.
Core Data & Shader Foundation
tools/unreal-vat-demo/Content/Rumba/mixamo.com-remap_info.json, tools/unreal-vat-demo/Content/Rumba/openvat.usf
Adds remap JSON with frame count and bounds; HLSL Custom node implements OpenVAT sampling, frame blending, position reconstruction, and normal output with UV2 usage guidance.
Python Asset Import & Validation
tools/unreal-vat-demo/Content/Python/build_vat_demo.py (import/validation helpers)
Texture and glTF import helpers (Interchange with StaticMesh override and fallback), asset discovery, pre-clean of prior skeletal imports, position texture import setting overrides, sidecar reader, and glTF TEXCOORD_1 verification.
Material Graph & Shader Integration
tools/unreal-vat-demo/Content/Python/build_vat_demo.py (material builder)
Creates/replaces M_OpenVAT, injects the Custom HLSL body, wires Time×fps → current_frame, feeds TexCoord[1] and sidecar bounds into the Custom node, stamps OpenVATBuild metadata, recompiles, and saves.
Editor Helpers & Actor Spawning
tools/unreal-vat-demo/Content/Python/build_vat_demo.py (editor helpers & spawn)
Provides editor-version actor access and viewport focus utilities; idempotently removes prior OpenVAT_Dancer, spawns StaticMeshActor or SkeletalMeshActor depending on import, assigns mesh/material with multiple API fallbacks, and disables skeletal animation when applicable.
Pipeline Entry & Orchestration
tools/unreal-vat-demo/Content/Python/build_vat_demo.py (main)
Top-level orchestration calling import, validation, sidecar read, material build, and actor spawn with logging and idempotent behavior; includes __main__ guard.
Editor Auto-run Bootstrap
tools/unreal-vat-demo/Content/Python/init_unreal.py
Startup script that decides whether to run the bootstrap on editor open, defers execution until editor-ready, and compares the material OpenVATBuild metadata against build_vat_demo.OPENVAT_BUILD to skip redundant rebuilds.
Documentation & Integration
tools/unreal-vat-demo/README.md, website/src/DocsApp.jsx
Comprehensive README with setup/troubleshooting/performance notes; website docs updated to show two Live demos (Godot web, Unreal sample).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I stitched UV2 into a midnight seam,
Baked frames whisper, pixels hum the dream,
Python wakes the shader's secret art,
A dancer spawns — each vertex plays its part,
Hop, build, save — the demo springs to heart. 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: introducing an Unreal demo project with a Python-driven bootstrap for asset generation.
Description check ✅ Passed The description comprehensively covers Summary and Technical Details sections with features, rationale, limitations, and test plan status, aligning well with the template requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/unreal-vat-demo

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 45cde3fa30

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +366 to +369
unreal.log("UV2 bake plan: %d Ogre vertices, %d position buckets. "
"Run the EUW or the C++ helper to commit per-vertex UV2."
% (len(ogre), len(bucket)))
return True
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail when UV2 baking is not actually performed

bake_uv2 currently computes metadata and logs a plan, but it never writes UV2 data back to the mesh and still returns success. The shader path built in this commit uses pos_tex.Load with integer UV2-derived indices, so leaving UV2 untouched causes incorrect texel lookups (scrambled or collapsed VAT deformation) while the bootstrap flow reports completion. This should return failure (or raise) until UV2 is genuinely written, so users don’t proceed with a broken setup.

Useful? React with 👍 / 👎.

Comment on lines +166 to +170
mat = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
"M_OpenVAT", DEMO_DIR, unreal.Material, mat_factory)
if mat is None:
unreal.log_error("Failed to create M_OpenVAT")
return None
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse existing material asset instead of recreating it

This path always calls create_asset("M_OpenVAT", ...) and aborts when creation fails, which happens once M_OpenVAT already exists. That makes reruns non-idempotent despite the script’s stated behavior, so users cannot regenerate the material after changing bake inputs or rerunning setup unless they manually delete assets first. The function should load/update the existing material (or delete-and-recreate explicitly) on subsequent runs.

Useful? React with 👍 / 👎.

fernandotonon added a commit that referenced this pull request May 20, 2026
Addresses Codex P1 + P2 on PR #652.

P1 — bake_uv2 used to return True even though it only logs the
matching plan; it never actually writes UV2 back to the mesh.
Combined with the bootstrap printing "=== Bootstrap done. ===",
users could believe the demo was ready when in fact the shader's
`pos_tex.Load(int3(col, …))` reads from arbitrary texels and the
dancer renders as scattered triangles. Now:
  - bake_uv2 returns False until a real write step ships.
  - main() reads the return value and prints a loud "Bootstrap
    INCOMPLETE" banner pointing at README Step 4 when False.
  - The warning inside bake_uv2 was upgraded to log_warning so
    it stands out in the Output Log.

P2 — build_material wasn't idempotent. create_asset on an existing
path returns None, the function then aborted with "Failed to create
M_OpenVAT", and a user rerunning after changing bake inputs (new
fps, new bounds, new texture) ended up with a stale material and
no graceful error. Now: detect the pre-existing asset, delete it,
recreate from scratch so the graph reflects the latest sidecar.
Idempotent at the granularity the bootstrap docs promise.
Companion to tools/godot-vat-demo/ — gives Unreal users a one-import
path to play the Rumba bake without wiring materials by hand. The
folder is structured as a real .uproject the user opens in UE 5.3+;
running build_vat_demo.py from the Python console then creates the
Material, configures the position-texture import settings, and
scaffolds the demo Blueprint.

  tools/unreal-vat-demo/
  ├── QtMeshVAT.uproject              ← UE 5.3+ project descriptor
  ├── README.md                       ← step-by-step setup
  ├── .gitignore                      ← excludes Binaries/Saved/cooked .uassets
  └── Content/
      ├── Rumba/                      ← bake artifacts (same as Godot demo)
      │   ├── source.gltf + .bin      ← vertex-order-aligned mesh
      │   ├── Boss_diffuse.png
      │   ├── mixamo.com_pos.png      ← 16-bit position+normal texture
      │   ├── mixamo.com-remap_info.json
      │   ├── mixamo.com_ogre_bind.bin← per-vertex bind signature
      │   └── openvat.usf             ← Custom-node body
      └── Python/build_vat_demo.py    ← run once to bootstrap

Why a Python bootstrap instead of pre-built .uassets:
Unreal's .uasset is a proprietary binary that re-cooks per engine
version — committing pre-built ones would re-break on every UE
upgrade. All the data (bake + shader + sidecar) is text/binary
standard formats; only the engine-specific glue (Material,
Texture import settings, BP skeleton) is engine-version-dependent,
and that's exactly what build_vat_demo.py builds.

Limitations documented in README:
- UV2-bake step is engine-version-dependent (Geometry Script plugin
  in 5.4+, C++ helper for 5.3).
- The Material assumes the bind pose is at the actor origin (true
  for Mixamo; documented as a caveat for other rigs).
- No HierarchicalInstancedStaticMesh perf variant yet — the
  1000-instance MultiMesh equivalent ships in
  tools/godot-vat-demo/scripts/PerfSpawnerVAT.gd and can be
  ported as a follow-up.
- No Unreal-web demo possible (UE's HTML5 export was deprecated).

Website docs gain a pointer to the Unreal sample project next to
the existing Godot live-demo link.
The .uproject's `Plugins` block listed `GLTFImporter` as a required
plugin, which doesn't exist in stock UE 5.x — Unreal threw
"This project requires the 'GLTFImporter' plugin, which could not
be found" on first open. glTF import is provided by Unreal's
built-in `Interchange` framework (UE 5.0+); no marketplace plugin
needed.

  - QtMeshVAT.uproject: drop the GLTFImporter dependency. Only
    PythonScriptPlugin + EditorScriptingUtilities remain, both
    shipped with stock UE.
  - build_vat_demo.py: switch glTF import from the non-existent
    `unreal.GLTFImportFactory()` to `InterchangeManager`, with a
    graceful warning + manual-import fallback for older engines
    where the Python API isn't available.
  - README: document the manual-import fallback.

The bake artifacts, the Custom-node Material, and the BP scaffold
are unchanged.
Addresses Codex P1 + P2 on PR #652.

P1 — bake_uv2 used to return True even though it only logs the
matching plan; it never actually writes UV2 back to the mesh.
Combined with the bootstrap printing "=== Bootstrap done. ===",
users could believe the demo was ready when in fact the shader's
`pos_tex.Load(int3(col, …))` reads from arbitrary texels and the
dancer renders as scattered triangles. Now:
  - bake_uv2 returns False until a real write step ships.
  - main() reads the return value and prints a loud "Bootstrap
    INCOMPLETE" banner pointing at README Step 4 when False.
  - The warning inside bake_uv2 was upgraded to log_warning so
    it stands out in the Output Log.

P2 — build_material wasn't idempotent. create_asset on an existing
path returns None, the function then aborted with "Failed to create
M_OpenVAT", and a user rerunning after changing bake inputs (new
fps, new bounds, new texture) ended up with a stale material and
no graceful error. Now: detect the pre-existing asset, delete it,
recreate from scratch so the graph reflects the latest sidecar.
Idempotent at the granularity the bootstrap docs promise.
…gltf

After PR #654 added `qtmesh vat --emit-uv2`, the bake's source.gltf
carries the per-vertex column index as TEXCOORD_1 directly. Unreal's
mesh importer reorders vertices for cache locality, but a vertex
attribute travels with its vertex through any reorder — so the
imported mesh's TexCoord[1] already points at the right column in
the position texture. No runtime UV2-baking, no bind-sidecar
matcher, no engine-version-specific Geometry Script paths.

This turns the previously-incomplete Unreal demo into a true
"open and play" setup:

  1. Open QtMeshVAT.uproject
  2. Run build_vat_demo.py from the Python console
  3. Wire BP_VATDancer's 4-node Tick (documented in README)
  4. Drop the actor into a level, hit Play.

Code changes:

  - Re-baked Content/Rumba/source.gltf + source.bin with
    `qtmesh vat --emit-uv2`. The .gltf now carries TEXCOORD_1 on
    every primitive; size grew from 642 KB to 689 KB for the UV2
    payload.
  - Replaced bake_uv2() (which returned False because it couldn't
    portably commit the UV2 write) with verify_gltf_has_uv2() —
    a fast pre-check that fails the bootstrap loudly if a user's
    bake folder predates --emit-uv2.
  - Dropped the obsolete read_ogre_bind() + matching plan and the
    struct/Geometry-Script path references.
  - Material graph comment updated: TexCoord[1] now comes straight
    from glTF's TEXCOORD_1, not a post-import EUW write.
  - README: drop "Step 4: bake UV2" entirely. New error case for
    "source.gltf is MISSING TEXCOORD_1" points the user at
    `qtmesh vat --emit-uv2`.
@fernandotonon fernandotonon force-pushed the feat/unreal-vat-demo branch from 27f9816 to 0fbb829 Compare May 21, 2026 02:24
DefaultEngine.ini and DefaultInput.ini are auto-generated by UE
on first project open and contain a randomly-generated SecurityToken
that has no business being in version control. Slipped in with the
previous commit because they were untracked when I staged the
whole tools/unreal-vat-demo/ folder.

Removed from the index and added Config/ to the project's .gitignore
so subsequent project opens don't re-stage them.
The previous bootstrap left the user with an empty map: it created
the Material + an empty BP_VATDancer (Python can't wire BP graphs)
and asked the user to drop the actor into a level + write a 4-node
Tick. Most users — me included on first try — just saw an empty
scene and assumed it was broken.

Two changes drop that gap:

1. Material self-drives current_frame from `Time × fps` instead of
   reading a scalar parameter the actor has to poke each Tick.
   Replaces the `current_frame` Scalar Parameter with an
   ExpressionTime → Multiply chain, plus a new `fps` Scalar
   Parameter (default 30). No Blueprint, no Tick, no
   MaterialInstanceDynamic required — Unreal's Time node ticks in
   the editor too, so the animation plays without hitting Play.

2. spawn_dancer_in_level() drops a SkeletalMeshActor at the world
   origin with SK_Rumba + M_OpenVAT applied and Animation Mode set
   to None. Replaces the BP_VATDancer scaffold + the manual
   4-node-wiring instructions in the README.

   Looks up `EditorLevelLibrary` (UE 5.0..5.4) or the new
   `EditorActorSubsystem` (UE 5.5+), and tries both names for the
   skeletal-mesh property setter — set_skeletal_mesh_asset is the
   5.4+ name; the editor_property fallback covers older builds.
   Idempotent — re-runs delete the previous OpenVAT_Dancer first.

The bootstrap now ends with the dancer visibly animating in the
editor viewport. README simplified accordingly: drop the entire
"Wire BP_VATDancer's tick" step.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/unreal-vat-demo/Content/Python/build_vat_demo.py`:
- Around line 390-395: The loops that remove actors use "except Exception: pass"
which silently swallows failures; change both occurrences handling
actor_lib.get_all_level_actors() (the block checking a.get_actor_label() ==
"OpenVAT_Dancer" and the similar block at 418-422) to catch the exception as a
variable (e.g., "except Exception as e") and emit a diagnostic (use
logging.exception or print including the actor identity and traceback) so
failures are visible, then continue; add/import the logging module if necessary.
- Around line 443-449: The final success banner is currently always logged even
if operations fail; update the main flow around read_sidecar, build_material,
and spawn_dancer_in_level so that errors or false/None return values from
build_material or spawn_dancer_in_level are detected and the banner is only
emitted on true success; wrap calls in try/except (or check their return
values), log a clear error via unreal.log_error when an operation fails, and
abort/return instead of falling through to the unconditional unreal.log(...)
success message.

In `@tools/unreal-vat-demo/README.md`:
- Around line 9-23: The README's fenced code block showing the directory tree
(the block that begins with "```" followed by "tools/unreal-vat-demo/") lacks a
language tag and triggers MD040; update that opening fence to include a language
tag (e.g., change the leading "```" to "```text") so the block is explicitly
marked as plain text and the markdown linter stops flagging it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d907227a-d2c4-4c52-97c5-8a2740d7d3a9

📥 Commits

Reviewing files that changed from the base of the PR and between cfbbbfb and 3a9e6a4.

⛔ Files ignored due to path filters (5)
  • tools/unreal-vat-demo/Content/Rumba/Boss_diffuse.png is excluded by !**/*.png
  • tools/unreal-vat-demo/Content/Rumba/mixamo.com_ogre_bind.bin is excluded by !**/*.bin
  • tools/unreal-vat-demo/Content/Rumba/mixamo.com_pos.png is excluded by !**/*.png
  • tools/unreal-vat-demo/Content/Rumba/source.bin is excluded by !**/*.bin
  • tools/unreal-vat-demo/Content/Rumba/source.gltf is excluded by !**/*.gltf
📒 Files selected for processing (7)
  • tools/unreal-vat-demo/.gitignore
  • tools/unreal-vat-demo/Content/Python/build_vat_demo.py
  • tools/unreal-vat-demo/Content/Rumba/mixamo.com-remap_info.json
  • tools/unreal-vat-demo/Content/Rumba/openvat.usf
  • tools/unreal-vat-demo/QtMeshVAT.uproject
  • tools/unreal-vat-demo/README.md
  • website/src/DocsApp.jsx

Comment thread tools/unreal-vat-demo/Content/Python/build_vat_demo.py Outdated
Comment thread tools/unreal-vat-demo/Content/Python/build_vat_demo.py Outdated
Comment on lines +9 to +23
```
tools/unreal-vat-demo/
├── QtMeshVAT.uproject ← open this in UE 5.3+
├── README.md ← you are here
└── Content/
├── Rumba/ ← bake artifacts (data files only)
│ ├── source.gltf + .bin ← mesh + per-vertex column index in TEXCOORD_1
│ ├── Boss_diffuse.png ← diffuse texture
│ ├── mixamo.com_pos.png ← 16-bit position+normal texture
│ ├── mixamo.com-remap_info.json
│ ├── mixamo.com_ogre_bind.bin ← legacy fallback, not used here
│ └── openvat.usf ← reference shader body
└── Python/
└── build_vat_demo.py ← runs once to create Material + BP
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language tag to the fenced code block.

This block currently triggers markdown lint (MD040), which may fail docs lint gates.

Proposed fix
-```
+```text
 tools/unreal-vat-demo/
 ├── QtMeshVAT.uproject           ← open this in UE 5.3+
@@
-```
+```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 9-9: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/unreal-vat-demo/README.md` around lines 9 - 23, The README's fenced
code block showing the directory tree (the block that begins with "```" followed
by "tools/unreal-vat-demo/") lacks a language tag and triggers MD040; update
that opening fence to include a language tag (e.g., change the leading "```" to
"```text") so the block is explicitly marked as plain text and the markdown
linter stops flagging it.

Re-running the bootstrap left the user staring at an empty viewport
with no clue where it failed. Every step now logs what it loaded,
the spawn function reports each branch, and a step-by-step trace in
main() makes the failing stage obvious in the Output Log.

Concretely:
  - main() now logs "step 1/5", "step 2/5", … and exits with a
    "Bootstrap STOPPED at step N" banner on any prerequisite miss.
  - Right after import_bake_assets() we `unreal.load_asset` each
    expected asset (SK_Rumba, T_OpenVAT_Pos, T_Boss_Diffuse) and
    log the resulting object. Silent import failure is now visible.
  - spawn_dancer_in_level() spawns at (200, 0, 0) facing -X (Mixamo
    bind pose) instead of at the origin — the default empty map's
    editor camera looks at +X from ~(0,0,200), so (200,0,0) lands
    front-and-center. The actor is also selected after spawn and
    the viewport is invalidated so the dancer shows immediately.
  - Tries set_skeletal_mesh_asset / set_skeletal_mesh /
    skeletal_mesh_asset / skeletal_mesh in turn (the property name
    moved across 5.3..5.5) and logs an error if none succeeded.
  - get_all_level_actors() failures are caught + logged rather than
    silently breaking the cleanup pass.

With this, "I see an empty map" reduces to a single log line that
says which step gave up.
Most users open a .uproject by double-clicking it; nobody actually
reads the README's "open Output Log, switch to Python, type
`py Content/Python/build_vat_demo.py`" step. So the user gets the
editor open, sees an empty viewport, and concludes the demo is
broken.

UE's PythonScriptPlugin auto-discovers `init_unreal.py` on any of
its startup-script paths — `<project>/Content/Python/` is on that
path by default. Drop the autorun there and the bootstrap fires on
the editor's first tick, no UI interaction needed.

  init_unreal.py:
    - Pre-checks: bake artifacts present AND M_OpenVAT doesn't
      already exist. Re-opens of a previously-bootstrapped project
      no-op (so we don't wipe + rebuild every launch).
    - Defers the actual main() call until the next editor tick via
      register_slate_post_tick_callback. init_unreal.py fires very
      early — before the level loader has finalised — and
      spawn_actor_from_class returns None at that point.
    - One-shot: unregisters its callback after the first fire so it
      doesn't run on every tick.
    - Fallback: if the slate-tick API isn't available (older UE
      builds), runs immediately and accepts the small risk that the
      spawn fails with a clear error.

  README:
    - Top-of-flow is now "double-click .uproject → bootstrap fires
      on its own". Three steps instead of five.
    - "How to re-run / how to force a rebuild" pushed below the
      golden path so users who just want to see the dancer don't
      have to read about it.
    - Troubleshooting section keeps the manual-import + missing-
      TEXCOORD_1 escape hatches.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/unreal-vat-demo/Content/Python/build_vat_demo.py`:
- Around line 513-525: The check currently aborts only if mesh (SK_Rumba) is
missing but not when pos (T_OpenVAT_Pos) or diff (T_Boss_Diffuse) are None,
which lets build_material() create M_OpenVAT with unbound textures; update the
validation after loading assets (mesh, pos, diff) to treat any None as a
bootstrap failure by logging a clear unreal.log_error and returning when pos or
diff is missing so build_material() is never called with missing T_OpenVAT_Pos
or T_Boss_Diffuse.
- Around line 417-421: The actor enumeration catch currently swallows the
exception and returns an empty list, which causes main() to continue and spawn
duplicate OpenVAT_Dancer actors; instead, after logging the error from
get_all_level_actors(), return None so the caller (main) takes the
partial-failure path and aborts spawn/cleanup; update the except block for
get_all_level_actors() to log the exception details and return None (not an
empty list), and ensure main() checks for None before proceeding to spawn
OpenVAT_Dancer instances.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: be116bb0-a144-467f-b6ed-dba6c803f3ac

📥 Commits

Reviewing files that changed from the base of the PR and between 3a9e6a4 and c83c468.

📒 Files selected for processing (1)
  • tools/unreal-vat-demo/Content/Python/build_vat_demo.py

Comment thread tools/unreal-vat-demo/Content/Python/build_vat_demo.py Outdated
Comment thread tools/unreal-vat-demo/Content/Python/build_vat_demo.py Outdated
fernandotonon and others added 5 commits May 21, 2026 02:25
Interchange's `destination_name` parameter on `ImportAssetParameters`
turns out to be advisory for glTF — UE 5.5/5.7 always writes the
skeletal mesh under `/Game/<src>/source/SkeletalMeshes/<mesh-name>`
regardless of what we asked for. The script was looking at
`/Game/Rumba/SK_Rumba`, finding nothing, and stopping with
"SK_Rumba did not import" while the mesh was actually sitting at
`/Game/Rumba/source/SkeletalMeshes/SK_Rumba`.

New `find_skeletal_mesh()` tries:
  1. Known paths in order:
       /Game/Rumba/SK_Rumba
       /Game/Rumba/source/SkeletalMeshes/SK_Rumba
       /Game/Rumba/Rumba_Dancing_mesh
       /Game/Rumba/source/SkeletalMeshes/Rumba_Dancing_mesh
  2. AssetRegistry sweep under /Game/Rumba/ for the first
     SkeletalMesh asset.

Reported by the user's UE 5.7 init_unreal log:
  LogInterchangeImport: Warning: Node [Rumba_Dancing_mesh] with a
    skinned mesh is not root.
  LogPython:   SK_Rumba = None
  LogPython: Error: === Bootstrap STOPPED: SK_Rumba did not import.
Two compounding bugs caused the dancer to render as a static
upside-down bind pose:

1. The Custom node wrote ABSOLUTE model-space coordinates into
   WPO. WPO expects an OFFSET, not a target position. The dancer's
   vertices were being pushed by ~1 cm (because the bake's bounds
   are ±1 m and WPO is interpreted in cm) — effectively static.

2. The bake's texels + bounds live in glTF-native space (Y-up RH,
   meters). Unreal's Interchange importer has already swizzled the
   imported SkeletalMesh to Z-up LH cm. So even a working offset
   would land on the wrong axis — the visible bind pose was
   upside-down because the offset's Y/Z were flipped.

Fix: read the bind position via the new MaterialExpressionPreSkinnedPosition
node (object-space, Z-up, cm), then compute the WPO offset inside
the Custom node as:

  target_yup_m  = bounds_min + p * (bounds_max - bounds_min)
  target_zup_cm = (target.x, -target.z, target.y) * 100   // Y-up → Z-up + m→cm
  wpo           = target_zup_cm - bind_local              // delta in cm, Z-up

That puts the swizzle and the unit conversion next to each other in
HLSL where they're easy to reason about, and keeps the actor's
transform clean: the spawned OpenVAT_Dancer no longer needs the
manual 200-unit offset or the 180° rotation hack — it spawns at
(0,0,0) facing +X and the material does all the coordinate-system
work.

Material `Time × fps` continues to drive `current_frame` inside the
Custom node, so the animation loops forever (the HLSL fmod with
N=frame_count was already there; just nothing was actually being
emitted as an offset for WPO to apply).
The auto-runner's "skip if material exists" check was preventing
the coordinate-system fix (0e7aa27) from running on user's next
project open. Replace it with a build-number compare: bump
OPENVAT_BUILD in build_vat_demo.py when the graph layout changes
and init_unreal will rebuild on next open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UE's SkeletalMesh importer renormalises every render section's
secondary UVs to its own [0,1] range, which destroys the absolute
column index `qtmesh vat --emit-uv2` writes — submeshes whose
column 0 happens to land on a stationary vertex render frozen,
others read the wrong slice of the position texture and produce
chaotic triangles.

Force Interchange to route the glTF as a StaticMesh (skipping the
skeleton path entirely; the VAT replaces skinning anyway) and use
MaterialExpressionLocalPosition instead of PreSkinnedPosition for
the bind-pose source. Also pre-clean any leftover SkeletalMesh
imports on each bootstrap run and spawn StaticMeshActor when the
static path took. Bumps OPENVAT_BUILD to 4 so auto-runner picks up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous attempt set Interchange's ForceAllMeshAsType on the
mesh_pipeline sub-object, but on UE 5.7 the property actually lives
on common_meshes_properties — so the override silently no-op'd and
the glTF kept routing through the skeletal-mesh path. Verified
against the engine headers
(InterchangeGenericAssetsPipelineSharedSettings.h:69-70).

Also pass the transient pipeline via params.pipelines (5.7+ accepts
that directly) instead of override_pipelines + SoftObjectPath, which
needs an on-disk asset. Plus: init_unreal now rebuilds when the mesh
is missing from disk, not just the material — otherwise a failed
import locks the demo into "skip; material is current."

Bumps OPENVAT_BUILD to 5 so the auto-runner picks up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
tools/unreal-vat-demo/Content/Python/build_vat_demo.py (3)

739-800: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast on partial actor configuration.

Both branches can log component/mesh/material assignment failures and still return a non-None actor. main() then treats that as success even though the spawned actor is empty or not using M_OpenVAT.

Suggested fix
     if is_static:
         sm_comp = actor.static_mesh_component
         if sm_comp is None:
             unreal.log_error("Spawned actor has no static_mesh_component.")
-            return actor
+            actor_lib.destroy_actor(actor)
+            return None
@@
         if not set_ok:
             unreal.log_error("Could not assign StaticMesh to the "
                              "spawned actor. The actor will render empty.")
+            actor_lib.destroy_actor(actor)
+            return None
         try:
             sm_comp.set_material(0, mat)
         except Exception as e:
             unreal.log_warning("set_material(0) failed: " + str(e))
+            actor_lib.destroy_actor(actor)
+            return None
     else:
         skel_comp = actor.skeletal_mesh_component
         if skel_comp is None:
             unreal.log_error("Spawned actor has no skeletal_mesh_component.")
-            return actor
+            actor_lib.destroy_actor(actor)
+            return None
@@
         if not set_ok:
             unreal.log_error("Could not assign SkeletalMesh to the "
                              "spawned actor. The actor will render empty.")
+            actor_lib.destroy_actor(actor)
+            return None
         try:
             skel_comp.set_material(0, mat)
         except Exception as e:
             unreal.log_warning("set_material(0) failed: " + str(e))
+            actor_lib.destroy_actor(actor)
+            return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/unreal-vat-demo/Content/Python/build_vat_demo.py` around lines 739 -
800, The spawn code returns an actor even when component/mesh/material
assignment partially fails; update both branches (the static branch using
static_mesh_component and methods set_static_mesh /
set_editor_property("static_mesh") and the skeletal branch using
skeletal_mesh_component and methods set_skeletal_mesh_asset / set_skeletal_mesh
/ set_editor_property("skeletal_mesh*")) to fail fast: if the component is
missing or any critical assignment (mesh or VAT material assignment via
set_material(0) to mat) did not succeed (set_ok is False or material assignment
raised), log the error and return None (or raise) instead of returning the actor
so main() won't treat a misconfigured actor as success. Ensure the final
animation_mode set_editor_property call remains guarded but does not override
the failure-return logic.

823-835: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Abort when either imported texture is missing.

This gate still stops only on a missing mesh. If T_OpenVAT_Pos or T_Boss_Diffuse failed to import, build_material() still runs with unbound texture inputs and the bootstrap can report success with a non-working demo.

Suggested fix
     mesh, _is_static = find_imported_mesh()
     pos  = unreal.load_asset(BAKE_DIR + "/T_OpenVAT_Pos")
     diff = unreal.load_asset(BAKE_DIR + "/T_Boss_Diffuse")
     unreal.log("  Mesh            = %s" % mesh)
     unreal.log("  T_OpenVAT_Pos   = %s" % pos)
     unreal.log("  T_Boss_Diffuse  = %s" % diff)
-    if mesh is None:
+    missing = []
+    if mesh is None:
+        missing.append("mesh")
+    if pos is None:
+        missing.append("T_OpenVAT_Pos")
+    if diff is None:
+        missing.append("T_Boss_Diffuse")
+    if missing:
         unreal.log_error(
-            "=== Bootstrap STOPPED: no mesh found under "
-            "/Game/Rumba/. Drag Content/Rumba/source.gltf into the "
-            "Content Browser manually (choose Static Mesh in the "
-            "import dialog) and re-run this script. ===")
+            "=== Bootstrap STOPPED: missing imported asset(s): %s. "
+            "Re-import the bake outputs under /Game/Rumba and re-run "
+            "this script. ===" % ", ".join(missing))
         return
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/unreal-vat-demo/Content/Python/build_vat_demo.py` around lines 823 -
835, The script currently only aborts when the imported mesh is missing; update
the bootstrap check to also abort if either texture asset load for T_OpenVAT_Pos
or T_Boss_Diffuse fails. After the unreal.load_asset calls that set pos and diff
(in the same block that calls find_imported_mesh()), add checks for pos is None
and diff is None and call unreal.log_error with clear messages and return early
(same pattern used for mesh), so that build_material() is not invoked with
missing/unbound textures.

704-708: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Abort the spawn path if actor enumeration fails.

Falling back to [] here skips cleanup and still spawns another OpenVAT_Dancer, so reruns can violate the idempotent contract.

Suggested fix
     try:
         all_actors = actor_lib.get_all_level_actors()
     except Exception as e:
         unreal.log_warning("get_all_level_actors raised: " + str(e))
-        all_actors = []
+        return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/unreal-vat-demo/Content/Python/build_vat_demo.py` around lines 704 -
708, The try/except that swallows errors from actor_lib.get_all_level_actors()
should not fall back to an empty list because that allows the spawn path to
proceed and create a duplicate OpenVAT_Dancer; instead, propagate the failure or
abort the spawn routine immediately: in the block surrounding
get_all_level_actors() (the function that later spawns OpenVAT_Dancer), replace
the current except branch that sets all_actors = [] with logic that either
re-raises the caught exception or returns/raises a specific error to stop
further execution so cleanup/idempotency runs and no new OpenVAT_Dancer is
spawned.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/unreal-vat-demo/Content/Python/build_vat_demo.py`:
- Around line 283-308: verify_imported_uv_channels currently logs a fatal
SkeletalMesh import but does not stop execution; change it to treat a
SkeletalMesh import as a hard stop by raising an exception (e.g. RuntimeError or
SystemExit) after logging the unreal.log_error so main() won’t continue with a
known-bad asset, and apply the same change for the analogous check referenced at
the other occurrence (the code around the second occurrence at lines noted). Use
the function name verify_imported_uv_channels and the main entry path to locate
and modify these checks so the import error aborts the script instead of
returning success.

In `@tools/unreal-vat-demo/Content/Python/init_unreal.py`:
- Around line 50-63: The mesh existence check in init_unreal.py currently uses a
narrow, hardcoded mesh_paths tuple which can miss assets placed at other paths
by the importer; replace that block with the same discovery logic used in
build_vat_demo.py (i.e. call or replicate the mesh-path discovery function used
by the bootstrap/importer instead of the static mesh_paths), or perform a
recursive asset search under the /Game/Rumba root (e.g., use
unreal.EditorAssetLibrary.list_assets('/Game/Rumba', recursive=True) and match
asset names like Rumba/SM_Rumba/SK_Rumba) and then use
any(...does_asset_exist...) against those discovered paths; locate the
mesh_paths tuple and the if not any(...) check in init_unreal.py and swap in the
shared discovery function or the recursive search so a successful import at
different paths is recognized and the rebuild is not triggered repeatedly.

---

Duplicate comments:
In `@tools/unreal-vat-demo/Content/Python/build_vat_demo.py`:
- Around line 739-800: The spawn code returns an actor even when
component/mesh/material assignment partially fails; update both branches (the
static branch using static_mesh_component and methods set_static_mesh /
set_editor_property("static_mesh") and the skeletal branch using
skeletal_mesh_component and methods set_skeletal_mesh_asset / set_skeletal_mesh
/ set_editor_property("skeletal_mesh*")) to fail fast: if the component is
missing or any critical assignment (mesh or VAT material assignment via
set_material(0) to mat) did not succeed (set_ok is False or material assignment
raised), log the error and return None (or raise) instead of returning the actor
so main() won't treat a misconfigured actor as success. Ensure the final
animation_mode set_editor_property call remains guarded but does not override
the failure-return logic.
- Around line 823-835: The script currently only aborts when the imported mesh
is missing; update the bootstrap check to also abort if either texture asset
load for T_OpenVAT_Pos or T_Boss_Diffuse fails. After the unreal.load_asset
calls that set pos and diff (in the same block that calls find_imported_mesh()),
add checks for pos is None and diff is None and call unreal.log_error with clear
messages and return early (same pattern used for mesh), so that build_material()
is not invoked with missing/unbound textures.
- Around line 704-708: The try/except that swallows errors from
actor_lib.get_all_level_actors() should not fall back to an empty list because
that allows the spawn path to proceed and create a duplicate OpenVAT_Dancer;
instead, propagate the failure or abort the spawn routine immediately: in the
block surrounding get_all_level_actors() (the function that later spawns
OpenVAT_Dancer), replace the current except branch that sets all_actors = []
with logic that either re-raises the caught exception or returns/raises a
specific error to stop further execution so cleanup/idempotency runs and no new
OpenVAT_Dancer is spawned.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 05dadb22-4057-4be8-b5b9-ebae9d3a4dae

📥 Commits

Reviewing files that changed from the base of the PR and between c83c468 and 8d852e8.

📒 Files selected for processing (3)
  • tools/unreal-vat-demo/Content/Python/build_vat_demo.py
  • tools/unreal-vat-demo/Content/Python/init_unreal.py
  • tools/unreal-vat-demo/README.md
✅ Files skipped from review due to trivial changes (1)
  • tools/unreal-vat-demo/README.md

Comment thread tools/unreal-vat-demo/Content/Python/build_vat_demo.py Outdated
Comment thread tools/unreal-vat-demo/Content/Python/init_unreal.py Outdated
fernandotonon and others added 9 commits May 21, 2026 16:02
…ak WPO feedback loop

MaterialExpressionLocalPosition defaults to IncludeOffsets, meaning
it returns the post-WPO vertex position — but the WPO is *what we're
computing*. Result: WPO = target - (bind + WPO_prev), a fixed-point
iteration that converges to bind pose for most vertices and
oscillates for some, which is exactly the "static head, weird foot
rotation, twisted eyes/mouth" symptom in the screenshot.

ExcludeOffsets returns the pre-WPO (= bind) vertex, which is what
the math actually requires.

Bumps OPENVAT_BUILD to 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Rumba mesh has 11 primitives → 11 material slots. spawn_dancer
was only calling set_material(0), so submeshes 1..10 kept the
Interchange-imported PBR materials (Skin_MAT, Clothes_MAT, etc.)
with no WPO and rendered in bind pose. The user's screenshot
showed exactly this: vest animating but head/eyes/hat/scarves
frozen in the bind pose.

Loop every slot, plus enable bIgnorePause on the Time node as a
safety net for editor-only viewports. Bumps OPENVAT_BUILD to 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rect z-order

MaterialFactoryNew's default blend mode varies by UE version + project
settings; 5.7 can default new materials to Masked/Translucent which
disables depth writes and causes the "no z-index" look (body parts
visually overlapping in random draw order). Explicitly set
Opaque/Surface/DefaultLit/single-sided so the mesh depth-tests
correctly. Bumps OPENVAT_BUILD to 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…urvives

Interchange merges glTF primitives that share a material name into
one render section (Mixamo's Skin_MAT covers head + arms + feet:
3 primitives → 1 section). The merged section renumbers TEXCOORD_1
within its combined vertex buffer, so the column index points at
the wrong texture column and head verts read body data and vice
versa. Visually: submeshes appear to render through each other.

Set b_keep_sections_separate=True on common_meshes_properties so
each glTF primitive becomes its own section with its original
TEXCOORD_1 values intact. Bumps OPENVAT_BUILD to 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+ wipe SM_Rumba between runs

The previous attempt used `b_keep_sections_separate` (the auto
snake-case form) but UE 5.7's Python binding rejected it. Try the
b-less `keep_sections_separate` first, then the b-prefixed variants;
log whichever takes.

Also: the pre-import cleanup only deleted SkeletalMesh-flavoured
assets, so a leftover SM_Rumba.uasset from build 8 was being reused
(carrying the merged-section config) instead of re-imported with the
new override flags. Nuke every mesh-side asset under /Game/Rumba/
each run. Textures still skip the cleanup — they're idempotent.

Bumps OPENVAT_BUILD to 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ocal dependency)

Drop the LocalPosition dependency entirely — instead of
WPO = target - bind_local, sample the bake at frame 0 (Mixamo's
bind/T-pose by construction) and compute WPO = (target - p0)
swizzled into Unreal cm. Frame 0 and target come from the same
texture in the same coordinate system, so the subtraction sidesteps
every "what coord system does LocalPosition return on UE 5.7
for a static mesh?" question.

Also dump per-section material-slot index after import so we can
diagnose Interchange's material-merge behaviour from the log.

Bumps OPENVAT_BUILD to 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… negation)

Interchange's glTF importer uses (X, Y, Z) → (X, Z, Y) without
negating Y — handedness flip is handled via reversed triangle
winding instead (verified in UE 5.7's ConversionUtilities.h:20 and
GLTFMeshFactory.cpp:632). My shader was using the standard
(x, -z, y) swizzle, which flipped one axis vs the imported mesh —
hence the dancer moving in unexpected directions while remaining
coherent.

Bumps OPENVAT_BUILD to 12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ditor tweaking

The bake's coord system depends on the entire import chain that
produced it (Ogre's Assimp uses ConvertToLeftHanded which negates
Z, Interchange uses (X, Z, Y) and reverses winding, etc.).
Hardcoding one swizzle doesn't work for every source. Drive the
final Y-up→Z-up mapping from three VectorParameters
(swizzle_row_x/y/z) so the user can twiddle in the Material editor
without rebuilding — change a single sign or permute rows and the
preview updates immediately.

Defaults to the (X, Z, Y) Interchange swizzle. To try alternatives:
  - (X, -Z, Y):   swizzle_row_y = (0, 0, -1), swizzle_row_z = (0, 1, 0)
  - (-X, -Z, Y):  swizzle_row_x = (-1, 0, 0), etc.

Bumps OPENVAT_BUILD to 13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…0 delta)

Mixamo's Rumba animation frame 0 is the first frame of the dance,
mid-pose — NOT the T-pose bind. Treating frame 0 as bind meant the
bake delta was "motion from mid-frame-0 pose," then applied on top
of the T-pose LocalPosition — produced coherent-but-wrong motion no
matter how the output swizzle was twiddled (because the math itself
was incoherent, not the coord system).

Now compute WPO as `target_absolute - bind_real` in bake's glTF
Y-up meters space:
  - target_yup_m = bounds_min + p × range          (absolute, from texture)
  - bind_yup_m = inverse_swizzle(LocalPosition) / 100   (from imported mesh)
  - WPO_unreal_cm = swizzle(target_yup_m - bind_yup_m) × 100

The user-tweakable swizzle params now control only the bake→Unreal
direction mapping (default = Interchange's (X, Z, Y)).

Bumps OPENVAT_BUILD to 14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fernandotonon and others added 3 commits May 22, 2026 00:10
…on't get culled

The StaticMesh's bounding box is built from the bind pose only —
Unreal has no idea the material's WPO will displace vertices, so
view-frustum culling and per-section visibility checks fire when
animated vertices wander outside that AABB. Symptom: tiny
submeshes (eyes, cigar tip) blink out on specific frames where the
head rotation briefly pushes their bind-pose position outside the
static AABB, even though the WPO would have put them back inside.

3× bounds_scale gives the dance plenty of headroom without hurting
culling perf for the demo's single-actor scene. Bumps OPENVAT_BUILD
to 15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d-trip)

Previous shader did target - bind_yup_m where bind_yup_m was
inverse-swizzled from LocalPosition then forward-swizzled back —
mathematically equivalent but introduces extra floating-point
operations that produce sub-mm errors. For Mixamo's eye sclera
shells (which sit fractions of a mm in front of the head plug)
those errors can flip z-order and cause the iris to briefly
disappear on specific frames.

Compute the target absolute position in Unreal cm directly, then
subtract LocalPosition once at the end. Same math for the normal
case, but with one less swizzle round-trip and less precision loss
near coplanar surfaces.

Bumps OPENVAT_BUILD to 16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… z-fight

The bake stores positions as uint16 normalized into the dancer's
full bounding range (~2.1m), giving ~0.032mm precision per axis.
Mixamo's eye sphere sits a fraction of a mm in front of the head's
"eye socket plug" submesh, so the per-frame quantization jitter is
on the same order as the eye/plug gap — classic z-fighting that:
  - flickers in/out frame by frame (the "underwater" look)
  - depends on camera angle (depth-test resolution varies)
  - fails asymmetrically (one eye visible, the other not)

Bias the entire material's rendered depth 5mm toward the camera
via PixelDepthOffset so the bake's fragments always win the depth
test against coplanar surfaces behind them. The eye/iris layer is
the only place this matters, and 5mm is well below visible-bias
threshold for the rest of the mesh.

The real fix is a 32-bit float (EXR) bake — would eliminate the
jitter entirely instead of masking it. That requires bake-pipeline
changes in qtmesh (TODO, separate epic). Bumps OPENVAT_BUILD to 17.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

fernandotonon and others added 20 commits May 22, 2026 03:29
…itter

The OpenVAT bake has been uint16 PNG since day one. For Mixamo-scale
characters (~2 m bounds) the per-axis quantization step is ~0.03 mm
— same order of magnitude as the gap between coplanar shells (e.g.
Mixamo's eye sphere ~0.5 mm in front of the head's eye-socket plug).
Result on the Unreal demo: per-frame jitter randomly flips the
depth-test outcome, eye/iris flickers in & out depending on camera
angle, dancer appears to be rendered "underwater".

Add a 32-bit-float code path that sidesteps quantization entirely:

  * `qtmesh vat --bake-precision 32` writes `<name>_pos.exr` instead
    of `<name>_pos.png`. EXR stores raw post-skin meters (no
    bounds-min/max remap); precision = float32 (sub-µm at sub-1m
    scales). File ~6× larger than the PNG but still small.
  * Sidecar JSON now carries `_bit_depth: 16|32` so consumers know
    which file to read and how to decode it. Old bakes (no field)
    default to 16. Backward compatible with all existing tooling.
  * Minimal scanline EXR writer (`MinimalEXRWriter.{h,cpp}`) — Qt 6
    has no native EXR plugin and pulling OpenEXR/Imath just for a
    one-off bake would blow up the build matrix, so ship a focused
    ~150 LOC encoder with its own round-trip unit tests.

Wire the Unreal demo to dispatch on `_bit_depth`:

  * `import_bake_assets()` looks for the EXR first, falls back to
    PNG. Texture compression is TC_HDR for EXR (RGBA16F internal,
    preserves float values) vs TC_VECTOR_DISPLACEMENTMAP for PNG.
  * Custom HLSL gets a `decode_mode` scalar param: 0 = remap via
    bounds (PNG path), 1 = raw texel (EXR path).
  * PixelDepthOffset hack from build 17 is now only applied to
    16-bit bakes — the EXR precision eliminates the eye z-fight
    at its source, no bias needed.

Re-baked the demo's Rumba asset at 32-bit so the next project open
picks up the EXR + sidecar automatically. Bumps OPENVAT_BUILD to 18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…D_1 > 2048

By default Interchange stores secondary UV channels as half-precision
floats (16-bit). The half-float mantissa is 11 bits, so integers
above 2048 round to the nearest representable value — aliasing
neighbouring TEXCOORD_1 column indices together.

For this Mixamo bake the asset has 5828 vertices, so all columns
past the first ~third get aliased: vertex N (col > 2048) reads
vertex M's animated position where M ≠ N but is the nearest
representable half-float. On most frames this looks fine because
neighbour deltas are small, but on frames where the aliased
neighbour happens to be displaced far from its bind, that vertex
visibly jumps to the wrong spot. Small submeshes whose column
ranges are adjacent to >2048 territory (the cigar at 1438-1549,
sitting right next to skin verts 1550-2855) show the artifact
most clearly because there's no surrounding geometry to mask it.

Set bUseFullPrecisionUVs=True so TEXCOORD_1 is stored as float32
and integer column indices round-trip exactly. Bumps OPENVAT_BUILD
to 19.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…izer depth precision)

Build 18 disabled the -0.5 cm PixelDepthOffset for 32-bit bakes on
the assumption that vertex-position quantization was the only source
of eye/teeth z-fighting. That was wrong — UE's depth buffer is
screen-space and its precision depends on the camera's near/far
ratio, not vertex precision. Mixamo's eye sclera and dental plug
sit sub-mm inside the head; even perfect float32 vertex positions
can still flip on the depth test when rasterized into a depth
buffer with lower precision than the vertex Z.

Re-apply -0.5 cm always so the VAT-displaced shells reliably win
z-tests against any coplanar static-pose plug behind them. 5mm is
invisible for non-coplanar surfaces. Bumps OPENVAT_BUILD to 20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…litch

Build 20 applied -0.5 cm PDO globally on the theory that
rasterizer depth-buffer precision was the bottleneck. In practice
the bias makes things worse: vertices that previously occluded
correctly now z-fight with the OTHER coplanar layer they were
sitting between. More frames glitch, both eyes affected, teeth too.

Drop PDO entirely. The 32-bit EXR bake + full-precision UVs are
the actual fix; remaining sub-mm glitches will be diagnosed
properly (likely Interchange vertex de-dup collapsing the eye
sclera's duplicate-position verts) instead of masked.

Bumps OPENVAT_BUILD to 21.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…AT slot

Eye z-fight diagnosis from video frames 1, 2, 4, 5, 6: the iris/pupil
submesh and the head's eye-socket plug behind it sit coplanar at sub-mm
depth. Even with float32 EXR positions, the rasterized depth buffer
can't reliably keep them apart from view-dependent angles — one frame
the iris wins, the next the plug wins (visible as a white sclera).

Global PixelDepthOffset (build 20) made it worse because it biased
ALL fragments forward, including the head plug itself, which then
z-fights with the OTHER face layers it was previously occluding.

Per-slot fix:
  * Build M_OpenVAT_Eye by duplicating M_OpenVAT and adding a
    PixelDepthOffset = -1 cm constant.
  * On spawn, walk static_materials slot-by-slot. Slots whose
    `material_slot_name` contains "Eyes_MAT" get M_OpenVAT_Eye;
    every other slot gets the original M_OpenVAT.
  * 1 cm is below the visible-bias threshold for the eye region
    (the eye sphere is the deepest layer there — no other coplanar
    geometry to fight with from this push direction).

Teeth share Skin_MAT with the rest of the face, so we can't isolate
them with the same per-slot trick. The teeth-distortion-on-some-
frames is left as a separate (smaller) bug. Bumps OPENVAT_BUILD to 22.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 22's M_OpenVAT_Eye with PDO=-1cm on the Eyes_MAT slot pushed
the iris fragments forward, which is correct for the eye-vs-plug
z-fight in isolation — but it also moved the iris vertices forward
of the ear and tooth submeshes that previously occluded them
correctly from oblique camera angles. Result: more frames glitch,
ears flicker.

Every PDO push reveals another coplanar layer behind. PDO is the
wrong tool for this geometry. Drop M_OpenVAT_Eye, return to plain
M_OpenVAT on every slot. The remaining glitch (sub-mm coplanar
z-fight on eye/teeth from certain angles) needs a real diagnosis
of WHY UE's static-mesh build is destabilising those verts —
likely vertex de-dup with TEXCOORD_1 not part of the equality key.

Bumps OPENVAT_BUILD to 23 and pre-cleans the stale M_OpenVAT_Eye
asset for users who already ran build 22.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TC_HDR is RGBA16F — half-precision float. We've been writing 32-bit
floats to the EXR (the whole point of the precision overhaul) only
to have UE round them back down to half-precision on GPU upload.
Verified in UE 5.7's TextureDefines.h:
  TC_HDR     → RGBA16F (~3 mm precision at head height)
  TC_HDR_F32 → RGBA32F (sub-µm precision)

At head height (Y ≈ 1.65 m) the gap between adjacent half-floats is
~2 mm, which is plenty to flip the depth-test outcome on coplanar
eye/teeth/ear sub-meshes from frame to frame. That's exactly the
"specific-frames glitch" pattern from the video.

Switch the EXR-import compression to TC_HDR_F32. Falls back to
TC_HDR if the enum value isn't exposed in older engines (5.4 and
below). Bumps OPENVAT_BUILD to 24.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…otated WPO verts

The mesh's vertex normals are baked from the BIND pose. The Custom
HLSL drives WPO (positions move per-frame) but never updates the
normals — so when the dance rotates the head/face 90°+ from bind,
small sub-meshes (eyes, teeth, ears) whose bind-pose normals
straddle the camera-facing threshold get back-face culled even
though their WPO-displaced positions are toward the camera.

Symptom: specific frames render the rotated face WITH eye/teeth
sub-meshes missing — the missing region was culled by a rasterizer
back-face test that used the wrong (bind-pose) normal direction.

two_sided draws both faces unconditionally, which is the correct
behaviour for any mesh whose normals don't track WPO. Costs a bit
of fill rate, no lighting artifact at this distance.

The complete fix would be to also output VAT-driven world-space
normals from the bake's lower-half normal texture, but two-sided is
the load-bearing fix; computed normals are a polish pass.

Bumps OPENVAT_BUILD to 25.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ngs + rebuild

User's observation: ONLY the head submeshes glitch, body fine. That
matches the col-index-above-2048 hypothesis (Skin0=1550-2855,
Skin1=2856-3236, Skin2=3877-4257, Eyes0=3691-3783, Eyes1=3784-3876
are all > 2048; Clothes/Cigar are mostly below). Half-float UV
storage rounds those columns to the nearest representable
half-precision value, aliasing head verts onto each other's
animation data.

We were already setting bUseFullPrecisionUVs=True on the Interchange
pipeline at import time, but that flag doesn't reliably propagate
into the StaticMesh's per-LOD FMeshBuildSettings — the StaticMesh
ends up keeping its default (half-precision) on rebuild. Belt-and-
braces: walk every LOD's BuildSettings, flip the flag, call build()
+ save. Two-sided remains from build 25.

Bumps OPENVAT_BUILD to 26.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sionUVs

Build 26 tried mesh.get_lod_build_settings(lod) — but that method
doesn't exist on UStaticMesh, the API lives on the
StaticMeshEditorSubsystem with the build settings passed/returned
by reference. Log was silent because the hasattr() check bailed.

Use the correct subsystem API now. If the flag was already set by
Interchange, log confirms and skip the rebuild. If not, flip + call
mesh.build() + save. Bumps OPENVAT_BUILD to 27.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 27's log showed:
  get_lod_build_settings() takes at most 2 arguments (3 given)

UE 5.7's Python binding flattens the C++ out-parameter signature:
  C++:    void GetLodBuildSettings(mesh, lod, OutSettings&)
  Python: settings = sme.get_lod_build_settings(mesh, lod)

Drop the 3rd arg and capture the return. Bumps OPENVAT_BUILD to 28.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The log confirmed bUseFullPrecisionUVs was already on, so half-float
UV aliasing isn't the bug. Remaining suspects for the head-only
glitch:

1. UE's StaticMesh build re-runs Mikkt tangent-space generation
   based on the imported UV0 + position. Mixamo's head has hundreds
   of UV-seam-split verts at the same position with different UV0/
   normal — Mikkt's edge cases on those can produce inconsistent
   tangents across adjacent triangles, which the rasterizer can use
   for back-face decisions on screen-space-degenerate triangles.
   Disable bRecomputeNormals + bRecomputeTangents so Mixamo's
   already-correct attributes pass straight through.

2. bounds_scale=3 might not cover hand-swing extremes. Bump to 10.

Bumps OPENVAT_BUILD to 29.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d glitch

Build 29 fixed most of the head glitch by disabling
bRecomputeNormals/bRecomputeTangents. User reports 1 eye + 1 ear
still flicker on fewer frames — an asymmetric residual.

UE stores tangents as 8-bit per axis by default. Mixamo's head has
hundreds of UV-seam-split verts whose imported tangents are nearly
but not exactly identical across adjacent triangles. After 8-bit
quantization, the differences land on different quantized values
some triangles, and the rasterizer's edge equations occasionally
disagree on which side wins — producing the asymmetric per-frame
glitch.

bUseHighPrecisionTangentBasis stores tangents at 16-bit per axis,
removing that quantization step. Costs ~2x tangent buffer (still
tiny vs the position+UV+color buffers). Bumps OPENVAT_BUILD to 30.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rld-space)

User: "blinking depending on angle, could it be normals?" — yes. The
bake stores per-frame normals in rows frame_count..2*frame_count-1
of the position texture, but until now the material left the mesh's
NORMAL output at the bind-pose vertex normal. WPO moves the vertex
positions correctly, but UE's shading + depth-pre-pass tests use
the static bind-pose normal — so small submeshes (eyes, ears, teeth)
whose bind normals briefly straddle the camera-facing threshold
during head rotation get discarded by view-dependent fragment tests.

Add a second Custom node that decodes (n+1)/2 → n from the bake's
normal half, applies the same Y-up→Z-up swizzle as positions, and
returns a unit world-space normal. Set the material's
tangent_space_normal=False so UE treats the output as world-space
(otherwise it would apply the inverse tangent basis and turn our
world normal into garbage).

Bumps OPENVAT_BUILD to 31.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…akes effect

The revert restored the script to build-30 semantics, but the stale
M_OpenVAT asset on disk is stamped with 31 — init_unreal's
"stamped < expected" check would skip rebuilding. Bumping to 32
forces the rebuild back to the build-30 state we know is the best
result so far.

Save-point tag unreal-demo-saving-point-build-30 still points at
the materially identical build-30 commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI fix:
- Add src/MinimalEXRWriter.cpp to tests/CMakeLists.txt so the three
  MaterialEditorQML test binaries link against VATBaker::bake's
  MinimalEXR::writeRGB32F reference. Was only in src/CMakeLists.txt.

Code-review applied (still-valid items only; earlier comments about
UV2 baking failure / non-idempotent material build / success-banner
guards / spawn-failure aborts are already implemented in current code):

- #7  Missing T_OpenVAT_Pos/T_Boss_Diffuse now hard-stops the
       bootstrap. Previously only the mesh check aborted, so a failed
       texture import could still reach the success banner with an
       unbound `pos_tex` and produce a dancer frozen in bind pose.
- #8  verify_imported_uv_channels now returns bool, and main()
       aborts when it returns False (a SkeletalMesh import despite
       our static-mesh override). Stops the script from continuing
       with a known-bad import.
- #6  spawn_dancer_in_level: when get_all_level_actors() raises,
       return None instead of falling back to [] — the empty list
       skipped the cleanup pass and allowed reruns to stack
       duplicate OpenVAT_Dancer actors. main() already handles None
       via the partial-failure path.
- #3  Replaced the silent `except Exception: pass` on actor
       destruction with a log_warning so cleanup failures are
       diagnosable instead of swallowed.
- #9  init_unreal.py: replaced the hard-coded mesh path tuple with
       a recursive AssetRegistry sweep under /Game/Rumba/ matching
       any StaticMesh or SkeletalMesh. Keeps the "skip if mesh
       present" decision in sync with build_vat_demo's
       find_imported_mesh, so a non-canonically-named import
       doesn't trigger a rebuild every editor open.

Bumps OPENVAT_BUILD to 33.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…angent)

Followed the canonical pattern from sharpen3d/openvat's Unreal
reference material (OpenVAT_Basic.uasset). The earlier world-space
attempt (build 31) failed because setting
tangent_space_normal=False breaks subtle parts of the lighting
evaluation chain.

New chain:
  Custom HLSL (returns object-space normal in Unreal local cm)
    └─► MaterialExpressionTransform (Source=Local, Dest=Tangent)
          └─► MP_NORMAL
  tangent_space_normal stays True (the material default)

The Custom node samples the bake's lower-half per-frame normal,
decodes (n+1)/2 → n in [-1..1], and applies the same swizzle the
WPO uses to land in Unreal's local space (Interchange swizzles
positions and normals together on import, so the same matrix
serves both).

The Transform node rotates the OS delta normal into the per-pixel
tangent basis UE's lighting expects with tangent_space_normal=True.
This is the exact chain documented in OpenVAT's Unreal walkthrough
(https://youtube.com/watch?v=T1KVvUIduGI), now mirrored in code so
small head submeshes (eyes, ears) don't blink on rotated frames
because their bind-pose normals diverge from the WPO-displaced
surface.

Bumps OPENVAT_BUILD to 34.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…akes effect

The build-34 normal chain (Custom HLSL OS-normal → Transform
Local→Tangent → MP_NORMAL) failed Material compile on SF_METAL_SM6:

  Failed to compile Material for platform SF_METAL_SM6,
  Default Material will be used in game.

UE substituted its default checkered material — no texture, no WPO,
no animation. Most likely the Transform node's compile path requires
a tangent basis flow that doesn't connect cleanly when its input
comes from a vertex-shader Custom node; needs more investigation
before re-attempting.

The c0a247c revert restored the build-30-equivalent code to OPENVAT_BUILD
= 33, but the stale M_OpenVAT on disk is stamped 34, so init_unreal's
"stamped < expected" check would skip rebuilding. Bumping to 35 forces
the rebuild to the build-30 known-good state ("1 eye + 1 ear blink on
specific angles, otherwise clean").

Save-point tag unreal-demo-saving-point-build-30 still points at the
materially identical state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant