first commit

This commit is contained in:
2026-10-06 12:16:46 +09:00
commit 619d980098
415 changed files with 22283 additions and 0 deletions
+460
View File
@@ -0,0 +1,460 @@
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 { 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 };
Object.assign(uniforms, { uRegionHistory: regionHistoryUniform });
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;
photoZoom = 1; photoPanX = 0; photoPanY = 0; 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;
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);
+274
View File
@@ -0,0 +1,274 @@
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 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;
return smoothstep(.8,.95,sourceDepth);
}
#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*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);
#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);
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;
#include <colorspace_fragment>
#else
color.a=atlasEvidence;
gl_FragColor=color;
#endif
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+35
View File
@@ -0,0 +1,35 @@
# 깊이·굴절 연결 개선 — 2026-09-30
사용자가 승인한 재점검 후속 구현. 옵션 1·2 문서는 보존하며 이번 결과를 새 개선안으로 구분한다. 수정 직전 `main.ts`, `mosaic.frag`는 이 폴더 baseline 파일에 보존했다. Git 저장소가 아니므로 커밋은 생성하지 않았다.
## 변경
- `src/main.ts`: 미디어 로드 시 zoom/pan 초기화 제거. 명시적 구도 초기화 버튼은 유지. RGB/Depth 공통 변환, 최대 depth 기준 안전 간격은 유지한다.
- `src/depth/DepthProcessor.ts`, `src/constants/depth.ts`: 사진의 상대 depth 히스토그램에서 P50–P85 증거 범위를 계산. 범위가 좁으면 P5–P95로 재평가하고, 여전히 평탄하면 어두운 색면 증거를 끈다. 단일 가까운 이상치에 고정 문턱이 끌리지 않게 한다. 사진 전체 통계이며 얼굴/강아지 검출은 아니다.
- `src/shaders/mosaic.frag`: 기존 연출 굴절을 출발점으로 세 번의 screen-depth 반복 보정을 추가. atlas/final에 동일 적용. 미수렴·무효 depth·유리 앞 표면에서는 원래 샘플로 복귀한다. 이동은 최대 셀 폭 0.35에 혼합 0.65를 적용하므로 0.2275셀 이하. 색면 강도의 거리 감쇠나 최종 원본 눈·코 복원은 없다.
- `src/constants/glass.ts`, Mosaic: 공통 환경의 어두운 아래/밝은 위 명암과 Fresnel 각도 항을 작은 반사로 추가. 기존 직사각 광원은 유지. 광택·굴절 0에서 새 반사도 0. 조각 유리 모드에만 적용하며 임의 시간 변화는 없다.
## 검증
- `npm.cmd run build`: TypeScript/Vite 통과. 기존 500kB 청크 경고 유지.
- `scripts/test-*.mjs` 9개 모두 통과. 새 depth-evidence 테스트는 미구현 상태와 bimodal 결함에서 각각 실패를 확인한 뒤 통과했다. 새 depth-refraction 테스트는 반사 게이트 추가 전 실패 후 통과했다.
- 기존 surface-reflection의 atlas 전체 바이트 동일성 검사는 의도적으로 바뀐 광학 계산을 허용하고 반사가 atlas에 들어가지 않는 검사로 변경했다. 기존 반사 상수 출력/atlas 오염 mutation 검사는 계속 통과한다. 고정 depth 문턱 source 검사는 새 uniform 연결 검사로 변경했다.
- 수학/소스 검사는 GPU 품질의 증명이 아니다. 기존 continuity 수치도 새 screen-depth 보정의 모든 경계/시점 연속성을 증명하지 않는다.
- 브라우저: dog1/dog2 자동 AI 추론 성공. dog1 정면·드래그, dog2 Final/No Glass, Glass Only 및 광택 0을 확인. 광택 0의 유리 단독 화면에는 반사가 남지 않았다.
- dog1 확대1.50/팬+0.10/-0.10을 설정한 후 같은 사진 재로드: 로드 직후와 AI 완료 후 값 유지. 명시적 구도 초기화 후 기본 구도로 돌아왔다.
- dog2 390×844 viewport에서 유리/사진 배치 확인, override 해제. 실제 휴대폰 검증은 아니다.
- 최종 콘솔 조회에서 error는 없었고 X3595(가변 반복문 내 gradient 연산) 경고 1건이 수집됐다. 기존에도 있던 종류의 경고이나 새 depth 반복 샘플링에도 관련될 수 있다. 다른 GPU에서의 영향은 미검증이다.
- 창 크기 변경으로 캡처마다 viewport가 다르다. 저장 화면은 정성적 확인용이며 픽셀 단위 전후 비교나 효과 크기의 정량 증거로 사용하지 않는다.
## 시각 판단과 남은 한계
흰 강아지의 두 눈·코 사각 면은 유지된다. 깊이에 따른 색면 위치가 바뀌며 반사는 절제된 수준이다. 어두운 강아지는 Grid13에서 눈·코 구분이 여전히 약하다. 이 결과를 모든 사진의 식별성 개선 또는 Ref1 재현 완료로 판정하지 않는다.
단일 깊이 표면과 화면에 보이는 depth만 사용하므로 가려진 옆면/배경을 복원하지 않는다. 실제 굴절 광선 추적의 완전한 해법이 아닌 기존 연출에 대한 제한된 보정이다. 강아지/얼굴을 분리하지 않으므로 얼굴을 유리 바로 뒤에 놓는 의미 기반 배치는 미구현이며 관통 방지를 위한 기존 전체 최대 depth 배치를 유지한다. 얼굴 확대·자동 팬은 추가하지 않았다.
연속 영상의 깜빡임, GPU별 성능/FPS, 카메라 범위 전체 끝점, 실기기 터치·자이로, 다른 사진 크기에서의 전수 검증은 미실시. 사진은 로컬 QA 용도이며 게시하지 않았다.
실행: `npm.cmd run dev -- --host 127.0.0.1 --port 5174`.
![흰 강아지 개선안](dog1-final.jpg)
![어두운 강아지 개선안](dog2-final.jpg)