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
+3
View File
@@ -0,0 +1,3 @@
// Relative-depth statistics, not semantic foreground detection or metric distance.
export const DEPTH_EVIDENCE = { lowerQuantile: 0.5, upperQuantile: 0.85, fallbackLowerQuantile: 0.05, fallbackUpperQuantile: 0.95, minimumSpan: 12 / 255 };
export const DEPTH_REFRACTION = { blend: 0.65, maxCellCorrection: 0.35 };
+1
View File
@@ -0,0 +1 @@
export const ENVIRONMENT_REFLECTION_STRENGTH = 0.12;
+109
View File
@@ -0,0 +1,109 @@
import * as THREE from 'three';
import { DepthProcessor } from './DepthProcessor';
import type { DepthProvider } from '../types/depth';
type WorkerReply =
| { type: 'status'; id: number; message: string }
| { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer }
| { type: 'error'; id: number; message: string };
type PendingRequest = {
id: number;
resolve: (texture: THREE.Texture) => void;
reject: (error: Error) => void;
};
export class AiDepthProvider implements DepthProvider {
private worker: Worker | null = null;
private pending: PendingRequest | null = null;
private requestId = 0;
constructor(private readonly onStatus: (message: string) => void) {}
async getDepthMap(image: HTMLImageElement): Promise<THREE.Texture> {
if (this.pending) throw new Error('AI depth 추론이 이미 진행 중입니다.');
const id = ++this.requestId;
return new Promise<THREE.Texture>((resolve, reject) => {
this.pending = { id, resolve, reject };
void this.captureAndSend(image, id).catch((error: unknown) => {
this.fail(id, error instanceof Error ? error : new Error('AI depth 생성에 실패했습니다.'));
});
});
}
cancel(): void {
const pending = this.pending;
this.pending = null;
this.requestId += 1;
this.worker?.terminate();
this.worker = null;
pending?.reject(new Error('AI depth 요청이 취소되었습니다.'));
}
private async captureAndSend(image: HTMLImageElement, id: number): Promise<void> {
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
if (!width || !height) throw new Error('사진 크기를 확인할 수 없습니다.');
const scale = Math.min(1, 1024 / Math.max(width, height));
const bitmap = await createImageBitmap(image, {
resizeWidth: Math.max(1, Math.round(width * scale)),
resizeHeight: Math.max(1, Math.round(height * scale)),
resizeQuality: 'high',
});
if (this.pending?.id !== id) {
bitmap.close();
return;
}
const worker = this.ensureWorker();
try {
worker.postMessage({ type: 'infer', id, image: bitmap }, [bitmap]);
} catch (error) {
bitmap.close();
throw error;
}
}
private ensureWorker(): Worker {
if (this.worker) return this.worker;
const worker = new Worker(new URL('./aiDepth.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event: MessageEvent<WorkerReply>) => {
const message = event.data;
if (this.pending?.id !== message.id) return;
if (message.type === 'status') {
this.onStatus(message.message);
} else if (message.type === 'result') {
const pending = this.pending;
this.pending = null;
try {
pending.resolve(DepthProcessor.fromNormalizedDepth(new Uint8Array(message.values), message.width, message.height));
} catch (error) {
pending.reject(error instanceof Error ? error : new Error('AI depth 결과를 읽지 못했습니다.'));
}
} else {
this.fail(message.id, new Error(message.message));
}
};
worker.onerror = (event) => {
this.resetWorker(worker);
this.fail(this.pending?.id ?? -1, new Error(event.message || 'AI depth worker 오류가 발생했습니다.'));
};
worker.onmessageerror = () => {
this.resetWorker(worker);
this.fail(this.pending?.id ?? -1, new Error('AI depth worker 결과를 읽지 못했습니다.'));
};
this.worker = worker;
return worker;
}
private fail(id: number, error: Error): void {
if (this.pending?.id !== id) return;
const pending = this.pending;
this.pending = null;
pending.reject(error);
}
private resetWorker(worker: Worker): void {
worker.terminate();
if (this.worker === worker) this.worker = null;
}
}
+99
View File
@@ -0,0 +1,99 @@
import * as THREE from 'three';
type Landmark = { name: string; x: number; y: number };
const LANDMARKS: Landmark[] = [
{ name: 'nose', x: 235, y: 310 },
{ name: 'left-eye', x: 198, y: 278 },
{ name: 'right-eye', x: 249, y: 273 },
{ name: 'face', x: 221, y: 267 },
{ name: 'left-ear', x: 158, y: 213 },
{ name: 'right-ear', x: 262, y: 206 },
{ name: 'body', x: 139, y: 419 },
];
/** Cached, hand-authored depth inspection for the local sample fixture. */
export class DepthDebug {
private values: Uint8ClampedArray | null = null;
private width = 0;
private height = 0;
private sampleKind = '';
setSample(image: HTMLImageElement): void {
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context || width < 1 || height < 1) {
this.clear();
return;
}
context.drawImage(image, 0, 0, width, height);
this.values = context.getImageData(0, 0, width, height).data;
this.width = width;
this.height = height;
this.sampleKind = 'sample';
}
clear(): void {
this.values = null;
this.width = 0;
this.height = 0;
this.sampleKind = '';
}
update(
camera: THREE.Camera | null,
mesh: THREE.Object3D | null,
depthScale: number,
depthBias: number,
canvasCssWidth: number,
sourceBaseZ: number,
maxDepth: number,
): string {
if (!this.values || !camera || !mesh || this.sampleKind === '') return '';
camera.updateMatrixWorld();
mesh.updateMatrixWorld(true);
const maxFrontZ = sourceBaseZ + (maxDepth - depthBias) * depthScale;
const gapToGlass = Math.max(0, 0.6 - maxFrontZ);
const output: string[] = [`cameraX=${camera.position.x.toFixed(3)} sample=${this.sampleKind}`, `contentBaseZ=${sourceBaseZ.toFixed(3)} maxFrontZ=${maxFrontZ.toFixed(3)} glassGap=${gapToGlass.toFixed(3)}`];
const local = new THREE.Vector3();
const world = new THREE.Vector3();
const projected = new THREE.Vector3();
for (const landmark of LANDMARKS) {
const depth = this.sampleBilinear(landmark.x, landmark.y);
const u = (landmark.x + 0.5) / this.width;
const v = 1 - (landmark.y + 0.5) / this.height;
local.set(u - 0.5, v - 0.5, (depth - depthBias) * depthScale);
world.copy(local);
mesh.localToWorld(world);
projected.copy(world).project(camera);
const projectedX = (projected.x * 0.5 + 0.5) * canvasCssWidth;
output.push(`${landmark.name}: depth=${depth.toFixed(3)} worldZ=${world.z.toFixed(3)} projectedX=${projectedX.toFixed(3)}px`);
}
return output.join('\n');
}
private sampleBilinear(x: number, yTop: number): number {
if (!this.values || this.width < 1 || this.height < 1) return 0;
const px = THREE.MathUtils.clamp(x, 0, this.width - 1);
const py = THREE.MathUtils.clamp(yTop, 0, this.height - 1);
const x0 = Math.floor(px);
const y0 = Math.floor(py);
const x1 = Math.min(x0 + 1, this.width - 1);
const y1 = Math.min(y0 + 1, this.height - 1);
const tx = px - x0;
const ty = py - y0;
const a = this.gray(x0, y0);
const b = this.gray(x1, y0);
const c = this.gray(x0, y1);
const d = this.gray(x1, y1);
return THREE.MathUtils.lerp(THREE.MathUtils.lerp(a, b, tx), THREE.MathUtils.lerp(c, d, tx), ty);
}
private gray(x: number, y: number): number {
return (this.values as Uint8ClampedArray)[(y * this.width + x) * 4] / 255;
}
}
+11
View File
@@ -0,0 +1,11 @@
export class DepthMapLoader {
async load(url: string): Promise<HTMLImageElement> {
const image = new Image();
image.src = url;
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error(`Depth map could not be loaded: ${url}`));
});
return image;
}
}
+82
View File
@@ -0,0 +1,82 @@
import * as THREE from 'three';
import { DEPTH_EVIDENCE } from '../constants/depth.ts';
export class DepthProcessor {
static evidenceRange(texture: THREE.Texture): [number, number] {
const data = (texture as THREE.DataTexture).image?.data;
if (!data || data.length < 4) return [1, 1];
const histogram = new Uint32Array(256);
for (let i = 0; i < data.length; i += 4) histogram[Number(data[i])] += 1;
const count = data.length / 4;
const quantile = (fraction: number): number => {
let sum = 0;
for (let i = 0; i < histogram.length; i += 1) {
sum += histogram[i];
if (sum >= count * fraction) return i / 255;
}
return 1;
};
let lower = quantile(DEPTH_EVIDENCE.lowerQuantile);
let upper = quantile(DEPTH_EVIDENCE.upperQuantile);
if (upper - lower < DEPTH_EVIDENCE.minimumSpan) {
lower = quantile(DEPTH_EVIDENCE.fallbackLowerQuantile);
upper = quantile(DEPTH_EVIDENCE.fallbackUpperQuantile);
}
if (upper - lower < DEPTH_EVIDENCE.minimumSpan) return [1, 1];
return [lower, upper];
}
static fromNormalizedDepth(values: Uint8Array, width: number, height: number): THREE.DataTexture {
if (values.length !== width * height) throw new Error('Depth data size does not match dimensions');
const rgba = new Uint8Array(width * height * 4);
for (let i = 0; i < values.length; i += 1) {
const offset = i * 4;
rgba[offset] = values[i];
rgba[offset + 1] = values[i];
rgba[offset + 2] = values[i];
rgba[offset + 3] = 255;
}
const texture = new THREE.DataTexture(rgba, width, height, THREE.RGBAFormat);
texture.needsUpdate = true;
texture.colorSpace = THREE.NoColorSpace;
texture.flipY = true;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
return texture;
}
static toTexture(image: HTMLImageElement, invert = false): THREE.DataTexture {
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas 2D context unavailable');
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
const values = new Uint8Array(canvas.width * canvas.height * 4);
for (let i = 0; i < canvas.width * canvas.height; i += 1) {
const value = invert ? 255 - pixels[i * 4] : pixels[i * 4];
values.set([value, value, value, 255], i * 4);
}
const texture = new THREE.DataTexture(values, canvas.width, canvas.height, THREE.RGBAFormat);
texture.needsUpdate = true;
texture.colorSpace = THREE.NoColorSpace;
texture.flipY = true;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
return texture;
}
static flatTexture(): THREE.DataTexture {
const texture = new THREE.DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1, THREE.RGBAFormat);
texture.needsUpdate = true;
texture.colorSpace = THREE.NoColorSpace;
texture.flipY = true;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
return texture;
}
}
+14
View File
@@ -0,0 +1,14 @@
import * as THREE from 'three';
import { DepthProvider } from '../types/depth';
import { DepthMapLoader } from './DepthMapLoader';
import { DepthProcessor } from './DepthProcessor';
export class StaticDepthProvider implements DepthProvider {
constructor(private readonly url?: string, private readonly invert = false) {}
async getDepthMap(image: HTMLImageElement): Promise<THREE.Texture> {
if (!this.url) return DepthProcessor.flatTexture();
const depthImage = await new DepthMapLoader().load(this.url);
return DepthProcessor.toTexture(depthImage, this.invert);
}
}
+141
View File
@@ -0,0 +1,141 @@
import { pipeline, type DepthEstimationPipelineOutput } from '@huggingface/transformers';
const MODEL_ID = 'onnx-community/depth-anything-v2-small-ONNX';
type Device = 'webgpu' | 'wasm';
type RequestMessage = { type: 'infer'; id: number; image: ImageBitmap };
type WorkerReply =
| { type: 'status'; id: number; message: string }
| { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer }
| { type: 'error'; id: number; message: string };
type Estimator = {
(image: OffscreenCanvas): Promise<DepthEstimationPipelineOutput | DepthEstimationPipelineOutput[]>;
dispose(): Promise<void>;
};
type Progress = { status: string; progress?: number };
type PipelineFactory = (task: string, model: string, options: {
device: Device;
progress_callback: (progress: Progress) => void;
}) => Promise<Estimator>;
const createPipeline = pipeline as unknown as PipelineFactory;
const workerScope = self as unknown as {
postMessage(message: WorkerReply, transfer?: Transferable[]): void;
addEventListener(type: 'message', listener: (event: MessageEvent<RequestMessage>) => void): void;
};
let estimator: Estimator | null = null;
let activeDevice: Device | null = null;
function reply(message: WorkerReply, transfer: Transferable[] = []): void {
workerScope.postMessage(message, transfer);
}
function setStatus(id: number, message: string): void {
reply({ type: 'status', id, message });
}
async function loadEstimator(device: Device, id: number): Promise<Estimator> {
setStatus(id, 'AI 모델 준비 중…');
return createPipeline('depth-estimation', MODEL_ID, {
device,
progress_callback: (progress) => {
if (progress.status === 'progress') {
setStatus(id, `AI 모델 다운로드 중 · ${Math.round(progress.progress ?? 0)}%`);
} else if (progress.status === 'initiate' || progress.status === 'download') {
setStatus(id, 'AI 모델 다운로드 중…');
}
},
});
}
async function ensureEstimator(id: number): Promise<Estimator> {
if (estimator) return estimator;
if ('gpu' in navigator) {
try {
estimator = await loadEstimator('webgpu', id);
activeDevice = 'webgpu';
return estimator;
} catch {
if (estimator) await estimator.dispose();
estimator = null;
setStatus(id, 'AI 모델을 준비하는 중입니다…');
}
}
estimator = await loadEstimator('wasm', id);
activeDevice = 'wasm';
return estimator;
}
function normalizeDepth(output: DepthEstimationPipelineOutput): { values: Uint8Array; width: number; height: number } {
const { predicted_depth: prediction } = output;
const [height, width] = prediction.dims.slice(-2);
const data = prediction.data;
if (!width || !height || data.length !== width * height) throw new Error('모델이 유효한 depth 크기를 반환하지 않았습니다.');
let min = Infinity;
let max = -Infinity;
for (let i = 0; i < data.length; i += 1) {
const value = Number(data[i]);
if (!Number.isFinite(value)) continue;
min = Math.min(min, value);
max = Math.max(max, value);
}
if (!Number.isFinite(min) || !Number.isFinite(max)) throw new Error('모델의 depth 결과가 비어 있습니다.');
const range = max - min;
const values = new Uint8Array(width * height);
if (range <= 1e-8) {
values.fill(128);
} else {
for (let i = 0; i < values.length; i += 1) {
const value = Number(data[i]);
// Depth Anything V2 predicts relative inverse depth: larger values are nearer.
values[i] = Number.isFinite(value) ? Math.round(((value - min) / range) * 255) : 128;
}
}
return { values, width, height };
}
async function infer({ id, image }: RequestMessage): Promise<void> {
try {
const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext('2d');
if (!context) throw new Error('사진을 처리할 수 없습니다.');
context.drawImage(image, 0, 0);
image.close();
let model = await ensureEstimator(id);
setStatus(id, 'AI 깊이를 계산하는 중입니다…');
let output: DepthEstimationPipelineOutput;
try {
const predicted = await model(canvas);
if (Array.isArray(predicted)) throw new Error('단일 사진 결과를 읽을 수 없습니다.');
output = predicted;
} catch (error) {
if (activeDevice !== 'webgpu') throw error;
await model.dispose();
estimator = null;
activeDevice = null;
setStatus(id, 'AI 추론을 다시 시도하는 중입니다…');
model = await loadEstimator('wasm', id);
estimator = model;
activeDevice = 'wasm';
const predicted = await model(canvas);
if (Array.isArray(predicted)) throw new Error('단일 사진 결과를 읽을 수 없습니다.');
output = predicted;
}
try {
const normalized = normalizeDepth(output);
const values = normalized.values.buffer as ArrayBuffer;
reply({ type: 'result', id, width: normalized.width, height: normalized.height, values }, [values]);
} finally {
output.predicted_depth.dispose();
}
} catch (error) {
reply({ type: 'error', id, message: error instanceof Error ? error.message : 'AI depth 생성에 실패했습니다.' });
}
}
workerScope.addEventListener('message', (event: MessageEvent<RequestMessage>) => {
if (event.data.type === 'infer') void infer(event.data);
});
+9
View File
@@ -0,0 +1,9 @@
import * as THREE from 'three';
export class CameraController {
readonly target = new THREE.Vector2(); readonly current = new THREE.Vector2();
constructor(private readonly camera: THREE.PerspectiveCamera, private maxOffset = 0.5) {}
setMaxOffset(value: number): void { this.maxOffset = THREE.MathUtils.clamp(value, 0, 0.9); }
add(x: number, y: number): void { this.target.set(THREE.MathUtils.clamp(this.target.x + x, -1, 1), THREE.MathUtils.clamp(this.target.y + y, -1, 1)); }
reset(): void { this.target.set(0, 0); this.current.set(0, 0); }
update(dt: number, sensitivity: number): THREE.Vector2 { this.current.lerp(this.target, 1 - Math.exp(-8 * dt)); const view = this.current.clone().multiplyScalar(sensitivity); view.x = THREE.MathUtils.clamp(view.x, -1, 1); view.y = THREE.MathUtils.clamp(view.y, -1, 1); this.camera.position.set(view.x * this.maxOffset, view.y * this.maxOffset, 3); this.camera.lookAt(0, 0, 0); return view; }
}
+29
View File
@@ -0,0 +1,29 @@
export class GyroController {
private enabled = false;
private pending = false;
private generation = 0;
private hasReading = false;
private base: { beta: number; gamma: number } | null = null;
constructor(private readonly setView: (x: number, y: number) => void, private readonly status: (message: string) => void) {}
async toggle(): Promise<boolean> {
if (this.enabled || this.pending) { this.dispose(); return false; }
if (!window.isSecureContext || !('DeviceOrientationEvent' in window)) { this.status('자이로스코프를 사용할 수 없습니다.'); return false; }
const generation = ++this.generation; this.pending = true;
try {
const device = window.DeviceOrientationEvent as typeof DeviceOrientationEvent & { requestPermission?: () => Promise<string> };
this.status('센서 권한을 기다리는 중…');
if (device.requestPermission && await device.requestPermission() !== 'granted') throw new Error('권한 거절됨');
if (generation !== this.generation) return false;
this.base = null; this.hasReading = false; this.pending = false; this.enabled = true; window.addEventListener('deviceorientation', this.onOrientation); this.status('센서값을 기다리는 중…'); return true;
} catch (error) { this.pending = false; if (generation === this.generation) this.status(`자이로스코프를 사용할 수 없습니다 (${(error as Error).message})`); return false; }
}
reset(): void { this.base = null; }
dispose(): void { ++this.generation; this.pending = false; this.enabled = false; this.base = null; window.removeEventListener('deviceorientation', this.onOrientation); }
private onOrientation = (event: DeviceOrientationEvent): void => {
if (!this.enabled || event.beta == null || event.gamma == null) return;
if (!this.base) this.base = { beta: event.beta, gamma: event.gamma };
const angle = (screen.orientation?.angle ?? 0) * Math.PI / 180;
const x = event.gamma - this.base.gamma; const y = event.beta - this.base.beta;
this.setView((x * Math.cos(angle) + y * Math.sin(angle)) / 35, (y * Math.cos(angle) - x * Math.sin(angle)) / 35); if (!this.hasReading) { this.hasReading = true; this.status('자이로스코프 활성'); }
};
}
+77
View File
@@ -0,0 +1,77 @@
import * as THREE from 'three';
export type MediaReady = (texture: THREE.Texture, aspect: number, video: HTMLVideoElement | null) => void;
export class MediaLoader {
private token = 0;
private activeUrl = '';
private pendingUrl = '';
private pendingVideo: HTMLVideoElement | null = null;
private activeVideo: HTMLVideoElement | null = null;
private retryVideo: HTMLVideoElement | null = null;
constructor(private readonly onReady: MediaReady, private readonly onStatus: (message: string) => void) {}
cancelPending(): void { ++this.token; this.clearPending(); this.retryVideo = null; }
load(file: File): void {
const token = ++this.token;
this.clearPending();
this.retryVideo = null;
const url = URL.createObjectURL(file);
this.pendingUrl = url;
if (file.type.startsWith('image/')) {
const image = new Image();
image.onload = () => {
if (token !== this.token) return this.disposeUrl(url);
if (this.pendingUrl === url) this.pendingUrl = '';
const texture = new THREE.Texture(image);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
this.replaceActive(url, null);
this.onReady(texture, image.width / image.height, null);
if (token === this.token) this.onStatus('');
};
image.onerror = () => { if (token === this.token) this.onStatus('이미지를 읽지 못했습니다.'); this.disposeUrl(url); };
image.src = url;
return;
}
const video = document.createElement('video');
this.pendingVideo = video;
video.muted = true; video.loop = true; video.playsInline = true; video.preload = 'auto'; video.src = url;
const stale = () => {
video.onloadeddata = null;
video.onerror = null;
video.pause();
video.removeAttribute('src');
video.load();
if (this.pendingVideo === video) this.pendingVideo = null;
if (this.pendingUrl === url) this.pendingUrl = '';
this.disposeUrl(url);
};
video.onerror = () => { if (token === this.token) this.onStatus('동영상을 읽지 못했습니다.'); stale(); };
video.onloadeddata = () => {
if (token !== this.token) return stale();
video.onloadeddata = null;
video.onerror = null;
if (this.pendingUrl === url) this.pendingUrl = '';
if (this.pendingVideo === video) this.pendingVideo = null;
const texture = new THREE.VideoTexture(video); texture.colorSpace = THREE.SRGBColorSpace;
this.replaceActive(url, video);
this.retryVideo = video;
this.onReady(texture, video.videoWidth / video.videoHeight, video);
if (token === this.token && this.activeVideo === video) this.onStatus('');
video.play().catch(() => {
if (token === this.token && video === this.activeVideo) this.onStatus('자동 재생이 차단되었습니다. 화면을 눌러 재생하세요.');
});
};
video.load();
}
retry(): void { const video = this.retryVideo; const token = this.token; video?.play().catch(() => { if (token === this.token && video === this.activeVideo) this.onStatus('재생할 수 없습니다.'); }); }
dispose(): void { ++this.token; this.clearPending(); this.retryVideo = null; if (this.activeVideo) { this.activeVideo.pause(); this.activeVideo.removeAttribute('src'); this.activeVideo.load(); this.activeVideo = null; } this.disposeUrl(this.activeUrl); this.activeUrl = ''; }
private replaceActive(url: string, video: HTMLVideoElement | null): void { if (this.activeVideo && this.activeVideo !== video) { this.activeVideo.pause(); this.activeVideo.removeAttribute('src'); this.activeVideo.load(); } if (this.activeUrl && this.activeUrl !== url) this.disposeUrl(this.activeUrl); this.activeUrl = url; this.activeVideo = video; this.retryVideo = video; }
private clearPending(): void { if (this.pendingVideo) { this.pendingVideo.onloadeddata = null; this.pendingVideo.onerror = null; this.pendingVideo.pause(); this.pendingVideo.removeAttribute('src'); this.pendingVideo.load(); this.pendingVideo = null; } if (this.pendingUrl) this.disposeUrl(this.pendingUrl); this.pendingUrl = ''; }
private disposeUrl(url: string): void { if (url) URL.revokeObjectURL(url); }
}
+10
View File
@@ -0,0 +1,10 @@
export class PointerController {
private activeId: number | null = null;
private lastX = 0; private lastY = 0;
private hasDragged = false;
constructor(private readonly element: HTMLElement, private readonly setView: (x: number, y: number) => void, private readonly onDrag?: () => void) { element.addEventListener('pointerdown', this.down); element.addEventListener('pointermove', this.move); element.addEventListener('pointerup', this.end); element.addEventListener('pointercancel', this.end); }
private down = (event: PointerEvent): void => { this.activeId = event.pointerId; this.lastX = event.clientX; this.lastY = event.clientY; this.element.setPointerCapture(event.pointerId); };
private move = (event: PointerEvent): void => { if (event.pointerId !== this.activeId) return; const dx = event.clientX - this.lastX; const dy = event.clientY - this.lastY; if (dx === 0 && dy === 0) return; if (!this.hasDragged) { this.hasDragged = true; this.element.classList.add('is-dragging'); this.onDrag?.(); } const r = this.element.getBoundingClientRect(); this.setView(dx / r.width, -dy / r.height); this.lastX = event.clientX; this.lastY = event.clientY; };
private end = (event: PointerEvent): void => { if (event.pointerId !== this.activeId) return; this.activeId = null; this.hasDragged = false; this.element.classList.remove('is-dragging'); if (this.element.hasPointerCapture(event.pointerId)) this.element.releasePointerCapture(event.pointerId); };
dispose(): void { this.element.removeEventListener('pointerdown', this.down); this.element.removeEventListener('pointermove', this.move); this.element.removeEventListener('pointerup', this.end); this.element.removeEventListener('pointercancel', this.end); }
}
+466
View File
@@ -0,0 +1,466 @@
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 } 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 },
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);
+24
View File
@@ -0,0 +1,24 @@
import * as THREE from 'three';
import vertexShader from '../shaders/petDepth.vert?raw';
import fragmentShader from '../shaders/petDepth.frag?raw';
export class PetDepthMesh extends THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial> {
constructor(texture: THREE.Texture, depthMap: THREE.Texture, depthScale = 0.25, depthBias = 0) {
const geometry = new THREE.PlaneGeometry(1, 1, 128, 128);
const uniforms = {
uTexture: { value: texture },
uDepthMap: { value: depthMap },
uDepthScale: { value: THREE.MathUtils.clamp(depthScale, 0, 0.4) },
uDepthBias: { value: depthBias },
uIsVideo: { value: false },
};
const material = new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader });
super(geometry, material);
}
setTexture(texture: THREE.Texture): void { this.material.uniforms.uTexture.value = texture; }
setDepthMap(depthMap: THREE.Texture): void { this.material.uniforms.uDepthMap.value = depthMap; }
setDepthScale(scale: number): void { this.material.uniforms.uDepthScale.value = THREE.MathUtils.clamp(scale, 0, 0.4); }
setVideo(isVideo: boolean): void { this.material.uniforms.uIsVideo.value = isVideo; }
get depthMap(): THREE.Texture { return this.material.uniforms.uDepthMap.value; }
}
+48
View File
@@ -0,0 +1,48 @@
import * as THREE from 'three';
export class WindowReveal {
readonly meshes: THREE.Mesh[];
private readonly geometries: THREE.BufferGeometry[];
constructor(material: THREE.Material) {
this.geometries = Array.from({ length: 4 }, () => {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(12), 3));
geometry.setIndex([0, 1, 2, 0, 2, 3]);
geometry.computeVertexNormals();
return geometry;
});
this.meshes = this.geometries.map((geometry) => new THREE.Mesh(geometry, material));
}
update(frontWidth: number, frontHeight: number, backWidth: number, backHeight: number, frontZ: number, backZ: number): void {
const frontX = frontWidth * 0.5;
const frontY = frontHeight * 0.5;
const backX = backWidth * 0.5;
const backY = backHeight * 0.5;
const seam = Math.min(frontWidth, frontHeight) * 0.002;
const revealFrontZ = frontZ + seam;
const revealBackX = Math.max(0, backX - seam);
const revealBackY = Math.max(0, backY - seam);
const quads = [
[[-frontX - seam, -frontY - seam, revealFrontZ], [-frontX - seam, frontY + seam, revealFrontZ], [-revealBackX, revealBackY, backZ], [-revealBackX, -revealBackY, backZ]],
[[frontX + seam, frontY + seam, revealFrontZ], [frontX + seam, -frontY - seam, revealFrontZ], [revealBackX, -revealBackY, backZ], [revealBackX, revealBackY, backZ]],
[[-frontX - seam, frontY + seam, revealFrontZ], [frontX + seam, frontY + seam, revealFrontZ], [revealBackX, revealBackY, backZ], [-revealBackX, revealBackY, backZ]],
[[frontX + seam, -frontY - seam, revealFrontZ], [-frontX - seam, -frontY - seam, revealFrontZ], [-revealBackX, -revealBackY, backZ], [revealBackX, -revealBackY, backZ]]
];
for (let side = 0; side < quads.length; side += 1) {
const position = this.geometries[side].getAttribute('position') as THREE.BufferAttribute;
for (let vertex = 0; vertex < 4; vertex += 1) {
const point = quads[side][vertex];
position.setXYZ(vertex, point[0], point[1], point[2]);
}
position.needsUpdate = true;
this.geometries[side].computeVertexNormals();
this.geometries[side].computeBoundingSphere();
}
}
dispose(): void {
this.geometries.forEach((geometry) => geometry.dispose());
}
}
+9
View File
@@ -0,0 +1,9 @@
varying vec2 vUv;
varying vec3 vWorldPosition;
void main() {
vUv = uv;
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
+10
View File
@@ -0,0 +1,10 @@
uniform sampler2D uSceneTexture; uniform mat4 uContentViewProjection; uniform mat4 uContentInverseViewProjection; uniform vec3 uContentCameraPosition,uBackgroundColor; uniform float uGlassZ,uSourceZ,uGridSize,uStrength,uTime; uniform vec2 uViewOffset,uResolution,uPetSize,uPaneSize;
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){if(any(lessThan(uv,vec2(0.)))||any(greaterThan(uv,vec2(1.))))return vec4(.005605,.009721,.016807,1.);return texture2D(uSceneTexture,uv);} float hash(vec2 p){return fract(sin(dot(p,vec2(41.3,17.9)))*15731.743);} float noise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.)),f.x),f.y);} 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;}
void main(){if(uStrength<=.001){gl_FragColor=texture2D(uSceneTexture,vUv);
#include <colorspace_fragment>
return;}vec2 q=vUv*2.-1.;vec4 fh=uContentInverseViewProjection*vec4(q,1.,1.);float fwDen=abs(fh.w)<.0001?(fh.w<0.?-.0001:.0001):fh.w;vec3 fw=fh.xyz/fwDen;vec3 i=normalize(fw-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;vec2 paneUv=gh.xy/uPaneSize+.5;if(any(lessThan(paneUv,vec2(0.)))||any(greaterThan(paneUv,vec2(1.)))){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}vec2 st=vUv*(uResolution/max(uResolution.y,1.)),np=st*9.+uViewOffset*.6,nn=vec2(noise(np),noise(np+vec2(23.7)))-vec2(.5);vec3 r=bend(i,normalize(vec3(nn*.32,1.)));vec3 sh=gh+i*((uSourceZ-gh.z)/safeZ(i.z)),bh=gh+r*((uSourceZ-gh.z)/safeZ(r.z));vec2 ru=vUv+projectUv(bh)-projectUv(sh),center=mix(vUv,ru,clamp(uStrength,0.,1.)),rad=vec2(.008)*clamp(uStrength,0.,1.);vec4 c=vec4(0.);c+=sampleScene(center+vec2(-rad.x,-rad.y));c+=sampleScene(center+vec2(0.,-rad.y));c+=sampleScene(center+vec2(rad.x,-rad.y));c+=sampleScene(center+vec2(-rad.x,0.));c+=sampleScene(center);c+=sampleScene(center+vec2(rad.x,0.));c+=sampleScene(center+vec2(-rad.x,rad.y));c+=sampleScene(center+vec2(0.,rad.y));c+=sampleScene(center+vec2(rad.x,rad.y));c/=9.;c.a=1.;gl_FragColor=c;
#include <colorspace_fragment>
}
+6
View File
@@ -0,0 +1,6 @@
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
+311
View File
@@ -0,0 +1,311 @@
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 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*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);
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
}
+15
View File
@@ -0,0 +1,15 @@
uniform sampler2D uTexture;
uniform bool uIsVideo;
varying vec2 vUv;
vec3 srgbToLinear(vec3 value) {
return mix(value / 12.92, pow((value + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), value));
}
void main() {
vec4 color = texture2D(uTexture, vUv);
if (uIsVideo) color.rgb = srgbToLinear(color.rgb);
color.a = 1.0;
gl_FragColor = color;
#include <colorspace_fragment>
}
+12
View File
@@ -0,0 +1,12 @@
uniform sampler2D uDepthMap;
uniform float uDepthScale;
uniform float uDepthBias;
varying vec2 vUv;
void main() {
vUv = uv;
float depth = texture2D(uDepthMap, uv).r;
vec3 displacedPosition = position;
displacedPosition.z += (depth - uDepthBias) * uDepthScale;
gl_Position = projectionMatrix * modelViewMatrix * vec4(displacedPosition, 1.0);
}
+10
View File
@@ -0,0 +1,10 @@
uniform sampler2D uSceneTexture; uniform mat4 uContentViewProjection; uniform mat4 uContentInverseViewProjection; uniform vec3 uContentCameraPosition,uBackgroundColor; uniform float uGlassZ,uSourceZ,uGridSize,uStrength,uTime; uniform vec2 uViewOffset,uResolution,uPetSize,uPaneSize;
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){if(any(lessThan(uv,vec2(0.)))||any(greaterThan(uv,vec2(1.))))return vec4(.005605,.009721,.016807,1.);return texture2D(uSceneTexture,uv);} float hash(float x){return fract(sin(x*91.7)*43758.5453);} 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;}
void main(){if(uStrength<=.001){gl_FragColor=texture2D(uSceneTexture,vUv);
#include <colorspace_fragment>
return;}vec2 q=vUv*2.-1.;vec4 fh=uContentInverseViewProjection*vec4(q,1.,1.);float fwDen=abs(fh.w)<.0001?(fh.w<0.?-.0001:.0001):fh.w;vec3 fw=fh.xyz/fwDen;vec3 i=normalize(fw-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;vec2 paneUv=gh.xy/uPaneSize+.5;if(any(lessThan(paneUv,vec2(0.)))||any(greaterThan(paneUv,vec2(1.)))){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}float count=max(1.,uGridSize*uPetSize.x/uPetSize.y),coord=vUv.x*count,cell=floor(coord),phase=fract(coord)*6.2831853,ridge=sin(phase+uViewOffset.x*.9),variation=mix(.92,1.08,hash(cell));vec3 r=bend(i,normalize(vec3(-ridge*.34*variation,0.,1.)));vec3 sh=gh+i*((uSourceZ-gh.z)/safeZ(i.z)),bh=gh+r*((uSourceZ-gh.z)/safeZ(r.z));vec2 ru=vUv+projectUv(bh)-projectUv(sh);vec2 uv=mix(vUv,ru,clamp(uStrength,0.,1.));vec4 c=sampleScene(uv);c.a=1.;gl_FragColor=c;
#include <colorspace_fragment>
}
+19
View File
@@ -0,0 +1,19 @@
uniform sampler2D uRegionPalette,uPreviousHistory;
uniform vec2 uRegionAtlasSize,uRegionGridSize,uRegionPaletteSize;
uniform float uHistoryBlend;
varying vec2 vUv;
vec3 paletteColor(vec2 cell,float label){vec2 uv=(vec2(cell.x*5.+label,cell.y)+.5)/uRegionPaletteSize;return texture2D(uRegionPalette,uv).rgb;}
void main(){
vec2 pixel=floor(vUv*uRegionAtlasSize),cell=floor(pixel/8.),local=(mod(pixel,8.)+.5)/8.;
vec2 metadataUv=(vec2(cell.x*5.+4.,cell.y)+.5)/uRegionPaletteSize;
vec4 metadata=texture2D(uRegionPalette,metadataUv);
float packed=floor(metadata.r*255.+.5),axis=floor(packed/8.),cut=packed-axis*8.;
float coordinate=axis<.5?local.x:local.y;
float label=coordinate<cut/8.?floor(metadata.g*255.+.5):floor(metadata.b*255.+.5);
float confidence=clamp(metadata.a,0.,1.);
vec4 current=vec4(paletteColor(cell,label)*confidence,confidence);
if(uHistoryBlend>=.9999){gl_FragColor=current;return;}
vec4 previous=texture2D(uPreviousHistory,vUv);
gl_FragColor=mix(previous,current,clamp(uHistoryBlend,0.,1.));
}
+89
View File
@@ -0,0 +1,89 @@
uniform sampler2D uRegionAtlas;
uniform vec2 uRegionAtlasSize,uRegionGridSize;
varying vec2 vUv;
float regionLuminance(vec3 c){return dot(c,vec3(.299,.587,.114));}
float distanceColor(vec3 a,vec3 b){vec3 d=a-b;float y=dot(d,vec3(.299,.587,.114));return dot(d,d)+.35*y*y;}
float sideColorCost(vec4 votes,vec3 target,vec3 c0,vec3 c1,vec3 c2,vec3 c3){return votes.x*distanceColor(c0,target)+votes.y*distanceColor(c1,target)+votes.z*distanceColor(c2,target)+votes.w*distanceColor(c3,target);}
vec4 atlasSampleData(vec2 cell,vec2 local){vec2 uv=(cell*8.+local+.5)/uRegionAtlasSize;return texture2D(uRegionAtlas,uv);}
vec3 atlasSample(vec2 cell,vec2 local){return atlasSampleData(cell,local).rgb;}
float nearestLabel(vec3 color,vec3 c0,vec3 c1,vec3 c2,vec3 c3){float d0=distanceColor(color,c0),d1=distanceColor(color,c1),d2=distanceColor(color,c2),d3=distanceColor(color,c3);if(d0<=d1&&d0<=d2&&d0<=d3)return 0.;if(d1<=d2&&d1<=d3)return 1.;if(d2<=d3)return 2.;return 3.;}
float majorityLabel(vec4 votes){float label=0.,count=votes.x;if(votes.y>count){label=1.;count=votes.y;}if(votes.z>count){label=2.;count=votes.z;}if(votes.w>count)label=3.;return label;}
void main(){
vec2 pixel=floor(gl_FragCoord.xy);float paletteIndex=mod(pixel.x,5.);vec3 cell=vec3(floor(pixel.x/5.),pixel.y,0.);
vec3 darkest=vec3(1.),lightest=vec3(0.);float minLum=2.,maxLum=-1.;
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec3 c=atlasSample(cell.xy,vec2(float(x),float(y)));float l=regionLuminance(c);
if(l<minLum){minLum=l;darkest=c;}if(l>maxLum){maxLum=l;lightest=c;}
}
vec3 third=darkest;float thirdDistance=-1.;
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec3 c=atlasSample(cell.xy,vec2(float(x),float(y)));float d=min(distanceColor(c,darkest),distanceColor(c,lightest));
if(d>thirdDistance){thirdDistance=d;third=c;}
}
vec3 fourth=darkest;float fourthDistance=-1.;
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec3 c=atlasSample(cell.xy,vec2(float(x),float(y)));float d=min(distanceColor(c,darkest),min(distanceColor(c,lightest),distanceColor(c,third)));
if(d>fourthDistance){fourthDistance=d;fourth=c;}
}
vec3 c0=darkest,c1=lightest,c2=third,c3=fourth;
for(int iteration=0;iteration<2;iteration++){
vec3 sum0=vec3(0.),sum1=vec3(0.),sum2=vec3(0.),sum3=vec3(0.);float n0=0.,n1=0.,n2=0.,n3=0.;
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec3 c=atlasSample(cell.xy,vec2(float(x),float(y)));
float d0=distanceColor(c,c0),d1=distanceColor(c,c1),d2=distanceColor(c,c2),d3=distanceColor(c,c3);
if(d0<=d1&&d0<=d2&&d0<=d3){sum0+=c;n0+=1.;}
else if(d1<=d2&&d1<=d3){sum1+=c;n1+=1.;}
else if(d2<=d3){sum2+=c;n2+=1.;}
else{sum3+=c;n3+=1.;}
}
if(n0>0.)c0=sum0/n0;if(n1>0.)c1=sum1/n1;if(n2>0.)c2=sum2/n2;if(n3>0.)c3=sum3/n3;
}
vec4 metadata=vec4(0.);
if(paletteIndex>3.5){
vec4 wholeVotes=vec4(0.);float cellEvidence=0.;
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec4 sampleData=atlasSampleData(cell.xy,vec2(float(x),float(y)));vec3 c=sampleData.rgb;float label=nearestLabel(c,c0,c1,c2,c3);
wholeVotes+=vec4(step(abs(label-vec4(0.,1.,2.,3.)),vec4(.1)));
cellEvidence+=sampleData.a;
}
float singleMajority=max(max(wholeVotes.x,wholeVotes.y),max(wholeVotes.z,wholeVotes.w))/64.;
float bestCost=1e20,bestConfidence=0.,bestAxis=0.,bestCut=4.,bestA=0.,bestB=0.;
for(int axis=0;axis<2;axis++)for(int candidateIndex=0;candidateIndex<5;candidateIndex++){
float cut=4.;
if(candidateIndex==1)cut=3.;else if(candidateIndex==2)cut=5.;else if(candidateIndex==3)cut=2.;else if(candidateIndex==4)cut=6.;
vec4 votesA=vec4(0.),votesB=vec4(0.),weightedVotesA=vec4(0.),weightedVotesB=vec4(0.);
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec4 sampleData=atlasSampleData(cell.xy,vec2(float(x),float(y)));vec3 c=sampleData.rgb;float label=nearestLabel(c,c0,c1,c2,c3);
vec4 oneHot=vec4(step(abs(label-vec4(0.,1.,2.,3.)),vec4(.1)));float sampleWeight=1.+3.*sampleData.a;
float coordinate=axis==0?float(x)+.5:float(y)+.5;
if(coordinate<cut){votesA+=oneHot;weightedVotesA+=oneHot*sampleWeight;}
else{votesB+=oneHot;weightedVotesB+=oneHot*sampleWeight;}
}
float labelA=0.,labelB=0.,pairCost=1e20,pairVotes=0.;
for(int a=0;a<4;a++)for(int b=0;b<4;b++){
if(a!=b&&votesA[a]>0.&&votesB[b]>0.){
vec3 colorA=a==0?c0:(a==1?c1:(a==2?c2:c3));
vec3 colorB=b==0?c0:(b==1?c1:(b==2?c2:c3));
float candidateCost=sideColorCost(weightedVotesA,colorA,c0,c1,c2,c3)+sideColorCost(weightedVotesB,colorB,c0,c1,c2,c3);
if(candidateCost<pairCost){pairCost=candidateCost;labelA=float(a);labelB=float(b);pairVotes=votesA[a]+votesB[b];}
}
}
if(pairCost<1e20){
float accuracy=pairVotes/64.,gain=accuracy-singleMajority;
float oldConfidence=smoothstep(.55,.75,accuracy)*smoothstep(.015625,.078125,gain);
float confidence=.85+.15*oldConfidence;
confidence=mix(confidence,1.,smoothstep(.02,.06,cellEvidence/64.));
if(pairCost<bestCost){bestCost=pairCost;bestConfidence=confidence;bestAxis=float(axis);bestCut=cut;bestA=labelA;bestB=labelB;}
}
}
metadata=vec4((bestAxis*8.+bestCut)/255.,bestA/255.,bestB/255.,bestConfidence);
}
vec3 result=c0;
if(paletteIndex>.5&&paletteIndex<1.5)result=c1;
else if(paletteIndex>1.5&&paletteIndex<2.5)result=c2;
else if(paletteIndex>2.5&&paletteIndex<3.5)result=c3;
gl_FragColor=paletteIndex>3.5?metadata:vec4(result,1.);
}
+5
View File
@@ -0,0 +1,5 @@
*{box-sizing:border-box}body{margin:0;background:#0d1117;color:#e6edf3;font:14px system-ui,sans-serif}main{min-height:100vh;display:flex;flex-direction:column}.stage{position:relative;flex:1;min-height:60vh;background:#111923}#canvas-wrap{position:absolute;inset:0;touch-action:none}canvas{width:100%;height:100%;display:block;cursor:grab}canvas.is-dragging{cursor:grabbing}#empty{position:absolute;inset:0;display:grid;place-items:center;color:#82909f;pointer-events:none;font-size:1rem}#hint{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);margin:0;padding:8px 12px;border-radius:20px;background:#0008;color:#c8d1dc;transition:opacity .5s}aside{padding:18px;display:grid;gap:12px;background:#161d27;border-top:1px solid #293544;min-width:0;overflow:hidden}h1{font-size:1.1rem;margin:0 0 4px}label{display:grid;gap:5px;color:#aeb9c6}input,select,button{font:inherit;color:inherit;background:#202b38;border:1px solid #3b4a5c;border-radius:7px;padding:9px}#test-photo{min-width:0;width:100%;max-width:100%}input[type=range]{padding:0;accent-color:#72b6ff}.upload{position:relative;cursor:pointer;text-align:center;background:#25364a;border-radius:7px;padding:10px;color:#dcecff}.upload input[type=file]{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);clip-path:inset(50%);white-space:nowrap;border:0}.upload:focus-within{outline:2px solid #8bc5ff;outline-offset:2px}output{float:right;color:#8bc5ff}button{cursor:pointer}#status{min-height:20px;margin:0;color:#ffbe79}#depth-provenance{margin:0;color:#aeb9c6;font-size:12px}#depth-debug{max-width:100%;overflow-x:auto;white-space:pre-wrap;word-break:break-word;font-size:11px}@media(min-width:720px){main{height:100dvh;min-height:0;flex-direction:row}.stage{min-height:0}aside{width:280px;flex:0 0 280px;align-content:start;border-top:0;border-left:1px solid #293544;overflow-y:auto}}
.visual-column{display:flex;flex:1;flex-direction:column;min-width:0;min-height:0}.reference-strip{position:relative;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;width:100%;height:200px;flex:0 0 200px;padding:10px 12px;background:#0d1117;border-bottom:1px solid #293544;overflow:hidden}.reference-strip[hidden]{display:none}.reference-strip figure{display:grid;grid-template-rows:minmax(0,1fr) auto;gap:5px;min-width:0;min-height:0;margin:0;padding:6px;border:1px solid #293544;border-radius:8px;background:#111923}.reference-strip img{display:block;width:100%;height:100%;min-width:0;min-height:0;object-fit:contain}.reference-strip figcaption{text-align:center;color:#aeb9c6;font-size:12px}#reference-status{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);margin:0;padding:5px 8px;border-radius:6px;background:#000b;color:#ffbe79;font-size:12px;white-space:nowrap}#reference-status[hidden]{display:none}@media(min-width:720px){.visual-column{min-height:0}.reference-strip{height:clamp(180px,30vh,300px);flex-basis:clamp(180px,30vh,300px)}}
details{border:1px solid #344255;border-radius:8px;padding:10px 12px;background:#111923}details summary{cursor:pointer;color:#dce6f2;font-weight:600}details[open] summary{margin-bottom:8px}.details-content{display:grid;gap:12px;padding-top:4px}.control-note,.control-help{margin:0;color:#9eacbb;font-size:12px}.control-help{margin-top:-7px}
.core-optics{gap:12px}
+10
View File
@@ -0,0 +1,10 @@
import * as THREE from 'three';
export interface DepthProvider {
getDepthMap(image: HTMLImageElement): Promise<THREE.Texture>;
}
export interface DepthSettings {
scale: number;
bias: number;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />