@call-me-sensei/toonlab 0.4.19 → 0.4.21
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/NPM-LIBRARY.md +230 -0
- package/README.md +78 -36
- package/agents/PROMPTS.md +91 -0
- package/agents/README.md +9 -14
- package/agents/claude/CLAUDE.md +8 -0
- package/agents/codex/AGENTS.md +161 -0
- package/agents/cursor/toonlab.mdc +37 -0
- package/agents/references/mcp-asset-discovery.md +12 -9
- package/agents/references/runtime-entry-points.md +5 -4
- package/agents/skills/claude/asset-sourcing/SKILL.md +9 -6
- package/agents/skills/claude/rock-ground-shaders/SKILL.md +16 -0
- package/agents/skills/claude/vegetation-sky/SKILL.md +7 -4
- package/agents/skills/codex/asset-sourcing/SKILL.md +9 -6
- package/agents/skills/codex/rock-ground-shaders/SKILL.md +16 -0
- package/agents/skills/codex/vegetation-sky/SKILL.md +7 -4
- package/cli/toonlab.mjs +3 -2
- package/database/fal-patina.mjs +131 -0
- package/database/generation-service.mjs +108 -7
- package/database/migrations/0007_creation_revisions.sql +80 -0
- package/database/migrations/0008_creation_revision_hardening.sql +98 -0
- package/database/migrations/0009_creation_revision_delete_integrity.sql +11 -0
- package/database/migrations/0010_external_catalog_assets.sql +28 -0
- package/database/providers.mjs +195 -16
- package/database/repository.mjs +518 -70
- package/database/seeds/catalog/0003_2026-08-open-assets.sql +159086 -0
- package/database/seeds/catalog/0004_2026-08-c7-rocks.sql +39405 -0
- package/mcp/public-catalog.mjs +4 -52
- package/mcp/server.mjs +175 -12
- package/mcp/vite-plugin.mjs +78 -2
- package/package.json +32 -12
- package/scripts/generate-catalog-seed.mjs +34 -7
- package/src/asset-policy/catalogLicenses.js +64 -6
- package/src/assetlib/loadImported.js +15 -1
- package/src/catalog/officialCatalogAssetRuntime.js +13 -2
- package/src/catalog/officialCatalogLod.js +34 -1
- package/src/catalog/officialCatalogPlacement.js +29 -14
- package/src/character/animationRetarget.js +2 -0
- package/src/character/characterRig.js +6 -1
- package/src/character/characterRuntime.js +175 -7
- package/src/environment/environmentMaterialAdapter.js +30 -0
- package/src/environment/environmentPresets.js +90 -0
- package/src/environment/environmentSunShadowPass.js +42 -0
- package/src/environment/toonLabSurfaceLighting.js +108 -10
- package/src/environment/urbanPropMaterial.js +104 -2
- package/src/lighting/lightingSystem.js +65 -10
- package/src/renderer/index.js +7 -0
- package/src/renderer/styleComparison.js +985 -0
- package/src/rock-shader/index.js +5 -0
- package/src/rock-shader/rockGeometryDetail.js +701 -0
- package/src/rock-shader/rockMaterial.js +782 -39
- package/src/rock-shader/rockRegionRuntime.js +185 -0
- package/src/rock-shader/rockSemanticMaterialRuntime.js +337 -0
- package/src/rock-shader/rockShaderRuntime.js +354 -67
- package/src/rock-shader/rockShaderSettings.js +246 -16
- package/src/rock-shader/rockTangentIntegrity.js +136 -0
- package/src/rock-shader/rockTextureIntegrity.js +115 -0
- package/src/rockgen/index.js +1 -0
- package/src/rockgen/rockDocument.js +27 -3
- package/src/rockgen/surface/c7GeologySurface.js +372 -0
- package/src/shaders-tsl/water.js +8 -0
- package/src/sky/cloudShadow.js +25 -1
- package/src/sky/skySystem.js +9 -0
- package/src/styles/index.js +23 -0
- package/src/styles/neutralStylePresets.js +712 -0
- package/src/styles/sceneStyleRuntime.js +51 -4
- package/src/styles/styleAdapters.js +13 -2
- package/src/styles/styleBundle.js +1 -1
- package/src/texgen/evaluateTexture.js +30 -3
- package/src/texgen/textureGenerators.js +63 -31
- package/src/texgen/textureSettings.js +25 -8
- package/src/toon/toonSettings.js +96 -2
- package/src/vegetation/branchTree.js +327 -42
- package/src/vegetation/index.js +24 -2
- package/src/vegetation/scatter.js +372 -1
- package/src/vegetation/stylizedTree.js +312 -126
- package/src/vegetation/stylizedTreeFoliage.js +229 -7
- package/src/vegetation/treeSurfaceTextures.js +328 -0
- package/src/version.js +1 -1
- package/src/water/waterSettings.js +15 -7
- package/types/asset-policy/catalogLicenses.d.ts +2 -0
- package/types/asset-policy/index.d.ts +2 -0
- package/types/assetlib/loadImported.d.ts +3 -1
- package/types/catalog/officialCatalogLod.d.ts +63 -5
- package/types/character/characterRig.d.ts +59 -0
- package/types/character/characterRuntime.d.ts +1 -0
- package/types/environment/environmentMaterialAdapter.d.ts +3 -1
- package/types/environment/toonLabSurfaceLighting.d.ts +20 -3
- package/types/index.d.ts +89 -7
- package/types/lighting/lightingSystem.d.ts +7 -0
- package/types/react/index.d.ts +20 -0
- package/types/renderer/index.d.ts +1 -0
- package/types/renderer/styleComparison.d.ts +409 -0
- package/types/rock-shader/index.d.ts +5 -0
- package/types/rock-shader/rockGeometryDetail.d.ts +300 -0
- package/types/rock-shader/rockMaterial.d.ts +3 -0
- package/types/rock-shader/rockRegionRuntime.d.ts +86 -0
- package/types/rock-shader/rockSemanticMaterialRuntime.d.ts +29 -0
- package/types/rock-shader/rockShaderRuntime.d.ts +2 -0
- package/types/rock-shader/rockShaderSettings.d.ts +19 -5
- package/types/rock-shader/rockTangentIntegrity.d.ts +51 -0
- package/types/rock-shader/rockTextureIntegrity.d.ts +61 -0
- package/types/rockgen/index.d.ts +1 -0
- package/types/rockgen/rockDocument.d.ts +29 -1
- package/types/rockgen/surface/c7GeologySurface.d.ts +8 -0
- package/types/sky/cloudShadow.d.ts +1 -0
- package/types/styles/index.d.ts +1 -0
- package/types/styles/neutralStylePresets.d.ts +944 -0
- package/types/styles/sceneStyleRuntime.d.ts +20 -0
- package/types/vegetation/branchTree.d.ts +4 -0
- package/types/vegetation/index.d.ts +9 -7
- package/types/vegetation/scatter.d.ts +1 -0
- package/types/vegetation/stylizedTree.d.ts +1 -2
- package/types/vegetation/stylizedTreeFoliage.d.ts +1 -0
- package/types/version.d.ts +1 -1
- package/agents/references/geology-playbook.md +0 -118
- package/agents/skills/claude/karst-cliff-construction/SKILL.md +0 -258
- package/agents/skills/claude/outdoor-world/SKILL.md +0 -492
- package/agents/skills/claude/outdoor-world/references/coastal-landform-formation.md +0 -100
- package/agents/skills/codex/karst-cliff-construction/SKILL.md +0 -258
- package/agents/skills/codex/outdoor-world/SKILL.md +0 -492
- package/agents/skills/codex/outdoor-world/references/coastal-landform-formation.md +0 -100
- package/src/vegetation/legacyTreePresets.js +0 -120
- package/types/vegetation/legacyTreePresets.d.ts +0 -22
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
// Style comparison — a single-load, dual-material A/B render path.
|
|
2
|
+
//
|
|
3
|
+
// A neutral-versus-styled wipe is a flagship ToonLab demonstration, and the
|
|
4
|
+
// only honest way to render one is to draw the SAME geometry buffers twice in
|
|
5
|
+
// the same frame with a different material treatment. Loading the subject
|
|
6
|
+
// twice (two GLB loads, two skeletons, two AnimationMixers, two scenes) cannot
|
|
7
|
+
// guarantee identical animation time or identical geometry, so the claim
|
|
8
|
+
// "only the shading differs" is not provable — it is merely plausible.
|
|
9
|
+
//
|
|
10
|
+
// This module makes the A/B a renderer feature instead of something every
|
|
11
|
+
// consumer re-implements incorrectly:
|
|
12
|
+
//
|
|
13
|
+
// const ab = createStyleComparison({ renderer, scene, camera });
|
|
14
|
+
// ab.track(character.carrier);
|
|
15
|
+
// ab.capture('neutral'); // before any style is applied
|
|
16
|
+
// applyToonShader(character.modelRoot, { preset: 'call_me_sensei' });
|
|
17
|
+
// ab.capture('toonlab'); // after
|
|
18
|
+
// ab.setVariants({ after: 'toonlab', before: 'neutral' });
|
|
19
|
+
// // per frame, INSTEAD of renderer.render(scene, camera):
|
|
20
|
+
// ab.render();
|
|
21
|
+
//
|
|
22
|
+
// One load, one skeleton, one animation clock, one camera, one light rig, one
|
|
23
|
+
// exposure. The two halves differ by material assignment and nothing else,
|
|
24
|
+
// because nothing else is touched between the two `renderer.render` calls.
|
|
25
|
+
//
|
|
26
|
+
// Why not the style transaction: `setTargetEnabled(id, false)` performs a full
|
|
27
|
+
// restoreMaterial traverse plus outline/fur child teardown and rebuild
|
|
28
|
+
// (src/styles/styleTransaction.js). That is the correct ONE-TIME primitive for
|
|
29
|
+
// building a variant, and far too expensive to run twice per frame at 60 fps.
|
|
30
|
+
// `capture()` is that one-time cost; `activate()` is a Map walk that assigns
|
|
31
|
+
// material references.
|
|
32
|
+
//
|
|
33
|
+
// Renderer support: any three.js renderer exposing setScissor/setScissorTest
|
|
34
|
+
// (WebGPURenderer on either backend, WebGLRenderer). The pixel-identity proof
|
|
35
|
+
// additionally needs `readRenderTargetPixelsAsync`.
|
|
36
|
+
|
|
37
|
+
import * as THREE from 'three';
|
|
38
|
+
|
|
39
|
+
/** Split axes. `vertical` is a vertical divider; `horizontal` is a horizontal one. */
|
|
40
|
+
export const STYLE_COMPARISON_AXES = Object.freeze(['horizontal', 'vertical']);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The renderer surface this module needs. Declared structurally rather than as
|
|
44
|
+
* `WebGPURenderer`, because that class lives in the `three/webgpu` entry point
|
|
45
|
+
* and is not exported from `three` — naming it here would make the generated
|
|
46
|
+
* declaration unresolvable and silently demote this module to a permissive
|
|
47
|
+
* `any` contract. WebGPURenderer (either backend) and WebGLRenderer both
|
|
48
|
+
* satisfy it.
|
|
49
|
+
*
|
|
50
|
+
* @typedef {object} StyleComparisonRenderer
|
|
51
|
+
* @property {(scene: THREE.Object3D, camera: THREE.Camera) => unknown} render
|
|
52
|
+
* @property {(x: number, y: number, width: number, height: number) => void} setScissor
|
|
53
|
+
* @property {(enabled: boolean) => void} setScissorTest
|
|
54
|
+
* @property {(target: THREE.Vector4) => THREE.Vector4} getScissor
|
|
55
|
+
* @property {() => boolean} getScissorTest
|
|
56
|
+
* @property {(target: THREE.RenderTarget|null) => void} setRenderTarget
|
|
57
|
+
* @property {() => THREE.RenderTarget|null} getRenderTarget
|
|
58
|
+
* @property {(target: THREE.Vector2|THREE.Vector4) => unknown} getSize
|
|
59
|
+
* @property {(color: THREE.Color) => THREE.Color} getClearColor
|
|
60
|
+
* @property {boolean} autoClear
|
|
61
|
+
* @property {boolean} autoClearColor
|
|
62
|
+
* @property {boolean} autoClearDepth
|
|
63
|
+
* @property {boolean} autoClearStencil
|
|
64
|
+
* @property {number} toneMappingExposure
|
|
65
|
+
*
|
|
66
|
+
* @typedef {'horizontal'|'vertical'} StyleComparisonAxis
|
|
67
|
+
*
|
|
68
|
+
* @typedef {THREE.RenderTarget|null} StyleComparisonTarget
|
|
69
|
+
*
|
|
70
|
+
* @typedef {object} StyleComparisonRect
|
|
71
|
+
* @property {number} height
|
|
72
|
+
* @property {number} width
|
|
73
|
+
* @property {number} x
|
|
74
|
+
* @property {number} y
|
|
75
|
+
*
|
|
76
|
+
* @typedef {object} StyleComparisonSize
|
|
77
|
+
* @property {number} height
|
|
78
|
+
* @property {number} width
|
|
79
|
+
*
|
|
80
|
+
* @typedef {object} StyleComparisonVariantPair
|
|
81
|
+
* @property {string} after Variant rendered full-frame.
|
|
82
|
+
* @property {string} before Variant rendered into the scissored region.
|
|
83
|
+
*
|
|
84
|
+
* @typedef {object} StyleComparisonRenderOptions
|
|
85
|
+
* @property {number} [split] Fraction of the frame showing `before`, 0..1.
|
|
86
|
+
* Defaults to the comparison's current split.
|
|
87
|
+
* @property {StyleComparisonTarget} [target] Render into this target instead of
|
|
88
|
+
* the canvas. Omit for the canvas.
|
|
89
|
+
*
|
|
90
|
+
* @typedef {object} StyleComparisonVariantRenderOptions
|
|
91
|
+
* @property {StyleComparisonTarget} [target]
|
|
92
|
+
*
|
|
93
|
+
* @typedef {object} StyleComparisonRenderResult
|
|
94
|
+
* @property {number} fraction
|
|
95
|
+
* @property {StyleComparisonSize} size
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Child meshes a style treatment generates on top of the subject. They exist
|
|
100
|
+
* only in the styled variant, so a variant captured before the style was
|
|
101
|
+
* applied does not know them and must hide — never remove — them.
|
|
102
|
+
*/
|
|
103
|
+
export const STYLE_COMPARISON_GENERATED_NODE_FLAGS = Object.freeze([
|
|
104
|
+
'isToonOutline',
|
|
105
|
+
'isToonFurShell',
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
function isRenderable(node) {
|
|
109
|
+
return Boolean(node?.isMesh || node?.isSkinnedMesh || node?.isPoints
|
|
110
|
+
|| node?.isLine || node?.isSprite || node?.isInstancedMesh);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function materialList(material) {
|
|
114
|
+
if (!material) return [];
|
|
115
|
+
return Array.isArray(material) ? material : [material];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function clamp01(value) {
|
|
119
|
+
const number = Number(value);
|
|
120
|
+
if (!Number.isFinite(number)) return 0;
|
|
121
|
+
return Math.min(1, Math.max(0, number));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function captureNodeState(node) {
|
|
125
|
+
return {
|
|
126
|
+
castShadow: node.castShadow,
|
|
127
|
+
// Geometry, skeleton and morph influences are captured for the identity
|
|
128
|
+
// audit and are NEVER re-applied. A variant that changed them would not be
|
|
129
|
+
// a material treatment.
|
|
130
|
+
geometry: node.geometry ?? null,
|
|
131
|
+
material: Array.isArray(node.material) ? [...node.material] : (node.material ?? null),
|
|
132
|
+
morphTargetInfluences: node.morphTargetInfluences ?? null,
|
|
133
|
+
receiveShadow: node.receiveShadow,
|
|
134
|
+
renderOrder: node.renderOrder,
|
|
135
|
+
skeleton: node.skeleton ?? null,
|
|
136
|
+
visible: node.visible,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function applyNodeState(node, state) {
|
|
141
|
+
node.material = Array.isArray(state.material) ? [...state.material] : state.material;
|
|
142
|
+
node.castShadow = state.castShadow;
|
|
143
|
+
node.receiveShadow = state.receiveShadow;
|
|
144
|
+
node.renderOrder = state.renderOrder;
|
|
145
|
+
node.visible = state.visible;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function matrixSignature(matrix) {
|
|
149
|
+
return matrix ? matrix.elements.map((value) => value.toFixed(9)).join(',') : '';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function collectLightSignature(scene) {
|
|
153
|
+
const lights = [];
|
|
154
|
+
scene.traverse((node) => {
|
|
155
|
+
if (!node?.isLight) return;
|
|
156
|
+
node.updateWorldMatrix(true, false);
|
|
157
|
+
lights.push({
|
|
158
|
+
color: node.color ? node.color.getHex() : null,
|
|
159
|
+
intensity: Number(node.intensity ?? 0),
|
|
160
|
+
matrixWorld: matrixSignature(node.matrixWorld),
|
|
161
|
+
name: node.name || node.type,
|
|
162
|
+
shadowBias: node.shadow ? Number(node.shadow.bias ?? 0) : null,
|
|
163
|
+
type: node.type,
|
|
164
|
+
uuid: node.uuid,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
lights.sort((a, b) => (a.uuid < b.uuid ? -1 : 1));
|
|
168
|
+
return lights;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function collectMixerSignature(roots) {
|
|
172
|
+
const mixers = [];
|
|
173
|
+
const seen = new Set();
|
|
174
|
+
for (const root of roots) {
|
|
175
|
+
root.traverse((node) => {
|
|
176
|
+
const mixer = node?.userData?.toonlabAnimationMixer;
|
|
177
|
+
if (mixer && !seen.has(mixer)) {
|
|
178
|
+
seen.add(mixer);
|
|
179
|
+
mixers.push({ time: Number(mixer.time ?? 0) });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return mixers;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Snapshot of everything the §11 contract requires to be equal across both
|
|
188
|
+
* halves: camera matrices, light transforms, exposure and render state, plus
|
|
189
|
+
* the animation clock of any registered mixer. Compared before and after a
|
|
190
|
+
* composite render — if the wipe moved any of them, the A/B is not honest.
|
|
191
|
+
*/
|
|
192
|
+
export function captureComparisonFrameState({ camera, mixers = [], renderer, scene }) {
|
|
193
|
+
camera.updateMatrixWorld();
|
|
194
|
+
return {
|
|
195
|
+
camera: {
|
|
196
|
+
far: Number(camera.far ?? 0),
|
|
197
|
+
fov: Number(camera.fov ?? 0),
|
|
198
|
+
matrixWorld: matrixSignature(camera.matrixWorld),
|
|
199
|
+
matrixWorldInverse: matrixSignature(camera.matrixWorldInverse),
|
|
200
|
+
near: Number(camera.near ?? 0),
|
|
201
|
+
projectionMatrix: matrixSignature(camera.projectionMatrix),
|
|
202
|
+
zoom: Number(camera.zoom ?? 1),
|
|
203
|
+
},
|
|
204
|
+
lights: collectLightSignature(scene),
|
|
205
|
+
mixers: mixers.map((mixer) => ({ time: Number(mixer?.time ?? 0) })),
|
|
206
|
+
renderState: {
|
|
207
|
+
outputColorSpace: renderer.outputColorSpace,
|
|
208
|
+
shadowMapEnabled: Boolean(renderer.shadowMap?.enabled),
|
|
209
|
+
shadowMapType: renderer.shadowMap?.type ?? null,
|
|
210
|
+
toneMapping: renderer.toneMapping,
|
|
211
|
+
toneMappingExposure: Number(renderer.toneMappingExposure ?? 1),
|
|
212
|
+
},
|
|
213
|
+
scene: {
|
|
214
|
+
backgroundIntensity: Number(scene.backgroundIntensity ?? 1),
|
|
215
|
+
environmentIntensity: Number(scene.environmentIntensity ?? 1),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function diffFrameState(before, after, path = '', issues = []) {
|
|
221
|
+
if (before === after) return issues;
|
|
222
|
+
if (Array.isArray(before) || Array.isArray(after)) {
|
|
223
|
+
const a = Array.isArray(before) ? before : [];
|
|
224
|
+
const b = Array.isArray(after) ? after : [];
|
|
225
|
+
if (a.length !== b.length) issues.push(`${path}.length ${a.length} → ${b.length}`);
|
|
226
|
+
for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
|
|
227
|
+
diffFrameState(a[index], b[index], `${path}[${index}]`, issues);
|
|
228
|
+
}
|
|
229
|
+
return issues;
|
|
230
|
+
}
|
|
231
|
+
if (before && after && typeof before === 'object' && typeof after === 'object') {
|
|
232
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
233
|
+
diffFrameState(before[key], after[key], path ? `${path}.${key}` : key, issues);
|
|
234
|
+
}
|
|
235
|
+
return issues;
|
|
236
|
+
}
|
|
237
|
+
if (before !== after) issues.push(`${path}: ${String(before)} → ${String(after)}`);
|
|
238
|
+
return issues;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Creates a single-load A/B comparison over one scene and one camera.
|
|
243
|
+
*
|
|
244
|
+
* @param {object} options
|
|
245
|
+
* @param {StyleComparisonRenderer} options.renderer
|
|
246
|
+
* @param {THREE.Scene} options.scene
|
|
247
|
+
* @param {THREE.Camera} options.camera
|
|
248
|
+
* @param {'vertical'|'horizontal'} [options.axis='vertical'] Divider orientation.
|
|
249
|
+
* `vertical` puts the `before` variant on the left; `horizontal` puts it on
|
|
250
|
+
* the bottom.
|
|
251
|
+
* @param {number} [options.split=0.5] Fraction of the frame showing `before`.
|
|
252
|
+
* @param {{after: string, before: string}} [options.variants]
|
|
253
|
+
* @param {boolean} [options.refreshRenderPasses=true] Call each tracked root's
|
|
254
|
+
* `userData.toonlabCharacterStyleIntegration.refresh()` after a swap so the
|
|
255
|
+
* depth prepass / self-shadow target binds the live materials.
|
|
256
|
+
*/
|
|
257
|
+
export function createStyleComparison({
|
|
258
|
+
axis = 'vertical',
|
|
259
|
+
camera,
|
|
260
|
+
refreshRenderPasses = true,
|
|
261
|
+
renderer,
|
|
262
|
+
scene,
|
|
263
|
+
split = 0.5,
|
|
264
|
+
variants = { after: 'toonlab', before: 'neutral' },
|
|
265
|
+
} = {}) {
|
|
266
|
+
if (!renderer || typeof renderer.render !== 'function') {
|
|
267
|
+
throw new TypeError('createStyleComparison requires a three.js renderer.');
|
|
268
|
+
}
|
|
269
|
+
if (!scene?.isScene) throw new TypeError('createStyleComparison requires a THREE.Scene.');
|
|
270
|
+
if (!camera?.isCamera) throw new TypeError('createStyleComparison requires a THREE.Camera.');
|
|
271
|
+
if (!STYLE_COMPARISON_AXES.includes(axis)) {
|
|
272
|
+
throw new TypeError(`Unknown style comparison axis "${axis}".`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** @type {Set<THREE.Object3D>} */
|
|
276
|
+
const roots = new Set();
|
|
277
|
+
const variantRecords = new Map();
|
|
278
|
+
const stateTrackers = new Map();
|
|
279
|
+
/** Union of every renderable node seen by any capture. */
|
|
280
|
+
const knownNodes = new Set();
|
|
281
|
+
const mixers = new Set();
|
|
282
|
+
|
|
283
|
+
let currentAxis = axis;
|
|
284
|
+
let currentSplit = clamp01(split);
|
|
285
|
+
let activeVariantId = null;
|
|
286
|
+
let variantPair = { after: variants?.after ?? 'toonlab', before: variants?.before ?? 'neutral' };
|
|
287
|
+
let disposed = false;
|
|
288
|
+
/**
|
|
289
|
+
* §11 requires both halves to share exposure exactly. This module never
|
|
290
|
+
* touches `toneMappingExposure`, but a lighting system that rewrites it can
|
|
291
|
+
* (ToonLab's does — see D19-043), and a mid-frame rewrite would make one half
|
|
292
|
+
* brighter than the other with nothing in the image to explain it. Cheap to
|
|
293
|
+
* check, impossible to spot by eye, so it is checked every frame and reported
|
|
294
|
+
* rather than assumed.
|
|
295
|
+
*/
|
|
296
|
+
let exposureDrift = null;
|
|
297
|
+
let exposureDriftWarned = false;
|
|
298
|
+
|
|
299
|
+
const scratchVector = new THREE.Vector4();
|
|
300
|
+
const scratchColor = new THREE.Color();
|
|
301
|
+
|
|
302
|
+
// THE COMPOSITE — variant A full-frame, variant B scissored over it.
|
|
303
|
+
//
|
|
304
|
+
// Three separate renderer defects had to be fixed to make this exact; each
|
|
305
|
+
// produced a plausible image and failed only under a pixel diff, which is
|
|
306
|
+
// why §11 asks for a proof and not a screenshot. See the deficiency log
|
|
307
|
+
// (D19-001) for the measurements.
|
|
308
|
+
//
|
|
309
|
+
// 1. three keeps TWO scissor rectangles. With a render target bound it
|
|
310
|
+
// reads the rect off `renderTarget.scissor` and ignores
|
|
311
|
+
// `renderer.setScissor` entirely, while still gating both on the CANVAS
|
|
312
|
+
// target's `scissorTest`. Setting one of the two is a silent no-op that
|
|
313
|
+
// renders a full frame.
|
|
314
|
+
// 2. A colour-attachment clear is ALWAYS attachment-wide — no graphics API
|
|
315
|
+
// scissors a clear — so the second pass must not clear colour, or it
|
|
316
|
+
// erases the first pass. Suppressing the clear instead leaves the first
|
|
317
|
+
// variant's pixels wherever it drew OUTSIDE the second variant's
|
|
318
|
+
// silhouette (a toon outline shell is exactly that), so the region is
|
|
319
|
+
// repainted by hand first.
|
|
320
|
+
// 3. `autoClearDepth = false` leaks into three's nested shadow-map passes:
|
|
321
|
+
// `resetRendererState` forces `autoClear` but not `autoClearDepth`, so
|
|
322
|
+
// the shadow map accumulates stale depth and shadow pixels drift with
|
|
323
|
+
// the split. Depth is therefore always cleared; the scissor already
|
|
324
|
+
// stops out-of-region colour writes, so it costs nothing.
|
|
325
|
+
const clearScene = new THREE.Scene();
|
|
326
|
+
// An NDC-unit orthographic camera, not a bare THREE.Camera — the renderer
|
|
327
|
+
// calls updateProjectionMatrix() on whatever it is handed.
|
|
328
|
+
const clearCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
329
|
+
const clearMaterial = new THREE.MeshBasicMaterial({
|
|
330
|
+
depthTest: false,
|
|
331
|
+
depthWrite: false,
|
|
332
|
+
fog: false,
|
|
333
|
+
toneMapped: false,
|
|
334
|
+
});
|
|
335
|
+
const clearQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), clearMaterial);
|
|
336
|
+
clearQuad.frustumCulled = false;
|
|
337
|
+
clearScene.add(clearQuad);
|
|
338
|
+
|
|
339
|
+
function paintScissorClear() {
|
|
340
|
+
if (scene.background?.isColor) scratchColor.copy(scene.background);
|
|
341
|
+
else renderer.getClearColor(scratchColor);
|
|
342
|
+
clearMaterial.color.copy(scratchColor);
|
|
343
|
+
clearMaterial.opacity = scene.background?.isColor ? 1 : (renderer.getClearAlpha?.() ?? 1);
|
|
344
|
+
clearMaterial.transparent = clearMaterial.opacity < 1;
|
|
345
|
+
// Runs with the caller's autoClear flags in force (colour off, depth on),
|
|
346
|
+
// so this single draw both repaints the scissored region and is the pass
|
|
347
|
+
// that clears depth for the variant render that follows it.
|
|
348
|
+
renderer.render(clearScene, clearCamera);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function assertLive() {
|
|
352
|
+
if (disposed) throw new Error('This style comparison has been disposed.');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function eachRenderable(callback) {
|
|
356
|
+
for (const root of roots) {
|
|
357
|
+
root.traverse((node) => {
|
|
358
|
+
if (isRenderable(node)) callback(node, root);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** @param {StyleComparisonTarget} target @returns {StyleComparisonSize} */
|
|
364
|
+
function drawingBufferSize(target) {
|
|
365
|
+
if (target) return { height: target.height, width: target.width };
|
|
366
|
+
renderer.getSize(scratchVector);
|
|
367
|
+
const pixelRatio = renderer.getPixelRatio?.() ?? 1;
|
|
368
|
+
return {
|
|
369
|
+
height: Math.max(1, Math.round(scratchVector.y * pixelRatio)),
|
|
370
|
+
width: Math.max(1, Math.round(scratchVector.x * pixelRatio)),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The scissor rectangle covering the `before` half, in the renderer's
|
|
376
|
+
* bottom-left-origin pixel space. Exposed so a verifier compares exactly the
|
|
377
|
+
* region the wipe writes rather than re-deriving it.
|
|
378
|
+
*/
|
|
379
|
+
/**
|
|
380
|
+
* @param {number} fraction
|
|
381
|
+
* @param {StyleComparisonSize} size
|
|
382
|
+
* @returns {StyleComparisonRect}
|
|
383
|
+
*/
|
|
384
|
+
function scissorRectFor(fraction, size) {
|
|
385
|
+
const clamped = clamp01(fraction);
|
|
386
|
+
if (currentAxis === 'vertical') {
|
|
387
|
+
return {
|
|
388
|
+
height: size.height,
|
|
389
|
+
width: Math.round(size.width * clamped),
|
|
390
|
+
x: 0,
|
|
391
|
+
y: 0,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
height: Math.round(size.height * clamped),
|
|
396
|
+
width: size.width,
|
|
397
|
+
x: 0,
|
|
398
|
+
y: 0,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* @param {THREE.Object3D} root
|
|
404
|
+
* @param {{mixer?: THREE.AnimationMixer|null}} [options]
|
|
405
|
+
* @returns {() => void} Untrack.
|
|
406
|
+
*/
|
|
407
|
+
function track(root, { mixer = null } = {}) {
|
|
408
|
+
assertLive();
|
|
409
|
+
if (!root?.isObject3D) throw new TypeError('track() requires an Object3D.');
|
|
410
|
+
roots.add(root);
|
|
411
|
+
if (mixer) {
|
|
412
|
+
mixers.add(mixer);
|
|
413
|
+
root.userData.toonlabAnimationMixer = mixer;
|
|
414
|
+
}
|
|
415
|
+
return () => { roots.delete(root); };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Registers non-material scene state that legitimately differs between the
|
|
420
|
+
* two treatments — a neutral sky, a neutral fog colour, a neutral background.
|
|
421
|
+
* `capture()` records it per variant and `activate()` restores it. Exposure
|
|
422
|
+
* and tone mapping are deliberately NOT trackable: §11 requires both halves
|
|
423
|
+
* to share them, and `auditIdentity()` enforces that.
|
|
424
|
+
*/
|
|
425
|
+
/**
|
|
426
|
+
* @param {string} id
|
|
427
|
+
* @param {{apply: (value: unknown) => void, capture: () => unknown}} handlers
|
|
428
|
+
* @returns {() => void} Untrack.
|
|
429
|
+
*/
|
|
430
|
+
function trackState(id, { apply, capture }) {
|
|
431
|
+
assertLive();
|
|
432
|
+
if (typeof apply !== 'function' || typeof capture !== 'function') {
|
|
433
|
+
throw new TypeError(`trackState("${id}") needs both capture() and apply().`);
|
|
434
|
+
}
|
|
435
|
+
stateTrackers.set(id, { apply, capture });
|
|
436
|
+
return () => { stateTrackers.delete(id); };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* @param {string} variantId
|
|
441
|
+
* @param {{label?: string}} [options]
|
|
442
|
+
* @returns {{id: string, label: string, materialCount: number, nodeCount: number}}
|
|
443
|
+
*/
|
|
444
|
+
function capture(variantId, { label = variantId } = {}) {
|
|
445
|
+
assertLive();
|
|
446
|
+
const id = String(variantId ?? '').trim();
|
|
447
|
+
if (!id) throw new TypeError('capture() requires a variant id.');
|
|
448
|
+
const nodes = new Map();
|
|
449
|
+
eachRenderable((node) => {
|
|
450
|
+
nodes.set(node, captureNodeState(node));
|
|
451
|
+
knownNodes.add(node);
|
|
452
|
+
});
|
|
453
|
+
const states = new Map();
|
|
454
|
+
for (const [stateId, tracker] of stateTrackers) states.set(stateId, tracker.capture());
|
|
455
|
+
variantRecords.set(id, { id, label, nodes, states });
|
|
456
|
+
activeVariantId = id;
|
|
457
|
+
return {
|
|
458
|
+
id,
|
|
459
|
+
label,
|
|
460
|
+
nodeCount: nodes.size,
|
|
461
|
+
materialCount: new Set([...nodes.values()].flatMap(({ material }) => materialList(material))).size,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* @param {string} variantId
|
|
467
|
+
* @param {{force?: boolean}} [options]
|
|
468
|
+
* @returns {object} The activated variant record.
|
|
469
|
+
*/
|
|
470
|
+
function activate(variantId, { force = false } = {}) {
|
|
471
|
+
assertLive();
|
|
472
|
+
const record = variantRecords.get(variantId);
|
|
473
|
+
if (!record) throw new Error(`Unknown comparison variant "${variantId}".`);
|
|
474
|
+
if (!force && activeVariantId === variantId) return record;
|
|
475
|
+
for (const [node, state] of record.nodes) applyNodeState(node, state);
|
|
476
|
+
// Nodes a later style added (outlines, fur shells) are unknown to an
|
|
477
|
+
// earlier variant. Hide them; never remove them — removal is what makes
|
|
478
|
+
// the style transaction too expensive to run per frame.
|
|
479
|
+
for (const node of knownNodes) {
|
|
480
|
+
if (!record.nodes.has(node)) node.visible = false;
|
|
481
|
+
}
|
|
482
|
+
for (const [stateId, tracker] of stateTrackers) {
|
|
483
|
+
if (record.states.has(stateId)) tracker.apply(record.states.get(stateId));
|
|
484
|
+
}
|
|
485
|
+
if (refreshRenderPasses) {
|
|
486
|
+
for (const root of roots) {
|
|
487
|
+
root.userData?.toonlabCharacterStyleIntegration?.refresh?.();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
activeVariantId = variantId;
|
|
491
|
+
return record;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* @template T
|
|
496
|
+
* @param {StyleComparisonTarget|undefined} target
|
|
497
|
+
* @param {() => T} callback
|
|
498
|
+
* @returns {T}
|
|
499
|
+
*/
|
|
500
|
+
function withRendererState(target, callback) {
|
|
501
|
+
const previousTarget = renderer.getRenderTarget();
|
|
502
|
+
const previousScissorTest = renderer.getScissorTest();
|
|
503
|
+
const previousScissor = new THREE.Vector4();
|
|
504
|
+
renderer.getScissor(previousScissor);
|
|
505
|
+
const previousAutoClear = {
|
|
506
|
+
autoClear: renderer.autoClear,
|
|
507
|
+
autoClearColor: renderer.autoClearColor,
|
|
508
|
+
autoClearDepth: renderer.autoClearDepth,
|
|
509
|
+
autoClearStencil: renderer.autoClearStencil,
|
|
510
|
+
};
|
|
511
|
+
const previousTargetScissor = target
|
|
512
|
+
? { rect: target.scissor.clone(), test: target.scissorTest }
|
|
513
|
+
: null;
|
|
514
|
+
try {
|
|
515
|
+
if (target !== undefined) renderer.setRenderTarget(target ?? null);
|
|
516
|
+
return callback();
|
|
517
|
+
} finally {
|
|
518
|
+
renderer.setScissorTest(previousScissorTest);
|
|
519
|
+
renderer.setScissor(previousScissor.x, previousScissor.y, previousScissor.z, previousScissor.w);
|
|
520
|
+
Object.assign(renderer, previousAutoClear);
|
|
521
|
+
if (previousTargetScissor) {
|
|
522
|
+
target.scissor.copy(previousTargetScissor.rect);
|
|
523
|
+
target.scissorTest = previousTargetScissor.test;
|
|
524
|
+
}
|
|
525
|
+
if (target !== undefined) renderer.setRenderTarget(previousTarget);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Installs a scissor rectangle on BOTH surfaces three tracks — see defect (1)
|
|
531
|
+
* above. Setting only `renderer.setScissor` renders a full frame into a
|
|
532
|
+
* render target with no error of any kind.
|
|
533
|
+
*
|
|
534
|
+
* @param {StyleComparisonTarget} target
|
|
535
|
+
* @param {StyleComparisonRect} rect
|
|
536
|
+
* @returns {void}
|
|
537
|
+
*/
|
|
538
|
+
function setScissorFor(target, rect) {
|
|
539
|
+
if (target) {
|
|
540
|
+
target.scissor.set(rect.x, rect.y, rect.width, rect.height);
|
|
541
|
+
target.scissorTest = true;
|
|
542
|
+
} else {
|
|
543
|
+
renderer.setScissor(rect.x, rect.y, rect.width, rect.height);
|
|
544
|
+
}
|
|
545
|
+
renderer.setScissorTest(true);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Clears any scissor still set, on BOTH surfaces three tracks.
|
|
550
|
+
*
|
|
551
|
+
* three's renderer keeps two scissor rectangles: the canvas target's (set by
|
|
552
|
+
* `renderer.setScissor`) and each RenderTarget's own `target.scissor`. When a
|
|
553
|
+
* render target is bound it reads the rectangle off the TARGET and ignores
|
|
554
|
+
* `renderer.setScissor` entirely — while still gating both on the canvas
|
|
555
|
+
* target's `scissorTest` flag. Touching only one of the two is a silent no-op,
|
|
556
|
+
* so every variant render explicitly clears both before drawing: a stale
|
|
557
|
+
* host scissor would crop one half and nothing would report it.
|
|
558
|
+
*/
|
|
559
|
+
/** @param {StyleComparisonTarget} target @returns {void} */
|
|
560
|
+
function clearScissorFor(target) {
|
|
561
|
+
if (target) target.scissorTest = false;
|
|
562
|
+
renderer.setScissorTest(false);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Renders one variant full-frame. The A/B halves in isolation.
|
|
567
|
+
*
|
|
568
|
+
* @param {string} variantId
|
|
569
|
+
* @param {StyleComparisonVariantRenderOptions} [options]
|
|
570
|
+
* @returns {void}
|
|
571
|
+
*/
|
|
572
|
+
function renderVariant(variantId, { target } = {}) {
|
|
573
|
+
assertLive();
|
|
574
|
+
activate(variantId);
|
|
575
|
+
return withRendererState(target, () => {
|
|
576
|
+
clearScissorFor(target ?? null);
|
|
577
|
+
renderer.render(scene, camera);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Renders the composite wipe: the `after` variant full-frame, then the
|
|
583
|
+
* `before` variant scissored into the leading region. Nothing between the
|
|
584
|
+
* two `renderer.render` calls touches the camera, the lights, the exposure
|
|
585
|
+
* or any animation clock — which is precisely why the comparison is honest.
|
|
586
|
+
*/
|
|
587
|
+
/** @param {StyleComparisonRenderOptions} [options] @returns {StyleComparisonRenderResult} */
|
|
588
|
+
function render({ split: splitOverride, target } = {}) {
|
|
589
|
+
assertLive();
|
|
590
|
+
const fraction = splitOverride === undefined ? currentSplit : clamp01(splitOverride);
|
|
591
|
+
const resolvedTarget = target ?? null;
|
|
592
|
+
const size = drawingBufferSize(resolvedTarget);
|
|
593
|
+
const exposureAtStart = renderer.toneMappingExposure;
|
|
594
|
+
const toneMappingAtStart = renderer.toneMapping;
|
|
595
|
+
const result = withRendererState(target, () => {
|
|
596
|
+
activate(variantPair.after);
|
|
597
|
+
clearScissorFor(resolvedTarget);
|
|
598
|
+
renderer.autoClear = true;
|
|
599
|
+
renderer.autoClearColor = true;
|
|
600
|
+
renderer.autoClearDepth = true;
|
|
601
|
+
renderer.render(scene, camera);
|
|
602
|
+
// NOTHING happens between the two renders: no clock advanced, no camera
|
|
603
|
+
// moved, no light touched, no exposure written. That gap is the whole A/B
|
|
604
|
+
// claim, and it is a scissor call wide.
|
|
605
|
+
if (fraction > 0) {
|
|
606
|
+
activate(variantPair.before);
|
|
607
|
+
setScissorFor(resolvedTarget, scissorRectFor(fraction, size));
|
|
608
|
+
renderer.autoClear = true;
|
|
609
|
+
renderer.autoClearColor = false;
|
|
610
|
+
renderer.autoClearDepth = true;
|
|
611
|
+
paintScissorClear();
|
|
612
|
+
renderer.render(scene, camera);
|
|
613
|
+
clearScissorFor(resolvedTarget);
|
|
614
|
+
}
|
|
615
|
+
return { fraction, size };
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
if (renderer.toneMappingExposure !== exposureAtStart
|
|
619
|
+
|| renderer.toneMapping !== toneMappingAtStart) {
|
|
620
|
+
exposureDrift = {
|
|
621
|
+
exposure: { after: renderer.toneMappingExposure, before: exposureAtStart },
|
|
622
|
+
toneMapping: { after: renderer.toneMapping, before: toneMappingAtStart },
|
|
623
|
+
};
|
|
624
|
+
if (!exposureDriftWarned) {
|
|
625
|
+
exposureDriftWarned = true;
|
|
626
|
+
console.warn(
|
|
627
|
+
'ToonLab style comparison: exposure or tone mapping changed DURING a composite render. '
|
|
628
|
+
+ 'The two halves no longer share exposure, which invalidates the A/B. '
|
|
629
|
+
+ 'Something outside this module is writing renderer.toneMappingExposure mid-frame.',
|
|
630
|
+
exposureDrift,
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
} else {
|
|
634
|
+
exposureDrift = null;
|
|
635
|
+
}
|
|
636
|
+
return result;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Structural proof that the two variants differ only by material treatment:
|
|
641
|
+
* the same geometry buffer, the same skeleton and the same morph influences
|
|
642
|
+
* on every tracked node, and exactly one shared camera / light rig /
|
|
643
|
+
* exposure. Cheap enough to assert every frame in a lab.
|
|
644
|
+
*/
|
|
645
|
+
function auditIdentity() {
|
|
646
|
+
assertLive();
|
|
647
|
+
const issues = [];
|
|
648
|
+
const ids = [...variantRecords.keys()];
|
|
649
|
+
if (ids.length < 2) issues.push(`Only ${ids.length} variant(s) captured; a comparison needs two.`);
|
|
650
|
+
for (const required of [variantPair.before, variantPair.after]) {
|
|
651
|
+
if (!variantRecords.has(required)) issues.push(`Variant "${required}" was never captured.`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
let sharedNodes = 0;
|
|
655
|
+
let divergentMaterials = 0;
|
|
656
|
+
const [first, ...rest] = ids.map((id) => variantRecords.get(id));
|
|
657
|
+
if (first) {
|
|
658
|
+
for (const [node, state] of first.nodes) {
|
|
659
|
+
let shared = true;
|
|
660
|
+
for (const other of rest) {
|
|
661
|
+
const otherState = other.nodes.get(node);
|
|
662
|
+
if (!otherState) continue;
|
|
663
|
+
if (otherState.geometry !== state.geometry) {
|
|
664
|
+
issues.push(`Node "${node.name || node.uuid}" has a different geometry in "${other.id}".`);
|
|
665
|
+
shared = false;
|
|
666
|
+
}
|
|
667
|
+
if (otherState.skeleton !== state.skeleton) {
|
|
668
|
+
issues.push(`Node "${node.name || node.uuid}" has a different skeleton in "${other.id}".`);
|
|
669
|
+
shared = false;
|
|
670
|
+
}
|
|
671
|
+
if (otherState.morphTargetInfluences !== state.morphTargetInfluences) {
|
|
672
|
+
issues.push(`Node "${node.name || node.uuid}" has different morph influences in "${other.id}".`);
|
|
673
|
+
shared = false;
|
|
674
|
+
}
|
|
675
|
+
const a = materialList(state.material);
|
|
676
|
+
const b = materialList(otherState.material);
|
|
677
|
+
if (a.length !== b.length || a.some((material, index) => material !== b[index])) {
|
|
678
|
+
divergentMaterials += 1;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (shared) sharedNodes += 1;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (divergentMaterials === 0 && ids.length >= 2) {
|
|
685
|
+
issues.push('No tracked node has a different material between variants — nothing to compare.');
|
|
686
|
+
}
|
|
687
|
+
if (exposureDrift) {
|
|
688
|
+
issues.push(
|
|
689
|
+
`Exposure changed during the composite render (${exposureDrift.exposure.before} → ${exposureDrift.exposure.after}); the halves do not share exposure.`,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const mixerList = [...mixers];
|
|
694
|
+
const observedMixers = collectMixerSignature(roots);
|
|
695
|
+
return {
|
|
696
|
+
axis: currentAxis,
|
|
697
|
+
exposureDrift,
|
|
698
|
+
frameState: captureComparisonFrameState({
|
|
699
|
+
camera, mixers: mixerList, renderer, scene,
|
|
700
|
+
}),
|
|
701
|
+
issues,
|
|
702
|
+
mixerCount: mixerList.length || observedMixers.length,
|
|
703
|
+
ok: issues.length === 0,
|
|
704
|
+
sharedGeometryNodes: sharedNodes,
|
|
705
|
+
split: currentSplit,
|
|
706
|
+
trackedNodes: knownNodes.size,
|
|
707
|
+
trackedRoots: roots.size,
|
|
708
|
+
variants: ids,
|
|
709
|
+
variantsWithDivergentMaterials: divergentMaterials,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
activate,
|
|
715
|
+
auditIdentity,
|
|
716
|
+
capture,
|
|
717
|
+
get activeVariant() { return activeVariantId; },
|
|
718
|
+
/** Non-null when exposure or tone mapping moved during the last composite. */
|
|
719
|
+
get exposureDrift() { return exposureDrift; },
|
|
720
|
+
get axis() { return currentAxis; },
|
|
721
|
+
get camera() { return camera; },
|
|
722
|
+
get mixers() { return [...mixers]; },
|
|
723
|
+
get renderer() { return renderer; },
|
|
724
|
+
get roots() { return [...roots]; },
|
|
725
|
+
get scene() { return scene; },
|
|
726
|
+
get split() { return currentSplit; },
|
|
727
|
+
get variantIds() { return [...variantRecords.keys()]; },
|
|
728
|
+
get variants() { return { ...variantPair }; },
|
|
729
|
+
dispose() {
|
|
730
|
+
if (disposed) return;
|
|
731
|
+
disposed = true;
|
|
732
|
+
roots.clear();
|
|
733
|
+
variantRecords.clear();
|
|
734
|
+
stateTrackers.clear();
|
|
735
|
+
knownNodes.clear();
|
|
736
|
+
mixers.clear();
|
|
737
|
+
clearQuad.geometry.dispose();
|
|
738
|
+
clearMaterial.dispose();
|
|
739
|
+
},
|
|
740
|
+
render,
|
|
741
|
+
renderVariant,
|
|
742
|
+
/** @type {(fraction: number, size?: StyleComparisonSize) => StyleComparisonRect} */
|
|
743
|
+
scissorRectFor: (fraction, size) => scissorRectFor(fraction, size ?? drawingBufferSize(null)),
|
|
744
|
+
/** @param {StyleComparisonAxis} next @returns {StyleComparisonAxis} */
|
|
745
|
+
setAxis(next) {
|
|
746
|
+
if (!STYLE_COMPARISON_AXES.includes(next)) throw new TypeError(`Unknown axis "${next}".`);
|
|
747
|
+
currentAxis = next;
|
|
748
|
+
return currentAxis;
|
|
749
|
+
},
|
|
750
|
+
/** @param {number} next @returns {number} */
|
|
751
|
+
setSplit(next) {
|
|
752
|
+
currentSplit = clamp01(next);
|
|
753
|
+
return currentSplit;
|
|
754
|
+
},
|
|
755
|
+
/**
|
|
756
|
+
* @param {Partial<StyleComparisonVariantPair>} [pair]
|
|
757
|
+
* @returns {StyleComparisonVariantPair}
|
|
758
|
+
*/
|
|
759
|
+
setVariants({ after, before } = {}) {
|
|
760
|
+
variantPair = {
|
|
761
|
+
after: after ?? variantPair.after,
|
|
762
|
+
before: before ?? variantPair.before,
|
|
763
|
+
};
|
|
764
|
+
return { ...variantPair };
|
|
765
|
+
},
|
|
766
|
+
track,
|
|
767
|
+
trackState,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function countDifferences(a, b, { height, region, tolerance = 0, width }) {
|
|
772
|
+
const x0 = region?.x ?? 0;
|
|
773
|
+
const y0 = region?.y ?? 0;
|
|
774
|
+
const x1 = Math.min(width, x0 + (region?.width ?? width));
|
|
775
|
+
const y1 = Math.min(height, y0 + (region?.height ?? height));
|
|
776
|
+
let differing = 0;
|
|
777
|
+
let maxDelta = 0;
|
|
778
|
+
for (let y = y0; y < y1; y += 1) {
|
|
779
|
+
for (let x = x0; x < x1; x += 1) {
|
|
780
|
+
const index = (y * width + x) * 4;
|
|
781
|
+
let delta = 0;
|
|
782
|
+
for (let channel = 0; channel < 4; channel += 1) {
|
|
783
|
+
delta = Math.max(delta, Math.abs(a[index + channel] - b[index + channel]));
|
|
784
|
+
}
|
|
785
|
+
if (delta > maxDelta) maxDelta = delta;
|
|
786
|
+
if (delta > tolerance) differing += 1;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const total = Math.max(1, (x1 - x0) * (y1 - y0));
|
|
790
|
+
return { differing, fraction: differing / total, maxDelta, total };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function differenceMask(a, b, { height, tolerance, width }) {
|
|
794
|
+
const mask = new Uint8Array(width * height);
|
|
795
|
+
for (let pixel = 0; pixel < mask.length; pixel += 1) {
|
|
796
|
+
const index = pixel * 4;
|
|
797
|
+
let delta = 0;
|
|
798
|
+
for (let channel = 0; channel < 4; channel += 1) {
|
|
799
|
+
delta = Math.max(delta, Math.abs(a[index + channel] - b[index + channel]));
|
|
800
|
+
}
|
|
801
|
+
if (delta > tolerance) mask[pixel] = 1;
|
|
802
|
+
}
|
|
803
|
+
return mask;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Proves that a comparison is a true renderer split of one framing, not two
|
|
808
|
+
* separately framed images — and that everything outside the treated subject
|
|
809
|
+
* is bit-identical between the halves.
|
|
810
|
+
*
|
|
811
|
+
* The five assertions:
|
|
812
|
+
*
|
|
813
|
+
* 1. `split = 0` is bit-identical to a full-frame render of the `after`
|
|
814
|
+
* variant.
|
|
815
|
+
* 2. `split = 1` is bit-identical to a full-frame render of the `before`
|
|
816
|
+
* variant.
|
|
817
|
+
* 3. At any intermediate split, the region inside the scissor is bit-identical
|
|
818
|
+
* to the SAME region of the `before` full frame, and the region outside it
|
|
819
|
+
* is bit-identical to the same region of the `after` full frame. A pixel at
|
|
820
|
+
* (x, y) in the wipe therefore equals that pixel in a full-frame render of
|
|
821
|
+
* its own variant — which is only possible if both halves share one camera.
|
|
822
|
+
* 4. Camera matrices, light transforms, exposure, tone mapping, shadow state
|
|
823
|
+
* and every registered animation clock are unchanged by the composite
|
|
824
|
+
* render.
|
|
825
|
+
* 5. Pixels that differ between the two variants lie inside the region the
|
|
826
|
+
* tracked subject affects (measured by rendering with the subject hidden),
|
|
827
|
+
* so nothing outside the intended material treatment moved.
|
|
828
|
+
*
|
|
829
|
+
* Deterministic and reusable: it renders into its own render target, so it does
|
|
830
|
+
* not depend on canvas size, page compositing or screenshot timing. This is the
|
|
831
|
+
* routine the filler register's equivalence test calls.
|
|
832
|
+
*/
|
|
833
|
+
export async function verifyStyleComparisonIdentity(comparison, {
|
|
834
|
+
height = 540,
|
|
835
|
+
onStage = null,
|
|
836
|
+
splits = [0.25, 0.5, 0.75],
|
|
837
|
+
tolerance = 0,
|
|
838
|
+
width = 960,
|
|
839
|
+
} = {}) {
|
|
840
|
+
const stage = (name) => { onStage?.(name); };
|
|
841
|
+
if (typeof comparison?.render !== 'function') {
|
|
842
|
+
throw new TypeError('verifyStyleComparisonIdentity requires a style comparison.');
|
|
843
|
+
}
|
|
844
|
+
const { camera, renderer, scene } = comparison;
|
|
845
|
+
if (typeof renderer.readRenderTargetPixelsAsync !== 'function') {
|
|
846
|
+
throw new Error('Pixel-identity verification requires readRenderTargetPixelsAsync.');
|
|
847
|
+
}
|
|
848
|
+
const { after, before } = comparison.variants;
|
|
849
|
+
const target = new THREE.RenderTarget(width, height, {
|
|
850
|
+
depthBuffer: true,
|
|
851
|
+
format: THREE.RGBAFormat,
|
|
852
|
+
type: THREE.UnsignedByteType,
|
|
853
|
+
});
|
|
854
|
+
target.depthTexture = new THREE.DepthTexture(width, height);
|
|
855
|
+
|
|
856
|
+
const read = async () => {
|
|
857
|
+
const buffer = await renderer.readRenderTargetPixelsAsync(target, 0, 0, width, height);
|
|
858
|
+
return new Uint8Array(buffer.buffer ?? buffer);
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
const checks = [];
|
|
862
|
+
const record = (id, description, result, extra = {}) => {
|
|
863
|
+
checks.push({
|
|
864
|
+
description,
|
|
865
|
+
differingPixels: result.differing,
|
|
866
|
+
fraction: result.fraction,
|
|
867
|
+
id,
|
|
868
|
+
maxChannelDelta: result.maxDelta,
|
|
869
|
+
ok: result.differing === 0,
|
|
870
|
+
totalPixels: result.total,
|
|
871
|
+
...extra,
|
|
872
|
+
});
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
try {
|
|
876
|
+
const stateBefore = captureComparisonFrameState({
|
|
877
|
+
camera, mixers: comparison.mixers, renderer, scene,
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
stage('render-before');
|
|
881
|
+
comparison.renderVariant(before, { target });
|
|
882
|
+
const beforePixels = await read();
|
|
883
|
+
stage('render-after');
|
|
884
|
+
comparison.renderVariant(after, { target });
|
|
885
|
+
const afterPixels = await read();
|
|
886
|
+
|
|
887
|
+
// The subject-free plate: what the frame looks like with nothing tracked
|
|
888
|
+
// in it. Any pixel the subject can possibly influence — including its
|
|
889
|
+
// shadows and its contribution to reflections — differs from this plate.
|
|
890
|
+
stage('render-plate');
|
|
891
|
+
const rootVisibility = comparison.roots.map((root) => [root, root.visible]);
|
|
892
|
+
for (const [root] of rootVisibility) root.visible = false;
|
|
893
|
+
renderer.setRenderTarget(target);
|
|
894
|
+
renderer.setScissorTest(false);
|
|
895
|
+
renderer.render(scene, camera);
|
|
896
|
+
renderer.setRenderTarget(null);
|
|
897
|
+
const platePixels = await read();
|
|
898
|
+
for (const [root, visible] of rootVisibility) root.visible = visible;
|
|
899
|
+
|
|
900
|
+
stage('wipe-0');
|
|
901
|
+
comparison.render({ split: 0, target });
|
|
902
|
+
record('split-0-equals-after', `split=0 is bit-identical to a full "${after}" frame`,
|
|
903
|
+
countDifferences(await read(), afterPixels, { height, tolerance, width }));
|
|
904
|
+
|
|
905
|
+
stage('wipe-1');
|
|
906
|
+
comparison.render({ split: 1, target });
|
|
907
|
+
record('split-1-equals-before', `split=1 is bit-identical to a full "${before}" frame`,
|
|
908
|
+
countDifferences(await read(), beforePixels, { height, tolerance, width }));
|
|
909
|
+
|
|
910
|
+
for (const split of splits) {
|
|
911
|
+
stage(`wipe-${split}`);
|
|
912
|
+
comparison.render({ split, target });
|
|
913
|
+
const wipe = await read();
|
|
914
|
+
const rect = comparison.scissorRectFor(split, { height, width });
|
|
915
|
+
const complement = comparison.axis === 'vertical'
|
|
916
|
+
? { height, width: width - rect.width, x: rect.width, y: 0 }
|
|
917
|
+
: { height: height - rect.height, width, x: 0, y: rect.height };
|
|
918
|
+
record(`split-${split}-inside`,
|
|
919
|
+
`split=${split}: the scissored region equals the same region of "${before}"`,
|
|
920
|
+
countDifferences(wipe, beforePixels, { height, region: rect, tolerance, width }),
|
|
921
|
+
{ region: rect, split });
|
|
922
|
+
record(`split-${split}-outside`,
|
|
923
|
+
`split=${split}: the region outside the scissor equals the same region of "${after}"`,
|
|
924
|
+
countDifferences(wipe, afterPixels, { height, region: complement, tolerance, width }),
|
|
925
|
+
{ region: complement, split });
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const stateAfter = captureComparisonFrameState({
|
|
929
|
+
camera, mixers: comparison.mixers, renderer, scene,
|
|
930
|
+
});
|
|
931
|
+
const frameStateIssues = diffFrameState(stateBefore, stateAfter);
|
|
932
|
+
checks.push({
|
|
933
|
+
description: 'camera matrices, light transforms, exposure and animation clocks are unchanged by the wipe',
|
|
934
|
+
differences: frameStateIssues,
|
|
935
|
+
id: 'frame-state-stable',
|
|
936
|
+
ok: frameStateIssues.length === 0,
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
stage('mask');
|
|
940
|
+
const treated = differenceMask(afterPixels, platePixels, { height, tolerance, width });
|
|
941
|
+
const treatedBefore = differenceMask(beforePixels, platePixels, { height, tolerance, width });
|
|
942
|
+
for (let pixel = 0; pixel < treated.length; pixel += 1) {
|
|
943
|
+
if (treatedBefore[pixel]) treated[pixel] = 1;
|
|
944
|
+
}
|
|
945
|
+
const variantDelta = differenceMask(afterPixels, beforePixels, { height, tolerance, width });
|
|
946
|
+
let outsideTreatment = 0;
|
|
947
|
+
let treatedPixels = 0;
|
|
948
|
+
let changedPixels = 0;
|
|
949
|
+
for (let pixel = 0; pixel < treated.length; pixel += 1) {
|
|
950
|
+
if (treated[pixel]) treatedPixels += 1;
|
|
951
|
+
if (!variantDelta[pixel]) continue;
|
|
952
|
+
changedPixels += 1;
|
|
953
|
+
if (!treated[pixel]) outsideTreatment += 1;
|
|
954
|
+
}
|
|
955
|
+
checks.push({
|
|
956
|
+
changedPixels,
|
|
957
|
+
description: 'every pixel that differs between the halves lies inside the treated subject',
|
|
958
|
+
differingPixels: outsideTreatment,
|
|
959
|
+
id: 'difference-confined-to-treatment',
|
|
960
|
+
ok: outsideTreatment === 0,
|
|
961
|
+
totalPixels: treated.length,
|
|
962
|
+
treatedPixels,
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
stage('structural');
|
|
966
|
+
const structural = comparison.auditIdentity();
|
|
967
|
+
checks.push({
|
|
968
|
+
description: 'both variants share geometry buffers, skeletons and morph influences',
|
|
969
|
+
differences: structural.issues,
|
|
970
|
+
id: 'structural-identity',
|
|
971
|
+
ok: structural.ok,
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
return {
|
|
975
|
+
checks,
|
|
976
|
+
ok: checks.every((check) => check.ok),
|
|
977
|
+
resolution: { height, width },
|
|
978
|
+
structural,
|
|
979
|
+
variants: { after, before },
|
|
980
|
+
};
|
|
981
|
+
} finally {
|
|
982
|
+
target.depthTexture?.dispose?.();
|
|
983
|
+
target.dispose();
|
|
984
|
+
}
|
|
985
|
+
}
|