mirror of
https://github.com/Toastconcern/FrameStudio.git
synced 2026-10-29 04:02:56 +01:00
Frame Studio now builds for Windows (x64, x86, ARM64), Linux (x64, x86, ARM64)
and macOS (Apple Silicon, Intel):
- studio/platform.h with Win32 and POSIX implementations (dialogs through
tinyfiledialogs on Linux/macOS), a GLFW window beside the Win32 one, and a
platform-neutral frame pacer
- MoltenVK support in the renderer (portability enumeration) and bundled in
the macOS app
- 32-bit builds: large-address-aware, LZMA packing within a 32-bit address space
- unaligned file reads in the loaders go through loadUnaligned (no UB)
- scripts/build-studio.sh, studio/build_windows.bat [x64|x86|arm64],
cmake/llvm-mingw.cmake, and a GitHub Actions workflow building every target
Frame Home installs without Studio: frame/install-frame-home.{sh,ps1} and
"Install Frame Home.bat" (packaged as FrameHome-installer.zip).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3405 lines
249 KiB
C++
3405 lines
249 KiB
C++
#pragma once
|
||
// V79 ".opa" loader — the OLD OFFICIAL Meta-home cooked-asset format (e.g. spacestation:
|
||
// com.oculus.environment.prod.spacestation__base.apk). Reverse-engineered FAITHFULLY from
|
||
// V79 libshell.so (Meta MHE's own core/reflection serializer — "VertexTypeSystem"/
|
||
// "dataformat"), NOT guessed. Produces the renderer's MeshData[] (positions/uvs/indices,
|
||
// per-node world transform baked into positions), the source-format side of the V79 -> new
|
||
// HSR env porter — same role as gltf_loader.h but for cooked .opa homes.
|
||
//
|
||
// Container nesting: APK -> assets/scene.zip -> cache/android/<name>.fbx.opa (geometry).
|
||
//
|
||
// .opa MODEL SCHEMA (decompiled: sub_AF0678 MeshData reader + sub_AECEF0/DC74A8/AF0384/...):
|
||
// [OPAA hdr 48B] then payload:
|
||
// u32 version (0x405) string typeName ("MeshData")
|
||
// NODES: u32 count; each = [8B lead][string name][i32 parent][T f32x3][R quat f32x4][S f32x3]
|
||
// MATERIALS u32 count; each = reflection obj: {[u8 tag !=0xC8][string field][value]}* [0xC8 end]
|
||
// fields: "Id"=u64, "Path"=string(.mat.asset)
|
||
// MESHES: u32 count; each entry =
|
||
// string posFmt("RigidPos") string dataFmt("StdData")
|
||
// u32 listCount(usually 0)
|
||
// u32 submeshCount; each submesh = [u32 ?][u32 firstIndex][u32 indexCount]
|
||
// [u32 matIndex][AABB min f32x3][AABB max f32x3][u8 bool]
|
||
// (version>=0x407 inserts 2 extra u32 @ +16/+20)
|
||
// AABB min f32x3 / max f32x3 (whole-mesh)
|
||
// u32 vertCount
|
||
// u32 posBytes; posBytes of RigidPos (pos f32x3, stride 12)
|
||
// u32 stdBytes; stdBytes of StdData (stride 20, uv f16x4 @ off 8)
|
||
// u32 idxCount
|
||
// string idxType("kUnsignedShort")
|
||
// u32 idxBytes; idxBytes of u16 indices
|
||
// u16 tail (version>=0x404)
|
||
// String codec (sub_AEF1EC): u16 marker; ==0xFFFF -> [u16 len][bytes] (len==0xFFFF ->
|
||
// [u32 len][bytes]); else marker is an interned-table index (not used by these .opa).
|
||
// Submesh draws indices [firstIndex, firstIndex+indexCount) with material[matIndex];
|
||
// mesh entry[i] uses node[i]'s world transform.
|
||
|
||
#include "core/types.h"
|
||
#include "core/parallel.h" // par::forEach (skinning across cores)
|
||
#include "core/load_progress.h" // live stage/counter for the loading splash
|
||
#include "miniz.h"
|
||
#include "loaders/zip_reader_guard.h"
|
||
#include "loaders/scene_zip.h"
|
||
#include "loaders/rendtxtr_parser.h" // astc::decodeASTC (KTX/ASTC -> RGBA)
|
||
#include "render/ibl.h" // SpecIbl diffuse irradiance cubemap (RGBA16F KTX)
|
||
#include "cook/node_rot_fit.h" // shared spin/sway fitter (V79->V203 cook) — same core the glTF loader uses
|
||
#include <vector>
|
||
#include <string>
|
||
#include <cstring>
|
||
#include <cstdio>
|
||
#include <cstdarg>
|
||
#include <cmath>
|
||
#include <algorithm>
|
||
#include <map>
|
||
#include <set>
|
||
#include <cstdint>
|
||
#include <cstdlib>
|
||
#include <unordered_map>
|
||
|
||
class OpaLoader {
|
||
public:
|
||
std::vector<MeshData> meshes;
|
||
bool verbose = true;
|
||
|
||
void log(const char* fmt, ...) {
|
||
if (!verbose) return;
|
||
va_list a; va_start(a, fmt); fprintf(stderr, "[OPA] "); vfprintf(stderr, fmt, a);
|
||
fprintf(stderr, "\n"); va_end(a);
|
||
}
|
||
|
||
// IEEE half -> float
|
||
static float h2f(uint16_t h) {
|
||
uint32_t s = (h & 0x8000u) << 16;
|
||
uint32_t e = (h >> 10) & 0x1F;
|
||
uint32_t m = h & 0x3FF;
|
||
uint32_t bits;
|
||
if (e == 0) {
|
||
if (m == 0) { bits = s; }
|
||
else { // subnormal
|
||
e = 127 - 15 + 1;
|
||
while (!(m & 0x400)) { m <<= 1; --e; }
|
||
m &= 0x3FF;
|
||
bits = s | (e << 23) | (m << 13);
|
||
}
|
||
} else if (e == 0x1F) {
|
||
bits = s | 0x7F800000u | (m << 13);
|
||
} else {
|
||
bits = s | ((e - 15 + 127) << 23) | (m << 13);
|
||
}
|
||
float f; memcpy(&f, &bits, 4); return f;
|
||
}
|
||
|
||
// ── sequential cursor over the payload (libshell sub_AEF07C semantics) ──
|
||
struct Cur {
|
||
const uint8_t* d = nullptr; size_t n = 0; size_t p = 0; bool ok = true;
|
||
bool avail(size_t k) const { return p + k <= n; }
|
||
uint8_t u8() { if (!avail(1)) { ok = false; return 0; } return d[p++]; }
|
||
uint16_t u16() { if (!avail(2)) { ok = false; return 0; } uint16_t v; memcpy(&v, d+p, 2); p += 2; return v; }
|
||
uint32_t u32() { if (!avail(4)) { ok = false; return 0; } uint32_t v; memcpy(&v, d+p, 4); p += 4; return v; }
|
||
int32_t i32() { return (int32_t)u32(); }
|
||
uint64_t u64() { if (!avail(8)) { ok = false; return 0; } uint64_t v; memcpy(&v, d+p, 8); p += 8; return v; }
|
||
float f32() { if (!avail(4)) { ok = false; return 0; } float v; memcpy(&v, d+p, 4); p += 4; return v; }
|
||
void skip(size_t k) { if (!avail(k)) { ok = false; p = n; } else p += k; }
|
||
const uint8_t* at(size_t off) const { return d + off; }
|
||
// sub_AEF1EC string record
|
||
std::string str() {
|
||
uint16_t m = u16();
|
||
if (m != 0xFFFF) { ok = false; return {}; } // interned index — not present in these .opa
|
||
uint32_t len = u16();
|
||
if (len == 0xFFFF) len = u32();
|
||
if (!avail(len)) { ok = false; return {}; }
|
||
std::string s((const char*)d + p, len);
|
||
p += len; return s;
|
||
}
|
||
};
|
||
|
||
// ── minimal 4x4 (column-major) for baking node world transforms ──
|
||
struct Mat4 { float m[16]; };
|
||
static Mat4 identity() { Mat4 r{}; r.m[0]=r.m[5]=r.m[10]=r.m[15]=1.0f; return r; }
|
||
static Mat4 mul(const Mat4& a, const Mat4& b) {
|
||
Mat4 r{};
|
||
for (int c = 0; c < 4; ++c)
|
||
for (int row = 0; row < 4; ++row) {
|
||
float s = 0;
|
||
for (int k = 0; k < 4; ++k) s += a.m[k*4+row] * b.m[c*4+k];
|
||
r.m[c*4+row] = s;
|
||
}
|
||
return r;
|
||
}
|
||
// column-major 4x4 helpers on raw float[16] (for skeletal skinning matrices)
|
||
static void mat4mul(const float* A, const float* B, float* C) {
|
||
for (int c=0;c<4;++c) for (int r=0;r<4;++r) { float s=0; for(int k=0;k<4;++k) s+=A[k*4+r]*B[c*4+k]; C[c*4+r]=s; }
|
||
}
|
||
static void mat4affineInverse(const float* M, float* out) {
|
||
float a0=M[0],a1=M[1],a2=M[2], a3=M[4],a4=M[5],a5=M[6], a6=M[8],a7=M[9],a8=M[10]; // 3x3 col-major
|
||
float det = a0*(a4*a8-a5*a7) - a3*(a1*a8-a2*a7) + a6*(a1*a5-a2*a4);
|
||
if (det > -1e-12f && det < 1e-12f) { for(int i=0;i<16;++i) out[i]=(i%5==0)?1.f:0.f; return; }
|
||
float id = 1.0f/det;
|
||
float b0=(a4*a8-a5*a7)*id, b1=(a2*a7-a1*a8)*id, b2=(a1*a5-a2*a4)*id; // inv col-major
|
||
float b3=(a5*a6-a3*a8)*id, b4=(a0*a8-a2*a6)*id, b5=(a2*a3-a0*a5)*id;
|
||
float b6=(a3*a7-a4*a6)*id, b7=(a1*a6-a0*a7)*id, b8=(a0*a4-a1*a3)*id;
|
||
float tx=M[12],ty=M[13],tz=M[14];
|
||
out[0]=b0; out[1]=b1; out[2]=b2; out[3]=0;
|
||
out[4]=b3; out[5]=b4; out[6]=b5; out[7]=0;
|
||
out[8]=b6; out[9]=b7; out[10]=b8; out[11]=0;
|
||
out[12]=-(b0*tx+b3*ty+b6*tz); out[13]=-(b1*tx+b4*ty+b7*tz); out[14]=-(b2*tx+b5*ty+b8*tz); out[15]=1;
|
||
}
|
||
static Mat4 trs(const float t[3], const float q[4], const float s[3]) {
|
||
float x=q[0],y=q[1],z=q[2],w=q[3];
|
||
float xx=x*x,yy=y*y,zz=z*z,xy=x*y,xz=x*z,yz=y*z,wx=w*x,wy=w*y,wz=w*z;
|
||
Mat4 r = identity();
|
||
r.m[0]=(1-2*(yy+zz))*s[0]; r.m[1]=(2*(xy+wz))*s[0]; r.m[2]=(2*(xz-wy))*s[0];
|
||
r.m[4]=(2*(xy-wz))*s[1]; r.m[5]=(1-2*(xx+zz))*s[1]; r.m[6]=(2*(yz+wx))*s[1];
|
||
r.m[8]=(2*(xz+wy))*s[2]; r.m[9]=(2*(yz-wx))*s[2]; r.m[10]=(1-2*(xx+yy))*s[2];
|
||
r.m[12]=t[0]; r.m[13]=t[1]; r.m[14]=t[2];
|
||
return r;
|
||
}
|
||
static void xform(const Mat4& M, float x, float y, float z, float out[3]) {
|
||
out[0] = M.m[0]*x + M.m[4]*y + M.m[8]*z + M.m[12];
|
||
out[1] = M.m[1]*x + M.m[5]*y + M.m[9]*z + M.m[13];
|
||
out[2] = M.m[2]*x + M.m[6]*y + M.m[10]*z + M.m[14];
|
||
}
|
||
|
||
struct Node { std::string name; int parent=-1; float t[3]={0,0,0}, r[4]={0,0,0,1}, s[3]={1,1,1}; };
|
||
std::vector<Node> nodes;
|
||
std::vector<Mat4> nodeWorld;
|
||
struct Mat { uint64_t id=0; std::string path; };
|
||
std::vector<Mat> materials;
|
||
|
||
// Decoded *.png.opa textures (OPAA container -> KTX/ASTC -> RGBA), keyed by their cooked
|
||
// basename (filename minus ".png.opa", lowercased) so ANY home's naming resolves — not
|
||
// just spacestation's "tx_" convention.
|
||
struct Tex { std::string key; mutable std::vector<uint8_t> rgba; uint32_t w=1, h=1; bool hasAlpha=false;
|
||
// Source ASTC mip chain (blocks only, per-mip imageSize prefixes stripped) + footprint, kept for LOSSLESS
|
||
// pass-through: an UNMODIFIED texture cooks its ORIGINAL ASTC verbatim instead of decode→re-encode (double
|
||
// ASTC compression at MEDIUM = "lost definition"). Empty when the source wasn't a plain KTX-ASTC.
|
||
// The chain as one shared source: every mesh using the texture points at it (no per-mesh copies).
|
||
std::shared_ptr<const TexSource> src;
|
||
// With a source chain, `rgba` is only a small mip (alpha scan; the renderer decodes from src) — full-size
|
||
// RGBA of every 4K texture, copied into every mesh using it, was GBs of RAM and paged the whole desktop.
|
||
// full() decodes the base mip on first use (lightmaps are sampled from RGBA).
|
||
mutable bool preview=false;
|
||
mutable uint32_t pvW=0, pvH=0; // rgba's size when it's a shrunk preview (else w x h)
|
||
uint32_t pw() const { return pvW ? pvW : w; }
|
||
uint32_t ph() const { return pvH ? pvH : h; }
|
||
void halve() {
|
||
const uint32_t sw = pw(), sh = ph(), nw = std::max(1u, sw / 2), nh = std::max(1u, sh / 2);
|
||
std::vector<uint8_t> o((size_t)nw * nh * 4);
|
||
for (uint32_t y = 0; y < nh; ++y) for (uint32_t x = 0; x < nw; ++x) for (int c = 0; c < 4; ++c) {
|
||
const uint32_t x0 = std::min(sw - 1, x * 2), x1 = std::min(sw - 1, x * 2 + 1), y0 = std::min(sh - 1, y * 2), y1 = std::min(sh - 1, y * 2 + 1);
|
||
o[((size_t)y * nw + x) * 4 + c] = (uint8_t)((rgba[((size_t)y0 * sw + x0) * 4 + c] + rgba[((size_t)y0 * sw + x1) * 4 + c] +
|
||
rgba[((size_t)y1 * sw + x0) * 4 + c] + rgba[((size_t)y1 * sw + x1) * 4 + c] + 2) / 4);
|
||
}
|
||
rgba.swap(o); pvW = nw; pvH = nh;
|
||
}
|
||
const std::vector<uint8_t>& full() const {
|
||
if (preview) {
|
||
const size_t mip0 = (size_t)((src->w + src->bw - 1) / src->bw) * ((src->h + src->bh - 1) / src->bh) * 16;
|
||
if (!astc::decodeASTC(src->astc.data(), (uint32_t)std::min(mip0, src->astc.size()), src->w, src->h, src->bw, src->bh, rgba)) rgba.clear();
|
||
preview = false; pvW = pvH = 0;
|
||
}
|
||
return rgba;
|
||
}
|
||
};
|
||
std::vector<Tex> textures;
|
||
|
||
// Faithful material metadata, read from the cooked *.mat.txt (libshell's own material
|
||
// descriptions): the home TELLS us blend mode + diffuse texture, so we don't guess.
|
||
// Keyed by the material's lowercased stem (its .mat.asset basename minus ".mat.*").
|
||
struct MatProps {
|
||
bool transparent=false, additive=false, alphaTest=false, doubleSided=false, unlit=true;
|
||
uint64_t diffuseId=0; // diffuse texture AssetId (-> assetIdToTexBase)
|
||
uint64_t lightmapId=0; // BAKED lightmap texture AssetId. The interior SHELL meshes
|
||
// (helmet/gem/octo/table/floor...) bake their lighting+detail into
|
||
// this; without it a no-albedo shell renders as a flat blob.
|
||
std::string diffuseBase; // OR diffuse texture basename from a Path field (lowercased)
|
||
float diffuseColor[3]={1,1,1}; // 'diffuse' basecolor UNIFORM (flat color when no texture; tint when textured)
|
||
float alpha=1.0f; // 'alpha' UNIFORM — libshell scales fragment alpha by it. Transparent
|
||
// effects (forge flicker=0.27, fog, dust) use <1 to be FAINT overlays;
|
||
// ignoring it = full-opacity dark box that occludes everything behind.
|
||
// PBR / SpecIbl uniforms (read verbatim from the cooked .mat.txt) — drive the split-sum IBL of
|
||
// no-albedo metallic/gem shells (divingHelmet metallic=1, rubyGem metallic=0 rough=0, etc).
|
||
float metallic=0.0f, roughness=1.0f;
|
||
float speciblDiffScale=1.0f, speciblSpecScale=1.0f;
|
||
float alphaCutoff=0.5f; // 'alphatestthreshold' UNIFORM — the AUTHORED AlphaTest discard threshold
|
||
// (V79 frag: `if (color.a < AlphaCutoff) discard`). The zen tree canopy
|
||
// authors 0.25; hardcoding 0.5 discarded its abundant 0.25-0.5 mid-alpha
|
||
// leaf detail = thin holey "blocky" canopy.
|
||
float lightmapPower[3]={1.0f,1.0f,1.0f}; // per-channel lightmap HDR boost (the neon/glow tint)
|
||
bool isSpecibl=false; // Shader: SpecIbl
|
||
bool found=false;
|
||
};
|
||
std::unordered_map<std::string, MatProps> matProps; // material stem -> props
|
||
std::unordered_map<uint64_t, std::string> assetIdToTexBase; // texture AssetId -> tex basename
|
||
|
||
static std::string lc(std::string s){ for(auto&c:s)c=(char)tolower((unsigned char)c); return s; }
|
||
// basename(path) with a trailing ".mat.asset"/".mat.opa"/".mat.txt" (or any ext run) stripped.
|
||
static std::string matStem(const std::string& path) {
|
||
size_t sl = path.find_last_of("/\\");
|
||
std::string b = (sl==std::string::npos)?path:path.substr(sl+1);
|
||
size_t m = b.find(".mat."); if (m!=std::string::npos) b=b.substr(0,m);
|
||
return lc(b);
|
||
}
|
||
const Tex* texByBase(const std::string& base) const {
|
||
std::string b=lc(base);
|
||
for (auto& t : textures) if (t.key==b) return &t;
|
||
return nullptr;
|
||
}
|
||
|
||
// KTX1 (ASTC) base mip -> RGBA (same decode the glTF loader uses).
|
||
// If srcAstcOut != null, ALSO extract the full ASTC mip chain (blocks only, imageSize prefixes stripped) +
|
||
// footprint (srcBw/srcBh) + mip count — for the cook's LOSSLESS pass-through of unmodified textures.
|
||
static bool decodeKtxBaseMip(const uint8_t* ktx, size_t n, std::vector<uint8_t>& rgba, uint32_t& outW, uint32_t& outH,
|
||
std::vector<uint8_t>* srcAstcOut = nullptr, uint32_t* srcBwOut = nullptr,
|
||
uint32_t* srcBhOut = nullptr, uint32_t* srcMipsOut = nullptr,
|
||
uint32_t previewSide = 0, uint32_t* pvWOut = nullptr, uint32_t* pvHOut = nullptr) {
|
||
if (n < 64) return false;
|
||
static const uint8_t id[12] = {0xAB,'K','T','X',' ','1','1',0xBB,'\r','\n',0x1A,'\n'};
|
||
if (memcmp(ktx, id, 12) != 0) return false;
|
||
auto u32a = [&](size_t o){ uint32_t v; memcpy(&v, ktx+o, 4); return v; };
|
||
uint32_t glInternalFormat = u32a(28);
|
||
uint32_t w = u32a(36), h = u32a(40);
|
||
uint32_t mipCount = u32a(56); if (mipCount < 1) mipCount = 1;
|
||
uint32_t bytesOfKeyValueData = u32a(60);
|
||
size_t off = 64 + bytesOfKeyValueData;
|
||
if (off + 4 > n) return false;
|
||
uint32_t imageSize = u32a(off); size_t base0 = off + 4;
|
||
if (base0 + imageSize > n) imageSize = (uint32_t)(n - base0);
|
||
// Full ASTC footprint table (linear 0x93B0..0x93BD, sRGB 0x93D0..0x93DD, same
|
||
// order of 14 footprints) — libshell reads the footprint from glInternalFormat,
|
||
// so every non-square one (e.g. 8x6 = 0x93B6) must map correctly; defaulting to
|
||
// 8x8 mis-strides the block grid and scrambles the texture.
|
||
static const uint8_t kFootprints[14][2] = {
|
||
{4,4},{5,4},{5,5},{6,5},{6,6},{8,5},{8,6},{8,8},
|
||
{10,5},{10,6},{10,8},{10,10},{12,10},{12,12}
|
||
};
|
||
uint32_t bw = 8, bh = 8;
|
||
int fidx = -1;
|
||
if (glInternalFormat >= 0x93B0 && glInternalFormat <= 0x93BD) fidx = (int)(glInternalFormat - 0x93B0);
|
||
else if (glInternalFormat >= 0x93D0 && glInternalFormat <= 0x93DD) fidx = (int)(glInternalFormat - 0x93D0);
|
||
if (fidx >= 0) { bw = kFootprints[fidx][0]; bh = kFootprints[fidx][1]; }
|
||
outW = w; outH = h;
|
||
// Optional: walk the whole KTX mip chain [imageSize u32][blocks] per level and concatenate the BLOCKS.
|
||
// previewSide: with a good chain, decode only the first mip that fits in previewSide (rgba is then that mip).
|
||
if (srcAstcOut && fidx >= 0) {
|
||
srcAstcOut->clear();
|
||
size_t p = off; uint32_t mw = w, mh = h; bool ok = true;
|
||
size_t pvOff = 0, pvSize = 0; uint32_t pvW = 0, pvH = 0;
|
||
for (uint32_t lvl = 0; lvl < mipCount; ++lvl) {
|
||
if (p + 4 > n) { ok = false; break; }
|
||
uint32_t isz = u32a(p); p += 4;
|
||
if (p + isz > n) { ok = false; break; }
|
||
size_t expect = (size_t)((mw + bw - 1) / bw) * ((mh + bh - 1) / bh) * 16;
|
||
if (isz != expect) { ok = false; break; } // not a plain single-face ASTC chain -> bail (re-encode)
|
||
if (previewSide && !pvW && (std::max(mw, mh) <= previewSide || lvl + 1 == mipCount)) {
|
||
pvOff = srcAstcOut->size(); pvSize = isz; pvW = mw; pvH = mh;
|
||
}
|
||
srcAstcOut->insert(srcAstcOut->end(), ktx + p, ktx + p + isz);
|
||
p += isz; mw = mw > 1 ? mw / 2 : 1; mh = mh > 1 ? mh / 2 : 1;
|
||
}
|
||
if (!ok) { srcAstcOut->clear(); }
|
||
else {
|
||
if (srcBwOut) *srcBwOut = bw; if (srcBhOut) *srcBhOut = bh; if (srcMipsOut) *srcMipsOut = mipCount;
|
||
if (pvW && pvOff == 0 && std::max(pvW, pvH) > previewSide * 2) {
|
||
// No level small enough (cooked without mips: a 6000^2 flipbook): decoding all of it for a
|
||
// preview took most of a home's load. ASTC blocks stand alone, so the preview is a sparse grid of
|
||
// the real blocks (one in k each way), decoded together: the same colours and alpha, ~1/k^2 the work.
|
||
const uint32_t nbx = (pvW + bw - 1) / bw, nby = (pvH + bh - 1) / bh;
|
||
const uint32_t want = std::max(1u, (previewSide * 2 + bw - 1) / bw);
|
||
const uint32_t k = std::max(1u, std::max(nbx, nby) / want);
|
||
const uint32_t mbx = (nbx + k - 1) / k, mby = (nby + k - 1) / k;
|
||
std::vector<uint8_t> mosaic((size_t)mbx * mby * 16);
|
||
for (uint32_t y = 0; y < mby; ++y)
|
||
for (uint32_t x = 0; x < mbx; ++x)
|
||
memcpy(&mosaic[((size_t)y * mbx + x) * 16], srcAstcOut->data() + ((size_t)(y * k) * nbx + x * k) * 16, 16);
|
||
if (pvWOut) *pvWOut = mbx * bw;
|
||
if (pvHOut) *pvHOut = mby * bh;
|
||
return astc::decodeASTC(mosaic.data(), (uint32_t)mosaic.size(), mbx * bw, mby * bh, bw, bh, rgba);
|
||
}
|
||
if (pvW) {
|
||
if (pvWOut) *pvWOut = pvW;
|
||
if (pvHOut) *pvHOut = pvH;
|
||
return astc::decodeASTC(srcAstcOut->data() + pvOff, (uint32_t)pvSize, pvW, pvH, bw, bh, rgba);
|
||
}
|
||
}
|
||
}
|
||
return astc::decodeASTC(ktx+base0, imageSize, w, h, bw, bh, rgba);
|
||
}
|
||
|
||
// ── sanim node-TRS animation (the looping ui_ring / wire motion) ──────────────────
|
||
// sanim.opa (same OPAA reflection format) stores per-node TRS tracks: for each
|
||
// (node, channel in {Translation,Rotation,Scale}): [u32 flag][u32 nKeys][u32 nVals]
|
||
// [nVals f32]. nFrames = nKeys+1, comps = nVals/nFrames (3 for T/S, 4 for R quat).
|
||
// No explicit per-key times -> dense per-frame sampling, so we loop at a fixed fps.
|
||
struct Track { int comps = 0; int nFrames = 0; int effFrames = 0; std::vector<float> v; };
|
||
struct NodeTracks { Track t, r, s; };
|
||
std::unordered_map<std::string, NodeTracks> nodeAnim;
|
||
struct AnimRec { size_t meshIdx; uint32_t nodeIdx; Mat4 parentWorld; std::vector<float> basePos;
|
||
// animateRuntime: rigid = drawn as md.animXform over the t=0 pose (-1 = not decided yet; 0 = its
|
||
// node is singular at t=0 or the mesh is also skinned / VAT, so its vertices are rewritten)
|
||
int rigid = -1; Mat4 rest{}; float restInv[16] = {}; bool atRest = false; };
|
||
// animate(t) rewrites every animated vertex (the cook samplers read them); animateRuntime(t) is the per-frame
|
||
// play: rigid node motion becomes md.animXform, so those meshes' vertices never change.
|
||
bool gpuRigid_ = false;
|
||
void animateRuntime(float t) { gpuRigid_ = true; animate(t); gpuRigid_ = false; }
|
||
static bool put(float& d, float v) { if (d == v) return false; d = v; return true; }
|
||
std::vector<AnimRec> animRecs;
|
||
std::vector<Mat4> nodeWorldAnim; // per-frame scratch: animated world transform of every node (reused)
|
||
std::vector<Node> animNodes; // GLOBAL persistent node list — every .fbx.opa appended (parents offset).
|
||
// animRec.nodeIdx indexes THIS, NOT the per-mesh `nodes` that parseModel
|
||
// CLEARS each call (which made the bird's wing read the waterfall's node).
|
||
int animMaxFrames = 0;
|
||
float animFps = 30.0f;
|
||
// UV-scroll loop-time cap ("Water flow" slider): boosts a slow track's rate so its loop completes in this many
|
||
// seconds. ⛔ DEFAULT OFF (0 = faithful authored rate): the renderer's animate() CLIP path already plays every
|
||
// UV track at its OWN authored duration (frames/fps — the "moves too fast / doesn't follow" fix), but the COOK's
|
||
// uvScrollRate still applied this boost, making preview and device speeds disagree for long water tracks.
|
||
// Preserve the authored near-static rate by default; opt in through the editor slider if a livelier result is wanted.
|
||
float uvMaxLoopSec = 0.0f;
|
||
// UV-scroll VISIBLE SPEED CAP (UV/sec). The mat.sanim has NO FrameRate field so a UV track inherits the NODE
|
||
// sanim's 30fps (bird/butterfly) — wrong: that makes the fog scroll 3.0 tiles/s & the waterfall 3.75 (user:
|
||
// "wrong speed", far too fast). The real playback is a runtime PlaybackState we can't read, so cap the visible
|
||
// speed to a calm UV/sec, preserving DIRECTION + RELATIVE speed. Shared by cook + renderer (uvScrollRate).
|
||
float uvSpeedMax = 0.5f;
|
||
|
||
// ── Skeletal animation (*.skel/*.anim.opa) for skinned meshes ──────────────────────────
|
||
// An .anim clip stores, per frame, one 4x4 SKINNING matrix per joint (frame0 ~= identity ->
|
||
// they're pre-multiplied jointWorld*inverseBind). vertex_world(t) = jointMat[t][bone]*bindVertex.
|
||
struct AnimClip { std::vector<std::string> joints; std::vector<float> mats; // mats = per-frame LOCAL 4x4
|
||
std::vector<int> parents; std::vector<float> invBind; // from .skel (parents + inverse bind WORLD)
|
||
int numJoints=0, numFrames=0; };
|
||
std::vector<AnimClip> clips;
|
||
std::unordered_map<std::string,std::pair<int,int>> jointToClip; // jointName -> (clipIdx, jointIdx)
|
||
// .skel.opa bind data, keyed by basename (owl_offset.skel <-> owl_offset.anim): parents + bind LOCAL 4x4.
|
||
std::unordered_map<std::string, std::pair<std::vector<int>, std::vector<float>>> skelData;
|
||
// Per skinned mesh: bind verts + per-vertex 4 (skin-bone, weight) for linear blend skinning,
|
||
// plus the skin's per-bone INVERSE-BIND (from the .skin.opa) and bone->clip-joint map.
|
||
// skinMat[bone] = clipJointWorld[boneClip[bone]] * invBind[bone].
|
||
struct SkinRec { size_t meshIdx; int clipIdx=-1; int nJoints=0; std::vector<float> basePos; // nv*3
|
||
std::vector<int> jidx; std::vector<float> jw; // nv*4 each (jidx = SKIN bone idx)
|
||
std::vector<int> boneClip; std::vector<float> invBind; // nJoints, nJoints*16
|
||
bool gpu = false; // skinned by the renderer (prepareGpuSkin)
|
||
std::vector<float> jointBox; // gpu: per joint, the bind-space box of the vertices it moves (6 floats)
|
||
float unriggedBox[6] = {1e30f, 1e30f, 1e30f, -1e30f, -1e30f, -1e30f}; }; // gpu: the vertices no joint moves
|
||
std::vector<SkinRec> skinRecs;
|
||
std::vector<std::vector<float>> _clipSkin; // per-frame scratch (reused -> no heap churn/stutter)
|
||
int skinShared_ = -1; // two skin records write one mesh (-1: not checked yet): skin serially
|
||
// GPU skinning (Frame Home / Studio): every skinned mesh the renderer can skin itself (<= 256 joints, one record, no
|
||
// other animation writing its vertices) gets its bind pose (skinBindPos) and 8-bit bone indices / weights;
|
||
// animateRuntime then writes only its joint matrices and box, never its vertices. Call before the meshes are drawn.
|
||
void prepareGpuSkin() {
|
||
std::map<size_t, int> per;
|
||
for (auto& sr : skinRecs) ++per[sr.meshIdx];
|
||
std::set<size_t> other;
|
||
for (auto& ar : animRecs) other.insert(ar.meshIdx);
|
||
for (auto& vr : vatRecs) other.insert(vr.meshIdx);
|
||
size_t n = 0;
|
||
for (auto& sr : skinRecs) {
|
||
if (sr.meshIdx >= meshes.size()) continue;
|
||
MeshData& md = meshes[sr.meshIdx];
|
||
const size_t nv = sr.basePos.size() / 3;
|
||
if (sr.nJoints < 1 || sr.nJoints > 256 || per[sr.meshIdx] != 1 || other.count(sr.meshIdx) || sr.jidx.size() < nv*4 ||
|
||
sr.jw.size() < nv*4 || md.positions.size() != nv*3 || md.nVerts != nv) continue;
|
||
md.skinBindPos = sr.basePos;
|
||
md.boneIndices.assign(nv*4, 0);
|
||
md.boneWeights.assign(nv*4, 0);
|
||
sr.jointBox.assign((size_t)sr.nJoints*6, 0.0f);
|
||
for (int b = 0; b < sr.nJoints; ++b) for (int k = 0; k < 3; ++k) { sr.jointBox[b*6+k] = 1e30f; sr.jointBox[b*6+3+k] = -1e30f; }
|
||
for (size_t v = 0; v < nv; ++v) {
|
||
float wsum = 0;
|
||
for (int i = 0; i < 4; ++i) {
|
||
const float w = sr.jw[v*4+i]; const int b = sr.jidx[v*4+i];
|
||
if (w > 0.0f && b >= 0 && b < sr.nJoints) wsum += w;
|
||
}
|
||
if (wsum < 1e-4f) { // unrigged: weights 0 = the bind position (as on the CPU)
|
||
for (int k = 0; k < 3; ++k) { sr.unriggedBox[k] = std::min(sr.unriggedBox[k], sr.basePos[v*3+k]); sr.unriggedBox[3+k] = std::max(sr.unriggedBox[3+k], sr.basePos[v*3+k]); }
|
||
continue;
|
||
}
|
||
int q[4] = {0, 0, 0, 0}, sum = 0, big = -1;
|
||
for (int i = 0; i < 4; ++i) {
|
||
const float w = sr.jw[v*4+i]; const int b = sr.jidx[v*4+i];
|
||
if (!(w > 0.0f && b >= 0 && b < sr.nJoints)) continue;
|
||
q[i] = (int)std::lround(w / wsum * 255.0f); sum += q[i];
|
||
if (big < 0 || q[i] > q[big]) big = i;
|
||
md.boneIndices[v*4+i] = (uint8_t)b;
|
||
float* bx = &sr.jointBox[(size_t)b*6];
|
||
for (int k = 0; k < 3; ++k) { bx[k] = std::min(bx[k], sr.basePos[v*3+k]); bx[3+k] = std::max(bx[3+k], sr.basePos[v*3+k]); }
|
||
}
|
||
if (big >= 0) q[big] += 255 - sum; // the weights sum to exactly 1
|
||
for (int i = 0; i < 4; ++i) md.boneWeights[v*4+i] = (uint8_t)std::clamp(q[i], 0, 255);
|
||
}
|
||
sr.gpu = true;
|
||
++n;
|
||
}
|
||
if (n) log("GPU skinning: %zu of %zu skinned meshes", n, skinRecs.size());
|
||
}
|
||
|
||
// mat.sanim "UVTransform" = per-mesh animated 2x3 UV matrix (flipbook / UV-scroll for smoke/
|
||
// fire/dust/fog/particles). libshell drives this as the material's UniformUVOffset/Texm. Keyed
|
||
// by the geo/node name (24/24 match the model nodes).
|
||
struct UVTrack { int nFrames=0; std::vector<float> m; // m = nFrames * 6 (a,b,c, d,e,f)
|
||
int scroll = -1; float ru = 0, rv = 0; }; // uvScrollRate's answer for it (-1: not asked yet)
|
||
std::unordered_map<std::string, UVTrack> matUVAnim;
|
||
struct UVAnimRec { size_t meshIdx; std::string node; std::vector<float> baseUV; bool atRest = false; };
|
||
// animateRuntime: this frame's UV matrix M becomes md.uvXform over the base uvs (written once), unless the mesh's
|
||
// lightmap reads the same uvs (no uvs2): then the uvs themselves are rewritten, as animate() does.
|
||
bool uvByMatrix(UVAnimRec& ur, MeshData& md, size_t nuv, const float M[6]) {
|
||
if (!gpuRigid_ || (md.hasLightmap && md.uvs2.size() < nuv*2)) {
|
||
ur.atRest = false;
|
||
static const float I[6] = {1,0,0, 0,1,0};
|
||
std::memcpy(md.uvXform, I, sizeof md.uvXform);
|
||
return false;
|
||
}
|
||
if (!ur.atRest) {
|
||
bool changed = false;
|
||
for (size_t i = 0; i < nuv*2; ++i) changed |= put(md.uvs[i], ur.baseUV[i]);
|
||
if (changed) ++md.uvGen;
|
||
ur.atRest = true;
|
||
}
|
||
std::memcpy(md.uvXform, M, sizeof md.uvXform);
|
||
return true;
|
||
}
|
||
std::vector<UVAnimRec> uvAnimRecs;
|
||
|
||
// mat.sanim "MaterialTint" = per-frame RGBA the shader multiplies into the fragment
|
||
// (UniformColor). For fog/dust/flicker the ALPHA animates 0..~0.22, keeping the effect FAINT
|
||
// and pulsing — dropping it makes fog render ~4-5x too dense. Keyed by geo/node name (same as
|
||
// UVTransform). animate(t) samples it into MeshData.curTint; the renderer pushes it as the tint.
|
||
struct TintTrack { int nFrames=0; std::vector<float> rgba; }; // rgba = nFrames * 4
|
||
std::unordered_map<std::string, TintTrack> matTintAnim;
|
||
struct TintRec { size_t meshIdx; std::string node; };
|
||
std::vector<TintRec> tintRecs;
|
||
|
||
// SpecIbl diffuse irradiance cubemap (RGBA16F), shipped as `*_diffuse.dds.opa`. The renderer bakes
|
||
// diffuseCube(worldN) into the per-vertex color of `*_specibl` meshes (env-lit, not white/dark).
|
||
ibl::Cubemap iblDiffuse;
|
||
// SpecIbl SPECULAR (roughness-prefiltered) cubemap (RGBA16F), shipped as `*_specular.dds.opa`. We
|
||
// decode mip0 for CPU per-vertex sampling (the sharp env reflection of metallic/gem shells) AND keep
|
||
// the raw bytes for an optional GPU cube upload.
|
||
ibl::Cubemap iblSpecular;
|
||
std::vector<uint8_t> iblSpecularRaw;
|
||
|
||
// ── VAT (Vertex Animation Texture): underwater coral/seaweed/fish/jellyfish ──────────────
|
||
// libshell's CoVertexAnimation: a `t_*_vatdata.exr.opa` (cooked as an UNCOMPRESSED RGBA32F KTX,
|
||
// width = #anim-verts, height = #frames) stores a per-frame, per-vertex POSITION OFFSET (frame 0
|
||
// = 0 = rest pose; tiny ±0.1 sway). The mesh's UV1.x (a_texcoords zw@16) is the column (vertex
|
||
// index) into that texture. Each frame: localPos = basePos + vatOffset[frame][col]; the instance
|
||
// node transform places it. Keyed by the geo basename (sm_<X>.fbx <-> t_<X>_vatdata.exr).
|
||
struct VatData { int cols=0, frames=0; std::vector<float> off; }; // off[(f*cols+c)*3 + xyz]
|
||
std::unordered_map<std::string, VatData> vatByBase;
|
||
struct VatRec { size_t meshIdx; std::vector<float> basePos; std::vector<int> col; Mat4 world; const VatData* vd=nullptr; };
|
||
std::vector<VatRec> vatRecs;
|
||
std::string curOpaBase; // set before each parseModel (composed scene) for VAT matching
|
||
float vatFps = 24.0f;
|
||
|
||
bool hasAnimation() const { return (!animRecs.empty() || !skinRecs.empty() || !uvAnimRecs.empty() || !vatRecs.empty()) && (animMaxFrames > 1 || !vatRecs.empty()); }
|
||
float animDuration() const { return animMaxFrames > 1 ? (float)animMaxFrames / animFps : 0.0f; }
|
||
|
||
// ── COOK: SKINNED HZANIM extraction (the V79 OPA→V205 port for ALL skinned meshes — was dropped, cook only
|
||
// did rotation/UV). Builds a device-FAITHFUL HIERARCHICAL skeleton (NOT the flat world-bake): joints = the
|
||
// clip joints, parents = clip.parents, per-frame trsLocal = clip.mats (already parent-LOCAL, the renderer
|
||
// composes animWorld[j]=animWorld[parent]*mats[f][j]), bind = inverse(skin invBind) → composed jointBindWorld
|
||
// (sr.invBind[b]=inverse(jointBindWorld[boneClip[b]]), proven from the renderer's skinning at sub animate()).
|
||
// boneIdx remaps skin-bone→clip-joint via boneClip. The cook's useHz path (Incredibles-proven) emits HZAN:SKEL+
|
||
// ACL HZAN:ANIM + AnimatorPlatformComponent. project_hsl_cooker_expose_all_audit.
|
||
struct OpaHzAnim {
|
||
std::vector<float> jointPos, jointQuat, jointScale; std::vector<int> parents;
|
||
std::vector<uint8_t> boneIdx, boneWgt; std::vector<float> trsLocal, restPos;
|
||
int jointCount=0, frameCount=0; float fps=0.f;
|
||
bool ok() const { return jointCount>0 && frameCount>1; }
|
||
};
|
||
// Does this mesh carry a CPU-skinning record (a .skin.opa submesh, device-cooked via HZANIM)? Used by the
|
||
// cook's tint routing: the getTime TINTREPLAY frag stage is NOT reliably driven for skinned-cutout on device,
|
||
// so skinned foliage tints must be baked STATICALLY into COLOR_0 (the base×COLOR0 path always works).
|
||
bool isSkinnedMesh(int meshIdx) const {
|
||
for (auto& r : skinRecs) if ((int)r.meshIdx == meshIdx) return true;
|
||
return false;
|
||
}
|
||
OpaHzAnim extractHzAnim(int meshIdx) {
|
||
OpaHzAnim e;
|
||
const SkinRec* rec=nullptr; for (auto& r : skinRecs) if ((int)r.meshIdx==meshIdx) { rec=&r; break; }
|
||
if (!rec || rec->clipIdx<0 || rec->clipIdx>=(int)clips.size()) return e;
|
||
const AnimClip& clip = clips[rec->clipIdx];
|
||
int nj=clip.numJoints, nf=clip.numFrames;
|
||
if (nj<1 || nf<2 || (int)clip.mats.size() < nf*nj*16) return e;
|
||
// column-major float[16] helpers (match gltf_loader matTrs/mulM)
|
||
auto mul16=[](const float* a,const float* b,float* o){ for(int c=0;c<4;c++)for(int r=0;r<4;r++) o[c*4+r]=a[r]*b[c*4]+a[4+r]*b[c*4+1]+a[8+r]*b[c*4+2]+a[12+r]*b[c*4+3]; };
|
||
auto invAff=[](const float* m,float* o){
|
||
float M00=m[0],M01=m[4],M02=m[8], M10=m[1],M11=m[5],M12=m[9], M20=m[2],M21=m[6],M22=m[10];
|
||
float det=M00*(M11*M22-M12*M21)-M01*(M10*M22-M12*M20)+M02*(M10*M21-M11*M20);
|
||
float id=(det>1e-20f||det<-1e-20f)?1.f/det:0.f;
|
||
o[0]=(M11*M22-M12*M21)*id; o[1]=(M12*M20-M10*M22)*id; o[2]=(M10*M21-M11*M20)*id;
|
||
o[4]=(M02*M21-M01*M22)*id; o[5]=(M00*M22-M02*M20)*id; o[6]=(M01*M20-M00*M21)*id;
|
||
o[8]=(M01*M12-M02*M11)*id; o[9]=(M02*M10-M00*M12)*id; o[10]=(M00*M11-M01*M10)*id;
|
||
o[3]=o[7]=o[11]=0; o[15]=1;
|
||
float tx=m[12],ty=m[13],tz=m[14];
|
||
o[12]=-(o[0]*tx+o[4]*ty+o[8]*tz); o[13]=-(o[1]*tx+o[5]*ty+o[9]*tz); o[14]=-(o[2]*tx+o[6]*ty+o[10]*tz); };
|
||
auto matTrs=[](const float* m,float* q,float* t,float* s){
|
||
t[0]=m[12];t[1]=m[13];t[2]=m[14];
|
||
s[0]=std::sqrt(m[0]*m[0]+m[1]*m[1]+m[2]*m[2]); s[1]=std::sqrt(m[4]*m[4]+m[5]*m[5]+m[6]*m[6]); s[2]=std::sqrt(m[8]*m[8]+m[9]*m[9]+m[10]*m[10]);
|
||
// det<0 = MIRRORED node (negative scale — e.g. the *_inside_* searchlight twins): all-positive sqrt
|
||
// scales force the rotation part to absorb the reflection = an IMPROPER matrix whose quaternion is
|
||
// GARBAGE → the mesh cooks rotated/positioned wrong ("out of place"). Negate ONE scale axis (x) so
|
||
// R = M·S⁻¹ is a proper rotation again.
|
||
float det = m[0]*(m[5]*m[10]-m[6]*m[9]) - m[4]*(m[1]*m[10]-m[2]*m[9]) + m[8]*(m[1]*m[6]-m[2]*m[5]);
|
||
if (det < 0.f) s[0] = -s[0];
|
||
float ix=std::fabs(s[0])>1e-8f?1/s[0]:0, iy=s[1]>1e-8f?1/s[1]:0, iz=s[2]>1e-8f?1/s[2]:0;
|
||
float r0=m[0]*ix,r1=m[1]*ix,r2=m[2]*ix, r3=m[4]*iy,r4=m[5]*iy,r5=m[6]*iy, r6=m[8]*iz,r7=m[9]*iz,r8=m[10]*iz;
|
||
float tr=r0+r4+r8;
|
||
if(tr>0){float S=std::sqrt(tr+1)*2;q[3]=0.25f*S;q[0]=(r5-r7)/S;q[1]=(r6-r2)/S;q[2]=(r1-r3)/S;}
|
||
else if(r0>r4&&r0>r8){float S=std::sqrt(1+r0-r4-r8)*2;q[3]=(r5-r7)/S;q[0]=0.25f*S;q[1]=(r3+r1)/S;q[2]=(r6+r2)/S;}
|
||
else if(r4>r8){float S=std::sqrt(1+r4-r0-r8)*2;q[3]=(r6-r2)/S;q[0]=(r3+r1)/S;q[1]=0.25f*S;q[2]=(r7+r5)/S;}
|
||
else{float S=std::sqrt(1+r8-r0-r4)*2;q[3]=(r1-r3)/S;q[0]=(r6+r2)/S;q[1]=(r7+r5)/S;q[2]=0.25f*S;} };
|
||
e.jointCount=nj; e.frameCount=nf; e.fps = animFps>0.f?animFps:30.f;
|
||
e.parents=clip.parents; if((int)e.parents.size()<nj) e.parents.resize(nj,-1);
|
||
e.restPos=rec->basePos;
|
||
size_t nv=rec->basePos.size()/3;
|
||
// per-vertex bone idx (skin-bone→clip-joint) + normalized u8 weights
|
||
e.boneIdx.assign(nv*4,0); e.boneWgt.assign(nv*4,0);
|
||
for(size_t v=0;v<nv;v++){ float ws=0; for(int c=0;c<4;c++) ws+=rec->jw[v*4+c];
|
||
for(int c=0;c<4;c++){ int sb=rec->jidx[v*4+c]; int cj=(sb>=0&&sb<(int)rec->boneClip.size())?rec->boneClip[sb]:0; if(cj<0||cj>=nj)cj=0;
|
||
e.boneIdx[v*4+c]=(uint8_t)cj; float w=ws>1e-6f?rec->jw[v*4+c]/ws:(c==0?1.f:0.f); int iw=(int)(w*255.f+0.5f); e.boneWgt[v*4+c]=(uint8_t)(iw<0?0:iw>255?255:iw); } }
|
||
// jointBindWorld[j] = inverse(sr.invBind[bone mapping to j]); fallback identity for jointless joints
|
||
std::vector<float> bw((size_t)nj*16,0.f); std::vector<char> have(nj,0);
|
||
for(int b=0;b<rec->nJoints;b++){ int cj=(b<(int)rec->boneClip.size())?rec->boneClip[b]:-1;
|
||
if(cj<0||cj>=nj||have[cj]||(size_t)(b*16+16)>rec->invBind.size()) continue;
|
||
invAff(rec->invBind.data()+(size_t)b*16, bw.data()+(size_t)cj*16); have[cj]=1; }
|
||
for(int j=0;j<nj;j++) if(!have[j]){ float* m=bw.data()+(size_t)j*16; for(int k=0;k<16;k++)m[k]=0; m[0]=m[5]=m[10]=m[15]=1; }
|
||
// bind LOCAL (relative to parent joint) → jointPos/Quat(wxyz)/Scale
|
||
e.jointPos.resize(nj*3); e.jointQuat.resize(nj*4); e.jointScale.resize(nj);
|
||
for(int j=0;j<nj;j++){ float bl[16]; int p=e.parents[j];
|
||
if(p>=0&&p<nj){ float ip[16]; invAff(bw.data()+(size_t)p*16,ip); mul16(ip,bw.data()+(size_t)j*16,bl); }
|
||
else memcpy(bl,bw.data()+(size_t)j*16,64);
|
||
float q[4],t[3],s[3]; matTrs(bl,q,t,s);
|
||
e.jointPos[j*3]=t[0];e.jointPos[j*3+1]=t[1];e.jointPos[j*3+2]=t[2];
|
||
e.jointQuat[j*4]=q[3];e.jointQuat[j*4+1]=q[0];e.jointQuat[j*4+2]=q[1];e.jointQuat[j*4+3]=q[2];
|
||
e.jointScale[j]=(s[0]>1e-4f||s[0]<-1e-4f)?s[0]:1.f; }
|
||
// per-frame local TRS (clip.mats are already parent-local) → trsLocal {qx,qy,qz,qw, t3, s3}
|
||
e.trsLocal.resize((size_t)nf*nj*10);
|
||
for(int f=0;f<nf;f++)for(int j=0;j<nj;j++){ const float* m=clip.mats.data()+((size_t)f*nj+j)*16;
|
||
float q[4],t[3],s[3]; matTrs(m,q,t,s); float* o=e.trsLocal.data()+((size_t)f*nj+j)*10;
|
||
o[0]=q[0];o[1]=q[1];o[2]=q[2];o[3]=q[3]; o[4]=t[0];o[5]=t[1];o[6]=t[2]; o[7]=s[0];o[8]=s[1];o[9]=s[2]; }
|
||
// QUATERNION HEMISPHERE CONTINUITY (same fix as the rigid extractor): fast rotating joints
|
||
// (hummingbird wing flaps, windmill spins) cross polarity in matTrs -> long-arc lerp twitches.
|
||
for (int j = 0; j < nj; j++) for (int f = 1; f < nf; f++) {
|
||
float* q = e.trsLocal.data() + ((size_t)f*nj + j)*10; const float* p = e.trsLocal.data() + ((size_t)(f-1)*nj + j)*10;
|
||
if (q[0]*p[0]+q[1]*p[1]+q[2]*p[2]+q[3]*p[3] < 0.f) { q[0]=-q[0]; q[1]=-q[1]; q[2]=-q[2]; q[3]=-q[3]; }
|
||
}
|
||
// BUTTERFLY-PARITY (cook-only, renderer unaffected): transform restPos from world-bind space
|
||
// (vertices at entity world position E) to model-local (near origin), matching the official
|
||
// vista butterfly: RENDMESH verts are model-local, root bind = identity, ACL root TRS = world
|
||
// trajectory (for us = E, constant). The device computes skeleton-derived cull bounds by
|
||
// applying each joint's current world matrix to its per-joint vertex extent; world-bind verts
|
||
// cause a double-transform (bw[j] applied to verts already at E → extent at 2E), while
|
||
// model-local verts transform correctly (bw[j] * near-origin ≈ E). extractNodeRigidHzAnim
|
||
// already uses this pattern (restPos = node-LOCAL, bind = identity) and was device-proven.
|
||
if (nj == 1 && have[0]) {
|
||
// 1-JOINT skin whose WORLD placement may live in the BIND, not in the clip trajectory (a SPIN-IN-PLACE:
|
||
// bluehills VE_WESTERN windmill_blades_GEO — clip root T=(0,0,0), the -22 world offset is only in the
|
||
// inverse-bind). Butterfly-parity below (model-local verts + identity root bind + clip-carried world)
|
||
// ASSUMES the clip root carries the world trajectory; for a spin-in-place clip it left the mesh at
|
||
// inv(E)·basePos = model ORIGIN = SPAWN whenever the device was NOT actively driving the 1-joint clip
|
||
// (the "animatedGroup moved to spawn after cook" bug). ROBUST relative form — verts WORLD-baked into the
|
||
// frame-0 rest, identity bind, clip RELATIVE to frame-0 — reproduces the renderer EXACTLY when driven and
|
||
// lands at the correct WORLD rest when frozen/un-driven (identical to extractNodeRigidHzAnim's fix):
|
||
// R(f) = clip.mats[f][0]·invBind_rep ; restPos = R(0)·basePos ; trsLocal[f][0] = clip.mats[f][0]·inv(clip.mats[0][0])
|
||
// driven: trsLocal[f][0]·restPos = clip.mats[f][0]·invBind_rep·basePos = renderer (= old butterfly driven)
|
||
// frozen→identity: restPos = R(0)·basePos = the renderer's frame-0 WORLD rest (the FIX; butterfly gave model-origin)
|
||
float ib0[16]; invAff(bw.data(), ib0); // invBind_rep = inverse(bindWorld[0]) = inv(E)
|
||
const float* C0 = clip.mats.data(); // clip.mats[0][0] (root joint, frame 0)
|
||
float R0[16]; mul16(C0, ib0, R0); // R(0) = clip.mats[0][0]·invBind_rep
|
||
for (size_t v = 0; v+2 < e.restPos.size(); v += 3) { // verts -> frame-0 WORLD rest
|
||
float x=e.restPos[v], y=e.restPos[v+1], z=e.restPos[v+2];
|
||
e.restPos[v] = R0[0]*x+R0[4]*y+R0[8]*z +R0[12];
|
||
e.restPos[v+1] = R0[1]*x+R0[5]*y+R0[9]*z +R0[13];
|
||
e.restPos[v+2] = R0[2]*x+R0[6]*y+R0[10]*z+R0[14]; }
|
||
float invC0[16]; invAff(C0, invC0); // inv(clip.mats[0][0])
|
||
for (int f = 0; f < nf; ++f) { // root clip -> RELATIVE to frame-0 world rest
|
||
const float* Cf = clip.mats.data() + (size_t)f*16; // nj==1 -> joint 0 at frame f
|
||
float rel[16]; mul16(Cf, invC0, rel);
|
||
float q[4],t[3],s[3]; matTrs(rel,q,t,s); float* o=e.trsLocal.data()+(size_t)f*10;
|
||
o[0]=q[0];o[1]=q[1];o[2]=q[2];o[3]=q[3]; o[4]=t[0];o[5]=t[1];o[6]=t[2]; o[7]=s[0];o[8]=s[1];o[9]=s[2]; }
|
||
e.jointPos[0]=0.f;e.jointPos[1]=0.f;e.jointPos[2]=0.f;
|
||
e.jointQuat[0]=1.f;e.jointQuat[1]=0.f;e.jointQuat[2]=0.f;e.jointQuat[3]=0.f;
|
||
e.jointScale[0]=1.f;
|
||
}
|
||
// ROBUST nj>1 (rest-relative reskinning) — GENERALIZES the nj==1 fix above to MULTI-joint skins.
|
||
// The old butterfly-parity baked restPos = inv(E)·basePos (model-LOCAL, near origin). That is only
|
||
// safe when the device is actively DRIVING the clip; whenever it is NOT, the skinning falls back to
|
||
// IDENTITY and those verts render at model ORIGIN ≈ spawn (the bluehills chicken / owl / centralBanner
|
||
// "gray collapsed blob at spawn" bug — m164/m172/m173, whose skeleton roots are world-offset so
|
||
// inv(E)·basePos lands at origin; flags whose roots sit near origin happened to survive). FIX: bake
|
||
// restPos = the fully-skinned FRAME-0 WORLD pose (exactly the renderer's worldRest = Σ wᵢ·clipJointWorld(0)[cjᵢ]·invBind[bᵢ]·basePos),
|
||
// and set each joint's BIND world = clipJointWorld(0)[j] so the device skinMatrix(0)=I. Then:
|
||
// frozen→identity: vert = restPos = worldRest (correct WORLD placement — THE FIX)
|
||
// driven: skinMatrix(f)[j] = clipJointWorld(f)[j]·inv(clipJointWorld(0)[j])
|
||
// → single-bound vert(f) = clipJointWorld(f)·invBind·basePos = the renderer EXACTLY
|
||
// (multi-bound is the standard rest-relative LBS approximation, visually faithful).
|
||
// clip.mats are parent-LOCAL; renderer composes clipJointWorld[j]=clipJointWorld[parent]·clip.mats[j]
|
||
// (opa_loader animate() L864-871). Renderer path is untouched (it reads rec->basePos/invBind directly).
|
||
else if (nj > 1 && have[0]) {
|
||
std::vector<float> cw0((size_t)nj*16); // clipJointWorld(0)[j] (frame-0, world)
|
||
for (int j=0;j<nj;j++){ const float* L = clip.mats.data()+(size_t)j*16; // frame 0, joint j, parent-local
|
||
int p=e.parents[j];
|
||
if (p<0||p>=j) memcpy(cw0.data()+(size_t)j*16, L, 64);
|
||
else mul16(cw0.data()+(size_t)p*16, L, cw0.data()+(size_t)j*16); } // cw0[p]·L
|
||
std::vector<float> wr(e.restPos.size()); // worldRest = renderer's frame-0 skinning
|
||
size_t nvv = rec->basePos.size()/3;
|
||
for (size_t vi=0; vi<nvv; vi++){
|
||
float bx=rec->basePos[vi*3], by=rec->basePos[vi*3+1], bz=rec->basePos[vi*3+2];
|
||
float ox=0,oy=0,oz=0,ws=0;
|
||
for(int i=0;i<4;i++){ float w=rec->jw[vi*4+i]; int b=rec->jidx[vi*4+i];
|
||
if(w<=0.f||b<0||b>=rec->nJoints) continue;
|
||
int cj=(b<(int)rec->boneClip.size())?rec->boneClip[b]:-1; if(cj<0||cj>=nj) continue;
|
||
if((size_t)(b*16+16)>rec->invBind.size()) continue;
|
||
float M[16]; mul16(cw0.data()+(size_t)cj*16, rec->invBind.data()+(size_t)b*16, M); // skinMatrix(0)[b]
|
||
ox+=w*(M[0]*bx+M[4]*by+M[8]*bz+M[12]);
|
||
oy+=w*(M[1]*bx+M[5]*by+M[9]*bz+M[13]);
|
||
oz+=w*(M[2]*bx+M[6]*by+M[10]*bz+M[14]); ws+=w; }
|
||
if(ws<1e-4f){ox=bx;oy=by;oz=bz;} else if(ws<0.999f||ws>1.001f){ox/=ws;oy/=ws;oz/=ws;}
|
||
wr[vi*3]=ox; wr[vi*3+1]=oy; wr[vi*3+2]=oz; }
|
||
e.restPos.swap(wr);
|
||
// bindWorld[j] := clipJointWorld(0)[j] → recompute bind LOCAL TRS so device invBind[j]=inv(cw0[j]).
|
||
for(int j=0;j<nj;j++){ float bl[16]; int p=e.parents[j];
|
||
if(p>=0&&p<nj){ float ip[16]; invAff(cw0.data()+(size_t)p*16,ip); mul16(ip,cw0.data()+(size_t)j*16,bl); }
|
||
else memcpy(bl,cw0.data()+(size_t)j*16,64);
|
||
float q[4],t[3],s[3]; matTrs(bl,q,t,s);
|
||
e.jointPos[j*3]=t[0];e.jointPos[j*3+1]=t[1];e.jointPos[j*3+2]=t[2];
|
||
e.jointQuat[j*4]=q[3];e.jointQuat[j*4+1]=q[0];e.jointQuat[j*4+2]=q[1];e.jointQuat[j*4+3]=q[2];
|
||
e.jointScale[j]=(s[0]>1e-4f||s[0]<-1e-4f)?s[0]:1.f; }
|
||
// trsLocal already = absolute parent-local clip (emitted above), unchanged.
|
||
}
|
||
if (std::getenv("HSR_HZBRANCH")) {
|
||
const char* br = (nj==1&&have[0]) ? "nj1" : (nj>1&&have[0]) ? "nj>1-worldRest" : "DEFAULT(basePos)";
|
||
// how far did reskinning move verts from the raw pre-skin basePos? (0 => restPos==basePos == default/scattered)
|
||
double moved=0; size_t nvv=std::min(e.restPos.size(),rec->basePos.size())/3;
|
||
for(size_t v=0;v<nvv;v++){ double dx=e.restPos[v*3]-rec->basePos[v*3],dy=e.restPos[v*3+1]-rec->basePos[v*3+1],dz=e.restPos[v*3+2]-rec->basePos[v*3+2]; double d=dx*dx+dy*dy+dz*dz; if(d>moved)moved=d; }
|
||
fprintf(stderr,"[HZBRANCH] mesh%d nj=%d have0=%d branch=%s maxVertMovedFromBase=%.3f\n", meshIdx, nj, (int)have[0], br, std::sqrt(moved));
|
||
}
|
||
return e;
|
||
}
|
||
|
||
// ── COOK: NON-skinned node-TRANSLATION (cars/train) → a 1-JOINT RIGID HZANIM clip. Reuses the cook's HZANIM
|
||
// emitter so arbitrary node PATHS port faithfully (the rotation-fit only does pure spin/sway; translation was
|
||
// DROPPED → cars static on device). joint0 = the node; clip[f] = nodeWorldAnim[node] sampled per frame (WORLD
|
||
// transform); restPos = node-LOCAL basePos; bind = IDENTITY (invBind=identity → skinMatrix(f)=nodeWorldAnim(f)
|
||
// → vertex(f)=nodeWorldAnim(f)*basePos = the renderer's node anim). Returns !ok() if the node doesn't TRANSLATE
|
||
// (origin static) so pure spins keep the lighter getTime() Rodrigues path. project_hsl_opa_anim_port_plan.
|
||
OpaHzAnim extractNodeRigidHzAnim(int meshIdx, bool allowPureRotation = false) {
|
||
OpaHzAnim e;
|
||
if (animMaxFrames < 2) return e;
|
||
const AnimRec* ar=nullptr; for (auto& a : animRecs) if ((int)a.meshIdx==meshIdx){ ar=&a; break; }
|
||
if (!ar || ar->basePos.size() < 9) return e;
|
||
uint32_t node = ar->nodeIdx;
|
||
// PER-NODE loop period — THE faithful fix (matches the renderer; user-confirmed correct). The renderer loops
|
||
// each node track at its OWN nFrames (sampleTrack fmod), so a node's motion repeats every max(effFrames) over
|
||
// its own track + animating ancestors. The GLOBAL animMaxFrames (longest track in the scene = 577 here) is
|
||
// WRONG for a shorter-period node: a 436-frame track sampled over
|
||
// the 577-frame global duration => 1.32 periods => the world position WRAPS mid-clip (sawtooth) => the device
|
||
// loop reset jumps MID-TILE = "speed backward". Sampling over the node's OWN period => exactly one loop =>
|
||
// frame[last]≈frame[0] => seamless. (HSR_NOPERNODELOOP restores the old global behavior.)
|
||
int nodeEff = 0;
|
||
if (!std::getenv("HSR_NOPERNODELOOP"))
|
||
for (int g=0, n=(int)node; n>=0 && n<(int)animNodes.size() && g<32; n=animNodes[n].parent, ++g) {
|
||
auto it=nodeAnim.find(animNodes[n].name);
|
||
if(it!=nodeAnim.end()){ const NodeTracks&q=it->second;
|
||
nodeEff=std::max(nodeEff, std::max(q.t.effFrames, std::max(q.r.effFrames, q.s.effFrames))); }
|
||
}
|
||
if (nodeEff < 2) nodeEff = animMaxFrames; // fallback: no per-node track found -> old global behavior
|
||
float clipDur = (animFps > 0.f) ? (float)nodeEff / animFps : animDuration(); if (clipDur <= 0.f) return e;
|
||
// ALL frames: bake the node path at its FULL native resolution (one sample per source track frame) —
|
||
// FULL PORT, no default cap (rigid clips are small; the old 4096 was arbitrary). HSR_NODECAP opts in.
|
||
int cap = 0x7FFFFFFF; if (const char* e2=std::getenv("HSR_NODECAP")) { int c=atoi(e2); if (c>1) cap=c; }
|
||
int NF = nodeEff > cap ? cap : nodeEff; if (NF < 2) return e;
|
||
auto matTrs=[](const float* m,float* q,float* t,float* s){
|
||
t[0]=m[12];t[1]=m[13];t[2]=m[14];
|
||
s[0]=std::sqrt(m[0]*m[0]+m[1]*m[1]+m[2]*m[2]); s[1]=std::sqrt(m[4]*m[4]+m[5]*m[5]+m[6]*m[6]); s[2]=std::sqrt(m[8]*m[8]+m[9]*m[9]+m[10]*m[10]);
|
||
// det<0 = MIRRORED node (negative scale — e.g. the *_inside_* searchlight twins): all-positive sqrt
|
||
// scales force the rotation part to absorb the reflection = an IMPROPER matrix whose quaternion is
|
||
// GARBAGE → the mesh cooks rotated/positioned wrong ("out of place"). Negate ONE scale axis (x) so
|
||
// R = M·S⁻¹ is a proper rotation again.
|
||
float det = m[0]*(m[5]*m[10]-m[6]*m[9]) - m[4]*(m[1]*m[10]-m[2]*m[9]) + m[8]*(m[1]*m[6]-m[2]*m[5]);
|
||
if (det < 0.f) s[0] = -s[0];
|
||
float ix=std::fabs(s[0])>1e-8f?1/s[0]:0, iy=s[1]>1e-8f?1/s[1]:0, iz=s[2]>1e-8f?1/s[2]:0;
|
||
float r0=m[0]*ix,r1=m[1]*ix,r2=m[2]*ix, r3=m[4]*iy,r4=m[5]*iy,r5=m[6]*iy, r6=m[8]*iz,r7=m[9]*iz,r8=m[10]*iz;
|
||
float tr=r0+r4+r8;
|
||
if(tr>0){float S=std::sqrt(tr+1)*2;q[3]=0.25f*S;q[0]=(r5-r7)/S;q[1]=(r6-r2)/S;q[2]=(r1-r3)/S;}
|
||
else if(r0>r4&&r0>r8){float S=std::sqrt(1+r0-r4-r8)*2;q[3]=(r5-r7)/S;q[0]=0.25f*S;q[1]=(r3+r1)/S;q[2]=(r6+r2)/S;}
|
||
else if(r4>r8){float S=std::sqrt(1+r4-r0-r8)*2;q[3]=(r6-r2)/S;q[0]=(r3+r1)/S;q[1]=0.25f*S;q[2]=(r7+r5)/S;}
|
||
else{float S=std::sqrt(1+r8-r0-r4)*2;q[3]=(r1-r3)/S;q[0]=(r6+r2)/S;q[1]=(r7+r5)/S;q[2]=0.25f*S;} };
|
||
// EXCLUSIVE endpoint [0, clipDur): the cooker's loopWrap appends the seam frame (frame0 copy) for
|
||
// cyclic clips — an extractor-side inclusive endpoint DOUBLED it (a held frame every loop). Open
|
||
// paths keep the intentional teleport wrap (cars crossing tile seams).
|
||
std::vector<Mat4> mats((size_t)NF); float o0[3]={0,0,0}, maxd=0.f, rotMaxd=0.f; float rs0[9]={0}; Mat4 W0 = identity();
|
||
for (int f=0; f<NF; f++) { evalAnimNodes(clipDur * (float)f / (float)NF);
|
||
Mat4 w = (node < nodeWorldAnim.size()) ? nodeWorldAnim[node] : identity();
|
||
mats[f]=w; float tx=w.m[12],ty=w.m[13],tz=w.m[14];
|
||
float rs[9]={w.m[0],w.m[1],w.m[2], w.m[4],w.m[5],w.m[6], w.m[8],w.m[9],w.m[10]}; // upper 3x3 (rotation*scale)
|
||
if (f==0){ o0[0]=tx;o0[1]=ty;o0[2]=tz; W0=w; for(int k=0;k<9;k++) rs0[k]=rs[k]; }
|
||
else { float dx=tx-o0[0],dy=ty-o0[1],dz=tz-o0[2]; float d=std::sqrt(dx*dx+dy*dy+dz*dz); if(d>maxd)maxd=d;
|
||
float rd=0.f; for(int k=0;k<9;k++){ float e2=rs[k]-rs0[k]; rd+=e2*e2; } rd=std::sqrt(rd); if(rd>rotMaxd)rotMaxd=rd; } }
|
||
animate(0.f); // restore rest pose (geometry bake reads the renderer's GPU model, this just resets the loader)
|
||
// Bail if the node NEITHER translates NOR (when allowed) rotates/scales. Pure single-axis SPINS normally go to the
|
||
// lighter getTime Rodrigues path — BUT a TUMBLE (aurora_noise: w=0 flip-quaternions about a sweeping axis) can't
|
||
// be represented by a single-axis getTime spin, so a UV card with such rotation asks (allowPureRotation) to
|
||
// capture the EXACT per-frame node matrix here = faithful "warped mesh rotates about itself + bends texture".
|
||
if (maxd < 0.01f && !(allowPureRotation && rotMaxd > 0.02f)) return e;
|
||
if (std::getenv("HSR_VERBOSE")) {
|
||
std::string chain; int nodeEff=0;
|
||
for (int g=0,n=(int)node; n>=0 && n<(int)animNodes.size() && g<32; n=animNodes[n].parent,++g) {
|
||
auto it=nodeAnim.find(animNodes[n].name); int te=0,tn=0;
|
||
if(it!=nodeAnim.end()){ const NodeTracks&q=it->second;
|
||
te=std::max(q.t.effFrames,std::max(q.r.effFrames,q.s.effFrames)); tn=std::max(q.t.nFrames,std::max(q.r.nFrames,q.s.nFrames)); }
|
||
chain += animNodes[n].name+"(n"+std::to_string(tn)+"/e"+std::to_string(te)+") "; nodeEff=std::max(nodeEff,te);
|
||
}
|
||
fprintf(stderr,"[NODEANIM] mesh%d node=%u period=%d(nodeEff) globalMax=%d maxd=%.0f chain: %s\n", meshIdx, node, nodeEff, animMaxFrames, maxd, chain.c_str());
|
||
// RAW keyframe range of the node's OWN T/R/S tracks — is the huge sweep authored or a decode over-scale?
|
||
{ auto it=nodeAnim.find(animNodes[node].name);
|
||
if(it!=nodeAnim.end()){ const auto& q=it->second;
|
||
if(q.t.nFrames>0){ float mn[3]={1e30f,1e30f,1e30f},mx[3]={-1e30f,-1e30f,-1e30f};
|
||
for(int f=0;f<q.t.nFrames;f++)for(int c=0;c<3;c++){ float v=q.t.v[(size_t)f*3+c]; if(v<mn[c])mn[c]=v; if(v>mx[c])mx[c]=v; }
|
||
fprintf(stderr,"[TRACKRAW] node=%s T(%df) X[%.1f,%.1f] Y[%.1f,%.1f] Z[%.1f,%.1f] R.nf=%d S.nf=%d\n",
|
||
animNodes[node].name.c_str(), q.t.nFrames, mn[0],mx[0], mn[1],mx[1], mn[2],mx[2], q.r.nFrames, q.s.nFrames); }
|
||
else fprintf(stderr,"[TRACKRAW] node=%s NO T track (R.nf=%d S.nf=%d) -> sweep from rotation/scale/parent\n",
|
||
animNodes[node].name.c_str(), q.r.nFrames, q.s.nFrames); } }
|
||
}
|
||
size_t nv = ar->basePos.size()/3;
|
||
// fps over the DOWNSAMPLED frame count so the device loop = the REAL clipDur (the OPA analogue of the
|
||
// 70274aa warp-speed fix). NF<=64 frames are sampled INCLUSIVELY over [0,clipDur] (line ~414) = NF-1
|
||
// intervals, so fps MUST be (NF-1)/clipDur, NOT the global animFps. With animFps a >64-frame path played
|
||
// in (NF-1)/animFps s (e.g. 63/30=2.1s for a real 10s path = ~5x too fast). clipDur>0 + NF>=2 hold above.
|
||
// ── TWO JOINTS: a STATIC root (joint 0) + a MOVING child (joint 1) that carries the whole path. ──────────────
|
||
// THE "train/cars FROZEN on device" fix. A 1-joint clip put the entire node TRANSLATION on the ROOT joint, but
|
||
// V205's AnimatorPlatformComponent does ROOT-MOTION EXTRACTION (libshell strings applyRootMotion /
|
||
// forceRootMotionOnEntityTransform on AnimatorPlatformComponentTranslatorV10): the root joint's TRANSLATION is
|
||
// pulled OUT of the skin pose and applied to the ENTITY transform instead. We cook the entity STATIC, so the
|
||
// extracted translation goes nowhere → the mesh skins at its frame-0 rest every frame = FROZEN IN PLACE (while
|
||
// root-ROTATION clips — spinning discs/doors — animate fine, because root motion only diverts translation+yaw).
|
||
// GROUND TRUTH for the asymmetry: root-rotation cooks loop on device, root-translation cooks freeze. Putting the
|
||
// motion on a CHILD joint (root stays identity) means there is NO root motion to extract → the child bone skins
|
||
// the verts along the full faithful path. HSR_RIGID1JOINT restores the old 1-joint clip.
|
||
bool oneJoint = std::getenv("HSR_RIGID1JOINT") != nullptr;
|
||
e.frameCount=NF; e.fps = (float)NF / clipDur; // NF frames over [0,clipDur); the cooker's loopWrap appends the seam frame for cyclic clips
|
||
// BIND = IDENTITY (both joints at origin), clip = RELATIVE to frame-0 (W(t)·inv(W0)) on the MOVING joint, verts
|
||
// WORLD-baked into W0. invBind = inverse(bind) = identity, rest = W0·basePos, motion joint world = W(t)·inv(W0):
|
||
// driven: vert = W(t)·inv(W0) · (W0·basePos) = W(t)·basePos (correct full-path anim)
|
||
// frozen→identity: vert = I · (W0·basePos) = W0·basePos = world rest (placed correctly when un-driven)
|
||
// (Identical math to the old 1-joint clip, only the motion now lives on a child bone so it survives root-motion extraction.)
|
||
float invW0[16]; mat4affineInverse(W0.m, invW0);
|
||
// ── ACL-CLEAN clip (the "comet thrashes to nonsense positions on device" fix) ────────────────────────────────
|
||
// The old clip stored the mover joint's LOCAL transform as rel = W(f)·inv(W0) against an IDENTITY bind. For a
|
||
// node that ROTATES while offset from the origin (the comets orbit a distant pivot) that decomposition is
|
||
// numerically vicious: rel.translation blows up to THOUSANDS of units (m006 mover maxAbs=10592) with a rotation
|
||
// that exactly cancels it. ACL quantizes translation & rotation SEPARATELY, so a tiny rotation-quat error times
|
||
// the huge offset = wild ±1000u vertex thrash → the comet skins to garbage positions (looked "not animated").
|
||
// FIX: store the mover clip-local as the node's ACTUAL world matrix W(f) (modest translation ~node position) and
|
||
// put W0 in the mover's BIND pose. The device then computes invBind = inv(W0), so skinMatrix = jointWorld(f)·invBind
|
||
// = W(f)·inv(W0) — the SAME skin matrix as before — but the encoded clip now holds small, ACL-friendly numbers.
|
||
// Un-driven fallback stays correct too: skinMatrix = bindWorld·invBind = W0·inv(W0) = I → vert = W0·basePos = world rest.
|
||
// HSR_RIGIDRELBIND restores the old identity-bind / rel-clip encoding.
|
||
float w0q[4], w0t[3], w0s[3]; matTrs(W0.m, w0q, w0t, w0s);
|
||
if (std::getenv("HSR_VERBOSE")) { // PROOF data for the bind: signed frame-0 scale + per-axis scale range over the loop
|
||
float smn[3]={1e30f,1e30f,1e30f}, smx[3]={-1e30f,-1e30f,-1e30f};
|
||
for (int f=0; f<NF; f++){ float q[4],t[3],s[3]; matTrs(mats[f].m,q,t,s);
|
||
for(int c=0;c<3;c++){ if(s[c]<smn[c])smn[c]=s[c]; if(s[c]>smx[c])smx[c]=s[c]; } }
|
||
fprintf(stderr,"[RIGIDBIND] mesh%d w0t=(%.1f,%.1f,%.1f) w0q=(%.3f,%.3f,%.3f,%.3f) w0s=(%.4f,%.4f,%.4f)%s sRange x[%.4f,%.4f] y[%.4f,%.4f] z[%.4f,%.4f]\n",
|
||
meshIdx, w0t[0],w0t[1],w0t[2], w0q[3],w0q[0],w0q[1],w0q[2], w0s[0],w0s[1],w0s[2],
|
||
(w0s[0]<0.f?" MIRRORED":""), smn[0],smx[0], smn[1],smx[1], smn[2],smx[2]);
|
||
}
|
||
const bool cleanBind = !oneJoint && !std::getenv("HSR_RIGIDRELBIND");
|
||
// BIND SCALE = 1 (the "beams out of place" fix). The device reconstructs invBind from the DECOMPOSED bind
|
||
// TRS with ONE UNIFORM scale per joint, and the ACL clip likewise carries ONE scale channel per joint. The
|
||
// old code baked rest/clip with the EXACT W0 (full per-axis scale) but bound with the uniform average w0su:
|
||
// for a node with NON-UNIFORM or NEGATIVE frame-0 scale (the color_beam stretch/mirror rigs) inv(bind)·W0≠I,
|
||
// and that constant error times the ~2400u node→verts lever = the beam rendered hundreds of units off.
|
||
// FIX: bind with UNIT scale (exactly representable) + store the clip's scale as the per-frame RATIO to the
|
||
// frame-0 scale (below) — constant-scale rigs then have ratio 1 everywhere: nothing for the device's single
|
||
// uniform channel to get wrong, and vert(f) = W(f)·inv(T0·R0)·(W0·basePos) = W(f)·basePos EXACTLY.
|
||
if (oneJoint) { e.jointCount=1; e.parents={-1}; e.jointPos={0,0,0}; e.jointQuat={1,0,0,0}; e.jointScale={1}; }
|
||
else if (cleanBind) { e.jointCount=2; e.parents={-1,0};
|
||
e.jointPos={0,0,0, w0t[0],w0t[1],w0t[2]};
|
||
e.jointQuat={1,0,0,0, w0q[3],w0q[0],w0q[1],w0q[2]}; // hzJointQuat is (w,x,y,z); matTrs q is (x,y,z,w)
|
||
e.jointScale={1, 1}; }
|
||
else { e.jointCount=2; e.parents={-1,0}; e.jointPos={0,0,0, 0,0,0}; e.jointQuat={1,0,0,0, 1,0,0,0}; e.jointScale={1,1}; }
|
||
const int nj = e.jointCount; // 1 (root only) or 2 (static root + moving child)
|
||
const int mj = nj - 1; // the MOVING joint index (child if 2-joint, else the root)
|
||
std::vector<float> trsv((size_t)NF*nj*10);
|
||
for (int f=0; f<NF; f++) {
|
||
float q[4],t[3],s[3];
|
||
if (cleanBind) { matTrs(mats[f].m, q, t, s);
|
||
// scale as the RATIO to frame-0: the frame-0 per-axis (possibly negative/non-uniform)
|
||
// scale is already IN the world-baked rest verts (rest = W0·basePos), so the clip only
|
||
// carries the VARIATION. Constant-scale rigs => ratio 1 on every axis => the device's
|
||
// single uniform scale channel is exact. vert(f) = T(f)R(f)·(s(f)/s0)·s0·basePos = W(f)·basePos.
|
||
for (int c=0;c<3;c++) s[c] = (std::fabs(w0s[c])>1e-8f) ? s[c]/w0s[c] : 1.f; }
|
||
else { float rel[16]; mat4mul(mats[f].m, invW0, rel); matTrs(rel, q, t, s); } // legacy rel = W(f)·inv(W0)
|
||
if (nj==2) { float* r=trsv.data()+(size_t)(f*nj+0)*10; r[0]=0;r[1]=0;r[2]=0;r[3]=1; r[4]=0;r[5]=0;r[6]=0; r[7]=1;r[8]=1;r[9]=1; } // joint0 = STATIC identity (no root motion)
|
||
float* p=trsv.data()+(size_t)(f*nj+mj)*10; p[0]=q[0];p[1]=q[1];p[2]=q[2];p[3]=q[3]; p[4]=t[0];p[5]=t[1];p[6]=t[2]; p[7]=s[0];p[8]=s[1];p[9]=s[2];
|
||
}
|
||
// QUATERNION HEMISPHERE CONTINUITY (the "very shaky" fix): matTrs emits q with an arbitrary sign per
|
||
// frame; a spin crossing 180 deg flips polarity mid-clip and the decoder's neighbor-lerp takes the LONG
|
||
// arc for one interval = a visible twitch every crossing. Keep each joint's quat in the same hemisphere
|
||
// as its previous frame (q and -q are the same rotation; lerp then always takes the short arc).
|
||
for (int j = 0; j < nj; j++) for (int f = 1; f < NF; f++) {
|
||
float* q = &trsv[((size_t)f*nj + j)*10]; const float* p = &trsv[((size_t)(f-1)*nj + j)*10];
|
||
if (q[0]*p[0]+q[1]*p[1]+q[2]*p[2]+q[3]*p[3] < 0.f) { q[0]=-q[0]; q[1]=-q[1]; q[2]=-q[2]; q[3]=-q[3]; }
|
||
}
|
||
e.restPos.resize(nv*3); // verts WORLD-baked into the frame-0 rest (model space == world rest)
|
||
for (size_t v=0; v<nv; v++) { const float* b=&ar->basePos[v*3]; float* o=&e.restPos[v*3];
|
||
o[0]=W0.m[0]*b[0]+W0.m[4]*b[1]+W0.m[8]*b[2]+W0.m[12];
|
||
o[1]=W0.m[1]*b[0]+W0.m[5]*b[1]+W0.m[9]*b[2]+W0.m[13];
|
||
o[2]=W0.m[2]*b[0]+W0.m[6]*b[1]+W0.m[10]*b[2]+W0.m[14]; }
|
||
e.boneIdx.assign(nv*4,0); e.boneWgt.assign(nv*4,0); for (size_t v=0;v<nv;v++){ e.boneIdx[v*4]=(uint8_t)mj; e.boneWgt[v*4]=255; } // weight every vert to the MOVING joint
|
||
e.trsLocal = std::move(trsv);
|
||
return e;
|
||
}
|
||
|
||
// ── COOK: per-frame ROTATION ANGLE replay (the aurora's NON-UNIFORM spin) for the shadergen ROTREPLAY vertex shader.
|
||
// Samples the node's WORLD rotation over its own loop, derives a fixed axis + the signed angle-about-axis relative
|
||
// to frame 0 (UNWRAPPED = continuous), + the pivot (node world origin). A getTime VERTEX shader then replays this:
|
||
// faithful non-uniform rotation, perfectly SMOOTH, and NOT throttled by the skeletal animator's LOD on the big
|
||
// background sky dome (the device-judder fix). Returns false if the node doesn't actually rotate. ──
|
||
bool cookExtractRotationReplay(int meshIdx, int N, float pivotOut[3], std::vector<float>& quats, float& loopSec) {
|
||
quats.clear();
|
||
const AnimRec* ar=nullptr; for (auto& a:animRecs) if((int)a.meshIdx==meshIdx){ar=&a;break;}
|
||
if (!ar) return false;
|
||
uint32_t node = ar->nodeIdx; if (node >= animNodes.size()) return false;
|
||
int nodeEff=0;
|
||
for (int g=0,n=(int)node; n>=0 && n<(int)animNodes.size() && g<32; n=animNodes[n].parent,++g){
|
||
auto it=nodeAnim.find(animNodes[n].name);
|
||
if(it!=nodeAnim.end()){const NodeTracks&q=it->second; nodeEff=std::max(nodeEff,std::max(q.t.effFrames,std::max(q.r.effFrames,q.s.effFrames)));}
|
||
}
|
||
if (nodeEff<2) nodeEff=animMaxFrames;
|
||
loopSec = (animFps>0.f)?(float)nodeEff/animFps:animDuration(); if (loopSec<=1e-4f) return false;
|
||
if (N<2) N=2;
|
||
auto matQuat=[](const float* m, float* q){
|
||
float sx=std::sqrt(m[0]*m[0]+m[1]*m[1]+m[2]*m[2]), sy=std::sqrt(m[4]*m[4]+m[5]*m[5]+m[6]*m[6]), sz=std::sqrt(m[8]*m[8]+m[9]*m[9]+m[10]*m[10]);
|
||
float ix=sx>1e-8f?1/sx:0, iy=sy>1e-8f?1/sy:0, iz=sz>1e-8f?1/sz:0;
|
||
float r0=m[0]*ix,r1=m[1]*ix,r2=m[2]*ix, r3=m[4]*iy,r4=m[5]*iy,r5=m[6]*iy, r6=m[8]*iz,r7=m[9]*iz,r8=m[10]*iz;
|
||
float tr=r0+r4+r8;
|
||
if(tr>0){float S=std::sqrt(tr+1)*2;q[3]=0.25f*S;q[0]=(r5-r7)/S;q[1]=(r6-r2)/S;q[2]=(r1-r3)/S;}
|
||
else if(r0>r4&&r0>r8){float S=std::sqrt(1+r0-r4-r8)*2;q[3]=(r5-r7)/S;q[0]=0.25f*S;q[1]=(r3+r1)/S;q[2]=(r6+r2)/S;}
|
||
else if(r4>r8){float S=std::sqrt(1+r4-r0-r8)*2;q[3]=(r6-r2)/S;q[0]=(r3+r1)/S;q[1]=0.25f*S;q[2]=(r7+r5)/S;}
|
||
else{float S=std::sqrt(1+r8-r0-r4)*2;q[3]=(r1-r3)/S;q[0]=(r6+r2)/S;q[1]=(r7+r5)/S;q[2]=0.25f*S;} };
|
||
evalAnimNodes(0.f);
|
||
Mat4 W0 = (node<nodeWorldAnim.size())?nodeWorldAnim[node]:identity();
|
||
pivotOut[0]=W0.m[12]; pivotOut[1]=W0.m[13]; pivotOut[2]=W0.m[14];
|
||
float q0[4]; matQuat(W0.m,q0); float c0[4]={-q0[0],-q0[1],-q0[2],q0[3]}; // conj(q0)
|
||
quats.resize((size_t)(N+1)*4); float bestMag=0.f;
|
||
for (int f=0; f<=N; f++){
|
||
evalAnimNodes(loopSec*(float)f/(float)N);
|
||
Mat4 W=(node<nodeWorldAnim.size())?nodeWorldAnim[node]:identity();
|
||
float q[4]; matQuat(W.m,q); float r[4]; // rel = q * conj(q0) (xyzw) = EXACT per-frame relative rotation
|
||
r[0]= q[3]*c0[0] + q[0]*c0[3] + q[1]*c0[2] - q[2]*c0[1];
|
||
r[1]= q[3]*c0[1] - q[0]*c0[2] + q[1]*c0[3] + q[2]*c0[0];
|
||
r[2]= q[3]*c0[2] + q[0]*c0[1] - q[1]*c0[0] + q[2]*c0[3];
|
||
r[3]= q[3]*c0[3] - q[0]*c0[0] - q[1]*c0[1] - q[2]*c0[2];
|
||
// NO handedness flip: rel = q*conj(q0) as-is IS the renderer's rotation. The old default FLIP
|
||
// (conjugate) made the cooked dome spin OPPOSITE the source, so the data UV counter-scroll ADDED
|
||
// instead of cancelling -> the aurora texture swept ~half the sky by mid-loop (verified by
|
||
// pinned-time captures: no-flip t=20 == source t=20 exactly; flipped drifted 2x0.24 widths).
|
||
if (std::getenv("HSR_ROTFLIP")) { r[0]=-r[0]; r[1]=-r[1]; r[2]=-r[2]; } // opt-in for A/B testing only
|
||
// sign-continuity: keep each quat in the same hemisphere as the previous so the shader nlerp takes the short arc
|
||
if (f>0){ float* p=&quats[(size_t)(f-1)*4]; if (r[0]*p[0]+r[1]*p[1]+r[2]*p[2]+r[3]*p[3] < 0){ r[0]=-r[0];r[1]=-r[1];r[2]=-r[2];r[3]=-r[3]; } }
|
||
float* o=&quats[(size_t)f*4]; o[0]=r[0];o[1]=r[1];o[2]=r[2];o[3]=r[3];
|
||
float mag=std::sqrt(r[0]*r[0]+r[1]*r[1]+r[2]*r[2]); if (mag>bestMag) bestMag=mag;
|
||
}
|
||
evalAnimNodes(0.f); // restore rest
|
||
if (bestMag < 0.02f) { quats.clear(); return false; } // node doesn't actually rotate
|
||
if (std::getenv("HSR_ROTDUMP")) { // diagnose tumble-vs-spin: print axis+angle of the sampled relative rotation
|
||
fprintf(stderr,"[ROTDUMP] mesh %d node '%s' loop=%.1fs pivot=(%.1f,%.1f,%.1f) N=%d\n",
|
||
meshIdx, (node<animNodes.size()?animNodes[node].name.c_str():"?"), loopSec, pivotOut[0],pivotOut[1],pivotOut[2], N);
|
||
for (int f=0; f<=N; f+=std::max(1,N/12)){
|
||
float* q=&quats[(size_t)f*4]; float w=q[3]; if(w>1)w=1; if(w<-1)w=-1;
|
||
float ang=2.f*std::acos(w)*57.2958f; float s=std::sqrt(1-w*w); float ax=0,ay=0,az=0;
|
||
if(s>1e-5f){ax=q[0]/s;ay=q[1]/s;az=q[2]/s;}
|
||
fprintf(stderr," f%3d ang=%6.1f deg axis=(%+.2f,%+.2f,%+.2f)\n", f, ang, ax,ay,az);
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ── COOK: node TRANSLATION (cars/train) → the NET world displacement over the clip, for a ShellPoseAnimationComponent
|
||
// (the FAITHFUL, device-proven V79→V205 node-anim port — NOT a 1-joint skin, which the device's MeshDefinition::fix
|
||
// REJECTS as a degenerate maxBoneIdx=0 skin → "flatbuffer verification failed" → no render). Mesh stays STATIC
|
||
// (valid), the entity pose lerps rest→rest+delta. Returns false if the node doesn't translate (pure spin keeps
|
||
// the getTime() Rodrigues path). delta = the MAX origin displacement over the clip. ──
|
||
bool extractNodeTranslate(int meshIdx, float delta[3]) {
|
||
delta[0]=delta[1]=delta[2]=0.f;
|
||
if (animMaxFrames < 2) return false;
|
||
const AnimRec* ar=nullptr; for (auto& a : animRecs) if ((int)a.meshIdx==meshIdx){ ar=&a; break; }
|
||
if (!ar) return false;
|
||
uint32_t node=ar->nodeIdx; float cd=animDuration(); if (cd<=0.f) return false;
|
||
evalAnimNodes(0.f);
|
||
float o0[3]={0,0,0}; if (node<nodeWorldAnim.size()){ o0[0]=nodeWorldAnim[node].m[12]; o0[1]=nodeWorldAnim[node].m[13]; o0[2]=nodeWorldAnim[node].m[14]; }
|
||
float best[3]={0,0,0}, bestd=0.f; const int NS=24;
|
||
for (int f=1; f<=NS; f++) { evalAnimNodes(cd*(float)f/(float)NS);
|
||
if (node>=nodeWorldAnim.size()) continue;
|
||
float dx=nodeWorldAnim[node].m[12]-o0[0], dy=nodeWorldAnim[node].m[13]-o0[1], dz=nodeWorldAnim[node].m[14]-o0[2];
|
||
float d=dx*dx+dy*dy+dz*dz; if (d>bestd){ bestd=d; best[0]=dx;best[1]=dy;best[2]=dz; } }
|
||
animate(0.f);
|
||
if (bestd < 1e-4f) return false;
|
||
delta[0]=best[0]; delta[1]=best[1]; delta[2]=best[2];
|
||
return true;
|
||
}
|
||
|
||
// ── COOK: node SCALE "breathe" (NON-UNIFORM per-axis) → N per-axis FACTOR frames (= scale(t)/scale(0), frame0=(1,1,1))
|
||
// sampled over the SCALE track's OWN length, for a shadergen::SCALE getTime() shader. pivot = node WORLD origin
|
||
// (the shader scales in place). Scale is a LOCAL channel, so the factor needs only the node's own scale track.
|
||
// Returns false if the node has no scale track or it doesn't actually breathe (matches the glTF extractNodeScaleFrames). ──
|
||
bool cookExtractNodeScaleFrames(int meshIdx, int N, std::vector<float>& frameFactors, float& loopSec, float pivot[3]) {
|
||
frameFactors.clear(); loopSec = 0.f; pivot[0]=pivot[1]=pivot[2]=0.f;
|
||
if (animMaxFrames < 2 || N < 2) return false;
|
||
const AnimRec* ar=nullptr; for (auto& a : animRecs) if ((int)a.meshIdx==meshIdx){ ar=&a; break; }
|
||
if (!ar) return false;
|
||
uint32_t node=ar->nodeIdx; if (node >= animNodes.size()) return false;
|
||
auto it = nodeAnim.find(animNodes[node].name);
|
||
if (it == nodeAnim.end() || it->second.s.nFrames <= 1 || it->second.s.comps != 3) return false; // no real scale track
|
||
const Track& st = it->second.s;
|
||
// loopSec = the SCALE track's OWN length (frames/fps), like the glTF per-clip-duration. animFps = the engine rate.
|
||
loopSec = (animFps > 0.f) ? (float)st.nFrames / animFps : 0.f;
|
||
if (loopSec <= 1e-4f) loopSec = animDuration();
|
||
if (loopSec <= 1e-4f) return false;
|
||
float base[3]={animNodes[node].s[0],animNodes[node].s[1],animNodes[node].s[2]};
|
||
sampleTrack(st, 0.f, animFps, 3, base); // t=0 baked scale (clamped first frame)
|
||
for (int c=0;c<3;c++) if (base[c]==0.f) base[c]=1.f;
|
||
frameFactors.assign((size_t)N*3, 1.f); float maxDev=0.f;
|
||
for (int fi=0; fi<N; ++fi){ float s[3]={base[0],base[1],base[2]};
|
||
sampleTrack(st, loopSec*(float)fi/(float)N, animFps, 3, s);
|
||
for (int c=0;c<3;c++){ float f=s[c]/base[c]; frameFactors[(size_t)fi*3+c]=f; float d=f>1.f?f-1.f:1.f-f; if(d>maxDev)maxDev=d; } }
|
||
evalAnimNodes(0.f); // rest pose -> node world origin = pivot
|
||
if (node < nodeWorldAnim.size()){ pivot[0]=nodeWorldAnim[node].m[12]; pivot[1]=nodeWorldAnim[node].m[13]; pivot[2]=nodeWorldAnim[node].m[14]; }
|
||
animate(0.f);
|
||
return maxDev > 1e-3f; // actually breathes
|
||
}
|
||
|
||
// COOK: does this mesh's node (or any animating ancestor) actually ROTATE or SCALE (beyond identity)? Effect cards
|
||
// (fog/dust) with a UV flipbook AND a node R/S can't be faithfully ported by the getTime translate shader (it only
|
||
// translates; a hierarchical/rotating/far-pivot scale would FLING the card). Such cards take the RIGID-HZANIM path
|
||
// (exact per-frame WORLD matrix via a skeleton) WITH the UV/fade baked into the SKINNED material shader. Pure-translate
|
||
// cards stay on the lighter getTime translate+flipbook+fade path. ── returns true iff a real R or S track is present.
|
||
bool nodeAnimatesRotOrScale(int meshIdx) {
|
||
const AnimRec* ar=nullptr; for (auto& a : animRecs) if ((int)a.meshIdx==meshIdx){ ar=&a; break; }
|
||
if (!ar) return false;
|
||
for (int n=(int)ar->nodeIdx, g=0; n>=0 && n<(int)animNodes.size() && g<32; n=animNodes[n].parent, ++g) {
|
||
auto it = nodeAnim.find(animNodes[n].name);
|
||
if (it == nodeAnim.end()) continue;
|
||
const NodeTracks& q = it->second;
|
||
if (q.s.nFrames>1 && q.s.comps==3) { // scale breathe (per-axis range > 2%)
|
||
float mn[3]={1e9f,1e9f,1e9f}, mx[3]={-1e9f,-1e9f,-1e9f};
|
||
for (int f=0; f<q.s.nFrames; ++f) for (int c=0;c<3;c++){ float v=q.s.v[(size_t)f*3+c]; if(v<mn[c])mn[c]=v; if(v>mx[c])mx[c]=v; }
|
||
for (int c=0;c<3;c++) if (mx[c]-mn[c] > 0.02f) return true;
|
||
}
|
||
if (q.r.nFrames>1 && q.r.comps==4) { // rotation quaternion actually varies
|
||
float maxd=0.f; for (int f=1; f<q.r.nFrames; ++f){ float d=0.f; for(int c=0;c<4;c++){ float e=q.r.v[(size_t)f*4+c]-q.r.v[c]; d+=e*e; } if(d>maxd)maxd=d; }
|
||
if (maxd > 1e-3f) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
// node index of a node-animated mesh (-1 if none), and a node's parent — so the cook can find the TRAIN BODY (the
|
||
// direct PARENT node of the train STEAM's node) and route it to the same getTime() clock so the two stay attached.
|
||
int animNodeOf(int meshIdx) const { for (auto& a:animRecs) if ((int)a.meshIdx==meshIdx) return (int)a.nodeIdx; return -1; }
|
||
int animNodeParentOf(int node) const { return (node>=0 && node<(int)animNodes.size()) ? animNodes[node].parent : -1; }
|
||
const char* animNodeNameOf(int meshIdx) const {
|
||
int node = animNodeOf(meshIdx);
|
||
return (node >= 0 && node < (int)animNodes.size()) ? animNodes[node].name.c_str() : "";
|
||
}
|
||
bool animNodeNamed(int meshIdx, const char* name) const {
|
||
int node = animNodeOf(meshIdx);
|
||
return name && node >= 0 && node < (int)animNodes.size() && animNodes[node].name == name;
|
||
}
|
||
|
||
// ── COOK: node TRANSLATION (train STEAM / drifting FOG) → N WORLD-space OFFSET frames (delta from the frame-0 world
|
||
// position, frame0=(0,0,0)) sampled over the node's OWN period, for a shadergen::TRANSLATE getTime() shader. The
|
||
// device-PROVEN self-looping getTime() VERTEX family (same as ROTATE/SCALE) — used to give a flipbook card its node
|
||
// motion WITHOUT the skinned (useHz) path that froze the texture. Verts stay world-baked; the shader adds offset(t).
|
||
// Mirrors the glTF extractNodeTranslation (WORLD delta so the parent scale is honored). Returns false if it doesn't move. ──
|
||
bool cookExtractNodeTranslateFrames(int meshIdx, int N, std::vector<float>& frameOffs, float& loopSec, float forceLoopSec=-1.f) {
|
||
frameOffs.clear(); loopSec = 0.f;
|
||
if (animMaxFrames < 2 || N < 2) return false;
|
||
const AnimRec* ar=nullptr; for (auto& a : animRecs) if ((int)a.meshIdx==meshIdx){ ar=&a; break; }
|
||
if (!ar) return false;
|
||
uint32_t node=ar->nodeIdx; if (node >= animNodes.size()) return false;
|
||
// PER-NODE period (matches the renderer's per-track loop + extractNodeRigidHzAnim): max effFrames up the chain.
|
||
int nodeEff=0;
|
||
if (!std::getenv("HSR_NOPERNODELOOP"))
|
||
for (int g=0,n=(int)node; n>=0 && n<(int)animNodes.size() && g<32; n=animNodes[n].parent,++g){
|
||
auto it=nodeAnim.find(animNodes[n].name);
|
||
if(it!=nodeAnim.end()){ const NodeTracks&q=it->second;
|
||
nodeEff=std::max(nodeEff, std::max(q.t.effFrames, std::max(q.r.effFrames, q.s.effFrames))); } }
|
||
if (nodeEff < 2) nodeEff = animMaxFrames;
|
||
loopSec = (animFps>0.f) ? (float)nodeEff/animFps : animDuration();
|
||
// SYNC: effect cards (fog/dust) drive UV-flipbook + fade + movement off ONE mat.sanim clock in V79 — pass the
|
||
// mat.sanim loopSec here so the node MOVEMENT is sampled over the SAME period (evalAnimNodes still loops the
|
||
// node's own track internally), locking movement to the fade/flipbook. Fixes desync ("moves too fast / doesn't
|
||
// follow its animation" when translate looped at 3.37s vs UV/fade 5.03s).
|
||
if (forceLoopSec > 1e-4f) loopSec = forceLoopSec;
|
||
if (loopSec <= 1e-4f) return false;
|
||
evalAnimNodes(0.f);
|
||
float w0[3]={0,0,0}; if (node<nodeWorldAnim.size()){ w0[0]=nodeWorldAnim[node].m[12]; w0[1]=nodeWorldAnim[node].m[13]; w0[2]=nodeWorldAnim[node].m[14]; }
|
||
frameOffs.assign((size_t)N*3, 0.f); float maxd=0.f;
|
||
for (int fi=0; fi<N; ++fi){ evalAnimNodes(loopSec*(float)fi/(float)N); // EXCLUSIVE endpoint [0,loopSec) -> one clean monotonic period
|
||
if (node>=nodeWorldAnim.size()) continue;
|
||
for(int c=0;c<3;c++){ float o=nodeWorldAnim[node].m[12+c]-w0[c]; frameOffs[(size_t)fi*3+c]=o; float ad=o<0?-o:o; if(ad>maxd)maxd=ad; } }
|
||
// DESPIKE lone "there-and-back" outliers: sampleTrack does LINEAR quaternion interp, which between two
|
||
// far-apart key rotations produces a WRONG intermediate orientation for a single sample -> one frame that
|
||
// flings a far-offset node (the comet's t2=-237 blip: t1->t2->t3 deltas point OPPOSITE ways). The desktop's
|
||
// 90fps continuous playback flickers past it, but a cooked N-frame getTime replay would show a teleport.
|
||
// Replace ONLY a frame whose in/out deltas oppose (a there-and-back reversal), both large -> neighbor midpoint.
|
||
// Smooth / monotonic / hold+teleport paths (aligned or ~zero deltas) are untouched. HSR_NODESPIKE disables.
|
||
if (!std::getenv("HSR_NODESPIKE")) for (int fi=1; fi+1<N; ++fi){
|
||
float* c=&frameOffs[(size_t)fi*3]; const float* p=&frameOffs[(size_t)(fi-1)*3]; const float* n=&frameOffs[(size_t)(fi+1)*3];
|
||
float d1[3]={c[0]-p[0],c[1]-p[1],c[2]-p[2]}, d2[3]={n[0]-c[0],n[1]-c[1],n[2]-c[2]};
|
||
float l1=std::sqrt(d1[0]*d1[0]+d1[1]*d1[1]+d1[2]*d1[2]), l2=std::sqrt(d2[0]*d2[0]+d2[1]*d2[1]+d2[2]*d2[2]);
|
||
float dot=d1[0]*d2[0]+d1[1]*d2[1]+d1[2]*d2[2];
|
||
if (l1 > 0.1f*maxd && l2 > 0.1f*maxd && dot < -0.3f*l1*l2) // clearly opposing (>~107 deg) + both large
|
||
{ c[0]=0.5f*(p[0]+n[0]); c[1]=0.5f*(p[1]+n[1]); c[2]=0.5f*(p[2]+n[2]); }
|
||
}
|
||
maxd=0.f; for (size_t k=0;k<frameOffs.size();++k){ float a=std::fabs(frameOffs[k]); if(a>maxd)maxd=a; } // recompute after despike
|
||
animate(0.f);
|
||
return maxd > 1e-3f; // actually translates (WORLD space)
|
||
}
|
||
// ── COOK: the mat.sanim MaterialTint ALPHA track (per-frame opacity, 0..~0.22 for fog/dust) — the FADE curve the
|
||
// fragment shader replays so the card fades IN/OUT over the loop (hiding a one-way node-translate's reset +
|
||
// underground tail). Returns all nFrames alpha values + the track's natural loop seconds. ──
|
||
bool cookExtractTintAlpha(int meshIdx, std::vector<float>& alpha, float& loopSec) {
|
||
alpha.clear(); loopSec = 0.f;
|
||
const TintRec* tr=nullptr; for (auto& r : tintRecs) if ((int)r.meshIdx==meshIdx){ tr=&r; break; }
|
||
if (!tr) return false;
|
||
auto it = matTintAnim.find(tr->node);
|
||
if (it == matTintAnim.end() || it->second.nFrames < 2) return false;
|
||
const TintTrack& tt = it->second;
|
||
alpha.resize(tt.nFrames);
|
||
float amax=0.f; for (int f=0;f<tt.nFrames;f++){ alpha[f]=tt.rgba[(size_t)f*4+3]; if(alpha[f]>amax)amax=alpha[f]; }
|
||
loopSec = (animFps>0.f) ? (float)tt.nFrames/animFps : 0.f;
|
||
return amax > 1e-4f && loopSec > 1e-4f; // a real (non-zero) fade
|
||
}
|
||
// ── COOK: the FULL mat.sanim MaterialTint RGBA track (per-frame color) — V79's tint COLOR cycling
|
||
// (stinson fireworks flash colors, city window-light flicker). The renderer plays this live (animate()
|
||
// -> md.curTint) so the preview was right, but the cook only ported the ALPHA subset as the flipbook
|
||
// fade -> device showed one static "generic" color. Subsampled to maxN (frame-SNAP replay; flashes are
|
||
// steps). Returns false when no track exists or nothing varies (static tint = the baked COLOR_0). ──
|
||
bool cookExtractTintRGBA(int meshIdx, int maxN, std::vector<float>& rgba, int& N, float& loopSec) {
|
||
rgba.clear(); N = 0; loopSec = 0.f;
|
||
const TintRec* tr = nullptr; for (auto& r : tintRecs) if ((int)r.meshIdx == meshIdx) { tr = &r; break; }
|
||
if (!tr || maxN < 2) return false;
|
||
auto it = matTintAnim.find(tr->node);
|
||
if (it == matTintAnim.end() || it->second.nFrames < 2) return false;
|
||
const TintTrack& tt = it->second;
|
||
loopSec = (animFps > 0.f) ? (float)tt.nFrames / animFps : 0.f;
|
||
if (loopSec <= 1e-4f) return false;
|
||
N = tt.nFrames <= maxN ? tt.nFrames : maxN;
|
||
rgba.assign((size_t)N * 4, 1.f);
|
||
float mn[4]={1e9f,1e9f,1e9f,1e9f}, mx[4]={-1e9f,-1e9f,-1e9f,-1e9f};
|
||
for (int i = 0; i < N; i++) {
|
||
int src = (tt.nFrames == N) ? i : (int)((double)i * tt.nFrames / (double)N); if (src >= tt.nFrames) src = tt.nFrames - 1;
|
||
for (int c = 0; c < 4; c++) { float v = tt.rgba[(size_t)src*4 + c]; rgba[(size_t)i*4 + c] = v; if (v<mn[c])mn[c]=v; if (v>mx[c])mx[c]=v; }
|
||
}
|
||
float dev = 0.f; for (int c = 0; c < 4; c++) { float d = mx[c]-mn[c]; if (d > dev) dev = d; }
|
||
return dev > 1e-3f; // actually cycles (any channel)
|
||
}
|
||
// ── COOK: bake a VAT (Vertex Animation Texture) mesh's per-frame WORLD-space offsets for the cooker's
|
||
// DEVICE-PROVEN useVat path (RGBA16 offset texture + vatunlitbasecolor/vatunlitblend, the Erebor-wisp
|
||
// pipeline). The underwater corals/seaweed/fish carry REAL source VAT data (t_*_vatdata.exr.opa) that the
|
||
// renderer plays via vatRecs, but the cook never consumed it → every VAT mesh cooked STATIC ("corals,
|
||
// fishes, etc most stuff ain't animating"). offset[f][v] = worldRot·(off[srcFrame(f)][col] − off[0][col]);
|
||
// the static geometry bake is the t=0 pose (animate(0) → base+off[0]) so the shader's `pos += offset(t)`
|
||
// reproduces the sway exactly, WORLD-space (the baked entity transform is identity). Frames are subsampled
|
||
// to `frames` over ONE loop (exclusive endpoint; the shader's time-fract wraps seamlessly like the source's
|
||
// own frame wrap). Returns empty if this mesh has no VAT record (or it's too wide for the offset texture).
|
||
std::vector<float> bakeVAT(int meshIdx, int frames, int& nvOut) {
|
||
nvOut = 0;
|
||
const VatRec* vr = nullptr;
|
||
for (auto& r : vatRecs) if ((int)r.meshIdx == meshIdx) { vr = &r; break; }
|
||
if (!vr || !vr->vd || vr->vd->frames < 2 || vr->vd->cols < 1 || frames < 2) return {};
|
||
const VatData& vd = *vr->vd;
|
||
size_t nv = vr->basePos.size() / 3;
|
||
if (nv < 1 || nv > 8192) return {}; // offset-texture width = vertex count; keep it GPU-sane
|
||
if (vr->col.size() < nv) return {};
|
||
nvOut = (int)nv;
|
||
// rotation(+scale) 3x3 of the instance world matrix — offsets are DIRECTION vectors (no translation)
|
||
const float* w = vr->world.m;
|
||
std::vector<float> out((size_t)frames * nv * 3);
|
||
for (int f = 0; f < frames; ++f) {
|
||
float sf = (float)f * (float)vd.frames / (float)frames; // [0, vd.frames) exclusive — one clean loop
|
||
int i0 = (int)sf; float frac = sf - (float)i0;
|
||
if (i0 >= vd.frames) { i0 = vd.frames - 1; frac = 0.f; }
|
||
int i1 = (i0 + 1 < vd.frames) ? i0 + 1 : 0; // wrap = seamless loop (matches animate())
|
||
for (size_t v = 0; v < nv; ++v) {
|
||
int col = vr->col[v]; if (col < 0 || col >= vd.cols) col = 0;
|
||
const float* o0 = &vd.off[((size_t)i0 * vd.cols + col) * 3];
|
||
const float* o1 = &vd.off[((size_t)i1 * vd.cols + col) * 3];
|
||
const float* z0 = &vd.off[(size_t)col * 3]; // frame 0 = the static bake pose
|
||
float dx = o0[0]*(1.f-frac) + o1[0]*frac - z0[0];
|
||
float dy = o0[1]*(1.f-frac) + o1[1]*frac - z0[1];
|
||
float dz = o0[2]*(1.f-frac) + o1[2]*frac - z0[2];
|
||
float* o = &out[((size_t)f * nv + v) * 3];
|
||
o[0] = w[0]*dx + w[4]*dy + w[8]*dz;
|
||
o[1] = w[1]*dx + w[5]*dy + w[9]*dz;
|
||
o[2] = w[2]*dx + w[6]*dy + w[10]*dz;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
// ── COOK: bake ANY node-animated mesh's EXACT per-frame WORLD motion as VAT offsets, by sampling the renderer's
|
||
// own evalAnimNodes(t) (the same path the OPA source draws with). Reproduces the motion BYTE-FOR-BYTE — no
|
||
// rigid-HZANIM restPos/clip/invBind decomposition (which amplified the stinson city color_beam ~4700u sweep so
|
||
// it flew out of the building). offset[f][v] = nodeWorld(t_f)·basePos[v] − nodeWorld(0)·basePos[v]; the static
|
||
// bake is frame 0 (animate(0)), so the device shader's pos += offset(t) == the source EXACTLY. ──
|
||
std::vector<float> bakeNodeAnimVAT(int meshIdx, int frames, int& nvOut, float& loopSec) {
|
||
nvOut = 0; loopSec = 0.f;
|
||
const AnimRec* ar = nullptr; for (auto& a : animRecs) if ((int)a.meshIdx == meshIdx) { ar = &a; break; }
|
||
if (!ar || ar->basePos.size() < 9 || frames < 2) return {};
|
||
size_t nv = ar->basePos.size()/3; if (nv < 1 || nv > 8192) return {};
|
||
uint32_t node = ar->nodeIdx;
|
||
int eff = 0; // per-node loop period (same as extractNodeRigidHzAnim / the renderer)
|
||
if (!std::getenv("HSR_NOPERNODELOOP"))
|
||
for (int g=0,n=(int)node; n>=0 && n<(int)animNodes.size() && g<32; n=animNodes[n].parent,++g){
|
||
auto it=nodeAnim.find(animNodes[n].name);
|
||
if(it!=nodeAnim.end()){ const NodeTracks&q=it->second; eff=std::max(eff, std::max(q.t.effFrames, std::max(q.r.effFrames, q.s.effFrames))); } }
|
||
if (eff < 2) eff = animMaxFrames;
|
||
float dur = (animFps>0.f) ? (float)eff/animFps : animDuration(); if (dur <= 0.f) return {};
|
||
loopSec = dur;
|
||
evalAnimNodes(0.f);
|
||
Mat4 w0 = (node < nodeWorldAnim.size()) ? nodeWorldAnim[node] : identity();
|
||
std::vector<float> p0(nv*3);
|
||
for (size_t v=0; v<nv; v++){ float o[3]; xform(w0, ar->basePos[v*3],ar->basePos[v*3+1],ar->basePos[v*3+2], o); p0[v*3]=o[0];p0[v*3+1]=o[1];p0[v*3+2]=o[2]; }
|
||
std::vector<float> out((size_t)frames*nv*3);
|
||
for (int f=0; f<frames; f++){
|
||
evalAnimNodes(dur * (float)f / (float)frames); // exclusive endpoint -> seamless loop
|
||
Mat4 w = (node < nodeWorldAnim.size()) ? nodeWorldAnim[node] : identity();
|
||
for (size_t v=0; v<nv; v++){ float o[3]; xform(w, ar->basePos[v*3],ar->basePos[v*3+1],ar->basePos[v*3+2], o);
|
||
out[((size_t)f*nv+v)*3] = o[0]-p0[v*3];
|
||
out[((size_t)f*nv+v)*3+1] = o[1]-p0[v*3+1];
|
||
out[((size_t)f*nv+v)*3+2] = o[2]-p0[v*3+2]; }
|
||
}
|
||
animate(0.f); // restore rest pose (the static geometry bake reads frame 0)
|
||
nvOut = (int)nv;
|
||
return out;
|
||
}
|
||
// ── COOK: batch-fit every node-animated mesh to a SPIN/SWAY about an axis (the V79->V203 port). Samples each
|
||
// animated mesh's WORLD positions across the clip via animate(t), runs the shared noderot::fit, and returns
|
||
// meshIdx -> Result. Leaves the meshes at REST (animate(0)) so the static geometry bake is the t=0 pose. The
|
||
// cooker's useRot path then ships a getTime() Rodrigues shader. (Node TRANSFORM anims = the bulk of OPA motion;
|
||
// UV-scroll / skinned / VAT are separate passes.) ──
|
||
void cookExtractRotations(std::unordered_map<size_t, noderot::Result>& out) {
|
||
out.clear();
|
||
if (animRecs.empty() || animMaxFrames < 2) return;
|
||
float globalDur = animDuration(); if (globalDur <= 0.f) return;
|
||
const int NS = 24;
|
||
const size_t budget = 48000000; // ≤ ~190MB of position floats in flight at once (no swap/softlock)
|
||
const size_t maxNv = budget / ((size_t)(NS+1)*3); // a single mesh bigger than this cooks static (huge meshes aren't the spinning ones)
|
||
// ── PER-NODE PERIOD (THE fix for the windmill_tails "in place but not moving" cook bug) ──────────────────────
|
||
// The fit MUST sample one clean rotation. Sampling a node over the GLOBAL clip (animMaxFrames=2501f/83.4s) when
|
||
// its OWN rotation period is short (windmill spin = 305f/~10s) packs ~8 turns into NS=24 samples → the angles
|
||
// alias and noderot::fit degenerates to a 4°/83s "sway" → the cooked getTime() shader barely moves on device
|
||
// (desktop preview animated fine because it drives the node directly). Sample each mesh over its node's OWN
|
||
// period (max track effFrames up its chain — identical to extractNodeRigidHzAnim/the renderer's per-node loop)
|
||
// → exactly one period → fit recovers the true SPIN. HSR_NOPERNODELOOP restores the old global behavior.
|
||
auto nodePeriodFrames = [&](uint32_t node)->int{
|
||
int eff=0;
|
||
if (!std::getenv("HSR_NOPERNODELOOP"))
|
||
for (int g=0,n=(int)node; n>=0 && n<(int)animNodes.size() && g<32; n=animNodes[n].parent,++g){
|
||
auto it=nodeAnim.find(animNodes[n].name);
|
||
if(it!=nodeAnim.end()){ const NodeTracks&q=it->second;
|
||
eff=std::max(eff, std::max(q.t.effFrames, std::max(q.r.effFrames, q.s.effFrames))); } }
|
||
if (eff < 2) eff = animMaxFrames;
|
||
return eff;
|
||
};
|
||
struct M { size_t idx; size_t nv; uint32_t node; const std::vector<float>* base; float pivot[3]; float dur; };
|
||
std::vector<M> ms; std::unordered_map<size_t,int> seen;
|
||
for (auto& ar : animRecs) { size_t nv = ar.basePos.size()/3; if (nv < 3 || nv > maxNv) continue; if (seen.count(ar.meshIdx)) continue;
|
||
seen[ar.meshIdx]=1; int eff=nodePeriodFrames(ar.nodeIdx); float dur=(animFps>0.f)?(float)eff/animFps:globalDur; if(dur<=0.f)dur=globalDur;
|
||
ms.push_back({ar.meshIdx, nv, ar.nodeIdx, &ar.basePos, {0,0,0}, dur}); }
|
||
if (ms.empty()) { animate(0.f); return; }
|
||
evalAnimNodes(0.f); // rest pose -> each node's world origin = its pivot
|
||
for (auto& m : ms) if (m.node < nodeWorldAnim.size()) { m.pivot[0]=nodeWorldAnim[m.node].m[12]; m.pivot[1]=nodeWorldAnim[m.node].m[13]; m.pivot[2]=nodeWorldAnim[m.node].m[14]; }
|
||
// Sample EACH mesh over its OWN period (per-mesh node eval — a handful of spin meshes × NS calls, cheap).
|
||
std::vector<std::vector<float>> fr(NS+1);
|
||
for (auto& m : ms) {
|
||
for (int f=0; f<=NS; f++) { evalAnimNodes(m.dur * (float)f / (float)NS);
|
||
Mat4 w=(m.node<nodeWorldAnim.size())?nodeWorldAnim[m.node]:identity();
|
||
fr[f].resize(m.nv*3);
|
||
for (size_t v=0; v<m.nv; v++){ float o[3]; xform(w,(*m.base)[v*3],(*m.base)[v*3+1],(*m.base)[v*3+2],o); fr[f][v*3]=o[0];fr[f][v*3+1]=o[1];fr[f][v*3+2]=o[2]; } }
|
||
std::vector<const float*> fp(NS+1); for (int f=0;f<=NS;f++) fp[f]=fr[f].data();
|
||
noderot::Result r = noderot::fit(fp, m.nv, m.pivot, m.dur); if (r.rotAnim) out[m.idx] = r;
|
||
if (std::getenv("HSR_ROTDIAG")) {
|
||
// DECISIVE diagnostic (exact eval path): node track frame counts + RAW swept angle of the
|
||
// farthest-from-pivot probe vertex over the SAMPLED period (m.dur) AND over the GLOBAL clip.
|
||
// Reveals whether a real spin is being aliased to a tiny "sway" by a wrong period.
|
||
auto rawSweep=[&](float dur)->std::pair<float,float>{ // {accumulated total, peak-from-frame0} radians
|
||
int ai=-1; float aR=0; const float* P0=nullptr;
|
||
std::vector<std::vector<float>> g(NS+1);
|
||
for (int f=0; f<=NS; f++){ evalAnimNodes(dur*(float)f/(float)NS);
|
||
Mat4 w=(m.node<nodeWorldAnim.size())?nodeWorldAnim[m.node]:identity(); g[f].resize(m.nv*3);
|
||
for (size_t v=0; v<m.nv; v++){ float o[3]; xform(w,(*m.base)[v*3],(*m.base)[v*3+1],(*m.base)[v*3+2],o); g[f][v*3]=o[0];g[f][v*3+1]=o[1];g[f][v*3+2]=o[2]; } }
|
||
P0=g[0].data();
|
||
for (size_t v=0;v<m.nv;v++){ float d[3]={P0[v*3]-m.pivot[0],P0[v*3+1]-m.pivot[1],P0[v*3+2]-m.pivot[2]}; float rr=d[0]*d[0]+d[1]*d[1]+d[2]*d[2]; if(rr>aR){aR=rr;ai=(int)v;} }
|
||
if (ai<0) return {0.f,0.f};
|
||
float ax[3]={0,1,0}; // estimate axis from angular momentum at fastest step
|
||
{ int ti=0; float bv=0; for(int f=0;f<NS;f++){ float dx=g[f+1][ai*3]-g[f][ai*3],dy=g[f+1][ai*3+1]-g[f][ai*3+1],dz=g[f+1][ai*3+2]-g[f][ai*3+2]; float s=dx*dx+dy*dy+dz*dz; if(s>bv){bv=s;ti=f;} }
|
||
double C[3]={0,0,0}; for(size_t v=0;v<m.nv;v++){C[0]+=g[ti][v*3];C[1]+=g[ti][v*3+1];C[2]+=g[ti][v*3+2];} C[0]/=m.nv;C[1]/=m.nv;C[2]/=m.nv;
|
||
double aa[3]={0,0,0}; for(size_t v=0;v<m.nv;v++){ float ri[3]={(float)(g[ti][v*3]-C[0]),(float)(g[ti][v*3+1]-C[1]),(float)(g[ti][v*3+2]-C[2])}, vi[3]={g[ti+1][v*3]-g[ti][v*3],g[ti+1][v*3+1]-g[ti][v*3+1],g[ti+1][v*3+2]-g[ti][v*3+2]}; aa[0]+=ri[1]*vi[2]-ri[2]*vi[1];aa[1]+=ri[2]*vi[0]-ri[0]*vi[2];aa[2]+=ri[0]*vi[1]-ri[1]*vi[0]; }
|
||
float l=(float)std::sqrt(aa[0]*aa[0]+aa[1]*aa[1]+aa[2]*aa[2]); if(l>1e-9f){ax[0]=(float)(aa[0]/l);ax[1]=(float)(aa[1]/l);ax[2]=(float)(aa[2]/l);} }
|
||
auto sang=[&](const float* a,const float* b){ float da=a[0]*ax[0]+a[1]*ax[1]+a[2]*ax[2], db=b[0]*ax[0]+b[1]*ax[1]+b[2]*ax[2]; float pa[3]={a[0]-da*ax[0],a[1]-da*ax[1],a[2]-da*ax[2]},pb[3]={b[0]-db*ax[0],b[1]-db*ax[1],b[2]-db*ax[2]}; float c[3]={pa[1]*pb[2]-pa[2]*pb[1],pa[2]*pb[0]-pa[0]*pb[2],pa[0]*pb[1]-pa[1]*pb[0]}; return std::atan2(c[0]*ax[0]+c[1]*ax[1]+c[2]*ax[2], pa[0]*pb[0]+pa[1]*pb[1]+pa[2]*pb[2]); };
|
||
float tot=0,peak=0; float r0[3]={g[0][ai*3]-m.pivot[0],g[0][ai*3+1]-m.pivot[1],g[0][ai*3+2]-m.pivot[2]};
|
||
for(int f=1;f<=NS;f++){ float pr[3]={g[f-1][ai*3]-m.pivot[0],g[f-1][ai*3+1]-m.pivot[1],g[f-1][ai*3+2]-m.pivot[2]}, cu[3]={g[f][ai*3]-m.pivot[0],g[f][ai*3+1]-m.pivot[1],g[f][ai*3+2]-m.pivot[2]}; tot+=sang(pr,cu); float th=sang(r0,cu); if(std::fabs(th)>std::fabs(peak))peak=th; }
|
||
return {tot,peak};
|
||
};
|
||
std::string nm = (m.idx<meshes.size())?meshes[m.idx].name:std::string();
|
||
int rn=0,re=0; std::string chain;
|
||
for (int g2=0,n=(int)m.node; n>=0 && n<(int)animNodes.size() && g2<32; n=animNodes[n].parent,++g2){
|
||
auto it=nodeAnim.find(animNodes[n].name);
|
||
if(it!=nodeAnim.end()){ const NodeTracks&q=it->second; rn=std::max(rn,q.r.nFrames);re=std::max(re,q.r.effFrames);
|
||
chain += animNodes[n].name+"(r"+std::to_string(q.r.nFrames)+"/e"+std::to_string(q.r.effFrames)+") "; } }
|
||
auto pNode=rawSweep(m.dur); auto pGlob=rawSweep(globalDur);
|
||
fprintf(stderr,"[ROTDIAG] mesh%zu '%s' node=%u dur=%.2fs(rN=%d/rE=%d) FIT{osc=%d omega=%.3f amp=%.1fdeg per=%.2fs} | rawNode tot=%.1fdeg peak=%.1fdeg | rawGlobal(%.1fs) tot=%.1fdeg peak=%.1fdeg | chain: %s\n",
|
||
m.idx, nm.c_str(), m.node, m.dur, rn, re, (int)r.isOsc, r.omega, r.amp*57.2958f, r.period, pNode.first*57.2958f, pNode.second*57.2958f, globalDur, pGlob.first*57.2958f, pGlob.second*57.2958f, chain.c_str());
|
||
}
|
||
}
|
||
animate(0.f); // FULL restore (skinning/UV too) -> leave every mesh at rest for the geometry bake
|
||
}
|
||
|
||
// ── COOK: mat.sanim UV-SCROLL port. A UVTrack is a per-frame 2x3 affine [a,b,c,d,e,f]; a continuous SCROLL
|
||
// (water/foam/waterfall) keeps the 2x2 ~ identity and TRANSLATES (c,f). Derive the VISIBLE scroll rate the
|
||
// rate = avg per-frame UV delta * fps = UV/second (the source's literal visible speed) -> uvRate for a
|
||
// getTime() uv += rate*time shader. Flipbook atlases (2x2 = cell scale) are a separate pass -> skipped. ──
|
||
// FAITHFUL UV-scroll velocity (UV/sec) for one track: avg per-frame translation delta * animFps. Returns false
|
||
// for a FLIPBOOK atlas (2x2 != identity) or a still track. This is the SINGLE source of truth used by BOTH the
|
||
// cook (cookExtractUVScroll -> getTime() `uv += rate*time` device shader) AND the renderer's animate(), so the
|
||
// live preview scrolls IDENTICALLY to the cooked device. Direction + speed come straight off the animation track
|
||
// (water/foam/waterfall scroll exactly +1 UV tile; the lake spreads it over 10079 frames = a calm ~0.003 UV/s).
|
||
bool uvScrollRate(const UVTrack& tr, float& ru, float& rv) const {
|
||
ru = rv = 0.f;
|
||
if (tr.nFrames < 2 || animFps <= 0.f) return false;
|
||
const float* M0 = tr.m.data();
|
||
if (std::fabs(M0[0]-1.f)>0.05f || std::fabs(M0[4]-1.f)>0.05f || std::fabs(M0[1])>0.05f || std::fabs(M0[3])>0.05f) return false; // flipbook atlas, not a continuous scroll
|
||
// SWIRL detection (pond_color_rgCombo "water colors swirling" — user-proven): a UV ROTATION lives in
|
||
// the 2x2 part (a,b,d,e) and builds up over the loop while the translation stays ~0 — every earlier
|
||
// check here (and the frame dumps) only looked at the c,f translations and the FIRST frames, so the
|
||
// swirl track summarized as "scroll 0.001/s" = static. If the 2x2 deviates anywhere along the track,
|
||
// this is NOT a scroll: return false so the mesh routes to the exact per-frame UV-MATRIX REPLAY.
|
||
{ int step = tr.nFrames > 64 ? tr.nFrames/64 : 1;
|
||
for (int f=step; f<tr.nFrames; f+=step){ const float* M=&tr.m[(size_t)f*6];
|
||
if (std::fabs(M[0]-M0[0])>0.02f || std::fabs(M[1]-M0[1])>0.02f ||
|
||
std::fabs(M[3]-M0[3])>0.02f || std::fabs(M[4]-M0[4])>0.02f) return false; } }
|
||
// FULL track window (the rgCombo "water colors swirling" fix): the authored track is a u-translation
|
||
// OSCILLATION 0→0.01→0 over 439 frames; the old 256-frame cap saw mostly the RISING half, so the
|
||
// oscillation gate below never triggered and the slosh cooked as a linear 0.001/s drift (= static).
|
||
// Scanning the whole track costs microseconds and lets the gate see the deltas cancel.
|
||
int win = tr.nFrames - 1;
|
||
double sdu=0, sdv=0; int n=0; double sadu=0, sadv=0;
|
||
for (int f=0; f<win; f++) {
|
||
float dc = tr.m[(size_t)(f+1)*6+2] - tr.m[(size_t)f*6+2];
|
||
float df = tr.m[(size_t)(f+1)*6+5] - tr.m[(size_t)f*6+5];
|
||
if (std::fabs(dc)>0.5f || std::fabs(df)>0.5f) continue; // skip a wrap-around jump
|
||
sdu += dc; sdv += df; ++n;
|
||
sadu += std::fabs(dc); sadv += std::fabs(df);
|
||
}
|
||
if (n < 1) return false;
|
||
double avgDu = sdu / n, avgDv = sdv / n;
|
||
// OSCILLATION test (pond_color_rgCombo "aint animating" fix): a back-and-forth track (water slosh)
|
||
// has per-frame deltas that CANCEL — the signed mean ≈ 0 while the |delta| mean is real, and cooking
|
||
// that mean as a "scroll" freezes the animation (rate 0.001 UV/s = static). If most of the motion is
|
||
// NOT explained by the mean (|mean| << mean|delta|), this is NOT a constant scroll — return false so
|
||
// the mesh falls through to the exact per-frame UV-MATRIX REPLAY.
|
||
{ double meanMag = std::sqrt(avgDu*avgDu + avgDv*avgDv), absMag = (sadu + sadv) / n;
|
||
if (absMag > 1e-6 && meanMag < 0.25 * absMag) return false; }
|
||
if (std::fabs(avgDu) > 0.04 || std::fabs(avgDv) > 0.04) return false; // cell-stepping flipbook, not a scroll (texture names confirm: *_flipbook / *_spritesheet)
|
||
ru = (float)(avgDu * animFps); rv = (float)(avgDv * animFps); // UV/sec = the track's AUTHORED visible speed at the base fps
|
||
// FAITHFUL: libshell plays the mat.sanim UVTransform by feeding the per-frame 2x3 matrix into the vertex
|
||
// shader's `BaseTextureMtx[2]` uniform (captured MeshShellEnv_runtime.vert) and computing oBaseTexCoord =
|
||
// BaseTextureMtx * TexCoord -- the matrix is applied DIRECTLY (no inversion) at the track's own rate. So the
|
||
// authored (rate, direction) per track IS the correct flow (waterfall = fast down, fog = slow drift). A prior
|
||
// GLOBAL speed-cap (0.5 UV/s) + direction-negate broke it: it slowed the waterfall to a crawl and flipped it to
|
||
// flow UP. REMOVED -- use the authored rate+direction verbatim. Only the SLOW-TRACK boost stays, to lift the
|
||
// near-frozen lake (10079 frames/30fps = 336s loop) into a visible drift; fast tracks are untouched.
|
||
if (uvMaxLoopSec > 0.f) {
|
||
float natLoop = (float)tr.nFrames / animFps;
|
||
if (natLoop > uvMaxLoopSec) { float s = natLoop / uvMaxLoopSec; ru *= s; rv *= s; }
|
||
}
|
||
return std::sqrt(ru*ru + rv*rv) >= 1e-4f;
|
||
}
|
||
// uvScrollRate once per track (it scans the whole track; animate() asks every frame, and a bake stores the answer).
|
||
bool uvScroll(UVTrack& tr, float& ru, float& rv) const {
|
||
if (tr.scroll < 0) tr.scroll = uvScrollRate(tr, tr.ru, tr.rv) ? 1 : 0;
|
||
ru = tr.ru; rv = tr.rv;
|
||
return tr.scroll == 1;
|
||
}
|
||
void cookExtractUVScroll(std::unordered_map<size_t, std::pair<float,float>>& out) {
|
||
out.clear();
|
||
bool uvdbg = std::getenv("HSR_UVDBG") != nullptr;
|
||
if (uvdbg) fprintf(stderr, "[UVDBG] cookExtractUVScroll: %zu uvAnimRecs animFps=%.2f\n", uvAnimRecs.size(), animFps);
|
||
if (uvAnimRecs.empty() || animFps <= 0.f) return;
|
||
for (auto& ur : uvAnimRecs) {
|
||
if (out.count(ur.meshIdx)) continue;
|
||
auto it = matUVAnim.find(ur.node);
|
||
if (it == matUVAnim.end()) continue;
|
||
float ru, rv;
|
||
if (uvScrollRate(it->second, ru, rv)) {
|
||
out[ur.meshIdx] = std::make_pair(ru, rv);
|
||
if (uvdbg) { const char* nm=(ur.meshIdx<meshes.size())?meshes[ur.meshIdx].name.c_str():"?";
|
||
const float* MLast = it->second.m.data()+(size_t)(it->second.nFrames-1)*6;
|
||
fprintf(stderr,"[UVDBG] m%zu '%s' nF=%d trans f0=(%.4f,%.4f) fLast=(%.4f,%.4f) -> SCROLL rate=(%.4f,%.4f)/s\n",
|
||
ur.meshIdx,nm,it->second.nFrames,it->second.m[2],it->second.m[5],MLast[2],MLast[5],ru,rv); }
|
||
}
|
||
}
|
||
}
|
||
// FLIPBOOK ATLAS tracks: a mat.sanim UVTransform whose 2x2 is identity but whose
|
||
// per-frame OFFSET steps by ~1/cols (a whole cell) instead of a tiny scroll. uvScrollRate() rejects these (returns
|
||
// false) so they are NOT cooked as a continuous scroll. Here we detect them + derive the grid so the cook routes
|
||
// them to the OFFSET flipbook shader (shadergen::FLIPBOOK, az=1) = frame-SNAP, matching the live preview.
|
||
struct FlipRec { int cols, rows, frames; float fps; };
|
||
void cookExtractFlipbook(std::unordered_map<size_t, FlipRec>& out) {
|
||
out.clear();
|
||
if (uvAnimRecs.empty() || animFps <= 0.f) return;
|
||
bool fdbg = std::getenv("HSR_FLIPDBG") != nullptr;
|
||
for (auto& ur : uvAnimRecs) {
|
||
if (out.count(ur.meshIdx)) continue;
|
||
auto it = matUVAnim.find(ur.node);
|
||
if (it == matUVAnim.end() || it->second.nFrames < 2) continue;
|
||
const UVTrack& tr = it->second;
|
||
const float* M0 = tr.m.data();
|
||
int win = tr.nFrames-1; if (win>256) win=256;
|
||
double sdu=0, sdv=0; int n=0;
|
||
for (int f=0; f<win; f++) {
|
||
float dc = tr.m[(size_t)(f+1)*6+2]-tr.m[(size_t)f*6+2];
|
||
float df = tr.m[(size_t)(f+1)*6+5]-tr.m[(size_t)f*6+5];
|
||
if (std::fabs(dc)>0.5f || std::fabs(df)>0.5f) continue;
|
||
sdu+=dc; sdv+=df; ++n;
|
||
}
|
||
double du = n? std::fabs(sdu/n):0.0, dv = n? std::fabs(sdv/n):0.0;
|
||
if (fdbg) { std::string nm=(ur.meshIdx<meshes.size())?meshes[ur.meshIdx].name:std::string();
|
||
fprintf(stderr,"[FLIPDBG] mesh%zu '%s' node='%s' nF=%d 2x2=[%.3f %.3f; %.3f %.3f] du=%.4f dv=%.4f signed(du=%.4f dv=%.4f)\n",
|
||
ur.meshIdx, nm.c_str(), ur.node.c_str(), tr.nFrames, M0[0],M0[1],M0[3],M0[4], du,dv, n?sdu/n:0.0, n?sdv/n:0.0); }
|
||
if (std::fabs(M0[0]-1.f)>0.05f || std::fabs(M0[4]-1.f)>0.05f) continue; // not an identity-offset track
|
||
if (n < 1) continue;
|
||
float ru0,rv0; if (uvScrollRate(tr, ru0, rv0)) continue; // a CONTINUOUS scroll -> NOT a flipbook (cooked as uvscroll). Only cell-stepping grids remain.
|
||
FlipRec fr;
|
||
fr.cols = (du>1e-4) ? (int)llround(1.0/du) : 1; if (fr.cols<1) fr.cols=1;
|
||
fr.frames = tr.nFrames;
|
||
// rows = ROUND(frames/cols), NOT ceil. The fog "flashing" bug: a 15-col / 151-frame sheet is a 15×10 grid
|
||
// (15×10=150 +1 loop-seam frame), but ceil(151/15)=11 made the shader divide the V offset by 11 → every cell
|
||
// landed on the wrong row = vertical smear/flash. round(151/15)=10 = the true row count, so row/rows aligns.
|
||
fr.rows = (int)llround((double)fr.frames/(double)fr.cols); if (fr.rows<1) fr.rows=1;
|
||
fr.fps = animFps;
|
||
out[ur.meshIdx] = fr;
|
||
}
|
||
}
|
||
|
||
// ── COOK: EXACT per-frame UV (c,f) OFFSETS from a mat.sanim track (the fog/dust "flashing wrong everything" fix).
|
||
// Instead of deriving a cols×rows grid from the avg step (which mis-grids a fog_spritesheet that isn't a clean
|
||
// grid, and MISSES large-step cell jumps where the dust offset moves >0.5/frame), replay the track's ACTUAL
|
||
// per-frame c,f offsets — exactly what the desktop's animate() flipbook branch applies (uv' = baseUV + (c,f)).
|
||
// ABSOLUTE offsets (not relative), subsampled to N, frame-snapped on device. loopSec = nFrames/animFps. ──
|
||
// ── COOK: the EXACT per-frame FULL 2x3 UV matrices from a mat.sanim track — the SAME data the desktop's animate()
|
||
// plays (opa_loader animate() flipbook branch: uv' = M[frame]·baseUV, frame = floor(fract(t/loopSec)·nFrames)).
|
||
// Pass ALL frames (cap maxN) so the device replays the source VERBATIM (no derived cols/rows grid to mis-guess):
|
||
// handles continuous scroll, sprite-cell atlases AND 2x2 SCALE (dust) uniformly. loopSec = nFrames/animFps (the
|
||
// desktop's flipSlow=1 rate). Returns false if the matrix doesn't change across frames (a still track). ──
|
||
bool cookExtractUVMatrices(int meshIdx, int maxN, std::vector<float>& mats, int& N, float& loopSec) {
|
||
mats.clear(); N=0; loopSec=0.f;
|
||
if (maxN < 2 || animFps <= 0.f) return false;
|
||
const UVAnimRec* ar=nullptr; for (auto& a : uvAnimRecs) if ((int)a.meshIdx==meshIdx){ ar=&a; break; }
|
||
if (!ar) return false;
|
||
auto it = matUVAnim.find(ar->node);
|
||
if (it == matUVAnim.end() || it->second.nFrames < 2) return false;
|
||
const UVTrack& tr = it->second; int nf = tr.nFrames;
|
||
loopSec = (float)nf / animFps; if (loopSec <= 1e-4f) return false;
|
||
N = nf <= maxN ? nf : maxN; // ALL source frames (no cell-skipping subsample) unless enormous
|
||
mats.assign((size_t)N*6, 0.f);
|
||
bool anim=false;
|
||
for (int i=0; i<N; i++) {
|
||
int src = (nf==N) ? i : (int)((double)i * nf / (double)N); if (src>=nf) src=nf-1;
|
||
for (int k=0;k<6;k++){ float v = tr.m[(size_t)src*6+k]; mats[(size_t)i*6+k]=v;
|
||
if (i>0 && std::fabs(v - mats[(size_t)k]) > 1e-5f) anim=true; }
|
||
}
|
||
if (std::getenv("HSR_ROTDUMP")) { // inspect the UVTransform: rotation vs scroll vs scale/warp
|
||
fprintf(stderr,"[UVDUMP] mesh %d node '%s' frames=%d N=%d loop=%.1fs (rows: m0 m1 m2 / m3 m4 m5 ; u'=m0u+m1v+m2)\n", meshIdx, ar->node.c_str(), nf, N, loopSec);
|
||
for (int i=0;i<N;i+=std::max(1,N/10)){ float*m=&mats[(size_t)i*6];
|
||
float rot=std::atan2(m[3],m[0])*57.2958f; float sc=std::sqrt(m[0]*m[0]+m[3]*m[3]);
|
||
fprintf(stderr," f%3d [%+.3f %+.3f %+.3f / %+.3f %+.3f %+.3f] ~rot=%.1fdeg scale=%.3f off=(%+.3f,%+.3f)\n",
|
||
i,m[0],m[1],m[2],m[3],m[4],m[5], rot, sc, m[2],m[5]); }
|
||
}
|
||
return anim; // the matrix actually changes across frames
|
||
}
|
||
|
||
// ── Per-clip frame-rate READ FROM THE COOKED DATA, not hardcoded ─────────────────────────
|
||
// libshell pulls the rate from a NAMED "FrameRate" field (driver sub_2EAEF5C does
|
||
// sub_2EF36E0(asset,"FrameRate") -> f32). We do the same: scan for the OPAA name record
|
||
// [0xFFFF][u16 len=9]["FrameRate"] and take the f32 right after. Returns <=0 when the cook
|
||
// stores no rate -> the caller keeps the engine default (many V79 .sanim/.mat.sanim cooks
|
||
// store NO rate field at all and are sampled at the engine's fixed cook rate).
|
||
static float findFrameRate(const std::vector<uint8_t>& d) {
|
||
static const char K[9] = {'F','r','a','m','e','R','a','t','e'};
|
||
if (d.size() < 21) return -1.0f;
|
||
for (size_t i = 0; i + 17 <= d.size(); ++i) {
|
||
if (d[i] != 0xFF || d[i+1] != 0xFF) continue;
|
||
uint16_t ln; memcpy(&ln, d.data()+i+2, 2);
|
||
if (ln != 9 || memcmp(d.data()+i+4, K, 9) != 0) continue;
|
||
float f; memcpy(&f, d.data()+i+13, 4);
|
||
if (f > 0.5f && f < 480.0f) return f; // sane fps only
|
||
}
|
||
return -1.0f;
|
||
}
|
||
|
||
// Evaluate ONLY the animated node hierarchy at time t -> nodeWorldAnim (NO mesh deform, NO skinning/UV/tint).
|
||
// The cook rotation sampler calls this per frame instead of the full animate() (which skins/UV-transforms EVERY
|
||
// mesh -> far too slow over 33 frames on a large environment).
|
||
void evalAnimNodes(float t) {
|
||
nodeWorldAnim.resize(animNodes.size());
|
||
for (size_t i = 0; i < animNodes.size(); ++i) {
|
||
const Node& nd = animNodes[i];
|
||
float T[3]={nd.t[0],nd.t[1],nd.t[2]};
|
||
float R[4]={nd.r[0],nd.r[1],nd.r[2],nd.r[3]}; // static R already (x,y,z,w)
|
||
float S[3]={nd.s[0],nd.s[1],nd.s[2]};
|
||
auto it = nodeAnim.find(nd.name);
|
||
if (it != nodeAnim.end()) {
|
||
const NodeTracks& nt = it->second;
|
||
sampleTrack(nt.t, t, animFps, 3, T);
|
||
sampleTrack(nt.r, t, animFps, 4, R);
|
||
sampleTrack(nt.s, t, animFps, 3, S);
|
||
if (nt.r.nFrames > 0) { float qw=R[0],qx=R[1],qy=R[2],qz=R[3]; R[0]=qx; R[1]=qy; R[2]=qz; R[3]=qw; }
|
||
float ql = sqrtf(R[0]*R[0]+R[1]*R[1]+R[2]*R[2]+R[3]*R[3]);
|
||
if (ql > 1e-6f) { R[0]/=ql; R[1]/=ql; R[2]/=ql; R[3]/=ql; } else { R[0]=R[1]=R[2]=0; R[3]=1; }
|
||
}
|
||
Mat4 local = trs(T, R, S);
|
||
int par = nd.parent;
|
||
nodeWorldAnim[i] = (par >= 0 && par < (int)i) ? mul(nodeWorldAnim[par], local) : local;
|
||
}
|
||
}
|
||
|
||
static void sampleTrack(const Track& tr, float t, float fps, int comps, float* out) {
|
||
if (tr.nFrames <= 0 || tr.comps != comps) return; // leave caller default
|
||
// Each track loops on its OWN length at the engine rate (NOT stretched to the global longest
|
||
// clip — with a 2501-frame bird path that made a short fan-rotation loop crawl over 83s).
|
||
float f = fmodf(t * fps, (float)tr.nFrames);
|
||
if (f < 0.0f) f += (float)tr.nFrames;
|
||
int i0 = (int)f; float frac = f - (float)i0;
|
||
if (i0 >= tr.nFrames) { i0 = tr.nFrames - 1; frac = 0.0f; }
|
||
int i1 = (i0 + 1 < tr.nFrames) ? i0 + 1 : 0; // wrap to frame 0 -> smooth loop seam
|
||
// SHORTEST-ARC quaternion lerp (see gltf sampleSampler): antipodal neighbour keys (q vs -q) lerped
|
||
// componentwise pass near |q|=0 = a wild long-arc sweep for that interval, baked into cooked clips.
|
||
float sgn = 1.f;
|
||
if (comps == 4) { float d = 0.f;
|
||
for (int c = 0; c < 4; ++c) d += tr.v[(size_t)i0*comps + c] * tr.v[(size_t)i1*comps + c];
|
||
if (d < 0.f) sgn = -1.f; }
|
||
for (int c = 0; c < comps; ++c)
|
||
out[c] = tr.v[(size_t)i0*comps + c] * (1.0f - frac) + tr.v[(size_t)i1*comps + c] * sgn * frac;
|
||
}
|
||
// Sample each animated node at looped time t and rewrite its mesh's world positions.
|
||
void animate(float t) {
|
||
if (!hasAnimation()) return;
|
||
// ── Evaluate the WHOLE node hierarchy with animation (libshell's scene graph): for each node
|
||
// world[i] = world[parent] * localTRS(i), where localTRS uses the sanim-sampled TRS when the
|
||
// node is keyed, else the node's STATIC local transform. This makes a mesh move when ANY
|
||
// ANCESTOR is animated (e.g. the bird mesh under the animated `birdBody_path` node) — not
|
||
// only when its own node is keyed. (Nodes are stored parent-before-child, so one pass.)
|
||
if (gpuRigid_ && !animRecs.empty() && animRecs[0].rigid < 0) {
|
||
std::set<size_t> cpu; // meshes something else also writes
|
||
for (auto& sr : skinRecs) cpu.insert(sr.meshIdx);
|
||
for (auto& vr : vatRecs) cpu.insert(vr.meshIdx);
|
||
evalAnimNodes(0.0f);
|
||
for (auto& ar : animRecs) {
|
||
ar.rest = (ar.nodeIdx < nodeWorldAnim.size()) ? nodeWorldAnim[ar.nodeIdx] : identity();
|
||
const float* M = ar.rest.m;
|
||
const float det = M[0]*(M[5]*M[10]-M[6]*M[9]) - M[4]*(M[1]*M[10]-M[2]*M[9]) + M[8]*(M[1]*M[6]-M[2]*M[5]);
|
||
ar.rigid = (std::fabs(det) > 1e-9f && !cpu.count(ar.meshIdx)) ? 1 : 0;
|
||
mat4affineInverse(M, ar.restInv);
|
||
}
|
||
}
|
||
if (!animRecs.empty()) evalAnimNodes(t); // node hierarchy -> nodeWorldAnim (shared with the cook sampler)
|
||
for (auto& ar : animRecs) {
|
||
Mat4 m = (ar.nodeIdx < nodeWorldAnim.size()) ? nodeWorldAnim[ar.nodeIdx] : identity();
|
||
MeshData& md = meshes[ar.meshIdx];
|
||
size_t nv = ar.basePos.size() / 3;
|
||
static int birddbg = -1; if (birddbg<0) birddbg = std::getenv("HSR_BIRDDBG")?1:0;
|
||
if (birddbg && nv>0) {
|
||
float bmn[3]={1e9f,1e9f,1e9f}, bmx[3]={-1e9f,-1e9f,-1e9f};
|
||
for (size_t i=0;i<nv;i++) for(int c=0;c<3;c++){float v=ar.basePos[i*3+c]; if(v<bmn[c])bmn[c]=v; if(v>bmx[c])bmx[c]=v;}
|
||
fprintf(stderr,"[BIRDDBG] mesh#%zu node=%u nodeWt=(%.2f,%.2f,%.2f) baseAABB=(%.2f,%.2f,%.2f)..(%.2f,%.2f,%.2f) diag=%.2f\n",
|
||
ar.meshIdx, ar.nodeIdx, m.m[12],m.m[13],m.m[14], bmn[0],bmn[1],bmn[2], bmx[0],bmx[1],bmx[2],
|
||
std::sqrt((bmx[0]-bmn[0])*(bmx[0]-bmn[0])+(bmx[1]-bmn[1])*(bmx[1]-bmn[1])+(bmx[2]-bmn[2])*(bmx[2]-bmn[2])));
|
||
}
|
||
if (md.positions.size() < nv*3) md.positions.resize(nv*3);
|
||
const bool rigid = gpuRigid_ && ar.rigid == 1;
|
||
if (rigid && ar.atRest) { mat4mul(m.m, ar.restInv, md.animXform); continue; }
|
||
const Mat4& at = rigid ? ar.rest : m;
|
||
bool changed = false;
|
||
for (size_t i = 0; i < nv; ++i) {
|
||
float o[3]; xform(at, ar.basePos[i*3], ar.basePos[i*3+1], ar.basePos[i*3+2], o);
|
||
changed |= put(md.positions[i*3], o[0]) | put(md.positions[i*3+1], o[1]) | put(md.positions[i*3+2], o[2]);
|
||
}
|
||
if (changed) ++md.posGen;
|
||
ar.atRest = rigid;
|
||
if (rigid) mat4mul(m.m, ar.restInv, md.animXform);
|
||
else std::memcpy(md.animXform, identity().m, sizeof md.animXform);
|
||
}
|
||
// ── Skeletal LBS (skinned meshes) — libshell's vertex shader, on the CPU:
|
||
// localPos = Σ_i ( Joints[idx_i] * bindPos ) * weight_i (i = 0..3) ─────────────────
|
||
// The .anim + .skel store LOCAL (parent-relative) joint poses. For the current frame:
|
||
// animWorld[j] = animWorld[parent[j]] * animLocal(f)[j] (parent[j] < j, single pass)
|
||
// Joints[j] = animWorld[j] * invBind[j] (invBind = inverse bind WORLD)
|
||
// (matches V79 libshell Skeleton.cpp: m_jointParents + m_jointLocalPoses).
|
||
auto& clipSkin = _clipSkin; clipSkin.resize(clips.size()); // reused scratch (capacity kept)
|
||
for (size_t ci = 0; ci < clips.size(); ++ci) {
|
||
const AnimClip& clip = clips[ci];
|
||
if (clip.numFrames < 1 || (int)clip.parents.size() < clip.numJoints) { clipSkin[ci].clear(); continue; }
|
||
// Interpolate BETWEEN baked frames (like sampleTrack does for sanim). Snapping to an
|
||
// integer frame made skeletal anims step at the baked fps = stutter (the chicken looked
|
||
// smooth only because it rides the interpolating sanim path). Dense baked frames => small
|
||
// per-frame delta => element-wise LERP of the LOCAL joint matrices is visually smooth.
|
||
// Each clip loops on its OWN length at the engine sample rate (animFps), independent of
|
||
// other clips — NOT stretched to the global longest-clip duration (that desynced/slowed
|
||
// shorter clips). i1 wraps to frame 0 so the loop seam interpolates smoothly.
|
||
float f = fmodf(t * animFps, (float)clip.numFrames);
|
||
if (f < 0.0f) f += (float)clip.numFrames;
|
||
int i0 = (int)f; float frac = f - (float)i0;
|
||
if (i0 < 0) { i0 = 0; frac = 0.0f; }
|
||
if (i0 >= clip.numFrames) { i0 = clip.numFrames - 1; frac = 0.0f; }
|
||
int i1 = (i0 + 1 < clip.numFrames) ? i0 + 1 : 0;
|
||
const float* fm0 = clip.mats.data() + (size_t)i0 * clip.numJoints * 16;
|
||
const float* fm1 = clip.mats.data() + (size_t)i1 * clip.numJoints * 16;
|
||
// Joints[j] = jointWORLD = compose(animLocal, parents). The SkinnedPos verts are
|
||
// authored joint-LOCAL (near origin), so jointWorld places them onto the building +
|
||
// poses them — NO inverse-bind (that would cancel the placement -> owl back at origin).
|
||
clipSkin[ci].resize((size_t)clip.numJoints*16);
|
||
for (int j = 0; j < clip.numJoints; ++j) {
|
||
float L[16];
|
||
for (int k = 0; k < 16; ++k)
|
||
L[k] = fm0[(size_t)j*16+k]*(1.0f-frac) + fm1[(size_t)j*16+k]*frac;
|
||
int p = clip.parents[j];
|
||
if (p < 0 || p >= j) memcpy(clipSkin[ci].data()+(size_t)j*16, L, 16*sizeof(float));
|
||
else mat4mul(clipSkin[ci].data()+(size_t)p*16, L, clipSkin[ci].data()+(size_t)j*16);
|
||
}
|
||
}
|
||
// Each skinned mesh on its own core (the meshes are independent unless two records share one).
|
||
if (skinShared_ < 0) {
|
||
std::set<size_t> seen;
|
||
skinShared_ = 0;
|
||
for (auto& sr : skinRecs) if (!seen.insert(sr.meshIdx).second) skinShared_ = 1;
|
||
}
|
||
auto skin = [&](size_t si) {
|
||
const SkinRec& sr = skinRecs[si];
|
||
if (sr.clipIdx < 0 || sr.clipIdx >= (int)clips.size()) return;
|
||
const AnimClip& clip = clips[sr.clipIdx];
|
||
if (clipSkin[sr.clipIdx].empty() || sr.nJoints < 1) return;
|
||
const float* cw = clipSkin[sr.clipIdx].data(); // clip joint WORLD matrices (composed)
|
||
// Per skin-bone skinning matrix = clipJointWorld[boneClip[bone]] * invBind[bone].
|
||
// (invBind = the SKIN's per-bone mesh-bind->joint-local; jointWorld places + poses.)
|
||
static thread_local std::vector<float> sm; sm.resize((size_t)sr.nJoints*16); // reused scratch (capacity kept)
|
||
for (int b = 0; b < sr.nJoints; ++b) {
|
||
int cj = (b < (int)sr.boneClip.size()) ? sr.boneClip[b] : -1;
|
||
if (cj < 0 || cj >= clip.numJoints || (size_t)(b*16+16) > sr.invBind.size()) {
|
||
for (int k=0;k<16;++k) sm[b*16+k] = (k%5==0)?1.f:0.f; continue;
|
||
}
|
||
mat4mul(cw + (size_t)cj*16, sr.invBind.data()+(size_t)b*16, sm.data()+(size_t)b*16);
|
||
}
|
||
MeshData& md = meshes[sr.meshIdx];
|
||
if (gpuRigid_ && sr.gpu) { // the renderer skins it: its joints and the box they move
|
||
const size_t nj = (size_t)sr.nJoints * 16;
|
||
if (md.skinJoints.size() == nj && std::memcmp(md.skinJoints.data(), sm.data(), nj * sizeof(float)) == 0) return;
|
||
md.skinJoints.assign(sm.begin(), sm.begin() + nj);
|
||
float box[6] = {1e30f, 1e30f, 1e30f, -1e30f, -1e30f, -1e30f};
|
||
for (int b = 0; b < sr.nJoints; ++b) {
|
||
const float* jb = &sr.jointBox[(size_t)b*6];
|
||
if (jb[0] > jb[3]) continue; // moves no vertex
|
||
const float* M = sm.data() + (size_t)b*16;
|
||
for (int c = 0; c < 8; ++c) {
|
||
const float x = jb[(c&1)?3:0], y = jb[(c&2)?4:1], z = jb[(c&4)?5:2];
|
||
const float o[3] = {M[0]*x + M[4]*y + M[8]*z + M[12], M[1]*x + M[5]*y + M[9]*z + M[13], M[2]*x + M[6]*y + M[10]*z + M[14]};
|
||
for (int k = 0; k < 3; ++k) { box[k] = std::min(box[k], o[k]); box[3+k] = std::max(box[3+k], o[k]); }
|
||
}
|
||
}
|
||
for (int k = 0; k < 3; ++k) { box[k] = std::min(box[k], sr.unriggedBox[k]); box[3+k] = std::max(box[3+k], sr.unriggedBox[3+k]); }
|
||
std::memcpy(md.skinBox, box, sizeof box);
|
||
++md.skinGen;
|
||
return;
|
||
}
|
||
size_t nv = sr.basePos.size() / 3;
|
||
if (md.positions.size() < nv*3) md.positions.resize(nv*3);
|
||
bool changed = false;
|
||
for (size_t v = 0; v < nv; ++v) {
|
||
float bx=sr.basePos[v*3], by=sr.basePos[v*3+1], bz=sr.basePos[v*3+2];
|
||
float ox=0, oy=0, oz=0, wsum=0;
|
||
for (int i = 0; i < 4; ++i) {
|
||
float w = sr.jw[v*4+i]; int b = sr.jidx[v*4+i];
|
||
if (w <= 0.0f || b < 0 || b >= sr.nJoints) continue;
|
||
const float* M = sm.data() + (size_t)b*16; // column-major skinning matrix
|
||
ox += w * (M[0]*bx + M[4]*by + M[8]*bz + M[12]);
|
||
oy += w * (M[1]*bx + M[5]*by + M[9]*bz + M[13]);
|
||
oz += w * (M[2]*bx + M[6]*by + M[10]*bz + M[14]);
|
||
wsum += w;
|
||
}
|
||
if (wsum < 1e-4f) { ox=bx; oy=by; oz=bz; } // unrigged vertex -> bind pose
|
||
else if (wsum < 0.999f || wsum > 1.001f) { ox/=wsum; oy/=wsum; oz/=wsum; } // normalize (weights not guaranteed to sum to 1)
|
||
changed |= put(md.positions[v*3], ox) | put(md.positions[v*3+1], oy) | put(md.positions[v*3+2], oz);
|
||
}
|
||
if (changed) ++md.posGen;
|
||
};
|
||
if (skinShared_) for (size_t si = 0; si < skinRecs.size(); ++si) skin(si);
|
||
else par::forEach(skinRecs.size(), skin);
|
||
// ── mat.sanim UV/flipbook: transform each animated mesh's base UVs by the current frame's
|
||
// 2x3 matrix [a,b,c, d,e,f]: uv' = (a*u + b*v + c, d*u + e*v + f). ──────────────────────
|
||
for (auto& ur : uvAnimRecs) {
|
||
auto it = matUVAnim.find(ur.node);
|
||
if (it == matUVAnim.end() || it->second.nFrames < 1) continue;
|
||
UVTrack& tr = it->second;
|
||
MeshData& md = meshes[ur.meshIdx];
|
||
size_t nuv = ur.baseUV.size() / 2;
|
||
if (md.uvs.size() < nuv*2) md.uvs.resize(nuv*2);
|
||
float ru, rv;
|
||
bool isScroll = uvScroll(tr, ru, rv);
|
||
// FAITHFUL per-animation speed (V79 AnimationLayer sub_1BC7118: phase += speed*dt/clipDuration —
|
||
// EACH clip, node OR texture, advances on its OWN duration/speed). The UV clip's authored
|
||
// duration = its own frame count / the sanim FrameRate. NO artificial cap/stretch (uvMaxLoopSec/
|
||
// flipSlow distorted the authored speed = the "moves too fast / doesn't follow" bug). Sync with
|
||
// the node movement + tint fade is EMERGENT (fog authors node/UV/tint at matching durations),
|
||
// exactly like the device — not forced. Loop = fract (recycle mode!=0 = repeat, sub_1BC70B8).
|
||
float clipSec = (animFps > 0.f) ? (float)tr.nFrames / animFps : 0.f;
|
||
static int legacyAnim = -1; if (legacyAnim<0) legacyAnim = std::getenv("HSR_LEGACYANIM")?1:0;
|
||
if (clipSec > 1e-4f && !legacyAnim) {
|
||
float phase = fmodf(t / clipSec, 1.0f) * (float)tr.nFrames;
|
||
if (phase < 0.0f) phase += (float)tr.nFrames;
|
||
int f0 = (int)phase; if (f0 >= tr.nFrames) f0 = tr.nFrames - 1; if (f0 < 0) f0 = 0;
|
||
int f1 = (f0 + 1 < tr.nFrames) ? f0 + 1 : 0;
|
||
float frac = phase - (float)f0; if (frac < 0.f) frac = 0.f; if (frac > 1.f) frac = 1.f;
|
||
const float* M0 = tr.m.data() + (size_t)f0 * 6;
|
||
const float* M1 = tr.m.data() + (size_t)f1 * 6;
|
||
// LERP a continuous scroll/warp (smooth flow); SNAP a sprite-atlas flipbook (lerp = double image).
|
||
float M[6];
|
||
if (isScroll) for (int k=0;k<6;k++) M[k] = M0[k]*(1.0f-frac) + M1[k]*frac;
|
||
else for (int k=0;k<6;k++) M[k] = M0[k];
|
||
if (!uvByMatrix(ur, md, nuv, M)) {
|
||
bool changed = false;
|
||
for (size_t i = 0; i < nuv; ++i) {
|
||
float u0 = ur.baseUV[i*2], v0 = ur.baseUV[i*2+1];
|
||
changed |= put(md.uvs[i*2], M[0]*u0 + M[1]*v0 + M[2]);
|
||
changed |= put(md.uvs[i*2+1], M[3]*u0 + M[4]*v0 + M[5]);
|
||
}
|
||
if (changed) ++md.uvGen;
|
||
}
|
||
static int matdbg = -1; if (matdbg<0) matdbg = std::getenv("HSR_MATDBG")?1:0;
|
||
if (matdbg && nuv>0) fprintf(stderr, "[MATDBG] t=%.2f mesh#%zu '%s' CLIP fr=%d/%d clip=%.2fs %s off=(%.3f,%.3f) uv0=(%.4f,%.4f)\n",
|
||
t, ur.meshIdx, ur.node.c_str(), f0, tr.nFrames, clipSec, isScroll?"scroll":"atlas", M[2],M[5], md.uvs[0], md.uvs[1]);
|
||
continue;
|
||
}
|
||
if (isScroll) {
|
||
// CONTINUOUS linear UV scroll: effect tracks use an IDENTITY 2x2 plus a constant-velocity offset, e.g.
|
||
// fog dc=0.1/fr & smoke dc=0.125/fr — NOT atlas flipbooks). The faithful play is uv = baseUV + rate*t
|
||
// (UNBOUNDED; the texture REPEAT wrap makes it seamless), which is EXACTLY the device cook's getTime()
|
||
// `uv += rate*time` shader (the SAME uvScrollRate), so the preview matches the headset. Frame-sampling
|
||
// a capped loop instead snaps the UV back to frame0 each loop -> a visible JUMP for the non-integer-tile
|
||
// fog/smoke (fog ends at u=9.9 = a 0.9 backward snap; smoke at 15.875) = the "not animated right" bug.
|
||
// WRAP the offset into one tile (fmod): t is a free-running wall clock, so an UNBOUNDED rate*t grows to
|
||
// hundreds of UV units -> float32 loses sub-texel precision in the GPU interpolator = shimmer/FLASH.
|
||
// fmod keeps it in (-1,1); the texture REPEAT makes the 1->0 wrap seamless (the whole card shifts by a
|
||
// uniform offset, so no torn triangle, no mip change). Device cook scrolls the same rate continuously.
|
||
float du = std::fmod(ru * t, 1.0f), dv = std::fmod(rv * t, 1.0f);
|
||
const float S[6] = {1, 0, du, 0, 1, dv};
|
||
if (!uvByMatrix(ur, md, nuv, S)) {
|
||
bool changed = false;
|
||
for (size_t i = 0; i < nuv; ++i) {
|
||
changed |= put(md.uvs[i*2], ur.baseUV[i*2] + du);
|
||
changed |= put(md.uvs[i*2+1], ur.baseUV[i*2+1] + dv);
|
||
}
|
||
if (changed) ++md.uvGen;
|
||
}
|
||
static int matdbg = -1; if (matdbg<0) matdbg = std::getenv("HSR_MATDBG")?1:0;
|
||
if (matdbg && nuv>0) fprintf(stderr, "[MATDBG] t=%.2f mesh#%zu '%s' SCROLL rate=(%.4f,%.4f) uv0=(%.4f,%.4f)\n",
|
||
t, ur.meshIdx, ur.node.c_str(), ru, rv, md.uvs[0], md.uvs[1]);
|
||
} else {
|
||
// FLIPBOOK ATLAS (both scale-flipbooks and effect tracks which step
|
||
// an identity-matrix UV by ~1/cols per frame): the texture is a sprite GRID, the track flips cells.
|
||
// SNAP to the integer frame (interpolating would slide across a cell boundary = blend two sprites =
|
||
// the smeared "all messed" waterfall). flipSlow stretches the loop so the flipbook reads as flowing
|
||
// water, not a frantic flicker (waterfall 64 frames @30fps = 2.1s was "way too fast"); HSR_FLIPSLOW tunes.
|
||
static float flipSlow = -1.f; if (flipSlow < 0.f) { const char* e = std::getenv("HSR_FLIPSLOW"); flipSlow = e ? (float)atof(e) : 1.0f; }
|
||
float loopSec = (animFps > 0.f) ? (float)tr.nFrames / animFps * flipSlow : 0.f;
|
||
if (uvMaxLoopSec > 0.f && loopSec > uvMaxLoopSec) loopSec = uvMaxLoopSec;
|
||
float phase = (loopSec > 1e-4f) ? fmodf(t / loopSec, 1.0f) * (float)tr.nFrames
|
||
: fmodf(t * animFps, (float)tr.nFrames);
|
||
if (phase < 0.0f) phase += (float)tr.nFrames;
|
||
int frame = (int)phase;
|
||
if (frame < 0) frame = 0; if (frame >= tr.nFrames) frame = tr.nFrames - 1;
|
||
const float* M = tr.m.data() + (size_t)frame * 6;
|
||
if (!uvByMatrix(ur, md, nuv, M)) {
|
||
bool changed = false;
|
||
for (size_t i = 0; i < nuv; ++i) {
|
||
float u0 = ur.baseUV[i*2], v0 = ur.baseUV[i*2+1];
|
||
changed |= put(md.uvs[i*2], M[0]*u0 + M[1]*v0 + M[2]);
|
||
changed |= put(md.uvs[i*2+1], M[3]*u0 + M[4]*v0 + M[5]);
|
||
}
|
||
if (changed) ++md.uvGen;
|
||
}
|
||
static int matdbg = -1; if (matdbg<0) matdbg = std::getenv("HSR_MATDBG")?1:0;
|
||
if (matdbg && nuv>0) fprintf(stderr, "[MATDBG] t=%.2f mesh#%zu '%s' FLIP fr=%d/%d M=[%.3f %.3f] uv0=(%.4f,%.4f)\n",
|
||
t, ur.meshIdx, ur.node.c_str(), frame, tr.nFrames, M[2], M[5], md.uvs[0], md.uvs[1]);
|
||
}
|
||
}
|
||
// ── mat.sanim MaterialTint: per-frame RGBA the shader multiplies into the fragment
|
||
// (UniformColor). UNLIKE the UV flipbook this is a smooth opacity fade, so LERP between
|
||
// frames (snapping would make the fog visibly step). This is the fog/dust/flicker
|
||
// OPACITY (alpha 0..~0.22) — without it fog renders far too dense. ───────────────────────
|
||
for (auto& tr : tintRecs) {
|
||
auto it = matTintAnim.find(tr.node);
|
||
if (it == matTintAnim.end() || it->second.nFrames < 1) continue;
|
||
const TintTrack& tt = it->second;
|
||
if (std::getenv("HSR_TINTDBG")) { static std::set<std::string> seen;
|
||
if (!seen.count(tr.node)) { seen.insert(tr.node);
|
||
float amn=1e9f,amx=-1e9f; for(int f=0;f<tt.nFrames;f++){float a=tt.rgba[(size_t)f*4+3]; if(a<amn)amn=a; if(a>amx)amx=a;}
|
||
auto A=[&](float p){int f=(int)(p*(tt.nFrames-1)+0.5f); return tt.rgba[(size_t)f*4+3];};
|
||
fprintf(stderr,"[TINTDBG] '%s' nf=%d alpha[min=%.3f max=%.3f] @0=%.3f @25=%.3f @50=%.3f @75=%.3f @100=%.3f\n",
|
||
tr.node.c_str(), tt.nFrames, amn,amx, A(0),A(.25f),A(.5f),A(.75f),A(1)); } }
|
||
static int legacyAnimT = -1; if (legacyAnimT<0) legacyAnimT = std::getenv("HSR_LEGACYANIM")?1:0;
|
||
float loopSecT;
|
||
if (animFps > 0.f && tt.nFrames > 1 && !legacyAnimT) {
|
||
loopSecT = (float)tt.nFrames / animFps; // FAITHFUL: the fade plays at ITS OWN authored duration/speed (no 5s cap) — sub_1BC7118 per-clip rate
|
||
} else {
|
||
static float matLoopMaxT = -1.f;
|
||
if (matLoopMaxT < 0.f) { const char* e = std::getenv("HSR_MATLOOP"); matLoopMaxT = e ? (float)atof(e) : 5.0f; }
|
||
loopSecT = (animFps > 0.f) ? (float)tt.nFrames / animFps : 0.f;
|
||
if (matLoopMaxT > 0.f && loopSecT > matLoopMaxT) loopSecT = matLoopMaxT;
|
||
}
|
||
float phase = (loopSecT > 1e-4f) ? fmodf(t / loopSecT, 1.0f) * (float)tt.nFrames
|
||
: fmodf(t * animFps, (float)tt.nFrames);
|
||
if (phase < 0.0f) phase += (float)tt.nFrames;
|
||
int f0 = (int)phase; float frac = phase - (float)f0;
|
||
if (f0 >= tt.nFrames) { f0 = tt.nFrames - 1; frac = 0.0f; }
|
||
int f1 = (f0 + 1 < tt.nFrames) ? f0 + 1 : 0; // wrap for a seamless loop
|
||
const float* a = tt.rgba.data() + (size_t)f0 * 4;
|
||
const float* b = tt.rgba.data() + (size_t)f1 * 4;
|
||
MeshData& md = meshes[tr.meshIdx];
|
||
for (int c = 0; c < 4; ++c) md.curTint[c] = a[c]*(1.0f-frac) + b[c]*frac;
|
||
}
|
||
// ── VAT (Vertex Animation Texture): localPos = basePos + offset[frame][col], then place by
|
||
// the instance world matrix. Offsets are LERP'd between frames (loops on its own length
|
||
// at vatFps). This is the coral/seaweed/fish/jellyfish sway. ────────────────────────────
|
||
for (auto& vr : vatRecs) {
|
||
if (!vr.vd || vr.vd->frames < 1 || vr.vd->cols < 1) continue;
|
||
const VatData& vd = *vr.vd;
|
||
float f = fmodf(t * vatFps, (float)vd.frames); if (f < 0.0f) f += (float)vd.frames;
|
||
int i0 = (int)f; float frac = f - (float)i0;
|
||
if (i0 < 0) i0 = 0; if (i0 >= vd.frames) { i0 = vd.frames - 1; frac = 0.0f; }
|
||
int i1 = (i0 + 1 < vd.frames) ? i0 + 1 : 0; // wrap for a seamless loop
|
||
MeshData& md = meshes[vr.meshIdx];
|
||
size_t nv = vr.basePos.size() / 3;
|
||
if (md.positions.size() < nv*3) md.positions.resize(nv*3);
|
||
bool changed = false;
|
||
for (size_t v = 0; v < nv; ++v) {
|
||
int col = vr.col[v]; if (col < 0 || col >= vd.cols) col = 0;
|
||
const float* o0 = &vd.off[((size_t)i0*vd.cols + col)*3];
|
||
const float* o1 = &vd.off[((size_t)i1*vd.cols + col)*3];
|
||
float lx = vr.basePos[v*3] + o0[0]*(1.0f-frac) + o1[0]*frac;
|
||
float ly = vr.basePos[v*3+1] + o0[1]*(1.0f-frac) + o1[1]*frac;
|
||
float lz = vr.basePos[v*3+2] + o0[2]*(1.0f-frac) + o1[2]*frac;
|
||
float wp[3]; xform(vr.world, lx, ly, lz, wp);
|
||
changed |= put(md.positions[v*3], wp[0]) | put(md.positions[v*3+1], wp[1]) | put(md.positions[v*3+2], wp[2]);
|
||
}
|
||
if (changed) ++md.posGen;
|
||
}
|
||
}
|
||
|
||
// Parse sanim.opa -> nodeAnim (called before parseModel so it can mark animated meshes).
|
||
void loadAnim(const std::vector<uint8_t>& sceneZip) {
|
||
std::vector<uint8_t> sa;
|
||
{ mz_zip_archive z; memset(&z, 0, sizeof(z));
|
||
if (!mz_zip_reader_init_mem(&z, sceneZip.data(), sceneZip.size(), 0)) return;
|
||
uint32_t nf = mz_zip_reader_get_num_files(&z); int found = -1;
|
||
for (uint32_t i = 0; i < nf; ++i) { mz_zip_archive_file_stat st;
|
||
if (!mz_zip_reader_file_stat(&z, i, &st)) continue;
|
||
std::string fn(st.m_filename);
|
||
// Match the NODE-animation file (...fbx.sanim.opa) but NOT ...fbx.mat.sanim.opa, which
|
||
// ALSO ends in ".sanim.opa" — grabbing that (UV flipbook tracks, no Translation/Rotation)
|
||
// gave "0 animated nodes" so the bird's flight + windmill fans never animated.
|
||
bool isMat = fn.size() >= 14 && fn.compare(fn.size()-14, 14, ".mat.sanim.opa") == 0;
|
||
if (!isMat && fn.size() >= 10 && fn.compare(fn.size()-10, 10, ".sanim.opa") == 0) { found = (int)i; break; } }
|
||
if (found < 0) { mz_zip_reader_end(&z); return; }
|
||
size_t sz = 0; void* d = mz_zip_reader_extract_to_heap(&z, found, &sz, 0);
|
||
mz_zip_reader_end(&z);
|
||
if (!d) return; sa.assign((uint8_t*)d, (uint8_t*)d + sz); mz_free(d); }
|
||
if (sa.size() < 52 || memcmp(sa.data(), "OPAA", 4) != 0) return;
|
||
{ float fr = findFrameRate(sa); if (fr > 0.f) { animFps = fr; fprintf(stderr, "[OPA] sanim FrameRate (from data) = %.2f\n", fr); } }
|
||
uint32_t hdr; memcpy(&hdr, sa.data()+16, 4); if (hdr < 48 || hdr >= sa.size()) hdr = 48;
|
||
auto rdName = [&](size_t off, std::string& out) -> size_t {
|
||
if (off + 4 > sa.size() || sa[off] != 0xFF || sa[off+1] != 0xFF) return 0;
|
||
uint16_t ln; memcpy(&ln, sa.data()+off+2, 2);
|
||
if (ln == 0 || ln > 64 || off + 4 + ln > sa.size()) return 0;
|
||
for (uint16_t k = 0; k < ln; ++k) { uint8_t ch = sa[off+4+k]; if (ch < 32 || ch > 126) return 0; }
|
||
out.assign((char*)sa.data()+off+4, ln); return 4u + ln; };
|
||
std::string curNode; size_t p = hdr + 4;
|
||
while (p + 4 <= sa.size()) {
|
||
std::string nm; size_t adv = rdName(p, nm);
|
||
if (!adv) { ++p; continue; }
|
||
if (nm == "Translation" || nm == "Rotation" || nm == "Scale") {
|
||
size_t kp = p + adv;
|
||
if (kp + 12 > sa.size()) { p += adv; continue; }
|
||
uint32_t nKeys, nVals;
|
||
memcpy(&nKeys, sa.data()+kp+4, 4); memcpy(&nVals, sa.data()+kp+8, 4);
|
||
if (std::getenv("HSR_SANIMDBG")) { uint32_t f0; memcpy(&f0, sa.data()+kp, 4);
|
||
fprintf(stderr,"[SANIM] node='%s' ch=%s hdr@kp[+0]=0x%08X(%u) nKeys=%u nVals=%u bytes[+0..+3]=%02X %02X %02X %02X\n",
|
||
curNode.c_str(), nm.c_str(), f0, f0, nKeys, nVals, sa[kp],sa[kp+1],sa[kp+2],sa[kp+3]); }
|
||
// comps come from the CHANNEL, not nKeys: Translation/Scale=3, Rotation=4 (quat); the
|
||
// value block is nVals floats right after the 12B header. Deriving frames from nKeys+1
|
||
// is wrong for flag=1 tracks (the bird's path: nVals=6000, nKeys+1=2001 -> 2001*3≠6000,
|
||
// so it was rejected and the bird never moved). frames = nVals/comps handles both flags.
|
||
int comps = (nm == "Rotation") ? 4 : 3;
|
||
size_t trackEnd = kp + 12 + (size_t)nVals*4;
|
||
if (trackEnd <= sa.size() && nVals >= (uint32_t)comps && (nVals % (uint32_t)comps) == 0) {
|
||
int nFrames = (int)(nVals / (uint32_t)comps);
|
||
if (!curNode.empty()) {
|
||
Track tr; tr.comps = comps; tr.nFrames = nFrames; tr.v.resize(nVals);
|
||
memcpy(tr.v.data(), sa.data()+kp+12, (size_t)nVals*4);
|
||
// Recover the authored motion range. Some cooked tracks store the real
|
||
// keyframes then HOLD the final value to pad out to the master clip length
|
||
// (e.g. bluehillgoldmine's background train: 625 frames crossing + ~1875
|
||
// identical "parked" frames). Looping the full track makes the prop sit
|
||
// still for most of the cycle ("train frozen"). Drop a pure trailing run of
|
||
// frames identical to the last keyframe so the loop covers only the motion.
|
||
{
|
||
// Per-channel trailing-static length (where THIS channel stops changing).
|
||
// Stored, NOT applied yet — we sync a node's T/R/S together below so they
|
||
// don't drift out of phase over loops.
|
||
const float* vv = tr.v.data();
|
||
auto same = [&](int A,int B){ for(int c=0;c<comps;++c) if (fabsf(vv[(size_t)A*comps+c]-vv[(size_t)B*comps+c])>1e-5f) return false; return true; };
|
||
int eff = tr.nFrames;
|
||
while (eff > 2 && same(eff-1, eff-2)) --eff;
|
||
tr.effFrames = eff;
|
||
}
|
||
NodeTracks& ntk = nodeAnim[curNode];
|
||
if (nm == "Translation") ntk.t = std::move(tr);
|
||
else if (nm == "Rotation") ntk.r = std::move(tr);
|
||
else ntk.s = std::move(tr);
|
||
}
|
||
p = trackEnd; continue; // resync (byte-scan) handles any flag=1 trailing key-time data
|
||
}
|
||
p += adv; continue; // bogus header -> resync by scanning for the next name marker
|
||
}
|
||
if (nm == "UVTransform") {
|
||
// Per-frame 2x3 UV affine [a,b,c, d,e,f] (6 floats/frame) authored INSIDE the node
|
||
// sanim — the material UV scroll / flipbook (fire, smoke, aurora, water). The old
|
||
// parser handled ONLY Translation/Rotation/Scale and silently DROPPED UVTransform,
|
||
// so every such material rendered STATIC ("animations left out": winterwonderland
|
||
// smoke_Geo*, fire_a_placement_*, aurora_noise, pSphere*). Feed matUVAnim[node] so the
|
||
// SAME UV-anim path the .mat.sanim.opa uses (renderer animate() + cook uvscroll/flipbook)
|
||
// drives them — GLOBAL, one code path for every env whose sanim carries UVTransform.
|
||
size_t kp = p + adv;
|
||
if (kp + 12 > sa.size()) { p += adv; continue; }
|
||
uint32_t nKeys, nVals;
|
||
memcpy(&nKeys, sa.data()+kp+4, 4); memcpy(&nVals, sa.data()+kp+8, 4); (void)nKeys;
|
||
size_t trackEnd = kp + 12 + (size_t)nVals*4;
|
||
if (!curNode.empty() && trackEnd <= sa.size() && nVals >= 6 && (nVals % 6) == 0) {
|
||
UVTrack tr; tr.nFrames = (int)(nVals / 6); tr.m.resize(nVals);
|
||
memcpy(tr.m.data(), sa.data()+kp+12, (size_t)nVals*4);
|
||
if (std::getenv("HSR_SANIMDBG"))
|
||
fprintf(stderr, "[SANIM-UV] node='%s' UVTransform frames=%d\n", curNode.c_str(), tr.nFrames);
|
||
matUVAnim[curNode] = std::move(tr);
|
||
p = trackEnd; continue;
|
||
}
|
||
p += adv; continue;
|
||
}
|
||
curNode = nm; p += adv; // a node name
|
||
}
|
||
// SYNC each node's channels: loop ALL of T/R/S on the SAME length = the latest frame ANY of
|
||
// them is still moving (max of the per-channel trailing-static lengths). Trimming each channel
|
||
// to its OWN trailing-static point (the old bug) desynced them over loops — the winterlodge
|
||
// tram's small rotation track trimmed shorter than its long translation, so over a few cycles
|
||
// the cabin's orientation drifted out of phase with its position ("elevator flew out / went
|
||
// funny / not looping"). A fully-parked node (background train) still trims (all channels
|
||
// become static together, so the shared max is the parked frame). Each animation thus loops
|
||
// independently on its own true length, with its channels kept in lockstep — faithful.
|
||
animMaxFrames = 0;
|
||
for (auto& kv : nodeAnim) {
|
||
NodeTracks& nt = kv.second;
|
||
int e = 2;
|
||
if (nt.t.nFrames > 0) e = std::max(e, nt.t.effFrames);
|
||
if (nt.r.nFrames > 0) e = std::max(e, nt.r.effFrames);
|
||
if (nt.s.nFrames > 0) e = std::max(e, nt.s.effFrames);
|
||
auto clampN = [&](Track& t){ if (t.nFrames > 0) { int d = t.comps ? (int)(t.v.size()/t.comps) : 0; t.nFrames = std::min(e, d); } };
|
||
clampN(nt.t); clampN(nt.r); clampN(nt.s);
|
||
if (e > animMaxFrames) animMaxFrames = e;
|
||
}
|
||
// UVTransform tracks parsed from the NODE sanim above ALSO define animation length. Without
|
||
// this, a UV-ONLY env (rockquarry fc_environment: the sanim carries the waterfall/fire/mist
|
||
// UVTransform flipbooks but few/no T/R/S node tracks) leaves animMaxFrames<=1 -> hasAnimation()
|
||
// returns FALSE -> the ENTIRE OPA animate/stream block is skipped and NOTHING animates (the
|
||
// "waterfalls are animated yet don't animate in render" bug). The reset above recomputes
|
||
// animMaxFrames from nodeAnim ONLY, wiping the per-track bump; fold the UV/tint tracks back in.
|
||
// (.mat.sanim.opa tracks raise animMaxFrames in loadMatAnim, which runs right after this.)
|
||
for (auto& kv : matUVAnim) if (kv.second.nFrames > animMaxFrames) animMaxFrames = kv.second.nFrames;
|
||
for (auto& kv : matTintAnim) if (kv.second.nFrames > animMaxFrames) animMaxFrames = kv.second.nFrames;
|
||
log("sanim: %zu animated nodes, %zu UV tracks, maxFrames=%d (%.1fs @%.0ffps)",
|
||
nodeAnim.size(), matUVAnim.size(), animMaxFrames, animDuration(), animFps);
|
||
}
|
||
|
||
// Parse *.mat.sanim.opa -> matUVAnim: per geo/node a "UVTransform" track = nFrames x 2x3 UV
|
||
// matrix (flipbook/scroll for smoke/fire/dust/fog/particles). Same track encoding as sanim.
|
||
void loadMatAnim(const std::vector<uint8_t>& sceneZip) {
|
||
// A scene can ship MULTIPLE *.mat.sanim.opa: one scene-wide (papercraft.fbx.mat.sanim.opa,
|
||
// holds e.g. the waterCard UVTransform) PLUS per-mesh ones (hummingbird_winguv...). libshell
|
||
// loads each cooked mesh's own material-anim, so we parse them ALL and merge by geo name —
|
||
// grabbing only the first (the old bug) missed animated water -> it rendered the
|
||
// whole 2x2 atlas static (green + black blotches) = the "dark / messed up moving lilypad".
|
||
std::vector<std::vector<uint8_t>> files;
|
||
{ mz_zip_archive z; memset(&z, 0, sizeof(z));
|
||
if (!mz_zip_reader_init_mem(&z, sceneZip.data(), sceneZip.size(), 0)) return;
|
||
uint32_t nf = mz_zip_reader_get_num_files(&z);
|
||
for (uint32_t i = 0; i < nf; ++i) { mz_zip_archive_file_stat st;
|
||
if (!mz_zip_reader_file_stat(&z, i, &st)) continue;
|
||
std::string fn(st.m_filename);
|
||
if (fn.size() >= 14 && fn.compare(fn.size()-14, 14, ".mat.sanim.opa") == 0) {
|
||
size_t sz = 0; void* d = mz_zip_reader_extract_to_heap(&z, i, &sz, 0);
|
||
if (d) { files.emplace_back((uint8_t*)d, (uint8_t*)d + sz); mz_free(d); } } }
|
||
mz_zip_reader_end(&z); }
|
||
for (auto& sa : files) {
|
||
if (sa.size() < 52 || memcmp(sa.data(), "OPAA", 4) != 0) continue;
|
||
{ float fr = findFrameRate(sa); if (fr > 0.f) animFps = fr; } // data-driven rate, not hardcoded
|
||
uint32_t hdr; memcpy(&hdr, sa.data()+16, 4); if (hdr < 48 || hdr >= sa.size()) hdr = 48;
|
||
auto rdName = [&](size_t off, std::string& out) -> size_t {
|
||
if (off + 4 > sa.size() || sa[off] != 0xFF || sa[off+1] != 0xFF) return 0;
|
||
uint16_t ln; memcpy(&ln, sa.data()+off+2, 2);
|
||
if (ln == 0 || ln > 64 || off + 4 + ln > sa.size()) return 0;
|
||
for (uint16_t k = 0; k < ln; ++k) { uint8_t ch = sa[off+4+k]; if (ch < 32 || ch > 126) return 0; }
|
||
out.assign((char*)sa.data()+off+4, ln); return 4u + ln; };
|
||
std::string curGeo; size_t p = hdr + 4;
|
||
while (p + 4 <= sa.size()) {
|
||
std::string nm; size_t adv = rdName(p, nm);
|
||
if (!adv) { ++p; continue; }
|
||
if (nm == "UVTransform" || nm == "MaterialTint") {
|
||
size_t kp = p + adv;
|
||
if (kp + 12 > sa.size()) { p += adv; continue; }
|
||
uint32_t nVals; memcpy(&nVals, sa.data()+kp+8, 4);
|
||
size_t end = kp + 12 + (size_t)nVals*4;
|
||
if (end > sa.size()) { p += adv; continue; }
|
||
// UVTransform = a 2x3 UV matrix per frame (6 floats). Derive frame count from the
|
||
// VALUE block (nVals/6), NOT nKeys+1 — flag=1 tracks store extra key-time data so
|
||
// nKeys+1 mismatches nVals (same fix loadSanim uses for comps=3/4 node tracks).
|
||
if (nm == "UVTransform" && nVals >= 6 && (nVals % 6) == 0 && !curGeo.empty()) {
|
||
UVTrack tr; tr.nFrames = (int)(nVals / 6); tr.m.resize(nVals);
|
||
memcpy(tr.m.data(), sa.data()+kp+12, (size_t)nVals*4);
|
||
if (tr.nFrames > animMaxFrames) animMaxFrames = tr.nFrames;
|
||
matUVAnim[curGeo] = std::move(tr);
|
||
}
|
||
// MaterialTint = per-frame RGBA (4 floats/frame); the fog/dust OPACITY animation.
|
||
if (nm == "MaterialTint" && nVals >= 4 && (nVals % 4) == 0 && !curGeo.empty()) {
|
||
TintTrack tr; tr.nFrames = (int)(nVals / 4); tr.rgba.resize(nVals);
|
||
memcpy(tr.rgba.data(), sa.data()+kp+12, (size_t)nVals*4);
|
||
if (tr.nFrames > animMaxFrames) animMaxFrames = tr.nFrames;
|
||
matTintAnim[curGeo] = std::move(tr);
|
||
}
|
||
p = end; continue; // track consumed
|
||
}
|
||
curGeo = nm; p += adv; // a geo/node name
|
||
}
|
||
}
|
||
log("mat.sanim: %zu UV-animated meshes (from %zu files)", matUVAnim.size(), files.size());
|
||
}
|
||
|
||
// ── public entry: returns true if this APK is an OPA env we parsed ──
|
||
bool load(const std::string& apkPath) {
|
||
const std::shared_ptr<const std::vector<uint8_t>> sceneZipHold = scenezip::get(apkPath); // shared: scene_zip.h
|
||
if (!sceneZipHold) return false;
|
||
const std::vector<uint8_t>& sceneZip = *sceneZipHold;
|
||
{
|
||
mz_zip_archive z; memset(&z, 0, sizeof(z));
|
||
if (!mz_zip_reader_init_mem(&z, sceneZip.data(), sceneZip.size(), 0)) return false;
|
||
std::string archiveError;
|
||
const bool safe = zipread::validateArchive(
|
||
z, hslcook::zipsafety::kSceneReadLimits, &archiveError);
|
||
mz_zip_reader_end(&z);
|
||
if (!safe) {
|
||
log("rejected unsafe or oversized scene.zip: %s", archiveError.c_str());
|
||
return false;
|
||
}
|
||
}
|
||
// Enumerate geometry *.fbx.opa. A COMPOSED multi-asset scene (underwater/oceanarium) ships
|
||
// MANY world-baked single-mesh *.fbx.opa placed by per-entity files (no instance list /
|
||
// one main model); a normal home ships ONE baked *.fbx.opa with an instance list. Materials
|
||
// (*.mat.opa), skins (*.skin.opa) etc. don't end in ".fbx.opa", so this selects geometry only.
|
||
std::vector<std::string> fbxOpas;
|
||
{ mz_zip_archive z; memset(&z,0,sizeof(z));
|
||
if (mz_zip_reader_init_mem(&z, sceneZip.data(), sceneZip.size(), 0)) {
|
||
uint32_t nf = mz_zip_reader_get_num_files(&z);
|
||
for (uint32_t i=0;i<nf;++i){ mz_zip_archive_file_stat st; if(!mz_zip_reader_file_stat(&z,i,&st)) continue;
|
||
std::string fn(st.m_filename);
|
||
if (fn.size()>=8 && fn.compare(fn.size()-8,8,".fbx.opa")==0) fbxOpas.push_back(fn); }
|
||
mz_zip_reader_end(&z);
|
||
} }
|
||
if (fbxOpas.empty()) return false; // not an OPA env
|
||
auto extractNamed = [&](const std::string& nm, std::vector<uint8_t>& out)->bool{
|
||
mz_zip_archive z; memset(&z,0,sizeof(z));
|
||
if (!mz_zip_reader_init_mem(&z, sceneZip.data(), sceneZip.size(), 0)) return false;
|
||
size_t sz=0; void* d = mz_zip_reader_extract_file_to_heap(&z, nm.c_str(), &sz, 0);
|
||
mz_zip_reader_end(&z);
|
||
if (!d) return false; out.assign((uint8_t*)d,(uint8_t*)d+sz); mz_free(d); return true;
|
||
};
|
||
g_loadProgress.set("Reading materials...");
|
||
parseAssetMeta(sceneZip); // .mat.txt (blend modes) + .png.asset (AssetId -> tex) FIRST
|
||
g_loadProgress.set("Decoding textures...");
|
||
loadTextures(sceneZip);
|
||
loadIBL(sceneZip); // SpecIbl diffuse irradiance cubemap (*_diffuse.dds.opa = RGBA16F KTX)
|
||
loadVatData(sceneZip); // VAT vertex-animation textures (underwater coral/fish/seaweed)
|
||
g_loadProgress.set("Loading animations...");
|
||
loadAnim(sceneZip);
|
||
loadMatAnim(sceneZip); // mat.sanim UV/flipbook effect animation (smoke/fire/dust/fog)
|
||
g_loadProgress.set("Building meshes...");
|
||
// Geo base from a *.fbx.opa name (strip path, leading "sm_", trailing ".fbx.opa") -> VAT key.
|
||
auto opaBase = [](const std::string& nm){ size_t s=nm.find_last_of('/'); std::string b=(s==std::string::npos)?nm:nm.substr(s+1);
|
||
std::string l; for(char c:b) l+=(char)tolower((unsigned char)c);
|
||
size_t d=l.find(".fbx.opa"); if(d!=std::string::npos) l=l.substr(0,d);
|
||
if(l.size()>3 && l.compare(0,3,"sm_")==0) l=l.substr(3); return l; };
|
||
bool ok;
|
||
if (fbxOpas.size() > 1) {
|
||
// Composed scene: load + MERGE every world-baked *.fbx.opa (each parseModel appends to
|
||
// meshes; nodes/materials are per-opa state, textures resolve into each MeshData).
|
||
log("composed scene: merging %zu .fbx.opa", fbxOpas.size());
|
||
int n=0; for (auto& nm : fbxOpas) { std::vector<uint8_t> opa; curOpaBase = opaBase(nm);
|
||
if (extractNamed(nm, opa) && parseModel(opa)) ++n; }
|
||
log("composed scene: merged %d/%zu .fbx.opa -> %zu meshes", n, (size_t)fbxOpas.size(), meshes.size());
|
||
ok = (n > 0);
|
||
} else {
|
||
std::vector<uint8_t> opa;
|
||
if (!extractNamed(fbxOpas[0], opa)) return false;
|
||
log("geometry asset: %s (%zu bytes)", fbxOpas[0].c_str(), opa.size());
|
||
ok = parseModel(opa);
|
||
}
|
||
// Skinned meshes (animals/flags/ships). The LBS math is implemented + confirmed from
|
||
// libshell, BUT correct deform/placement needs the true inverse-bind from the .skel (its
|
||
// compact bind layout isn't decoded yet); the .anim joint matrices are WORLD-space, so
|
||
// applying them without inverseBind spikes/mis-places. Until that's cracked we render the
|
||
// skins STATIC at bind pose (coherent geometry, no spikes). Set HSR_OPA_SKIN to try the
|
||
// (still-WIP) skeletal animation. TODO: decode .skel bind transforms -> real inverseBind.
|
||
loadAnimClips(sceneZip);
|
||
loadSkins(sceneZip);
|
||
if (hasAnimation()) animate(0.0f);
|
||
if (std::getenv("HSR_ANIMDBG") && hasAnimation()) {
|
||
auto centroid = [&](size_t mi, float& x, float& y, float& z){
|
||
x=y=z=0; size_t np=meshes[mi].positions.size()/3; if(!np)return;
|
||
for(size_t q=0;q<np;q++){x+=meshes[mi].positions[q*3];y+=meshes[mi].positions[q*3+1];z+=meshes[mi].positions[q*3+2];}
|
||
x/=np;y/=np;z/=np; };
|
||
log("[ANIMDBG] animRecs=%zu uvAnimRecs=%zu skinRecs=%zu animMaxFrames=%d", animRecs.size(), uvAnimRecs.size(), skinRecs.size(), animMaxFrames);
|
||
for (auto& ar : animRecs) {
|
||
animate(40.0f); float x0,y0,z0; centroid(ar.meshIdx,x0,y0,z0); // a window that was PARKED before the trim
|
||
animate(45.0f); float x1,y1,z1; centroid(ar.meshIdx,x1,y1,z1);
|
||
float d = sqrtf((x1-x0)*(x1-x0)+(y1-y0)*(y1-y0)+(z1-z0)*(z1-z0));
|
||
if (d > 0.01f || meshes[ar.meshIdx].name.find("train")!=std::string::npos || meshes[ar.meshIdx].name.find("rain")!=std::string::npos) {
|
||
// raw basePos (cooked, pre-transform) centroid to tell LOCAL vs WORLD space
|
||
float bx=0,by=0,bz=0; size_t bn=ar.basePos.size()/3;
|
||
for(size_t q=0;q<bn;q++){bx+=ar.basePos[q*3];by+=ar.basePos[q*3+1];bz+=ar.basePos[q*3+2];}
|
||
if(bn){bx/=bn;by/=bn;bz/=bn;}
|
||
log("[ANIMDBG] mesh[%zu] node=%d '%s' rawBase=(%.1f,%.1f,%.1f) world0=(%.1f,%.1f,%.1f) move@5s=%.2f", ar.meshIdx, ar.nodeIdx, meshes[ar.meshIdx].name.c_str(), bx,by,bz, x0,y0,z0, d);
|
||
}
|
||
}
|
||
animate(0.0f);
|
||
// Dump the node chain for any 'train' node: local TRS + parent so we can see if it floats.
|
||
for (size_t i = 0; i < nodes.size(); ++i) {
|
||
std::string ln; for (char ch : nodes[i].name) ln += (char)tolower((unsigned char)ch);
|
||
if (ln.find("train")!=std::string::npos || ln.find("track")!=std::string::npos ||
|
||
ln.find("car_strip_01")!=std::string::npos || ln=="anim_cars") {
|
||
int par = nodes[i].parent;
|
||
const auto at=nodeAnim.find(nodes[i].name);
|
||
const bool keyed=at!=nodeAnim.end();
|
||
const float* t0=(keyed && at->second.t.v.size()>=3)?at->second.t.v.data():nullptr;
|
||
log("[ANIMDBG] node[%zu] '%s' parent=%d('%s') localT=(%.2f,%.2f,%.2f) S=(%.3f,%.3f,%.3f) keyed=%d trackT0=(%.2f,%.2f,%.2f)",
|
||
i, nodes[i].name.c_str(), par, (par>=0&&par<(int)nodes.size())?nodes[par].name.c_str():"-",
|
||
nodes[i].t[0],nodes[i].t[1],nodes[i].t[2], nodes[i].s[0],nodes[i].s[1],nodes[i].s[2],
|
||
(int)keyed, t0?t0[0]:0.f,t0?t0[1]:0.f,t0?t0[2]:0.f);
|
||
}
|
||
}
|
||
}
|
||
return ok || !meshes.empty();
|
||
}
|
||
|
||
private:
|
||
// Read the cooked metadata the home ships so rendering is FAITHFUL + GENERAL (works for any
|
||
// OPA home, not just spacestation): *.mat.txt give per-material blend mode + diffuse texture
|
||
// ref; *.png.asset give each texture's AssetId so a material's diffuse Id resolves to a file.
|
||
void parseAssetMeta(const std::vector<uint8_t>& sceneZip) {
|
||
mz_zip_archive z; memset(&z, 0, sizeof(z));
|
||
if (!mz_zip_reader_init_mem(&z, sceneZip.data(), sceneZip.size(), 0)) return;
|
||
uint32_t nf = mz_zip_reader_get_num_files(&z);
|
||
auto readText = [&](uint32_t i) -> std::string {
|
||
size_t sz=0; void* d=mz_zip_reader_extract_to_heap(&z,i,&sz,0);
|
||
if(!d) return {}; std::string s((char*)d,sz); mz_free(d); return s;
|
||
};
|
||
auto findU64 = [](const std::string& s, size_t from)->uint64_t{
|
||
size_t i=from; while(i<s.size() && !(s[i]>='0'&&s[i]<='9')) ++i;
|
||
uint64_t v=0; bool any=false; for(;i<s.size()&&s[i]>='0'&&s[i]<='9';++i){v=v*10+(s[i]-'0');any=true;}
|
||
return any?v:0;
|
||
};
|
||
for (uint32_t i=0;i<nf;++i) {
|
||
mz_zip_archive_file_stat st; if(!mz_zip_reader_file_stat(&z,i,&st)) continue;
|
||
std::string fn(st.m_filename);
|
||
if (fn.size()>8 && fn.substr(fn.size()-8)==".mat.txt") {
|
||
std::string s = readText(i);
|
||
MatProps mp; mp.found=true;
|
||
auto flag=[&](const char* key)->bool{
|
||
size_t p=s.find(key); if(p==std::string::npos) return false;
|
||
size_t c=s.find(':',p); if(c==std::string::npos) return false;
|
||
size_t t=s.find_first_not_of(" \t",c+1);
|
||
return t!=std::string::npos && s.compare(t,4,"true")==0;
|
||
};
|
||
mp.transparent = flag("Transparent");
|
||
mp.additive = flag("Additive");
|
||
mp.alphaTest = flag("AlphaTest");
|
||
mp.doubleSided = flag("DoubleSided");
|
||
mp.unlit = flag("Unlit");
|
||
// Color texture ref: ShellEnv materials name it "diffuse"; SpecIbl name it
|
||
// "basecolor" (some "albedo"). CRITICAL: search ONLY inside the "Textures:" section.
|
||
// SpecIbl materials ALSO declare 'basecolor'/'diffuse' UNIFORMS (in the Uniforms
|
||
// section, with a Value but NO texture Id) — matching those gave Id=0 -> ground/
|
||
// terrain/cavern rendered untextured = the "grey/white where it shouldn't be".
|
||
size_t texSec = s.find("Textures:");
|
||
size_t from = (texSec != std::string::npos) ? texSec : 0;
|
||
size_t dp = std::string::npos;
|
||
for (const char* key : {"Name: diffuse","Name:diffuse","Name: basecolor","Name:basecolor","Name: albedo","Name:albedo"}) {
|
||
size_t p = s.find(key, from);
|
||
if (p!=std::string::npos && (dp==std::string::npos || p<dp)) dp = p;
|
||
}
|
||
if (dp!=std::string::npos) {
|
||
size_t idp = s.find("Id:", dp);
|
||
if (idp!=std::string::npos && idp < dp+200) mp.diffuseId = findU64(s, idp+3);
|
||
size_t pp = s.find("Path:", dp);
|
||
if (pp!=std::string::npos && pp < dp+200) {
|
||
size_t a=s.find_first_not_of(" \t",pp+5), b=s.find_first_of("\r\n",a);
|
||
std::string path=s.substr(a, b==std::string::npos?std::string::npos:b-a);
|
||
size_t sl=path.find_last_of("/\\"); if(sl!=std::string::npos) path=path.substr(sl+1);
|
||
size_t d2=path.find(".png"); if(d2!=std::string::npos) path=path.substr(0,d2);
|
||
mp.diffuseBase = lc(path);
|
||
}
|
||
}
|
||
// BAKED lightmap texture ref (Textures section "Name: lightmap" -> Id). The interior
|
||
// SHELL meshes carry their full baked lighting/detail here; resolve it so no-albedo
|
||
// shells (helmet/gem/...) can use it as their visible surface instead of a flat blob.
|
||
if (texSec != std::string::npos) {
|
||
size_t lp = s.find("Name: lightmap", texSec);
|
||
if (lp == std::string::npos) lp = s.find("Name:lightmap", texSec);
|
||
if (lp != std::string::npos) {
|
||
size_t idp = s.find("Id:", lp);
|
||
if (idp!=std::string::npos && idp < lp+200) mp.lightmapId = findU64(s, idp+3);
|
||
}
|
||
}
|
||
// 'diffuse' basecolor UNIFORM (NOT the Textures-section ref). When a material has
|
||
// no texture this IS its flat color (black_mtl=[0,0,0] -> black/invisible; stars=[0.5];
|
||
// SpecIbl terrain=[1,1,1] tint). Search only inside the Uniforms section.
|
||
size_t uniSec = s.find("Uniforms:");
|
||
if (uniSec != std::string::npos) {
|
||
size_t dn = std::string::npos;
|
||
for (const char* key : {"Name: diffuse","Name:diffuse","Name: basecolor","Name:basecolor"}) {
|
||
size_t p = s.find(key, uniSec);
|
||
if (p!=std::string::npos && (dn==std::string::npos || p<dn)) dn = p;
|
||
}
|
||
if (dn != std::string::npos) {
|
||
size_t vp = s.find("Value", dn);
|
||
if (vp!=std::string::npos && vp < dn+60) {
|
||
size_t lim = s.size();
|
||
for (const char* k : {"UniformProperty","Textures:"}) { size_t p=s.find(k,vp+6); if(p!=std::string::npos&&p<lim) lim=p; }
|
||
{ size_t p=s.find("Name:",vp+6); if(p!=std::string::npos&&p<lim) lim=p; }
|
||
int got=0; size_t q=s.find(':',vp); if(q!=std::string::npos) ++q;
|
||
while (got<3 && q!=std::string::npos && q<lim) {
|
||
bool num = (s[q]>='0'&&s[q]<='9');
|
||
bool sign = (s[q]=='-'||s[q]=='.') && q+1<lim && ((s[q+1]>='0'&&s[q+1]<='9')||s[q+1]=='.');
|
||
if (!num && !sign) { ++q; continue; }
|
||
size_t s0=q++; while(q<lim && ((s[q]>='0'&&s[q]<='9')||s[q]=='.'||s[q]=='e'||s[q]=='E'||s[q]=='-'||s[q]=='+')) ++q;
|
||
try { mp.diffuseColor[got++] = std::stof(s.substr(s0,q-s0)); } catch(...) {}
|
||
}
|
||
}
|
||
}
|
||
// 'alpha' UNIFORM (first component). It precedes 'alphatestthreshold' in the
|
||
// Uniforms list, so the first "Name: alpha" whose next char is whitespace/newline
|
||
// (NOT the 't' of alphatestthreshold) is the one we want.
|
||
size_t ap = uniSec;
|
||
while ((ap = s.find("Name:", ap)) != std::string::npos) {
|
||
size_t c = ap + 5; while (c<s.size() && (s[c]==' '||s[c]=='\t')) ++c;
|
||
if (s.compare(c,5,"alpha")==0 && c+5<s.size() && s[c+5]!='t') {
|
||
size_t vp = s.find("Value", c);
|
||
if (vp!=std::string::npos && vp < c+60) {
|
||
size_t q = s.find(':', vp); if (q!=std::string::npos) ++q;
|
||
while (q<s.size() && !((s[q]>='0'&&s[q]<='9')||((s[q]=='-'||s[q]=='.')&&q+1<s.size()&&((s[q+1]>='0'&&s[q+1]<='9')||s[q+1]=='.')))) ++q;
|
||
size_t s0=q; while(q<s.size() && ((s[q]>='0'&&s[q]<='9')||s[q]=='.'||s[q]=='e'||s[q]=='E'||s[q]=='-'||s[q]=='+')) ++q;
|
||
try { mp.alpha = std::stof(s.substr(s0,q-s0)); } catch(...) {}
|
||
}
|
||
break;
|
||
}
|
||
ap = c;
|
||
}
|
||
}
|
||
// PBR/SpecIbl scalars (first vec component) — read VERBATIM from the cooked uniforms,
|
||
// for the no-albedo metallic/gem shells' split-sum IBL (divingHelmet, rubyGem, ...).
|
||
mp.isSpecibl = (s.find("Shader: SpecIbl")!=std::string::npos) || (s.find("Shader:SpecIbl")!=std::string::npos);
|
||
auto uniScalar=[&](const char* name, float defv)->float{
|
||
size_t nl=std::strlen(name), pos=0;
|
||
while ((pos=s.find("Name:",pos))!=std::string::npos) {
|
||
size_t c=pos+5; while(c<s.size()&&(s[c]==' '||s[c]=='\t'))++c;
|
||
if (s.compare(c,nl,name)==0) {
|
||
char after = (c+nl<s.size())? s[c+nl] : '\n';
|
||
if (after=='\n'||after=='\r'||after==' '||after=='\t') { // exact name, not a prefix
|
||
size_t vp=s.find("Value",c);
|
||
if (vp!=std::string::npos && vp<c+80) {
|
||
size_t q=s.find('[',vp);
|
||
if (q!=std::string::npos) { ++q;
|
||
while(q<s.size()&&!((s[q]>='0'&&s[q]<='9')||((s[q]=='-'||s[q]=='.')&&q+1<s.size())))++q;
|
||
size_t s0=q; while(q<s.size()&&((s[q]>='0'&&s[q]<='9')||s[q]=='.'||s[q]=='-'||s[q]=='+'||s[q]=='e'||s[q]=='E'))++q;
|
||
try { return std::stof(s.substr(s0,q-s0)); } catch(...) {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
pos=c;
|
||
}
|
||
return defv;
|
||
};
|
||
mp.metallic = uniScalar("metallic", 0.0f);
|
||
mp.roughness = uniScalar("roughness", 1.0f);
|
||
mp.alphaCutoff = uniScalar("alphatestthreshold", 0.5f); // authored AlphaTest discard threshold
|
||
mp.speciblDiffScale = uniScalar("specibldiffusescale", 1.0f);
|
||
mp.speciblSpecScale = uniScalar("speciblspecularscale", 1.0f);
|
||
// lightmappower: per-channel HDR boost on the baked lightmap = the neon/glow tint.
|
||
// Read all 3 components (libshell: lightmapColor *= lightmappower.rgb). "lightmappowertweaks"
|
||
// is the same slot in some materials. uniScalar reads the first component; pull xyz here.
|
||
auto uniVec3=[&](const char* name, float* out){
|
||
size_t nl=std::strlen(name), pos=0;
|
||
while ((pos=s.find("Name:",pos))!=std::string::npos) {
|
||
size_t c=pos+5; while(c<s.size()&&(s[c]==' '||s[c]=='\t'))++c;
|
||
if (s.compare(c,nl,name)==0) { char a=(c+nl<s.size())?s[c+nl]:'\n';
|
||
if (a=='\n'||a=='\r'||a==' '||a=='\t') {
|
||
size_t q=s.find("Value",c); if(q==std::string::npos||q>=c+80){pos=c;continue;}
|
||
// Read up to 3 numbers between Value and the NEXT property (handles BOTH
|
||
// inline `Value: [a, b, c]` and multi-line `Value:\n - a\n - b\n - c`).
|
||
size_t lim=std::min(s.size(), q+160);
|
||
for (const char* k : {"UniformProperty","Name:","Textures:"}) { size_t p=s.find(k,q+5); if(p!=std::string::npos&&p<lim) lim=p; }
|
||
int got=0; size_t i=q+5;
|
||
while (got<3 && i<lim) {
|
||
if ((s[i]>='0'&&s[i]<='9')||((s[i]=='-'||s[i]=='.')&&i+1<lim&&((s[i+1]>='0'&&s[i+1]<='9')||s[i+1]=='.'))) {
|
||
size_t s0=i++; while(i<lim&&((s[i]>='0'&&s[i]<='9')||s[i]=='.'||s[i]=='-'||s[i]=='+'||s[i]=='e'||s[i]=='E'))++i;
|
||
try { out[got++]=std::stof(s.substr(s0,i-s0)); } catch(...){ }
|
||
} else ++i;
|
||
}
|
||
return got>0;
|
||
}
|
||
}
|
||
pos=c;
|
||
}
|
||
return false;
|
||
};
|
||
if (!uniVec3("lightmappower", mp.lightmapPower)) uniVec3("lightmappowertweaks", mp.lightmapPower);
|
||
matProps[matStem(fn)] = mp;
|
||
} else if (fn.size()>10 && fn.substr(fn.size()-10)==".png.asset") {
|
||
std::string s = readText(i);
|
||
size_t ip = s.find("AssetId:");
|
||
uint64_t id = (ip!=std::string::npos) ? findU64(s, ip+8) : 0;
|
||
size_t sl=fn.find_last_of('/'); std::string base=(sl==std::string::npos)?fn:fn.substr(sl+1);
|
||
size_t d2=base.find(".png.asset"); if(d2!=std::string::npos) base=base.substr(0,d2);
|
||
if (id) assetIdToTexBase[id] = lc(base);
|
||
}
|
||
}
|
||
mz_zip_reader_end(&z);
|
||
log("parsed metadata: %zu materials, %zu texture AssetIds", matProps.size(), assetIdToTexBase.size());
|
||
}
|
||
|
||
// Find + decode the SpecIbl DIFFUSE irradiance cubemap (`*_diffuse.dds.opa` / `*hdr*diffuse*`,
|
||
// RGBA16F KTX, 6 faces). The renderer uses it to env-light `*_specibl` materials. (The matching
|
||
// `*_specular.dds.opa` is for the reflection layer — TODO.)
|
||
void loadIBL(const std::vector<uint8_t>& sceneZip) {
|
||
mz_zip_archive z; memset(&z, 0, sizeof(z));
|
||
if (!mz_zip_reader_init_mem(&z, sceneZip.data(), sceneZip.size(), 0)) return;
|
||
uint32_t nf = mz_zip_reader_get_num_files(&z); int found = -1, foundSpec = -1;
|
||
for (uint32_t i = 0; i < nf; ++i) { mz_zip_archive_file_stat st;
|
||
if (!mz_zip_reader_file_stat(&z, i, &st)) continue;
|
||
std::string fn(st.m_filename); std::string lo=fn; for(auto&c:lo)c=(char)tolower((unsigned char)c);
|
||
if (lo.size()>8 && lo.compare(lo.size()-8,8,".dds.opa")==0) {
|
||
if (lo.find("diffuse") != std::string::npos && found < 0) found = (int)i;
|
||
if (lo.find("specular") != std::string::npos && foundSpec < 0) foundSpec = (int)i;
|
||
}
|
||
}
|
||
if (found >= 0) {
|
||
size_t sz = 0; void* d = mz_zip_reader_extract_to_heap(&z, found, &sz, 0);
|
||
if (d) { if (ibl::decodeCubemap((const uint8_t*)d, sz, iblDiffuse))
|
||
log("IBL diffuse cubemap loaded: %d^2 x6 faces", iblDiffuse.size);
|
||
else log("IBL diffuse cubemap: decode FAILED");
|
||
mz_free(d); }
|
||
}
|
||
if (foundSpec >= 0) { // keep the raw RGBA16F specular bytes AND decode mip0 for CPU sampling
|
||
size_t sz = 0; void* d = mz_zip_reader_extract_to_heap(&z, foundSpec, &sz, 0);
|
||
if (d) { iblSpecularRaw.assign((uint8_t*)d, (uint8_t*)d + sz);
|
||
if (ibl::decodeCubemap((const uint8_t*)d, sz, iblSpecular))
|
||
log("IBL specular cubemap decoded: %d^2 x6 (+%zu raw bytes)", iblSpecular.size, iblSpecularRaw.size());
|
||
else log("IBL specular cubemap raw kept (%zu bytes, mip0 decode FAILED)", iblSpecularRaw.size());
|
||
mz_free(d); }
|
||
}
|
||
mz_zip_reader_end(&z);
|
||
}
|
||
|
||
// Decode EVERY *.png.opa (OPAA container -> KTX at payload offset -> ASTC -> RGBA), keyed by
|
||
// its cooked basename so any home resolves (spacestation "tx_*", rockquarry "*_diffuse", ...).
|
||
void loadTextures(const std::vector<uint8_t>& sceneZip) {
|
||
mz_zip_archive z; memset(&z, 0, sizeof(z));
|
||
if (!mz_zip_reader_init_mem(&z, sceneZip.data(), sceneZip.size(), 0)) return;
|
||
uint32_t nf = mz_zip_reader_get_num_files(&z);
|
||
g_loadProgress.set("Decoding textures...", 0, (int)nf);
|
||
for (uint32_t i = 0; i < nf; ++i) {
|
||
g_loadProgress.tick((int)i);
|
||
mz_zip_archive_file_stat st;
|
||
if (!mz_zip_reader_file_stat(&z, i, &st)) continue;
|
||
std::string fn(st.m_filename);
|
||
size_t slash = fn.find_last_of('/');
|
||
std::string base = (slash == std::string::npos) ? fn : fn.substr(slash + 1);
|
||
size_t dot = base.find(".png.opa");
|
||
if (dot == std::string::npos) continue;
|
||
std::string key = lc(base.substr(0, dot)); // full basename (no "tx_" stripping)
|
||
size_t sz = 0; void* d = mz_zip_reader_extract_to_heap(&z, i, &sz, 0);
|
||
if (!d) continue;
|
||
if (sz > 48 && memcmp(d, "OPAA", 4) == 0) {
|
||
uint32_t hdr; memcpy(&hdr, (uint8_t*)d + 16, 4); if (hdr < 48 || hdr >= sz) hdr = 48;
|
||
Tex t; t.key = key;
|
||
auto chain = std::make_shared<TexSource>();
|
||
uint32_t pw = 0, ph = 0;
|
||
if (decodeKtxBaseMip((uint8_t*)d + hdr, sz - hdr, t.rgba, t.w, t.h, &chain->astc, &chain->bw, &chain->bh, &chain->mips, 256, &pw, &ph) && !t.rgba.empty()) {
|
||
if (!chain->astc.empty()) {
|
||
chain->w = t.w; chain->h = t.h;
|
||
t.src = chain;
|
||
t.preview = t.rgba.size() < (size_t)t.w * t.h * 4;
|
||
if (t.preview && pw && ph && t.rgba.size() >= (size_t)pw * ph * 4) { t.pvW = pw; t.pvH = ph; }
|
||
}
|
||
// KEEP the real KTX alpha (we render unlit, outputting texture rgb + a). Blend
|
||
// mode comes from the material's .mat.txt (Transparent/Additive), not a guess;
|
||
// hasAlpha stays only as a fallback for materials with no .mat.txt.
|
||
size_t total = t.rgba.size() / 4, lowA = 0;
|
||
for (size_t px = 3; px < t.rgba.size(); px += 4) if (t.rgba[px] < 128) ++lowA;
|
||
t.hasAlpha = total && (lowA * 100 > total * 3);
|
||
// Cooked without mips, the "preview" is the whole image: kept (and copied into every mesh) that was
|
||
// GBs. A small copy stays for the alpha checks; full() decodes the real one if it's ever needed.
|
||
while (t.src && std::max(t.pw(), t.ph()) > 256 && t.rgba.size() >= (size_t)t.pw() * t.ph() * 4) t.halve();
|
||
if (t.src && t.rgba.size() < (size_t)t.w * t.h * 4) t.preview = true;
|
||
textures.push_back(std::move(t));
|
||
}
|
||
}
|
||
mz_free(d);
|
||
}
|
||
mz_zip_reader_end(&z);
|
||
log("decoded %zu textures", textures.size());
|
||
}
|
||
|
||
// Decode every *_vatdata.exr.opa. Despite the ".exr" name it's cooked as an UNCOMPRESSED
|
||
// RGBA32F KTX (glType GL_FLOAT 5126, glIntFmt GL_RGBA32F 0x8814): width = #anim verts,
|
||
// height = #frames, each texel.xyz = the per-frame vertex POSITION OFFSET (frame0 = 0 = rest).
|
||
// Keyed by the geo base (strip "t_" + "_vatdata.exr") to match the mesh ("sm_<X>.fbx").
|
||
void loadVatData(const std::vector<uint8_t>& sceneZip) {
|
||
mz_zip_archive z; memset(&z,0,sizeof(z));
|
||
if (!mz_zip_reader_init_mem(&z, sceneZip.data(), sceneZip.size(), 0)) return;
|
||
uint32_t nf = mz_zip_reader_get_num_files(&z);
|
||
static const uint8_t kid[12]={0xAB,'K','T','X',' ','1','1',0xBB,'\r','\n',0x1A,'\n'};
|
||
for (uint32_t i=0;i<nf;++i){ mz_zip_archive_file_stat st; if(!mz_zip_reader_file_stat(&z,i,&st)) continue;
|
||
std::string fn(st.m_filename);
|
||
size_t slash=fn.find_last_of('/'); std::string base=(slash==std::string::npos)?fn:fn.substr(slash+1);
|
||
size_t dot=base.find("_vatdata.exr.opa"); if(dot==std::string::npos) continue;
|
||
std::string key=lc(base.substr(0,dot));
|
||
if(key.size()>2 && key[0]=='t' && key[1]=='_') key=key.substr(2); // t_<X> -> <X>
|
||
size_t sz=0; void* dd=mz_zip_reader_extract_to_heap(&z,i,&sz,0); if(!dd) continue;
|
||
std::vector<uint8_t> file((uint8_t*)dd,(uint8_t*)dd+sz); mz_free(dd);
|
||
uint32_t hdr; if(file.size()<20){continue;} memcpy(&hdr,file.data()+16,4); if(hdr<48||hdr>=file.size()) hdr=48;
|
||
const uint8_t* k=file.data()+hdr; size_t kn=file.size()-hdr;
|
||
if (kn<80 || memcmp(k,kid,12)!=0) continue;
|
||
auto u=[&](size_t o){ uint32_t v; memcpy(&v,k+o,4); return v; };
|
||
uint32_t glType=u(16), w=u(36), h=u(40), kv=u(60);
|
||
size_t off=64+kv+4; // skip header + kvData + u32 imageSize
|
||
if (glType!=5126 || !w || !h) continue; // expect GL_FLOAT RGBA32F
|
||
if (off + (size_t)w*h*16 > kn) continue;
|
||
VatData vd; vd.cols=(int)w; vd.frames=(int)h; vd.off.resize((size_t)w*h*3);
|
||
const float* src=(const float*)(k+off);
|
||
for (size_t t=0;t<(size_t)w*h;++t){ vd.off[t*3]=src[t*4]; vd.off[t*3+1]=src[t*4+1]; vd.off[t*3+2]=src[t*4+2]; }
|
||
vatByBase[key]=std::move(vd);
|
||
}
|
||
mz_zip_reader_end(&z);
|
||
if (!vatByBase.empty()) log("loaded %zu VAT vertex-animations", vatByBase.size());
|
||
}
|
||
// Match a material base name to a texture by longest common prefix (the names differ:
|
||
// M_station_tubes_a_01 -> tx_station_tubes_a_03, M_ui_ring_a_dblsided -> tx_ui_ring_a_01).
|
||
const Tex* bestTexFor(const std::string& matBaseLower) const {
|
||
const Tex* best = nullptr; size_t bestLen = 0;
|
||
for (auto& t : textures) {
|
||
size_t l = 0;
|
||
while (l < matBaseLower.size() && l < t.key.size() && matBaseLower[l] == t.key[l]) ++l;
|
||
if (l > bestLen) { bestLen = l; best = &t; }
|
||
}
|
||
return (bestLen >= 3) ? best : nullptr;
|
||
}
|
||
static std::string matBaseName(const std::string& path) {
|
||
size_t a = path.find(".M_"); if (a == std::string::npos) return {};
|
||
a += 3; size_t b = path.find(".mat.asset");
|
||
std::string s = (b != std::string::npos && b > a) ? path.substr(a, b - a) : path.substr(a);
|
||
for (auto& c : s) c = (char)tolower((unsigned char)c);
|
||
return s;
|
||
}
|
||
|
||
// Find the geometry .opa: a "*.fbx.opa" that is NOT a per-material "*.mat.opa".
|
||
static bool findFbxOpa(const std::vector<uint8_t>& sceneZip, std::vector<uint8_t>& out, std::string& nameOut) {
|
||
mz_zip_archive z; memset(&z, 0, sizeof(z));
|
||
if (!mz_zip_reader_init_mem(&z, sceneZip.data(), sceneZip.size(), 0)) return false;
|
||
u32 nf = mz_zip_reader_get_num_files(&z);
|
||
int best = -1; size_t bestSz = 0; std::string bestName;
|
||
for (u32 i = 0; i < nf; ++i) {
|
||
mz_zip_archive_file_stat st;
|
||
if (!mz_zip_reader_file_stat(&z, i, &st)) continue;
|
||
std::string fn(st.m_filename);
|
||
auto ends = [&](const char* suf) {
|
||
size_t l = strlen(suf);
|
||
return fn.size() >= l && fn.compare(fn.size()-l, l, suf) == 0;
|
||
};
|
||
if (ends(".fbx.opa") && !ends(".mat.opa")) {
|
||
// pick the largest (the geometry blob dwarfs anything else)
|
||
if ((size_t)st.m_uncomp_size > bestSz) { best = (int)i; bestSz = (size_t)st.m_uncomp_size; bestName = fn; }
|
||
}
|
||
}
|
||
if (best < 0) { mz_zip_reader_end(&z); return false; }
|
||
size_t sz = 0; void* d = mz_zip_reader_extract_to_heap(&z, best, &sz, 0);
|
||
mz_zip_reader_end(&z);
|
||
if (!d) return false;
|
||
out.assign((uint8_t*)d, (uint8_t*)d + sz);
|
||
mz_free(d);
|
||
nameOut = bestName;
|
||
return true;
|
||
}
|
||
|
||
void computeNodeWorld() {
|
||
nodeWorld.assign(nodes.size(), identity());
|
||
// nodes are stored parent-before-child here (RootNode first); compute in order,
|
||
// falling back to identity for any out-of-range parent.
|
||
for (size_t i = 0; i < nodes.size(); ++i) {
|
||
Mat4 local = trs(nodes[i].t, nodes[i].r, nodes[i].s);
|
||
int par = nodes[i].parent;
|
||
if (par >= 0 && par < (int)i) nodeWorld[i] = mul(nodeWorld[par], local);
|
||
else nodeWorld[i] = local;
|
||
}
|
||
}
|
||
|
||
// ── *.anim.opa skeletal clip: per-frame, per-joint 4x4 SKINNING matrices (pre-multiplied
|
||
// jointWorld*inverseBind; frame0 ~= identity). Confirmed via libshell's skinning vertex shader
|
||
// (linear blend skinning): localPos = Σ Joints[idx_i]*pos * weight_i. ────────────────────
|
||
// .skel.opa (positional, like V79 libshell Skeleton.cpp): ver, extra, jointIds[u64], jointNames
|
||
// [str], jointParents[i32], jointLocalPoses[T3 f32 + R4 f32 (w,x,y,z) + S3 f32]. -> parents + bind LOCAL 4x4.
|
||
bool parseSkel(const std::vector<uint8_t>& file, std::vector<int>& parents, std::vector<float>& bindLocal) {
|
||
if (file.size() < 52 || memcmp(file.data(), "OPAA", 4) != 0) return false;
|
||
uint32_t hdr; memcpy(&hdr, file.data()+16, 4); if (hdr < 48 || hdr >= file.size()) hdr = 48;
|
||
Cur c; c.d = file.data(); c.n = file.size(); c.p = hdr;
|
||
uint32_t sver = c.u32(); // ver
|
||
if (sver >= 0x408) c.u32(); // extra(=1) — ONLY ver>=0x408; ver 0x405 has none
|
||
// (reading it unconditionally desynced -> 0 joints ->
|
||
// spacestation's 25 vehicle bones never loaded -> ships frozen)
|
||
uint32_t nIds = c.u32(); for (uint32_t i=0;i<nIds && c.ok;++i) c.u64(); // jointIds (skip)
|
||
uint32_t nNm = c.u32(); for (uint32_t i=0;i<nNm && c.ok;++i) c.str(); // jointNames(skip)
|
||
uint32_t nPar = c.u32(); parents.resize(nPar); for (uint32_t i=0;i<nPar && c.ok;++i) parents[i]=c.i32();
|
||
uint32_t nPose= c.u32();
|
||
if (!c.ok || nPose != nPar || nPose == 0) return false;
|
||
bindLocal.resize((size_t)nPose*16);
|
||
for (uint32_t j=0;j<nPose && c.ok;++j) {
|
||
float T[3]={c.f32(),c.f32(),c.f32()};
|
||
float Rw=c.f32(),Rx=c.f32(),Ry=c.f32(),Rz=c.f32(); // skel quat = (w,x,y,z)
|
||
float S[3]={c.f32(),c.f32(),c.f32()};
|
||
float q[4]={Rx,Ry,Rz,Rw}; // trs() wants (x,y,z,w)
|
||
Mat4 m = trs(T, q, S);
|
||
memcpy(bindLocal.data()+(size_t)j*16, m.m, 16*sizeof(float));
|
||
}
|
||
return c.ok;
|
||
}
|
||
void loadAnimClips(const std::vector<uint8_t>& sceneZip) {
|
||
mz_zip_archive z; memset(&z, 0, sizeof(z));
|
||
if (!mz_zip_reader_init_mem(&z, sceneZip.data(), sceneZip.size(), 0)) return;
|
||
uint32_t nf = mz_zip_reader_get_num_files(&z);
|
||
auto baseOf = [](const std::string& fn, const char* suf)->std::string{
|
||
size_t sl=fn.find_last_of('/'); std::string b=(sl==std::string::npos)?fn:fn.substr(sl+1);
|
||
size_t sp=b.rfind(suf); if(sp!=std::string::npos) b=b.substr(0,sp); return lc(b); };
|
||
// pass 1: skeletons (parents + bind local)
|
||
for (uint32_t i = 0; i < nf; ++i) { mz_zip_archive_file_stat st; if (!mz_zip_reader_file_stat(&z, i, &st)) continue;
|
||
std::string fn(st.m_filename);
|
||
if (fn.size() < 9 || fn.substr(fn.size()-9) != ".skel.opa") continue;
|
||
size_t sz=0; void* dd=mz_zip_reader_extract_to_heap(&z,i,&sz,0); if(!dd) continue;
|
||
std::vector<uint8_t> file((uint8_t*)dd,(uint8_t*)dd+sz); mz_free(dd);
|
||
std::vector<int> par; std::vector<float> bl;
|
||
if (parseSkel(file, par, bl)) skelData[baseOf(fn,".skel.opa")] = { std::move(par), std::move(bl) };
|
||
}
|
||
// pass 2: anim clips, attach the matching skel -> compute inverse bind WORLD
|
||
for (uint32_t i = 0; i < nf; ++i) {
|
||
mz_zip_archive_file_stat st; if (!mz_zip_reader_file_stat(&z, i, &st)) continue;
|
||
std::string fn(st.m_filename);
|
||
if (fn.size() < 9 || fn.substr(fn.size()-9) != ".anim.opa") continue;
|
||
if (fn.find(".sanim.") != std::string::npos) continue; // sanim = node TRS (handled elsewhere)
|
||
size_t sz = 0; void* dd = mz_zip_reader_extract_to_heap(&z, i, &sz, 0);
|
||
if (!dd) continue;
|
||
std::vector<uint8_t> file((uint8_t*)dd, (uint8_t*)dd + sz); mz_free(dd);
|
||
AnimClip clip;
|
||
if (parseAnimClip(file, clip) && clip.numFrames > 1 && clip.numJoints > 0) {
|
||
auto sit = skelData.find(baseOf(fn, ".anim.opa"));
|
||
if (sit != skelData.end() && (int)sit->second.first.size() == clip.numJoints
|
||
&& (int)sit->second.second.size() == clip.numJoints*16) {
|
||
clip.parents = sit->second.first;
|
||
const std::vector<float>& bl = sit->second.second;
|
||
std::vector<float> bw((size_t)clip.numJoints*16); // bind WORLD = compose(bindLocal,parents)
|
||
for (int j=0;j<clip.numJoints;++j) { int p=clip.parents[j];
|
||
if (p<0||p>=j) memcpy(bw.data()+(size_t)j*16, bl.data()+(size_t)j*16, 16*sizeof(float));
|
||
else mat4mul(bw.data()+(size_t)p*16, bl.data()+(size_t)j*16, bw.data()+(size_t)j*16); }
|
||
clip.invBind.resize((size_t)clip.numJoints*16);
|
||
for (int j=0;j<clip.numJoints;++j) mat4affineInverse(bw.data()+(size_t)j*16, clip.invBind.data()+(size_t)j*16);
|
||
}
|
||
int ci = (int)clips.size();
|
||
for (int j = 0; j < (int)clip.joints.size(); ++j) jointToClip[clip.joints[j]] = {ci, j};
|
||
if (clip.numFrames > animMaxFrames) animMaxFrames = clip.numFrames;
|
||
clips.push_back(std::move(clip));
|
||
}
|
||
}
|
||
mz_zip_reader_end(&z);
|
||
log("loaded %zu skeletal anim clips (%zu skeletons)", clips.size(), skelData.size());
|
||
}
|
||
bool parseAnimClip(const std::vector<uint8_t>& file, AnimClip& clip) {
|
||
if (file.size() < 48 || memcmp(file.data(), "OPAA", 4) != 0) return false;
|
||
uint32_t hdrSize; memcpy(&hdrSize, file.data()+16, 4); if (hdrSize < 48 || hdrSize >= file.size()) hdrSize = 48;
|
||
Cur c; c.d = file.data(); c.n = file.size(); c.p = hdrSize;
|
||
c.u32(); // ver
|
||
uint32_t jc = c.u32();
|
||
if (c.p + 2 <= c.n) { uint16_t m; memcpy(&m, c.d+c.p, 2); if (m != 0xFFFF) jc = c.u32(); } // skip extra field
|
||
for (uint32_t j = 0; j < jc && c.ok; ++j) clip.joints.push_back(c.str());
|
||
if (!c.ok) return false;
|
||
// After the joint names there's a skeleton block (parent indices / bind data) before the
|
||
// "Transform" track — scan to it rather than assuming it's immediate.
|
||
size_t tp = std::string::npos;
|
||
for (size_t q = c.p; q + 9 <= c.n; ++q) if (memcmp(c.d+q, "Transform", 9) == 0) { tp = q; break; }
|
||
if (tp == std::string::npos || tp < 4) return false;
|
||
c.p = tp - 4; // back to the string record (ffff + len)
|
||
std::string track = c.str(); // "Transform"
|
||
if (!c.ok || track != "Transform") return false;
|
||
c.u32(); // flag
|
||
c.u32(); // nKeys
|
||
uint32_t nVals = c.u32();
|
||
if (!jc || nVals == 0 || (nVals % (jc*16)) != 0) return false;
|
||
if (!c.avail((size_t)nVals*4)) return false;
|
||
clip.numJoints = (int)jc; clip.numFrames = (int)(nVals / (jc*16));
|
||
clip.mats.resize(nVals);
|
||
memcpy(clip.mats.data(), c.d + c.p, (size_t)nVals*4); // per-frame LOCAL joint poses
|
||
return true;
|
||
}
|
||
|
||
// ── Skinned meshes (*.skin.opa): the horses/owl/chickens/flags/spaceships ──────────────
|
||
// The .skin.opa container = ver, a material ref (Id + Path to a .mat.asset), then the geometry
|
||
// (posFmt "SkinnedPos" [stride 24 = pos f32x3 + weights f16x4 + bone idx u8x4], dataFmt
|
||
// "StdData" [stride 20, uv f16x4 @12], indices "kUnsignedShort"), then a joint name list. We
|
||
// render it at BIND POSE (the SkinnedPos verts are already in model space) so the animals/
|
||
// ships APPEAR; full CPU skeletal skinning (via *.skel/*.anim) is a follow-up. Robust to the
|
||
// exact container header by SCANNING for the "SkinnedPos" posFmt marker.
|
||
void loadSkins(const std::vector<uint8_t>& sceneZip) {
|
||
if (std::getenv("HSR_DUMPNODES")) { log("=== scene nodes: %zu ===", nodes.size());
|
||
for (size_t k=0;k<nodes.size();++k){ const float* W=(k<nodeWorld.size())?nodeWorld[k].m:nullptr;
|
||
log("node[%zu] '%s' par=%d t=(%.2f,%.2f,%.2f)", k, nodes[k].name.c_str(), nodes[k].parent, W?W[12]:0.f, W?W[13]:0.f, W?W[14]:0.f); } }
|
||
mz_zip_archive z; memset(&z, 0, sizeof(z));
|
||
if (!mz_zip_reader_init_mem(&z, sceneZip.data(), sceneZip.size(), 0)) return;
|
||
uint32_t nf = mz_zip_reader_get_num_files(&z);
|
||
int loaded = 0;
|
||
for (uint32_t i = 0; i < nf; ++i) {
|
||
mz_zip_archive_file_stat st; if (!mz_zip_reader_file_stat(&z, i, &st)) continue;
|
||
std::string fn(st.m_filename);
|
||
if (fn.size() < 9 || fn.substr(fn.size()-9) != ".skin.opa") continue;
|
||
size_t sz = 0; void* dd = mz_zip_reader_extract_to_heap(&z, i, &sz, 0);
|
||
if (!dd) continue;
|
||
std::vector<uint8_t> file((uint8_t*)dd, (uint8_t*)dd + sz); mz_free(dd);
|
||
if (parseSkin(file, fn)) ++loaded;
|
||
}
|
||
mz_zip_reader_end(&z);
|
||
log("loaded %d skinned meshes (bind pose)", loaded);
|
||
}
|
||
|
||
bool parseSkin(const std::vector<uint8_t>& file, const std::string& fn) {
|
||
if (file.size() < 48 || memcmp(file.data(), "OPAA", 4) != 0) return false;
|
||
uint32_t hdrSize; memcpy(&hdrSize, file.data()+16, 4); if (hdrSize < 48 || hdrSize >= file.size()) hdrSize = 48;
|
||
uint32_t ver; memcpy(&ver, file.data()+hdrSize, 4);
|
||
auto findStr = [&](const char* needle, size_t nlen, size_t from) -> size_t {
|
||
for (size_t i = from; i + nlen <= file.size(); ++i)
|
||
if (memcmp(file.data()+i, needle, nlen) == 0) return i;
|
||
return std::string::npos;
|
||
};
|
||
size_t sp = findStr("SkinnedPos", 10, hdrSize);
|
||
if (sp == std::string::npos) return false;
|
||
// ALL material .mat.asset refs in container order (the material table sits BEFORE the geometry) —
|
||
// a skin can carry MULTIPLE submeshes with DIFFERENT materials (stinson palmtree_grp_a: palmTrunk +
|
||
// palmFronds). The old first-match-only pairing gave EVERY submesh the FIRST material, so the palm
|
||
// FRONDS wore the TRUNK texture ("leaves have wrong skinned texture"). Submesh.matIndex indexes this
|
||
// list; single-material skins are unchanged (matRefs[0]).
|
||
std::vector<std::string> matRefs;
|
||
{ size_t from = 0;
|
||
while (true) { size_t m = findStr(".mat.asset", 10, from);
|
||
if (m == std::string::npos || m > sp) break;
|
||
size_t s = m + 10; while (s > 0) { char ch = (char)file[s-1];
|
||
if (isalnum((unsigned char)ch) || ch=='_'||ch=='-'||ch=='.'||ch=='/') --s; else break; }
|
||
matRefs.push_back(std::string((const char*)file.data()+s, (m+10)-s));
|
||
from = m + 10; } }
|
||
std::string matRef = matRefs.empty() ? std::string() : matRefs[0];
|
||
Cur c; c.d = file.data(); c.n = file.size(); c.p = sp - 4; // at the posFmt string record
|
||
std::string posFmt = c.str(); // "SkinnedPos"
|
||
std::string dataFmt = c.str(); // "StdData"
|
||
uint32_t listCount = c.u32(); if (listCount != 0) return false;
|
||
uint32_t submeshCount = c.u32();
|
||
struct SubR { uint32_t baseVertex, firstIndex, indexCount, matIndex; };
|
||
std::vector<SubR> subs; subs.reserve(std::min<size_t>(submeshCount, c.n - c.p)); // count is file data: never reserve past the bytes left
|
||
for (uint32_t s = 0; s < submeshCount && c.ok; ++s) {
|
||
uint32_t bv=c.u32(), fi=c.u32(), ic=c.u32(), mi=c.u32();
|
||
// Submesh extras by version — IDENTICAL to parseModel (the working 0x409 rigid path):
|
||
// 0x407/0x408 add 2 u32, 0x409 adds 2 MORE (4 total). The old per-submesh skip(24)+u8 here
|
||
// was wrong (parseModel has no per-submesh AABB) and made every 0x409 skin fail to parse
|
||
// -> "loaded 0 skinned meshes" (affected skinned scenery never reached the GPU).
|
||
if (ver >= 0x407) { c.u32(); c.u32(); }
|
||
if (ver >= 0x409) { c.u32(); c.u32(); }
|
||
c.skip(24); c.u8(); // per-submesh AABB (24) + flag (1) — the SKIN has this, the rigid model doesn't
|
||
subs.push_back({bv, fi, ic, mi});
|
||
}
|
||
c.skip(24); uint32_t vertCount = c.u32(); (void)vertCount;
|
||
uint32_t posBytes = c.u32(); size_t posOff = c.p; c.skip(posBytes);
|
||
uint32_t stdBytes = c.u32(); size_t stdOff = c.p; c.skip(stdBytes);
|
||
uint32_t idxCnt = c.u32(); (void)idxCnt;
|
||
std::string idxType = c.str();
|
||
uint32_t idxBytes = c.u32(); size_t idxOff = c.p; c.skip(idxBytes);
|
||
if (!c.ok || idxType != "kUnsignedShort") return false;
|
||
uint32_t posStride = (posFmt.find("Skinned") != std::string::npos) ? 24 : 12;
|
||
uint32_t nv = posBytes / posStride, nstd = stdBytes / 20, nidx = idxBytes / 2;
|
||
const uint8_t* posP = c.at(posOff); const uint8_t* stdP = c.at(stdOff);
|
||
const uint16_t* idxP = (const uint16_t*)c.at(idxOff);
|
||
// texture/blend resolver — called PER SUBMESH with its OWN material (sub.matIndex -> matRefs)
|
||
auto resolveMat = [&](const std::string& mref, const Tex*& tex, const MatProps*& mp) {
|
||
tex = nullptr; mp = nullptr;
|
||
std::string stem = matStem(mref); auto it = matProps.find(stem); if (it != matProps.end()) mp = &it->second;
|
||
if (mp) { if (!mp->diffuseBase.empty()) tex = texByBase(mp->diffuseBase);
|
||
if (!tex && mp->diffuseId) { auto a = assetIdToTexBase.find(mp->diffuseId); if (a != assetIdToTexBase.end()) tex = texByBase(a->second); } } };
|
||
const Tex* tex = nullptr; const MatProps* mp = nullptr;
|
||
resolveMat(matRef, tex, mp);
|
||
static int novflip = -1; if (novflip < 0) novflip = std::getenv("HSR_NOVFLIP") ? 1 : 0;
|
||
// Skin's own joint name list (after the index data) -> map each bone index to a clip joint.
|
||
// The skin's joints share names (and usually order) with its .anim clip. bone idx (u8x4)
|
||
// indexes THIS list; we resolve name -> jointToClip to get the (clip, jointInClip).
|
||
// ── Skin tail (libshell-faithful, DETERMINISTIC) ──────────────────────────────────
|
||
// After the index data comes a u16 tail (ver>=0x404, same as the model mesh entry),
|
||
// then THREE count-prefixed lists: jointNames [u32 cnt][cnt str], jointIds
|
||
// [u32 cnt][cnt u64], and the per-bone INVERSE-BIND [u32 cnt][cnt 4x4 col-major].
|
||
// libshell's string reader (sub_AEF1EC) copies raw length-prefixed bytes with NO
|
||
// character filter, so joint names may contain ':' (Maya/FBX namespaces, e.g.
|
||
// "tent_a_cloth_dembones:tentA1_joint" — the Asgard's Wrath DemBones tents). The bone
|
||
// idx (u8x4 in SkinnedPos) indexes THIS jointNames list; resolve name -> jointToClip.
|
||
std::vector<std::string> skinJoints;
|
||
std::vector<float> skinInvBind;
|
||
{
|
||
Cur t = c; // positioned right after the index data
|
||
if (ver >= 0x404) t.skip(2); // u16 tail
|
||
uint32_t jnCount = t.u32();
|
||
bool good = t.ok && jnCount >= 1 && jnCount <= 512;
|
||
std::vector<std::string> names;
|
||
for (uint32_t j = 0; good && j < jnCount; ++j) { std::string s = t.str(); if (!t.ok) { good = false; break; } names.push_back(std::move(s)); }
|
||
if (good) { uint32_t jidCount = t.u32(); if (t.ok && jidCount <= 512) t.skip((size_t)jidCount*8); else good = false; }
|
||
if (good) { uint32_t ibCount = t.u32();
|
||
if (t.ok && ibCount == jnCount && t.avail((size_t)ibCount*64)) {
|
||
skinJoints = std::move(names);
|
||
skinInvBind.resize((size_t)ibCount*16);
|
||
memcpy(skinInvBind.data(), t.d + t.p, (size_t)ibCount*64);
|
||
} }
|
||
}
|
||
// Fallback (robust to any container quirk): scan for length-prefixed name records
|
||
// (':' allowed) and take the trailing [u32 cnt][cnt 4x4] invBind block.
|
||
if (skinJoints.empty() || skinInvBind.empty()) {
|
||
skinJoints.clear(); skinInvBind.clear();
|
||
size_t q = idxOff + (size_t)idxBytes;
|
||
while (q + 4 <= file.size() && skinJoints.size() < 512) {
|
||
uint16_t mk; memcpy(&mk, file.data()+q, 2);
|
||
if (mk == 0xFFFF) { uint16_t L; memcpy(&L, file.data()+q+2, 2);
|
||
if (L >= 2 && L <= 64 && q+4+L <= file.size()) {
|
||
bool nameish = true; for (uint16_t t=0;t<L;++t){ char ch=(char)file[q+4+t]; if(!(isalnum((unsigned char)ch)||ch=='_'||ch=='-'||ch==':')){nameish=false;break;} }
|
||
if (nameish) { skinJoints.push_back(std::string((const char*)file.data()+q+4, L)); q += 4+L; continue; } } }
|
||
++q; }
|
||
int nJ = (int)skinJoints.size();
|
||
if (nJ > 0 && file.size() >= (size_t)nJ*64 + 4) {
|
||
size_t cntOff = file.size() - (size_t)nJ*64 - 4;
|
||
uint32_t cnt; memcpy(&cnt, file.data()+cntOff, 4);
|
||
if (cnt == (uint32_t)nJ) { skinInvBind.resize((size_t)nJ*16); memcpy(skinInvBind.data(), file.data()+cntOff+4, (size_t)nJ*64); }
|
||
}
|
||
}
|
||
// ── Pick the clip that best matches THIS skin's OWN joint set — NOT the last-writer-wins
|
||
// jointToClip map. When a full-scene skeleton (e.g. winterwonderland `rootnode`, which
|
||
// duplicates every banner + stringlight joint) is loaded AFTER a mesh's dedicated
|
||
// sub-skeleton (`banners_VFX`, `strings_lights`), the shared joint names overwrote
|
||
// jointToClip and bound the skin to the WRONG (superset) clip. Composing the skin's
|
||
// banners_VFX-bind invBind against the rootnode clip's differently-indexed joints
|
||
// COLLAPSED the banner to the origin (measured frame-0 centroid (-0.17,-0.07,-1.46)
|
||
// vs the correct (-5.06,-2.63,-42.71) under banners_VFX) — the "banners broken" +
|
||
// "stringlights don't animate" bug. The V79 entity graph pairs each CoSkinnedMesh with
|
||
// its parent CoSkeleton's clip; the joint-set best-match reproduces that GLOBALLY (the
|
||
// dedicated clip is the one containing all the skin's bones with the FEWEST extras).
|
||
// HSR_SKINCLIP_NAMEMAP restores the old behavior.
|
||
int skinClip = -1;
|
||
std::vector<int> boneToClipJoint(skinJoints.size(), -1);
|
||
static int nameMap = -1; if (nameMap < 0) nameMap = std::getenv("HSR_SKINCLIP_NAMEMAP") ? 1 : 0;
|
||
if (nameMap) {
|
||
for (size_t b = 0; b < skinJoints.size(); ++b) {
|
||
auto it = jointToClip.find(skinJoints[b]);
|
||
if (it != jointToClip.end()) { boneToClipJoint[b] = it->second.second; if (skinClip < 0) skinClip = it->second.first; }
|
||
}
|
||
} else {
|
||
int bestClip = -1, bestCover = 0, bestExtra = 0;
|
||
for (int ci = 0; ci < (int)clips.size(); ++ci) {
|
||
const AnimClip& cl = clips[ci];
|
||
int cover = 0;
|
||
for (auto& jn : skinJoints)
|
||
for (int j = 0; j < (int)cl.joints.size(); ++j) if (cl.joints[j] == jn) { ++cover; break; }
|
||
if (cover == 0) continue;
|
||
int extra = (int)cl.joints.size() - cover;
|
||
if (cover > bestCover || (cover == bestCover && extra < bestExtra)) { bestCover = cover; bestExtra = extra; bestClip = ci; }
|
||
}
|
||
if (bestClip >= 0) {
|
||
skinClip = bestClip; const AnimClip& cl = clips[bestClip];
|
||
for (size_t b = 0; b < skinJoints.size(); ++b)
|
||
for (int j = 0; j < (int)cl.joints.size(); ++j) if (cl.joints[j] == skinJoints[b]) { boneToClipJoint[b] = j; break; }
|
||
}
|
||
}
|
||
bool canSkin = (skinClip >= 0) && !skinInvBind.empty();
|
||
// MaterialTint (mat.sanim) for SKINNED meshes — the track is keyed by the GEO name
|
||
// ('MaterialSplit_2'), the skin by its FILENAME stem ('materialsplit_2.skin.opa'): match
|
||
// case-insensitively. Only the static entry parse attached tints; skins never did, so the
|
||
// stinson zen tree (8 skinned canopy layers, a 35s green→cyan→pink staggered RGBA cycle)
|
||
// cooked UNTINTED = washed-out "generic colors".
|
||
const std::string* skinTintKey = nullptr;
|
||
{
|
||
std::string stem = fn;
|
||
size_t sl = stem.find_last_of('/'); if (sl != std::string::npos) stem = stem.substr(sl + 1);
|
||
size_t fx = stem.rfind(".fbx."); if (fx != std::string::npos) stem = stem.substr(fx + 5);
|
||
size_t sk2 = stem.find(".skin"); if (sk2 != std::string::npos) stem = stem.substr(0, sk2);
|
||
for (auto& kv : matTintAnim) {
|
||
if (kv.first.size() != stem.size()) continue;
|
||
bool eq = true;
|
||
for (size_t q = 0; q < stem.size(); ++q)
|
||
if (tolower((unsigned char)kv.first[q]) != tolower((unsigned char)stem[q])) { eq = false; break; }
|
||
if (eq) { skinTintKey = &kv.first; break; }
|
||
}
|
||
}
|
||
int emitted = 0;
|
||
for (auto& sub : subs) {
|
||
if (sub.indexCount == 0 || (uint64_t)sub.firstIndex + sub.indexCount > nidx) continue; // u64: u32 sum wraps
|
||
// per-SUBMESH material (stinson palm fronds-vs-trunk fix): matIndex indexes the container's own
|
||
// material table; out-of-range or single-material skins keep the first ref (old behavior).
|
||
if (matRefs.size() > 1 && sub.matIndex < matRefs.size())
|
||
resolveMat(matRefs[sub.matIndex], tex, mp);
|
||
MeshData md; md.name = fn;
|
||
if (auto sl = fn.rfind('/'); sl != std::string::npos) md.path = fn.substr(0, sl);
|
||
std::unordered_map<uint32_t,uint32_t> remap; remap.reserve(sub.indexCount);
|
||
md.indices.reserve(sub.indexCount);
|
||
SkinRec sr; sr.clipIdx = skinClip;
|
||
for (uint32_t k = 0; k < sub.indexCount; ++k) {
|
||
uint32_t oi = sub.baseVertex + idxP[sub.firstIndex + k];
|
||
if (oi >= nv) oi = 0;
|
||
auto it = remap.find(oi); uint32_t ni;
|
||
if (it == remap.end()) {
|
||
ni = (uint32_t)(md.positions.size()/3); remap.emplace(oi, ni);
|
||
float px, py, pz; memcpy(&px, posP+oi*posStride+0, 4); memcpy(&py, posP+oi*posStride+4, 4); memcpy(&pz, posP+oi*posStride+8, 4);
|
||
md.positions.push_back(px); md.positions.push_back(py); md.positions.push_back(pz); // bind verts (model space)
|
||
float u=0, v=0;
|
||
if (oi < nstd) { uint16_t hu, hv; memcpy(&hu, stdP+oi*20+12, 2); memcpy(&hv, stdP+oi*20+14, 2); u=h2f(hu); v=h2f(hv); if (!novflip) v=1.0f-v; }
|
||
md.uvs.push_back(u); md.uvs.push_back(v);
|
||
// EXTRACT ALL OPA PROPERTIES: StdData a_color u8x4 @8 — authored per-vertex shading
|
||
// (leaf AO/color variation). The skin path DROPPED it entirely; the preview and the
|
||
// cook's COLOR_0 both multiply base×vertexColor, so carrying it is the 1:1 port.
|
||
if (oi < nstd) { const uint8_t* cc = stdP+oi*20+8;
|
||
md.colors.push_back(cc[0]); md.colors.push_back(cc[1]); md.colors.push_back(cc[2]); md.colors.push_back(cc[3]); }
|
||
else { md.colors.push_back(255); md.colors.push_back(255); md.colors.push_back(255); md.colors.push_back(255); }
|
||
if (canSkin) { // SkinnedPos: weights f16x4 @12, bone idx u8x4 @20 (jidx = SKIN bone)
|
||
const uint8_t* sv = posP + oi*posStride;
|
||
for (int w=0; w<4; ++w) {
|
||
uint16_t hw; memcpy(&hw, sv+12+w*2, 2); float wt = h2f(hw);
|
||
uint8_t bi = sv[20+w];
|
||
bool ok = (bi < boneToClipJoint.size() && boneToClipJoint[bi] >= 0);
|
||
sr.jidx.push_back(ok ? (int)bi : 0); sr.jw.push_back(ok ? wt : 0.0f);
|
||
}
|
||
}
|
||
} else ni = it->second;
|
||
md.indices.push_back((u32)ni);
|
||
}
|
||
md.nVerts = (u32)(md.positions.size()/3); md.nIdx = (u32)md.indices.size();
|
||
if (std::getenv("HSR_VERBOSE") && md.uvs.size() >= 12) { // SOURCE uv values for cook-fidelity diff vs the shipped RENDMESH f16 stream
|
||
fprintf(stderr, "[SKINUV] '%s' uv0..5:", fn.c_str());
|
||
for (int q3=0; q3<6; q3++) fprintf(stderr, " (%.4f,%.4f)", md.uvs[q3*2], md.uvs[q3*2+1]);
|
||
fprintf(stderr, "\n");
|
||
}
|
||
if (std::getenv("HSR_VERBOSE") && md.colors.size() >= 8) { // authored a_color stats (is it real shading data?)
|
||
int mn[4]={255,255,255,255}, mx[4]={0,0,0,0}; long sum[4]={0,0,0,0}; size_t n4=md.colors.size()/4;
|
||
for (size_t q2=0;q2<n4;q2++) for (int c2=0;c2<4;c2++){ int vv2=md.colors[q2*4+c2]; if(vv2<mn[c2])mn[c2]=vv2; if(vv2>mx[c2])mx[c2]=vv2; sum[c2]+=vv2; }
|
||
fprintf(stderr, "[SKINCOLOR] '%s' a_color r[%d..%d avg %ld] g[%d..%d avg %ld] b[%d..%d avg %ld] a[%d..%d avg %ld]\n",
|
||
fn.c_str(), mn[0],mx[0],sum[0]/(long)n4, mn[1],mx[1],sum[1]/(long)n4, mn[2],mx[2],sum[2]/(long)n4, mn[3],mx[3],sum[3]/(long)n4);
|
||
}
|
||
if (tex) { md.texW=tex->w; md.texH=tex->h; md.hasTexture=true; md.texRGBA=tex->rgba;
|
||
md.texSrc=tex->src;
|
||
md.tint[0]=md.tint[1]=md.tint[2]=1.0f; md.useBlend = mp ? (mp->transparent||mp->additive) : tex->hasAlpha;
|
||
md.alphaTest = mp ? mp->alphaTest : false; md.alphaCutoff = mp ? mp->alphaCutoff : 0.5f; }
|
||
else { md.texW=md.texH=1; md.hasTexture=true; // no texture -> flat diffuse UNIFORM color (not grey)
|
||
float dr=mp?mp->diffuseColor[0]:1.f, dg=mp?mp->diffuseColor[1]:1.f, db=mp?mp->diffuseColor[2]:1.f;
|
||
auto cl=[](float x){return (u8)(x<0?0:x>1?255:x*255.f+0.5f);};
|
||
md.texRGBA={cl(dr),cl(dg),cl(db),255}; }
|
||
if (canSkin) { // CPU linear-blend skinning every frame (animate()) — bind pose at t=0
|
||
md.dynamicVerts = true;
|
||
sr.meshIdx = meshes.size(); sr.basePos = md.positions;
|
||
sr.boneClip = boneToClipJoint; sr.invBind = skinInvBind; sr.nJoints = (int)skinJoints.size();
|
||
skinRecs.push_back(std::move(sr));
|
||
}
|
||
if (skinTintKey) { // same attach as the static entry parse (curTint streamed by animate())
|
||
tintRecs.push_back({ meshes.size(), *skinTintKey });
|
||
md.dynamicVerts = true;
|
||
const TintTrack& tt = matTintAnim[*skinTintKey];
|
||
if (tt.nFrames > 0) { md.curTint[0]=tt.rgba[0]; md.curTint[1]=tt.rgba[1];
|
||
md.curTint[2]=tt.rgba[2]; md.curTint[3]=tt.rgba[3]; }
|
||
float amn=1e9f, amx=-1e9f;
|
||
for (int f = 0; f < tt.nFrames; ++f) { float a = tt.rgba[(size_t)f*4+3]; if (a<amn)amn=a; if (a>amx)amx=a; }
|
||
if (tt.nFrames > 1 && amx - amn > 0.05f) md.animatedTintAlpha = true;
|
||
}
|
||
meshes.push_back(std::move(md)); ++emitted;
|
||
}
|
||
return emitted > 0;
|
||
}
|
||
|
||
bool parseModel(const std::vector<uint8_t>& file) {
|
||
if (file.size() < 48 || memcmp(file.data(), "OPAA", 4) != 0) { log("not an OPAA container"); return false; }
|
||
uint32_t hdrSize; memcpy(&hdrSize, file.data()+16, 4);
|
||
if (hdrSize < 48 || hdrSize >= file.size()) hdrSize = 48;
|
||
|
||
Cur c; c.d = file.data(); c.n = file.size(); c.p = hdrSize;
|
||
uint32_t ver = c.u32();
|
||
// Newer cooked versions (seen in 0x408 stock Meta homes, e.g. bluehillgoldmine) insert an
|
||
// extra u32 (=1) between the version and the root type-name string. The type name is always
|
||
// the literal "MeshData", encoded with the long-string marker 0xFFFF — so if 0xFFFF isn't
|
||
// the very next thing, consume the inserted field. (Robust across versions, not hardcoded.)
|
||
if (c.p + 2 <= c.n) {
|
||
uint16_t mark; memcpy(&mark, c.d + c.p, 2);
|
||
if (mark != 0xFFFF) c.skip(4);
|
||
}
|
||
std::string type = c.str();
|
||
log("version=0x%X type=%s", ver, type.c_str());
|
||
if (!c.ok || type != "MeshData") { log("unexpected type"); return false; }
|
||
|
||
// ── nodes ──
|
||
uint32_t nodeCount = c.u32();
|
||
nodes.clear(); nodes.reserve(std::min<size_t>(nodeCount, c.n - c.p)); // count is file data: never reserve past the bytes left
|
||
for (uint32_t i = 0; i < nodeCount && c.ok; ++i) {
|
||
Node nd;
|
||
c.skip(8); // lead 8 bytes
|
||
nd.name = c.str();
|
||
nd.parent = c.i32();
|
||
for (int k = 0; k < 3; ++k) nd.t[k] = c.f32();
|
||
// OPA stores the quaternion W-FIRST (w,x,y,z) — Meta's cooked-asset convention — but
|
||
// trs() (like glTF) wants (x,y,z,w). Reading it in file order made the RootNode's
|
||
// identity (1,0,0,0=w-first) parse as x=1,w=0 = a 180° rotation about X -> the WHOLE
|
||
// home rendered upside down (spacestation, bluehillgoldmine). Reorder to (x,y,z,w).
|
||
{ float qw=c.f32(), qx=c.f32(), qy=c.f32(), qz=c.f32();
|
||
nd.r[0]=qx; nd.r[1]=qy; nd.r[2]=qz; nd.r[3]=qw; }
|
||
for (int k = 0; k < 3; ++k) nd.s[k] = c.f32();
|
||
nodes.push_back(nd);
|
||
}
|
||
if (!c.ok) { log("node parse failed"); return false; }
|
||
computeNodeWorld();
|
||
// Append THIS .fbx.opa's nodes to the persistent global list (parents offset) so the animRecs
|
||
// created below can index their OWN nodes for the whole scene's lifetime (see animNodes decl).
|
||
size_t animNodeBase = animNodes.size();
|
||
for (const Node& nd : nodes) {
|
||
Node n2 = nd;
|
||
if (n2.parent >= 0) n2.parent += (int)animNodeBase;
|
||
animNodes.push_back(n2);
|
||
}
|
||
log("nodes=%u (animNodeBase=%zu)", nodeCount, animNodeBase);
|
||
|
||
// ── materials ──
|
||
uint32_t matCount = c.u32();
|
||
materials.clear(); materials.reserve(std::min<size_t>(matCount, c.n - c.p));
|
||
for (uint32_t i = 0; i < matCount && c.ok; ++i) {
|
||
Mat mt;
|
||
while (c.ok) {
|
||
uint8_t tag = c.u8();
|
||
if (tag == 0xC8) break; // sub_AEF158: 0xC8 == end-of-object
|
||
std::string fn = c.str();
|
||
if (fn == "Id") mt.id = c.u64();
|
||
else if (fn == "Path") mt.path = c.str();
|
||
else { log("material[%u] unknown field '%s' @%zu — cannot skip", i, fn.c_str(), c.p); return false; }
|
||
}
|
||
materials.push_back(mt);
|
||
}
|
||
if (!c.ok) { log("material parse failed"); return false; }
|
||
log("materials=%u", matCount);
|
||
|
||
// ── mesh entries ──
|
||
uint32_t meshCount = c.u32();
|
||
log("meshCount=%u", meshCount);
|
||
// Parse mesh entries as GEOMETRY ONLY (offsets into the file). The mesh<->node placement
|
||
// is the INSTANCE LIST that follows (read below) — NOT a 1:1 entry/node guess.
|
||
struct SubR { uint32_t baseVertex, firstIndex, indexCount, matIndex; };
|
||
struct MeshEntry { size_t posOff=0, stdOff=0, idxOff=0; uint32_t nv=0, nstd=0, nidx=0; bool ok=false; std::vector<SubR> subs; };
|
||
std::vector<MeshEntry> entries; entries.reserve(std::min<size_t>(meshCount, c.n - c.p));
|
||
for (uint32_t e = 0; e < meshCount && c.ok; ++e) {
|
||
std::string posFmt = c.str();
|
||
std::string dataFmt = c.str();
|
||
uint32_t listCount = c.u32();
|
||
if (listCount != 0) { log("entry[%u] AF0A94 list count=%u unsupported", e, listCount); return false; }
|
||
uint32_t submeshCount = c.u32();
|
||
MeshEntry me; me.subs.reserve(std::min<size_t>(submeshCount, c.n - c.p));
|
||
for (uint32_t s = 0; s < submeshCount && c.ok; ++s) {
|
||
// field[0] = baseVertex (drawIndexed vertexOffset; cooked meshes split into
|
||
// <=65535-vert u16-indexable chunks). Then firstIndex, indexCount, matIndex.
|
||
uint32_t baseVertex = c.u32();
|
||
uint32_t firstIndex = c.u32();
|
||
uint32_t indexCount = c.u32();
|
||
uint32_t matIndex = c.u32();
|
||
if (ver >= 0x407) { c.u32(); c.u32(); } // 2 extra u32 in newer cooks (0x407/0x408)
|
||
if (ver >= 0x409) { c.u32(); c.u32(); } // 0x409 (underwater/oceanarium) adds 2 MORE (4 total)
|
||
c.skip(24); // submesh AABB (min/max vec3)
|
||
c.u8(); // bool
|
||
me.subs.push_back({baseVertex, firstIndex, indexCount, matIndex});
|
||
}
|
||
c.skip(24); // whole-mesh AABB
|
||
uint32_t vertCount = c.u32(); // (== posBytes/12)
|
||
uint32_t posBytes = c.u32(); me.posOff = c.p; c.skip(posBytes);
|
||
uint32_t stdBytes = c.u32(); me.stdOff = c.p; c.skip(stdBytes);
|
||
uint32_t idxCnt = c.u32();
|
||
std::string idxType = c.str();
|
||
uint32_t idxBytes = c.u32(); me.idxOff = c.p; c.skip(idxBytes);
|
||
if (ver >= 0x404) c.u16(); // tail
|
||
if (!c.ok) { log("entry[%u] truncated", e); return false; }
|
||
me.nv = posBytes / 12; me.nstd = stdBytes / 20; me.nidx = idxBytes / 2;
|
||
me.ok = (idxType == "kUnsignedShort");
|
||
(void)vertCount; (void)dataFmt; (void)posFmt; (void)idxCnt;
|
||
log("entry[%u] nv=%u std=%u idx=%u submeshes=%zu%s", e, me.nv, me.nstd, me.nidx,
|
||
me.subs.size(), me.ok ? "" : " (idxType unsupported)");
|
||
entries.push_back(std::move(me));
|
||
}
|
||
if (!c.ok) { log("mesh parse failed"); return false; }
|
||
|
||
// ── INSTANCE LIST (libshell sub_AF1D74 -> sub_AF1E88): the AUTHORITATIVE mesh<->node map ──
|
||
// [u32 count] then per instance [u32 nodeIndex][u32 meshIndex] (+3 u32 @ver>=0x407, more
|
||
// @ver>=0x409). libshell draws mesh[meshIndex] at node[nodeIndex]'s transform. ONE mesh can
|
||
// be instanced at MANY nodes (one car mesh -> several animated car_strip nodes); STATIC
|
||
// meshes (vista/interior) land on static nodes -> no more "whole world moves", and props
|
||
// (the fan) get their real node transform (on the roof, not the floor).
|
||
struct Inst { uint32_t node, mesh, cell; };
|
||
std::vector<Inst> instances; // (nodeIdx, meshIdx, atlasCell)
|
||
int atlasGrid = 1; // >1 => per-instance variation atlas (remap UV0 into cell)
|
||
{
|
||
uint32_t instCount = c.ok ? c.u32() : 0;
|
||
// Instance record size by version: 0x409 = 11 u32 ([node][mesh] + 9 extra), 0x407/0x408 = 5,
|
||
// older = 2. The 0x409 record is 44 bytes (libshell sub_AF1D74/sub_AF1E88): [0]=node [1]=mesh
|
||
// [3]=rotation [4]=ATLAS CELL INDEX [5]=scale [7..10]=color(rgba). field[4] is the per-instance
|
||
// diffuse-atlas cell: the cooker bakes one texture VARIANT per instance into a square atlas, and
|
||
// each instance samples ITS cell. The mesh's UV0 is authored in [0,1] (one cell); without the
|
||
// remap a single coral samples the WHOLE atlas -> scrambled patchwork (the "bad texture").
|
||
int perInst = (ver >= 0x409) ? 11 : (ver >= 0x407) ? 5 : 2;
|
||
static int vatdbg = -1; if (vatdbg<0) vatdbg = std::getenv("HSR_VATDBG") ? 1 : 0;
|
||
uint32_t f4max=0; bool f4varies=false; uint32_t f4prev=0;
|
||
for (uint32_t i = 0; i < instCount && c.ok; ++i) {
|
||
uint32_t rec[16] = {0};
|
||
uint32_t nodeIdx = rec[0] = c.u32();
|
||
uint32_t meshIdx = rec[1] = c.u32();
|
||
for (int k = 2; k < perInst; ++k) rec[k] = c.u32(); // skip extra fields (LOD/material override)
|
||
uint32_t cell = (perInst >= 5) ? rec[4] : 0;
|
||
if (perInst >= 5) { if(cell>f4max)f4max=cell; if(i>0 && cell!=f4prev)f4varies=true; f4prev=cell; }
|
||
instances.push_back({nodeIdx, meshIdx, cell});
|
||
}
|
||
// Square atlas grid: cells 0..f4max packed row-major in a ceil(sqrt(N))² grid (matches the
|
||
// decoded atlases: angelfish 2x2, antlercoral 4x4, braincoral 7x7). Only meshes whose cell
|
||
// index actually VARIES across instances are atlas-variation meshes; tiled/static props keep
|
||
// field4==0 (no remap).
|
||
if (f4varies) { int n = (int)f4max + 1; atlasGrid = (int)std::ceil(std::sqrt((double)n)); if (atlasGrid < 1) atlasGrid = 1; }
|
||
log("instances=%zu (perInst=%d) atlasGrid=%d", instances.size(), perInst, atlasGrid);
|
||
}
|
||
// Fallback for cooks with no instance list -> 1:1 entry[i] at node[i].
|
||
if (instances.empty())
|
||
for (uint32_t e = 0; e < (uint32_t)entries.size(); ++e) instances.push_back({e, e, 0});
|
||
|
||
// VAT (Vertex Animation Texture) mesh? (curOpaBase is set by the composed-scene loader)
|
||
static int novat = -1; if (novat<0) novat = std::getenv("HSR_NOVAT") ? 1 : 0;
|
||
const VatData* curVat = (!novat && vatByBase.count(curOpaBase)) ? &vatByBase[curOpaBase] : nullptr;
|
||
|
||
// OPA scenes frequently instance the same material and texture across many nodes. Large
|
||
// legacy scenes can otherwise retain hundreds of identical decoded 2K/4K RGBA buffers and
|
||
// exhaust memory before Vulkan or the cooker is initialized. Keep one owner for plain,
|
||
// immutable opaque base textures and let subsequent meshes borrow it via texShareSrc.
|
||
// Materials whose pixels can differ stay on the independent path.
|
||
std::unordered_map<const Tex*, int> opaqueBaseTextureOwner;
|
||
|
||
// ── emit one renderable MeshData per (instance, submesh) ──
|
||
for (auto& inst : instances) {
|
||
uint32_t nodeIdx = inst.node, meshIdx = inst.mesh;
|
||
// Per-instance diffuse-atlas cell remap (see INSTANCE LIST note): map this instance's
|
||
// UV0 (authored in [0,1]) into its atlas cell so each variant shows ONE coherent texture.
|
||
int aCol = atlasGrid>1 ? (int)(inst.cell % (uint32_t)atlasGrid) : 0;
|
||
int aRow = atlasGrid>1 ? (int)(inst.cell / (uint32_t)atlasGrid) : 0;
|
||
if (meshIdx >= entries.size()) continue;
|
||
MeshEntry& me = entries[meshIdx];
|
||
if (!me.ok || me.subs.empty()) continue;
|
||
// Animated -> keep LOCAL verts; animate() applies the node's full animated world matrix
|
||
// each frame. Static -> bake its full world transform into the verts. A mesh counts as
|
||
// animated if its own node OR ANY ANCESTOR is keyed in the sanim (e.g. the bird mesh sits
|
||
// under the animated `birdBody_path` parent — only the parent moves), matching libshell's
|
||
// scene-graph propagation.
|
||
bool animated = false;
|
||
for (int an = (int)nodeIdx; an >= 0 && an < (int)nodes.size(); an = nodes[an].parent)
|
||
if (nodeAnim.count(nodes[an].name) > 0) { animated = true; break; }
|
||
Mat4 world = animated ? identity()
|
||
: ((nodeIdx < nodeWorld.size()) ? nodeWorld[nodeIdx] : identity());
|
||
Mat4 parentWorld = identity();
|
||
if (animated) {
|
||
int par = nodes[nodeIdx].parent;
|
||
if (par >= 0 && par < (int)nodeWorld.size()) parentWorld = nodeWorld[par];
|
||
}
|
||
const uint8_t* posP = c.at(me.posOff);
|
||
const uint8_t* stdP = c.at(me.stdOff);
|
||
const uint16_t* idxP = (const uint16_t*)c.at(me.idxOff);
|
||
uint32_t nv = me.nv, nstd = me.nstd, nidx = me.nidx;
|
||
for (auto& sub : me.subs) {
|
||
if (sub.indexCount == 0) continue;
|
||
if ((uint64_t)sub.firstIndex + sub.indexCount > nidx) { log("inst mesh%u submesh range OOB", meshIdx); continue; }
|
||
MeshData md;
|
||
md.name = (sub.matIndex < materials.size()) ? materials[sub.matIndex].path : (type + ".sub");
|
||
for (int an = (int)nodeIdx, guard = 0; an >= 0 && an < (int)nodes.size() && guard < 64; an = nodes[an].parent, ++guard) // its node and the ones above
|
||
if (!nodes[an].name.empty()) md.path = md.path.empty() ? nodes[an].name : nodes[an].name + "/" + md.path;
|
||
std::unordered_map<uint32_t, uint32_t> remap;
|
||
remap.reserve(sub.indexCount);
|
||
md.indices.reserve(sub.indexCount);
|
||
std::vector<int> vatCols; // VAT: per-emitted-vertex column (UV1.x) into the vatdata
|
||
double aR=0, aG=0, aB=0; uint32_t aN=0; // StdData a_color average (baked albedo)
|
||
double aASum=0; uint8_t aAMin=255, aAMax=0; // a_color ALPHA range (DBG: fog opacity?)
|
||
for (uint32_t k = 0; k < sub.indexCount; ++k) {
|
||
uint32_t oi = sub.baseVertex + idxP[sub.firstIndex + k]; // baseVertex = vertexOffset
|
||
if (oi >= nv) { oi = 0; }
|
||
auto it = remap.find(oi);
|
||
uint32_t ni;
|
||
if (it == remap.end()) {
|
||
ni = (uint32_t)(md.positions.size() / 3);
|
||
remap.emplace(oi, ni);
|
||
// position (RigidPos f32x3). For VAT meshes keep LOCAL (animate() adds the
|
||
// per-frame offset then applies the instance world matrix); else world-bake.
|
||
float px, py, pz; memcpy(&px, posP+oi*12+0, 4); memcpy(&py, posP+oi*12+4, 4); memcpy(&pz, posP+oi*12+8, 4);
|
||
if (curVat) {
|
||
md.positions.push_back(px); md.positions.push_back(py); md.positions.push_back(pz);
|
||
uint16_t hc; memcpy(&hc, stdP+(oi<nstd?oi:0)*20+16, 2); int col=(int)(h2f(hc)+0.5f); // UV1.x
|
||
vatCols.push_back((col>=0 && col<curVat->cols) ? col : 0);
|
||
} else {
|
||
float wp[3]; xform(world, px, py, pz, wp);
|
||
md.positions.push_back(wp[0]); md.positions.push_back(wp[1]); md.positions.push_back(wp[2]);
|
||
}
|
||
// uv: StdData = a_normal i16x2(@0) + a_tangent i16x2(@4) + a_color
|
||
// u8x4(@8) + a_texcoords f16x4(@12). UV is at offset 12 (NOT 8 — that's
|
||
// the color; reading it as f16 gave NaN UVs -> textures sampled garbage).
|
||
float u = 0, v = 0;
|
||
if (oi < nstd) {
|
||
uint16_t hu, hv; memcpy(&hu, stdP+oi*20+12, 2); memcpy(&hv, stdP+oi*20+14, 2);
|
||
u = h2f(hu); v = h2f(hv);
|
||
// V-FLIP: libshell's model shader does oTexCoord.y = 1.0 - TexCoord.y
|
||
// (the cooked texcoords use the bottom-up / OpenGL convention). Without
|
||
// this the textures map mirrored vertically = "not properly mapped".
|
||
// (HSR_NOVFLIP disables it for testing.)
|
||
static int novflip = -1;
|
||
if (novflip < 0) novflip = std::getenv("HSR_NOVFLIP") ? 1 : 0;
|
||
if (!novflip) v = 1.0f - v;
|
||
}
|
||
// Per-instance atlas cell remap: UV0 in [0,1] -> this instance's cell sub-rect.
|
||
if (atlasGrid > 1) {
|
||
float g = (float)atlasGrid;
|
||
u = (u + (float)aCol) / g;
|
||
v = (v + (float)aRow) / g;
|
||
}
|
||
md.uvs.push_back(u); md.uvs.push_back(v);
|
||
// uv1 (lightmap unwrap) = a_texcoords.zw @16. Sampled to bake the lightmap for
|
||
// textured ShellEnv shells. (For VAT meshes @16 is the column index, not a UV —
|
||
// harmless to store; only used when bakeLightmapVtx is set, which VAT never is.)
|
||
{ float u1=0.f, v1=0.f;
|
||
if (oi < nstd) { uint16_t hu1,hv1; memcpy(&hu1,stdP+oi*20+16,2); memcpy(&hv1,stdP+oi*20+18,2);
|
||
u1=h2f(hu1); v1=h2f(hv1);
|
||
static int nv1=-1; if(nv1<0) nv1=std::getenv("HSR_NOVFLIP")?1:0; if(!nv1) v1=1.0f-v1; }
|
||
md.uvs2.push_back(u1); md.uvs2.push_back(v1); }
|
||
if (oi < nstd) { const uint8_t* cc = stdP+oi*20+8; // a_color u8x4 @ offset 8
|
||
aR += cc[0]; aG += cc[1]; aB += cc[2]; ++aN;
|
||
aASum += cc[3]; if (cc[3]<aAMin) aAMin=cc[3]; if (cc[3]>aAMax) aAMax=cc[3]; }
|
||
} else ni = it->second;
|
||
md.indices.push_back((u32)ni);
|
||
}
|
||
md.nVerts = (u32)(md.positions.size() / 3);
|
||
md.nIdx = (u32)md.indices.size();
|
||
// placeholder shading until tx_*.png.opa decode lands: stable tint per material
|
||
uint64_t hsh = materials.empty() ? sub.matIndex : (sub.matIndex < materials.size() ? materials[sub.matIndex].id : sub.matIndex);
|
||
md.tint[0] = 0.45f + 0.5f * (((hsh) & 0xFF) / 255.0f);
|
||
md.tint[1] = 0.45f + 0.5f * (((hsh >> 8) & 0xFF) / 255.0f);
|
||
md.tint[2] = 0.45f + 0.5f * (((hsh >> 16)& 0xFF) / 255.0f);
|
||
// Resolve base texture + blend mode the FAITHFUL way: the material's .mat.txt
|
||
// (libshell's own description) names the diffuse texture (by AssetId or Path) and
|
||
// declares Transparent/Additive. This generalises to ANY OPA home; we fall back to
|
||
// the old name-prefix heuristic only when a material has no .mat.txt.
|
||
const Tex* tex = nullptr;
|
||
const Tex* lmTex = nullptr;
|
||
const MatProps* mp = nullptr;
|
||
if (sub.matIndex < materials.size()) {
|
||
std::string stem = matStem(materials[sub.matIndex].path);
|
||
auto it = matProps.find(stem);
|
||
if (it != matProps.end()) mp = &it->second;
|
||
if (mp) {
|
||
if (!mp->diffuseBase.empty()) tex = texByBase(mp->diffuseBase);
|
||
if (!tex && mp->diffuseId) {
|
||
auto ai = assetIdToTexBase.find(mp->diffuseId);
|
||
if (ai != assetIdToTexBase.end()) tex = texByBase(ai->second);
|
||
}
|
||
if (mp->lightmapId) { // resolve the baked lightmap texture
|
||
auto li = assetIdToTexBase.find(mp->lightmapId);
|
||
if (li != assetIdToTexBase.end()) lmTex = texByBase(li->second);
|
||
}
|
||
}
|
||
if (!tex) { // fallback: old prefix heuristic (spacestation-style names)
|
||
std::string b = matBaseName(materials[sub.matIndex].path);
|
||
if (!b.empty()) tex = bestTexFor(b);
|
||
}
|
||
// DBG (HSR_FOGDBG): decoded texture ALPHA range — the renderer's OWN decode of
|
||
// the sprite, to prove a transparent material's alpha is read faithfully. The
|
||
// per-texture scan is gated so it doesn't slow normal loads.
|
||
static int fogdbg = -1; if (fogdbg<0) fogdbg = std::getenv("HSR_FOGDBG") ? 1 : 0;
|
||
if (fogdbg) {
|
||
int txAMin=255, txAMax=0; double txASum=0; size_t txAN=0;
|
||
if (tex && !tex->rgba.empty())
|
||
for (size_t q=3; q<tex->rgba.size(); q+=4) { int a=tex->rgba[q];
|
||
if(a<txAMin)txAMin=a; if(a>txAMax)txAMax=a; txASum+=a; ++txAN; }
|
||
log(" submesh mat[%u] stem='%s' -> tex '%s' (%ux%u) blend=%d vcolRGB=(%.0f,%.0f,%.0f) vcolA[min=%u max=%u mean=%.0f] texAlpha[min=%d max=%d mean=%.1f]",
|
||
sub.matIndex, stem.c_str(), tex?tex->key.c_str():"<none>",
|
||
tex?tex->w:0, tex?tex->h:0, mp?(int)(mp->transparent||mp->additive):-1,
|
||
aN?aR/aN:0, aN?aG/aN:0, aN?aB/aN:0, aAMin, aAMax, aN?aASum/aN:0,
|
||
txAN?txAMin:0, txAN?txAMax:0, txAN?txASum/txAN:0.0);
|
||
} else
|
||
log(" submesh mat[%u] stem='%s' -> tex '%s' (%ux%u) blend=%d | diffId=%llu lmId=%llu lmTex='%s'",
|
||
sub.matIndex, stem.c_str(), tex?tex->key.c_str():"<none>",
|
||
tex?tex->w:0, tex?tex->h:0, mp?(int)(mp->transparent||mp->additive):-1,
|
||
(unsigned long long)(mp?mp->diffuseId:0), (unsigned long long)(mp?mp->lightmapId:0),
|
||
lmTex?lmTex->key.c_str():"<none>");
|
||
// HSR_VATDBG: flag near-BLACK textures + the mesh world centroid, to hunt down
|
||
// "black rectangle" meshes (a mesh whose UVs land on a black texture region).
|
||
static int vatdbg2 = -1; if (vatdbg2<0) vatdbg2 = std::getenv("HSR_VATDBG") ? 1 : 0;
|
||
if (vatdbg2 && tex && !tex->rgba.empty()) {
|
||
double br=0; size_t np=tex->rgba.size()/4, step=np>4096?np/4096:1, cnt=0;
|
||
for (size_t q=0;q<np;q+=step){ br+=tex->rgba[q*4]+tex->rgba[q*4+1]+tex->rgba[q*4+2]; ++cnt; }
|
||
double mean = cnt? br/(cnt*3):0;
|
||
if (mean < 35.0) {
|
||
const Mat4& nw=(nodeIdx<nodeWorld.size())?nodeWorld[nodeIdx]:world;
|
||
double vr=aN?aR/aN:0, vg=aN?aG/aN:0, vb=aN?aB/aN:0; // avg a_color (vertex colour)
|
||
log(" DARKTEX texmean=%.1f vcol=(%.0f,%.0f,%.0f) mesh#%zu stem='%s' tex='%s'",
|
||
mean, vr,vg,vb, meshes.size(), stem.c_str(), tex->key.c_str());
|
||
}
|
||
}
|
||
}
|
||
// Face culling — FAITHFUL to the material's `DoubleSided` flag (libshell back-face
|
||
// culls single-sided materials). This matters most for TRANSPARENT cards: a
|
||
// DoubleSided:false fog/smoke card drawn with CULL_NONE blends BOTH faces (front +
|
||
// back), ~doubling its apparent density ("fog too visible"). Respecting the flag culls
|
||
// the back face so each card blends once. Default stays doubleSided=true (CULL_NONE)
|
||
// for materials with no .mat.txt, preserving prior OPA behaviour. HSR_NOOPACULL keeps
|
||
// everything double-sided (fallback if a CW-wound mesh culls its visible face).
|
||
// Scope to TRANSPARENT materials: that's where back-face culling has a real effect
|
||
// (a 2-sided blended card blends both faces). For OPAQUE meshes 2-sided vs culled is
|
||
// visually identical (front face covers the back), so we leave them CULL_NONE to avoid
|
||
// any winding-regression risk on other OPA envs.
|
||
static int noOpaCull = -1; if (noOpaCull<0) noOpaCull = std::getenv("HSR_NOOPACULL") ? 1 : 0;
|
||
if (mp && !noOpaCull && (mp->transparent || mp->additive)) md.doubleSided = mp->doubleSided;
|
||
// SpecIbl materials are env-lit by the IBL cubemap (whether or not they have an albedo).
|
||
bool isSpecibl = false;
|
||
if (sub.matIndex < materials.size()) { std::string mpath = materials[sub.matIndex].path;
|
||
for (auto& c : mpath) c=(char)tolower((unsigned char)c); isSpecibl = mpath.find("specibl")!=std::string::npos; }
|
||
if (tex) {
|
||
md.texW = tex->w; md.texH = tex->h; md.hasTexture = true;
|
||
md.texRGBA = tex->rgba;
|
||
md.texSrc = tex->src;
|
||
// CAPTURE-CONFIRMED: no runtime IBL shader exists; textured specibl = diffuse·lightmap
|
||
// (set below) or diffuse-only. Old IBL path is opt-in via HSR_IBL for A/B comparison.
|
||
md.iblLit = isSpecibl && std::getenv("HSR_IBL");
|
||
md.tint[0] = md.tint[1] = md.tint[2] = 1.0f; // texture carries the color
|
||
// Blend from the material spec; if no .mat.txt, fall back to the alpha scan.
|
||
md.useBlend = mp ? (mp->transparent || mp->additive) : tex->hasAlpha;
|
||
md.additive = mp ? mp->additive : false; // god-rays/glow -> ADD blend (not alpha)
|
||
md.alphaTest = mp ? mp->alphaTest : false; // cutout -> opaque pass + discard
|
||
md.alphaCutoff = mp ? mp->alphaCutoff : 0.5f; // AUTHORED discard threshold (zen tree 0.25)
|
||
// Scale the per-mesh texture alpha by the material 'alpha' UNIFORM (libshell does
|
||
// this in the shader). A transparent effect with a fully-opaque texture but a low
|
||
// alpha uniform (forge flicker=0.27) must be a FAINT overlay, not an opaque dark
|
||
// box that occludes everything behind it. Only for alpha-blended (not additive).
|
||
if (md.useBlend && !md.additive && mp && mp->alpha < 0.999f) {
|
||
uint8_t a8 = (uint8_t)(mp->alpha * 255.0f + 0.5f);
|
||
for (size_t q = 3; q < md.texRGBA.size(); q += 4)
|
||
md.texRGBA[q] = (uint8_t)((md.texRGBA[q] * a8) / 255);
|
||
}
|
||
// TEXTURED interior shell that ALSO has a baked lightmap (concrete/floor/walls):
|
||
// libshell shades `diffuse · lightmap · lightmappower`. We sample the lightmap at uv1
|
||
// per vertex, scale by lightmappower, and bake it into the per-vertex colour the frag
|
||
// multiplies by (diffuse · vColor). lightmappower is the coloured neon/glow boost.
|
||
// (Only opaque shells; skip blended/additive effects.) Disables IBL (lightmap is the light).
|
||
if (lmTex && (lmTex->src || !lmTex->full().empty()) && !md.uvs2.empty() && !md.useBlend) {
|
||
// PER-PIXEL lightmap (libshell ShellEnv = diffuse·lightmap·lightmappower). The V79
|
||
// shader samples the 'lightmap' sampler (set2 bind3) at uv1 and multiplies; we
|
||
// pre-bake the per-channel lightmappower into the lightmap texels here (LDR clamp).
|
||
if (lmTex->src) md.lmLdrSrc = lmTex->src; else md.lmRGBA = lmTex->rgba; // shared, still compressed
|
||
md.lmW = lmTex->w; md.lmH = lmTex->h;
|
||
md.hasLightmap = true; md.iblLit = false; md.bakeLightmapVtx = false;
|
||
// FAITHFUL (captured MeshShellEnv frag): lit = diffuse·lightmap·lightmappower applied
|
||
// IN-SHADER (HDR order, clamped once at output) via the per-mesh tint push-constant
|
||
// (renderer pushes UniformColor = tint*lmPow). Previously lightmappower was pre-baked
|
||
// into the 8-bit lightmap with an LDR clamp, which CLIPPED the HDR neon boost
|
||
// (concrete=[3.76,3.21,4.36]) -> washed-out/flat lighting. Keep the lightmap RAW.
|
||
md.lightmapPower[0]=mp?mp->lightmapPower[0]:1.f;
|
||
md.lightmapPower[1]=mp?mp->lightmapPower[1]:1.f;
|
||
md.lightmapPower[2]=mp?mp->lightmapPower[2]:1.f;
|
||
}
|
||
} else if (lmTex && (lmTex->src || !lmTex->full().empty())) {
|
||
// No diffuse texture, but a BAKED LIGHTMAP exists. The interior SHELL meshes
|
||
// (divingHelmet, rubyGem, octo, loft_table/lamp, ...) bake their full lit
|
||
// appearance + surface detail into the lightmap. libshell shades these as
|
||
// `colour = basecolorFactor · lightmap`. We use the lightmap AS the base texture
|
||
// (sampled with uv0 = the lightmap unwrap on these no-albedo merged shells) and
|
||
// multiply the basecolor factor into it. This replaces the flat IBL blob with the
|
||
// real baked detail. Do NOT also IBL-light it — the lightmap already IS the light.
|
||
float lp0 = mp?mp->lightmapPower[0]:1.f, lp1 = mp?mp->lightmapPower[1]:1.f, lp2 = mp?mp->lightmapPower[2]:1.f;
|
||
static float lmGain = -1.f; if (lmGain<0.f){ const char* g=std::getenv("HSR_LMGAIN"); lmGain = g?(float)atof(g):1.0f; }
|
||
if (!md.uvs2.empty() && !std::getenv("HSR_LMBASE")) {
|
||
// FAITHFUL MeshShellEnv: no basecolor texture -> base = BaseColorFactor (white), lit by
|
||
// the lightmap sampled at uv1 (its proper unwrap) × lightmappower applied IN-SHADER via
|
||
// the tint push-constant (HDR, clamped once). Replaces lightmap-as-base in the uv0
|
||
// diffuse slot × guessed 2.5 gain (WRONG uv channel + clipped tone -> "messed up"
|
||
// loft_lamp). loft_lamp/helmet/gem/octo/pencil are single PBR shells; lightmap = uv1.
|
||
md.texW = 1; md.texH = 1; md.hasTexture = true;
|
||
md.texRGBA = {255,255,255,255};
|
||
if (lmTex->src) md.lmLdrSrc = lmTex->src; else md.lmRGBA = lmTex->rgba; // shared, still compressed
|
||
md.lmW = lmTex->w; md.lmH = lmTex->h;
|
||
md.hasLightmap = true; md.bakeLightmapVtx = false;
|
||
md.lightmapPower[0]=lp0; md.lightmapPower[1]=lp1; md.lightmapPower[2]=lp2;
|
||
} else {
|
||
// Fallback: the lightmap is unwrapped in uv0 here (single UV set) -> use it as the uv0
|
||
// base, RAW. lightmappower applied IN-SHADER via tint (gm.lmPow) — NOT pre-baked + LDR
|
||
// clamped, which blew the bright baked shiny detail to WHITE (the "messed up"/textureless
|
||
// lamp). HSR_LMGAIN (default 1.0 = faithful) is an optional global lift folded into power.
|
||
md.texW = lmTex->w; md.texH = lmTex->h; md.hasTexture = true;
|
||
if (lmTex->src) md.texSrc = lmTex->src; else md.texRGBA = lmTex->rgba;
|
||
md.lightmapPower[0]=lp0*lmGain; md.lightmapPower[1]=lp1*lmGain; md.lightmapPower[2]=lp2*lmGain;
|
||
}
|
||
md.tint[0] = md.tint[1] = md.tint[2] = 1.0f;
|
||
md.iblLit = false; md.iblFullSpec = false;
|
||
static int lmdbg = -1; if (lmdbg<0) lmdbg = std::getenv("HSR_LMDBG") ? 1 : 0;
|
||
if (lmdbg) log(" LIGHTMAP-BASE mesh#%zu tex='%s' (%ux%u) lmPow=(%.1f,%.1f,%.1f) gain=%.1f",
|
||
meshes.size(), lmTex->key.c_str(), lmTex->w, lmTex->h, lp0,lp1,lp2,lmGain);
|
||
} else {
|
||
// No diffuse texture. FAITHFUL to libshell's model shader (IDA 0x1e76b0): when a
|
||
// material has no base-colour texture the fragment colour is the colour UNIFORM
|
||
// ITSELF — `lowp vec4 color = BaseColorFactor;` with NO vertex-colour modulation.
|
||
// So a flat ShellEnv material renders as its `diffuse` uniform: oldWest_ember =
|
||
// (0.78,0.53,0.24) ORANGE (was rendering BLACK because we multiplied by the ember's
|
||
// black a_color), black_mtl = (0,0,0). The ONLY surfaces that need the StdData
|
||
// a_color are SpecIbl ground/terrain whose `diffuse` is the neutral [1,1,1] TINT and
|
||
// whose baked albedo lives in the vertex colour — detect that (tint≈white) and use
|
||
// the vertex colour there; otherwise the uniform is the colour.
|
||
md.texW = 1; md.texH = 1; md.hasTexture = true;
|
||
if (isSpecibl && std::getenv("HSR_WHITEDBG")) {
|
||
std::string mstem = (sub.matIndex<materials.size()) ? materials[sub.matIndex].path : std::string("?");
|
||
uint64_t did = mp?mp->diffuseId:0, lid = mp?mp->lightmapId:0;
|
||
auto di = assetIdToTexBase.find(did); auto li2 = assetIdToTexBase.find(lid);
|
||
log(" WHITEDBG mesh#%zu mat='%s' mp=%d diffId=%llu(map=%d tex=%d) lmId=%llu(map=%d tex=%d) diffBase='%s'",
|
||
meshes.size(), mstem.c_str(), (int)(mp!=nullptr),
|
||
(unsigned long long)did, (int)(di!=assetIdToTexBase.end()), (int)(di!=assetIdToTexBase.end() && texByBase(di->second)!=nullptr),
|
||
(unsigned long long)lid, (int)(li2!=assetIdToTexBase.end()), (int)(li2!=assetIdToTexBase.end() && texByBase(li2->second)!=nullptr),
|
||
(mp?mp->diffuseBase.c_str():""));
|
||
}
|
||
bool transp = mp && (mp->transparent || mp->additive);
|
||
float dr = mp ? mp->diffuseColor[0] : 1.f, dg = mp ? mp->diffuseColor[1] : 1.f, db = mp ? mp->diffuseColor[2] : 1.f;
|
||
bool tint = (dr>0.97f && dg>0.97f && db>0.97f); // diffuse==white => pure tint, real colour is a_color
|
||
float vr=1.f, vg=1.f, vb=1.f;
|
||
if (tint && aN) { vr=(float)aR/aN/255.f; vg=(float)aG/aN/255.f; vb=(float)aB/aN/255.f; }
|
||
auto cl = [](float x){ return (u8)(x<0?0:x>1?255:x*255.f+0.5f); };
|
||
if (transp) { md.texRGBA = {255,255,255,0}; md.useBlend = true; }
|
||
else { md.texRGBA = {cl(vr*dr), cl(vg*dg), cl(vb*db), 255};
|
||
// CAPTURE-CONFIRMED (Frida rip of the live shell across all 26 envs): home
|
||
// meshes render ONLY through MeshShellEnv, which has NO IBL/specibl/fresnel
|
||
// branch. The env `_ibl/*_hdri_*.dds` cubemaps are baked into the `lightmap`
|
||
// (Modmap) at COOK time, not sampled at runtime. So a no-texture specibl mesh
|
||
// = BaseColorFactor (the `diffuse` uniform) -> the vr/vg/vb·dr/dg/db colour we
|
||
// just wrote, identical to a flat ShellEnv material. The old phantom split-sum
|
||
// IBL path rendered WHITE when no cubemap was loaded; keep it opt-in (HSR_IBL).
|
||
if (isSpecibl && mp && std::getenv("HSR_IBL")) {
|
||
md.iblLit = true; md.iblFullSpec = true;
|
||
md.texRGBA = {255,255,255,255};
|
||
md.metallic = mp->metallic; md.roughness = mp->roughness;
|
||
md.speciblDiffScale = mp->speciblDiffScale; md.speciblSpecScale = mp->speciblSpecScale;
|
||
md.albedoFactor[0]=dr; md.albedoFactor[1]=dg; md.albedoFactor[2]=db;
|
||
} }
|
||
}
|
||
// Animated node -> stream world positions every frame (renderer keeps the VBO
|
||
// mapped). Record the LOCAL base positions; animate(t) rewrites md.positions.
|
||
if (animated) {
|
||
md.dynamicVerts = true;
|
||
animRecs.push_back({ meshes.size(), (uint32_t)(animNodeBase + nodeIdx), parentWorld, md.positions });
|
||
}
|
||
// UV/flipbook material animation (mat.sanim) keyed by this mesh's geo/node name.
|
||
if (nodeIdx < nodes.size() && matUVAnim.count(nodes[nodeIdx].name)) {
|
||
md.dynamicVerts = true; // positions stay baked; UVs stream each frame
|
||
uvAnimRecs.push_back({ meshes.size(), nodes[nodeIdx].name, md.uvs });
|
||
static int vatdbg = -1; if (vatdbg<0) vatdbg = std::getenv("HSR_VATDBG") ? 1 : 0;
|
||
if (vatdbg) {
|
||
const Mat4& nw = (nodeIdx < nodeWorld.size()) ? nodeWorld[nodeIdx] : parentWorld;
|
||
float u0=1e9f,u1=-1e9f,v0=1e9f,v1=-1e9f;
|
||
for(size_t q=0;q+1<md.uvs.size();q+=2){float u=md.uvs[q],v=md.uvs[q+1];if(u<u0)u0=u;if(u>u1)u1=u;if(v<v0)v0=v;if(v>v1)v1=v;}
|
||
log(" UVANIM mesh#%zu node='%s' fr=%d W=(%.1f,%.1f,%.1f) UV0 u[%.2f,%.2f] v[%.2f,%.2f]",
|
||
meshes.size(), nodes[nodeIdx].name.c_str(), matUVAnim[nodes[nodeIdx].name].nFrames, nw.m[12],nw.m[13],nw.m[14], u0,u1,v0,v1);
|
||
}
|
||
}
|
||
// MaterialTint (per-frame RGBA opacity) — keyed by the SAME geo/node name. The fog
|
||
// (fog_0X_geo_Y) and dust carry an alpha 0..~0.22 fade; apply it as md.curTint each
|
||
// frame so the fragment shader multiplies it in (faithful: frag = texture * tint).
|
||
if (nodeIdx < nodes.size() && matTintAnim.count(nodes[nodeIdx].name)) {
|
||
tintRecs.push_back({ meshes.size(), nodes[nodeIdx].name });
|
||
md.dynamicVerts = true; // so the renderer streams curTint each frame
|
||
const TintTrack& tt = matTintAnim[nodes[nodeIdx].name];
|
||
if (tt.nFrames > 0) { md.curTint[0]=tt.rgba[0]; md.curTint[1]=tt.rgba[1];
|
||
md.curTint[2]=tt.rgba[2]; md.curTint[3]=tt.rgba[3]; }
|
||
// If the tint ALPHA actually fades (min≈0, real swing), this is an ALPHA-BLEND that
|
||
// relies on alpha — flag it so the renderer never routes it to the hard-additive
|
||
// (src=ONE) pipeline (which ignores alpha and freezes the fog/dust at full opacity).
|
||
{ float amn=1e9f, amx=-1e9f; for (int f=0; f<tt.nFrames; ++f) { float a=tt.rgba[(size_t)f*4+3]; if(a<amn)amn=a; if(a>amx)amx=a; }
|
||
if (tt.nFrames > 1 && amx - amn > 0.05f) md.animatedTintAlpha = true; }
|
||
}
|
||
// VAT: keep LOCAL basePos + per-vertex column; animate() adds the per-frame offset
|
||
// and applies the instance world matrix (animate(0) places them at the rest pose).
|
||
if (curVat && vatCols.size() == md.positions.size()/3) {
|
||
md.dynamicVerts = true;
|
||
VatRec vr; vr.meshIdx = meshes.size(); vr.basePos = md.positions;
|
||
vr.col = std::move(vatCols); vr.world = world; vr.vd = curVat;
|
||
vatRecs.push_back(std::move(vr));
|
||
if ((int)curVat->frames > animMaxFrames) animMaxFrames = curVat->frames;
|
||
}
|
||
{ // HSR_VATDBG: dump UV0 range for VAT meshes (one atlas cell vs whole atlas?)
|
||
static int vatdbg = -1; if (vatdbg<0) vatdbg = std::getenv("HSR_VATDBG") ? 1 : 0;
|
||
if (vatdbg && curVat && !md.uvs.empty()) {
|
||
float u0=1e9f,u1=-1e9f,v0=1e9f,v1=-1e9f;
|
||
double cx=0,cy=0,cz=0; size_t nP=md.positions.size()/3;
|
||
for (size_t q=0;q+1<md.uvs.size();q+=2){ float u=md.uvs[q],v=md.uvs[q+1]; if(u<u0)u0=u;if(u>u1)u1=u;if(v<v0)v0=v;if(v>v1)v1=v; }
|
||
for (size_t q=0;q<nP;++q){ cx+=md.positions[q*3];cy+=md.positions[q*3+1];cz+=md.positions[q*3+2]; }
|
||
if(nP){cx/=nP;cy/=nP;cz/=nP;}
|
||
float wc[3]; xform(world,(float)cx,(float)cy,(float)cz,wc);
|
||
log(" VATDBG mesh#%zu '%s' UV0 u[%.3f,%.3f] v[%.3f,%.3f] verts=%zu worldC=(%.2f,%.2f,%.2f)", meshes.size(), curOpaBase.c_str(), u0,u1,v0,v1, nP, wc[0],wc[1],wc[2]);
|
||
}
|
||
}
|
||
const bool shareOpaqueBase = tex && md.hasTexture && !md.useBlend && !md.additive &&
|
||
!md.alphaTest && !md.hasLightmap && !md.hasNormal && !md.hasOrm &&
|
||
!md.hasEmissive && !md.hasVat && md.normalRGBA.empty() && md.ormRGBA.empty() &&
|
||
md.emissiveRGBA.empty() && md.lmRGBA.empty() && md.vatRaw.empty();
|
||
if (shareOpaqueBase) {
|
||
auto owner = opaqueBaseTextureOwner.find(tex);
|
||
if (owner == opaqueBaseTextureOwner.end()) {
|
||
opaqueBaseTextureOwner.emplace(tex, static_cast<int>(meshes.size()));
|
||
} else {
|
||
md.texShareSrc = owner->second;
|
||
std::vector<u8>().swap(md.texRGBA);
|
||
std::vector<u8>().swap(md.srcAstc);
|
||
std::vector<u8>().swap(md.astcRaw);
|
||
}
|
||
}
|
||
meshes.push_back(std::move(md));
|
||
}
|
||
}
|
||
log("emitted %zu renderable submeshes from %zu instances (%zu anim)",
|
||
meshes.size(), instances.size(), animRecs.size());
|
||
return !meshes.empty();
|
||
}
|
||
};
|