Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
ab24474
feat(vat): Unreal demo project — Python-driven asset bootstrap
fernandotonon May 20, 2026
ae4b264
fix(unreal-demo): drop fake GLTFImporter plugin, use Interchange
fernandotonon May 20, 2026
9081e77
review(unreal-demo): bake_uv2 returns False, build_material idempotent
fernandotonon May 20, 2026
0fbb829
feat(unreal-demo): drop UV2-write step, rely on --emit-uv2 in source.…
fernandotonon May 21, 2026
686a35c
chore(unreal-demo): drop accidentally-committed Config/
fernandotonon May 21, 2026
3a9e6a4
feat(unreal-demo): auto-spawn dancer + self-driving Time-based material
fernandotonon May 21, 2026
c83c468
fix(unreal-demo): diagnostic logging + spawn 200 cm in front of camera
fernandotonon May 21, 2026
2460cf7
feat(unreal-demo): init_unreal.py auto-runs bootstrap on project open
fernandotonon May 21, 2026
4871442
fix(unreal-demo): locate skeletal mesh wherever Interchange put it
fernandotonon May 21, 2026
0e7aa27
fix(unreal-demo): WPO delta + glTF→Unreal swizzle so the dancer animates
fernandotonon May 21, 2026
7c14b3c
fix(unreal-demo): version-tag M_OpenVAT so stale builds auto-rebuild
fernandotonon May 21, 2026
9f2a7af
fix(unreal-demo): import glTF as StaticMesh to preserve TEXCOORD_1
fernandotonon May 21, 2026
8d852e8
fix(unreal-demo): set force_all_mesh_as_type on the right sub-object
fernandotonon May 21, 2026
ddc62c7
fix(unreal-demo): LocalPosition.IncludedOffsets=ExcludeOffsets to bre…
fernandotonon May 21, 2026
4f34f90
fix(unreal-demo): apply M_OpenVAT to every material slot
fernandotonon May 21, 2026
c848237
fix(unreal-demo): force Opaque blend so depth-write/test produces cor…
fernandotonon May 21, 2026
79328a4
fix(unreal-demo): keep sections separate so TEXCOORD_1 column index s…
fernandotonon May 21, 2026
066fb76
fix(unreal-demo): try snake-name variants for keep_sections_separate …
fernandotonon May 22, 2026
d997d19
fix(unreal-demo): compute WPO as delta from bake frame 0 (drop bind_l…
fernandotonon May 22, 2026
12898aa
fix(unreal-demo): match Interchange's (x, z, y) swizzle exactly (no Y…
fernandotonon May 22, 2026
84e69f9
feat(unreal-demo): expose swizzle matrix as VectorParameters for in-e…
fernandotonon May 22, 2026
616e80b
fix(unreal-demo): subtract real bind from absolute target (not frame-…
fernandotonon May 22, 2026
b9173d5
fix(unreal-demo): inflate bounds_scale=3 so WPO-displaced submeshes d…
fernandotonon May 22, 2026
076bc4c
fix(unreal-demo): WPO = absolute_target - LocalPosition (no bind roun…
fernandotonon May 22, 2026
c52c27e
fix(unreal-demo): PixelDepthOffset=-0.5cm to mask 16-bit quantization…
fernandotonon May 22, 2026
e5843f0
feat(vat): 32-bit float EXR bake to eliminate position quantization j…
fernandotonon May 22, 2026
426915e
fix(unreal-demo): force bUseFullPrecisionUVs=True to preserve TEXCOOR…
fernandotonon May 22, 2026
a3707b1
fix(unreal-demo): re-enable PixelDepthOffset for 32-bit bakes (raster…
fernandotonon May 22, 2026
9ec855e
revert(unreal-demo): remove PixelDepthOffset — it makes more frames g…
fernandotonon May 22, 2026
825c89b
fix(unreal-demo): per-slot PDO — M_OpenVAT_Eye applied only to Eyes_M…
fernandotonon May 22, 2026
926fa33
revert(unreal-demo): undo per-slot eye PDO — made ear/teeth glitch too
fernandotonon May 22, 2026
ad8280f
fix(unreal-demo): TC_HDR_F32 (RGBA32F) for EXR position texture
fernandotonon May 22, 2026
f6c85eb
fix(unreal-demo): two_sided=True — bind-pose normals back-face-cull r…
fernandotonon May 22, 2026
af43ae5
fix(unreal-demo): force bUseFullPrecisionUVs on StaticMesh BuildSetti…
fernandotonon May 22, 2026
0824771
fix(unreal-demo): use StaticMeshEditorSubsystem to flip bUseFullPreci…
fernandotonon May 22, 2026
848f2fb
fix(unreal-demo): correct get_lod_build_settings call signature
fernandotonon May 22, 2026
ea2a9e0
fix(unreal-demo): disable normal/tangent recompute + bounds_scale=10
fernandotonon May 23, 2026
03a3c54
fix(unreal-demo): bUseHighPrecisionTangentBasis=True for residual hea…
fernandotonon May 23, 2026
47f74c0
fix(unreal-demo): drive Normal output from the bake's normal half (wo…
fernandotonon May 23, 2026
d040d6c
Revert "fix(unreal-demo): drive Normal output from the bake's normal …
fernandotonon May 23, 2026
38643b5
chore(unreal-demo): bump OPENVAT_BUILD to 32 so the build-31 revert t…
fernandotonon May 23, 2026
db630ed
fix(unreal-demo): linux test link + address code-review feedback
fernandotonon May 23, 2026
c0a247c
feat(unreal-demo): OpenVAT-style normal chain (OS → Transform Local→T…
fernandotonon May 23, 2026
c1968be
Revert "feat(unreal-demo): OpenVAT-style normal chain (OS → Transform…
fernandotonon May 23, 2026
ddf6139
chore(unreal-demo): bump OPENVAT_BUILD to 35 so the build-34 revert t…
fernandotonon May 23, 2026
0af93d1
docs(unreal-demo): add language tag to tree-listing fence + update fo…
fernandotonon May 23, 2026
9eb70fe
fix(exr-writer): bounded scan instead of unbounded strlen (Sonar S5813)
fernandotonon May 23, 2026
4764458
feat(unreal-demo): swap to Mixamo Hip Hop Dancing (1 submesh, simpler…
fernandotonon May 23, 2026
74db228
fix(unreal-demo): use post_edit_change() to trigger StaticMesh rebuild
fernandotonon May 23, 2026
105ffa1
fix(cli): emitGltfUv2 wrote bake col = gltfIdx instead of Ogre col
fernandotonon May 23, 2026
4e4ddbb
fix(unreal-demo): drop dead post_edit_change call — SetLodBuildSettin…
fernandotonon May 23, 2026
f39c249
diag(unreal-demo): log imported mesh's render-vertex count
fernandotonon May 23, 2026
1754773
revert(unreal-demo): swap back to Rumba — Hip Hop showed same artifacts
fernandotonon May 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions src/CLIPipeline.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,7 @@
" triangle, rasterizes barycentric-interpolated vertex colors,\n"
" then dilates outward by N pixels to mask seam bleed at MIP time.\n"
" Default resolution=1024, dilation=4. Output PNG is RGBA.\n"
" vat <file> --anim <name> [--fps N] [-o <dir>] [--include-shaders {godot,unity,unreal,all}] [--emit-uv2 [N]] [--json]\n"
" vat <file> --anim <name> [--fps N] [-o <dir>] [--include-shaders {godot,unity,unreal,all}] [--emit-uv2 [N]] [--bake-precision {16,32}] [--json]\n"
" Bake a skeletal animation into a Vertex Animation Texture\n"
" in OpenVAT (sharpen3d/openvat) format: a single 16-bit RGB\n"
" PNG (height = 2 × frames; top half positions, bottom half\n"
Expand Down Expand Up @@ -5821,9 +5821,22 @@
float vMin = std::numeric_limits<float>::infinity();
float vMax = -std::numeric_limits<float>::infinity();
for (size_t i = a; i < b; ++i) {
// i = Ogre vertex index = bake column index (the bake walks
// Ogre's vertex buffer in order, so column == Ogre index).
// gltfIdx = glTF buffer position of the SAME vertex after
// Assimp's JoinIdenticalVertices reorder.
const uint32_t gltfIdx = permutation[i];
const uint32_t col = gltfIdx % static_cast<uint32_t>(texWidth);
const uint32_t row = gltfIdx / static_cast<uint32_t>(texWidth);
const uint32_t ogreIdx = static_cast<uint32_t>(i);

Check warning on line 5829 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace the redundant type with "auto".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ5WcCxINZwKksNFRG8X&open=AZ5WcCxINZwKksNFRG8X&pullRequest=652
// The bake column we want this glTF vertex to read is the
// ORIGINAL Ogre column `i`, not `gltfIdx`. Earlier code
// mistakenly used `gltfIdx`, which on meshes with non-
// identity Assimp permutations (e.g. Mixamo Hip Hop Dancing)
// meant every vertex read animation data from a
// *different* vertex's column — body parts visibly flew
// apart on specific frames where the wrong-source motion
// was large.
const uint32_t col = ogreIdx % static_cast<uint32_t>(texWidth);
const uint32_t row = ogreIdx / static_cast<uint32_t>(texWidth);
// The destination is glTF index gltfIdx, which is offset
// (gltfIdx - a) within this primitive.
const size_t localDst = static_cast<size_t>(gltfIdx) - a;
Expand Down Expand Up @@ -5916,7 +5929,7 @@

int CLIPipeline::cmdVat(int argc, char* argv[])
{
// Parse: vat <file> --anim <name> [--fps N] [-o <dir>] [--include-shaders {godot,unity,unreal,all}] [--emit-uv2 [N]] [--json]
// Parse: vat <file> --anim <name> [--fps N] [-o <dir>] [--include-shaders {godot,unity,unreal,all}] [--emit-uv2 [N]] [--bake-precision {16,32}] [--json]
//
// Output is always OpenVAT (sharpen3d/openvat) — a single packed
// 16-bit RGB PNG (`<basename>_pos.png`, height = 2*frames, top half
Expand All @@ -5934,6 +5947,12 @@
// bind-sidecar matching and just read TEXCOORD_<channel> as
// (column % tex_width, column / tex_width) directly.
int emitUv2Channel = -1;
// --bake-precision {16,32}: per-channel bit depth for the position
// texture. Default 16 (PNG, ~0.03mm precision over 2m bounds).
// 32 writes an EXR with raw float32 positions — sidesteps the
// sub-mm quantization artifact that flickers Mixamo's coplanar
// eye-sphere/head-plug geometry. ~2× larger file.
int bakeBitDepth = 16;

for (int i = 1; i < argc; ++i) {
QString arg(argv[i]);
Expand All @@ -5954,6 +5973,17 @@
if ((arg == "-o" || arg == "--output") && i + 1 < argc) {
outDir = QString(argv[++i]); continue;
}
if (arg == "--bake-precision" && i + 1 < argc) {
bool ok = false;
const int v = QString(argv[++i]).toInt(&ok);
if (!ok || (v != 16 && v != 32)) {
err() << "Error: --bake-precision must be 16 or 32 "
"(got \"" << argv[i] << "\")." << Qt::endl;
return 2;
}
bakeBitDepth = v;
continue;
}
// --include-shaders <list>: comma-separated subset of
// {godot, unity, unreal} (or "all") — copies the matching
// drop-in shader templates into the bake's output directory
Expand Down Expand Up @@ -6023,7 +6053,7 @@

if (filePath.isEmpty()) {
err() << "Error: No input file specified." << Qt::endl;
err() << "Usage: qtmesh vat <file> --anim <name> [--fps N] [-o <dir>] [--include-shaders {godot,unity,unreal,all}] [--emit-uv2 [N]] [--json]" << Qt::endl;
err() << "Usage: qtmesh vat <file> --anim <name> [--fps N] [-o <dir>] [--include-shaders {godot,unity,unreal,all}] [--emit-uv2 [N]] [--bake-precision {16,32}] [--json]" << Qt::endl;
return 2;
}
if (animName.isEmpty()) {
Expand Down Expand Up @@ -6225,6 +6255,7 @@
opts.fps = fps;
opts.outputDir = outDir;
opts.basename = animName;
opts.bitDepth = bakeBitDepth;
// Keep a copy for the UV2 post-pass (the move below sinks the
// original into VATBaker::Options).
std::vector<uint32_t> vertexPermCopy = vertexPerm;
Expand Down
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ VertexColorBaker.cpp
VATBaker.cpp
VATBakerController.cpp
VATShaderEmitter.cpp
MinimalEXRWriter.cpp
MorphAnimationManager.cpp
NodeAnimationManager.cpp
PoseLibrary.cpp
Expand Down
214 changes: 214 additions & 0 deletions src/MinimalEXRWriter.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
#include "MinimalEXRWriter.h"

#include <QFile>
#include <QIODevice>
#include <cstring>

namespace MinimalEXR {

namespace {

// EXR LE writers — the format is always little-endian regardless of host.
void writeU32LE(QByteArray& buf, uint32_t v) {
char b[4];

Check warning on line 13 in src/MinimalEXRWriter.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "std::string" instead of a C-style char array.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ5TqYJzXSDqH0Ac65jJ&open=AZ5TqYJzXSDqH0Ac65jJ&pullRequest=652
b[0] = static_cast<char>(v & 0xff);
b[1] = static_cast<char>((v >> 8) & 0xff);
b[2] = static_cast<char>((v >> 16) & 0xff);
b[3] = static_cast<char>((v >> 24) & 0xff);
buf.append(b, 4);
}
void writeI32LE(QByteArray& buf, int32_t v) { writeU32LE(buf, static_cast<uint32_t>(v)); }
void writeU64LE(QByteArray& buf, uint64_t v) {
writeU32LE(buf, static_cast<uint32_t>(v & 0xffffffffu));
writeU32LE(buf, static_cast<uint32_t>(v >> 32));
}
void writeF32LE(QByteArray& buf, float v) {
uint32_t bits;
std::memcpy(&bits, &v, sizeof(bits));
writeU32LE(buf, bits);
}

// Append a NUL-terminated string. EXR uses C-strings for attr names + types.
//
// Bounded against a 256-byte cap so a malformed (un-terminated) caller
// can't walk off the end of the source buffer. All current call sites
// pass string literals at most ~16 chars long, so the cap is a defense-
// in-depth measure; if it ever fires the EXR output is structurally
// malformed and we let the file write succeed but report nothing —
// downstream `file` will fail to parse and the caller sees the error.
void writeCStr(QByteArray& buf, const char* s) {
if (s == nullptr) return;
// Bounded scan — portable equivalent of `strnlen` so the function
// is safe even if a future caller forgets to NUL-terminate.
constexpr size_t kMaxAttrLen = 256;
size_t n = 0;
while (n < kMaxAttrLen && s[n] != '\0') ++n;
buf.append(s, static_cast<int>(n));
buf.append('\0');
}

// Attribute header: name\0 type\0 size_u32 payload[size].
// `payload` is appended verbatim — caller has already serialized it LE.
void writeAttr(QByteArray& buf,
const char* name,
const char* type,
const QByteArray& payload) {
writeCStr(buf, name);
writeCStr(buf, type);
writeI32LE(buf, static_cast<int32_t>(payload.size()));
buf.append(payload);
}

// Channel-list attribute: a sequence of channel records terminated by a NUL
// byte. Each record:
// name\0 pixelType(u32) pLinear(u8) reserved[3] xSampling(u32) ySampling(u32)
// pixelType: 0=UINT 1=HALF 2=FLOAT.
//
// Order matters: EXR reads back the channels in the order they appear, and
// the convention for RGB is alphabetical (B, G, R). We write B then G then R
// to match what every EXR-reading tool expects to find.
QByteArray buildChannelListBGR_F32() {
QByteArray out;
const char* names[3] = { "B", "G", "R" };

Check warning on line 72 in src/MinimalEXRWriter.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "std::array" or "std::vector" instead of a C-style array.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ5TqYJzXSDqH0Ac65jL&open=AZ5TqYJzXSDqH0Ac65jL&pullRequest=652
for (const char* n : names) {
writeCStr(out, n);
writeU32LE(out, 2u); // FLOAT
out.append('\0'); // pLinear = 0
out.append('\0'); // reserved
out.append('\0');
out.append('\0');
writeU32LE(out, 1u); // xSampling
writeU32LE(out, 1u); // ySampling
}
out.append('\0'); // terminator
return out;
}

QByteArray buildBox2i(int xMin, int yMin, int xMax, int yMax) {
QByteArray out;
writeI32LE(out, xMin);
writeI32LE(out, yMin);
writeI32LE(out, xMax);
writeI32LE(out, yMax);
return out;
}

QByteArray buildV2f(float a, float b) {
QByteArray out;
writeF32LE(out, a);
writeF32LE(out, b);
return out;
}

QByteArray buildF32(float v) {
QByteArray out;
writeF32LE(out, v);
return out;
}

QByteArray buildU32(uint32_t v) {
QByteArray out;
writeU32LE(out, v);
return out;
}

QByteArray buildU8(uint8_t v) {
QByteArray out(1, static_cast<char>(v));
return out;
}

} // namespace

bool writeRGB32F(const QString& path,
int width,
int height,
const std::vector<float>& rgbData)
{
if (width <= 0 || height <= 0) return false;
const size_t expected = static_cast<size_t>(width)
* static_cast<size_t>(height) * 3u;
if (rgbData.size() != expected) return false;

Check warning on line 130 in src/MinimalEXRWriter.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "expected" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ5TqYJzXSDqH0Ac65jM&open=AZ5TqYJzXSDqH0Ac65jM&pullRequest=652

// ── Header ────────────────────────────────────────────────────────
QByteArray header;
// Magic + version.
writeU32LE(header, 0x01312f76u); // EXR magic
writeU32LE(header, 2u); // version 2, no special flags

// Attributes — name\0 type\0 size payload, terminated by a single NUL.
writeAttr(header, "channels", "chlist",
buildChannelListBGR_F32());
writeAttr(header, "compression", "compression",
buildU8(0)); // NO_COMPRESSION
writeAttr(header, "dataWindow", "box2i",
buildBox2i(0, 0, width - 1, height - 1));
writeAttr(header, "displayWindow", "box2i",
buildBox2i(0, 0, width - 1, height - 1));
writeAttr(header, "lineOrder", "lineOrder",
buildU8(0)); // INCREASING_Y
writeAttr(header, "pixelAspectRatio", "float",
buildF32(1.0f));
writeAttr(header, "screenWindowCenter", "v2f",
buildV2f(0.0f, 0.0f));
writeAttr(header, "screenWindowWidth", "float",
buildF32(1.0f));

// Attribute-list terminator.
header.append('\0');

// ── Scanline offset table ────────────────────────────────────────
// One uint64 per scanline. The offset is the absolute file position
// of that scanline's payload block (which starts with the y-coord
// u32 then the data-size u32 then the pixel bytes).
//
// Each scanline payload = 4 (y) + 4 (size) + (width × 3 channels ×
// 4 bytes/float) = 8 + width × 12.
const uint64_t scanlinePayloadSize = 8u
+ static_cast<uint64_t>(width) * 3u * 4u;
const uint64_t offsetTableSize = static_cast<uint64_t>(height) * 8u;
const uint64_t firstScanlineOffset =
static_cast<uint64_t>(header.size()) + offsetTableSize;

QByteArray offsetTable;
offsetTable.reserve(static_cast<int>(offsetTableSize));
for (int y = 0; y < height; ++y) {
const uint64_t off = firstScanlineOffset
+ static_cast<uint64_t>(y) * scanlinePayloadSize;
writeU64LE(offsetTable, off);
}

// ── Scanlines ────────────────────────────────────────────────────
// Channels are written out in the order declared in the channel list
// — i.e. B, G, R per scanline. Inside each channel block, the entire
// row of pixels for THAT channel is written contiguously.
QByteArray pixels;
pixels.reserve(static_cast<int>(
static_cast<uint64_t>(height) * scanlinePayloadSize));
const uint32_t pixelDataSize =
static_cast<uint32_t>(width) * 3u * 4u;

for (int y = 0; y < height; ++y) {
writeI32LE(pixels, y);
writeU32LE(pixels, pixelDataSize);
// Row pointer into the caller's interleaved RGB buffer.
const float* row = rgbData.data()
+ static_cast<size_t>(y) * static_cast<size_t>(width) * 3u;
// B channel.
for (int x = 0; x < width; ++x) writeF32LE(pixels, row[x * 3 + 2]);
// G channel.
for (int x = 0; x < width; ++x) writeF32LE(pixels, row[x * 3 + 1]);
// R channel.
for (int x = 0; x < width; ++x) writeF32LE(pixels, row[x * 3 + 0]);
}

// ── Write to disk in one shot ────────────────────────────────────
QFile out(path);
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) return false;
if (out.write(header) != header.size()) return false;
if (out.write(offsetTable) != offsetTable.size()) return false;
if (out.write(pixels) != pixels.size()) return false;
out.close();
return out.error() == QFile::NoError;
}

} // namespace MinimalEXR
52 changes: 52 additions & 0 deletions src/MinimalEXRWriter.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#pragma once

#include <QString>
#include <cstdint>
#include <vector>

#include <cstddef>

/// Minimal OpenEXR 2.0 writer — 3-channel (R,G,B) float32 scanline images,
/// uncompressed. Just enough to round-trip a VAT bake's position+normal
/// texture into a format Unreal Engine's TextureFactory accepts as a
/// non-color HDR source (TC_HDR / TC_VectorDisplacementmap).
///
/// Why we ship our own writer instead of pulling OpenEXR:
/// * Qt has no native EXR support in 6.9.x.
/// * Adding the OpenEXR/Imath dependency for a single one-off code path
/// would balloon the bake module's link surface and CI matrix.
/// * The spec is small enough that a no-compression scanline writer is
/// ~150 LOC and trivially testable.
///
/// Format: spec-compliant subset of OpenEXR 2.0 with these fixed choices:
/// * Magic 0x01312f76, version 2, no tile bit, no long-names bit.
/// * Attributes: channels (R, G, B float32), compression NO_COMPRESSION,
/// dataWindow = displayWindow = (0,0,W-1,H-1), lineOrder INCREASING_Y,
/// pixelAspectRatio 1.0, screenWindowCenter (0,0), screenWindowWidth 1.
/// * Scanline payload: R-row then G-row then B-row per scanline, no
/// compression, no padding.
///
/// Not implemented (intentional):
/// * Tiled / multi-part files.
/// * Compression (zip / piz / pxr24).
/// * Half-precision channels (the whole point is full float32 precision).
/// * Alpha / Z / sampled-rate / arbitrary attributes.
namespace MinimalEXR {

/// Write a 3-channel float32 scanline EXR to `path`.
///
/// @param path Output filesystem path. Overwritten if it exists.
/// @param width Image width in pixels (must be > 0).
/// @param height Image height in pixels (must be > 0).
/// @param rgbData Row-major (height × width × 3) float buffer. The pixel at
/// (x, y) is at index (y * width + x) * 3 + {0,1,2} for R/G/B.
/// Caller owns; the writer only reads.
///
/// @return true on success. false if the buffer size mismatches the
/// declared dimensions or the file write fails.
bool writeRGB32F(const QString& path,
int width,
int height,
const std::vector<float>& rgbData);

} // namespace MinimalEXR
Loading
Loading