first commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export const ENVIRONMENT_REFLECTION_STRENGTH = 0.12;
|
||||
// Keep color planes dominant without flattening all continuous lens detail.
|
||||
export const GLASS_TRANSMISSION_DETAIL = 0.42;
|
||||
export const CELL_LENS_SCALE = 1.2;
|
||||
@@ -0,0 +1,468 @@
|
||||
import * as THREE from 'three';
|
||||
import './style.css';
|
||||
import vertexShader from './shaders/common.vert?raw';
|
||||
import mosaicShader from './shaders/mosaic.frag?raw';
|
||||
import reededShader from './shaders/reeded.frag?raw';
|
||||
import frostedShader from './shaders/frosted.frag?raw';
|
||||
import { MediaLoader } from './input/MediaLoader';
|
||||
import { PointerController } from './input/PointerController';
|
||||
import { CameraController } from './input/CameraController';
|
||||
import { GyroController } from './input/GyroController';
|
||||
import { StaticDepthProvider } from './depth/StaticDepthProvider';
|
||||
import { DepthProcessor } from './depth/DepthProcessor';
|
||||
import { DEPTH_REFRACTION } from './constants/depth';
|
||||
import { ENVIRONMENT_REFLECTION_STRENGTH, GLASS_TRANSMISSION_DETAIL, CELL_LENS_SCALE } from './constants/glass';
|
||||
import { AiDepthProvider } from './depth/AiDepthProvider';
|
||||
import { PetDepthMesh } from './objects/PetDepthMesh';
|
||||
import { DepthDebug } from './depth/DepthDebug';
|
||||
import { WindowReveal } from './objects/WindowReveal';
|
||||
import glassScreenVertex from './shaders/glassScreen.vert?raw';
|
||||
import regionPaletteShader from './shaders/region-palette.frag?raw';
|
||||
import regionHistoryShader from './shaders/region-history.frag?raw';
|
||||
|
||||
const wrap = document.querySelector('#canvas-wrap') as HTMLElement;
|
||||
const status = document.querySelector('#status') as HTMLElement;
|
||||
const depthProvenance = document.querySelector('#depth-provenance') as HTMLElement;
|
||||
const aiDepthButton = document.querySelector('#ai-depth') as HTMLButtonElement;
|
||||
const empty = document.querySelector('#empty') as HTMLElement;
|
||||
const hintElement = document.querySelector('#hint') as HTMLElement;
|
||||
hintElement.style.opacity = '0';
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
|
||||
wrap.append(renderer.domElement);
|
||||
const scene = new THREE.Scene();
|
||||
const glassScene = new THREE.Scene();
|
||||
const frameScene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x111923);
|
||||
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
|
||||
camera.position.z = 3;
|
||||
const geometry = new THREE.PlaneGeometry(1, 1);
|
||||
const emptyTex = new THREE.DataTexture(new Uint8Array([38, 48, 62, 255]), 1, 1);
|
||||
emptyTex.needsUpdate = true;
|
||||
const petMat = new THREE.MeshBasicMaterial({ map: emptyTex });
|
||||
const pet = new THREE.Mesh(geometry, petMat);
|
||||
scene.add(pet);
|
||||
const depthMap: THREE.Texture = DepthProcessor.flatTexture();
|
||||
const petDepth = new PetDepthMesh(emptyTex, depthMap, 0.25, 0);
|
||||
const petDepthMaterial = petDepth.material;
|
||||
const depthUniforms = petDepthMaterial.uniforms;
|
||||
scene.add(petDepth);
|
||||
const uniforms = { uSceneTexture: { value: emptyTex as THREE.Texture }, uSceneDepth: { value: emptyTex as THREE.Texture }, uProtectionDepthMap: depthUniforms.uDepthMap, uPetWorldToLocal: { value: new THREE.Matrix4() }, uContentViewProjection: { value: new THREE.Matrix4() }, uContentInverseViewProjection: { value: new THREE.Matrix4() }, uContentCameraPosition: { value: new THREE.Vector3() }, uLightPosition: { value: new THREE.Vector3(0.6, 0.8, 2.6) }, uBackgroundColor: { value: scene.background.clone() }, uGlassZ: { value: 0.6 }, uSourceZ: { value: 0.56 }, uGridSize: { value: 13 }, uMosaicStyle: { value: 0 }, uStrength: { value: 1 }, uGlossStrength: { value: 0.3 }, uCurvature: { value: 1 }, uRegionStrength: { value: 1 }, uRegionAvailable: { value: 0 }, uRegionAtlasSize: { value: new THREE.Vector2(104, 104) }, uRegionGridSize: { value: new THREE.Vector2(13, 13) }, uRegionPaletteSize: { value: new THREE.Vector2(65, 13) }, uRegionPalette: { value: emptyTex as THREE.Texture }, uRegionAtlas: { value: emptyTex as THREE.Texture }, uViewOffset: { value: new THREE.Vector2() }, uPaneRect: { value: new THREE.Vector4(0, 0, 1, 1) }, uResolution: { value: new THREE.Vector2() }, uTime: { value: 0 }, uPetSize: { value: new THREE.Vector2(1, 1) }, uPaneSize: { value: new THREE.Vector2(1, 1) } };
|
||||
const regionHistoryUniform = { value: emptyTex as THREE.Texture };
|
||||
const depthEvidenceUniform = { value: new THREE.Vector2(1, 1) };
|
||||
Object.assign(uniforms, { uRegionHistory: regionHistoryUniform, uDepthEvidenceRange: depthEvidenceUniform,
|
||||
uEnvironmentStrength: { value: ENVIRONMENT_REFLECTION_STRENGTH },
|
||||
uTransmissionDetail: { value: GLASS_TRANSMISSION_DETAIL },
|
||||
uCellLensScale: { value: CELL_LENS_SCALE },
|
||||
uDepthCorrection: { value: new THREE.Vector2(DEPTH_REFRACTION.blend, DEPTH_REFRACTION.maxCellCorrection) } });
|
||||
const glassMat = new THREE.ShaderMaterial({ uniforms, vertexShader: glassScreenVertex, fragmentShader: mosaicShader });
|
||||
const glass = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), glassMat);
|
||||
glassScene.add(glass);
|
||||
const atlasScene = new THREE.Scene();
|
||||
const atlasMaterial = new THREE.ShaderMaterial({ uniforms, vertexShader: glassScreenVertex, fragmentShader: mosaicShader, defines: { REGION_ATLAS: 1 }, toneMapped: false });
|
||||
const atlasMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), atlasMaterial);
|
||||
atlasScene.add(atlasMesh);
|
||||
const paletteScene = new THREE.Scene();
|
||||
const paletteUniforms = { uRegionAtlas: { value: null as THREE.Texture | null }, uRegionAtlasSize: uniforms.uRegionAtlasSize, uRegionGridSize: uniforms.uRegionGridSize };
|
||||
const paletteMaterial = new THREE.ShaderMaterial({ uniforms: paletteUniforms, vertexShader: glassScreenVertex, fragmentShader: regionPaletteShader, toneMapped: false });
|
||||
const paletteMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), paletteMaterial);
|
||||
paletteScene.add(paletteMesh);
|
||||
const frameGeometry = new THREE.BoxGeometry(1, 1, 1);
|
||||
const frameMaterials = [
|
||||
new THREE.MeshBasicMaterial({ color: 0x3a2419 }),
|
||||
new THREE.MeshBasicMaterial({ color: 0x4a2e20 }),
|
||||
new THREE.MeshBasicMaterial({ color: 0x2b1a13 }),
|
||||
new THREE.MeshBasicMaterial({ color: 0x513326 }),
|
||||
new THREE.MeshBasicMaterial({ color: 0x321f17 }),
|
||||
new THREE.MeshBasicMaterial({ color: 0x43291d })
|
||||
];
|
||||
const frameLeft = new THREE.Mesh(frameGeometry, frameMaterials);
|
||||
const frameRight = new THREE.Mesh(frameGeometry, frameMaterials);
|
||||
const frameTop = new THREE.Mesh(frameGeometry, frameMaterials);
|
||||
const frameBottom = new THREE.Mesh(frameGeometry, frameMaterials);
|
||||
frameScene.add(frameLeft, frameRight, frameTop, frameBottom);
|
||||
const windowRevealMaterial = new THREE.MeshBasicMaterial({ color: frameMaterials[1].color, side: THREE.DoubleSide });
|
||||
const windowReveal = new WindowReveal(windowRevealMaterial);
|
||||
frameScene.add(...windowReveal.meshes);
|
||||
const postCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
const renderTarget = new THREE.WebGLRenderTarget(1, 1, { colorSpace: THREE.NoColorSpace, type: THREE.HalfFloatType, depthBuffer: true });
|
||||
renderTarget.depthTexture = new THREE.DepthTexture(1, 1, THREE.UnsignedIntType);
|
||||
renderTarget.depthTexture.format = THREE.DepthFormat;
|
||||
uniforms.uSceneDepth.value = renderTarget.depthTexture;
|
||||
const regionAtlasTarget = new THREE.WebGLRenderTarget(104, 104, { colorSpace: THREE.NoColorSpace, type: THREE.HalfFloatType, depthBuffer: false, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter });
|
||||
const regionPaletteTarget = new THREE.WebGLRenderTarget(65, 13, { colorSpace: THREE.NoColorSpace, type: THREE.UnsignedByteType, depthBuffer: false, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter });
|
||||
const splitHistoryTargets = [0, 1].map(() => new THREE.WebGLRenderTarget(104, 104, { colorSpace: THREE.NoColorSpace, type: THREE.HalfFloatType, depthBuffer: false, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter }));
|
||||
const historyScene = new THREE.Scene();
|
||||
const historyUniforms = { uRegionPalette: { value: regionPaletteTarget.texture }, uPreviousHistory: { value: splitHistoryTargets[0].texture }, uRegionAtlasSize: uniforms.uRegionAtlasSize, uRegionGridSize: uniforms.uRegionGridSize, uRegionPaletteSize: uniforms.uRegionPaletteSize, uHistoryBlend: { value: 1 } };
|
||||
const historyMaterial = new THREE.ShaderMaterial({ uniforms: historyUniforms, vertexShader: glassScreenVertex, fragmentShader: regionHistoryShader, toneMapped: false });
|
||||
const historyMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), historyMaterial);
|
||||
historyScene.add(historyMesh);
|
||||
uniforms.uRegionPalette.value = regionPaletteTarget.texture;
|
||||
paletteUniforms.uRegionAtlas.value = regionAtlasTarget.texture;
|
||||
let regionSignature = '';
|
||||
let splitHistorySignature = '';
|
||||
let splitHistoryRead = 0;
|
||||
let splitHistoryInitialized = false;
|
||||
function updateRegionTargetSizes(): void {
|
||||
const gridX = Math.max(1, Math.ceil(uniforms.uGridSize.value));
|
||||
const gridYValue = Math.max(1, uniforms.uGridSize.value * uniforms.uPaneSize.value.y / Math.max(uniforms.uPaneSize.value.x, 0.001));
|
||||
const gridY = Math.ceil(gridYValue);
|
||||
const atlasWidth = gridX * 8, atlasHeight = gridY * 8, paletteWidth = gridX * 5, paletteHeight = gridY;
|
||||
const available = Math.max(atlasWidth, atlasHeight, paletteWidth, paletteHeight) <= renderer.capabilities.maxTextureSize;
|
||||
const changed = uniforms.uRegionAvailable.value !== (available ? 1 : 0) || (available && (regionAtlasTarget.width !== atlasWidth || regionAtlasTarget.height !== atlasHeight || regionPaletteTarget.width !== paletteWidth || regionPaletteTarget.height !== paletteHeight || splitHistoryTargets[0].width !== atlasWidth || splitHistoryTargets[0].height !== atlasHeight)) || uniforms.uRegionGridSize.value.x !== uniforms.uGridSize.value || uniforms.uRegionGridSize.value.y !== gridYValue;
|
||||
if (available && (regionAtlasTarget.width !== atlasWidth || regionAtlasTarget.height !== atlasHeight)) regionAtlasTarget.setSize(atlasWidth, atlasHeight);
|
||||
if (available && (regionPaletteTarget.width !== paletteWidth || regionPaletteTarget.height !== paletteHeight)) regionPaletteTarget.setSize(paletteWidth, paletteHeight);
|
||||
for (const target of splitHistoryTargets) if (available && (target.width !== atlasWidth || target.height !== atlasHeight)) target.setSize(atlasWidth, atlasHeight);
|
||||
uniforms.uRegionAvailable.value = available ? 1 : 0;
|
||||
uniforms.uRegionAtlasSize.value.set(available ? atlasWidth : regionAtlasTarget.width, available ? atlasHeight : regionAtlasTarget.height);
|
||||
uniforms.uRegionGridSize.value.set(uniforms.uGridSize.value, gridYValue);
|
||||
uniforms.uRegionPaletteSize.value.set(available ? paletteWidth : regionPaletteTarget.width, available ? paletteHeight : regionPaletteTarget.height);
|
||||
paletteUniforms.uRegionAtlas.value = regionAtlasTarget.texture;
|
||||
uniforms.uRegionPalette.value = regionPaletteTarget.texture;
|
||||
uniforms.uRegionAtlas.value = regionAtlasTarget.texture;
|
||||
if (changed) regionSignature = '';
|
||||
}
|
||||
let active: THREE.Texture = emptyTex;
|
||||
let activeIsVideo = false;
|
||||
let aspect = 1;
|
||||
let baseW = 1;
|
||||
let baseH = 1;
|
||||
let photoZoom = 1;
|
||||
let photoPanX = 0;
|
||||
let photoPanY = 0;
|
||||
let pendingSampleFraming = false;
|
||||
let activeTestPhotoHasNoDepth = false;
|
||||
let mode = 'noglass';
|
||||
let hintGeneration = 0;
|
||||
let hintDraggedGeneration = -1;
|
||||
let pendingPairDepth: THREE.Texture | null = null;
|
||||
let pendingSampleDebugImage: HTMLImageElement | null = null;
|
||||
let pendingTestPhotoHasNoDepth = false;
|
||||
let mediaRequest = 0;
|
||||
let depthRequest = 0;
|
||||
let mediaBusy = false;
|
||||
let manualDepthBusy = false;
|
||||
let aiDepthBusy = false;
|
||||
let disposed = false;
|
||||
const listenerAbort = new AbortController();
|
||||
function syncAIDepthButton(): void {
|
||||
aiDepthButton.disabled = active === emptyTex || activeIsVideo || mediaBusy || manualDepthBusy || aiDepthBusy || disposed;
|
||||
aiDepthButton.textContent = aiDepthBusy ? 'AI 깊이 생성 중…' : 'AI 깊이 생성';
|
||||
}
|
||||
const aiDepthProvider = new AiDepthProvider((message) => { if (!disposed && aiDepthBusy) status.textContent = message; });
|
||||
function invalidateDepthRequests(): void {
|
||||
depthRequest += 1;
|
||||
aiDepthProvider.cancel();
|
||||
aiDepthBusy = false;
|
||||
manualDepthBusy = false;
|
||||
syncAIDepthButton();
|
||||
}
|
||||
function clearPendingPair(): void { if (pendingPairDepth) pendingPairDepth.dispose(); pendingPairDepth = null; }
|
||||
let maxDepthValue = 0;
|
||||
function contentBaseZ(): number { return uniforms.uGlassZ.value - 0.04 - (maxDepthValue - depthUniforms.uDepthBias.value) * depthUniforms.uDepthScale.value; }
|
||||
function clearPendingTestState(): void { clearPendingPair(); pendingSampleDebugImage = null; pendingSampleFraming = false; pendingTestPhotoHasNoDepth = false; }
|
||||
function applyFraming(): void {
|
||||
const baseZ = contentBaseZ();
|
||||
const worldScale = (camera.position.z - baseZ) / camera.position.z;
|
||||
const x = baseW * (photoZoom - 1) * 0.5 * photoPanX * worldScale;
|
||||
const y = baseH * (photoZoom - 1) * 0.5 * photoPanY * worldScale;
|
||||
pet.scale.set(baseW * photoZoom * worldScale, baseH * photoZoom * worldScale, 1);
|
||||
pet.position.set(x, y, baseZ);
|
||||
petDepth.scale.copy(pet.scale);
|
||||
petDepth.position.copy(pet.position);
|
||||
uniforms.uSourceZ.value = baseZ;
|
||||
windowReveal.update(uniforms.uPaneSize.value.x, uniforms.uPaneSize.value.y, baseW * (3 - baseZ) / 3, baseH * (3 - baseZ) / 3, uniforms.uGlassZ.value, baseZ);
|
||||
}
|
||||
function resize(): void {
|
||||
const bounds = wrap.getBoundingClientRect();
|
||||
renderer.setSize(bounds.width, bounds.height, false);
|
||||
camera.aspect = bounds.width / bounds.height;
|
||||
camera.updateProjectionMatrix();
|
||||
const height = 1.7;
|
||||
const scale = Math.min(height * bounds.width / bounds.height / aspect, height);
|
||||
baseW = scale * aspect;
|
||||
baseH = scale;
|
||||
applyFraming();
|
||||
uniforms.uPetSize.value.set(baseW, baseH);
|
||||
uniforms.uResolution.value.set(bounds.width, bounds.height);
|
||||
const glassHeight = 2 * (camera.position.z - 0.6) * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
|
||||
uniforms.uPetSize.value.set(glassHeight * camera.aspect, glassHeight);
|
||||
uniforms.uPaneSize.value.set(baseW * (3 - uniforms.uGlassZ.value) / 3, baseH * (3 - uniforms.uGlassZ.value) / 3);
|
||||
windowReveal.update(uniforms.uPaneSize.value.x, uniforms.uPaneSize.value.y, baseW * (3 - contentBaseZ()) / 3, baseH * (3 - contentBaseZ()) / 3, uniforms.uGlassZ.value, contentBaseZ());
|
||||
updateRegionTargetSizes();
|
||||
const frameW = uniforms.uPaneSize.value.x;
|
||||
const frameH = uniforms.uPaneSize.value.y;
|
||||
const frameThickness = Math.min(frameW, frameH) * 0.035;
|
||||
const frameDepth = frameThickness * 1.5;
|
||||
const frameZ = uniforms.uGlassZ.value + frameDepth * 0.25;
|
||||
frameLeft.position.set(-(frameW + frameThickness) * 0.5, 0, frameZ);
|
||||
frameRight.position.set((frameW + frameThickness) * 0.5, 0, frameZ);
|
||||
frameTop.position.set(0, (frameH + frameThickness) * 0.5, frameZ);
|
||||
frameBottom.position.set(0, -(frameH + frameThickness) * 0.5, frameZ);
|
||||
frameLeft.scale.set(frameThickness, frameH, frameDepth);
|
||||
frameRight.scale.set(frameThickness, frameH, frameDepth);
|
||||
frameTop.scale.set(frameW + frameThickness * 2, frameThickness, frameDepth);
|
||||
frameBottom.scale.set(frameW + frameThickness * 2, frameThickness, frameDepth);
|
||||
const neutralGlassHeight = 2 * 3 * Math.tan(THREE.MathUtils.degToRad(40 / 2));
|
||||
const neutralGlassWidth = neutralGlassHeight * camera.aspect;
|
||||
uniforms.uPaneRect.value.set(Math.max(0, 0.5 - baseW / (2 * neutralGlassWidth)), Math.max(0, 0.5 - baseH / (2 * neutralGlassHeight)), Math.min(1, 0.5 + baseW / (2 * neutralGlassWidth)), Math.min(1, 0.5 + baseH / (2 * neutralGlassHeight)));
|
||||
renderTarget.setSize(Math.max(1, Math.floor(bounds.width * Math.min(devicePixelRatio, 1.5))), Math.max(1, Math.floor(bounds.height * Math.min(devicePixelRatio, 1.5))));
|
||||
}
|
||||
function resetHintForMedia(): void { hintGeneration += 1; hintDraggedGeneration = -1; hintElement.style.opacity = '0'; }
|
||||
function showHint(): void { if (hintDraggedGeneration === hintGeneration) return; hintElement.style.opacity = '1'; }
|
||||
function hideHintAfterDrag(): void { hintDraggedGeneration = hintGeneration; hintElement.style.opacity = '0'; }
|
||||
function applyMedia(texture: THREE.Texture, mediaAspect: number, video: HTMLVideoElement | null): void {
|
||||
if (active !== emptyTex) active.dispose();
|
||||
active = texture; activeIsVideo = Boolean(video); mediaBusy = false; aspect = mediaAspect;
|
||||
pendingSampleFraming = false;
|
||||
syncPhotoControls();
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
depthUniforms.uTexture.value = texture;
|
||||
petMat.map = texture; petMat.needsUpdate = true;
|
||||
empty.style.display = 'none'; resize(); syncAIDepthButton();
|
||||
}
|
||||
function setDepth(texture: THREE.Texture): void {
|
||||
if (depthUniforms.uDepthMap.value !== depthMap) depthUniforms.uDepthMap.value.dispose();
|
||||
depthUniforms.uDepthMap.value = texture; texture.colorSpace = THREE.NoColorSpace;
|
||||
depthEvidenceUniform.value.fromArray(DepthProcessor.evidenceRange(texture));
|
||||
const data = (texture as THREE.DataTexture).image?.data;
|
||||
maxDepthValue = 0;
|
||||
if (data && data.length >= 4) {
|
||||
for (let i = 0; i < data.length; i += 4) maxDepthValue = Math.max(maxDepthValue, Number(data[i]) / 255);
|
||||
}
|
||||
applyFraming();
|
||||
regionSignature = '';
|
||||
if (mode === 'depth') { petMat.map = texture; petMat.needsUpdate = true; }
|
||||
}
|
||||
function setMode(next: string): void {
|
||||
mode = next;
|
||||
if (modeInput) modeInput.value = next;
|
||||
pet.visible = next === 'rgb' || next === 'depth';
|
||||
petDepth.visible = next === 'noglass' || next === 'final';
|
||||
glass.visible = next === 'final' || next === 'glass';
|
||||
petMat.map = next === 'depth' ? depthUniforms.uDepthMap.value : active;
|
||||
petMat.needsUpdate = true;
|
||||
petDepthMaterial.uniforms.uTexture.value = active;
|
||||
}
|
||||
const retry = document.createElement('button');
|
||||
retry.textContent = '재생'; retry.hidden = true;
|
||||
(document.querySelector('aside') as HTMLElement).append(retry);
|
||||
const debugOutput = document.createElement('pre');
|
||||
debugOutput.id = 'depth-debug';
|
||||
debugOutput.hidden = true;
|
||||
(document.querySelector('aside') as HTMLElement).append(debugOutput);
|
||||
const depthDebug = new DepthDebug();
|
||||
const loader = new MediaLoader((texture, mediaAspect, video) => { const hasSampleDepth = pendingSampleFraming && pendingPairDepth !== null; const hasTestWithoutDepth = pendingTestPhotoHasNoDepth; const shouldGenerateDepth = !video && !hasSampleDepth; applyMedia(texture, mediaAspect, video); petDepth.setVideo(Boolean(video)); setDepth(pendingPairDepth ?? DepthProcessor.flatTexture()); pendingPairDepth = null; if (hasSampleDepth && pendingSampleDebugImage) depthDebug.setSample(pendingSampleDebugImage); else depthDebug.clear(); pendingSampleDebugImage = null; activeTestPhotoHasNoDepth = hasTestWithoutDepth; pendingTestPhotoHasNoDepth = false; depthProvenance.hidden = !(hasSampleDepth || activeTestPhotoHasNoDepth); if (hasSampleDepth) depthProvenance.textContent = '수작업 테스트 Depth · AI 추정 아님'; else if (activeTestPhotoHasNoDepth) depthProvenance.textContent = 'Depth 없음 · 평면 사진 테스트'; setMode('final'); showHint(); if (sampleButton) sampleButton.textContent = '선택한 테스트 사진 불러오기'; if (shouldGenerateDepth) void generateAIDepth(true); }, (message) => { mediaBusy = false; syncAIDepthButton(); if (message || !aiDepthBusy) status.textContent = message; retry.hidden = !message.includes('자동'); if (message) { clearPendingTestState(); if (sampleButton) sampleButton.textContent = '선택한 테스트 사진 불러오기'; } });
|
||||
retry.onclick = () => loader.retry();
|
||||
const sampleDepthProvider = new StaticDepthProvider('/assets/sample-pet-depth.png');
|
||||
const cameraController = new CameraController(camera);
|
||||
const pointer = new PointerController(renderer.domElement, (x, y) => cameraController.add(x, y), hideHintAfterDrag);
|
||||
const gyro = new GyroController((x, y) => cameraController.target.set(THREE.MathUtils.clamp(x, -1, 1), THREE.MathUtils.clamp(y, -1, 1)), (message) => { status.textContent = message; });
|
||||
const imageInput = document.querySelector('#image') as HTMLInputElement;
|
||||
const videoInput = document.querySelector('#video') as HTMLInputElement;
|
||||
const depthFileInput = document.querySelector('#depth-image') as HTMLInputElement;
|
||||
const sampleButton = document.querySelector('#sample') as HTMLButtonElement | null;
|
||||
const testPhotoInput = document.querySelector('#test-photo') as HTMLSelectElement;
|
||||
const referenceStrip = document.querySelector('#reference-strip') as HTMLElement;
|
||||
const referenceStatus = document.querySelector('#reference-status') as HTMLElement;
|
||||
const referenceImages = [
|
||||
document.querySelector('#reference-image-1') as HTMLImageElement,
|
||||
document.querySelector('#reference-image-2') as HTMLImageElement
|
||||
];
|
||||
referenceStrip.hidden = false;
|
||||
const showReferenceError = () => { referenceStatus.hidden = false; };
|
||||
referenceImages.forEach((image) => image.addEventListener('error', showReferenceError, { signal: listenerAbort.signal }));
|
||||
void Promise.all([
|
||||
import('../docs/qa-five-photos/reference1.png?url'),
|
||||
import('../docs/qa-five-photos/reference2.png?url')
|
||||
]).then(([reference1, reference2]) => {
|
||||
if (disposed) return;
|
||||
referenceImages[0].src = reference1.default;
|
||||
referenceImages[1].src = reference2.default;
|
||||
}).catch(() => { if (!disposed) showReferenceError(); });
|
||||
const effectInput = document.querySelector('#effect') as HTMLSelectElement;
|
||||
const modeInput = document.querySelector('#view-mode') as HTMLSelectElement | null;
|
||||
const sensitivityInput = document.querySelector('#sensitivity') as HTMLInputElement;
|
||||
const depthInput = document.querySelector('#depth-scale') as HTMLInputElement | null;
|
||||
const cameraOffsetInput = document.querySelector('#camera-offset') as HTMLInputElement;
|
||||
const gridInput = document.querySelector('#grid-size') as HTMLInputElement;
|
||||
const gridOutput = document.querySelector('#gridOut') as HTMLOutputElement;
|
||||
const mosaicStyleInput = document.querySelector('#mosaic-style') as HTMLSelectElement;
|
||||
const mosaicStyleLabel = mosaicStyleInput.closest('label');
|
||||
const glossControls = document.querySelector('#gloss-controls') as HTMLElement | null;
|
||||
const regionFixedNote = document.querySelector('#region-fixed-note') as HTMLElement | null;
|
||||
const curvatureInput = document.querySelector('#curvature') as HTMLInputElement | null;
|
||||
const glossInput = document.querySelector('#gloss-strength') as HTMLInputElement | null;
|
||||
const lightXInput = document.querySelector('#light-x') as HTMLInputElement | null;
|
||||
const lightYInput = document.querySelector('#light-y') as HTMLInputElement | null;
|
||||
const glossOutput = document.querySelector('#glossOut') as HTMLOutputElement | null;
|
||||
const lightXOutput = document.querySelector('#lightXOut') as HTMLOutputElement | null;
|
||||
const lightYOutput = document.querySelector('#lightYOut') as HTMLOutputElement | null;
|
||||
const cameraOutput = document.querySelector('#cameraOut') as HTMLOutputElement;
|
||||
const sensitivityOutput = document.querySelector('#sensitivityOut') as HTMLOutputElement;
|
||||
const depthOutput = document.querySelector('#depthOut') as HTMLOutputElement | null;
|
||||
const photoZoomInput = document.querySelector('#photo-zoom') as HTMLInputElement | null;
|
||||
const photoZoomOutput = document.querySelector('#photoZoomOut') as HTMLOutputElement | null;
|
||||
const photoPanXInput = document.querySelector('#photo-pan-x') as HTMLInputElement | null;
|
||||
const photoPanXOutput = document.querySelector('#photoPanXOut') as HTMLOutputElement | null;
|
||||
const photoPanYInput = document.querySelector('#photo-pan-y') as HTMLInputElement | null;
|
||||
const photoPanYOutput = document.querySelector('#photoPanYOut') as HTMLOutputElement | null;
|
||||
const photoResetButton = document.querySelector('#photo-reset') as HTMLButtonElement | null;
|
||||
const resetButton = document.querySelector('#reset') as HTMLButtonElement;
|
||||
const gyroButton = document.querySelector('#gyro') as HTMLButtonElement;
|
||||
const effects = [mosaicShader, reededShader, frostedShader];
|
||||
let sensitivity = 1;
|
||||
function setGlossControlsVisibility(): void {
|
||||
const enabled = effectInput.value === 'mosaic';
|
||||
const ref1Mosaic = enabled && mosaicStyleInput.value === '0';
|
||||
if (mosaicStyleLabel) mosaicStyleLabel.style.display = enabled ? 'grid' : 'none';
|
||||
mosaicStyleInput.disabled = !enabled;
|
||||
if (glossControls) { glossControls.hidden = !enabled; glossControls.style.display = enabled ? 'grid' : 'none'; }
|
||||
if (regionFixedNote) regionFixedNote.hidden = !ref1Mosaic;
|
||||
[glossInput, lightXInput, lightYInput, curvatureInput].forEach((input) => { if (input) input.disabled = !enabled; });
|
||||
}
|
||||
function syncPhotoControls(): void {
|
||||
if (photoZoomInput) photoZoomInput.value = photoZoom.toFixed(2);
|
||||
if (photoZoomOutput) photoZoomOutput.value = photoZoom.toFixed(2);
|
||||
if (photoPanXInput) photoPanXInput.value = photoPanX.toFixed(2);
|
||||
if (photoPanXOutput) photoPanXOutput.value = photoPanX.toFixed(2);
|
||||
if (photoPanYInput) photoPanYInput.value = photoPanY.toFixed(2);
|
||||
if (photoPanYOutput) photoPanYOutput.value = photoPanY.toFixed(2);
|
||||
}
|
||||
async function generateAIDepth(automatic = false): Promise<void> {
|
||||
if (aiDepthButton.disabled || !(active.image instanceof HTMLImageElement)) return;
|
||||
const sourceTexture = active;
|
||||
const sourceImage = active.image;
|
||||
const request = ++depthRequest;
|
||||
aiDepthBusy = true;
|
||||
status.textContent = 'AI 모델을 준비하는 중입니다. 처음 실행하면 모델 파일을 다운로드합니다.';
|
||||
syncAIDepthButton();
|
||||
try {
|
||||
const texture = await aiDepthProvider.getDepthMap(sourceImage);
|
||||
if (disposed || request !== depthRequest || active !== sourceTexture || activeIsVideo) {
|
||||
texture.dispose();
|
||||
return;
|
||||
}
|
||||
setDepth(texture);
|
||||
depthDebug.clear();
|
||||
activeTestPhotoHasNoDepth = false;
|
||||
depthProvenance.hidden = false;
|
||||
depthProvenance.textContent = 'AI 추정 · Depth Anything V2 Small (상대 깊이, 0=뒤 · 1=앞)';
|
||||
setMode('final');
|
||||
status.textContent = automatic ? '자동 AI 추정 depth를 적용했습니다.' : 'AI 추정 depth를 적용하고 Final 뷰로 돌아왔습니다.';
|
||||
showHint();
|
||||
} catch (error) {
|
||||
if (!disposed && request === depthRequest) {
|
||||
const detail = error instanceof Error ? error.message : '알 수 없는 오류';
|
||||
status.textContent = automatic ? `자동 depth 생성 실패 (${detail}). 평면 사진 배치를 유지합니다. AI 깊이 생성 버튼으로 다시 시도할 수 있습니다.` : `AI 깊이 생성 실패 (${detail}). 다시 시도할 수 있으며 기존 depth는 유지됩니다.`;
|
||||
}
|
||||
} finally {
|
||||
if (request === depthRequest) {
|
||||
aiDepthBusy = false;
|
||||
syncAIDepthButton();
|
||||
}
|
||||
}
|
||||
}
|
||||
aiDepthButton.addEventListener('click', () => { void generateAIDepth(); }, { signal: listenerAbort.signal });
|
||||
imageInput.onchange = (event) => { const input = event.target as HTMLInputElement; const file = input.files?.[0]; if (file) { resetHintForMedia(); mediaRequest += 1; invalidateDepthRequests(); mediaBusy = true; syncAIDepthButton(); clearPendingTestState(); loader.load(file); } input.value = ''; };
|
||||
videoInput.onchange = (event) => { const input = event.target as HTMLInputElement; const file = input.files?.[0]; if (file) { resetHintForMedia(); mediaRequest += 1; invalidateDepthRequests(); mediaBusy = true; syncAIDepthButton(); clearPendingTestState(); loader.load(file); } input.value = ''; };
|
||||
photoZoomInput?.addEventListener('input', (event) => { photoZoom = Number((event.target as HTMLInputElement).value); applyFraming(); syncPhotoControls(); }, { signal: listenerAbort.signal });
|
||||
photoPanXInput?.addEventListener('input', (event) => { photoPanX = Number((event.target as HTMLInputElement).value); applyFraming(); syncPhotoControls(); }, { signal: listenerAbort.signal });
|
||||
photoPanYInput?.addEventListener('input', (event) => { photoPanY = Number((event.target as HTMLInputElement).value); applyFraming(); syncPhotoControls(); }, { signal: listenerAbort.signal });
|
||||
photoResetButton?.addEventListener('click', () => { photoZoom = 1; photoPanX = 0; photoPanY = 0; applyFraming(); syncPhotoControls(); }, { signal: listenerAbort.signal });
|
||||
sampleButton?.addEventListener('click', async () => { const request = ++mediaRequest; const selected = testPhotoInput.value; const isSample = selected === 'sample'; const filePath = isSample ? '/assets/sample-pet.png' : `/assets/qa/${selected === 'dog3' ? 'dog3.webp' : `${selected}.jpeg`}`; invalidateDepthRequests(); mediaBusy = true; syncAIDepthButton(); loader.cancelPending(); clearPendingTestState(); sampleButton.textContent = `불러오는 중: ${filePath.split('/').pop()}`; status.textContent = ''; let url = ''; try { const rgbResponse = await fetch(filePath); if (!rgbResponse.ok) throw new Error('image'); const rgb = await rgbResponse.blob(); if (disposed || request !== mediaRequest) return; if (isSample) { const depthResponse = await fetch('/assets/sample-pet-depth.png'); if (!depthResponse.ok) throw new Error('depth'); const depth = await depthResponse.blob(); if (disposed || request !== mediaRequest) return; url = URL.createObjectURL(depth); const image = new Image(); image.src = url; await image.decode(); if (disposed || request !== mediaRequest) return; const loadedDepth = await sampleDepthProvider.getDepthMap(image); if (disposed || request !== mediaRequest) { loadedDepth.dispose(); return; } pendingPairDepth = loadedDepth; pendingSampleDebugImage = image; pendingSampleFraming = true; } pendingTestPhotoHasNoDepth = !isSample; loader.load(new File([rgb], filePath.split('/').pop() ?? 'test-photo', { type: rgbResponse.headers.get('content-type') ?? 'image/jpeg' })); if (isSample) status.textContent = '수작업 테스트 Depth · AI 추정 아님'; } catch { if (!disposed && request === mediaRequest) { mediaBusy = false; syncAIDepthButton(); clearPendingTestState(); status.textContent = isSample ? '샘플 사진 또는 depth map을 읽지 못했습니다.' : '테스트 사진을 읽지 못했습니다.'; sampleButton.textContent = '선택한 테스트 사진 불러오기'; } } finally { if (url) URL.revokeObjectURL(url); } }, { signal: listenerAbort.signal });
|
||||
sampleButton?.addEventListener('click', resetHintForMedia, { signal: listenerAbort.signal });
|
||||
effectInput.addEventListener('change', (event) => { const index = ['mosaic', 'reeded', 'frosted'].indexOf((event.target as HTMLSelectElement).value); glassMat.fragmentShader = effects[index]; glassMat.needsUpdate = true; setGlossControlsVisibility(); }, { signal: listenerAbort.signal });
|
||||
modeInput?.addEventListener('change', (event) => setMode((event.target as HTMLSelectElement).value), { signal: listenerAbort.signal });
|
||||
sensitivityInput.oninput = (event) => { const value = Number((event.target as HTMLInputElement).value); sensitivity = value; sensitivityOutput.value = value.toFixed(2); };
|
||||
depthInput?.addEventListener('input', (event) => { const value = Math.min(Number((event.target as HTMLInputElement).value), 0.4); depthUniforms.uDepthScale.value = value; applyFraming(); regionSignature = ''; if (depthOutput) depthOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
|
||||
cameraOffsetInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); cameraController.setMaxOffset(value); cameraOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
|
||||
gridInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGridSize.value = value; gridOutput.value = value.toFixed(0); updateRegionTargetSizes(); }, { signal: listenerAbort.signal });
|
||||
mosaicStyleInput.addEventListener('change', (event) => { if (effectInput.value !== 'mosaic') return; const style = Number((event.target as HTMLSelectElement).value); uniforms.uMosaicStyle.value = style; uniforms.uGridSize.value = style === 0 ? 13 : 24; uniforms.uStrength.value = 1; gridInput.value = String(uniforms.uGridSize.value); gridOutput.value = String(uniforms.uGridSize.value); updateRegionTargetSizes(); setGlossControlsVisibility(); }, { signal: listenerAbort.signal });
|
||||
curvatureInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uCurvature.value = value; const output = document.querySelector('#curvatureOut') as HTMLOutputElement | null; if (output) output.value = value.toFixed(2); }, { signal: listenerAbort.signal });
|
||||
glossInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGlossStrength.value = value; if (glossOutput) glossOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
|
||||
lightXInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uLightPosition.value.x = value; if (lightXOutput) lightXOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
|
||||
lightYInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uLightPosition.value.y = value; if (lightYOutput) lightYOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
|
||||
depthFileInput?.addEventListener('change', async () => { invalidateDepthRequests(); const request = depthRequest; const file = depthFileInput.files?.[0]; if (!file) return; manualDepthBusy = true; syncAIDepthButton(); depthDebug.clear(); const url = URL.createObjectURL(file); let texture: THREE.Texture | null = null; try { const image = new Image(); image.src = url; await image.decode(); if (disposed || request !== depthRequest) return; if (aspect > 0 && Math.abs(image.width / image.height - aspect) > 0.02) throw new Error('aspect'); texture = DepthProcessor.toTexture(image); if (disposed || request !== depthRequest) { texture.dispose(); texture = null; return; } setDepth(texture); texture = null; depthProvenance.hidden = true; status.textContent = '수동 depth map 적용됨 (0=뒤, 1=앞)'; } catch { if (!disposed && request === depthRequest) status.textContent = 'RGB와 종횡비가 다른 depth map입니다.'; } finally { texture?.dispose(); URL.revokeObjectURL(url); if (request === depthRequest) { depthFileInput.value = ''; manualDepthBusy = false; syncAIDepthButton(); } } }, { signal: listenerAbort.signal });
|
||||
resetButton.onclick = () => { cameraController.reset(); gyro.reset(); };
|
||||
gyroButton.onclick = async () => { gyroButton.textContent = `Gyroscope: ${(await gyro.toggle()) ? 'ON' : 'OFF'}`; };
|
||||
const observer = new ResizeObserver(resize); observer.observe(wrap); resize(); setMode('final'); setGlossControlsVisibility(); syncAIDepthButton();
|
||||
let last = performance.now(); let raf = 0;
|
||||
function currentRegionSignature(): string {
|
||||
const view = uniforms.uContentViewProjection.value.elements.map((value) => value.toFixed(4)).join(',');
|
||||
const object = petDepth.matrixWorld.elements.map((value) => value.toFixed(4)).join(',');
|
||||
const depth = depthUniforms.uDepthMap.value;
|
||||
return [mode, active.uuid, depth.uuid, view, object, uniforms.uGridSize.value, uniforms.uStrength.value, uniforms.uCurvature.value, uniforms.uMosaicStyle.value, uniforms.uPaneSize.value.x, uniforms.uPaneSize.value.y, depthUniforms.uDepthScale.value, depthUniforms.uDepthBias.value, renderTarget.width, renderTarget.height, regionAtlasTarget.width, regionAtlasTarget.height].join('|');
|
||||
}
|
||||
function currentSplitHistorySignature(): string {
|
||||
const object = petDepth.matrixWorld.elements.map((value) => value.toFixed(4)).join(',');
|
||||
const depth = depthUniforms.uDepthMap.value;
|
||||
return [mode, effectInput.value, mosaicStyleInput.value, active.uuid, depth.uuid, uniforms.uRegionAvailable.value, uniforms.uGridSize.value, uniforms.uMosaicStyle.value, uniforms.uStrength.value, uniforms.uRegionStrength.value, uniforms.uCurvature.value, uniforms.uGlassZ.value, uniforms.uSourceZ.value, uniforms.uPaneSize.value.x, uniforms.uPaneSize.value.y, object, depthUniforms.uDepthScale.value, depthUniforms.uDepthBias.value, renderTarget.width, renderTarget.height, regionAtlasTarget.width, regionAtlasTarget.height, regionPaletteTarget.width, regionPaletteTarget.height].join('|');
|
||||
}
|
||||
function clearSplitHistory(): void {
|
||||
const previousTarget = renderer.getRenderTarget();
|
||||
const clearColor = renderer.getClearColor(new THREE.Color());
|
||||
const clearAlpha = renderer.getClearAlpha();
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
for (const target of splitHistoryTargets) {
|
||||
renderer.setRenderTarget(target);
|
||||
renderer.clear(true, false, false);
|
||||
}
|
||||
renderer.setRenderTarget(previousTarget);
|
||||
renderer.setClearColor(clearColor, clearAlpha);
|
||||
splitHistoryRead = 0;
|
||||
splitHistoryInitialized = false;
|
||||
regionHistoryUniform.value = splitHistoryTargets[0].texture;
|
||||
}
|
||||
function updateSplitHistory(dt: number): void {
|
||||
const signature = currentSplitHistorySignature();
|
||||
if (signature !== splitHistorySignature) {
|
||||
clearSplitHistory();
|
||||
splitHistorySignature = signature;
|
||||
}
|
||||
const enabled = (mode === 'final' || mode === 'glass') && effectInput.value === 'mosaic' && mosaicStyleInput.value === '0' && uniforms.uRegionAvailable.value > 0.5 && uniforms.uRegionStrength.value > 0.001 && uniforms.uStrength.value > 0.001;
|
||||
if (!enabled) return;
|
||||
|
||||
const writeIndex = 1 - splitHistoryRead;
|
||||
historyUniforms.uPreviousHistory.value = splitHistoryTargets[splitHistoryRead].texture;
|
||||
historyUniforms.uHistoryBlend.value = splitHistoryInitialized ? 1 - Math.exp(-dt / 0.07) : 1;
|
||||
renderer.setRenderTarget(splitHistoryTargets[writeIndex]);
|
||||
renderer.render(historyScene, postCamera);
|
||||
renderer.setRenderTarget(null);
|
||||
splitHistoryRead = writeIndex;
|
||||
splitHistoryInitialized = true;
|
||||
regionHistoryUniform.value = splitHistoryTargets[splitHistoryRead].texture;
|
||||
}
|
||||
function updateRegionPalette(): void {
|
||||
const enabled = effectInput.value === 'mosaic' && mosaicStyleInput.value === '0' && uniforms.uRegionAvailable.value > 0.5 && uniforms.uRegionStrength.value > 0.001 && uniforms.uStrength.value > 0.001;
|
||||
if (!enabled) return;
|
||||
const signature = currentRegionSignature();
|
||||
if (!activeIsVideo && signature === regionSignature) return;
|
||||
renderer.setRenderTarget(regionAtlasTarget);
|
||||
renderer.render(atlasScene, postCamera);
|
||||
renderer.setRenderTarget(regionPaletteTarget);
|
||||
renderer.render(paletteScene, postCamera);
|
||||
renderer.setRenderTarget(null);
|
||||
regionSignature = signature;
|
||||
}
|
||||
function frame(now: number): void {
|
||||
const dt = Math.min((now - last) / 1000, 0.1); last = now;
|
||||
uniforms.uViewOffset.value.copy(cameraController.update(dt, sensitivity)); camera.updateMatrixWorld(true); petDepth.updateMatrixWorld(true);
|
||||
uniforms.uPetWorldToLocal.value.copy(petDepth.matrixWorld).invert();
|
||||
uniforms.uContentViewProjection.value.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
|
||||
uniforms.uContentInverseViewProjection.value.copy(uniforms.uContentViewProjection.value).invert();
|
||||
uniforms.uContentCameraPosition.value.copy(camera.position); uniforms.uTime.value = now / 1000;
|
||||
const diagnostic = depthDebug.update(camera, petDepth, depthUniforms.uDepthScale.value, depthUniforms.uDepthBias.value, wrap.clientWidth, uniforms.uSourceZ.value, maxDepthValue);
|
||||
debugOutput.textContent = diagnostic; debugOutput.hidden = !diagnostic;
|
||||
if (mode === 'final' || mode === 'glass') {
|
||||
renderer.setRenderTarget(renderTarget); renderer.render(scene, camera); uniforms.uSceneTexture.value = renderTarget.texture;
|
||||
updateRegionPalette();
|
||||
updateSplitHistory(dt);
|
||||
renderer.setRenderTarget(null); renderer.render(glassScene, postCamera);
|
||||
const previousAutoClear = renderer.autoClear; renderer.autoClear = false; renderer.clearDepth(); renderer.render(frameScene, camera); renderer.autoClear = previousAutoClear;
|
||||
} else { updateSplitHistory(dt); renderer.setRenderTarget(null); renderer.render(scene, camera); }
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
raf = requestAnimationFrame(frame);
|
||||
function teardown(): void { disposed = true; mediaRequest += 1; depthRequest += 1; aiDepthProvider.cancel(); aiDepthButton.disabled = true; clearPendingPair(); listenerAbort.abort(); cancelAnimationFrame(raf); observer.disconnect(); imageInput.onchange = null; videoInput.onchange = null; effectInput.onchange = null; sensitivityInput.oninput = null; resetButton.onclick = null; gyroButton.onclick = null; retry.onclick = null; pointer.dispose(); gyro.dispose(); loader.dispose(); renderTarget.dispose(); regionAtlasTarget.dispose(); regionPaletteTarget.dispose(); splitHistoryTargets.forEach((target) => target.dispose()); historyMesh.geometry.dispose(); historyMaterial.dispose(); atlasMesh.geometry.dispose(); atlasMaterial.dispose(); paletteMesh.geometry.dispose(); paletteMaterial.dispose(); windowReveal.dispose(); windowRevealMaterial.dispose(); frameMaterials.forEach((material) => material.dispose()); if (active !== emptyTex) active.dispose(); if (depthUniforms.uDepthMap.value !== depthMap) depthUniforms.uDepthMap.value.dispose(); depthMap.dispose(); petDepth.geometry.dispose(); petDepthMaterial.dispose(); geometry.dispose(); petMat.dispose(); glass.geometry.dispose(); glassMat.dispose(); frameGeometry.dispose(); emptyTex.dispose(); renderer.domElement.remove(); renderer.dispose(); hintElement.style.opacity = '0'; retry.remove(); debugOutput.remove(); }
|
||||
const hot = import.meta as ImportMeta & { hot?: { dispose(fn: () => void): void } }; hot.hot?.dispose(teardown);
|
||||
@@ -0,0 +1,316 @@
|
||||
uniform sampler2D uSceneTexture,uSceneDepth;
|
||||
uniform sampler2D uProtectionDepthMap;
|
||||
uniform mat4 uContentViewProjection,uContentInverseViewProjection;
|
||||
uniform mat4 uPetWorldToLocal;
|
||||
uniform vec3 uContentCameraPosition,uLightPosition,uBackgroundColor;
|
||||
uniform float uGlassZ,uSourceZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlossStrength;
|
||||
uniform vec2 uViewOffset,uPetSize,uPaneSize;
|
||||
uniform vec2 uDepthEvidenceRange,uDepthCorrection;
|
||||
uniform float uEnvironmentStrength;
|
||||
uniform float uTransmissionDetail;
|
||||
uniform float uCellLensScale;
|
||||
uniform vec4 uPaneRect;
|
||||
uniform sampler2D uRegionPalette;
|
||||
uniform sampler2D uRegionAtlas,uRegionHistory;
|
||||
uniform vec2 uRegionAtlasSize,uRegionGridSize,uRegionPaletteSize;
|
||||
uniform float uRegionStrength,uRegionAvailable;
|
||||
varying vec2 vUv;
|
||||
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;}
|
||||
vec4 sampleScene(vec2 uv){return texture2D(uSceneTexture,clamp(uv,0.,1.));}
|
||||
vec4 samplePane(vec2 pane){return sampleScene(projectUv(vec3((pane-.5)*uPaneSize,uGlassZ)));}
|
||||
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
|
||||
vec2 hash2(vec2 p){return vec2(hash(p),hash(p+vec2(19.19,73.73)));}
|
||||
vec2 cellBlurStep(vec2 cell,vec2 cellSize,float style){
|
||||
if(style<=.5)return cellSize*mix(.006,.018,hash(cell+vec2(5.)));
|
||||
return cellSize*mix(.006,.012,hash(cell+vec2(5.)));
|
||||
}
|
||||
vec4 sampleCell(vec2 pane,vec2 sourceUv,float strength,vec2 cell,vec2 cellSize,float style){
|
||||
vec2 center=mix(pane,sourceUv,strength);
|
||||
vec2 blurOffset=strength*cellBlurStep(cell,cellSize,style);
|
||||
vec4 color;
|
||||
if(style<=.5){
|
||||
color=samplePane(center)*.6;
|
||||
color+=samplePane(center+vec2(blurOffset.x,0.))*.1;
|
||||
color+=samplePane(center-vec2(blurOffset.x,0.))*.1;
|
||||
color+=samplePane(center+vec2(0.,blurOffset.y))*.1;
|
||||
color+=samplePane(center-vec2(0.,blurOffset.y))*.1;
|
||||
}else{
|
||||
color=samplePane(center)*.4;
|
||||
color+=samplePane(center+vec2(blurOffset.x,0.))*.15;
|
||||
color+=samplePane(center-vec2(blurOffset.x,0.))*.15;
|
||||
color+=samplePane(center+vec2(0.,blurOffset.y))*.15;
|
||||
color+=samplePane(center-vec2(0.,blurOffset.y))*.15;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;}
|
||||
vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
|
||||
float softLimit(float value,float limit){return value*limit/(limit+abs(value));}
|
||||
float surfaceCurvature(){float curvature=clamp(uCurvature,-1.,1.),excess=max(curvature-.65,0.);return curvature-(.25/.35)*excess*excess;}
|
||||
float signedCurvatureScale(){return clamp(surfaceCurvature()/.65,-1.25,1.25);}
|
||||
float regionDistance(vec3 a,vec3 b){vec3 d=a-b;float y=dot(d,vec3(.299,.587,.114));return dot(d,d)+.35*y*y;}
|
||||
float regionLuminance(vec3 c){return dot(c,vec3(.299,.587,.114));}
|
||||
vec3 regionColor(vec2 cell,float index){vec2 uv=(vec2(cell.x*5.+index,cell.y)+.5)/uRegionPaletteSize;return texture2D(uRegionPalette,uv).rgb;}
|
||||
float nearestRegion(vec3 color,vec2 cell){float best=0.,distance=regionDistance(color,regionColor(cell,0.));for(int i=1;i<4;i++){float candidate=regionDistance(color,regionColor(cell,float(i)));if(candidate<distance){distance=candidate;best=float(i);}}return best;}
|
||||
float nearestRegion(vec3 color,vec3 p0,vec3 p1,vec3 p2,vec3 p3){float best=0.,distance=regionDistance(color,p0);float candidate=regionDistance(color,p1);if(candidate<distance){distance=candidate;best=1.;}candidate=regionDistance(color,p2);if(candidate<distance){distance=candidate;best=2.;}candidate=regionDistance(color,p3);if(candidate<distance)best=3.;return best;}
|
||||
vec3 selectRegion(vec3 p0,vec3 p1,vec3 p2,vec3 p3,float index){if(index<.5)return p0;if(index<1.5)return p1;if(index<2.5)return p2;return p3;}
|
||||
vec3 regionAtlasColor(vec2 cell,vec2 texel){vec2 uv=(cell*8.+clamp(texel,vec2(0.),vec2(7.))+.5)/uRegionAtlasSize;return texture2D(uRegionAtlas,uv).rgb;}
|
||||
bool reconstructScenePoint(vec2 sceneUv,out vec3 worldPoint){
|
||||
if(sceneUv.x<0.||sceneUv.x>1.||sceneUv.y<0.||sceneUv.y>1.)return false;
|
||||
float depth=texture2D(uSceneDepth,sceneUv).x;
|
||||
if(depth>=.999999)return false;
|
||||
vec4 clip=vec4(sceneUv*2.-1.,depth*2.-1.,1.);
|
||||
vec4 world=uContentInverseViewProjection*clip;
|
||||
if(abs(world.w)<.0001)return false;
|
||||
worldPoint=world.xyz/world.w;
|
||||
return true;
|
||||
}
|
||||
float regionSurfaceValid(vec2 sceneUv,vec3 glassHit){
|
||||
vec3 worldPoint;
|
||||
if(!reconstructScenePoint(sceneUv,worldPoint))return 0.;
|
||||
float axialGap=glassHit.z-worldPoint.z;
|
||||
if(axialGap<=.0001)return 0.;
|
||||
return 1.;
|
||||
}
|
||||
float regionProtectionWeight(vec2 sceneUv){
|
||||
vec3 worldPoint;
|
||||
if(!reconstructScenePoint(sceneUv,worldPoint))return 0.;
|
||||
vec3 localPoint=(uPetWorldToLocal*vec4(worldPoint,1.)).xyz;
|
||||
if(localPoint.x<-.5||localPoint.x>.5||localPoint.y<-.5||localPoint.y>.5)return 0.;
|
||||
vec2 sourceUv=localPoint.xy+.5;
|
||||
float sourceDepth=texture2D(uProtectionDepthMap,sourceUv).r;
|
||||
if(uDepthEvidenceRange.y-uDepthEvidenceRange.x<.0001)return 0.;
|
||||
return smoothstep(uDepthEvidenceRange.x,uDepthEvidenceRange.y,sourceDepth);
|
||||
}
|
||||
// Screen-depth refinement of an artistic ray. Occluded surfaces are unavailable;
|
||||
// retain the original sample if the visible-depth solve cannot converge.
|
||||
vec3 refineSceneHit(vec3 glassHit,vec3 ray,vec3 artisticHit,float cellWorld){
|
||||
vec3 planeHit=glassHit+ray*((uSourceZ-glassHit.z)/safeZ(ray.z));
|
||||
vec3 origin=glassHit+vec3(artisticHit.xy-planeHit.xy,0.);
|
||||
vec3 hit=artisticHit,surface;
|
||||
for(int iteration=0;iteration<3;iteration++){
|
||||
if(!reconstructScenePoint(projectUv(hit),surface))return artisticHit;
|
||||
vec3 localPoint=(uPetWorldToLocal*vec4(surface,1.)).xyz;
|
||||
if(any(greaterThan(abs(localPoint.xy),vec2(.5)))||surface.z>=uGlassZ-.0001)return artisticHit;
|
||||
hit=origin+ray*((surface.z-origin.z)/safeZ(ray.z));
|
||||
}
|
||||
if(!reconstructScenePoint(projectUv(hit),surface))return artisticHit;
|
||||
if(surface.z>=uGlassZ-.0001)return artisticHit;
|
||||
float confidence=1.-smoothstep(cellWorld*.05,cellWorld*.25,abs(hit.z-surface.z));
|
||||
// Compare at the same reference Z, so only the projected sampling position changes.
|
||||
vec3 viewRay=hit-uContentCameraPosition;
|
||||
vec3 projected=uContentCameraPosition+viewRay*((uSourceZ-uContentCameraPosition.z)/safeZ(viewRay.z));
|
||||
vec2 delta=projected.xy-artisticHit.xy;
|
||||
float limit=cellWorld*uDepthCorrection.y;
|
||||
delta*=min(1.,limit/max(length(delta),.00001));
|
||||
return artisticHit+vec3(delta*uDepthCorrection.x*confidence,0.);
|
||||
}
|
||||
#ifdef REGION_ATLAS
|
||||
float regionAtlasEvidence(vec2 sceneUv,vec3 color,vec3 glassHit){
|
||||
if(regionSurfaceValid(sceneUv,glassHit)<=0.)return 0.;
|
||||
return regionProtectionWeight(sceneUv)*(1.-smoothstep(.10,.20,regionLuminance(color)));
|
||||
}
|
||||
vec4 regionAtlasProbe(vec3 sceneHit,vec2 worldOffset,vec3 glassHit){
|
||||
vec2 probeSceneUv=projectUv(sceneHit+vec3(worldOffset,0.));
|
||||
vec4 probe=sampleScene(probeSceneUv);
|
||||
return vec4(probe.rgb,regionAtlasEvidence(probeSceneUv,probe.rgb,glassHit));
|
||||
}
|
||||
vec4 regionAtlasScore(vec3 centerColor,float centerEvidence,vec3 probeColor,float probeEvidence){
|
||||
float evidenceGain=probeEvidence-centerEvidence,darkGain=regionLuminance(centerColor)-regionLuminance(probeColor),blend=0.;
|
||||
if(probeEvidence>.05&&evidenceGain>.02&&darkGain>.015)blend=min(.4,evidenceGain*2.)*min(1.,max(0.,darkGain*4.));
|
||||
return vec4(blend,probeEvidence,0.,0.);
|
||||
}
|
||||
vec3 regionAtlasRepresentative(vec3 centerColor,float centerEvidence,vec3 sceneHit,vec3 glassHit,out float evidence){
|
||||
vec3 bestColor=centerColor;float bestEvidence=centerEvidence,bestBlend=0.;
|
||||
vec2 probeStep=uPaneSize/(uRegionGridSize*8.)*.25;
|
||||
vec4 probe,score;
|
||||
probe=regionAtlasProbe(sceneHit,vec2(probeStep.x,0.),glassHit);score=regionAtlasScore(centerColor,centerEvidence,probe.rgb,probe.a);if(score.x>bestBlend){bestColor=mix(centerColor,probe.rgb,score.x);bestEvidence=max(centerEvidence,probe.a);bestBlend=score.x;}
|
||||
probe=regionAtlasProbe(sceneHit,vec2(-probeStep.x,0.),glassHit);score=regionAtlasScore(centerColor,centerEvidence,probe.rgb,probe.a);if(score.x>bestBlend){bestColor=mix(centerColor,probe.rgb,score.x);bestEvidence=max(centerEvidence,probe.a);bestBlend=score.x;}
|
||||
probe=regionAtlasProbe(sceneHit,vec2(0.,probeStep.y),glassHit);score=regionAtlasScore(centerColor,centerEvidence,probe.rgb,probe.a);if(score.x>bestBlend){bestColor=mix(centerColor,probe.rgb,score.x);bestEvidence=max(centerEvidence,probe.a);bestBlend=score.x;}
|
||||
probe=regionAtlasProbe(sceneHit,vec2(0.,-probeStep.y),glassHit);score=regionAtlasScore(centerColor,centerEvidence,probe.rgb,probe.a);if(score.x>bestBlend){bestColor=mix(centerColor,probe.rgb,score.x);bestEvidence=max(centerEvidence,probe.a);bestBlend=score.x;}
|
||||
evidence=bestEvidence;
|
||||
return bestColor;
|
||||
}
|
||||
#endif
|
||||
void evaluateCell(vec2 cell,vec2 local,vec2 grid,vec2 cellSize,vec2 cellDimensions,float cellWorld,float opticalTravel,float style,out vec2 affineUv,out mat2 transform,out vec2 curvedSlope,out vec2 facetSlope){
|
||||
vec2 random=hash2(cell),widths;
|
||||
if(style>.5){
|
||||
widths=mix(vec2(.82),vec2(1.12),hash2(cell+vec2(11.4,9.8)));
|
||||
float angle=(random.x-.5)*.08,c=cos(angle),s=sin(angle);
|
||||
transform=mat2(widths.x*c,-widths.y*s,widths.x*s,widths.y*c);
|
||||
float centerJitter=.16;
|
||||
vec2 sourceCenter=(cell+.5)/grid+(random-.5)*centerJitter*cellSize;
|
||||
affineUv=sourceCenter+transform*(local*cellSize);
|
||||
}else{
|
||||
// Default style keeps a single continuous image footprint across cells.
|
||||
transform=mat2(1.);
|
||||
affineUv=(cell+.5)/grid+local*cellSize;
|
||||
}
|
||||
|
||||
// Positive height bulges toward the observer (+Z); negative height is concave.
|
||||
float curvature=clamp(uCurvature,-1.,1.),surfaceCurvatureValue=surfaceCurvature();
|
||||
vec2 q=clamp(2.*local,-1.,1.),q2=q*q,q3=q2*q,b=1.-q2*q2;
|
||||
if(style>.5){
|
||||
float amplitude=mix(.92,1.08,hash(cell+vec2(41.7,17.3))),height=.009*cellWorld*surfaceCurvatureValue*amplitude;
|
||||
vec2 skew=(hash2(cell+vec2(23.8,61.2))-.5)*.28;float asym=1.+dot(skew,q);
|
||||
vec2 dhdq=height*vec2(-4.*q3.x*b.y*asym+b.x*b.y*skew.x,-4.*q3.y*b.x*asym+b.x*b.y*skew.y);
|
||||
vec2 grad=-2.*dhdq/cellDimensions;
|
||||
curvedSlope=grad+(random-.5)*.012;facetSlope=(random-.5)*.012;
|
||||
}else{
|
||||
curvedSlope=.30*uCellLensScale*cellDimensions/max(abs(opticalTravel),.6)*signedCurvatureScale()*sin(6.2831853*local);
|
||||
facetSlope=vec2(0.);
|
||||
}
|
||||
}
|
||||
void main(){
|
||||
vec2 screenUv=vUv;
|
||||
#ifdef REGION_ATLAS
|
||||
vec2 atlasPixel=floor(vUv*uRegionAtlasSize),atlasCell=floor(atlasPixel/8.),atlasLocal=(mod(atlasPixel,8.)+.5)/8.;
|
||||
vec2 atlasPaneUv=(atlasCell+atlasLocal)/uRegionGridSize;
|
||||
screenUv=projectUv(vec3((atlasPaneUv-.5)*uPaneSize,uGlassZ));
|
||||
#endif
|
||||
if(uStrength<=.001){
|
||||
#ifdef REGION_ATLAS
|
||||
gl_FragColor=vec4(sampleScene(screenUv).rgb,0.);
|
||||
#else
|
||||
gl_FragColor=sampleScene(screenUv);
|
||||
#include <colorspace_fragment>
|
||||
#endif
|
||||
return;}
|
||||
vec4 f=uContentInverseViewProjection*vec4(screenUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
|
||||
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid;float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
|
||||
#ifdef REGION_ATLAS
|
||||
paneUv=atlasPaneUv;grid= uRegionGridSize;gridUv=paneUv*grid;cell=floor(gridUv);local=fract(gridUv)-.5;cellSize=1./grid;cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
|
||||
#endif
|
||||
if(paneUv.x<0.||paneUv.x>1.||paneUv.y<0.||paneUv.y>1.){
|
||||
#ifdef REGION_ATLAS
|
||||
gl_FragColor=vec4(uBackgroundColor,0.);
|
||||
#else
|
||||
gl_FragColor=vec4(uBackgroundColor,1.);
|
||||
#include <colorspace_fragment>
|
||||
#endif
|
||||
return;}
|
||||
vec2 cellDimensions=uPaneSize/grid;
|
||||
float opticalTravel=-max(abs(uSourceZ-gh.z),.6);
|
||||
vec2 affineUv,curvedSlope,facetSlope;mat2 transform;
|
||||
evaluateCell(cell,local,grid,cellSize,cellDimensions,cellWorld,opticalTravel,style,affineUv,transform,curvedSlope,facetSlope);
|
||||
if(style<=.5){
|
||||
vec2 smoothFacet=.003*vec2(sin(6.2831853*paneUv.x),sin(6.2831853*paneUv.y));
|
||||
curvedSlope+=smoothFacet;facetSlope=smoothFacet;
|
||||
}else{
|
||||
vec2 edge=.5-abs(local),neighborSign=mix(vec2(-1.),vec2(1.),step(vec2(0.),local));
|
||||
float normalBlend=.16;
|
||||
vec2 weights=1.-smoothstep(vec2(-normalBlend),vec2(normalBlend),edge);
|
||||
float xSplitWeight=weights.x*(1.-weights.y),ySplitWeight=(1.-weights.x)*weights.y;
|
||||
float diagonalSplitWeight=weights.x*weights.y,centerSplitWeight=(1.-weights.x)*(1.-weights.y);
|
||||
if(weights.x>0.||weights.y>0.){
|
||||
vec2 neighborAffine,neighborCurved,neighborFacet;mat2 neighborTransform;
|
||||
vec2 xWeight=vec2(xSplitWeight,0.);
|
||||
vec2 yWeight=vec2(0.,ySplitWeight);
|
||||
affineUv*=centerSplitWeight;transform*=centerSplitWeight;curvedSlope*=centerSplitWeight;facetSlope*=centerSplitWeight;
|
||||
if(xWeight.x>0.){
|
||||
vec2 offset=vec2(neighborSign.x,0.);
|
||||
evaluateCell(cell+offset,local-offset,grid,cellSize,cellDimensions,cellWorld,opticalTravel,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
|
||||
affineUv+=neighborAffine*xWeight.x;transform+=neighborTransform*xWeight.x;curvedSlope+=neighborCurved*xWeight.x;facetSlope+=neighborFacet*xWeight.x;
|
||||
}
|
||||
if(yWeight.y>0.){
|
||||
vec2 offset=vec2(0.,neighborSign.y);
|
||||
evaluateCell(cell+offset,local-offset,grid,cellSize,cellDimensions,cellWorld,opticalTravel,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
|
||||
affineUv+=neighborAffine*yWeight.y;transform+=neighborTransform*yWeight.y;curvedSlope+=neighborCurved*yWeight.y;facetSlope+=neighborFacet*yWeight.y;
|
||||
}
|
||||
if(diagonalSplitWeight>0.){
|
||||
vec2 offset=neighborSign;
|
||||
evaluateCell(cell+offset,local-offset,grid,cellSize,cellDimensions,cellWorld,opticalTravel,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
|
||||
affineUv+=neighborAffine*diagonalSplitWeight;transform+=neighborTransform*diagonalSplitWeight;curvedSlope+=neighborCurved*diagonalSplitWeight;facetSlope+=neighborFacet*diagonalSplitWeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
vec3 nCurved=normalize(vec3(curvedSlope,1.)),nFacet=normalize(vec3(facetSlope,1.));
|
||||
vec3 straightHit=gh+i*((uSourceZ-gh.z)/safeZ(i.z));
|
||||
vec3 curvedRay=bend(i,nCurved),curvedHit=gh+curvedRay*((uSourceZ-gh.z)/safeZ(curvedRay.z));
|
||||
// Keep the artistic Mosaic optical path independent of physical glass-to-photo contact distance.
|
||||
vec2 rawDelta=(curvedRay.xy/safeZ(curvedRay.z)-i.xy/safeZ(i.z))*opticalTravel;
|
||||
vec2 worldDelta=vec2(softLimit(rawDelta.x,cellWorld*.55),softLimit(rawDelta.y,cellWorld*.55));
|
||||
if(style>.5){
|
||||
vec3 facetRay=bend(i,nFacet);vec3 facetHit=gh+facetRay*((uSourceZ-gh.z)/safeZ(facetRay.z));
|
||||
vec2 base=facetHit.xy-straightHit.xy,curve=curvedHit.xy-facetHit.xy;
|
||||
worldDelta=vec2(softLimit(base.x,cellWorld*.08),softLimit(base.y,cellWorld*.08));
|
||||
vec2 d=curve/cellWorld;
|
||||
worldDelta+=vec2(.3*d.x/(.3+abs(d.x)),.3*d.y/(.3+abs(d.y)))*cellWorld;
|
||||
}else{
|
||||
vec3 referenceI=normalize(gh-vec3(0.,0.,uContentCameraPosition.z)),referenceRay=bend(referenceI,nCurved);
|
||||
vec2 incidenceDelta=curvedRay.xy/max(-curvedRay.z,.0001)-referenceRay.xy/max(-referenceRay.z,.0001);
|
||||
vec2 boundedIncidence=incidenceDelta/sqrt(incidenceDelta*incidenceDelta+vec2(.025*.025));
|
||||
vec2 cellEnvelope=.5+.5*cos(6.2831853*local);
|
||||
worldDelta+=boundedIncidence*cellEnvelope*cellDimensions*signedCurvatureScale()*.18;
|
||||
}
|
||||
vec2 sampleUv=affineUv+transform*(worldDelta/uPaneSize);
|
||||
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));float edgeTaper=style<=.5?1.25:.75;float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*edgeTaper,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;
|
||||
vec4 color;vec3 sceneHit=straightHit+vec3(worldDelta*strength,0.);vec2 finalSceneUv=projectUv(sceneHit);
|
||||
if(style<=.5){
|
||||
sceneHit=refineSceneHit(gh,normalize(mix(i,curvedRay,strength)),sceneHit,cellWorld);
|
||||
finalSceneUv=projectUv(sceneHit);
|
||||
}
|
||||
#ifdef REGION_ATLAS
|
||||
vec3 atlasCenterColor=sampleScene(finalSceneUv).rgb;
|
||||
float atlasEvidence=regionProtectionWeight(finalSceneUv)*(1.-smoothstep(.10,.20,regionLuminance(atlasCenterColor)));
|
||||
#endif
|
||||
#ifdef REGION_ATLAS
|
||||
if(style<=.5){
|
||||
float representativeEvidence=atlasEvidence;
|
||||
color=vec4(regionAtlasRepresentative(atlasCenterColor,atlasEvidence,sceneHit,gh,representativeEvidence),representativeEvidence);
|
||||
atlasEvidence=representativeEvidence;
|
||||
}else{
|
||||
color=sampleCell(paneUv,sampleUv,strength,cell,cellSize,style);
|
||||
}
|
||||
#else
|
||||
if(style<=.5){
|
||||
color=sampleScene(finalSceneUv);
|
||||
}else{
|
||||
color=sampleCell(paneUv,sampleUv,strength,cell,cellSize,style);
|
||||
}
|
||||
#endif
|
||||
#ifndef REGION_ATLAS
|
||||
if(uRegionAvailable>.5&&uRegionStrength>.001&&uMosaicStyle<.5){
|
||||
vec3 p0=regionColor(cell,0.),p1=regionColor(cell,1.),p2=regionColor(cell,2.),p3=regionColor(cell,3.);
|
||||
float ownLabel=nearestRegion(color.rgb,p0,p1,p2,p3);vec4 votes=vec4(0.);float totalWeight=0.,ownWeight=0.;
|
||||
vec2 texelCoord=fract(gridUv)*8.-.5,anchor=floor(texelCoord+.5);
|
||||
for(int y=-1;y<=1;y++)for(int x=-1;x<=1;x++){
|
||||
vec2 offset=vec2(float(x),float(y)),delta=anchor+offset-texelCoord;
|
||||
float weight=pow(max(0.,1.-length(delta)/1.5),2.);
|
||||
if(weight>0.){
|
||||
vec3 atlasColor=regionAtlasColor(cell,anchor+offset);float label=nearestRegion(atlasColor,p0,p1,p2,p3);
|
||||
votes+=vec4(step(abs(label-vec4(0.,1.,2.,3.)),vec4(.1)))*weight;totalWeight+=weight;
|
||||
ownWeight+=step(abs(label-ownLabel),.1)*weight;
|
||||
}
|
||||
}
|
||||
float winningLabel=ownLabel,winningWeight=ownWeight;
|
||||
if(votes.x>winningWeight){winningLabel=0.;winningWeight=votes.x;}if(votes.y>winningWeight){winningLabel=1.;winningWeight=votes.y;}if(votes.z>winningWeight){winningLabel=2.;winningWeight=votes.z;}if(votes.w>winningWeight){winningLabel=3.;winningWeight=votes.w;}
|
||||
bool simplify=totalWeight>0.&&winningWeight/totalWeight>.55&&ownWeight/totalWeight<.40;
|
||||
float radialLabel=simplify?winningLabel:ownLabel;vec3 radialColor=selectRegion(p0,p1,p2,p3,radialLabel);
|
||||
vec2 historyTexel=clamp(floor(fract(gridUv)*8.),vec2(0.),vec2(7.));
|
||||
vec2 historyUv=(cell*8.+historyTexel+.5)/uRegionAtlasSize;
|
||||
vec4 splitHistory=texture2D(uRegionHistory,historyUv);
|
||||
vec3 splitColor=splitHistory.a>.00001?splitHistory.rgb/splitHistory.a:radialColor;
|
||||
vec3 candidateColor=mix(radialColor,splitColor,splitHistory.a);
|
||||
// The palette describes broad color planes, not an opaque replacement image.
|
||||
// Preserve the same continuously refracted sample underneath every plane.
|
||||
candidateColor=mix(candidateColor,color.rgb,uTransmissionDetail);
|
||||
color.rgb=mix(color.rgb,candidateColor,clamp(uRegionStrength*clamp(uStrength,0.,1.)*regionSurfaceValid(finalSceneUv,gh),0.,1.));
|
||||
}
|
||||
#endif
|
||||
#ifndef REGION_ATLAS
|
||||
color.a=1.;
|
||||
vec3 v=normalize(uContentCameraPosition-gh),reflected=reflect(-v,nCurved); float lightT=(uLightPosition.z-gh.z)/safeZ(reflected.z); vec2 lightHit=gh.xy+reflected.xy*lightT; vec2 halfSize=vec2(.32,.42)*min(uPaneSize.x,uPaneSize.y); vec2 windowCoord=abs(lightHit-uLightPosition.xy)/halfSize; vec2 windowMask=1.-smoothstep(vec2(.45),vec2(1.15),windowCoord); float surfaceReflectionWeight=mix(.35,1.,edgeWeight); float glossStrength=clamp(uGlossStrength,0.,1.); float spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float specGloss=spec*glossStrength*.12*surfaceReflectionWeight*clamp(uStrength,0.,1.); vec2 broadWindowMask=1.-smoothstep(vec2(.15),vec2(1.75),windowCoord); float broadFacing=pow(max(dot(nCurved,v),0.),2.); float broadReflection=broadWindowMask.x*broadWindowMask.y*broadFacing*glossStrength*.035*surfaceReflectionWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),specGloss+broadReflection); gl_FragColor=color;
|
||||
// Common, static room environment. Reflected direction varies with the same
|
||||
// curved normal as transmission; no image relighting or timed sparkle.
|
||||
float fresnel=.04+.96*pow(1.-clamp(dot(nCurved,v),0.,1.),5.);
|
||||
float environmentWeight=uEnvironmentStrength*glossStrength*(.35+.65*fresnel)*surfaceReflectionWeight*clamp(uStrength,0.,1.);
|
||||
vec3 environmentColor=mix(vec3(.10,.13,.18),vec3(.65,.72,.80),smoothstep(-.35,.65,reflected.y));
|
||||
if(style<=.5)color.rgb=mix(color.rgb,environmentColor,environmentWeight);
|
||||
gl_FragColor=color;
|
||||
#include <colorspace_fragment>
|
||||
#else
|
||||
color.a=atlasEvidence;
|
||||
gl_FragColor=color;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import * as settings from '../src/constants/glass.ts';
|
||||
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
|
||||
assert.ok(settings.GLASS_TRANSMISSION_DETAIL > 0 && settings.GLASS_TRANSMISSION_DETAIL < 0.5,
|
||||
'maximum simplification must retain optical detail while keeping color planes dominant');
|
||||
assert.match(shader, /candidateColor=mix\(candidateColor,color.rgb,uTransmissionDetail\)/,
|
||||
'retain the refracted scene, not raw photo UV or a facial-feature fallback');
|
||||
console.log('Glass material keeps continuous refracted detail under maximum color-plane simplification.');
|
||||
@@ -0,0 +1,202 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { CELL_LENS_SCALE } from '../src/constants/glass.ts';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8');
|
||||
assert.match(shader, /curvedSlope=\.30\*uCellLensScale\*cellDimensions\/max\(abs\(opticalTravel\),\.6\)\*signedCurvatureScale\(\)\*sin\(6\.2831853\*local\)/);
|
||||
assert.match(shader, /smoothFacet=\.003\*vec2\(sin\(6\.2831853\*paneUv\.x\),sin\(6\.2831853\*paneUv\.y\)\)/);
|
||||
assert.match(shader, /float opticalTravel=-max\(abs\(uSourceZ-gh\.z\),\.6\)/);
|
||||
assert.match(shader, /if\(style<=\.5\)\{\s*vec2 smoothFacet/);
|
||||
assert.match(shader, /vec3 referenceI=normalize\(gh-vec3\(0\.,0\.,uContentCameraPosition\.z\)\),referenceRay=bend\(referenceI,nCurved\)/);
|
||||
assert.match(shader, /incidenceDelta\/sqrt\(incidenceDelta\*incidenceDelta\+vec2\(\.025\*\.025\)\)/);
|
||||
assert.match(shader, /cellEnvelope\*cellDimensions\*signedCurvatureScale\(\)\*\.18/);
|
||||
assert.match(shader, /edgeTaper=style<=\.5\?1\.25:\.75/);
|
||||
assert.match(shader, /float normalBlend=\.16/);
|
||||
assert.match(shader, /vec3 sceneHit=straightHit\+vec3\(worldDelta\*strength,0\.\);vec2 finalSceneUv=projectUv\(sceneHit\)/);
|
||||
assert.match(shader, /color=sampleScene\(finalSceneUv\)/);
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
const cameraZ = 3;
|
||||
const glassZ = 0.6;
|
||||
const strengthSetting = 0.9;
|
||||
const fovScale = Math.tan(20 * Math.PI / 180);
|
||||
const add = (a, b) => a.map((v, i) => v + b[i]);
|
||||
const sub = (a, b) => a.map((v, i) => v - b[i]);
|
||||
const mul = (a, s) => a.map(v => v * s);
|
||||
const dot = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
|
||||
const norm = a => mul(a, 1 / Math.hypot(...a));
|
||||
const safeZ = z => Math.abs(z) < 1e-4 ? (z < 0 ? -1e-4 : 1e-4) : z;
|
||||
const smoothstep = (a, b, x) => {
|
||||
const t = Math.max(0, Math.min(1, (x - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
const softLimit = (value, limit) => value * limit / (limit + Math.abs(value));
|
||||
const surfaceCurvature = curvature => {
|
||||
const clamped = Math.max(-1, Math.min(1, curvature));
|
||||
const excess = Math.max(clamped - 0.65, 0);
|
||||
return clamped - (0.25 / 0.35) * excess * excess;
|
||||
};
|
||||
const curvatureScale = curvature => Math.max(-1.25, Math.min(1.25, surfaceCurvature(curvature) / 0.65));
|
||||
function refract(i, n) {
|
||||
const eta = 0.75;
|
||||
const d = dot(n, i);
|
||||
const k = 1 - eta * eta * (1 - d * d);
|
||||
if (k < 0) return i;
|
||||
return sub(mul(i, eta), mul(n, eta * d + Math.sqrt(k)));
|
||||
}
|
||||
|
||||
function mapToSource(uv, { gridSize, curvature, pane, cameraXY, sourceZ }) {
|
||||
const [width, height] = pane;
|
||||
const grid = [gridSize, Math.max(1, gridSize * height / width)];
|
||||
const cellSize = [1 / grid[0], 1 / grid[1]];
|
||||
const cellDimensions = [width / grid[0], height / grid[1]];
|
||||
const cellWorld = Math.min(...cellDimensions);
|
||||
const gx = uv[0] * grid[0], gy = uv[1] * grid[1];
|
||||
const local = [gx - Math.floor(gx) - 0.5, gy - Math.floor(gy) - 0.5];
|
||||
const gh = [(uv[0] - 0.5) * width, (uv[1] - 0.5) * height, glassZ];
|
||||
const camera = [cameraXY[0], cameraXY[1], cameraZ];
|
||||
const i = norm(sub(gh, camera));
|
||||
const opticalTravel = -Math.max(Math.abs(sourceZ - gh[2]), 0.6);
|
||||
const signedScale = curvatureScale(curvature);
|
||||
const curvedSlope = [0, 1].map(axis =>
|
||||
0.30 * CELL_LENS_SCALE * cellDimensions[axis] / Math.max(Math.abs(opticalTravel), 0.6) * signedScale * Math.sin(TAU * local[axis])
|
||||
);
|
||||
const smoothFacet = [0.003 * Math.sin(TAU * uv[0]), 0.003 * Math.sin(TAU * uv[1])];
|
||||
const nCurved = norm([curvedSlope[0] + smoothFacet[0], curvedSlope[1] + smoothFacet[1], 1]);
|
||||
const curvedRay = refract(i, nCurved);
|
||||
const rawDelta = [0, 1].map(axis =>
|
||||
(curvedRay[axis] / safeZ(curvedRay[2]) - i[axis] / safeZ(i[2])) * opticalTravel
|
||||
);
|
||||
const worldDelta = rawDelta.map(value => softLimit(value, cellWorld * 0.55));
|
||||
const referenceI = norm([gh[0], gh[1], gh[2] - cameraZ]);
|
||||
const referenceRay = refract(referenceI, nCurved);
|
||||
const cellEnvelope = local.map(value => 0.5 + 0.5 * Math.cos(TAU * value));
|
||||
const incidenceDelta = [0, 1].map(axis => curvedRay[axis] / -curvedRay[2] - referenceRay[axis] / -referenceRay[2]);
|
||||
const boundedIncidence = incidenceDelta.map(value => value / Math.sqrt(value * value + 0.025 ** 2));
|
||||
for (let axis = 0; axis < 2; axis++) {
|
||||
worldDelta[axis] += boundedIncidence[axis] * cellEnvelope[axis] * cellDimensions[axis] * signedScale * 0.18;
|
||||
}
|
||||
const straightT = (sourceZ - gh[2]) / safeZ(i[2]);
|
||||
const straightHit = add(gh, mul(i, straightT));
|
||||
const edgeDistance = Math.min(uv[0], uv[1], 1 - uv[0], 1 - uv[1]);
|
||||
const edgeWeight = smoothstep(0, Math.max(...cellSize) * 1.25, edgeDistance);
|
||||
const sceneHit = add(straightHit, [worldDelta[0] * strengthSetting * edgeWeight, worldDelta[1] * strengthSetting * edgeWeight, 0]);
|
||||
const depth = cameraZ - sceneHit[2];
|
||||
return [
|
||||
0.5 + 0.5 * (sceneHit[0] - cameraXY[0]) / (depth * fovScale),
|
||||
0.5 + 0.5 * (sceneHit[1] - cameraXY[1]) / (depth * fovScale)
|
||||
];
|
||||
}
|
||||
|
||||
const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
const grids = [6, 10, 96];
|
||||
const curvatures = [-1, 0, 0.65, 1];
|
||||
const panes = [[1.2, 0.8], [1.28, 1.6]];
|
||||
const cameras = [[0, 0], [0.3, -0.3], [-0.6, 0.6], [-0.9, 0.9], [0.9, -0.9]];
|
||||
const sourcePlanes = [0.16, 0.56];
|
||||
const phases = [0.03, 0.25, 0.49, 0.5, 0.75, 0.97];
|
||||
const interiorPhases = [-0.4, -0.25, 0, 0.25, 0.4];
|
||||
let pointsChecked = 0;
|
||||
let seamsChecked = 0;
|
||||
|
||||
for (const gridSize of grids) for (const curvature of curvatures) for (const pane of panes) {
|
||||
for (const cameraXY of cameras) for (const sourceZ of sourcePlanes) {
|
||||
const config = { gridSize, curvature, pane, cameraXY, sourceZ };
|
||||
const points = [];
|
||||
for (let line = 0; line <= gridSize; line++) {
|
||||
const seam = line / gridSize;
|
||||
for (const phase of phases) {
|
||||
points.push([seam, phase], [phase, seam]);
|
||||
}
|
||||
for (let cell = 1; cell < gridSize; cell += Math.max(1, Math.floor(gridSize / 4))) {
|
||||
for (const local of interiorPhases) {
|
||||
points.push([(cell + 0.5 + local) / gridSize, (cell + 0.5) / gridSize]);
|
||||
points.push([(cell + 0.5) / gridSize, (cell + 0.5 + local) / gridSize]);
|
||||
}
|
||||
}
|
||||
}
|
||||
points.push([0.001, 0.001], [0.999, 0.001], [0.001, 0.999], [0.999, 0.999]);
|
||||
const h = Math.min(1e-6, 1e-4 / gridSize);
|
||||
for (const [u0, v0] of points) {
|
||||
const u = Math.max(h * 2, Math.min(1 - h * 2, u0));
|
||||
const v = Math.max(h * 2, Math.min(1 - h * 2, v0));
|
||||
const left = mapToSource([u - h, v], config), right = mapToSource([u + h, v], config);
|
||||
const down = mapToSource([u, v - h], config), up = mapToSource([u, v + h], config);
|
||||
const dx = [(right[0] - left[0]) / (2 * h), (right[1] - left[1]) / (2 * h)];
|
||||
const dy = [(up[0] - down[0]) / (2 * h), (up[1] - down[1]) / (2 * h)];
|
||||
const determinant = dx[0] * dy[1] - dy[0] * dx[1];
|
||||
assert.ok(dx[0] > 0 && dy[1] > 0 && determinant > 0,
|
||||
`folded projected UV at grid=${gridSize}, curvature=${curvature}, pane=${pane}, camera=${cameraXY}, sourceZ=${sourceZ}, uv=${u},${v}; jac=[${dx},${dy}]`);
|
||||
pointsChecked++;
|
||||
}
|
||||
|
||||
const epsilon = 1e-4 / gridSize;
|
||||
for (let line = 1; line < gridSize; line++) {
|
||||
const seam = line / gridSize;
|
||||
for (const phase of [0.07, 0.31, 0.5, 0.69, 0.93]) {
|
||||
for (const [axis, other] of [[0, phase], [1, phase]]) {
|
||||
const a = axis === 0 ? [seam - epsilon, other] : [other, seam - epsilon];
|
||||
const b = axis === 0 ? [seam + epsilon, other] : [other, seam + epsilon];
|
||||
const aHalf = axis === 0 ? [seam - epsilon / 2, other] : [other, seam - epsilon / 2];
|
||||
const bHalf = axis === 0 ? [seam + epsilon / 2, other] : [other, seam + epsilon / 2];
|
||||
const fullStep = distance(mapToSource(a, config), mapToSource(b, config));
|
||||
const halfStep = distance(mapToSource(aHalf, config), mapToSource(bHalf, config));
|
||||
assert.ok(fullStep > 0 && Math.abs(fullStep - 2 * halfStep) < fullStep * 0.03,
|
||||
`seam does not converge continuously at grid=${gridSize}, curvature=${curvature}, uv=${seam},${other}`);
|
||||
seamsChecked++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function displacementAt(uv, config) {
|
||||
const { gridSize, pane, cameraXY, sourceZ } = config;
|
||||
const [width, height] = pane;
|
||||
const grid = [gridSize, Math.max(1, gridSize * height / width)];
|
||||
const gh = [(uv[0] - 0.5) * width, (uv[1] - 0.5) * height, glassZ];
|
||||
const i = norm(sub(gh, [cameraXY[0], cameraXY[1], cameraZ]));
|
||||
const cell = [Math.floor(uv[0] * grid[0]), Math.floor(uv[1] * grid[1])];
|
||||
const local = [uv[0] * grid[0] - cell[0] - 0.5, uv[1] * grid[1] - cell[1] - 0.5];
|
||||
const cellDimensions = [width / grid[0], height / grid[1]];
|
||||
const travel = -Math.max(Math.abs(sourceZ - glassZ), 0.6);
|
||||
const scale = curvatureScale(config.curvature);
|
||||
const slope = local.map((v, axis) => 0.30 * CELL_LENS_SCALE * cellDimensions[axis] / Math.max(Math.abs(travel), 0.6) * scale * Math.sin(TAU * v));
|
||||
const smoothFacet = [0.003 * Math.sin(TAU * uv[0]), 0.003 * Math.sin(TAU * uv[1])];
|
||||
const n = norm([slope[0] + smoothFacet[0], slope[1] + smoothFacet[1], 1]);
|
||||
const ray = refract(i, n);
|
||||
const reference = refract(norm([gh[0], gh[1], gh[2] - cameraZ]), n);
|
||||
const envelope = local.map(v => 0.5 + 0.5 * Math.cos(TAU * v));
|
||||
return [0, 1].map(axis => {
|
||||
const a = ray[axis] / -ray[2] - reference[axis] / -reference[2];
|
||||
return a / Math.sqrt(a * a + 0.025 ** 2) * envelope[axis] * cellDimensions[axis] * scale * 0.18;
|
||||
});
|
||||
}
|
||||
|
||||
for (const curvature of [-1, -0.65, 0.65, 1]) {
|
||||
const centered = { gridSize: 10, curvature, pane: [1.2, 0.8], cameraXY: [0, 0], sourceZ: 0.16 };
|
||||
const cameraView = { ...centered, cameraXY: [0.3, -0.3] };
|
||||
const gridY = Math.max(1, centered.gridSize * centered.pane[1] / centered.pane[0]);
|
||||
const cellCenter = [(4 + 0.5) / centered.gridSize, (Math.floor(gridY / 2) + 0.5) / gridY];
|
||||
const xEdges = [4 / centered.gridSize, 5 / centered.gridSize];
|
||||
const yIndex = Math.floor(gridY / 2), yEdges = [yIndex / gridY, (yIndex + 1) / gridY];
|
||||
const atCellCenter = displacementAt(cellCenter, centered);
|
||||
assert.ok(Math.hypot(...atCellCenter) < 1e-10, `center view should add no local displacement at curvature=${curvature}`);
|
||||
const edgePoints = [
|
||||
[[xEdges[0], cellCenter[1]], 0], [[xEdges[1], cellCenter[1]], 0],
|
||||
[[cellCenter[0], yEdges[0]], 1], [[cellCenter[0], yEdges[1]], 1]
|
||||
];
|
||||
for (const [uv, fixedAxis] of edgePoints) {
|
||||
assert.ok(Math.abs(displacementAt(uv, cameraView)[fixedAxis]) < 1e-8, `cell edge axis should remain fixed at curvature=${curvature}, uv=${uv}`);
|
||||
}
|
||||
const samples = [
|
||||
[cellCenter[0] - 0.02, cellCenter[1]], cellCenter, [cellCenter[0] + 0.02, cellCenter[1]],
|
||||
[cellCenter[0], cellCenter[1] - 0.02], [cellCenter[0], cellCenter[1] + 0.02]
|
||||
];
|
||||
const offsets = samples.map(uv => displacementAt(uv, cameraView));
|
||||
const mean = offsets.reduce((sum, value) => add(sum, value), [0, 0]).map(value => value / offsets.length);
|
||||
const variance = offsets.reduce((sum, value) => sum + dot(sub(value, mean), sub(value, mean)), 0) / offsets.length;
|
||||
assert.ok(variance > 1e-8, `camera incidence should create varying within-cell displacement at curvature=${curvature}`);
|
||||
}
|
||||
|
||||
console.log(`Mosaic projected-UV continuity passed: ${pointsChecked} Jacobians, ${seamsChecked} seam convergence checks, center/edge anchors, and view-dependent within-cell displacement.`);
|
||||
Reference in New Issue
Block a user