feat(vat): Unreal demo project — Python-driven asset bootstrap#652
feat(vat): Unreal demo project — Python-driven asset bootstrap#652fernandotonon wants to merge 45 commits into
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds 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. ChangesUnreal OpenVAT Demo
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
💡 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".
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
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`.
27f9816 to
0fbb829
Compare
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.
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (5)
tools/unreal-vat-demo/Content/Rumba/Boss_diffuse.pngis excluded by!**/*.pngtools/unreal-vat-demo/Content/Rumba/mixamo.com_ogre_bind.binis excluded by!**/*.bintools/unreal-vat-demo/Content/Rumba/mixamo.com_pos.pngis excluded by!**/*.pngtools/unreal-vat-demo/Content/Rumba/source.binis excluded by!**/*.bintools/unreal-vat-demo/Content/Rumba/source.gltfis excluded by!**/*.gltf
📒 Files selected for processing (7)
tools/unreal-vat-demo/.gitignoretools/unreal-vat-demo/Content/Python/build_vat_demo.pytools/unreal-vat-demo/Content/Rumba/mixamo.com-remap_info.jsontools/unreal-vat-demo/Content/Rumba/openvat.usftools/unreal-vat-demo/QtMeshVAT.uprojecttools/unreal-vat-demo/README.mdwebsite/src/DocsApp.jsx
| ``` | ||
| 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 | ||
| ``` |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
📒 Files selected for processing (1)
tools/unreal-vat-demo/Content/Python/build_vat_demo.py
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>
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (3)
tools/unreal-vat-demo/Content/Python/build_vat_demo.py (3)
739-800:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFail fast on partial actor configuration.
Both branches can log component/mesh/material assignment failures and still return a non-
Noneactor.main()then treats that as success even though the spawned actor is empty or not usingM_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 winAbort when either imported texture is missing.
This gate still stops only on a missing mesh. If
T_OpenVAT_PosorT_Boss_Diffusefailed 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 winAbort the spawn path if actor enumeration fails.
Falling back to
[]here skips cleanup and still spawns anotherOpenVAT_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
📒 Files selected for processing (3)
tools/unreal-vat-demo/Content/Python/build_vat_demo.pytools/unreal-vat-demo/Content/Python/init_unreal.pytools/unreal-vat-demo/README.md
✅ Files skipped from review due to trivial changes (1)
- tools/unreal-vat-demo/README.md
…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>
…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>
|
…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>
…half (world-space)" This reverts commit 47f74c0.
…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>
… Local→Tangent)" This reverts commit c0a247c.
…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>



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