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