incanto 0.43.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/incanto-frame.mjs +55 -11
- package/dist/3d.d.ts +14 -40
- package/dist/3d.js +5 -4
- package/dist/{create-game-B3vBWgVD.js → create-game-CCFUOWfb.js} +14 -79
- package/dist/debug.d.ts +59 -3
- package/dist/debug.js +706 -15
- package/dist/{environment-presets-Ds5kXLoF.js → environment-presets-2GgTlPt1.js} +173 -36
- package/dist/frame-report-Ct8XgsmV.d.ts +80 -0
- package/dist/frame-report-Lr3VO24R.js +197 -0
- package/dist/index.js +1 -1
- package/dist/{physics-3d-CLPFv99o.js → physics-3d-nLI_8bUR.js} +1 -1
- package/dist/react.js +1 -1
- package/dist/{src-CwYxzZKl.js → src-CV_uN7j4.js} +1 -1
- package/dist/{test-BMgiiD5i.js → test-R8JCuDlv.js} +3 -3
- package/dist/test.js +1 -1
- package/dist/vite.d.ts +15 -1
- package/dist/vite.js +49 -6
- package/editor/assets/{agent8-Cl3qFuBB.js → agent8-Cw3Qoi5e.js} +1 -1
- package/editor/assets/debug-Mac205vz.js +3 -0
- package/editor/assets/{index-Dk2ZlO68.js → index-BGYeGNEh.js} +49 -49
- package/editor/index.html +1 -1
- package/package.json +1 -1
- package/skills/incanto-verifying-your-game.md +60 -4
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- package/editor/assets/debug-u3yLCciO.js +0 -3
|
@@ -5958,6 +5958,96 @@ function boneSubtree(root) {
|
|
|
5958
5958
|
return out;
|
|
5959
5959
|
}
|
|
5960
5960
|
//#endregion
|
|
5961
|
+
//#region src/3d/animation/retarget-rig.ts
|
|
5962
|
+
/**
|
|
5963
|
+
* Play someone else's animation without becoming someone else.
|
|
5964
|
+
*
|
|
5965
|
+
* A Mixamo clip is not a list of rotations. It carries a POSITION and a SCALE
|
|
5966
|
+
* track for every bone — 195 tracks for 65 bones on the stock character — and
|
|
5967
|
+
* three's mixer applies all of them. Bind a clip like that to a character by
|
|
5968
|
+
* node name and the clip does not animate the character: it REPLACES the
|
|
5969
|
+
* character's skeleton with the one the clip was authored on. A stocky
|
|
5970
|
+
* four-heads-tall model quietly becomes eight heads tall the moment it idles,
|
|
5971
|
+
* and there is nothing in the scene JSON that says so.
|
|
5972
|
+
*
|
|
5973
|
+
* That is backwards. The model owns its proportions; the animation is the thing
|
|
5974
|
+
* that has to adapt. So only the rotations cross over — they mean the same
|
|
5975
|
+
* thing on any skeleton — and of the translations only the hips survive, scaled
|
|
5976
|
+
* by the ratio of hip heights. That one exception matters: hip travel is in the
|
|
5977
|
+
* SOURCE rig's units, so handing a short character a tall rig's stride slides
|
|
5978
|
+
* its feet and overshoots its jumps.
|
|
5979
|
+
*
|
|
5980
|
+
* Same algorithm the VRM path has always used (`retarget-vrm.ts`), which until
|
|
5981
|
+
* now was the only path that had it.
|
|
5982
|
+
*/
|
|
5983
|
+
/** Mixamo exports this bone as `mixamorigHips` or `mixamorig:Hips`. */
|
|
5984
|
+
function findHips(root) {
|
|
5985
|
+
let found = null;
|
|
5986
|
+
root.traverse((o) => {
|
|
5987
|
+
if (found) return;
|
|
5988
|
+
if (/^mixamorig[:_]?hips$/i.test(o.name) || /^hips$/i.test(o.name)) found = o;
|
|
5989
|
+
});
|
|
5990
|
+
return found;
|
|
5991
|
+
}
|
|
5992
|
+
/** How high the hips sit above the rig's own root, in that rig's own units. */
|
|
5993
|
+
function hipHeight(root, hips) {
|
|
5994
|
+
root.updateMatrixWorld(true);
|
|
5995
|
+
const inRoot = hips.matrixWorld.clone().premultiply(root.matrixWorld.clone().invert());
|
|
5996
|
+
return Math.abs(inRoot.elements[13] ?? 0);
|
|
5997
|
+
}
|
|
5998
|
+
const boneOf = (trackName) => trackName.split(".")[0] ?? "";
|
|
5999
|
+
const propertyOf = (trackName) => trackName.split(".").pop() ?? "";
|
|
6000
|
+
/**
|
|
6001
|
+
* A copy of `clip` that animates `targetRoot` without re-proportioning it.
|
|
6002
|
+
*
|
|
6003
|
+
* Passes the clip through untouched when it cannot do better — a clip whose
|
|
6004
|
+
* bones the target does not have would be stripped to nothing, and a silent
|
|
6005
|
+
* empty animation is worse than a wrong-sized one.
|
|
6006
|
+
*/
|
|
6007
|
+
function retargetClipToRig(clip, sourceScene, targetRoot) {
|
|
6008
|
+
const targetBones = /* @__PURE__ */ new Set();
|
|
6009
|
+
targetRoot.traverse((o) => targetBones.add(o.name));
|
|
6010
|
+
if (clip.tracks.filter((t) => propertyOf(t.name) === "quaternion" && targetBones.has(boneOf(t.name))).length === 0) return {
|
|
6011
|
+
clip,
|
|
6012
|
+
droppedTracks: 0,
|
|
6013
|
+
hipsScale: 1,
|
|
6014
|
+
passthrough: true,
|
|
6015
|
+
reason: "no bone in this clip matches the model — played as authored"
|
|
6016
|
+
};
|
|
6017
|
+
const sourceHips = findHips(sourceScene);
|
|
6018
|
+
const targetHips = findHips(targetRoot);
|
|
6019
|
+
const sourceHeight = sourceHips ? hipHeight(sourceScene, sourceHips) : 0;
|
|
6020
|
+
const targetHeight = targetHips ? hipHeight(targetRoot, targetHips) : 0;
|
|
6021
|
+
const hipsScale = sourceHeight > 1e-6 && targetHeight > 1e-6 ? targetHeight / sourceHeight : 1;
|
|
6022
|
+
const hipsName = targetHips?.name ?? "";
|
|
6023
|
+
const tracks = [];
|
|
6024
|
+
let dropped = 0;
|
|
6025
|
+
for (const track of clip.tracks) {
|
|
6026
|
+
const bone = boneOf(track.name);
|
|
6027
|
+
const property = propertyOf(track.name);
|
|
6028
|
+
if (!targetBones.has(bone)) {
|
|
6029
|
+
dropped += 1;
|
|
6030
|
+
continue;
|
|
6031
|
+
}
|
|
6032
|
+
if (property === "quaternion") {
|
|
6033
|
+
tracks.push(track);
|
|
6034
|
+
continue;
|
|
6035
|
+
}
|
|
6036
|
+
if (property === "position" && bone === hipsName) {
|
|
6037
|
+
const values = Array.from(track.values, (v) => v * hipsScale);
|
|
6038
|
+
tracks.push(new VectorKeyframeTrack(track.name, Array.from(track.times), values));
|
|
6039
|
+
continue;
|
|
6040
|
+
}
|
|
6041
|
+
dropped += 1;
|
|
6042
|
+
}
|
|
6043
|
+
return {
|
|
6044
|
+
clip: new AnimationClip(`${clip.name} (retargeted)`, clip.duration, tracks),
|
|
6045
|
+
droppedTracks: dropped,
|
|
6046
|
+
hipsScale,
|
|
6047
|
+
passthrough: false
|
|
6048
|
+
};
|
|
6049
|
+
}
|
|
6050
|
+
//#endregion
|
|
5961
6051
|
//#region src/3d/animation/rigmap.ts
|
|
5962
6052
|
/**
|
|
5963
6053
|
* Mixamo rig name → VRM humanoid bone name (the vibe-starter-3d /
|
|
@@ -6190,6 +6280,8 @@ var ModelInstance3D = class extends Node3D {
|
|
|
6190
6280
|
entry = null;
|
|
6191
6281
|
mountedRef = "";
|
|
6192
6282
|
fittedHeight = -1;
|
|
6283
|
+
/** A skinned model waiting for a posed skeleton it can be measured against. */
|
|
6284
|
+
fitPending = false;
|
|
6193
6285
|
fitGroup = null;
|
|
6194
6286
|
/**
|
|
6195
6287
|
* @internal The mounted model, or null while the asset is still loading.
|
|
@@ -6340,18 +6432,15 @@ var ModelInstance3D = class extends Node3D {
|
|
|
6340
6432
|
});
|
|
6341
6433
|
});
|
|
6342
6434
|
}
|
|
6343
|
-
if (this.fitGroup
|
|
6344
|
-
this.fittedHeight
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
this.fitGroup.scale.setScalar(1);
|
|
6348
|
-
const size = skinnedAwareBox(this.fitGroup).getSize(new Vector3());
|
|
6349
|
-
const scale = size.y > 0 ? this.targetHeight / size.y : prev;
|
|
6350
|
-
if (scale < .05 || scale > 20) {
|
|
6435
|
+
if (this.fitGroup) {
|
|
6436
|
+
if (this.fittedHeight !== this.targetHeight) {
|
|
6437
|
+
this.fittedHeight = this.targetHeight;
|
|
6438
|
+
if (this.targetHeight <= 0) {
|
|
6351
6439
|
this.fitGroup.scale.setScalar(1);
|
|
6352
|
-
this.
|
|
6353
|
-
} else this.fitGroup.
|
|
6354
|
-
|
|
6440
|
+
this.fitPending = false;
|
|
6441
|
+
} else if (hasSkinning(this.fitGroup)) this.fitPending = true;
|
|
6442
|
+
else this.applyFit();
|
|
6443
|
+
}
|
|
6355
6444
|
}
|
|
6356
6445
|
if (this.fitGroup) this.fitGroup.traverse((obj) => {
|
|
6357
6446
|
const mesh = obj;
|
|
@@ -6365,6 +6454,30 @@ var ModelInstance3D = class extends Node3D {
|
|
|
6365
6454
|
this.syncAnimation(assets);
|
|
6366
6455
|
this.syncUpperAnimation(assets);
|
|
6367
6456
|
}
|
|
6457
|
+
/**
|
|
6458
|
+
* Scale the model so it stands `targetHeight` tall, measured ONCE.
|
|
6459
|
+
*
|
|
6460
|
+
* Once, deliberately: the measurement reflects the pose the model happens to
|
|
6461
|
+
* be in, and re-running it every frame would let a walk cycle resize the
|
|
6462
|
+
* character mid-stride. In practice the pose barely matters — across a whole
|
|
6463
|
+
* idle clip this rig's height moves 0.5% — but "barely" is not "never", and
|
|
6464
|
+
* a size that breathes is worse than one that is half a percent off.
|
|
6465
|
+
*/
|
|
6466
|
+
applyFit() {
|
|
6467
|
+
const fit = this.fitGroup;
|
|
6468
|
+
if (!fit) return;
|
|
6469
|
+
this.fitPending = false;
|
|
6470
|
+
const previous = fit.scale.x;
|
|
6471
|
+
fit.scale.setScalar(1);
|
|
6472
|
+
const height = renderedBox(fit).getSize(new Vector3()).y;
|
|
6473
|
+
const scale = height > 0 ? this.targetHeight / height : previous;
|
|
6474
|
+
if (scale < .05 || scale > 20) {
|
|
6475
|
+
fit.scale.setScalar(1);
|
|
6476
|
+
this.diagnose("warn", `[incanto] ModelInstance3D '${this.name}': targetHeight fit measured an implausible scale (${scale.toFixed(2)}x) — the model may have no geometry. Rendering at the model's authored scale instead.`);
|
|
6477
|
+
return;
|
|
6478
|
+
}
|
|
6479
|
+
fit.scale.setScalar(scale);
|
|
6480
|
+
}
|
|
6368
6481
|
/** Apply per-instance material mods — `tint` (multiplied into colour) and the
|
|
6369
6482
|
* `metalness`/`roughness` overrides — by cloning the captured originals
|
|
6370
6483
|
* (never mutates the shared source), disposing prior clones on change. */
|
|
@@ -6427,7 +6540,12 @@ var ModelInstance3D = class extends Node3D {
|
|
|
6427
6540
|
this.entry.retargeted.set(ref, result.clip);
|
|
6428
6541
|
return result.clip;
|
|
6429
6542
|
}
|
|
6430
|
-
|
|
6543
|
+
const cached = this.entry.retargeted.get(ref);
|
|
6544
|
+
if (cached) return cached;
|
|
6545
|
+
const rig = retargetClipToRig(source, anim.scene, this.fitGroup ?? this._ensureObject3D());
|
|
6546
|
+
if (rig.passthrough) this.diagnose("warn", `incanto: animation '${ref}' — ${rig.reason}`);
|
|
6547
|
+
this.entry.retargeted.set(ref, rig.clip);
|
|
6548
|
+
return rig.clip;
|
|
6431
6549
|
}
|
|
6432
6550
|
/** Masked upper/lower variants of a clip for two-layer playback (cached). */
|
|
6433
6551
|
layerVariants(clip) {
|
|
@@ -6536,6 +6654,7 @@ var ModelInstance3D = class extends Node3D {
|
|
|
6536
6654
|
}
|
|
6537
6655
|
this.mixer?.update(dt);
|
|
6538
6656
|
if (this.entry?.isVrm && this.entry.claimedBy === this) this.entry.vrm?.update(dt);
|
|
6657
|
+
if (this.fitPending) this.applyFit();
|
|
6539
6658
|
}
|
|
6540
6659
|
onExitTree() {
|
|
6541
6660
|
this.unmount(this._ensureObject3D());
|
|
@@ -6560,39 +6679,57 @@ var ModelInstance3D = class extends Node3D {
|
|
|
6560
6679
|
this.fittedHeight = -1;
|
|
6561
6680
|
}
|
|
6562
6681
|
};
|
|
6682
|
+
/** Does anything under here reach the screen through a skeleton? */
|
|
6683
|
+
function hasSkinning(root) {
|
|
6684
|
+
let found = false;
|
|
6685
|
+
root.traverse((o) => {
|
|
6686
|
+
if (o.isSkinnedMesh) found = true;
|
|
6687
|
+
});
|
|
6688
|
+
return found;
|
|
6689
|
+
}
|
|
6563
6690
|
/**
|
|
6564
|
-
*
|
|
6565
|
-
*
|
|
6566
|
-
*
|
|
6567
|
-
*
|
|
6568
|
-
*
|
|
6569
|
-
*
|
|
6691
|
+
* How big the model will actually BE on screen, in the fit group's own space.
|
|
6692
|
+
*
|
|
6693
|
+
* A skinned vertex does not reach the screen through `matrixWorld`. three puts
|
|
6694
|
+
* it through the whole skinning chain
|
|
6695
|
+
*
|
|
6696
|
+
* world = matrixWorld · bindMatrixInverse · boneMatrix · bindMatrix · v
|
|
6697
|
+
*
|
|
6698
|
+
* and on a mixamo rig those terms are not decoration: the armature carries a
|
|
6699
|
+
* 0.01 node scale that lives in one of them and the 100 cancelling it lives in
|
|
6700
|
+
* another. Measuring the geometry box through `matrixWorld` alone — the obvious
|
|
6701
|
+
* thing, and what this did for four releases — reported the character a HUNDRED
|
|
6702
|
+
* times too small, tripped the plausibility guard, and silently threw away
|
|
6703
|
+
* `targetHeight` on every skinned character in four shipped templates.
|
|
6704
|
+
*
|
|
6705
|
+
* Every cheaper proxy is wrong too, and by enough to see: on the same character
|
|
6706
|
+
* the joint positions measure 1.49 and the bind-pose geometry box 1.50 where
|
|
6707
|
+
* the renderer draws 1.66. So skinned meshes are measured the way three itself
|
|
6708
|
+
* measures them — `getVertexPosition`, which walks each vertex through its bone
|
|
6709
|
+
* transforms. Fitting to that gave 1.7000 for a `targetHeight` of 1.7 and
|
|
6710
|
+
* 2.5001 for 2.5.
|
|
6711
|
+
*
|
|
6712
|
+
* Rigid meshes keep their bounding box: it is exact for them and free.
|
|
6570
6713
|
*/
|
|
6571
|
-
function
|
|
6572
|
-
root.
|
|
6714
|
+
function renderedBox(root) {
|
|
6715
|
+
root.updateMatrixWorld(true);
|
|
6573
6716
|
const box = new Box3();
|
|
6574
6717
|
const tmp = new Box3();
|
|
6575
|
-
const invRoot = root.matrixWorld.clone().invert();
|
|
6576
|
-
const m4 = new Matrix4();
|
|
6577
6718
|
const v = new Vector3();
|
|
6719
|
+
const invRoot = root.matrixWorld.clone().invert();
|
|
6578
6720
|
root.traverse((o) => {
|
|
6579
|
-
const skinned = o;
|
|
6580
|
-
if (skinned.isSkinnedMesh && skinned.skeleton) {
|
|
6581
|
-
for (const inverse of skinned.skeleton.boneInverses) {
|
|
6582
|
-
m4.copy(inverse).invert().premultiply(skinned.matrixWorld).premultiply(invRoot);
|
|
6583
|
-
v.setFromMatrixPosition(m4);
|
|
6584
|
-
box.expandByPoint(v);
|
|
6585
|
-
}
|
|
6586
|
-
return;
|
|
6587
|
-
}
|
|
6588
6721
|
const mesh = o;
|
|
6589
|
-
if (mesh.isMesh
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6722
|
+
if (!mesh.isMesh || !mesh.geometry) return;
|
|
6723
|
+
const position = mesh.geometry.getAttribute("position");
|
|
6724
|
+
if (mesh.isSkinnedMesh && typeof mesh.getVertexPosition === "function" && position && mesh.geometry.getAttribute("skinIndex") && mesh.geometry.getAttribute("skinWeight") && position) {
|
|
6725
|
+
for (let i = 0; i < position.count; i++) {
|
|
6726
|
+
mesh.getVertexPosition?.(i, v);
|
|
6727
|
+
box.expandByPoint(v.applyMatrix4(mesh.matrixWorld).applyMatrix4(invRoot));
|
|
6594
6728
|
}
|
|
6729
|
+
return;
|
|
6595
6730
|
}
|
|
6731
|
+
mesh.geometry.computeBoundingBox();
|
|
6732
|
+
if (mesh.geometry.boundingBox) box.union(tmp.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld).applyMatrix4(invRoot));
|
|
6596
6733
|
});
|
|
6597
6734
|
return box;
|
|
6598
6735
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//#region src/3d/frame-report.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* What the frame looks like, as something worth saying.
|
|
4
|
+
*
|
|
5
|
+
* The pixels are not the deliverable. A 1280x720 frame is 2.8 million numbers:
|
|
6
|
+
* unreadable by a person, unaffordable for a model (roughly a thousand tokens
|
|
7
|
+
* as an image, roughly a million as text). The deliverable is the JUDGEMENT —
|
|
8
|
+
* black screen, one flat colour, washed out — and the numbers behind it, small
|
|
9
|
+
* enough to print and compare.
|
|
10
|
+
*
|
|
11
|
+
* Same principle as `framing`'s "lit by: NOTHING": a report is a sentence
|
|
12
|
+
* before it is a number, because a number needs a reader who already knows what
|
|
13
|
+
* to compare it against.
|
|
14
|
+
*
|
|
15
|
+
* Pure arithmetic over an RGBA buffer — no three, no DOM, no GPU. Whoever has
|
|
16
|
+
* the pixels calls this; in practice that is the browser holding the canvas.
|
|
17
|
+
*/
|
|
18
|
+
/** Per-cell mean colour, `[r, g, b]` 0..255. */
|
|
19
|
+
type GridCell = [number, number, number];
|
|
20
|
+
interface FrameStats {
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
/** Every pixel below the black threshold — the render produced nothing. */
|
|
24
|
+
black: boolean;
|
|
25
|
+
/** One colour everywhere: a clear-colour fill, or a camera inside geometry. */
|
|
26
|
+
uniform: boolean;
|
|
27
|
+
/** 0..1 mean relative luminance. */
|
|
28
|
+
meanLuma: number;
|
|
29
|
+
/** Coarse mean colour per cell, rows top to bottom. */
|
|
30
|
+
grid: GridCell[][];
|
|
31
|
+
}
|
|
32
|
+
interface FrameStatsOptions {
|
|
33
|
+
/** Cells across and down. Default 16x9 — 144 cells, printable. */
|
|
34
|
+
grid?: [number, number];
|
|
35
|
+
}
|
|
36
|
+
declare function frameStats(pixels: Uint8Array | Uint8ClampedArray, width: number, height: number, opts?: FrameStatsOptions): FrameStats;
|
|
37
|
+
/** The report a person or an agent reads. Verdict first, numbers after. */
|
|
38
|
+
declare function frameText(stats: FrameStats): string;
|
|
39
|
+
/**
|
|
40
|
+
* Comparison resolution. Fine enough that a one-pixel line moves a cell by
|
|
41
|
+
* several levels at 720p, small enough (6912 bytes) to hold and to send.
|
|
42
|
+
*/
|
|
43
|
+
declare const SIGNATURE_GRID: readonly [number, number];
|
|
44
|
+
interface FrameSignature {
|
|
45
|
+
/** The frame this was made from — a different size is not comparable. */
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
cols: number;
|
|
49
|
+
rows: number;
|
|
50
|
+
/** base64 of `cols * rows * 3` bytes: cell means, rows top to bottom. */
|
|
51
|
+
data: string;
|
|
52
|
+
}
|
|
53
|
+
interface FrameDiff {
|
|
54
|
+
/** False when the two cannot be subtracted at all; see `reason`. */
|
|
55
|
+
comparable: boolean;
|
|
56
|
+
reason?: string;
|
|
57
|
+
/** 0..1 fraction of cells that moved past the threshold. */
|
|
58
|
+
changed: number;
|
|
59
|
+
/** Largest single-channel move, 0..255. */
|
|
60
|
+
maxDelta: number;
|
|
61
|
+
/** Mean single-channel move across the frame, 0..255. */
|
|
62
|
+
meanDelta: number;
|
|
63
|
+
/** Where the change is, in pixels of the compared frame. */
|
|
64
|
+
box: {
|
|
65
|
+
x: number;
|
|
66
|
+
y: number;
|
|
67
|
+
w: number;
|
|
68
|
+
h: number;
|
|
69
|
+
} | null;
|
|
70
|
+
/** A thin full-length band is worth naming: that is what a seam looks like. */
|
|
71
|
+
shape: "vertical band" | "horizontal band" | null;
|
|
72
|
+
}
|
|
73
|
+
declare function frameSignature(pixels: Uint8Array | Uint8ClampedArray, width: number, height: number, grid?: readonly [number, number]): FrameSignature;
|
|
74
|
+
declare function diffSignatures(before: FrameSignature, after: FrameSignature, opts?: {
|
|
75
|
+
threshold?: number;
|
|
76
|
+
}): FrameDiff;
|
|
77
|
+
/** The comparison a person or an agent reads. */
|
|
78
|
+
declare function diffText(diff: FrameDiff): string;
|
|
79
|
+
//#endregion
|
|
80
|
+
export { GridCell as a, diffText as c, frameText as d, FrameStatsOptions as i, frameSignature as l, FrameSignature as n, SIGNATURE_GRID as o, FrameStats as r, diffSignatures as s, FrameDiff as t, frameStats as u };
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
//#region src/3d/frame-report.ts
|
|
2
|
+
/** Rec. 709 relative luminance of an 8-bit triple, 0..1. */
|
|
3
|
+
function luma(r, g, b) {
|
|
4
|
+
return (.2126 * r + .7152 * g + .0722 * b) / 255;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A pixel this dark is indistinguishable from "nothing was drawn". Not zero:
|
|
8
|
+
* a real render of a night scene still lands a few levels above black, and
|
|
9
|
+
* calling that a failure would make the check useless where it matters most.
|
|
10
|
+
*/
|
|
11
|
+
const BLACK_LEVEL = .012;
|
|
12
|
+
/** Cell-to-cell luminance range below this: the frame is one flat colour. */
|
|
13
|
+
const UNIFORM_EPSILON = .004;
|
|
14
|
+
/**
|
|
15
|
+
* Mean colour of every cell of a `cols` x `rows` box filter, as a flat
|
|
16
|
+
* `cols * rows * 3` byte array, rows top to bottom.
|
|
17
|
+
*
|
|
18
|
+
* The one place a frame is reduced. Both the printable report and the
|
|
19
|
+
* comparison signature are this at different resolutions, so a boundary that
|
|
20
|
+
* moves moves for both.
|
|
21
|
+
*/
|
|
22
|
+
function cellMeans(pixels, width, height, cols, rows) {
|
|
23
|
+
const out = new Uint8Array(cols * rows * 3);
|
|
24
|
+
for (let row = 0; row < rows; row++) for (let col = 0; col < cols; col++) {
|
|
25
|
+
const x0 = Math.floor(col * width / cols);
|
|
26
|
+
const x1 = Math.max(x0 + 1, Math.floor((col + 1) * width / cols));
|
|
27
|
+
const y0 = Math.floor(row * height / rows);
|
|
28
|
+
const y1 = Math.max(y0 + 1, Math.floor((row + 1) * height / rows));
|
|
29
|
+
let r = 0;
|
|
30
|
+
let g = 0;
|
|
31
|
+
let b = 0;
|
|
32
|
+
let n = 0;
|
|
33
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
|
34
|
+
const i = (y * width + x) * 4;
|
|
35
|
+
r += pixels[i] ?? 0;
|
|
36
|
+
g += pixels[i + 1] ?? 0;
|
|
37
|
+
b += pixels[i + 2] ?? 0;
|
|
38
|
+
n += 1;
|
|
39
|
+
}
|
|
40
|
+
const o = (row * cols + col) * 3;
|
|
41
|
+
out[o] = Math.round(r / n);
|
|
42
|
+
out[o + 1] = Math.round(g / n);
|
|
43
|
+
out[o + 2] = Math.round(b / n);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
function frameStats(pixels, width, height, opts = {}) {
|
|
48
|
+
const [cols, rows] = opts.grid ?? [16, 9];
|
|
49
|
+
const means = cellMeans(pixels, width, height, cols, rows);
|
|
50
|
+
const grid = [];
|
|
51
|
+
let total = 0;
|
|
52
|
+
let maxLuma = 0;
|
|
53
|
+
let minLuma = 1;
|
|
54
|
+
for (let row = 0; row < rows; row++) {
|
|
55
|
+
const line = [];
|
|
56
|
+
for (let col = 0; col < cols; col++) {
|
|
57
|
+
const o = (row * cols + col) * 3;
|
|
58
|
+
const cell = [
|
|
59
|
+
means[o],
|
|
60
|
+
means[o + 1],
|
|
61
|
+
means[o + 2]
|
|
62
|
+
];
|
|
63
|
+
const cellLuma = luma(cell[0], cell[1], cell[2]);
|
|
64
|
+
total += cellLuma;
|
|
65
|
+
maxLuma = Math.max(maxLuma, cellLuma);
|
|
66
|
+
minLuma = Math.min(minLuma, cellLuma);
|
|
67
|
+
line.push(cell);
|
|
68
|
+
}
|
|
69
|
+
grid.push(line);
|
|
70
|
+
}
|
|
71
|
+
const cells = cols * rows;
|
|
72
|
+
const meanLuma = total / cells;
|
|
73
|
+
return {
|
|
74
|
+
width,
|
|
75
|
+
height,
|
|
76
|
+
black: maxLuma <= BLACK_LEVEL,
|
|
77
|
+
uniform: maxLuma - minLuma < UNIFORM_EPSILON,
|
|
78
|
+
meanLuma: Math.round(meanLuma * 1e3) / 1e3,
|
|
79
|
+
grid
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** The report a person or an agent reads. Verdict first, numbers after. */
|
|
83
|
+
function frameText(stats) {
|
|
84
|
+
const lines = [];
|
|
85
|
+
if (stats.black) lines.push("BLACK SCREEN — nothing was drawn (no light, no camera, or nothing in view)");
|
|
86
|
+
else if (stats.uniform) lines.push("one flat colour — the camera may be inside geometry, or only the sky is drawn");
|
|
87
|
+
lines.push(`frame ${stats.width}×${stats.height} · luminance ${stats.meanLuma.toFixed(2)}`);
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Comparison resolution. Fine enough that a one-pixel line moves a cell by
|
|
92
|
+
* several levels at 720p, small enough (6912 bytes) to hold and to send.
|
|
93
|
+
*/
|
|
94
|
+
const SIGNATURE_GRID = [64, 36];
|
|
95
|
+
/**
|
|
96
|
+
* Below this, a cell has not changed. Compression, dithering and the last bit
|
|
97
|
+
* of a tonemap all wander by a level or two between two captures of the same
|
|
98
|
+
* still frame; calling that a change would bury the real one.
|
|
99
|
+
*/
|
|
100
|
+
const DIFF_THRESHOLD = 3;
|
|
101
|
+
function frameSignature(pixels, width, height, grid = SIGNATURE_GRID) {
|
|
102
|
+
const [cols, rows] = grid;
|
|
103
|
+
return {
|
|
104
|
+
width,
|
|
105
|
+
height,
|
|
106
|
+
cols,
|
|
107
|
+
rows,
|
|
108
|
+
data: toBase64(cellMeans(pixels, width, height, cols, rows))
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function diffSignatures(before, after, opts = {}) {
|
|
112
|
+
const none = {
|
|
113
|
+
changed: 0,
|
|
114
|
+
maxDelta: 0,
|
|
115
|
+
meanDelta: 0,
|
|
116
|
+
box: null,
|
|
117
|
+
shape: null
|
|
118
|
+
};
|
|
119
|
+
if (before.width !== after.width || before.height !== after.height) return {
|
|
120
|
+
comparable: false,
|
|
121
|
+
reason: `frame size changed: ${before.width}×${before.height} → ${after.width}×${after.height}`,
|
|
122
|
+
...none
|
|
123
|
+
};
|
|
124
|
+
if (before.cols !== after.cols || before.rows !== after.rows) return {
|
|
125
|
+
comparable: false,
|
|
126
|
+
reason: "signature grid changed",
|
|
127
|
+
...none
|
|
128
|
+
};
|
|
129
|
+
const threshold = opts.threshold ?? DIFF_THRESHOLD;
|
|
130
|
+
const a = fromBase64(before.data);
|
|
131
|
+
const b = fromBase64(after.data);
|
|
132
|
+
const { cols, rows } = after;
|
|
133
|
+
let changedCells = 0;
|
|
134
|
+
let maxDelta = 0;
|
|
135
|
+
let totalDelta = 0;
|
|
136
|
+
let minCol = cols;
|
|
137
|
+
let maxCol = -1;
|
|
138
|
+
let minRow = rows;
|
|
139
|
+
let maxRow = -1;
|
|
140
|
+
for (let row = 0; row < rows; row++) for (let col = 0; col < cols; col++) {
|
|
141
|
+
const o = (row * cols + col) * 3;
|
|
142
|
+
const delta = Math.max(Math.abs((a[o] ?? 0) - (b[o] ?? 0)), Math.abs((a[o + 1] ?? 0) - (b[o + 1] ?? 0)), Math.abs((a[o + 2] ?? 0) - (b[o + 2] ?? 0)));
|
|
143
|
+
totalDelta += delta;
|
|
144
|
+
maxDelta = Math.max(maxDelta, delta);
|
|
145
|
+
if (delta >= threshold) {
|
|
146
|
+
changedCells += 1;
|
|
147
|
+
minCol = Math.min(minCol, col);
|
|
148
|
+
maxCol = Math.max(maxCol, col);
|
|
149
|
+
minRow = Math.min(minRow, row);
|
|
150
|
+
maxRow = Math.max(maxRow, row);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const cells = cols * rows;
|
|
154
|
+
const box = maxCol < 0 ? null : {
|
|
155
|
+
x: Math.floor(minCol * after.width / cols),
|
|
156
|
+
y: Math.floor(minRow * after.height / rows),
|
|
157
|
+
w: Math.ceil((maxCol + 1 - minCol) * after.width / cols),
|
|
158
|
+
h: Math.ceil((maxRow + 1 - minRow) * after.height / rows)
|
|
159
|
+
};
|
|
160
|
+
const spanCols = maxCol < 0 ? 0 : maxCol + 1 - minCol;
|
|
161
|
+
const spanRows = maxRow < 0 ? 0 : maxRow + 1 - minRow;
|
|
162
|
+
return {
|
|
163
|
+
comparable: true,
|
|
164
|
+
changed: changedCells / cells,
|
|
165
|
+
maxDelta,
|
|
166
|
+
meanDelta: Math.round(totalDelta / cells * 10) / 10,
|
|
167
|
+
box,
|
|
168
|
+
shape: spanCols > 0 && spanCols <= 2 && spanRows >= rows / 2 ? "vertical band" : spanRows > 0 && spanRows <= 2 && spanCols >= cols / 2 ? "horizontal band" : null
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** The comparison a person or an agent reads. */
|
|
172
|
+
function diffText(diff) {
|
|
173
|
+
if (!diff.comparable) return `cannot compare — ${diff.reason ?? "unknown reason"}`;
|
|
174
|
+
if (diff.changed === 0) return `identical — nothing moved (largest cell shift ${diff.maxDelta}/255)`;
|
|
175
|
+
const lines = [`changed ${(diff.changed * 100).toFixed(1)}% of the frame · largest shift ${diff.maxDelta}/255, mean ${diff.meanDelta}`];
|
|
176
|
+
if (diff.box) {
|
|
177
|
+
const { x, y, w, h } = diff.box;
|
|
178
|
+
lines.push(`region x ${x}..${x + w}, y ${y}..${y + h}${diff.shape ? ` — a ${diff.shape}` : ""}`);
|
|
179
|
+
}
|
|
180
|
+
if (diff.changed > .9) lines.push("the whole frame moved — if the scene is animating, pause it first (debug ☰ → Time → Pause) so the comparison is of your change, not of time passing");
|
|
181
|
+
return lines.join("\n");
|
|
182
|
+
}
|
|
183
|
+
/** Portable in a browser and in node; both have had `btoa`/`atob` for years. */
|
|
184
|
+
function toBase64(bytes) {
|
|
185
|
+
let binary = "";
|
|
186
|
+
const CHUNK = 32768;
|
|
187
|
+
for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
188
|
+
return btoa(binary);
|
|
189
|
+
}
|
|
190
|
+
function fromBase64(text) {
|
|
191
|
+
const binary = atob(text);
|
|
192
|
+
const out = new Uint8Array(binary.length);
|
|
193
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
export { frameStats as a, frameSignature as i, diffSignatures as n, frameText as o, diffText as r, SIGNATURE_GRID as t };
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { n as jsonEquals, r as jsonKind, t as jsonClone } from "./json-BLk7H2Qa.
|
|
|
7
7
|
import { a as getNodeSignals, c as mergeStaticSignals, i as getNodeSchema, l as registerNode, n as clearRegistry, o as getNodeType, r as createNode, u as registeredTypes } from "./registry-IyWCGe4q.js";
|
|
8
8
|
import { t as createNoise2D } from "./noise-CGUMx44x.js";
|
|
9
9
|
import { i as applyParticlePreset, n as PARTICLE_PRESETS, r as PARTICLE_PRESET_NAMES, t as ParticleSim } from "./particle-sim-Bw7hB93B.js";
|
|
10
|
-
import { a as findPath, i as preloadUrls, n as newUid, o as gridFromRows, r as assetUrls, t as VERSION } from "./src-
|
|
10
|
+
import { a as findPath, i as preloadUrls, n as newUid, o as gridFromRows, r as assetUrls, t as VERSION } from "./src-CV_uN7j4.js";
|
|
11
11
|
import { i as resolveRendering, n as attachTouchControls, r as joystickVector, t as TouchControls } from "./touch-BoNg_MnF.js";
|
|
12
12
|
import { t as duplicateNode } from "./duplicate-CRtihGmC.js";
|
|
13
13
|
export { AudioBuses, AudioPlayer, BASE_LOCALE, Behavior, CONST_REF_KEY, Engine, HudLayer, IncantoError, InputMap, Localization, LogManager, MusicManager, Node, ORDER_GROUP_BASE, PARTICLE_PRESETS, PARTICLE_PRESET_NAMES, ParticleSim, ROLLOFF_MODELS, Rng, SCENE_FORMAT, SFX_PRESETS, SFX_PRESET_NAMES, SaveSlots, Scene, SceneTree, Settings, SfxEngine, Signal, T_PREFIX, Timer, TouchControls, UiBanner, UiBar, UiButton, UiDialogue, UiImage, UiPanel, UiSelect, UiSlider, UiText, UiToggle, VERSION, WebAudioMusicBackend, applyParticlePreset, assetUrls, attachTouchControls, auditScene, behaviorSchema, behaviorSignals, behaviorsWithoutSave, captureBehaviors, clearBehaviors, clearRegistry, computeViewport, createNode, createNoise2D, createSaveStore, crossfadeGains, duplicateNode, effectiveOrder, fadeGain, findPath, getBehavior, getNodeSchema, getNodeSignals, getNodeType, gridFromRows, isAudioContextAvailable, isConstRef, joystickVector, jsonClone, jsonEquals, jsonKind, loadScene, mergeStaticSignals, newUid, parseNodePath, preloadUrls, qualityEnvironment, readDeviceHints, registerBehavior, registerCoreNodes, registerNode, registeredBehaviors, registeredTypes, replay, resolveConstants, resolveOrderGroups, resolveRendering, resolveViewport, restoreBehaviors, serializeNode, spatialGain, spatialPan, startRecording, suggestLocale, suggestQuality, synthSfx, translationKey };
|
|
@@ -3,7 +3,7 @@ import { v as diagnose } from "./loader-r49nDwB4.js";
|
|
|
3
3
|
import { t as IncantoError } from "./errors-BpWbnbb_.js";
|
|
4
4
|
import { F as PhysicsBody3D, I as RigidBody3D, N as Area3D, P as CharacterBody3D, R as Node3D, z as validateCollider3D } from "./gameplay-BQOeAid6.js";
|
|
5
5
|
import { n as registerDebugSource } from "./debug-draw-BM3DsvtT.js";
|
|
6
|
-
import { A as Terrain3D, F as InstancedMesh3D, L as buildMeshGeometry, P as Joint3D } from "./environment-presets-
|
|
6
|
+
import { A as Terrain3D, F as InstancedMesh3D, L as buildMeshGeometry, P as Joint3D } from "./environment-presets-2GgTlPt1.js";
|
|
7
7
|
import { Euler, Matrix4, Quaternion, Vector3 } from "three";
|
|
8
8
|
//#region src/3d/physics/collider-lines.ts
|
|
9
9
|
/**
|
package/dist/react.js
CHANGED
|
@@ -156,7 +156,7 @@ function IncantoCanvas(props) {
|
|
|
156
156
|
pointer: latest.pointer,
|
|
157
157
|
...keyboard !== void 0 ? { keyboard } : {}
|
|
158
158
|
};
|
|
159
|
-
const next = await (_gameFactory ?? (mode === "3d" ? async (o) => (await import("./create-game-
|
|
159
|
+
const next = await (_gameFactory ?? (mode === "3d" ? async (o) => (await import("./create-game-CCFUOWfb.js").then((n) => n.n)).createGame3D(o) : async (o) => (await import("./create-game-DFRbG0Bj.js").then((n) => n.n)).createGame2D(o)))(opts);
|
|
160
160
|
if (disposed) {
|
|
161
161
|
next.dispose();
|
|
162
162
|
return;
|
|
@@ -161,6 +161,6 @@ function newUid() {
|
|
|
161
161
|
//#endregion
|
|
162
162
|
//#region src/index.ts
|
|
163
163
|
/** Engine version. Kept in sync with package.json by the release pipeline. */
|
|
164
|
-
const VERSION = "0.
|
|
164
|
+
const VERSION = "0.45.0";
|
|
165
165
|
//#endregion
|
|
166
166
|
export { findPath as a, preloadUrls as i, newUid as n, gridFromRows as o, assetUrls as r, VERSION as t };
|
|
@@ -6,7 +6,7 @@ import { n as jsonEquals, t as jsonClone } from "./json-BLk7H2Qa.js";
|
|
|
6
6
|
import { i as getNodeSchema, s as mergeStaticProps } from "./registry-IyWCGe4q.js";
|
|
7
7
|
import { n as registerGameplayBehaviors } from "./gameplay-BQOeAid6.js";
|
|
8
8
|
import { t as registerNodes2D } from "./register-R2JTnIMw.js";
|
|
9
|
-
import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-
|
|
9
|
+
import { n as registerNodes3D, t as resolveEnvironmentHdri } from "./environment-presets-2GgTlPt1.js";
|
|
10
10
|
import { t as registerNodesNet } from "./register-D651it1J.js";
|
|
11
11
|
import { Box3, Euler, Matrix4, PerspectiveCamera, Quaternion, Vector3 } from "three";
|
|
12
12
|
//#region src/test/framing.ts
|
|
@@ -1370,7 +1370,7 @@ async function runScript(json, opts) {
|
|
|
1370
1370
|
const { enablePhysics2D } = await import("./physics-2d-BmgXBNDB.js").then((n) => n.r);
|
|
1371
1371
|
await enablePhysics2D(engine);
|
|
1372
1372
|
} else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
|
|
1373
|
-
const { enablePhysics3D } = await import("./physics-3d-
|
|
1373
|
+
const { enablePhysics3D } = await import("./physics-3d-nLI_8bUR.js").then((n) => n.r);
|
|
1374
1374
|
await enablePhysics3D(engine);
|
|
1375
1375
|
}
|
|
1376
1376
|
const failures = [];
|
|
@@ -1486,7 +1486,7 @@ async function createPlaySession(json, opts = {}) {
|
|
|
1486
1486
|
const { enablePhysics2D } = await import("./physics-2d-BmgXBNDB.js").then((n) => n.r);
|
|
1487
1487
|
await enablePhysics2D(engine);
|
|
1488
1488
|
} else if (physics === "3d" || physics === "auto" && scene.dimension === "3d") {
|
|
1489
|
-
const { enablePhysics3D } = await import("./physics-3d-
|
|
1489
|
+
const { enablePhysics3D } = await import("./physics-3d-nLI_8bUR.js").then((n) => n.r);
|
|
1490
1490
|
await enablePhysics3D(engine);
|
|
1491
1491
|
}
|
|
1492
1492
|
const stepMs = 1e3 / (opts.fixedHz ?? 60);
|
package/dist/test.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { r as auditScene } from "./replay-DilbZgQI.js";
|
|
2
|
-
import { _ as framingText, a as registerAllNodes, c as feelReport, d as facingText, f as failingReplays, g as describeFraming, h as playtestText, i as findFloatingProps, l as feelText, m as playtest, n as createPlaySession, o as runScript, p as findPlayer, r as describeCapture, s as validateScene, t as captureScene, u as facingReport } from "./test-
|
|
2
|
+
import { _ as framingText, a as registerAllNodes, c as feelReport, d as facingText, f as failingReplays, g as describeFraming, h as playtestText, i as findFloatingProps, l as feelText, m as playtest, n as createPlaySession, o as runScript, p as findPlayer, r as describeCapture, s as validateScene, t as captureScene, u as facingReport } from "./test-R8JCuDlv.js";
|
|
3
3
|
export { auditScene, captureScene, createPlaySession, describeCapture, describeFraming, facingReport, facingText, failingReplays, feelReport, feelText, findFloatingProps, findPlayer, framingText, playtest, playtestText, registerAllNodes, runScript, validateScene };
|
package/dist/vite.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { n as FrameSignature } from "./frame-report-Ct8XgsmV.js";
|
|
2
|
+
|
|
1
3
|
//#region src/vite/discover.d.ts
|
|
2
4
|
/**
|
|
3
5
|
* The listening TCP ports in `/proc/net/tcp` (and `tcp6`) format.
|
|
@@ -25,7 +27,7 @@ interface FrameHost {
|
|
|
25
27
|
root?: string;
|
|
26
28
|
};
|
|
27
29
|
middlewares: {
|
|
28
|
-
use(path: string, handler: (req:
|
|
30
|
+
use(path: string, handler: (req: FrameReq, res: FrameRes) => void): void;
|
|
29
31
|
};
|
|
30
32
|
/** Vite's HMR channel. `ws` on v5, `hot` on v6+ — both are accepted. */
|
|
31
33
|
ws?: FrameChannel;
|
|
@@ -44,10 +46,22 @@ interface FrameRes {
|
|
|
44
46
|
setHeader(name: string, value: string): void;
|
|
45
47
|
end(body?: string): void;
|
|
46
48
|
}
|
|
49
|
+
/** Connect strips the mount path off `url`; `originalUrl` keeps the query. */
|
|
50
|
+
interface FrameReq {
|
|
51
|
+
url?: string;
|
|
52
|
+
originalUrl?: string;
|
|
53
|
+
}
|
|
47
54
|
interface FrameEndpointOptions {
|
|
48
55
|
/** Overridden in tests; defaults to the real clock. */
|
|
49
56
|
now?: () => number;
|
|
50
57
|
timeoutMs?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Remembered frames, by label. Lives in the dev server's MEMORY on purpose:
|
|
60
|
+
* a baseline is only valid for the browser and the window that made it, so it
|
|
61
|
+
* should die with the server rather than linger in the project as a file
|
|
62
|
+
* nobody asked for.
|
|
63
|
+
*/
|
|
64
|
+
store?: Map<string, FrameSignature>;
|
|
51
65
|
}
|
|
52
66
|
/**
|
|
53
67
|
* Wire `/__incanto/ping` and `/__incanto/frame` onto a dev server.
|