first commit
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,240 @@
|
||||
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 { PetDepthMesh } from './objects/PetDepthMesh';
|
||||
import { DepthDebug } from './depth/DepthDebug';
|
||||
import glassScreenVertex from './shaders/glassScreen.vert?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 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 }, 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 }, uGridSize: { value: 10 }, uMosaicStyle: { value: 0 }, uStrength: { value: 0.9 }, uGlossStrength: { value: 0.3 }, uCurvature: { value: 0.65 }, 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 glassMat = new THREE.ShaderMaterial({ uniforms, vertexShader: glassScreenVertex, fragmentShader: mosaicShader });
|
||||
const glass = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), glassMat);
|
||||
glassScene.add(glass);
|
||||
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 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 });
|
||||
let active: THREE.Texture = emptyTex;
|
||||
let aspect = 1;
|
||||
let baseW = 1;
|
||||
let baseH = 1;
|
||||
let photoZoom = 1;
|
||||
let photoPanX = 0;
|
||||
let photoPanY = 0;
|
||||
let pendingSampleFraming = false;
|
||||
let mode = 'noglass';
|
||||
let hintTimeout = 0;
|
||||
let pendingPairDepth: THREE.Texture | null = null;
|
||||
let mediaRequest = 0;
|
||||
let depthRequest = 0;
|
||||
let disposed = false;
|
||||
const listenerAbort = new AbortController();
|
||||
function clearPendingPair(): void { if (pendingPairDepth) pendingPairDepth.dispose(); pendingPairDepth = null; }
|
||||
function applyFraming(): void {
|
||||
const x = baseW * (photoZoom - 1) * 0.5 * photoPanX;
|
||||
const y = baseH * (photoZoom - 1) * 0.5 * photoPanY;
|
||||
pet.scale.set(baseW * photoZoom, baseH * photoZoom, 1);
|
||||
pet.position.set(x, y, 0);
|
||||
petDepth.scale.copy(pet.scale);
|
||||
petDepth.position.copy(pet.position);
|
||||
}
|
||||
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);
|
||||
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 hint(): void {
|
||||
window.clearTimeout(hintTimeout);
|
||||
hintElement.style.opacity = '1';
|
||||
hintTimeout = window.setTimeout(() => { hintElement.style.opacity = '0'; }, 2000);
|
||||
}
|
||||
function applyMedia(texture: THREE.Texture, mediaAspect: number, video: HTMLVideoElement | null): void {
|
||||
if (active !== emptyTex) active.dispose();
|
||||
active = texture; aspect = mediaAspect;
|
||||
if (pendingSampleFraming) { photoZoom = 2.4; photoPanX = -0.10; photoPanY = -0.35; pendingSampleFraming = false; } else { photoZoom = 1; photoPanX = 0; photoPanY = 0; }
|
||||
syncPhotoControls();
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
depthUniforms.uTexture.value = texture;
|
||||
petMat.map = texture; petMat.needsUpdate = true;
|
||||
empty.style.display = 'none'; resize();
|
||||
}
|
||||
function setDepth(texture: THREE.Texture): void {
|
||||
if (depthUniforms.uDepthMap.value !== depthMap) depthUniforms.uDepthMap.value.dispose();
|
||||
depthUniforms.uDepthMap.value = texture; texture.colorSpace = THREE.NoColorSpace;
|
||||
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; applyMedia(texture, mediaAspect, video); petDepth.setVideo(Boolean(video)); setDepth(pendingPairDepth ?? DepthProcessor.flatTexture()); pendingPairDepth = null; depthProvenance.hidden = !hasSampleDepth; if (hasSampleDepth) depthProvenance.textContent = '수작업 테스트 Depth · AI 추정 아님'; setMode('final'); hint(); }, (message) => { status.textContent = message; retry.hidden = !message.includes('자동'); });
|
||||
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));
|
||||
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 effectInput = document.querySelector('#effect') as HTMLSelectElement;
|
||||
const modeInput = document.querySelector('#view-mode') as HTMLSelectElement | null;
|
||||
const strengthInput = document.querySelector('#strength') as HTMLInputElement;
|
||||
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 glossControls = document.querySelector('#gloss-controls') 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 strengthOutput = document.querySelector('#strengthOut') 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';
|
||||
if (glossControls) { glossControls.hidden = !enabled; glossControls.style.display = enabled ? 'grid' : 'none'; }
|
||||
[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);
|
||||
}
|
||||
imageInput.onchange = (event) => { const input = event.target as HTMLInputElement; pendingSampleFraming = false; mediaRequest += 1; depthRequest += 1; clearPendingPair(); depthDebug.clear(); if (input.files?.[0]) loader.load(input.files[0]); input.value = ''; };
|
||||
videoInput.onchange = (event) => { const input = event.target as HTMLInputElement; pendingSampleFraming = false; mediaRequest += 1; depthRequest += 1; clearPendingPair(); depthDebug.clear(); if (input.files?.[0]) loader.load(input.files[0]); 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; depthRequest += 1; clearPendingPair(); depthDebug.clear(); let url = ''; try { const [rgb, depth] = await Promise.all([fetch('/assets/sample-pet.png').then((r) => r.blob()), fetch('/assets/sample-pet-depth.png').then((r) => r.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; } clearPendingPair(); pendingPairDepth = loadedDepth; depthDebug.setSample(image); pendingSampleFraming = true; loader.load(new File([rgb], 'sample-pet.png', { type: 'image/png' })); status.textContent = '수작업 테스트 Depth · AI추정 아님'; } catch { if (!disposed && request === mediaRequest) status.textContent = '샘플 depth map을 읽지 못했습니다.'; } finally { if (url) URL.revokeObjectURL(url); } }, { 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 });
|
||||
strengthInput.oninput = (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uStrength.value = value; strengthOutput.value = value.toFixed(2); };
|
||||
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; 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); }, { signal: listenerAbort.signal });
|
||||
mosaicStyleInput.addEventListener('change', (event) => { const style = Number((event.target as HTMLSelectElement).value); uniforms.uMosaicStyle.value = style; uniforms.uGridSize.value = style === 0 ? 10 : 28; uniforms.uStrength.value = style === 0 ? 0.9 : 0.85; gridInput.value = String(uniforms.uGridSize.value); gridOutput.value = String(uniforms.uGridSize.value); strengthInput.value = String(uniforms.uStrength.value); strengthOutput.value = uniforms.uStrength.value.toFixed(2); }, { 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 () => { const request = ++depthRequest; const file = depthFileInput.files?.[0]; if (!file) return; 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); depthFileInput.value = ''; } }, { 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();
|
||||
let last = performance.now(); let raf = 0;
|
||||
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.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); debugOutput.textContent = diagnostic; debugOutput.hidden = !diagnostic; if (mode === 'final' || mode === 'glass') { renderer.setRenderTarget(renderTarget); renderer.render(scene, camera); renderer.setRenderTarget(null); uniforms.uSceneTexture.value = renderTarget.texture; renderer.render(glassScene, postCamera); const previousAutoClear = renderer.autoClear; renderer.autoClear = false; renderer.clearDepth(); renderer.render(frameScene, camera); renderer.autoClear = previousAutoClear; } else { renderer.setRenderTarget(null); renderer.render(scene, camera); } raf = requestAnimationFrame(frame); }
|
||||
raf = requestAnimationFrame(frame);
|
||||
function teardown(): void { disposed = true; mediaRequest += 1; depthRequest += 1; clearPendingPair(); listenerAbort.abort(); cancelAnimationFrame(raf); window.clearTimeout(hintTimeout); observer.disconnect(); imageInput.onchange = null; videoInput.onchange = null; effectInput.onchange = null; strengthInput.oninput = null; sensitivityInput.oninput = null; resetButton.onclick = null; gyroButton.onclick = null; retry.onclick = null; pointer.dispose(); gyro.dispose(); loader.dispose(); renderTarget.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(); frameMaterials.forEach((material) => material.dispose()); emptyTex.dispose(); renderer.domElement.remove(); renderer.dispose(); hintElement.style.opacity = '0'; retry.remove(); debugOutput.remove(); }
|
||||
const hot = import.meta as ImportMeta & { hot?: { dispose(fn: () => void): void } }; hot.hot?.dispose(teardown);
|
||||
@@ -0,0 +1,48 @@
|
||||
uniform sampler2D uSceneTexture;
|
||||
uniform mat4 uContentViewProjection,uContentInverseViewProjection;
|
||||
uniform vec3 uContentCameraPosition,uLightPosition,uBackgroundColor;
|
||||
uniform float uGlassZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlossStrength;
|
||||
uniform vec2 uViewOffset,uPetSize,uPaneSize;
|
||||
uniform vec4 uPaneRect;
|
||||
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)));}
|
||||
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));}
|
||||
void main(){
|
||||
if(uStrength<=.001){gl_FragColor=sampleScene(vUv);
|
||||
#include <colorspace_fragment>
|
||||
return;}
|
||||
vec4 f=uContentInverseViewProjection*vec4(vUv*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,random=hash2(cell);float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
|
||||
if(paneUv.x<0.||paneUv.x>1.||paneUv.y<0.||paneUv.y>1.){gl_FragColor=vec4(uBackgroundColor,1.);
|
||||
#include <colorspace_fragment>
|
||||
return;}
|
||||
// Positive height bulges toward the observer (+Z); negative height is concave.
|
||||
float curvature=clamp(uCurvature,-1.,1.),excess=max(curvature-.65,0.),surfaceCurvature=curvature-(.25/.35)*excess*excess; float height=.009*cellWorld*surfaceCurvature*mix(.92,1.08,hash(cell+vec2(41.7,17.3))); vec2 q=2.*local; vec2 q2=q*q,q3=q2*q,b=1.-q2*q2; 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 cellDimensions=uPaneSize/grid; vec2 grad=-2.*dhdq/cellDimensions; vec3 nCurved=normalize(vec3(grad+(random-.5)*.012,1.)),nFacet=normalize(vec3((random-.5)*.012,1.));
|
||||
vec3 straightHit=gh+i*(-gh.z/safeZ(i.z));vec3 facetRay=bend(i,nFacet),curvedRay=bend(i,nCurved);vec3 facetHit=gh+facetRay*(-gh.z/safeZ(facetRay.z)),curvedHit=gh+curvedRay*(-gh.z/safeZ(curvedRay.z));vec2 base=facetHit.xy-straightHit.xy,curve=curvedHit.xy-facetHit.xy;vec2 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;
|
||||
vec2 sourceCenter=(cell+.5)/grid+(random-.5)*.16*cellSize;
|
||||
vec2 widths;
|
||||
if(style>.5){
|
||||
widths=mix(vec2(.82),vec2(1.12),hash2(cell+vec2(11.4,9.8)));
|
||||
}else{
|
||||
float widthSeed=hash(cell+vec2(53.1,27.4));
|
||||
if(widthSeed<.6)widths=mix(vec2(.90),vec2(1.08),hash2(cell+vec2(7.2,3.1)));
|
||||
else if(widthSeed<.9)widths=mix(vec2(.46),vec2(.70),hash2(cell+vec2(7.2,3.1)));
|
||||
else widths=mix(vec2(1.10),vec2(1.28),hash2(cell+vec2(7.2,3.1)));
|
||||
}
|
||||
float angle=(random.x-.5)*.08,c=cos(angle),s=sin(angle);
|
||||
vec2 warpedLocal=local*cellSize+worldDelta/uPaneSize;
|
||||
vec2 rawSampleUv=sourceCenter+(mat2(c,-s,s,c)*warpedLocal)*widths;
|
||||
// Fade cell displacement near its edges to keep neighboring samples continuous.
|
||||
vec2 cellEdge=.5-abs(local);
|
||||
float interiorWeight=smoothstep(0.,.085,cellEdge.x)*smoothstep(0.,.085,cellEdge.y);
|
||||
vec2 sampleUv=paneUv+(rawSampleUv-paneUv)*interiorWeight;
|
||||
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*.75,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;vec2 blurStep=cellSize*mix(.006,.012,hash(cell+vec2(5.)));vec2 centerUv=mix(paneUv,sampleUv,strength);vec4 color=samplePane(centerUv)*.4;color+=samplePane(mix(paneUv,sampleUv+vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(paneUv,sampleUv-vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(paneUv,sampleUv+vec2(0.,blurStep.y),strength))*.15;color+=samplePane(mix(paneUv,sampleUv-vec2(0.,blurStep.y),strength))*.15;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 spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float gloss=spec*clamp(uGlossStrength,0.,1.)*.12*edgeWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),gloss); gl_FragColor=color;
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,21 @@
|
||||
# 1차 수정본 QA 및 2차 개선 계획
|
||||
|
||||
2026-09-13. 사용자 요청: 1차 수정본 QA, 개선점을 보고하고 구현. karpathy-guidelines 적용.
|
||||
|
||||
## 재현한 문제
|
||||
|
||||
1. 샘플 확대3/X-.15/Y-.35/Grid8/Distortion.9에서 오른눈을 가로지르는 흰 선과 코/입 아래 뾰족한 이음새. gloss0 및 curvature0에서도 남는다. 현재 edge product weight가 두 축의 UV 변위를 원본으로 복귀시켜 경계 위치를 원본에 고정하는 구조가 유력 원인이다.
|
||||
2. Reeded에서 Mosaic 스타일을 촘촘 유리로 변경하면 Grid28/Distortion.85로 덮어쓴다. Mosaic 전용 UI가 타 효과에도 활성화되어 있다.
|
||||
|
||||
## 개선 명세
|
||||
|
||||
- 경계에서 원본 좌표로 되돌리는 1차 방식 제거. 중심 셀과 가까운 이웃 최대4개에서 affine UV, 배율/회전 행렬, 표면 기울기를 연속 가중치로 혼합한다. 내부는 1개, 모서리2개, 코너4개 셀 평가를 목표로 한다.
|
||||
- 혼합한 곡면/facet 법선으로 기존 굴절을 1회 계산하고, 동일 곡면 법선을 반사에 공유한다. 기존 확대분포, seed, jitter.16, signed default+.65, curve0 셀효과,5tap,2pass,외곽,Distortion0는 유지한다.
|
||||
- 전이 half-width .16셀에서 시작. affine 위치는 전이구간만큼 이웃셀 밖으로 연장하고, 곡면 q만 [-1,1]로 제한한다. 전역 단조성을 보장하는 광학 해법은 아니므로 접힘/늘어짐을 시각 검증한다.
|
||||
- Mosaic 스타일 label은 Mosaic에서만 표시, select disabled와 handler guard를 함께 적용한다. 효과 전환 시 기존 스타일/Grid/Distortion을 초기화하지 않는다.
|
||||
|
||||
## 검증
|
||||
|
||||
변경 전 NoGlass 코/눈/몸 카메라 시차를 먼저 재확인했다(cameraX0→.092). 같은 샘플/구도/광택0에서 눈의 선과 뾰족한 이음새 감소, 큰눈코/셀정보 차이 보존을 확인한다. 이후 signed0/±값,drag,mobile,compact스타일,RT효과전환,브라우저shader오류와 build를 확인한다. UI는 Grid20/Distortion.72로 맞춘 후 Reeded→Frosted→Mosaic에서 설정 비활성/복원과 값 보존을 확인한다.
|
||||
|
||||
추가 texture fetch는 없지만 셀별 ALU 연산은 증가한다. 실제 기기 성능 측정 없이 모바일 성능 동등성을 주장하지 않는다. 원본/샘플 depth를 변경하거나 외부에 게시하지 않는다. 기존 Distortion0 액자밖 출력 계약은 이번 수정에서 유지한다.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 1차 수정본 QA 및 2차 개선 결과
|
||||
|
||||
2026-09-13. karpathy-guidelines 적용. Astra 설계/독립 정적·이미지 검토, Luna 두 에이전트가 각각 shader와 UI 구현, 주 에이전트 실제 브라우저 검증.
|
||||
|
||||
## QA 결과와 해결
|
||||
|
||||
1. **눈의 가는 절개선과 입/목의 뾰족한 이음새**: 1차 구현의 원본 UV 복귀가 유력 원인. 광택0, 곡률0, 드래그에서 재현했다. 셀 경계에서 원본 UV로 되돌리는 방식은 제거하고, 가까운 이웃 셀의 affine UV/회전·배율/표면 기울기를 최대4개까지 보간했다. 같은 혼합 법선으로 굴절과 반사를 계산한다. 사진 색상을 이중 혼합하지 않으며 5회 texture fetch는 유지한다.
|
||||
2. **Reeded/Frosted에 Mosaic 스타일이 간섭**: 전용 label을 숨기고 select disabled 및 change handler guard를 적용했다. 일반 Grid/Distortion 조절과 효과 전환 시 값 보존은 유지한다.
|
||||
|
||||
변경 소스: src/shaders/mosaic.frag, src/main.ts. 원본은 before.frag / before-main.ts에 보존. 추가 UI/새 자산/새 라이브러리/AI depth/사진 리라이팅 없음. 기존 signed default+.65, 확대 seed/widths, jitter.16,곡률0facet,Depth/RT,외곽,Distortion0 계약 유지.
|
||||
|
||||
## 시각 판단
|
||||
|
||||
before-close.png와 after-close.png는 같은 샘플 확대3/X-.15/Y-.35/Grid8/Distortion.9/곡률+.65/광택0이다. 눈 가로 절개선과 입/목의 날카로운 연결이 크게 완화된다. 일부 늘어남과 셀별 배율 차이는 남으며, 완전한 물리 유리 재현이나 결함 전부 제거를 주장하지 않는다.
|
||||
|
||||
기본 확대2.4/X-.10/Y-.35/Grid10/.9/+.65/광택.3은 docs/natural-glass/after-default.png가 1차, 이 폴더 after-default.png가 2차다. 이마/몸의 사각 조각감은 약해지는 절충이 있다. 전이 half-width .12도 비교했으나 명확한 조각감 개선이 없어 .16을 최종 채택했다. narrow-*.png는 미채택 .12 후보다. Astra도 .16 수용 가능, 정적 차단 결함 없음으로 판단했다.
|
||||
|
||||
## 검증 증거
|
||||
|
||||
- 변경 전 Glass OFF cameraX0→.092: 코531.509→528.803px, 왼눈459.283→457.560px, 몸350.477→350.467px. 수작업 Depth 기반 실제 부위별 카메라 시차 확인.
|
||||
- 최종 .16에서 광택0/곡률0,-1,+1, 기본/근접 구도 비교. 곡률0에서도 셀 샘플 효과가 남음. 같은 법선 사용은 정적 검토와 Glass Only 반사 드래그로 확인.
|
||||
- 390×844 근접 화면에서 눈·코 식별과 액자/유리 경계 고정, 드래그cameraX .123 관측. 최대시점 도달 테스트가 아님. 뷰포트 override 복원. after-mobile.png는 광택.15이다.
|
||||
- Glass Only 광택1/조명0,0에서 사각 반사 영역과 드래그에 따른 위치변화 확인. 이 진단에서 일부 셀 반복은 보인다.
|
||||
- Distortion0 원본 출력 확인. 기존 액자밖 원본 노출 한계는 계약에 따라 유지.
|
||||
- 조각/촘촘 스타일, Reeded/Frosted 실제 렌더 확인.
|
||||
- UI: 촘촘 선택 후 Grid20/Distortion.72 설정 → Reeded에서 styleVisible=false/styleEnabled=false → Frosted → Mosaic 복귀 시 촘촘 선택과20/.72 유지. CSS label display:grid에 hidden만 사용하는 문제를 피하도록 display를 명시했다.
|
||||
- Luna 최종 npm.cmd run build: tsc-b와 Vite production build 성공.
|
||||
- 작업 중 누적 로그에서 중간 저장본의 evaluateCell 대각 인수 순서 오류 1건을 발견. 최종 파일은 올바른 순서이며, **새 탭**에서 최종 샘플을 다시 로딩/렌더한 후 error logs=[] 확인. 중간 오류를 최종 오류로 보고하거나 누적 로그 전체가 없었다고 주장하지 않는다.
|
||||
|
||||
## 남은 개선과 한계
|
||||
|
||||
- 가장 큰 미완료는 레퍼런스처럼 넓게 늘어나는 셀과 특징이 보이는 셀 사이의 정보량 차이. 현재는 선 제거와 경계 연속성을 우선했고 주변 털/배경이 비교적 많이 남는다.
|
||||
- 셀 경계 보간은 광학적으로 완전한 렌즈 해법이 아니며 모든 사진/시점에서 접힘 없는 UV를 보장하지 않는다. 과도한 배율차와 극단 설정의 한계는 별도이다.
|
||||
- 인접 셀 평가로 ALU 연산 증가. texture fetch 개수는 동일하지만 실제 모바일 FPS/배터리/GPU별 성능은 측정하지 않았다.
|
||||
- 영상 재생, 실기기 자이로/터치, 장시간 메모리, 일반 사용자 사진 여러 장 QA는 이번에 실행하지 않았다. 샘플은 수작업 Depth이며 일반 사진 자동 깊이를 의미하지 않는다.
|
||||
- 모바일 시작 안내문 줄바꿈도 관찰했으나 이번 유리 경계 및 효과 전용 컨트롤 범위에서는 변경하지 않았다.
|
||||
|
||||
실행: npm.cmd run dev -- --host 127.0.0.1, http://127.0.0.1:5173/ 에서 샘플 로드. 최종 미리보기는 확대3/-.15/-.35/Grid8/.9/+.65/광택.15, 나머지 기본값, 정면이다. 원본 사진/Depth 변경 및 외부 게시 없음.
|
||||
Reference in New Issue
Block a user