129 lines
8.5 KiB
JavaScript
129 lines
8.5 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import * as THREE from 'three';
|
|
|
|
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
|
|
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
|
|
assert.match(shader, /uniform sampler2D uSceneTexture,uSceneDepth/);
|
|
assert.match(shader, /color=sampleScene\(finalSceneUv\)/);
|
|
assert.match(shader, /regionSurfaceValid\(finalSceneUv,gh\)/);
|
|
assert.match(shader, /float depth=texture2D\(uSceneDepth,sceneUv\)\.x/);
|
|
assert.doesNotMatch(shader, /smoothstep\(\.045,\.14,gap\)/);
|
|
assert.match(shader, /uRegionStrength\*clamp\(uStrength,0\.,1\.\)\*regionSurfaceValid\(finalSceneUv,gh\)/);
|
|
assert.match(shader, /uniform sampler2D uProtectionDepthMap/);
|
|
assert.match(shader, /uniform mat4 uPetWorldToLocal/);
|
|
assert.match(shader, /uProtectionDepthMap,sourceUv\)\.r/);
|
|
assert.match(shader, /return smoothstep\(uDepthEvidenceRange.x,uDepthEvidenceRange.y,sourceDepth\)/);
|
|
assert.match(main, /depthEvidenceUniform.value.fromArray\(DepthProcessor.evidenceRange\(texture\)\)/);
|
|
assert.match(shader, /if\(!reconstructScenePoint\(sceneUv,worldPoint\)\)return 0\./);
|
|
assert.match(shader, /if\(localPoint\.x<-.5\|\|localPoint\.x>\.5\|\|localPoint\.y<-.5\|\|localPoint\.y>\.5\)return 0\./);
|
|
assert.match(shader, /atlasEvidence=regionProtectionWeight\(finalSceneUv\)\*\(1\.-smoothstep\(\.10,\.20,regionLuminance\(atlasCenterColor\)\)\)/);
|
|
assert.match(shader, /color\.a=atlasEvidence/);
|
|
assert.match(shader, /gl_FragColor=vec4\(sampleScene\(screenUv\)\.rgb,0\.\)/);
|
|
assert.match(shader, /gl_FragColor=vec4\(uBackgroundColor,0\.\)/);
|
|
assert.doesNotMatch(shader, /protectDark|protectedColor|ownPalette|darkestPalette/);
|
|
assert.match(main, /uProtectionDepthMap: depthUniforms\.uDepthMap/);
|
|
assert.match(main, /uPetWorldToLocal: \{ value: new THREE\.Matrix4\(\) \}/);
|
|
assert.match(main, /uPetWorldToLocal\.value\.copy\(petDepth\.matrixWorld\)\.invert\(\)/);
|
|
assert.match(main, /renderTarget\.depthTexture\s*=\s*new THREE\.DepthTexture/);
|
|
|
|
function reconstructPoint(uv, depth, inverseViewProjection) {
|
|
const world = new THREE.Vector4(uv.x * 2 - 1, uv.y * 2 - 1, depth * 2 - 1, 1)
|
|
.applyMatrix4(inverseViewProjection);
|
|
return new THREE.Vector3(world.x / world.w, world.y / world.w, world.z / world.w);
|
|
}
|
|
|
|
function validAtDepth(depth, uv, glassHit, inverseViewProjection, worldW = 1) {
|
|
if (uv.x < 0 || uv.x > 1 || uv.y < 0 || uv.y > 1) return 0;
|
|
if (depth >= 0.999999 || Math.abs(worldW) < 0.0001) return 0;
|
|
const subject = reconstructPoint(uv, depth, inverseViewProjection);
|
|
const axialGap = glassHit.z - subject.z;
|
|
if (axialGap <= 0.0001) return 0;
|
|
return 1;
|
|
}
|
|
function protectionWeight(sourceDepth) {
|
|
const t = Math.max(0, Math.min(1, (sourceDepth - 0.8) / (0.95 - 0.8)));
|
|
return t * t * (3 - 2 * t);
|
|
}
|
|
function projectSourceUv(sourceUv, sourceDepth, depthScale, photoZoom, photoPan, cameraPosition) {
|
|
const baseW = 1.65, baseH = 1.2, worldScale = 0.48, baseZ = 0.42, depthBias = 0.1;
|
|
const position = new THREE.Vector3(
|
|
baseW * (photoZoom - 1) * 0.5 * photoPan.x * worldScale,
|
|
baseH * (photoZoom - 1) * 0.5 * photoPan.y * worldScale,
|
|
baseZ
|
|
);
|
|
const scale = new THREE.Vector3(baseW * photoZoom * worldScale, baseH * photoZoom * worldScale, 1);
|
|
const petMatrix = new THREE.Matrix4().compose(position, new THREE.Quaternion(), scale);
|
|
const local = new THREE.Vector3(sourceUv.x - 0.5, sourceUv.y - 0.5, (sourceDepth - depthBias) * depthScale);
|
|
const worldPoint = local.clone().applyMatrix4(petMatrix);
|
|
const camera = new THREE.PerspectiveCamera(40, 1.4, 0.1, 100);
|
|
camera.position.copy(cameraPosition);
|
|
camera.lookAt(0, 0, 0);
|
|
camera.updateMatrixWorld(true);
|
|
const viewProjection = new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
|
const inverseViewProjection = viewProjection.clone().invert();
|
|
const clip = new THREE.Vector4(worldPoint.x, worldPoint.y, worldPoint.z, 1).applyMatrix4(viewProjection);
|
|
const ndc = new THREE.Vector3(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
|
|
const screenUv = new THREE.Vector2(ndc.x * 0.5 + 0.5, ndc.y * 0.5 + 0.5);
|
|
const reconstructed = reconstructPoint(screenUv, ndc.z * 0.5 + 0.5, inverseViewProjection);
|
|
const recoveredLocal = reconstructed.applyMatrix4(petMatrix.clone().invert());
|
|
return { screenUv, recoveredUv: new THREE.Vector2(recoveredLocal.x + 0.5, recoveredLocal.y + 0.5) };
|
|
}
|
|
|
|
for (const [sourceDepth, expected] of [[0.79, 0], [0.8, 0], [0.875, 0.5], [0.95, 1], [1, 1]]) {
|
|
assert.ok(Math.abs(protectionWeight(sourceDepth) - expected) < 1e-9,
|
|
`near-depth gate at ${sourceDepth} should be ${expected}`);
|
|
}
|
|
assert.equal(protectionWeight(0), 0, 'flat black depth disables the protection gate');
|
|
|
|
let mapped = 0;
|
|
for (const depthScale of [0, 0.25, 0.4]) for (const photo of [
|
|
{ zoom: 1, pan: new THREE.Vector2(0, 0) },
|
|
{ zoom: 1.6, pan: new THREE.Vector2(0.6, -0.35) },
|
|
]) for (const cameraPosition of [new THREE.Vector3(0, 0, 3), new THREE.Vector3(0.35, -0.2, 3.2)]) {
|
|
const sourceUv = new THREE.Vector2(0.12, 0.74);
|
|
const result = projectSourceUv(sourceUv, 0.92, depthScale, photo.zoom, photo.pan, cameraPosition);
|
|
assert.ok(result.recoveredUv.distanceTo(sourceUv) < 1e-5,
|
|
`source depth lookup must survive depthScale ${depthScale}, zoom ${photo.zoom}, camera ${cameraPosition.toArray()}`);
|
|
mapped++;
|
|
}
|
|
|
|
let reconstructed = 0;
|
|
for (const position of [[0, 0, 3], [1.8, 1.2, 3.4], [-1.8, -1.2, 3.4]]) {
|
|
const camera = new THREE.PerspectiveCamera(40, 1.4, 0.1, 100);
|
|
camera.position.set(...position);
|
|
camera.lookAt(0, 0, 0);
|
|
camera.updateMatrixWorld(true);
|
|
const viewProjection = new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
|
const inverseViewProjection = viewProjection.clone().invert();
|
|
const rayDirection = camera.getWorldDirection(new THREE.Vector3());
|
|
const rayToGlass = (0.6 - camera.position.z) / rayDirection.z;
|
|
const glassHit = camera.position.clone().addScaledVector(rayDirection, rayToGlass);
|
|
|
|
for (const gap of [0.0002, 0.04, 0.045, 0.09, 0.14, 0.16, 1.2]) {
|
|
const subject = glassHit.clone().addScaledVector(rayDirection, gap);
|
|
const clip = new THREE.Vector4(subject.x, subject.y, subject.z, 1).applyMatrix4(viewProjection);
|
|
const ndc = new THREE.Vector3(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
|
|
const uv = new THREE.Vector2(ndc.x * 0.5 + 0.5, ndc.y * 0.5 + 0.5);
|
|
const depth = ndc.z * 0.5 + 0.5;
|
|
const recovered = reconstructPoint(uv, depth, inverseViewProjection);
|
|
assert.ok(recovered.distanceTo(subject) < 1e-5, `world reconstruction failed at camera ${position}, gap ${gap}`);
|
|
reconstructed++;
|
|
assert.equal(validAtDepth(depth, uv, glassHit, inverseViewProjection), 1, `valid surface at gap ${gap} should receive full configured region strength`);
|
|
}
|
|
|
|
assert.equal(validAtDepth(1, new THREE.Vector2(0.5, 0.5), glassHit, inverseViewProjection), 0, 'background depth must bypass simplification');
|
|
assert.equal(validAtDepth(0.5, new THREE.Vector2(-0.01, 0.5), glassHit, inverseViewProjection), 0, 'out-of-range UV must bypass simplification');
|
|
assert.equal(validAtDepth(0.5, new THREE.Vector2(0.5, 0.5), glassHit, inverseViewProjection, 0.00001), 0, 'invalid homogeneous world coordinate must bypass simplification');
|
|
const frontPoint = glassHit.clone().addScaledVector(rayDirection, -0.04);
|
|
const frontClip = new THREE.Vector4(frontPoint.x, frontPoint.y, frontPoint.z, 1).applyMatrix4(viewProjection);
|
|
const frontNdc = new THREE.Vector3(frontClip.x / frontClip.w, frontClip.y / frontClip.w, frontClip.z / frontClip.w);
|
|
assert.equal(validAtDepth(frontNdc.z * 0.5 + 0.5, new THREE.Vector2(frontNdc.x * 0.5 + 0.5, frontNdc.y * 0.5 + 0.5), glassHit, inverseViewProjection), 0, 'surface in front of glass must bypass simplification');
|
|
const contactPoint = glassHit.clone().addScaledVector(rayDirection, 0.00005);
|
|
const contactClip = new THREE.Vector4(contactPoint.x, contactPoint.y, contactPoint.z, 1).applyMatrix4(viewProjection);
|
|
const contactNdc = new THREE.Vector3(contactClip.x / contactClip.w, contactClip.y / contactClip.w, contactClip.z / contactClip.w);
|
|
assert.equal(validAtDepth(contactNdc.z * 0.5 + 0.5, new THREE.Vector2(contactNdc.x * 0.5 + 0.5, contactNdc.y * 0.5 + 0.5), glassHit, inverseViewProjection), 0, 'same or nearly coincident glass surface must bypass simplification');
|
|
}
|
|
|
|
console.log(`Mosaic depth-region projection checks passed: ${reconstructed} perspective reconstructions, ${mapped} original-depth UV mappings across camera/zoom-pan/depthScale, near-depth gate, distance-independent color application, and invalid-surface guards.`);
|