Files
behind_glass/scripts/test-camera-history.mjs
T
2026-10-06 12:16:46 +09:00

68 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const controller = await readFile(new URL('../src/input/CameraController.ts', import.meta.url), 'utf8');
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
const historyShader = await readFile(new URL('../src/shaders/region-history.frag', import.meta.url), 'utf8');
const mosaicShader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
assert.match(controller, /maxOffset = 0\.5/);
assert.match(controller, /clamp\(value, 0, 0\.9\)/);
assert.match(controller, /multiplyScalar\(sensitivity\)/);
assert.match(html, /id="camera-offset" type="range" min="0" max="0\.9" step="0\.01" value="0\.5"/);
assert.match(html, /id="sensitivity" type="range" min="0\.2" max="2"/);
assert.match(html, /Camera Offset <output id="cameraOut">0\.50/);
assert.match(html, /권장 0\.35–0\.65 · 시점 이동 범위/);
assert.match(mosaicShader, /edgeTaper=style<=\.5\?1\.25:\.75/);
assert.match(mosaicShader, /historyTexel=clamp\(floor\(fract\(gridUv\)\*8\.\),vec2\(0\.\),vec2\(7\.\)\)/);
assert.equal(Math.floor(0.45 * 8), 3, 'split sample just left of the half-cell boundary stays in texel 3');
assert.equal(Math.floor(0.50 * 8), 4, 'split sample at the half-cell boundary enters texel 4');
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const cameraPosition = (target, sensitivity, maxOffset) => clamp(target * sensitivity, -1, 1) * maxOffset;
assert.equal(cameraPosition(1, 1, 0.5), 0.5, 'default offset reaches the configured midpoint');
assert.equal(cameraPosition(1, 1, 0.9), 0.9, 'maximum offset reaches 0.9');
assert.equal(cameraPosition(1, 2, 0.9), 0.9, 'sensitivity does not exceed the view clamp');
assert.equal(clamp(1, 0, 0.9), 0.9, 'UI maximum matches controller clamp');
const signatureStart = main.indexOf('function currentSplitHistorySignature()');
const signatureEnd = main.indexOf('\nfunction clearSplitHistory()', signatureStart);
assert.ok(signatureStart >= 0 && signatureEnd > signatureStart, 'history reset signature exists');
const signature = main.slice(signatureStart, signatureEnd);
for (const key of ['mode', 'effectInput.value', 'mosaicStyleInput.value', 'active.uuid', 'depth.uuid', 'uRegionAvailable', 'uGridSize', 'uStrength', 'uRegionStrength', 'uCurvature', 'uPaneSize', 'matrixWorld', 'uDepthScale', 'uDepthBias', 'renderTarget.width', 'regionAtlasTarget.width', 'regionPaletteTarget.width']) {
assert.ok(signature.includes(key), `model-reset signature includes ${key}`);
}
assert.doesNotMatch(signature, /uContentViewProjection|cameraController|uContentCameraPosition/, 'camera movement must not reset split history');
assert.match(main, /const writeIndex = 1 - splitHistoryRead/);
assert.match(main, /uPreviousHistory\.value = splitHistoryTargets\[splitHistoryRead\]\.texture/);
assert.match(main, /uHistoryBlend\.value = splitHistoryInitialized \? 1 - Math\.exp\(-dt \/ 0\.07\) : 1/);
assert.match(main, /updateSplitHistory\(dt\); renderer\.setRenderTarget\(null\); renderer\.render\(scene, camera\)/, 'inactive view updates the signature and clears stale history');
assert.match(main, /splitHistoryTargets\.forEach\(\(target\) => target\.dispose\(\)\)/);
assert.match(main, /target\.setSize\(atlasWidth, atlasHeight\)/);
assert.match(historyShader, /if\(uHistoryBlend>=\.9999\)\{gl_FragColor=current;return;\}/, 'first frame avoids sampling uninitialized history');
assert.match(historyShader, /gl_FragColor=mix\(previous,current,clamp\(uHistoryBlend,0\.,1\.\)\)/);
function advance(fps, seconds, initial, target) {
let value = initial;
const dt = 1 / fps;
for (let frame = 0; frame < Math.round(fps * seconds); frame++) {
const alpha = 1 - Math.exp(-dt / 0.07);
value += (target - value) * alpha;
}
return value;
}
const cadence = [30, 60, 120].map(fps => advance(fps, 0.7, 0, 1));
assert.ok(Math.max(...cadence) - Math.min(...cadence) < 1e-12, `30/60/120Hz history cadence differs: ${cadence}`);
assert.ok(Math.abs(cadence[0] - (1 - Math.exp(-0.7 / 0.07))) < 1e-12, 'history follows the 70ms exponential time constant');
assert.ok(cadence[0] > 0.999, 'a settled model reaches its target after 700ms');
const color = [0.8, 0.25, 0.1], confidence = 0.6;
const premultiplied = [...color.map(channel => channel * confidence), confidence];
assert.deepEqual(premultiplied.slice(0, 3).map(channel => channel / premultiplied[3]), color, 'split color is stored premultiplied by confidence');
const previous = [0, 0, 0, 0], blend = 0.25;
const filtered = previous.map((channel, index) => channel + (premultiplied[index] - channel) * blend);
assert.equal(filtered[3], confidence * blend);
assert.deepEqual(filtered.slice(0, 3).map(channel => channel / filtered[3]), color, 'filtered premultiplied color retains its hue');
console.log(`Camera/history checks passed: default/max clamp, model reset keys, ping-pong and disposal, premultiplied color, cadence ${cadence.map(v => v.toFixed(6)).join('/')}, 70ms settling.`);