muse-engine-32 / engine /check /model-check.js
axonis22's picture
Muse Engine 32: your FBX/GLB scene as the level, skinned characters, Model check, save/load setup
6c78aa4 verified
Raw History Blame Contribute Delete
12 kB
// engine/check/model-check.js - written by Muse Glimmer (Phase 32, part A, 27 Sep 2026; raw output in game/muse-authored/phase32/raw/),
// reviewed by Grok Bot (coach) with game/muse-authored/phase32/harness/. Coach fixes are marked [coach fix P32-n], see FIXES-P32.md.
(function () {
// [coach fix P32-2] one LIMITS object: analyze() used a private copy, so the exported MuseModelCheck.LIMITS was not the one in force
var LIMITS = {
sceneTrisWarn: 300000,
sceneTrisError: 1000000,
charTrisWarn: 100000,
charTrisError: 400000,
partsWarn: 400,
maxBonesDefault: 60,
rootMotionMinM: 0.1,
rootMotionFrac: 0.05,
charHeightMin: 0.5,
charHeightMax: 3.0,
sceneExtentMin: 2,
sceneExtentMax: 5000,
fpsLow: 15,
};
function medianLower(arr) {
var n = arr.length;
if (n === 0) return 0;
var a = arr.slice().sort(function (a, b) {
return a - b;
});
var idx = Math.floor((n - 1) / 2);
return a[idx];
}
function estimateFps(times) {
var t = Array.isArray(times) ? times : Array.from(times || []); // [coach fix P32-2] estimateFps() / estimateFps(undefined) threw
var n = t.length;
if (n === 0) {
return { fps: 0, frames: 0, standard: false };
}
var diffs = [];
for (var i = 1; i < n; i++) {
var d = t[i] - t[i - 1];
if (d >= 1e-6) diffs.push(d);
}
if (diffs.length === 0) {
return { fps: 0, frames: n, standard: false };
}
var dt = medianLower(diffs);
var raw = 1 / dt;
var S = [12, 15, 23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60, 100, 120];
var best = S[0];
var bestDiff = Math.abs(raw - best);
for (var i = 1; i < S.length; i++) {
var d = Math.abs(raw - S[i]);
if (d < bestDiff - 1e-12) {
bestDiff = d;
best = S[i];
}
}
var s = best;
var within = bestDiff <= 0.02 * s;
var fps, standard;
if (within) {
fps = s;
standard = true;
} else {
fps = Math.round(raw * 100) / 100;
standard = false;
}
var duration = t[n - 1] - t[0];
var frames = fps === 0 ? n : Math.round(duration * fps) + 1;
return { fps: fps, frames: frames, standard: standard };
}
function analyze(facts) {
var f = facts || {};
var kind = f.kind || '';
var name = f.name || '';
var format = f.format || '';
var units = f.units || {};
var up = f.up || {};
var triangles = f.triangles || 0;
var parts = f.parts || 0;
var sizeM = f.sizeM || [0, 0, 0];
var textures = f.textures || {};
var scene = f.scene || {};
var rig = f.rig || {};
var warnings = [];
function push(code, level, msg, fix) {
warnings.push({ code: code, level: level, msg: msg, fix: fix });
}
// rule 1
if (kind === 'character') {
var skinned = !!rig.skinned;
if (!skinned) {
push(
'NO_SKIN',
'warn',
'No skinned mesh / skeleton: drawn as a static model scaled into the enemy box (no animation).',
'Export with Skin/Skinning and Deformations enabled, bind the mesh to the skeleton.',
);
// skip 2-6
} else {
var bones = rig.bones || 0;
var maxBones = rig.maxBones || LIMITS.maxBonesDefault; // [coach fix P32-1] spec: maxBones || default (0 / missing = 60); was != null, so 0 printed "62 bones > 0 GPU slots"
var merged = rig.merged || [];
if (bones > maxBones) {
var first8 = merged.slice(0, 8);
var mergedStr = first8.join(', ');
if (merged.length > 8) mergedStr += ', …';
var msg = bones + ' bones > ' + maxBones + ' GPU slots: ' + merged.length + ' minor leaf bones merged into their parents';
if (merged.length) msg += ' (' + mergedStr + ')';
msg += '.';
push('BONES_OVER_LIMIT', 'warn', msg, 'Remove or skip fingers/twist/helper bones on export, or accept the merge.');
}
var clips = rig.clips || [];
if (clips.length === 0) {
push(
'NO_CLIPS',
'warn',
'Skinned but no animation takes: the character stands in its bind pose.',
'Bake animation on export (Maya: Bake Animation; Max: Bake Animation) and include the takes.',
);
} else {
var threshold = Math.max(LIMITS.rootMotionMinM, LIMITS.rootMotionFrac * (rig.heightM || 1.8));
var moving = [];
for (var i = 0; i < clips.length; i++) {
var c = clips[i];
var rm = c.rootMotionM || 0;
if (rm > threshold) {
moving.push(c);
}
}
if (moving.length) {
var level = moving.every(function (c) {
return !!c.stripped;
})
? 'info'
: 'warn';
var partsMsg = moving
.map(function (c) {
return c.name + ' ' + c.rootMotionM.toFixed(2) + ' m';
})
.join(', ');
var msg =
'Root motion in ' +
moving.length +
' clip(s): ' +
partsMsg +
(level === 'info' ? ' (stripped: played in place).' : ' (not stripped: the character slides away from its capsule).');
push('ROOT_MOTION', level, msg, 'Keep the toggle "Strip root motion" on, or export in-place animations.');
}
var roles = rig.roles || {};
var miss = [];
['idle', 'walk', 'run', 'shoot', 'death'].forEach(function (r) {
var idx = roles[r];
if (idx < 0 || idx == null) miss.push(r);
});
if (miss.length) {
push(
'UNMAPPED_ROLES',
'info',
'No clip found for: ' + miss.join(', ') + ' (pick one in the clip picker, or name takes e.g. Idle, Walk, Run, Shoot, Death).',
'Rename the takes or pick clips in the panel.',
);
}
var low = [];
var odd = [];
for (var i = 0; i < clips.length; i++) {
var c = clips[i];
var e = estimateFps(c.times || []);
if (e.fps > 0 && e.fps < LIMITS.fpsLow) {
low.push({ name: c.name, fps: e.fps });
} else if (e.fps > 0 && !e.standard) {
odd.push({ name: c.name, fps: e.fps });
}
}
if (low.length) {
var msg =
'Low key rate in: ' +
low
.map(function (o) {
return o.name + ' ' + o.fps + ' fps';
})
.join(', ') +
'.';
push('LOW_FPS', 'warn', msg, 'Bake at 30 fps (every frame).');
} else if (odd.length) {
var msg =
'Unusual key rate in: ' +
odd
.map(function (o) {
return o.name + ' ' + o.fps + ' fps';
})
.join(', ') +
'.';
push('ODD_FPS', 'info', msg, 'Bake every frame at the scene frame rate (24/25/30/60).');
}
}
}
}
// rule 7
if (kind === 'character') {
var h = rig.heightM || 0;
if (h > 0 && (h < LIMITS.charHeightMin || h > LIMITS.charHeightMax)) {
push(
'ODD_SIZE',
'warn',
'Character is ' + h.toFixed(2) + ' m tall after unit conversion: check the units / scale.',
'Set the units in the panel (Units) or export in centimetres with unit metadata.',
);
}
} else if (kind === 'scene') {
var ext = Math.max(sizeM[0] || 0, sizeM[1] || 0, sizeM[2] || 0);
if (ext > 0 && (ext < LIMITS.sceneExtentMin || ext > LIMITS.sceneExtentMax)) {
push(
'ODD_SIZE',
'warn',
'Scene is ' + ext.toFixed(1) + ' m across after unit conversion: check the units / scale.',
'Set the units in the panel (Units) or export in centimetres with unit metadata.',
);
}
}
// rule 8
if (units && units.source === 'guess') {
var v = units.value || '';
push(
'NO_UNITS_INFO',
'warn',
'No unit information in the file: guessed ' + v + ' from the size.',
'FBX: keep the file units (Maya: Preferences > Settings > Working units; export with Automatic units). glTF is always metres.',
);
}
// rule 9
if (up && up.source === 'guess') {
var uv = up.value || '';
push(
'NO_UP_INFO',
'warn',
'No up-axis information in the file: assumed ' + uv.toUpperCase() + '-up.',
'FBX: export with Up Axis set (Y or Z); the engine converts Z-up to Y-up.',
);
}
// rule 10
var missing = textures.missing || [];
if (missing.length) {
var n = missing.length;
var first6 = missing.slice(0, 6);
var names = first6.join(', ');
if (n > 6) names += ', …';
push(
'MISSING_TEXTURES',
'warn',
n + ' texture(s) not found: ' + names + '. Material colours used instead.',
'Embed media in the FBX (Embed Media) or select the texture files together with the model; for .gltf select the .bin and images too.',
);
}
// rule 11
var warnAt, errorAt;
if (kind === 'scene') {
warnAt = LIMITS.sceneTrisWarn;
errorAt = LIMITS.sceneTrisError;
} else {
warnAt = LIMITS.charTrisWarn;
errorAt = LIMITS.charTrisError;
}
if (triangles >= errorAt) {
push(
'HEAVY_MESH',
'error',
triangles.toLocaleString('en-US') + ' triangles: very heavy for WebGL1, expect low frame rates.',
'Reduce polygons (Maya: Mesh > Reduce; Max: ProOptimizer) or remove hidden detail.',
);
} else if (triangles >= warnAt) {
push(
'HEAVY_MESH',
'warn',
triangles.toLocaleString('en-US') + ' triangles: heavy, frame rate may drop on laptops.',
'Reduce polygons (Maya: Mesh > Reduce; Max: ProOptimizer) or remove hidden detail.',
);
}
// rule 12
if (parts > LIMITS.partsWarn) {
push(
'MANY_PARTS',
'info',
parts + ' draw parts (meshes x materials): the engine batches parts that share a material.',
'Combine meshes that share a material before export.',
);
}
// rule 13
if (kind === 'scene' && scene.mode === 'level') {
var walkable = scene.walkableTris || 0;
if (walkable === 0) {
push(
'NO_FLOOR',
'error',
'No walkable floor found (no upward-facing collision triangles): you will fall.',
'Check the up axis / units, and that floors are not named _nocol.',
);
}
var spawn = scene.spawn;
if (spawn !== 'node') {
push(
'NO_SPAWN',
'info',
'No PlayerStart node: spawning on the nearest open floor.',
'Add an empty/dummy named PlayerStart where the player should start.',
);
}
}
var order = { error: 0, warn: 1, info: 2 };
warnings.sort(function (a, b) {
var oa = order[a.level];
var ob = order[b.level];
if (oa !== ob) return oa - ob;
return 0;
});
var errors = 0,
warns = 0,
infos = 0;
var codes = [];
for (var i = 0; i < warnings.length; i++) {
var w = warnings[i];
codes.push(w.code);
if (w.level === 'error') errors++;
else if (w.level === 'warn') warns++;
else infos++;
}
var level = 'ok';
if (errors > 0) level = 'error';
else if (warns > 0) level = 'warn';
return { level: level, warnings: warnings, summary: { errors: errors, warns: warns, infos: infos, codes: codes } };
}
var MuseModelCheck = {
analyze: analyze,
estimateFps: estimateFps,
LIMITS: LIMITS, // [coach fix P32-2]
};
if (typeof window !== 'undefined') window.MuseModelCheck = MuseModelCheck;
if (typeof module !== 'undefined') module.exports = MuseModelCheck;
})();