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 { 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);