Replace project with current server release and Windows setup

Import the 2026-10-09 source release, Fastify/SQLite backend, restored source
assets, and analytics-enabled server build. Replace files from the previous
release while retaining the existing repository history.

Make builds portable across Windows and Linux, configure local startup,
normalize Windows static asset paths, and fix test fixture cleanup and file
URL handling. Exclude local environment settings, databases, dependencies,
validation output, and TypeScript build cache from version control.

Validation: npm ci, static and server builds, 106 arrival tests, and actual
local HTTP checks passed. The full suite passed 467 of 470 tests; three tests
still require QA fixtures omitted from the supplied release, documented in
README-서버실행.md.
This commit is contained in:
2026-10-09 16:43:35 +09:00
parent 71c8d3b7ff
commit e358e24436
642 changed files with 35202 additions and 17642 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 804 KiB

-34
View File
@@ -1,34 +0,0 @@
# 사진용 AI Depth 도입
2026-09-14 사용자 승인: Depth Anything V2 Small의 브라우저 로컬 추론 적용 검토 후 “그럼 해봐”.
## 범위
- 사진을 선택하고 `AI 깊이 생성`을 눌러 정적 depth를 한 번 생성한다.
- Depth Anything V2 Small의 Apache 2.0 모델과 Transformers.js용 ONNX 변환본을 사용한다. 사진은 서버에 업로드하지 않는다.
- 모델은 기능을 처음 사용할 때 로드한다. 로딩·추론·실패 상태를 안내한다.
- 기존 DepthProvider → PetDepthMesh → RenderTarget → Glass 경로를 유지한다.
- 기존 수작업 샘플, 수동 depth 입력, 영상, Mosaic/Reeded/Frosted를 보존한다.
- AI 결과는 `AI 추정`으로 표시한다. 기존 수작업 depth와 실측 깊이를 구분한다.
- AI 결과 적용 후 No Glass로 전환하여 시차를 먼저 확인한다.
## 구현·검증 기준
- 내부 depth는 0=뒤, 1=앞. 원본 RGB와 depth의 방향·비율·구도를 일치시킨다.
- 사진·영상·수동 depth 변경 및 종료 뒤 오래된 추론 결과가 적용되지 않게 한다.
- 추론 실패 시 기존 depth를 유지하고 다시 시도할 수 있다.
- 같은 샘플/구도에서 No Glass의 코·눈·얼굴 시차를 확인한 다음 Final의 Mosaic를 확인한다.
- 빌드·타입 검사와 실제 모델 실행·시각 검증 결과를 구분해 기록한다.
- 모바일의 다운로드·추론 시간·메모리는 실제로 측정하기 전에는 보장하지 않는다.
## 이번 범위 외
실시간 영상 depth, 사진 리라이팅, 완전한 3D 복원, 유리 셰이더 전면 교체, 서버 추론은 포함하지 않는다.
## 확인한 배포 자료
- https://github.com/DepthAnything/Depth-Anything-V2#license
- https://huggingface.co/onnx-community/depth-anything-v2-small-ONNX
- https://huggingface.co/docs/transformers.js/guides/webgpu
Small만 상업 이용 가능한 Apache 2.0 조건으로 검토했으며 배포 시 라이선스 및 관련 고지를 보존한다.
-38
View File
@@ -1,38 +0,0 @@
# AI depth for photos
Choose a photo, then select **AI 깊이 생성**. The app snapshots the decoded photo, sends the snapshot to a module worker, and runs Depth Anything V2 Small through Transformers.js. WebGPU is tried first; the worker retries with WASM if WebGPU is unavailable or fails. The first run downloads the model files from Hugging Face. Inference itself runs in the browser, and the photo pixels are not sent to the model host.
For the 2026-10-06 comparison, **개발 비교 → AI 깊이 모델** can select V2 Base, then **AI 깊이 생성** recomputes the same photo at input 770. Small remains the default; Base is a noncommercial test option. Switching the model keeps the current depth until inference succeeds and does not change zoom, pan, glass settings or photo–glass distance. Only one estimator is kept in the worker at a time.
The same-photo Small/Base values, maximum masks and distance checks are recorded in [the Base QA report](qa-ai-depth-base-2026-10-06/report.md). Both models' maxima were in the nose region; Base did not establish a dramatic improvement of facial geometry.
**최대 깊이 영역 확인** overlays the normalized Float32 maximum (`1`, pink), byte-texture maximum (`255/255`, cyan), and near-maximum (`≥0.99`, yellow) on the original photo. Counts and the first maximum's image coordinates are displayed. Clicking the preview or entering pixel coordinates shows both normalized model and byte-rendering values. The Float32 map is retained solely for inspection; the geometry still uses the existing byte texture and 128×128 mesh. A normalized maximum is guaranteed for any nonconstant finite prediction and is not evidence of correct anatomical depth. Byte rounding can create a larger `depth=1` area.
Distance stays anchored to the **whole-map maximum**, without a semantic nose detector: `gap(pixel) = photoGlassGap + (maxDepth - byteDepth(pixel)) * depthScale`. The diagnostic gap is the map-point value in scene units, not cm or an exact measure of the interpolated mesh triangle. The maximum surface's gap is 0.04–0.60. Other points retain their depth differences and the RGB/Depth transform and refraction base plane move together.
The source image is resized proportionally so its longest edge is at most 1024 pixels. Transformers.js preserves the image aspect ratio when preparing its model input and resizes the prediction to that same snapshot. The relative depth output is normalized per image to grayscale bytes: **0 is far/back and 255 is near/front**. The shader samples those bytes as normalized red-channel values from 0 to 1. Depth Anything V2 predicts relative inverse depth, so larger raw prediction values map to nearer points. This is an estimate, not measured distance or a complete 3D reconstruction.
As of 2026-10-06, the processor's nominal input size is **770**, configured by `AI_DEPTH.inputSize` in `src/constants/depth.ts`. The shared estimator loader applies it to both WebGPU and WASM. Aspect ratio and alignment to multiples of 14 still determine the actual tensor dimensions; the final map remains the snapshot's size. The UI displays `입력 770`. Browser verification and the 518 comparison are recorded in [the 770 QA report](qa-ai-depth-770-2026-10-06/report.md).
AI depth can be generated for still photos only. While the photo is changing, a manual depth map is loading, or inference is running, the button is disabled. A source change cancels the worker request. A successful result replaces the active depth and switches to **Final**; choose **No Glass** for parallax review. A failure leaves the current depth in place and the button can be used to retry.
## Model and license
- Model: [`onnx-community/depth-anything-v2-small-ONNX`](https://huggingface.co/onnx-community/depth-anything-v2-small-ONNX), Apache-2.0.
- Optional test model: [`onnx-community/depth-anything-v2-base-ONNX`](https://huggingface.co/onnx-community/depth-anything-v2-base-ONNX), CC-BY-NC-4.0.
- Runtime: [`@huggingface/transformers` 4.3.0](https://github.com/huggingface/transformers.js), Apache-2.0.
- The model follows [Depth Anything V2](https://github.com/DepthAnything/Depth-Anything-V2); the Small model is distributed under Apache-2.0 in the upstream project.
The AI depth provenance label names the model. The deployed [third-party notice](../public/THIRD-PARTY-NOTICES.txt) and [Apache 2.0 license text](../public/licenses/Apache-2.0.txt) are copied into `dist/` by Vite. Model weights are cached by the browser runtime when available.
## Browser smoke test
Start the app on a local URL, then run `npm run ai-depth-smoke`. The script loads the supplied sample, performs real model inference, records the normalized worker result at the sample's nose, eye, face, ear, and body landmarks, captures No Glass before and after a drag, captures Final after the drag, checks 390-pixel horizontal overflow, captures a mobile screenshot, and switches photos during a second request to check stale-result cancellation. It records browser console/page/request errors alongside the results.
Set `AI_DEPTH_BASE_URL` if Vite is running on a different local address. Set `CHROME_EXECUTABLE_PATH` or `PLAYWRIGHT_CHROMIUM_PATH` when using a specific installed Chrome/Chromium executable; without either variable, Playwright's default executable is used. The script does not hard-code a machine-specific browser path. Output is written to `test-results/ai-depth/`.
## Validation
On 2026-09-14, headless Chrome with WebGPU completed first-use model download and inference in 11.1 seconds. No Glass showed a recognizable dog face; Final Mosaic and Reeded/Frosted rendered without page errors. The captured normalized landmark depths were nose 0.722, left eye 0.694, right eye 0.651, ears 0.667/0.596, and body 0.671. These facial/object differences are subtle compared with the deliberately exaggerated hand-authored test map. The 390-pixel layout had no horizontal overflow, manual depth input remained usable, and switching photos during a second request prevented the stale AI result from applying.
The browser console still reported ONNX CPU-assignment warnings and the existing missing-favicon 404; the page-error count was zero. Actual video playback, WASM-only runtime, and inference performance on a physical mobile device were not measured.
+119
View File
@@ -0,0 +1,119 @@
# 방문·도착 집계 운영 및 이전 안내
이 안내는 Fastify + SQLite 서버 운영 절차다. 이번 ZIP에는 현재 소스와 집계 활성 서버용 빌드가 포함되어 있다. 실행 방법과 검증 범위는 루트의 README-서버실행.md를 확인한다. 사용자의 서버에 실제 배포한 기록은 아니다.
## 데이터와 집계 기준
- 방문은 브라우저 UUID와 서버가 계산한 한국 날짜(Asia/Seoul)당 한 행이다. MAU는 해당 한국 달력 월의 서로 다른 UUID 수이며 일별 합계가 아니다. 페이지 진입 때 기록하고 열린 탭을 자정마다 새 방문으로 만들지 않는다.
- 도착은 현재 개인 사진의 유효한 AI 성공 때만 기록한다. 샘플·영상·직접 올린 깊이·실패·평면 보기는 제외한다. 샘플 방문도 MAU에는 남는다.
- 이름은 브라우저에서 NFC/공백 정리/20 Unicode code point 제한 후 사용하고 빈 이름만 `멍멍이`로 취급한다. UUID와 이름을 사진 시작 때 고정한 뒤 버전이 포함된 JSON 배열을 SHA-256으로 해시한다. 서버에는 방문 UUID 또는 도착 해시만 전송하며 사진·파일명·이름 원문을 보내지 않는다.
- 같은 UUID·같은 이름의 도착은 중복되지 않는다. 이름 변경·저장소 초기화·다른 기기는 추가 집계될 수 있다. 실제 사람이나 반려견의 정확한 고유 수가 아니다. 해시를 완전한 익명화로 보지 않는다.
- 요청은 3초 제한, 일시 장애·429·5xx만 최대 두 번 재시도한다. 페이지 종료나 오프라인으로 유실될 수 있으며 영구 오프라인 큐는 없다. 서버 장애가 사진 체험을 막지 않는다.
## 하나의 프런트엔드 소스와 두 가지 빌드
사진 체험·화면·기능은 같은 `src` 소스를 사용한다. 기능 수정은 한 번만 하고, 정적 Site와 Fastify 서버용으로 각각 빌드한다. 두 개의 프런트엔드 복사본이나 별도 기능 브랜치를 장기 운영하지 않는다. 빌드 차이는 집계 활성 여부이며, 한 곳에 배포했다고 다른 곳에 자동 배포되는 것은 아니다. 이번 배포본은 현재 테스트 Site와 같은 소스 커밋에서 서버용으로 빌드했다.
소스 및 `package-lock.json`을 함께 옮긴다. 지원 대상은 Node 24이며 `.node-version`의 검증된 패치를 사용한다. 대상 서버 OS/CPU에서 better-sqlite3 네이티브 모듈이 실제 로드되는지 먼저 확인한다. 이 작업에서 만든 `node_modules`를 다른 서버로 복사하지 않는다.
```sh
npm ci
node -p "process.version + ' ' + process.platform + ' ' + process.arch"
node --input-type=module -e "import Database from 'better-sqlite3'; const db = new Database(':memory:'); console.log(db.prepare('select sqlite_version() as version').get()); db.close()"
npm run test:arrival
```
기존 정적 Site용 기본 빌드는 집계를 끈다. 집계 UUID 생성·저장소 읽기와 API 요청이 없다.
```sh
npm run build
```
Fastify 서버와 함께 사용할 때만 집계 활성 빌드를 만든다.
```sh
npm run build:server
```
두 명령 모두 빌드 출력은 `dist`다. 기존 Site에 집계 활성 결과물을 덮어 올리지 않는다. 빌드/타입 확인과 실제 브라우저·휴대폰 확인은 다르며 이후 배포 단계에서 별도로 검사한다.
## 서버 환경과 데이터 위치
- `DB_PATH`: 반드시 명시한 절대경로. 소스·dist·public 밖의 영구 로컬 디스크에 둔다. 예: `/var/lib/behind-glass/analytics.sqlite`.
- `PORT`: 기본 3000.
- `HOST`: 기본 `127.0.0.1`. 외부 공개와 네트워크 설정 변경은 배포 승인 뒤 진행한다.
- `PUBLIC_ORIGIN`: 운영 환경에서 필수. 경로·쿼리·계정정보 없는 HTTP(S) origin. 예: `https://your-approved-host.example`.
- `NODE_ENV=production`: 운영 Origin 필수 검사를 사용한다.
설치 경로·데이터 경로와 서비스 계정은 미래 서버에서 확정한다. 데이터 폴더와 파일은 해당 서비스 계정만 사용할 수 있게 하며, 디렉터리 0700/DB 0600 수준을 유지한다. 기존 권한을 넓히지 않는다. DB 본체와 WAL/SHM, 백업을 공개 정적 경로나 소스/빌드에 넣지 않는다.
```sh
DB_PATH=/var/lib/behind-glass/analytics.sqlite \
PUBLIC_ORIGIN=https://your-approved-host.example \
NODE_ENV=production npm start
```
한 프로세스·한 SQLite 연결·한 서버 로컬 디스크를 전제로 한다. 클러스터 워커나 여러 컨테이너 복제본, 공유 네트워크 파일시스템으로 확장하지 않는다. 시작 시 버전 마이그레이션을 트랜잭션으로 적용하고 WAL과 제한된 busy timeout을 사용한다. SIGTERM/SIGINT로 정상 종료한 후 서비스 관리자에서 재시작한다. 새 릴리스로 바꾸거나 빌드 출력을 지워도 `DB_PATH`는 그대로 유지한다.
## 공개 API와 운영 조회
공개 API는 다음 세 가지뿐이다. 관리자·식별자 목록·MAU HTTP 경로는 없다.
- `POST /api/visits` JSON `{ "visitor_id": "UUID-v4" }` → 204
- `POST /api/arrivals` JSON `{ "dog_id": "64자리 소문자 SHA-256" }` → 204 (신규와 중복 동일)
- `GET /api/arrivals/count` → `{ "count": 0 }` 같은 실제 누적 정수
1 KiB 본문 제한과 엄격한 형식·추가 필드 거부를 적용한다. 없는 API는 JSON 404다. 공개 화면의 숫자는 마지막 정상값을 유지하며 아직 성공한 조회가 없으면 숨긴다.
MAU는 서버 터미널에서만 조회한다. 기존 DB를 읽기 전용으로 열며 오타 경로에 빈 DB를 만들지 않는다.
```sh
DB_PATH=/var/lib/behind-glass/analytics.sqlite npm run --silent metrics -- --month 2026-10
```
`--silent`는 npm의 실행 안내를 숨겨 JSON만 출력한다. 명령 결과는 월·MAU·누적 도착 합계뿐이다. 월은 한국 달력 기준 `YYYY-MM`이다. 방문 UUID·도착 해시를 출력하거나 운영 로그에 기록하지 않는다.
## 일관된 백업과 복원
운영 중에는 SQLite backup API를 사용한다. WAL 모드에서 DB 본체 파일 하나만 복사하면 최신 기록을 잃을 수 있다. 백업 경로도 공개 루트 밖의 절대경로로 지정한다. 같은 경로와 기존 파일 덮어쓰기를 허용하지 않는다.
```sh
DB_PATH=/var/lib/behind-glass/analytics.sqlite npm run --silent backup -- \
--output /var/backups/behind-glass/analytics-2026-10-08.sqlite
```
복원 절차:
1. 해당 DB를 사용하는 서버와 운영 명령을 모두 종료한다. 자동 재시작도 잠시 멈춘다.
2. 현재 DB/WAL/SHM을 덮어쓰지 말고 안전한 별도 보관 위치로 옮겨 복구 지점을 남긴다.
3. 검증할 백업을 새로운 비공개 데이터 경로로 복사하고 서비스 계정 소유·제한된 권한을 유지한다. 과거 WAL/SHM 파일과 섞지 않는다.
4. 다음처럼 복원된 파일의 무결성과 합계를 확인한다. 결과가 맞지 않으면 재시작하지 않는다.
5. `DB_PATH`를 검증된 복원 파일로 지정해 재시작한다. 집계/체험 동작을 확인한다.
```sh
DB_PATH=/var/lib/behind-glass-restored/analytics.sqlite node --input-type=module -e \
"import Database from 'better-sqlite3'; const db = new Database(process.env.DB_PATH, {readonly:true,fileMustExist:true}); console.log(db.pragma('integrity_check')); db.close()"
DB_PATH=/var/lib/behind-glass-restored/analytics.sqlite npm run --silent metrics -- --month 2026-10
```
`integrity_check`가 `ok`인지 확인하고 백업 당시 합계와 비교한다. 백업·복원 성공은 QA에서 실제 임시 SQLite 파일을 사용해 확인한 범위와 미래 운영 데이터 복원을 구분한다. 정기 백업 스케줄과 보존 정책은 배포 단계에서 별도 결정한다.
## 향후 HTTPS 프록시와 정적 파일 점검
이번 구현은 프록시나 방화벽을 설정하지 않는다. 배포 승인 뒤 같은 HTTPS origin에서 정적 앱과 `/api`가 함께 동작하도록 연결한다. `PUBLIC_ORIGIN`에는 사용자가 실제 접속하는 정확한 origin을 넣는다.
Origin 검사와 메모리 기반 요청 제한은 허위 집계를 완전히 막는 인증 기능이 아니다. Origin 없는 비브라우저 요청도 가능하다. 제한 카운터는 프로세스 재시작 시 초기화한다. `trustProxy:false`를 유지하므로 프록시 뒤에서는 연결 IP를 공유해 제한을 함께 받을 수 있다. 임의 forwarded header를 신뢰하도록 즉석 변경하지 말고 배포 구조와 신뢰 경계를 검토한다.
앱은 본문·UUID·해시·IP를 로그에 남기지 않는다. 프록시·호스팅의 기본 접속 로그에는 IP가 남을 수 있으므로 배포 전에 기록 범위·보존·접근 권한을 확인한다.
HTML/API는 오래 캐시하지 않고, 해시 자산은 immutable 장기 캐시, 일반 정적 자산은 재검증한다. AI worker·모델·외부 ONNX WASM 다운로드 경로는 기존 설정을 유지한다. 별도 분석 서비스나 사진 업로드를 추가하지 않는다. CSP/COEP/COOP를 일괄 추가하면 기존 외부 AI 자산이 차단될 수 있으므로 이번 작업에서 변경하지 않는다.
## 배포 전 남은 실제 확인
- 미래 서버의 Node/네이티브 의존성 설치, 데이터 권한·영구 저장·프로세스 재시작
- 승인된 HTTPS 주소에서 실제 API와 정적 파일, 외부 모델/WASM 로딩
- 실제 휴대폰/WebGL, 사진 선택·성공·실패·평면 보기·취소·이름 수정·샘플 흐름
- 도착 숫자 표시와 스크린리더/IME 등 브라우저 상호작용
- 프록시의 Origin 전달·캐시·요청 제한과 로그 정책
현재 클라우드 작업 공간을 상시 운영 서버로 약속하지 않으며, 실제 접속 주소는 배포가 확인될 때 별도로 제공한다.
-13
View File
@@ -1,13 +0,0 @@
# 비대칭 곡면·창 반사 보정
2026-09-11. Astra 설계, Luna 코드, 조정 에이전트 시각 검증.
src/shaders/mosaic.frag의 높이/법선과 광택 두 블록만 수정. 기존 world pane/grid/RT/샘플링/UI 유지.
- H=A(1-qx^4)(1-qy^4)(1+skew·q). 중심을 더 평탄하게 하고 고정 hash 기반 작은 비대칭을 적용. 높이 .009cell과 ±8% 편차로 과도한 테두리 경사 억제. 미분 법선은 굴절과 반사 공유.
- 반사 시선을 기존 조명 Z 평면에 교차시켜 부드러운 직사각형 광원 mask 계산. 반사 경계가 가는 격자로 보이는 초기 결과를 보고 smoothstep 범위 .78..1을 .45..1.15로 넓힘. 최대 흰색 혼합 gain .12 유지.
- 신규 API/자산/리라이팅 없음. README/AGENTS 갱신. 이전 shader는 docs/before-asymmetric-glass/에 보존.
검증: Glass Only 조명XY0 전후 비교에서 둥근 반복 하이라이트가 직사각형 반사로 바뀜. 기본 샘플 Final에서 눈/코 정보 유지. 조명X +.6→-.6 및 실제 카메라 드래그 후 반사 영역 변화, 광택0에서도 곡면 굴절 보존 확인. 390x844 모바일 샘플 확인, override 복원. 최종 브라우저 error 로그 []. Luna npm.cmd run build 성공.
한계: 셀 경계 반복이 완전히 사라지는 것은 아님. 단일 직사각형 광원 근사이며 환경맵/다중반사/실제 유리 두께는 없음. 이 변경에서 영상/실기기gyro/성능 측정은 재실행하지 않음.
-62
View File
@@ -1,62 +0,0 @@
uniform sampler2D uTexture;
uniform float uGridSize;
uniform float uStrength;
uniform vec2 uViewOffset;
uniform vec2 uResolution;
uniform float uTime;
uniform vec2 uPetSize;
uniform bool uIsVideo;
varying vec2 vUv;
varying vec3 vWorldPosition;
float hash(vec2 p) { return fract(sin(dot(p, vec2(41.3, 17.9))) * 15731.743); }
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0)), f.x), f.y);
}
vec3 srgbToLinear(vec3 value) {
return mix(value / 12.92, pow((value + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), value));
}
vec4 mediaSample(vec2 uv) {
vec3 background = srgbToLinear(vec3(17.0, 25.0, 35.0) / 255.0);
if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) return vec4(background, 1.0);
vec4 color = texture2D(uTexture, uv);
if (uIsVideo) color.rgb = srgbToLinear(color.rgb);
return color;
}
vec2 hitUv(vec3 ray) {
float rayZ = abs(ray.z) < 0.0001 ? (ray.z < 0.0 ? -0.0001 : 0.0001) : ray.z;
float signedT = -vWorldPosition.z / rayZ;
return (vWorldPosition + ray * signedT).xy / uPetSize + 0.5;
}
void main() {
vec2 stableUv = vUv * (uResolution / max(uResolution.y, 1.0));
vec2 noisePos = stableUv * 9.0 + uViewOffset * 0.6;
vec2 randomNormal = vec2(noise(noisePos), noise(noisePos + vec2(23.7))) - vec2(0.5);
vec3 incident = normalize(vWorldPosition - cameraPosition);
vec3 normal = normalize(vec3(randomNormal * 0.32, 1.0));
vec3 refracted = refract(incident, normal, 0.75);
vec2 centerUv = mix(hitUv(incident), hitUv(refracted), clamp(uStrength, 0.0, 1.0));
vec2 radius = vec2(0.008) * clamp(uStrength, 0.0, 1.0);
vec4 color = vec4(0.0);
color += mediaSample(centerUv + vec2(-radius.x, -radius.y));
color += mediaSample(centerUv + vec2(0.0, -radius.y));
color += mediaSample(centerUv + vec2(radius.x, -radius.y));
color += mediaSample(centerUv + vec2(-radius.x, 0.0));
color += mediaSample(centerUv);
color += mediaSample(centerUv + vec2(radius.x, 0.0));
color += mediaSample(centerUv + vec2(-radius.x, radius.y));
color += mediaSample(centerUv + vec2(0.0, radius.y));
color += mediaSample(centerUv + vec2(radius.x, radius.y));
color /= 9.0;
color.a = 1.0;
gl_FragColor = color;
#include <colorspace_fragment>
}
-56
View File
@@ -1,56 +0,0 @@
uniform sampler2D uTexture;
uniform float uGridSize;
uniform float uStrength;
uniform vec2 uViewOffset;
uniform vec2 uResolution;
uniform float uTime;
uniform vec2 uPetSize;
uniform bool uIsVideo;
varying vec2 vUv;
varying vec3 vWorldPosition;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
vec3 srgbToLinear(vec3 value) {
return mix(value / 12.92, pow((value + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), value));
}
vec4 mediaSample(vec2 uv) {
vec3 background = srgbToLinear(vec3(17.0, 25.0, 35.0) / 255.0);
if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) return vec4(background, 1.0);
vec4 color = texture2D(uTexture, uv);
if (uIsVideo) color.rgb = srgbToLinear(color.rgb);
return color;
}
vec2 hitUv(vec3 ray) {
float rayZ = ray.z;
rayZ = abs(rayZ) < 0.0001 ? (rayZ < 0.0 ? -0.0001 : 0.0001) : rayZ;
float signedT = -vWorldPosition.z / rayZ;
return (vWorldPosition + ray * signedT).xy / uPetSize + 0.5;
}
void main() {
vec2 grid = vec2(uGridSize, max(1.0, uGridSize * uPetSize.y / uPetSize.x));
vec2 scaled = vUv * grid;
vec2 cell = floor(scaled);
vec2 local = fract(scaled) - 0.5;
float variation = mix(0.85, 1.15, hash(cell));
vec2 lensLocal = local + uViewOffset * (0.12 + 0.08 * hash(cell + vec2(9.0)));
float bulge = max(0.0, 0.25 - dot(lensLocal, lensLocal));
vec3 normal = normalize(vec3(-lensLocal * bulge * 1.8 * variation, 1.0));
vec3 incident = normalize(vWorldPosition - cameraPosition);
vec3 refracted = refract(incident, normal, 0.75);
vec2 straightUv = hitUv(incident);
vec2 refractedUv = hitUv(refracted);
vec2 uv = mix(straightUv, refractedUv, clamp(uStrength, 0.0, 1.0));
vec4 color = mediaSample(uv);
float edge = smoothstep(0.46, 0.5, max(abs(local.x), abs(local.y)));
color.rgb *= mix(1.0, 0.75, edge * clamp(uStrength, 0.0, 1.0));
color.a = 1.0;
gl_FragColor = color;
#include <colorspace_fragment>
}
-48
View File
@@ -1,48 +0,0 @@
uniform sampler2D uTexture;
uniform float uGridSize;
uniform float uStrength;
uniform vec2 uViewOffset;
uniform vec2 uResolution;
uniform float uTime;
uniform vec2 uPetSize;
uniform bool uIsVideo;
varying vec2 vUv;
varying vec3 vWorldPosition;
float hash(float value) { return fract(sin(value * 91.7) * 43758.5453); }
vec3 srgbToLinear(vec3 value) {
return mix(value / 12.92, pow((value + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), value));
}
vec4 mediaSample(vec2 uv) {
vec3 background = srgbToLinear(vec3(17.0, 25.0, 35.0) / 255.0);
if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) return vec4(background, 1.0);
vec4 color = texture2D(uTexture, uv);
if (uIsVideo) color.rgb = srgbToLinear(color.rgb);
return color;
}
vec2 hitUv(vec3 ray) {
float rayZ = abs(ray.z) < 0.0001 ? (ray.z < 0.0 ? -0.0001 : 0.0001) : ray.z;
float signedT = -vWorldPosition.z / rayZ;
return (vWorldPosition + ray * signedT).xy / uPetSize + 0.5;
}
void main() {
float ridgeCount = max(1.0, uGridSize * uPetSize.x / uPetSize.y);
float ridgeCoordinate = vUv.x * ridgeCount;
float ridgeCell = floor(ridgeCoordinate);
float phase = fract(ridgeCoordinate) * 6.2831853;
float ridge = sin(phase + uViewOffset.x * 0.9);
float ridgeVariation = mix(0.92, 1.08, hash(ridgeCell));
vec3 normal = normalize(vec3(-ridge * 0.34 * ridgeVariation, 0.0, 1.0));
vec3 incident = normalize(vWorldPosition - cameraPosition);
vec3 refracted = refract(incident, normal, 0.75);
vec2 uv = mix(hitUv(incident), hitUv(refracted), clamp(uStrength, 0.0, 1.0));
vec4 color = mediaSample(uv);
color.a = 1.0;
gl_FragColor = color;
#include <colorspace_fragment>
}
-31
View File
@@ -1,31 +0,0 @@
uniform sampler2D uSceneTexture;
uniform mat4 uContentViewProjection,uContentInverseViewProjection;
uniform vec3 uContentCameraPosition,uLightPosition;
uniform float uGlassZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlossStrength;
uniform vec2 uViewOffset,uPetSize,uPaneSize;
uniform vec4 uPaneRect;
varying vec2 vUv;
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;}
vec4 sampleScene(vec2 uv){return texture2D(uSceneTexture,clamp(uv,0.,1.));}
vec4 samplePane(vec2 pane){return sampleScene(projectUv(vec3((pane-.5)*uPaneSize,uGlassZ)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2 hash2(vec2 p){return vec2(hash(p),hash(p+vec2(19.19,73.73)));}
float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;}
vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
float softLimit(float value,float limit){return value*limit/(limit+abs(value));}
void main(){
if(uStrength<=.001){gl_FragColor=sampleScene(vUv);
#include <colorspace_fragment>
return;}
vec4 f=uContentInverseViewProjection*vec4(vUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid,random=hash2(cell);float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
if(paneUv.x<0.||paneUv.x>1.||paneUv.y<0.||paneUv.y>1.){gl_FragColor=sampleScene(vUv);
#include <colorspace_fragment>
return;}
float height=.016*cellWorld*clamp(uCurvature,0.,1.)*mix(.88,1.12,hash(cell+vec2(41.7,17.3)));vec2 q=2.*local;vec2 grad=4.*height/cellWorld*vec2(q.x*(1.-q.y*q.y),q.y*(1.-q.x*q.x));vec3 nCurved=normalize(vec3(grad+(random-.5)*.012,1.)),nFacet=normalize(vec3((random-.5)*.012,1.));
vec3 straightHit=gh+i*(-gh.z/safeZ(i.z));vec3 facetRay=bend(i,nFacet),curvedRay=bend(i,nCurved);vec3 facetHit=gh+facetRay*(-gh.z/safeZ(facetRay.z)),curvedHit=gh+curvedRay*(-gh.z/safeZ(curvedRay.z));vec2 base=facetHit.xy-straightHit.xy,curve=curvedHit.xy-facetHit.xy;vec2 worldDelta=vec2(softLimit(base.x,cellWorld*.08),softLimit(base.y,cellWorld*.08));vec2 d=curve/cellWorld;worldDelta+=vec2(.3*d.x/(.3+abs(d.x)),.3*d.y/(.3+abs(d.y)))*cellWorld;
vec2 sourceCenter=(cell+.5)/grid+(random-.5)*.20*cellSize+worldDelta/uPaneSize;vec2 widths=mix(vec2(.72),vec2(1.08),hash2(cell+vec2(7.2,3.1)));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);vec2 sampleUv=sourceCenter+mat2(c,-s,s,c)*(local*cellSize)*widths;
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*.75,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;vec2 blurStep=cellSize*mix(.006,.012,hash(cell+vec2(5.)));vec2 centerUv=mix(paneUv,sampleUv,strength);vec4 color=samplePane(centerUv)*.4;color+=samplePane(mix(paneUv,sampleUv+vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(paneUv,sampleUv-vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(paneUv,sampleUv+vec2(0.,blurStep.y),strength))*.15;color+=samplePane(mix(paneUv,sampleUv-vec2(0.,blurStep.y),strength))*.15;color.a=1.;
vec3 l=normalize(uLightPosition-gh),v=normalize(uContentCameraPosition-gh),h=normalize(l+v);float spec=pow(max(dot(nCurved,h),0.),160.);float gloss=spec*clamp(uGlossStrength,0.,1.)*.12*edgeWeight*clamp(uStrength,0.,1.);color.rgb=mix(color.rgb,vec3(1.),gloss);gl_FragColor=color;
#include <colorspace_fragment>
}
-172
View File
@@ -1,172 +0,0 @@
import * as THREE from 'three';
import './style.css';
import vertexShader from './shaders/common.vert?raw';
import mosaicShader from './shaders/mosaic.frag?raw';
import reededShader from './shaders/reeded.frag?raw';
import frostedShader from './shaders/frosted.frag?raw';
import { MediaLoader } from './input/MediaLoader';
import { PointerController } from './input/PointerController';
import { CameraController } from './input/CameraController';
import { GyroController } from './input/GyroController';
import { StaticDepthProvider } from './depth/StaticDepthProvider';
import { DepthProcessor } from './depth/DepthProcessor';
import { PetDepthMesh } from './objects/PetDepthMesh';
import { DepthDebug } from './depth/DepthDebug';
import glassScreenVertex from './shaders/glassScreen.vert?raw';
const wrap = document.querySelector('#canvas-wrap') as HTMLElement;
const status = document.querySelector('#status') as HTMLElement;
const 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();
scene.background = new THREE.Color(0x111923);
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
camera.position.z = 3;
const geometry = new THREE.PlaneGeometry(1, 1);
const emptyTex = new THREE.DataTexture(new Uint8Array([38, 48, 62, 255]), 1, 1);
emptyTex.needsUpdate = true;
const petMat = new THREE.MeshBasicMaterial({ map: emptyTex });
const pet = new THREE.Mesh(geometry, petMat);
scene.add(pet);
const depthMap: THREE.Texture = DepthProcessor.flatTexture();
const petDepth = new PetDepthMesh(emptyTex, depthMap, 0.25, 0);
const petDepthMaterial = petDepth.material;
const depthUniforms = petDepthMaterial.uniforms;
scene.add(petDepth);
const uniforms = { uSceneTexture: { value: emptyTex as THREE.Texture }, uContentViewProjection: { value: new THREE.Matrix4() }, uContentInverseViewProjection: { value: new THREE.Matrix4() }, uContentCameraPosition: { value: new THREE.Vector3() }, uLightPosition: { value: new THREE.Vector3(0.6, 0.8, 2.6) }, uGlassZ: { value: 0.6 }, uGridSize: { value: 28 }, uMosaicStyle: { value: 0 }, uStrength: { value: 0.9 }, uGlossStrength: { value: 0.55 }, 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) } };
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 postCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const renderTarget = new THREE.WebGLRenderTarget(1, 1, { colorSpace: THREE.NoColorSpace, type: THREE.HalfFloatType, depthBuffer: true });
let active: THREE.Texture = emptyTex;
let aspect = 1;
let mode = 'noglass';
let hintTimeout = 0;
let pendingPairDepth: THREE.Texture | null = null;
let mediaRequest = 0;
let depthRequest = 0;
let disposed = false;
const listenerAbort = new AbortController();
function clearPendingPair(): void { if (pendingPairDepth) pendingPairDepth.dispose(); pendingPairDepth = null; }
function 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);
pet.scale.set(scale * aspect, scale, 1);
petDepth.scale.copy(pet.scale);
uniforms.uPetSize.value.set(pet.scale.x, pet.scale.y);
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);
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 - pet.scale.x / (2 * neutralGlassWidth)), Math.max(0, 0.5 - pet.scale.y / (2 * neutralGlassHeight)), Math.min(1, 0.5 + pet.scale.x / (2 * neutralGlassWidth)), Math.min(1, 0.5 + pet.scale.y / (2 * neutralGlassHeight)));
renderTarget.setSize(Math.max(1, Math.floor(bounds.width * Math.min(devicePixelRatio, 1.5))), Math.max(1, Math.floor(bounds.height * Math.min(devicePixelRatio, 1.5))));
}
function hint(): void {
window.clearTimeout(hintTimeout);
hintElement.style.opacity = '1';
hintTimeout = window.setTimeout(() => { hintElement.style.opacity = '0'; }, 2000);
}
function applyMedia(texture: THREE.Texture, mediaAspect: number, video: HTMLVideoElement | null): void {
if (active !== emptyTex) active.dispose();
active = texture; aspect = mediaAspect;
texture.colorSpace = THREE.SRGBColorSpace;
depthUniforms.uTexture.value = texture;
petMat.map = texture; petMat.needsUpdate = true;
empty.style.display = 'none'; resize();
}
function setDepth(texture: THREE.Texture): void {
if (depthUniforms.uDepthMap.value !== depthMap) depthUniforms.uDepthMap.value.dispose();
depthUniforms.uDepthMap.value = texture; texture.colorSpace = THREE.NoColorSpace;
if (mode === 'depth') { petMat.map = texture; petMat.needsUpdate = true; }
}
function setMode(next: string): void {
mode = next;
if (modeInput) modeInput.value = next;
pet.visible = next === 'rgb' || next === 'depth';
petDepth.visible = next === 'noglass' || next === 'final';
glass.visible = next === 'final' || next === 'glass';
petMat.map = next === 'depth' ? depthUniforms.uDepthMap.value : active;
petMat.needsUpdate = true;
petDepthMaterial.uniforms.uTexture.value = active;
}
const retry = document.createElement('button');
retry.textContent = '재생'; retry.hidden = true;
(document.querySelector('aside') as HTMLElement).append(retry);
const debugOutput = document.createElement('pre');
debugOutput.id = 'depth-debug';
debugOutput.hidden = true;
(document.querySelector('aside') as HTMLElement).append(debugOutput);
const depthDebug = new DepthDebug();
const loader = new MediaLoader((texture, mediaAspect, video) => { applyMedia(texture, mediaAspect, video); petDepth.setVideo(Boolean(video)); setDepth(pendingPairDepth ?? DepthProcessor.flatTexture()); pendingPairDepth = null; setMode('final'); hint(); }, (message) => { status.textContent = message; retry.hidden = !message.includes('자동'); });
retry.onclick = () => loader.retry();
const sampleDepthProvider = new StaticDepthProvider('/assets/sample-pet-depth.png');
const cameraController = new CameraController(camera);
const pointer = new PointerController(renderer.domElement, (x, y) => cameraController.add(x, y));
const gyro = new GyroController((x, y) => cameraController.target.set(THREE.MathUtils.clamp(x, -1, 1), THREE.MathUtils.clamp(y, -1, 1)), (message) => { status.textContent = message; });
const imageInput = document.querySelector('#image') as HTMLInputElement;
const videoInput = document.querySelector('#video') as HTMLInputElement;
const depthFileInput = document.querySelector('#depth-image') as HTMLInputElement;
const sampleButton = document.querySelector('#sample') as HTMLButtonElement | null;
const effectInput = document.querySelector('#effect') as HTMLSelectElement;
const modeInput = document.querySelector('#view-mode') as HTMLSelectElement | null;
const strengthInput = document.querySelector('#strength') as HTMLInputElement;
const sensitivityInput = document.querySelector('#sensitivity') as HTMLInputElement;
const depthInput = document.querySelector('#depth-scale') as HTMLInputElement | null;
const cameraOffsetInput = document.querySelector('#camera-offset') as HTMLInputElement;
const gridInput = document.querySelector('#grid-size') as HTMLInputElement;
const gridOutput = document.querySelector('#gridOut') as HTMLOutputElement;
const mosaicStyleInput = document.querySelector('#mosaic-style') as HTMLSelectElement;
const glossControls = document.querySelector('#gloss-controls') as HTMLElement | null;
const glossInput = document.querySelector('#gloss-strength') as HTMLInputElement | null;
const lightXInput = document.querySelector('#light-x') as HTMLInputElement | null;
const lightYInput = document.querySelector('#light-y') as HTMLInputElement | null;
const glossOutput = document.querySelector('#glossOut') as HTMLOutputElement | null;
const lightXOutput = document.querySelector('#lightXOut') as HTMLOutputElement | null;
const lightYOutput = document.querySelector('#lightYOut') as HTMLOutputElement | null;
const cameraOutput = document.querySelector('#cameraOut') as HTMLOutputElement;
const strengthOutput = document.querySelector('#strengthOut') as HTMLOutputElement;
const sensitivityOutput = document.querySelector('#sensitivityOut') as HTMLOutputElement;
const depthOutput = document.querySelector('#depthOut') as HTMLOutputElement | null;
const resetButton = document.querySelector('#reset') as HTMLButtonElement;
const gyroButton = document.querySelector('#gyro') as HTMLButtonElement;
const effects = [mosaicShader, reededShader, frostedShader];
let sensitivity = 1;
function setGlossControlsVisibility(): void {
const enabled = effectInput.value === 'mosaic';
if (glossControls) { glossControls.hidden = !enabled; glossControls.style.display = enabled ? 'grid' : 'none'; }
[glossInput, lightXInput, lightYInput].forEach((input) => { if (input) input.disabled = !enabled; });
}
imageInput.onchange = (event) => { const input = event.target as HTMLInputElement; mediaRequest += 1; depthRequest += 1; clearPendingPair(); depthDebug.clear(); if (input.files?.[0]) loader.load(input.files[0]); input.value = ''; };
videoInput.onchange = (event) => { const input = event.target as HTMLInputElement; mediaRequest += 1; depthRequest += 1; clearPendingPair(); depthDebug.clear(); if (input.files?.[0]) loader.load(input.files[0]); input.value = ''; };
sampleButton?.addEventListener('click', async () => { const request = ++mediaRequest; depthRequest += 1; clearPendingPair(); depthDebug.clear(); let url = ''; try { const [rgb, depth] = await Promise.all([fetch('/assets/sample-pet.png').then((r) => r.blob()), fetch('/assets/sample-pet-depth.png').then((r) => r.blob())]); if (disposed || request !== mediaRequest) return; url = URL.createObjectURL(depth); const image = new Image(); image.src = url; await image.decode(); if (disposed || request !== mediaRequest) return; const loadedDepth = await sampleDepthProvider.getDepthMap(image); if (disposed || request !== mediaRequest) { loadedDepth.dispose(); return; } clearPendingPair(); pendingPairDepth = loadedDepth; depthDebug.setSample(image); loader.load(new File([rgb], 'sample-pet.png', { type: 'image/png' })); status.textContent = '수작업 테스트 Depth · AI추정 아님'; } catch { if (!disposed && request === mediaRequest) status.textContent = '샘플 depth map을 읽지 못했습니다.'; } finally { if (url) URL.revokeObjectURL(url); } }, { signal: listenerAbort.signal });
effectInput.addEventListener('change', (event) => { const index = ['mosaic', 'reeded', 'frosted'].indexOf((event.target as HTMLSelectElement).value); glassMat.fragmentShader = effects[index]; glassMat.needsUpdate = true; setGlossControlsVisibility(); }, { signal: listenerAbort.signal });
modeInput?.addEventListener('change', (event) => setMode((event.target as HTMLSelectElement).value), { signal: listenerAbort.signal });
strengthInput.oninput = (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uStrength.value = value; strengthOutput.value = value.toFixed(2); };
sensitivityInput.oninput = (event) => { const value = Number((event.target as HTMLInputElement).value); sensitivity = value; sensitivityOutput.value = value.toFixed(2); };
depthInput?.addEventListener('input', (event) => { const value = Math.min(Number((event.target as HTMLInputElement).value), 0.4); depthUniforms.uDepthScale.value = value; if (depthOutput) depthOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
cameraOffsetInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); cameraController.setMaxOffset(value); cameraOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
gridInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGridSize.value = value; gridOutput.value = value.toFixed(0); }, { signal: listenerAbort.signal });
mosaicStyleInput.addEventListener('change', (event) => { const style = Number((event.target as HTMLSelectElement).value); uniforms.uMosaicStyle.value = style; uniforms.uGridSize.value = style === 0 ? 28 : 40; uniforms.uStrength.value = style === 0 ? 0.9 : 1; gridInput.value = String(uniforms.uGridSize.value); gridOutput.value = String(uniforms.uGridSize.value); strengthInput.value = String(uniforms.uStrength.value); strengthOutput.value = uniforms.uStrength.value.toFixed(2); }, { signal: listenerAbort.signal });
glossInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGlossStrength.value = value; if (glossOutput) glossOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
lightXInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uLightPosition.value.x = value; if (lightXOutput) lightXOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
lightYInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uLightPosition.value.y = value; if (lightYOutput) lightYOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
depthFileInput?.addEventListener('change', async () => { const request = ++depthRequest; const file = depthFileInput.files?.[0]; if (!file) return; depthDebug.clear(); const url = URL.createObjectURL(file); let texture: THREE.Texture | null = null; try { const image = new Image(); image.src = url; await image.decode(); if (disposed || request !== depthRequest) return; if (aspect > 0 && Math.abs(image.width / image.height - aspect) > 0.02) throw new Error('aspect'); texture = DepthProcessor.toTexture(image); if (disposed || request !== depthRequest) { texture.dispose(); texture = null; return; } setDepth(texture); texture = null; status.textContent = '수동 depth map 적용됨 (0=뒤, 1=앞)'; } catch { if (!disposed && request === depthRequest) status.textContent = 'RGB와 종횡비가 다른 depth map입니다.'; } finally { texture?.dispose(); URL.revokeObjectURL(url); depthFileInput.value = ''; } }, { signal: listenerAbort.signal });
resetButton.onclick = () => { cameraController.reset(); gyro.reset(); };
gyroButton.onclick = async () => { gyroButton.textContent = `Gyroscope: ${(await gyro.toggle()) ? 'ON' : 'OFF'}`; };
const observer = new ResizeObserver(resize); observer.observe(wrap); resize(); setMode('final'); setGlossControlsVisibility();
let last = performance.now(); let raf = 0;
function frame(now: number): void { const dt = Math.min((now - last) / 1000, 0.1); last = now; uniforms.uViewOffset.value.copy(cameraController.update(dt, sensitivity)); camera.updateMatrixWorld(true); petDepth.updateMatrixWorld(true); uniforms.uContentViewProjection.value.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); uniforms.uContentInverseViewProjection.value.copy(uniforms.uContentViewProjection.value).invert(); uniforms.uContentCameraPosition.value.copy(camera.position); uniforms.uTime.value = now / 1000; const diagnostic = depthDebug.update(camera, petDepth, depthUniforms.uDepthScale.value, depthUniforms.uDepthBias.value, wrap.clientWidth); debugOutput.textContent = diagnostic; debugOutput.hidden = !diagnostic; if (mode === 'final' || mode === 'glass') { renderer.setRenderTarget(renderTarget); renderer.render(scene, camera); renderer.setRenderTarget(null); uniforms.uSceneTexture.value = renderTarget.texture; renderer.render(glassScene, postCamera); } else { renderer.setRenderTarget(null); renderer.render(scene, camera); } raf = requestAnimationFrame(frame); }
raf = requestAnimationFrame(frame);
function teardown(): void { disposed = true; mediaRequest += 1; depthRequest += 1; clearPendingPair(); listenerAbort.abort(); cancelAnimationFrame(raf); window.clearTimeout(hintTimeout); observer.disconnect(); imageInput.onchange = null; videoInput.onchange = null; effectInput.onchange = null; strengthInput.oninput = null; sensitivityInput.oninput = null; resetButton.onclick = null; gyroButton.onclick = null; retry.onclick = null; pointer.dispose(); gyro.dispose(); loader.dispose(); renderTarget.dispose(); if (active !== emptyTex) active.dispose(); if (depthUniforms.uDepthMap.value !== depthMap) depthUniforms.uDepthMap.value.dispose(); depthMap.dispose(); petDepth.geometry.dispose(); petDepthMaterial.dispose(); geometry.dispose(); petMat.dispose(); glass.geometry.dispose(); glassMat.dispose(); 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);
-32
View File
@@ -1,32 +0,0 @@
uniform sampler2D uSceneTexture;
uniform mat4 uContentViewProjection;
uniform mat4 uContentInverseViewProjection;
uniform vec3 uContentCameraPosition;
uniform vec3 uLightPosition;
uniform float uGlassZ,uGridSize,uStrength,uMosaicStyle;
uniform float uGlossStrength;
uniform vec2 uViewOffset,uPetSize;
uniform vec4 uPaneRect;
varying vec2 vUv;
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;}
vec4 sampleScene(vec2 uv){return texture2D(uSceneTexture,clamp(uv,0.,1.));}
vec4 samplePane(vec2 uv){return texture2D(uSceneTexture,clamp(uv,uPaneRect.xy,uPaneRect.zw));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2 hash2(vec2 p){return vec2(hash(p),hash(p+vec2(19.19,73.73)));}
float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;}
vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
void main(){
if(vUv.x<uPaneRect.x||vUv.x>uPaneRect.z||vUv.y<uPaneRect.y||vUv.y>uPaneRect.w){
gl_FragColor=sampleScene(vUv);
#include <colorspace_fragment>
return;
}
vec4 f=uContentInverseViewProjection*vec4(vUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPetSize.y/uPetSize.x)),gridUv=vUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid,random=hash2(cell);
vec3 n=normalize(vec3((random-.5)*.10+local*.025,1.)),r=bend(i,n);vec3 sh=gh+i*(-gh.z/safeZ(i.z)),rh=gh+r*(-gh.z/safeZ(r.z));vec2 viewRefraction=clamp(projectUv(rh)-projectUv(sh),-cellSize*.4,cellSize*.4)+(uViewOffset*(random-.5))*cellSize*.12;
float style=clamp(uMosaicStyle,0.,1.);vec2 sourceCenter=(cell+.5)/grid+(random-.5)*.45*cellSize+viewRefraction;float footprint=mix(mix(.35,.50,random.y),mix(.03,.10,random.y),style);float angle=(random.x-.5)*mix(.06,.12,1.-style),c=cos(angle),s=sin(angle);vec2 sampleUv=clamp(sourceCenter+mat2(c,-s,s,c)*local*footprint*cellSize,uPaneRect.xy,uPaneRect.zw);
float edgeDistance=min(min(vUv.x-uPaneRect.x,uPaneRect.z-vUv.x),min(vUv.y-uPaneRect.y,uPaneRect.w-vUv.y));float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*.75,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;vec2 blurStep=cellSize*mix(.015,.07,style);
vec4 color=samplePane(mix(vUv,sampleUv,strength))*.4;color+=samplePane(mix(vUv,sampleUv+vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(vUv,sampleUv-vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(vUv,sampleUv+vec2(0.,blurStep.y),strength))*.15;color+=samplePane(mix(vUv,sampleUv-vec2(0.,blurStep.y),strength))*.15;color.a=1.;
vec3 l=normalize(uLightPosition-gh),v=normalize(uContentCameraPosition-gh),h=normalize(l+v);float spec=pow(max(dot(n,h),0.),64.);float gloss=spec*clamp(uGlossStrength,0.,1.)*.28*edgeWeight*clamp(uStrength,0.,1.);color.rgb=mix(color.rgb,vec3(1.),gloss);gl_FragColor=color;
#include <colorspace_fragment>
}
-152
View File
@@ -1,152 +0,0 @@
import * as THREE from 'three';
import './style.css';
import vertexShader from './shaders/common.vert?raw';
import mosaicShader from './shaders/mosaic.frag?raw';
import reededShader from './shaders/reeded.frag?raw';
import frostedShader from './shaders/frosted.frag?raw';
import { MediaLoader } from './input/MediaLoader';
import { PointerController } from './input/PointerController';
import { CameraController } from './input/CameraController';
import { GyroController } from './input/GyroController';
import { StaticDepthProvider } from './depth/StaticDepthProvider';
import { DepthProcessor } from './depth/DepthProcessor';
import { PetDepthMesh } from './objects/PetDepthMesh';
import { DepthDebug } from './depth/DepthDebug';
import glassScreenVertex from './shaders/glassScreen.vert?raw';
const wrap = document.querySelector('#canvas-wrap') as HTMLElement;
const status = document.querySelector('#status') as HTMLElement;
const 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();
scene.background = new THREE.Color(0x111923);
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
camera.position.z = 3;
const geometry = new THREE.PlaneGeometry(1, 1);
const emptyTex = new THREE.DataTexture(new Uint8Array([38, 48, 62, 255]), 1, 1);
emptyTex.needsUpdate = true;
const petMat = new THREE.MeshBasicMaterial({ map: emptyTex });
const pet = new THREE.Mesh(geometry, petMat);
scene.add(pet);
const depthMap: THREE.Texture = DepthProcessor.flatTexture();
const petDepth = new PetDepthMesh(emptyTex, depthMap, 0.25, 0);
const petDepthMaterial = petDepth.material;
const depthUniforms = petDepthMaterial.uniforms;
scene.add(petDepth);
const uniforms = { uSceneTexture: { value: emptyTex as THREE.Texture }, uContentViewProjection: { value: new THREE.Matrix4() }, uContentInverseViewProjection: { value: new THREE.Matrix4() }, uContentCameraPosition: { value: new THREE.Vector3() }, uGlassZ: { value: 0.6 }, uGridSize: { value: 8 }, uStrength: { value: 0.65 }, uViewOffset: { value: new THREE.Vector2() }, uResolution: { value: new THREE.Vector2() }, uTime: { value: 0 }, uPetSize: { value: new THREE.Vector2(1, 1) } };
const glassMat = new THREE.ShaderMaterial({ uniforms, vertexShader: glassScreenVertex, fragmentShader: mosaicShader });
const glass = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), glassMat);
glassScene.add(glass);
const postCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const renderTarget = new THREE.WebGLRenderTarget(1, 1, { colorSpace: THREE.NoColorSpace, type: THREE.HalfFloatType, depthBuffer: true });
let active: THREE.Texture = emptyTex;
let aspect = 1;
let mode = 'noglass';
let hintTimeout = 0;
let pendingPairDepth: THREE.Texture | null = null;
let mediaRequest = 0;
let depthRequest = 0;
let disposed = false;
const listenerAbort = new AbortController();
function clearPendingPair(): void { if (pendingPairDepth) pendingPairDepth.dispose(); pendingPairDepth = null; }
function 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);
pet.scale.set(scale * aspect, scale, 1);
petDepth.scale.copy(pet.scale);
uniforms.uPetSize.value.set(pet.scale.x, pet.scale.y);
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);
renderTarget.setSize(Math.max(1, Math.floor(bounds.width * Math.min(devicePixelRatio, 1.5))), Math.max(1, Math.floor(bounds.height * Math.min(devicePixelRatio, 1.5))));
}
function hint(): void {
window.clearTimeout(hintTimeout);
hintElement.style.opacity = '1';
hintTimeout = window.setTimeout(() => { hintElement.style.opacity = '0'; }, 2000);
}
function applyMedia(texture: THREE.Texture, mediaAspect: number, video: HTMLVideoElement | null): void {
if (active !== emptyTex) active.dispose();
active = texture; aspect = mediaAspect;
texture.colorSpace = THREE.SRGBColorSpace;
depthUniforms.uTexture.value = texture;
petMat.map = texture; petMat.needsUpdate = true;
empty.style.display = 'none'; resize();
}
function setDepth(texture: THREE.Texture): void {
if (depthUniforms.uDepthMap.value !== depthMap) depthUniforms.uDepthMap.value.dispose();
depthUniforms.uDepthMap.value = texture; texture.colorSpace = THREE.NoColorSpace;
if (mode === 'depth') { petMat.map = texture; petMat.needsUpdate = true; }
}
function setMode(next: string): void {
mode = next;
if (modeInput) modeInput.value = next;
pet.visible = next === 'rgb' || next === 'depth';
petDepth.visible = next === 'noglass' || next === 'final';
glass.visible = next === 'final' || next === 'glass';
petMat.map = next === 'depth' ? depthUniforms.uDepthMap.value : active;
petMat.needsUpdate = true;
petDepthMaterial.uniforms.uTexture.value = active;
}
const retry = document.createElement('button');
retry.textContent = '재생'; retry.hidden = true;
(document.querySelector('aside') as HTMLElement).append(retry);
const debugOutput = document.createElement('pre');
debugOutput.id = 'depth-debug';
debugOutput.hidden = true;
(document.querySelector('aside') as HTMLElement).append(debugOutput);
const depthDebug = new DepthDebug();
const loader = new MediaLoader((texture, mediaAspect, video) => { applyMedia(texture, mediaAspect, video); petDepth.setVideo(Boolean(video)); setDepth(pendingPairDepth ?? DepthProcessor.flatTexture()); pendingPairDepth = null; setMode('noglass'); hint(); }, (message) => { status.textContent = message; retry.hidden = !message.includes('자동'); });
retry.onclick = () => loader.retry();
const sampleDepthProvider = new StaticDepthProvider('/assets/sample-pet-depth.png');
const cameraController = new CameraController(camera);
const pointer = new PointerController(renderer.domElement, (x, y) => cameraController.add(x, y));
const gyro = new GyroController((x, y) => cameraController.target.set(THREE.MathUtils.clamp(x, -1, 1), THREE.MathUtils.clamp(y, -1, 1)), (message) => { status.textContent = message; });
const imageInput = document.querySelector('#image') as HTMLInputElement;
const videoInput = document.querySelector('#video') as HTMLInputElement;
const depthFileInput = document.querySelector('#depth-image') as HTMLInputElement;
const sampleButton = document.querySelector('#sample') as HTMLButtonElement | null;
const effectInput = document.querySelector('#effect') as HTMLSelectElement;
const modeInput = document.querySelector('#view-mode') as HTMLSelectElement | null;
const strengthInput = document.querySelector('#strength') as HTMLInputElement;
const sensitivityInput = document.querySelector('#sensitivity') as HTMLInputElement;
const depthInput = document.querySelector('#depth-scale') as HTMLInputElement | null;
const cameraOffsetInput = document.querySelector('#camera-offset') as HTMLInputElement;
const gridInput = document.querySelector('#grid-size') as HTMLInputElement;
const gridOutput = document.querySelector('#gridOut') as HTMLOutputElement;
const cameraOutput = document.querySelector('#cameraOut') as HTMLOutputElement;
const strengthOutput = document.querySelector('#strengthOut') as HTMLOutputElement;
const sensitivityOutput = document.querySelector('#sensitivityOut') as HTMLOutputElement;
const depthOutput = document.querySelector('#depthOut') as HTMLOutputElement | null;
const resetButton = document.querySelector('#reset') as HTMLButtonElement;
const gyroButton = document.querySelector('#gyro') as HTMLButtonElement;
const effects = [mosaicShader, reededShader, frostedShader];
let sensitivity = 1;
imageInput.onchange = (event) => { const input = event.target as HTMLInputElement; mediaRequest += 1; depthRequest += 1; clearPendingPair(); depthDebug.clear(); if (input.files?.[0]) loader.load(input.files[0]); input.value = ''; };
videoInput.onchange = (event) => { const input = event.target as HTMLInputElement; mediaRequest += 1; depthRequest += 1; clearPendingPair(); depthDebug.clear(); if (input.files?.[0]) loader.load(input.files[0]); input.value = ''; };
sampleButton?.addEventListener('click', async () => { const request = ++mediaRequest; depthRequest += 1; clearPendingPair(); depthDebug.clear(); let url = ''; try { const [rgb, depth] = await Promise.all([fetch('/assets/sample-pet.png').then((r) => r.blob()), fetch('/assets/sample-pet-depth.png').then((r) => r.blob())]); if (disposed || request !== mediaRequest) return; url = URL.createObjectURL(depth); const image = new Image(); image.src = url; await image.decode(); if (disposed || request !== mediaRequest) return; const loadedDepth = await sampleDepthProvider.getDepthMap(image); if (disposed || request !== mediaRequest) { loadedDepth.dispose(); return; } clearPendingPair(); pendingPairDepth = loadedDepth; depthDebug.setSample(image); loader.load(new File([rgb], 'sample-pet.png', { type: 'image/png' })); status.textContent = '수작업 테스트 Depth · AI추정 아님'; } catch { if (!disposed && request === mediaRequest) status.textContent = '샘플 depth map을 읽지 못했습니다.'; } finally { if (url) URL.revokeObjectURL(url); } }, { signal: listenerAbort.signal });
effectInput.onchange = (event) => { const index = ['mosaic', 'reeded', 'frosted'].indexOf((event.target as HTMLSelectElement).value); glassMat.fragmentShader = effects[index]; glassMat.needsUpdate = true; };
modeInput?.addEventListener('change', (event) => setMode((event.target as HTMLSelectElement).value), { signal: listenerAbort.signal });
strengthInput.oninput = (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uStrength.value = value; strengthOutput.value = value.toFixed(2); };
sensitivityInput.oninput = (event) => { const value = Number((event.target as HTMLInputElement).value); sensitivity = value; sensitivityOutput.value = value.toFixed(2); };
depthInput?.addEventListener('input', (event) => { const value = Math.min(Number((event.target as HTMLInputElement).value), 0.4); depthUniforms.uDepthScale.value = value; if (depthOutput) depthOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
cameraOffsetInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); cameraController.setMaxOffset(value); cameraOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
gridInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGridSize.value = value; gridOutput.value = value.toFixed(0); }, { signal: listenerAbort.signal });
depthFileInput?.addEventListener('change', async () => { const request = ++depthRequest; const file = depthFileInput.files?.[0]; if (!file) return; depthDebug.clear(); const url = URL.createObjectURL(file); let texture: THREE.Texture | null = null; try { const image = new Image(); image.src = url; await image.decode(); if (disposed || request !== depthRequest) return; if (aspect > 0 && Math.abs(image.width / image.height - aspect) > 0.02) throw new Error('aspect'); texture = DepthProcessor.toTexture(image); if (disposed || request !== depthRequest) { texture.dispose(); texture = null; return; } setDepth(texture); texture = null; status.textContent = '수동 depth map 적용됨 (0=뒤, 1=앞)'; } catch { if (!disposed && request === depthRequest) status.textContent = 'RGB와 종횡비가 다른 depth map입니다.'; } finally { texture?.dispose(); URL.revokeObjectURL(url); depthFileInput.value = ''; } }, { signal: listenerAbort.signal });
resetButton.onclick = () => { cameraController.reset(); gyro.reset(); };
gyroButton.onclick = async () => { gyroButton.textContent = `Gyroscope: ${(await gyro.toggle()) ? 'ON' : 'OFF'}`; };
const observer = new ResizeObserver(resize); observer.observe(wrap); resize(); setMode('noglass');
let last = performance.now(); let raf = 0;
function frame(now: number): void { const dt = Math.min((now - last) / 1000, 0.1); last = now; uniforms.uViewOffset.value.copy(cameraController.update(dt, sensitivity)); camera.updateMatrixWorld(true); petDepth.updateMatrixWorld(true); uniforms.uContentViewProjection.value.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); uniforms.uContentInverseViewProjection.value.copy(uniforms.uContentViewProjection.value).invert(); uniforms.uContentCameraPosition.value.copy(camera.position); uniforms.uTime.value = now / 1000; const diagnostic = depthDebug.update(camera, petDepth, depthUniforms.uDepthScale.value, depthUniforms.uDepthBias.value, wrap.clientWidth); debugOutput.textContent = diagnostic; debugOutput.hidden = !diagnostic; if (mode === 'final' || mode === 'glass') { renderer.setRenderTarget(renderTarget); renderer.render(scene, camera); renderer.setRenderTarget(null); uniforms.uSceneTexture.value = renderTarget.texture; renderer.render(glassScene, postCamera); } else { renderer.setRenderTarget(null); renderer.render(scene, camera); } raf = requestAnimationFrame(frame); }
raf = requestAnimationFrame(frame);
function teardown(): void { disposed = true; mediaRequest += 1; depthRequest += 1; clearPendingPair(); listenerAbort.abort(); cancelAnimationFrame(raf); window.clearTimeout(hintTimeout); observer.disconnect(); imageInput.onchange = null; videoInput.onchange = null; effectInput.onchange = null; strengthInput.oninput = null; sensitivityInput.oninput = null; resetButton.onclick = null; gyroButton.onclick = null; retry.onclick = null; pointer.dispose(); gyro.dispose(); loader.dispose(); renderTarget.dispose(); if (active !== emptyTex) active.dispose(); if (depthUniforms.uDepthMap.value !== depthMap) depthUniforms.uDepthMap.value.dispose(); depthMap.dispose(); petDepth.geometry.dispose(); petDepthMaterial.dispose(); geometry.dispose(); petMat.dispose(); glass.geometry.dispose(); glassMat.dispose(); 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);
-9
View File
@@ -1,9 +0,0 @@
uniform sampler2D uSceneTexture; uniform mat4 uContentViewProjection; uniform mat4 uContentInverseViewProjection; uniform vec3 uContentCameraPosition; uniform float uGlassZ,uGridSize,uStrength,uTime; uniform vec2 uViewOffset,uResolution,uPetSize;
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(127.1,311.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(){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 grid=vec2(uGridSize,max(1.,uGridSize*uPetSize.y/uPetSize.x));vec2 s=vUv*grid,cell=floor(s),local=fract(s)-.5;float var=mix(.85,1.15,hash(cell));vec2 ll=local+uViewOffset*(.12+.08*hash(cell+vec2(9.)));float bulge=max(0.,.25-dot(ll,ll));vec3 n=normalize(vec3(-ll*bulge*1.8*var,1.));vec3 r=bend(i,n);vec3 sh=gh+i*(-gh.z/safeZ(i.z)),bh=gh+r*(-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);float edge=smoothstep(.46,.5,max(abs(local.x),abs(local.y)));c.rgb*=mix(1.,.75,edge*clamp(uStrength,0.,1.));c.a=1.;gl_FragColor=c;
#include <colorspace_fragment>
}
-32
View File
@@ -1,32 +0,0 @@
uniform sampler2D uSceneTexture;
uniform mat4 uContentViewProjection,uContentInverseViewProjection;
uniform vec3 uContentCameraPosition,uLightPosition,uBackgroundColor;
uniform float uGlassZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlossStrength;
uniform vec2 uViewOffset,uPetSize,uPaneSize;
uniform vec4 uPaneRect;
varying vec2 vUv;
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;}
vec4 sampleScene(vec2 uv){return texture2D(uSceneTexture,clamp(uv,0.,1.));}
vec4 samplePane(vec2 pane){return sampleScene(projectUv(vec3((pane-.5)*uPaneSize,uGlassZ)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2 hash2(vec2 p){return vec2(hash(p),hash(p+vec2(19.19,73.73)));}
float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;}
vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
float softLimit(float value,float limit){return value*limit/(limit+abs(value));}
void main(){
if(uStrength<=.001){gl_FragColor=sampleScene(vUv);
#include <colorspace_fragment>
return;}
vec4 f=uContentInverseViewProjection*vec4(vUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid,random=hash2(cell);float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
if(paneUv.x<0.||paneUv.x>1.||paneUv.y<0.||paneUv.y>1.){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}
// Positive height bulges toward the observer (+Z); negative height is concave.
float height=.009*cellWorld*clamp(uCurvature,-1.,1.)*mix(.92,1.08,hash(cell+vec2(41.7,17.3))); vec2 q=2.*local; vec2 q2=q*q,q3=q2*q,b=1.-q2*q2; vec2 skew=(hash2(cell+vec2(23.8,61.2))-.5)*.28; float asym=1.+dot(skew,q); vec2 dhdq=height*vec2(-4.*q3.x*b.y*asym+b.x*b.y*skew.x,-4.*q3.y*b.x*asym+b.x*b.y*skew.y); vec2 cellDimensions=uPaneSize/grid; vec2 grad=-2.*dhdq/cellDimensions; vec3 nCurved=normalize(vec3(grad+(random-.5)*.012,1.)),nFacet=normalize(vec3((random-.5)*.012,1.));
vec3 straightHit=gh+i*(-gh.z/safeZ(i.z));vec3 facetRay=bend(i,nFacet),curvedRay=bend(i,nCurved);vec3 facetHit=gh+facetRay*(-gh.z/safeZ(facetRay.z)),curvedHit=gh+curvedRay*(-gh.z/safeZ(curvedRay.z));vec2 base=facetHit.xy-straightHit.xy,curve=curvedHit.xy-facetHit.xy;vec2 worldDelta=vec2(softLimit(base.x,cellWorld*.08),softLimit(base.y,cellWorld*.08));vec2 d=curve/cellWorld;worldDelta+=vec2(.3*d.x/(.3+abs(d.x)),.3*d.y/(.3+abs(d.y)))*cellWorld;
vec2 sourceCenter=(cell+.5)/grid+(random-.5)*.32*cellSize+worldDelta/uPaneSize;vec2 widths;if(style>.5){widths=mix(vec2(.82),vec2(1.12),hash2(cell+vec2(11.4,9.8)));}else{float widthSeed=hash(cell+vec2(53.1,27.4));if(widthSeed<.6)widths=mix(vec2(.90),vec2(1.08),hash2(cell+vec2(7.2,3.1)));else if(widthSeed<.9)widths=mix(vec2(.46),vec2(.70),hash2(cell+vec2(7.2,3.1)));else widths=mix(vec2(1.10),vec2(1.28),hash2(cell+vec2(7.2,3.1)));}float angle=(random.x-.5)*.08,c=cos(angle),s=sin(angle);vec2 sampleUv=sourceCenter+mat2(c,-s,s,c)*(local*cellSize)*widths;
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*.75,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;vec2 blurStep=cellSize*mix(.006,.012,hash(cell+vec2(5.)));vec2 centerUv=mix(paneUv,sampleUv,strength);vec4 color=samplePane(centerUv)*.4;color+=samplePane(mix(paneUv,sampleUv+vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(paneUv,sampleUv-vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(paneUv,sampleUv+vec2(0.,blurStep.y),strength))*.15;color+=samplePane(mix(paneUv,sampleUv-vec2(0.,blurStep.y),strength))*.15;color.a=1.;
vec3 v=normalize(uContentCameraPosition-gh),reflected=reflect(-v,nCurved); float lightT=(uLightPosition.z-gh.z)/safeZ(reflected.z); vec2 lightHit=gh.xy+reflected.xy*lightT; vec2 halfSize=vec2(.32,.42)*min(uPaneSize.x,uPaneSize.y); vec2 windowCoord=abs(lightHit-uLightPosition.xy)/halfSize; vec2 windowMask=1.-smoothstep(vec2(.45),vec2(1.15),windowCoord); float spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float gloss=spec*clamp(uGlossStrength,0.,1.)*.12*edgeWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),gloss); gl_FragColor=color;
#include <colorspace_fragment>
}
-175
View File
@@ -1,175 +0,0 @@
import * as THREE from 'three';
import './style.css';
import vertexShader from './shaders/common.vert?raw';
import mosaicShader from './shaders/mosaic.frag?raw';
import reededShader from './shaders/reeded.frag?raw';
import frostedShader from './shaders/frosted.frag?raw';
import { MediaLoader } from './input/MediaLoader';
import { PointerController } from './input/PointerController';
import { CameraController } from './input/CameraController';
import { GyroController } from './input/GyroController';
import { StaticDepthProvider } from './depth/StaticDepthProvider';
import { DepthProcessor } from './depth/DepthProcessor';
import { PetDepthMesh } from './objects/PetDepthMesh';
import { DepthDebug } from './depth/DepthDebug';
import glassScreenVertex from './shaders/glassScreen.vert?raw';
const wrap = document.querySelector('#canvas-wrap') as HTMLElement;
const status = document.querySelector('#status') as HTMLElement;
const 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();
scene.background = new THREE.Color(0x111923);
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
camera.position.z = 3;
const geometry = new THREE.PlaneGeometry(1, 1);
const emptyTex = new THREE.DataTexture(new Uint8Array([38, 48, 62, 255]), 1, 1);
emptyTex.needsUpdate = true;
const petMat = new THREE.MeshBasicMaterial({ map: emptyTex });
const pet = new THREE.Mesh(geometry, petMat);
scene.add(pet);
const depthMap: THREE.Texture = DepthProcessor.flatTexture();
const petDepth = new PetDepthMesh(emptyTex, depthMap, 0.25, 0);
const petDepthMaterial = petDepth.material;
const depthUniforms = petDepthMaterial.uniforms;
scene.add(petDepth);
const uniforms = { uSceneTexture: { value: emptyTex as THREE.Texture }, uContentViewProjection: { value: new THREE.Matrix4() }, uContentInverseViewProjection: { value: new THREE.Matrix4() }, uContentCameraPosition: { value: new THREE.Vector3() }, uLightPosition: { value: new THREE.Vector3(0.6, 0.8, 2.6) }, uGlassZ: { value: 0.6 }, uGridSize: { value: 18 }, uMosaicStyle: { value: 0 }, uStrength: { value: 0.75 }, uGlossStrength: { value: 0.3 }, uCurvature: { value: 0.65 }, uViewOffset: { value: new THREE.Vector2() }, uPaneRect: { value: new THREE.Vector4(0, 0, 1, 1) }, uResolution: { value: new THREE.Vector2() }, uTime: { value: 0 }, uPetSize: { value: new THREE.Vector2(1, 1) }, uPaneSize: { value: new THREE.Vector2(1, 1) } };
const glassMat = new THREE.ShaderMaterial({ uniforms, vertexShader: glassScreenVertex, fragmentShader: mosaicShader });
const glass = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), glassMat);
glassScene.add(glass);
const postCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const renderTarget = new THREE.WebGLRenderTarget(1, 1, { colorSpace: THREE.NoColorSpace, type: THREE.HalfFloatType, depthBuffer: true });
let active: THREE.Texture = emptyTex;
let aspect = 1;
let mode = 'noglass';
let hintTimeout = 0;
let pendingPairDepth: THREE.Texture | null = null;
let mediaRequest = 0;
let depthRequest = 0;
let disposed = false;
const listenerAbort = new AbortController();
function clearPendingPair(): void { if (pendingPairDepth) pendingPairDepth.dispose(); pendingPairDepth = null; }
function 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);
pet.scale.set(scale * aspect, scale, 1);
petDepth.scale.copy(pet.scale);
uniforms.uPetSize.value.set(pet.scale.x, pet.scale.y);
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(pet.scale.x * (3 - uniforms.uGlassZ.value) / 3, pet.scale.y * (3 - uniforms.uGlassZ.value) / 3);
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 - pet.scale.x / (2 * neutralGlassWidth)), Math.max(0, 0.5 - pet.scale.y / (2 * neutralGlassHeight)), Math.min(1, 0.5 + pet.scale.x / (2 * neutralGlassWidth)), Math.min(1, 0.5 + pet.scale.y / (2 * neutralGlassHeight)));
renderTarget.setSize(Math.max(1, Math.floor(bounds.width * Math.min(devicePixelRatio, 1.5))), Math.max(1, Math.floor(bounds.height * Math.min(devicePixelRatio, 1.5))));
}
function hint(): void {
window.clearTimeout(hintTimeout);
hintElement.style.opacity = '1';
hintTimeout = window.setTimeout(() => { hintElement.style.opacity = '0'; }, 2000);
}
function applyMedia(texture: THREE.Texture, mediaAspect: number, video: HTMLVideoElement | null): void {
if (active !== emptyTex) active.dispose();
active = texture; aspect = mediaAspect;
texture.colorSpace = THREE.SRGBColorSpace;
depthUniforms.uTexture.value = texture;
petMat.map = texture; petMat.needsUpdate = true;
empty.style.display = 'none'; resize();
}
function setDepth(texture: THREE.Texture): void {
if (depthUniforms.uDepthMap.value !== depthMap) depthUniforms.uDepthMap.value.dispose();
depthUniforms.uDepthMap.value = texture; texture.colorSpace = THREE.NoColorSpace;
if (mode === 'depth') { petMat.map = texture; petMat.needsUpdate = true; }
}
function setMode(next: string): void {
mode = next;
if (modeInput) modeInput.value = next;
pet.visible = next === 'rgb' || next === 'depth';
petDepth.visible = next === 'noglass' || next === 'final';
glass.visible = next === 'final' || next === 'glass';
petMat.map = next === 'depth' ? depthUniforms.uDepthMap.value : active;
petMat.needsUpdate = true;
petDepthMaterial.uniforms.uTexture.value = active;
}
const retry = document.createElement('button');
retry.textContent = '재생'; retry.hidden = true;
(document.querySelector('aside') as HTMLElement).append(retry);
const debugOutput = document.createElement('pre');
debugOutput.id = 'depth-debug';
debugOutput.hidden = true;
(document.querySelector('aside') as HTMLElement).append(debugOutput);
const depthDebug = new DepthDebug();
const loader = new MediaLoader((texture, mediaAspect, video) => { applyMedia(texture, mediaAspect, video); petDepth.setVideo(Boolean(video)); setDepth(pendingPairDepth ?? DepthProcessor.flatTexture()); pendingPairDepth = null; setMode('final'); hint(); }, (message) => { status.textContent = message; retry.hidden = !message.includes('자동'); });
retry.onclick = () => loader.retry();
const sampleDepthProvider = new StaticDepthProvider('/assets/sample-pet-depth.png');
const cameraController = new CameraController(camera);
const pointer = new PointerController(renderer.domElement, (x, y) => cameraController.add(x, y));
const gyro = new GyroController((x, y) => cameraController.target.set(THREE.MathUtils.clamp(x, -1, 1), THREE.MathUtils.clamp(y, -1, 1)), (message) => { status.textContent = message; });
const imageInput = document.querySelector('#image') as HTMLInputElement;
const videoInput = document.querySelector('#video') as HTMLInputElement;
const depthFileInput = document.querySelector('#depth-image') as HTMLInputElement;
const sampleButton = document.querySelector('#sample') as HTMLButtonElement | null;
const effectInput = document.querySelector('#effect') as HTMLSelectElement;
const modeInput = document.querySelector('#view-mode') as HTMLSelectElement | null;
const strengthInput = document.querySelector('#strength') as HTMLInputElement;
const sensitivityInput = document.querySelector('#sensitivity') as HTMLInputElement;
const depthInput = document.querySelector('#depth-scale') as HTMLInputElement | null;
const cameraOffsetInput = document.querySelector('#camera-offset') as HTMLInputElement;
const gridInput = document.querySelector('#grid-size') as HTMLInputElement;
const gridOutput = document.querySelector('#gridOut') as HTMLOutputElement;
const mosaicStyleInput = document.querySelector('#mosaic-style') as HTMLSelectElement;
const glossControls = document.querySelector('#gloss-controls') as HTMLElement | null;
const curvatureInput = document.querySelector('#curvature') as HTMLInputElement | null;
const glossInput = document.querySelector('#gloss-strength') as HTMLInputElement | null;
const lightXInput = document.querySelector('#light-x') as HTMLInputElement | null;
const lightYInput = document.querySelector('#light-y') as HTMLInputElement | null;
const glossOutput = document.querySelector('#glossOut') as HTMLOutputElement | null;
const lightXOutput = document.querySelector('#lightXOut') as HTMLOutputElement | null;
const lightYOutput = document.querySelector('#lightYOut') as HTMLOutputElement | null;
const cameraOutput = document.querySelector('#cameraOut') as HTMLOutputElement;
const strengthOutput = document.querySelector('#strengthOut') as HTMLOutputElement;
const sensitivityOutput = document.querySelector('#sensitivityOut') as HTMLOutputElement;
const depthOutput = document.querySelector('#depthOut') as HTMLOutputElement | null;
const resetButton = document.querySelector('#reset') as HTMLButtonElement;
const gyroButton = document.querySelector('#gyro') as HTMLButtonElement;
const effects = [mosaicShader, reededShader, frostedShader];
let sensitivity = 1;
function setGlossControlsVisibility(): void {
const enabled = effectInput.value === 'mosaic';
if (glossControls) { glossControls.hidden = !enabled; glossControls.style.display = enabled ? 'grid' : 'none'; }
[glossInput, lightXInput, lightYInput, curvatureInput].forEach((input) => { if (input) input.disabled = !enabled; });
}
imageInput.onchange = (event) => { const input = event.target as HTMLInputElement; mediaRequest += 1; depthRequest += 1; clearPendingPair(); depthDebug.clear(); if (input.files?.[0]) loader.load(input.files[0]); input.value = ''; };
videoInput.onchange = (event) => { const input = event.target as HTMLInputElement; mediaRequest += 1; depthRequest += 1; clearPendingPair(); depthDebug.clear(); if (input.files?.[0]) loader.load(input.files[0]); input.value = ''; };
sampleButton?.addEventListener('click', async () => { const request = ++mediaRequest; depthRequest += 1; clearPendingPair(); depthDebug.clear(); let url = ''; try { const [rgb, depth] = await Promise.all([fetch('/assets/sample-pet.png').then((r) => r.blob()), fetch('/assets/sample-pet-depth.png').then((r) => r.blob())]); if (disposed || request !== mediaRequest) return; url = URL.createObjectURL(depth); const image = new Image(); image.src = url; await image.decode(); if (disposed || request !== mediaRequest) return; const loadedDepth = await sampleDepthProvider.getDepthMap(image); if (disposed || request !== mediaRequest) { loadedDepth.dispose(); return; } clearPendingPair(); pendingPairDepth = loadedDepth; depthDebug.setSample(image); loader.load(new File([rgb], 'sample-pet.png', { type: 'image/png' })); status.textContent = '수작업 테스트 Depth · AI추정 아님'; } catch { if (!disposed && request === mediaRequest) status.textContent = '샘플 depth map을 읽지 못했습니다.'; } finally { if (url) URL.revokeObjectURL(url); } }, { signal: listenerAbort.signal });
effectInput.addEventListener('change', (event) => { const index = ['mosaic', 'reeded', 'frosted'].indexOf((event.target as HTMLSelectElement).value); glassMat.fragmentShader = effects[index]; glassMat.needsUpdate = true; setGlossControlsVisibility(); }, { signal: listenerAbort.signal });
modeInput?.addEventListener('change', (event) => setMode((event.target as HTMLSelectElement).value), { signal: listenerAbort.signal });
strengthInput.oninput = (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uStrength.value = value; strengthOutput.value = value.toFixed(2); };
sensitivityInput.oninput = (event) => { const value = Number((event.target as HTMLInputElement).value); sensitivity = value; sensitivityOutput.value = value.toFixed(2); };
depthInput?.addEventListener('input', (event) => { const value = Math.min(Number((event.target as HTMLInputElement).value), 0.4); depthUniforms.uDepthScale.value = value; if (depthOutput) depthOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
cameraOffsetInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); cameraController.setMaxOffset(value); cameraOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
gridInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGridSize.value = value; gridOutput.value = value.toFixed(0); }, { signal: listenerAbort.signal });
mosaicStyleInput.addEventListener('change', (event) => { const style = Number((event.target as HTMLSelectElement).value); uniforms.uMosaicStyle.value = style; uniforms.uGridSize.value = style === 0 ? 18 : 28; uniforms.uStrength.value = style === 0 ? 0.75 : 0.85; gridInput.value = String(uniforms.uGridSize.value); gridOutput.value = String(uniforms.uGridSize.value); strengthInput.value = String(uniforms.uStrength.value); strengthOutput.value = uniforms.uStrength.value.toFixed(2); }, { signal: listenerAbort.signal });
curvatureInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uCurvature.value = value; const output = document.querySelector('#curvatureOut') as HTMLOutputElement | null; if (output) output.value = value.toFixed(2); }, { signal: listenerAbort.signal });
glossInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGlossStrength.value = value; if (glossOutput) glossOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
lightXInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uLightPosition.value.x = value; if (lightXOutput) lightXOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
lightYInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uLightPosition.value.y = value; if (lightYOutput) lightYOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
depthFileInput?.addEventListener('change', async () => { const request = ++depthRequest; const file = depthFileInput.files?.[0]; if (!file) return; depthDebug.clear(); const url = URL.createObjectURL(file); let texture: THREE.Texture | null = null; try { const image = new Image(); image.src = url; await image.decode(); if (disposed || request !== depthRequest) return; if (aspect > 0 && Math.abs(image.width / image.height - aspect) > 0.02) throw new Error('aspect'); texture = DepthProcessor.toTexture(image); if (disposed || request !== depthRequest) { texture.dispose(); texture = null; return; } setDepth(texture); texture = null; status.textContent = '수동 depth map 적용됨 (0=뒤, 1=앞)'; } catch { if (!disposed && request === depthRequest) status.textContent = 'RGB와 종횡비가 다른 depth map입니다.'; } finally { texture?.dispose(); URL.revokeObjectURL(url); depthFileInput.value = ''; } }, { signal: listenerAbort.signal });
resetButton.onclick = () => { cameraController.reset(); gyro.reset(); };
gyroButton.onclick = async () => { gyroButton.textContent = `Gyroscope: ${(await gyro.toggle()) ? 'ON' : 'OFF'}`; };
const observer = new ResizeObserver(resize); observer.observe(wrap); resize(); setMode('final'); setGlossControlsVisibility();
let last = performance.now(); let raf = 0;
function frame(now: number): void { const dt = Math.min((now - last) / 1000, 0.1); last = now; uniforms.uViewOffset.value.copy(cameraController.update(dt, sensitivity)); camera.updateMatrixWorld(true); petDepth.updateMatrixWorld(true); uniforms.uContentViewProjection.value.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); uniforms.uContentInverseViewProjection.value.copy(uniforms.uContentViewProjection.value).invert(); uniforms.uContentCameraPosition.value.copy(camera.position); uniforms.uTime.value = now / 1000; const diagnostic = depthDebug.update(camera, petDepth, depthUniforms.uDepthScale.value, depthUniforms.uDepthBias.value, wrap.clientWidth); debugOutput.textContent = diagnostic; debugOutput.hidden = !diagnostic; if (mode === 'final' || mode === 'glass') { renderer.setRenderTarget(renderTarget); renderer.render(scene, camera); renderer.setRenderTarget(null); uniforms.uSceneTexture.value = renderTarget.texture; renderer.render(glassScene, postCamera); } else { renderer.setRenderTarget(null); renderer.render(scene, camera); } raf = requestAnimationFrame(frame); }
raf = requestAnimationFrame(frame);
function teardown(): void { disposed = true; mediaRequest += 1; depthRequest += 1; clearPendingPair(); listenerAbort.abort(); cancelAnimationFrame(raf); window.clearTimeout(hintTimeout); observer.disconnect(); imageInput.onchange = null; videoInput.onchange = null; effectInput.onchange = null; strengthInput.oninput = null; sensitivityInput.oninput = null; resetButton.onclick = null; gyroButton.onclick = null; retry.onclick = null; pointer.dispose(); gyro.dispose(); loader.dispose(); renderTarget.dispose(); if (active !== emptyTex) active.dispose(); if (depthUniforms.uDepthMap.value !== depthMap) depthUniforms.uDepthMap.value.dispose(); depthMap.dispose(); petDepth.geometry.dispose(); petDepthMaterial.dispose(); geometry.dispose(); petMat.dispose(); glass.geometry.dispose(); glassMat.dispose(); 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);
-31
View File
@@ -1,31 +0,0 @@
uniform sampler2D uSceneTexture;
uniform mat4 uContentViewProjection,uContentInverseViewProjection;
uniform vec3 uContentCameraPosition,uLightPosition;
uniform float uGlassZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlossStrength;
uniform vec2 uViewOffset,uPetSize,uPaneSize;
uniform vec4 uPaneRect;
varying vec2 vUv;
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;}
vec4 sampleScene(vec2 uv){return texture2D(uSceneTexture,clamp(uv,0.,1.));}
vec4 samplePane(vec2 pane){return sampleScene(projectUv(vec3((pane-.5)*uPaneSize,uGlassZ)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2 hash2(vec2 p){return vec2(hash(p),hash(p+vec2(19.19,73.73)));}
float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;}
vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
float softLimit(float value,float limit){return value*limit/(limit+abs(value));}
void main(){
if(uStrength<=.001){gl_FragColor=sampleScene(vUv);
#include <colorspace_fragment>
return;}
vec4 f=uContentInverseViewProjection*vec4(vUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid,random=hash2(cell);float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
if(paneUv.x<0.||paneUv.x>1.||paneUv.y<0.||paneUv.y>1.){gl_FragColor=sampleScene(vUv);
#include <colorspace_fragment>
return;}
float height=.009*cellWorld*clamp(uCurvature,0.,1.)*mix(.92,1.08,hash(cell+vec2(41.7,17.3))); vec2 q=2.*local; vec2 q2=q*q,q3=q2*q,b=1.-q2*q2; vec2 skew=(hash2(cell+vec2(23.8,61.2))-.5)*.28; float asym=1.+dot(skew,q); vec2 dhdq=height*vec2(-4.*q3.x*b.y*asym+b.x*b.y*skew.x,-4.*q3.y*b.x*asym+b.x*b.y*skew.y); vec2 cellDimensions=uPaneSize/grid; vec2 grad=-2.*dhdq/cellDimensions; vec3 nCurved=normalize(vec3(grad+(random-.5)*.012,1.)),nFacet=normalize(vec3((random-.5)*.012,1.));
vec3 straightHit=gh+i*(-gh.z/safeZ(i.z));vec3 facetRay=bend(i,nFacet),curvedRay=bend(i,nCurved);vec3 facetHit=gh+facetRay*(-gh.z/safeZ(facetRay.z)),curvedHit=gh+curvedRay*(-gh.z/safeZ(curvedRay.z));vec2 base=facetHit.xy-straightHit.xy,curve=curvedHit.xy-facetHit.xy;vec2 worldDelta=vec2(softLimit(base.x,cellWorld*.08),softLimit(base.y,cellWorld*.08));vec2 d=curve/cellWorld;worldDelta+=vec2(.3*d.x/(.3+abs(d.x)),.3*d.y/(.3+abs(d.y)))*cellWorld;
vec2 sourceCenter=(cell+.5)/grid+(random-.5)*.20*cellSize+worldDelta/uPaneSize;vec2 widths=mix(vec2(.72),vec2(1.08),hash2(cell+vec2(7.2,3.1)));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);vec2 sampleUv=sourceCenter+mat2(c,-s,s,c)*(local*cellSize)*widths;
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*.75,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;vec2 blurStep=cellSize*mix(.006,.012,hash(cell+vec2(5.)));vec2 centerUv=mix(paneUv,sampleUv,strength);vec4 color=samplePane(centerUv)*.4;color+=samplePane(mix(paneUv,sampleUv+vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(paneUv,sampleUv-vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(paneUv,sampleUv+vec2(0.,blurStep.y),strength))*.15;color+=samplePane(mix(paneUv,sampleUv-vec2(0.,blurStep.y),strength))*.15;color.a=1.;
vec3 v=normalize(uContentCameraPosition-gh),reflected=reflect(-v,nCurved); float lightT=(uLightPosition.z-gh.z)/safeZ(reflected.z); vec2 lightHit=gh.xy+reflected.xy*lightT; vec2 halfSize=vec2(.32,.42)*min(uPaneSize.x,uPaneSize.y); vec2 windowCoord=abs(lightHit-uLightPosition.xy)/halfSize; vec2 windowMask=1.-smoothstep(vec2(.45),vec2(1.15),windowCoord); float spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float gloss=spec*clamp(uGlossStrength,0.,1.)*.12*edgeWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),gloss); gl_FragColor=color;
#include <colorspace_fragment>
}
-26
View File
@@ -1,26 +0,0 @@
# 인접 셀 연결성 재보정
## 요구와 원인
유리는 공통 사진의 눈·코 윤곽을 셀 내부에서 확대/압축하되, 인접 셀이 서로 무관한 사진 조각을 가져오는 인상을 만들면 안 된다. 자동 확대·팬·크롭과 RGB/depth 배치는 변경하지 않는다.
이전의 광학 거리 0.6 보정은 셀 끝까지 커지는 곡면 기울기와 폭 0.04의 급격한 방향 전환을 함께 증폭했다. 셀 폭 0.55의 변위 상한은 좌표 변화율을 제한하지 않으므로 국소적으로 사진을 읽는 순서가 뒤집히는 것을 방지하지 못한다. 공통 광선 사용만으로 연결성이 보장되지는 않는다.
## 설계
기본 조각 유리는 전 셀에 이어지는 사인 곡면 기울기를 사용한다. 셀 중심과 경계에서 기울기가 0이며, 확대와 압축은 셀 전체에 분산한다. 독립 셀 난수 기울기는 작은 유리판 전체 기울기로 대체한다. 굴절·반사는 같은 법선을 사용하며 색면 atlas도 같은 좌표 계산을 따른다.
강도는 셀 크기와 유효 광학 거리로 보정한다. 실제 가까운 피사체 배치는 유지한다. 이는 연출용 광학 근사이며 유리 두께의 물리 시뮬레이션은 아니다.
## 검증 기준
- 색면·광택 0에서 같은 눈·코 윤곽이 셀 경계를 넘어 연결되는지 확인한다.
- 실제 투영 좌표의 변화율을 수치 검사한다. 양의 행렬식뿐 아니라 가로·세로 좌표 진행 방향도 확인한다.
- 셀 경계 근처, 유리판 테두리, Grid/굴곡/카메라/피사체 간격의 여러 조건을 포함한다.
- 색면 복원 후 색상 경계와 굴절 좌표 단절을 구분한다. 셀별 팔레트 자체의 색상 차이는 별도 한계이며 기하 검사를 통과했다고 없어지는 것은 아니다.
## 적용 및 결과
`src/shaders/mosaic.frag`에 기본 조각 유리의 사인 곡면과 전역 facet 기울기를 적용했다. 촘촘 유리 분기는 기존 동작을 유지했다. `scripts/test-mosaic-continuity.mjs`는 Grid 6/10/96, 굴곡 -1/0/0.65/1, 두 패널 비율, 카메라 세 위치, 두 sourceZ를 순회하며 최종 투영 좌표의 Jacobian과 셀 경계 수렴을 검사한다.
2026-09-14 결과: 66,816개 Jacobian과 52,320개 경계 검사를 통과했다. `npm run build`도 통과했다(기존 500KB 초과 번들 경고). 브라우저에서 색면·광택 0의 dog1을 확인한 뒤 기본 색면 0.65·광택 0.3·depth 0.25를 복원했다. 정성적으로 인접 셀의 눈·코 윤곽은 이전처럼 서로 다른 조각으로 튀지 않고 이어졌지만, Ref2의 큰 얼굴 구도와 색면 내부 명암 보존은 별도 작업이다.
-11
View File
@@ -1,11 +0,0 @@
# 셀 곡면 보정 설계
2026-09-11. 기존 QA 원인 대응.
- Mosaic 셀은 world z=uGlassZ 유리판 기준으로 고정. uPaneSize는 중립 사진 크기와 투영이 일치하도록 계산. Grid Size는 유리 가로 셀 수이며 화면 비율에 무관하다.
- 각 셀은 얕은 사각 pillow 곡면으로 모델링한다. 동일한 곡면 법선을 굴절/광택에 공유하며 고정 hash로 미세 높이 편차만 준다. 시간 난수, 검은 격자, 방사형 파문은 제외.
- 곡면 굴절과 평면 기울기의 굴절을 따로 제한해, 큰 시점 이동이 작은 굴곡을 지우지 않도록 한다. RenderTarget을 사용하는 얕은 렌즈 근사이며 정확한 광선추적은 아니다.
- 샘플 폭을 넓혀 셀 안의 사진 정보 보존. 기본 조각18칸/.75, 촘촘28칸/.85. 광택 .30에서 시작하며 강한 회색 덧칠을 줄인다.
- 셀 굴곡 조절로 광택을 끈 상태에서 곡률 유무 비교. 사진 리라이팅은 제외. Depth/RT와 다른 유리 셰이더는 유지.
완료 기준: 두 viewport에서 동일 가로 셀 수; 눈/코 정보 유지; 광택0에서도 곡면 왜곡; 곡면 법선에 따른 반사; 실제 drag에서 world 판 좌표 일관성; Distortion0 원본 복원; 빌드/브라우저 셰이더 오류 확인. 정밀 물리 유리 두께/환경맵/새 이미지 생성은 범위 밖.
-24
View File
@@ -1,24 +0,0 @@
# 셀 곡면 수정 검증
2026-09-11. Astra 설계/정적 검토, GPT-5.6 Luna 소스 구현, 조정 에이전트 브라우저 QA.
## 반영
- src/shaders/mosaic.frag: 유리판 world 좌표에서 셀/마스크/곡면 계산, pane→world→screen projection으로 RT 샘플. 동일 사각 pillow 곡면 법선을 굴절과 광택에 사용. 곡면 변화량과 기본 굴절량을 별도 soft limit. 작은 샘플 폭/색블록 문제 완화. 기존 셀 hash, 렌더타깃, 굴절 및 5-tap 흐름 유지.
- src/main.ts, index.html: uPaneSize, 셀 굴곡 0~1 기본 .65, 조각18/.75·촘촘28/.85, 광택 .30. Mosaic 전용 컨트롤 숨김 유지.
- 원본은 docs/before-cell-curvature/에 보존. README와 AGENTS 갱신.
## 수행한 QA
- 코드 변경 전 No Glass 실제 드래그로 Depth 시차가 유지됨을 재확인.
- 같은 샘플 사진/시점에서 광택0+굴곡0/.65 비교. 곡면ON에서 사진 윤곽이 셀 내부에서 휘고 OFF에서는 평면 조각으로 돌아감. 눈/코가 이전 극단적 확대보다 더 식별됨.
- 기본 광택 .30에서 셀 곡면의 밝기 차이 관찰. 이전 .55/white mix .28보다 회색 덧칠 감소.
- 390x844 모바일과 데스크톱에서 동일 가로18칸과 사진/셀 비율 확인. 모바일 문서 폭375로 가로 넘침 없음. viewport override 복원.
- 카메라 offset .6 실제 drag에서 world pane 변형/반사와 배경 시차 확인. 유리판과 사진의 외곽은 서로 다른 깊이이므로 이동 시 같지 않음.
- 움직인 시점에서 Distortion0 Final/No Glass 시각 비교: 정렬과 색상 일치. 픽셀 차이 자동 측정은 미수행.
- 두 Mosaic 프리셋, Reeded/Frosted 전환 확인. 최종 파일 저장 후 새 탭 error 로그 [] 확인.
- npm.cmd run build: tsc -b / Vite 성공,20 modules, JS489.37kB(gzip125.06kB).
## 범위와 한계
얕은 유리 표면과 굴절을 셰이더로 근사한다. 실제 두께/환경맵 반사/그림자 광선추적은 없다. 참고 사진의 프레임·피사체 구도까지 재현한 것은 아니며, 테스트 원본은 그대로다. 수작업 Depth의 외곽 늘어짐 한계는 유지된다. 실제 영상/gyro/프레임 성능 측정은 이번에 수행하지 않았다.
-13
View File
@@ -1,13 +0,0 @@
# 얼굴이 유리를 채우는 구도
사용자 요청: 멀리 떨어진 강아지 사진을 유리 너머로 보는 인상보다, 강아지가 유리 바로 뒤에 있고 얼굴이 크게 보이는 구도를 원한다.
현재 dog1 테스트 사진의 사진 확대1은 전신과 잔디를 보여준다. 기존 RGB/Depth 공통 구도 조절로 확대2.2, 가로0, 세로-0.4를 적용한 실제 사용자 화면에서 얼굴이 유리판 대부분을 채우는 것을 확인했다.
최소 변경은 dog1을 다시 불러올 때 이 얼굴 구도를 기본 적용하는 것이다. 원본 파일, 유리 효과, 색면, 격자, 카메라와 실제 유리-사진 간격은 변경하지 않는다. 임의 업로드에 얼굴 위치 자동 인식을 추가하지 않는다. 기존 사진 구도 초기화로 전체 사진으로 돌아갈 수 있다.
실제 간격을 크게 줄이면 현재 광선 모델의 굴절 이동량도 줄어 사용자가 긍정적으로 평가한 셀 효과가 약해진다. 이번 결과는 얼굴 구도 확대이며, 실제 3차원 강아지가 유리에 접촉하도록 바꾼 결과로 표현하지 않는다.
검증 기준: dog1 재로드에 확대2.2/가로0/세로-0.4, 기존 sample 프리셋 유지, 원래 구도 초기화와 재로드 복귀, 브라우저 오류 없음. 실제 거리 모델과 AI depth는 변경하지 않는다.
적용/검증: `src/main.ts`에서 dog1의 일회성 대기 구도 플래그를 추가하고 취소 시 해제하도록 했다. 빌드·타입 검사 통과. 실제 5173 사용자 탭에서 sample 기존 구도, dog1 자동 얼굴 구도, 초기화1/0/0, 재로드2.2/0/-0.4를 확인했다. 최종 오류 로그 없음. 화면은 dog1 얼굴 구도와 사용자의 색면1·Grid14로 두었다. 강아지 얼굴이 유리 대부분을 채우는 것을 최종 화면에서 확인했다.
-18
View File
@@ -1,18 +0,0 @@
# Contact depth placement
Depth uses normalized values where 0 is behind and 1 is in front. For each installed `DataTexture`, the app scans the red channel once and places the photo and depth mesh at
```text
contentBaseZ = glassZ - 0.04 - (maxDepth - depthBias) * depthScale
```
The depth mesh keeps a Z scale of 1, so its highest vertex remains 0.04 world units behind the glass as the depth scale changes. The XY photo size and pan are multiplied by `(cameraZ - contentBaseZ) / cameraZ` to keep the flat photo footprint stable as it moves closer. A flat depth map stays at the near-flat placement `glassZ - 0.04`.
Mosaic, Reeded, and Frosted trace refracted rays to the same `contentBaseZ` plane. The Mosaic color-region atlas shares that uniform. Existing glass surface normals and Mosaic height cap are unchanged; no extra slab or optical surface is added.
New still photos without paired manual depth start the existing Depth Anything V2 Small provider automatically. Videos and the paired local sample remain unchanged. Automatic depth keeps the current view mode; the manual AI button still switches to No Glass for parallax inspection. The model does not detect or crop faces, and the highest depth point may be a paw or another foreground part, so general uploads keep their chosen framing.
## Verification
- `npm run build` passed (`tsc -b` and Vite production build); Vite reports the existing large-chunk advisory.
- Glass-off sample check at depth scale 0.25 reported `contentBaseZ=0.315`, `maxFrontZ=0.560`, and `glassGap=0.040`. At scale 0, the placement formula keeps the front at 0.560 with the base at 0.560. Scale 0.4 keeps the same front clearance by moving the base farther back.
-50
View File
@@ -1,50 +0,0 @@
# 근접 배치 이후 시점 변형 약화 검토
## 수정 완료 — 2026-09-14
## 후속 수정 — 시점에 따른 셀 내부 굴절
기본 Mosaic에만 셀 내부 시점 반응 항을 추가했다. 현재 입사광선과 같은 곡면 법선을 통과하는 중심 기준광선의 굴절 기울기 차이를 부드럽게 제한해, cosine envelope가 0인 셀 경계는 고정하고 내부 이동만 만든다. 계산은 공유 signed curvature scale을 사용하며 REGION_ATLAS와 최종 Mosaic 모두 같은 셰이더 경로를 거친다. 초기 gain .18 cell로 두었다. 이는 유리 입출사면을 추가한 물리 광선 추적이 아니라 현재 평면 교점 광학 모델 안의 연출 근사다.
CPU 연속성 검사는 296,256개 Jacobian과 52,320개 경계 수렴 사례에서 양수 방향/연속성을 통과했고, 정면 기준 변위 0·셀 축 경계 고정·비스듬한 시점의 셀 내부 변위 분산도 통과했다.
브라우저에서는 dog4/AI depth/Grid10/Distortion .9/굴곡 .65/광택 0/Depth .25/확대 1/팬 0에서 중앙과 좌우 약 120px 이동을 비교했다. 색면 0에서 코·가슴 윤곽의 폭과 위치가 달라졌고, 색면 1에서도 셀 내부 경계와 목줄·가슴의 색 영역 변화가 확인됐다. 변화는 사진틀 이동에만 그치지 않았다. 색면 1에는 포스터화 인상이 여전히 남는다. 실제 연속 영상의 깜빡임은 정량 검증하지 않았으며, Ref1의 최종 달성으로 판단하지 않는다. 추가 샘플 확인은 진행 중이다.
후속 실화면 확인에서 정면 시점과 좌우 약 120px 이동 시 색면 1에서도 내부 변형이 보였고, Distortion 0에서는 기존 무효과 출력으로 돌아가는 것을 확인했다. 이 역시 특정 테스트 사진의 시각 확인이며, 일반 사진 전체나 연속 프레임 flicker의 검증은 아니다.
기본 Mosaic의 굴절 변위만 실제 피사체 간격에서 분리했다. `mosaic.frag`에서 입사/굴절 광선의 XY 기울기 차이에 최소 0.6의 음수 광학 이동 거리를 곱하고 기존 셀 크기 기반 제한을 적용한다. 직진 교점과 최종 투영은 실제 `uSourceZ`를 유지한다. 사진/깊이 배치, 확대·팬, 곡면 법선과 반사, 색면 알고리즘은 변경하지 않았다. 이 최소 거리는 연출용 근사이며 실제 유리 두께가 아니다. 촘촘 유리/Reeded/Frosted는 이번 수정 대상이 아니다.
Luna 구현 후 `npm run build` 통과(기존 번들 크기 경고), Astra 독립 수식/분기 검토에서 차단 문제 없음. 브라우저 5173에서 멍멍이1/자동 AI depth/zoom1/pan0/Grid10/Distortion0.9로 확인했다. HMR로 사진이 초기화되어 같은 테스트 사진을 다시 로드했다.
- No Glass에서 수평 드래그 후 깊이 장면 이동을 확인했다. 이번에는 랜드마크별 시차를 계측하지 않았다.
- 색면0·광택0의 Final에서 눈·코와 털 윤곽이 셀별로 확대/압축되어 보이는 것을 확인했다. 좌우 드래그에서 형태와 위치 변화가 보였지만 연속 프레임 안정성을 정량 측정하지 않았다.
- Depth Scale0으로 실제 간격이 최소일 때도 굴절 변형이 유지됐다. Distortion0에서는 셀 변형 없이 원본 윤곽으로 돌아왔다.
- 브라우저 오류 로그 없음. 색면1·광택0.3·depth0.25·Distortion0.9를 복원하고 시점을 초기화했다. Ref1|Ref2 비교 배치 유지.
이번 통과 범위는 근접 배치 때문에 굴절이 약화되는 회귀 수정이다. 색면1에서 눈 내부 명암이 평평해지고 잔디의 작은 영역이 많이 남는 문제는 여전히 보인다. Ref2와 최종 시각 일치까지 완료했다고 판단하지 않는다.
사용자 지적: 유리에 붙은 이미지 개념을 적용한 뒤 시점에 따른 강아지 형상 변화가 거의 없어졌다.
## 결론
근접 배치와 굴절 변형의 이동 거리를 동일하게 묶은 변경이 원인이다. 피사체를 가까이 배치해야 한다는 요구를 만족시키면서 기존 셀의 시점 의존 변형을 유지하는지를 검증하지 못했다. 이전의 간격0.040/관통 방지 검증만으로 시각 목표를 통과 처리한 것은 불충분했다.
`src/main.ts`의 contentBaseZ와 uSourceZ는 같은 값이다. `src/shaders/mosaic.frag`의 직진·굴절 교점 차이는 유리에서 이 기준 평면까지의 거리에 비례한다. Reeded/Frosted도 같은 방식이다.
기존 간격0.6은 maxDepth1/depthScale0.25일 때0.29로 줄고, 평면 입력이나 depthScale0일 때0.04로 줄었다. 같은 광선과 법선의 제한 전 변위는 각각 기존의 약48%/7%다. 정면 투영의 작은 변위로 환산하면 약54%/8%지만, 실제 셰이더에는 변위 제한이 있어 화면 전체가 이 비율로 변한다고 단정하지 않는다.
## 구분해야 할 점
- depth mesh의 점 사이 z차이는 여전히 depthScale×깊이차다. 카메라도 실제 이동한다. 따라서 깊이 자체가 평면화됐다고 설명하면 부정확하다.
- XY 보정은 기준 평면의 정면 크기를 유지한다. 상대 깊이를 없애는 계산은 아니다.
- 현재 유리는 평면 교점에서 곡면 법선으로 광선을 한 번 굴절시키는 근사다. 유리 두께와 출사면, 실제 depth 표면과의 굴절광선 교차는 구현돼 있지 않다. 피사체 간격만 줄여서는 Ref의 렌즈 같은 변형을 유지할 수 없다.
## 이번 확인
5173 사용자 dog1/AIdepth/색면1/Grid10/depthScale0.25/CameraOffset0.3 설정에서 Final과 No Glass를 비교하고, 짧은 수평 드래그 후 반대 방향으로 되돌렸다. Final에서는 큰 형상 변화보다 작은 이동과 색면 변화가 두드러졌다. 정확한 랜드마크 변형량이나 모든 연속 프레임을 측정한 검사는 아니다. 코드와 광선식에 근거해 원인을 분리했다. 소스 수정·빌드는 하지 않았다.
## 다음 수정 기준
사진 구도와 자동 구도 조정 금지, 근접 배치는 유지한다. 공통 광선과 같은 곡면 법선을 사용하면서 굴절 변형의 유효 광학 거리를 피사체의 실제 간격에서 분리하는 것이 작은 수정 방향이다. 직진 기준과 최종 장면 투영은 실제 평면을 사용해야 하며, 임의 셀별 사진 크롭으로 대체하지 않는다. 이 경우 의도적인 광학 근사라고 명시한다. 더 물리적인 대안인 유리 두께·입출사 양면 구현은 별도 범위다.
검증 순서: 같은 사진·같은 구도에서 No Glass 상대 시차 → 색면/광택0 Mosaic의 셀 내부 변형 → 색면/광택 복원. 근접한 배치에서도 드래그 시 눈·코 윤곽과 배율 변화가 확인돼야 하며, 간격 수치가 맞는 것만으로 완료하지 않는다.
-26
View File
@@ -1,26 +0,0 @@
# 현재 상태 QA — 2026-09-11
소스 수정 없이 브라우저 QA와 Astra 정적 검토를 수행했다.
## 재현된 개선 항목
1. 창틀 밖 효과: 샘플 얼굴 구도에서 Final → Reeded/Frosted 전환 시 확대 사진과 효과가 창틀 밖까지 표시된다. 기존 문서에 명시된 한계로, 이번 곡률 변경의 회귀는 아니다. 모든 효과의 유리 영역을 같은 pane 경계로 맞추는 것이 시각 일관성의 우선 과제다. Distortion 0의 원본 출력 요구사항은 별도로 유지해야 한다.
2. P2 키보드 업로드: 새로고침 후 첫 Tab이 샘플 버튼으로 이동한다. 사진/영상/Depth input은 computed display:none이며 label은 키보드 동작을 제공하지 않는다. src/style.css:1, index.html:4.
3. P2 샘플 출처 안내: 샘플 로딩 완료 후 #status.textContent가 빈 문자열이다. main.ts의 안내를 MediaLoader.ts:31에서 지운다. 수작업 Depth이며 AI가 아니라는 안내를 지속적으로 표시해야 한다.
## 시각 판단
- 창틀과 Mosaic 경계는 데스크톱/모바일 및 이동 시 함께 유지된다.
- 강한 볼록(+1), 특히 이동한 모바일 화면에서는 눈·코를 가로지르는 조각의 불연속이 뚜렷하다. 오목(-1)은 윤곽이 상대적으로 이어지지만 다른 유리 형태이므로 이를 단순한 품질 우위로 간주하지 않는다.
- 다음 시각 보정은 광택 강도를 올리기보다 셀별 샘플 이동/배율 불연속과 곡면 기여를 분리해 비교하는 방향을 권한다. 레퍼런스의 조각별 정보 차이는 보존해야 하므로 경계를 모두 흐리는 방식은 피한다. 이번 QA에서 재튜닝은 하지 않았다.
## 확인한 동작
- npm.cmd run build 통과, 브라우저 error 로그 없음.
- No Glass 카메라 시차: cameraX 0→0.107에서 nose X 531.509→528.377, eye 459.283→457.303, body 350.477→350.378 px. 부위별 이동량 차이 확인. 수작업 테스트 Depth 기준이다.
- Final/No Glass 전환, Mosaic/Reeded/Frosted 전환 및 Mosaic 전용 굴곡/광택 컨트롤 숨김.
- 오목 -1, 볼록 +1 표시, 기본 설정 및 Reset view.
- 1280×720와 390×844 화면. 모바일 Camera Offset .6 설정 후 드래그, 창틀 경계 결합 확인. 실제 도달 cameraX .324를 관측했으며 최대 offset 도달을 주장하지 않는다.
- 정적 검토: signed 높이 방향 및 공유 법선, 곡률0의 facet 유지, RGB/Depth 공통 구도, RT 구조, 창틀 좌표 및 자원 정리 일치.
영상 재생, 실제 센서/터치 하드웨어, 성능 계측, 장시간 메모리 누수는 이번 검증에서 실행하지 않았다. 뷰포트 override는 복원했고 임시 QA 탭은 닫았다.
-56
View File
@@ -1,56 +0,0 @@
# Depth 확장 구현 명세
## 판단과 범위
사용자 수정지시 원문은 `docs/depth-task-brief.txt`이다. 기존 main.ts 기반의 작은 앱을 유지하고, 조직만을 위한 SceneManager 등 전면 재구성은 하지 않는다. 현재 카메라도 실제로 이동하지만 피사체는 평면이며 Glass가 원본 이미지를 직접 읽는다. 이번 핵심은 이 두 지점을 depth mesh와 content render target으로 바꾸는 것이다. 기존 Mosaic의 셀별 normal/refract/lens variation은 보존한다.
첨부 사진의 깊이는 실측 또는 AI 추정 데이터가 없다. 따라서 테스트용 깊이는 사진을 직접 확인하여 만든 수작업 가상 깊이이며 UI/보고에서 이 사실을 밝힌다. 사용자 업로드에 샘플용 깊이를 적용하지 않는다. 업로드 후 깊이가 없으면 flat depth로 복귀한다. AI, segmentation, video depth estimation은 제외한다.
## 단계 1A: 샘플 깊이 자산
- 원본은 `public/assets/sample-pet.png` (415 × 739)를 그대로 사용한다.
- 동일 크기 grayscale `public/assets/sample-pet-depth.png`와 재생성 가능한 작은 스크립트를 만든다. RGB 이미지 자체의 편집은 하지 않는다.
- 좌표는 이미지 좌상단 원점이다. 사진을 직접 확인한 대략의 landmark: 코 (235,310), 왼쪽 눈 (198,278), 오른쪽 눈 (249,273), 얼굴 (221,267), 왼쪽 귀 (158,213), 오른쪽 귀 (262,206), 몸 (139,419). 좌우는 보는 사람 기준이다.
- 0=뒤, 1=앞. 배경 약 .08, 몸 .30, 귀 .43, 얼굴 .60, 눈 .68, 주둥이 .82, 코 .98을 목표로 부드러운 타원/마스크를 합성한다. 단순 전체 gradient나 코와 눈이 같은 plateau인 map은 피한다. 겹친 영역의 최종 깊이를 실제 PNG에서 읽고 확인한다.
- 얼굴/몸/귀의 크기를 사진에 맞춰 선택하고 타원 가장자리는 연속적으로 부드럽게 처리한다. 코와 눈은 별도의 작은 영역이며 사진 배경을 과도하게 앞으로 끌어오지 않는다.
- CPU 측정과 shader에서 같은 좌표/값을 사용할 수 있도록 landmark 좌표와 grayscale 값을 보고한다. 이미지 상단 y와 texture UV의 v 반전을 명확히 한다.
## 단계 1B: Glass OFF 깊이 구현
이 단계에서는 RenderTarget과 Glass shader 변경을 금지한다. 검증 통과 후 별도 단계로 연결한다.
1. `DepthProvider`와 static loader를 최소한으로 추가한다. 입력은 별도 depth 이미지와 샘플 pair이며, 내부 depth texture는 NoColorSpace / 0뒤1앞이다. grayscale 및 필요 시 invert UI로 규칙을 명확히 한다. RGB와 depth의 aspect가 다르면 사용자가 알 수 있는 오류를 표시한다. async stale 요청을 무시하고 교체/실패/HMR에서 texture 및 object URL을 정리한다.
2. `PetDepthMesh`를 추가한다. PlaneGeometry(1,1,128,128), RGB texture, depth texture, ShaderMaterial을 갖는다. vertex shader에서 z += (depth - bias) * scale을 실제로 적용한다. fragment는 RGB를 읽고 출력 색공간 처리를 한다. RGB는 sRGB, depth는 비색상 데이터다. 기존 VideoTexture는 flat depth 또는 명시 업로드한 static depth로 지원한다.
3. Depth scale 기본 .25, 범위 0~.4. bias는 .08 정도의 고정값도 충분하다. 미디어 contain 계산은 기존 resize를 이용하고 z축 scale은 1을 유지한다. 기존 flat pet을 RGB/debug 또는 fallback으로 활용해도 된다.
4. View mode: Final(기존 Glass 경로, 임시), RGB(평면 원본), Depth(평면 grayscale map), No Glass(depth mesh). 샘플 로드 시 No Glass를 선택해 단계 목표를 바로 보이게 한다. Depth view는 조명/굴절 없이 map을 확인할 수 있어야 한다.
5. camera offset UI 기본 .3, 최대 .6, z=3, lookAt(0,0,0). 기존 target/current 보간, pointer, gyro, sensitivity/reset은 유지한다. viewOffset은 현 Glass용 보조 입력으로 그대로 남긴다. 실제 parallax 검증은 No Glass 경로만 대상으로 한다.
6. 사진/영상 업로드와 sample pair 전환에서 이전 sample depth가 새 미디어에 남지 않게 한다. sample pair load도 기존 loader의 request lifetime과 충돌하지 않도록 하나의 로딩 경로를 재사용한다.
### 단계 1 게이트
- `npm run build` 통과를 기록한다. 이는 시각 동작 검증을 대체하지 않는다.
- 실제 브라우저에서 샘플 RGB/Depth/No Glass를 확인한다. No Glass, scale .3, 카메라 왼쪽/중앙/오른쪽 이미지를 캡처한다. depth 0에서도 동일 카메라 조건을 캡처한다.
- landmark별 실제 map 값을 읽고 world 위치 및 camera projection으로 CSS pixel 위치를 측정한다. mesh local x=u-.5, y=.5-yTop, z=(d-bias)*scale에 실제 mesh transform을 적용하고, 현재 camera matrix로 project한다. 측정에 사용한 depth 샘플링은 GPU linear sampling 및 UV 방향과 일치해야 한다.
- 각 landmark i의 좌우 이동 D_i(scale)=x_i(right)-x_i(left)를 얻는다. depth로 추가된 이동 E_i=D_i(.3)-D_i(0)를 기록한다. flat 상태에서도 perspective/lookAt 때문에 영역별 움직임이 조금 다르므로 raw 이동만으로 성공을 주장하지 않는다.
- 코-눈 평균 및 코-몸의 상대 이동 차이 E_nose-E_eyes, E_nose-E_body를 기록한다. 의도한 방향에서 코가 눈보다, 눈이 몸보다 크게 추가 이동해야 한다. 800px 안팎의 데스크톱 canvas에서 코-눈 상대 차이 절댓값 1 CSS px 이상을 목표로 하고 값이 작으면 map/offset을 허용 범위 안에서 조정한다. 코/눈/몸의 차이를 화면에서도 확인해야 한다.
- scale 0에서 displaced mesh가 RGB와 맞고 Depth map의 코/눈/귀/몸 위치가 원본과 정렬되는지 확인한다. 정상 이미지 로딩, flat fallback, video 유지와 반복 콘솔 오류 없음도 확인한다.
- 통과 판정 이전에는 단계 2를 구현하지 않는다.
## 단계 2: Content → RenderTarget → 기존 Glass
단계 1 통과 후 수행한다.
- contentScene에 depth mesh/배경을 두고 실제 이동 camera로 renderTarget에 렌더한다. 화면 출력을 위한 glassScene 또는 fullscreen surface는 별도로 둔다. contentScene에 glass 자체를 넣지 않아 feedback을 방지한다.
- renderer/RT DPR을 최대 1.5로 제한하고 resize 시 실제 drawing buffer size에 맞춘다. target은 교체/teardown에서 dispose한다.
- 기존 Mosaic 셀별 lens normal, variation, refract, viewOffset 보조 효과를 유지한다. Reel/Frosted도 source sampling만 scene texture로 전환한다. 새 입력 이름은 uSceneTexture 권장.
- renderTarget은 전체 viewport 이미지이다. 기존 hitUv의 pet-local UV를 그대로 RT UV로 사용하면 contain/카메라 이동이 어긋난다. world hit를 현재 content camera의 view-projection matrix로 투영하거나 screen-space straight UV + projection된 refracted displacement로 바꾼다. strength=0에서 screen pixel의 원본 scene 결과가 정확히 유지되는 것을 기준으로 삼는다.
- fullscreen quad를 택하면 실제 content camera 정보/virtual glass plane ray를 명시적으로 제공한다. post camera의 cameraPosition을 content camera로 잘못 사용하는 일이 없어야 한다. world-space GlassPlane을 유지하면 glass가 maximum displaced mesh보다 앞에 있도록 배치하고 나머지 화면에도 content가 보이게 한다.
- RT의 linear color를 glass에서 다시 sRGB decode하지 않는다. video decode는 content 단계에서 끝낸다. 최종 screen 출력만 colorspace 변환한다. 밝기/색이 No Glass와 strength0 Final에서 일치해야 한다.
- Final은 새 2-pass 결과, No Glass는 직접 content 렌더, RGB는 평면, Depth는 map, Glass Only는 중립 배경 위 lens 패턴의 진단이다. gridSize도 UI에 노출한다.
### 최종 검증
- stage1 parallax 게이트 재확인, Mosaic에서 좌우 이동 시 코/눈이 셀 안에서 달라지는 실제 화면 확인.
- strength0 Final vs No Glass의 정렬/색 일치, 세 효과 모두 scene target sampling 확인.
- 데스크톱/모바일 contain, resize, 사진 업로드→sample→video→image 전환, depth 교체, reset, build 및 반복 콘솔 오류 확인.
- 보고: 수정/신규 파일, depth 규칙과 수작업 한계, 128 mesh 및 z displacement, camera 이동, 2-pass와 lens 보존, 측정 결과, 실행 방법, 미실행 검증, AI provider를 교체할 위치를 간단히 정리한다.
-904
View File
@@ -1,904 +0,0 @@
[수정 작업지시서]
프로젝트명
Pet Glass / Pet Behind Glass
수정 목적
현재 진행 중인 구현은
"PetPlane 평면 Texture + GlassPlane Shader + viewOffset 기반 UV 왜곡"
방식으로 구성되어 있다.
하지만 이 방식만으로는
실제 사진에서 보이는 핵심 시각효과를 충분히 재현하기 어렵다.
실제 모자이크/굴절 유리에서 재미있는 변화는
유리 뒤 피사체의 눈, 코, 얼굴, 몸 등이 서로 다른 깊이에 있기 때문에
관찰 시점이 변할 때 각 부분이 서로 다른 위치로 보이는 데서 발생한다.
따라서 기존 구조를 전면 폐기하지 말고,
현재 구현을 기반으로 아래 방향으로 확장한다.
========================================
1. 핵심 변경사항
========================================
기존:
Camera
↓
Glass Plane
↓
Pet Plane (완전한 평면)
+
Pointer 위치를 uViewOffset으로 Shader에 전달
+
UV sampling 위치를 인위적으로 이동
변경:
Camera
↓
Glass Surface
↓
Depth-displaced Pet Mesh
↓
Background
즉,
Pet 이미지를 단순 평면으로 두지 않고
Depth Map을 이용하여 얕은 3D 형태의 Mesh로 변환한다.
카메라 이동에 따라
실제 perspective/parallax가 발생하도록 만들고,
Glass Shader는 이 결과를 굴절시킨다.
========================================
2. 기존 코드 유지 원칙
========================================
현재 작성된 코드를 전부 다시 만들지 않는다.
가능하면 다음 구조는 유지:
- SceneManager
- CameraController
- RendererManager
- MediaLoader
- PointerController
- GyroController
- GlassPlane
- 기존 UI
- 기존 Mosaic/Reeded/Frosted shader
수정 핵심은:
PetPlane
→
PetDepthMesh
로 확장하는 것.
기존 PetPlane 구현은 필요하면 fallback 용도로 남겨도 된다.
========================================
3. 새 구조
========================================
권장 구조:
src/
├─ scene/
│ ├─ SceneManager.ts
│ ├─ CameraController.ts
│ └─ RendererManager.ts
├─ objects/
│ ├─ PetPlane.ts
│ ├─ PetDepthMesh.ts // 신규
│ ├─ BackgroundPlane.ts // 선택
│ └─ GlassPlane.ts
├─ depth/
│ ├─ DepthMapLoader.ts // 신규
│ ├─ DepthProcessor.ts // 신규
│ └─ DepthProvider.ts // 신규 interface
├─ shaders/
│ ├─ common.vert
│ ├─ petDepth.vert // 신규
│ ├─ petDepth.frag // 필요시
│ ├─ mosaic.frag
│ ├─ reeded.frag
│ └─ frosted.frag
├─ input/
│ ├─ MediaLoader.ts
│ ├─ PointerController.ts
│ └─ GyroController.ts
└─ types/
├─ glass.ts
└─ depth.ts // 신규
========================================
4. Depth Map 우선 구현 전략
========================================
1차 수정에서는
AI depth estimation을 즉시 통합하지 않아도 된다.
먼저 Depth Map이 이미 존재한다고 가정하고
렌더링 파이프라인부터 완성한다.
즉:
RGB Image
+
Depth Image
두 장을 입력받아
Depth 기반 2.5D Mesh가 정상 동작하도록 구현한다.
개발 테스트용으로:
assets/
├─ sample-pet.jpg
└─ sample-pet-depth.png
형태를 지원.
실제 depth 이미지 파일이 없으면
gradient 또는 procedural depth texture를 생성해서
기능 검증 가능하도록 한다.
========================================
5. PetDepthMesh 구현
========================================
기존 PlaneGeometry보다 segment 수가 많은
PlaneGeometry를 사용.
예:
new THREE.PlaneGeometry(
width,
height,
128,
128
)
또는
256 x 256
모바일 성능을 고려하여
기본값은 128x128 정도에서 시작.
PetDepthMesh는:
- RGB Texture
- Depth Texture
- ShaderMaterial
- depthScale
- depthBias
를 가진다.
========================================
6. PetDepth Vertex Shader
========================================
새 vertex shader를 작성.
목적:
Depth Map의 값을 읽어서
각 vertex를 Z 방향으로 이동.
개념:
uniform sampler2D uDepthMap;
uniform float uDepthScale;
uniform float uDepthBias;
varying vec2 vUv;
void main() {
vUv = uv;
float depth =
texture2D(uDepthMap, uv).r;
depth =
(depth - uDepthBias)
* uDepthScale;
vec3 displacedPosition =
position;
displacedPosition.z += depth;
gl_Position =
projectionMatrix
* modelViewMatrix
* vec4(displacedPosition, 1.0);
}
주의:
Depth Map에 따라
흰색이 가까운지,
검정이 가까운지는
DepthProvider 쪽에서 통일할 것.
내부 규칙:
0.0 = 뒤
1.0 = 앞
으로 통일 권장.
========================================
7. Depth Scale
========================================
UI에 Depth Strength 또는 Depth Scale 추가.
예:
Depth
[----------]
기본값:
0.15 ~ 0.3 정도
목표는
실제 3D 모델처럼 과도하게 튀어나오게 만드는 것이 아니라
카메라 좌우 이동 시
코/눈/얼굴 간 parallax가 눈에 보이는 수준.
Depth Scale이 너무 크면
사진이 고무판처럼 찌그러져 보이므로
최대값 제한.
========================================
8. Camera 이동 방식 수정
========================================
기존에는 Pointer 값을
Shader의 uViewOffset에 직접 사용하는 비중이 컸다.
이제는 실제 Camera 이동을 우선한다.
예:
targetCameraX =
normalizedPointerX * maxCameraOffset;
targetCameraY =
normalizedPointerY * maxCameraOffsetY;
camera.position.x = lerp(...)
camera.position.y = lerp(...)
camera.lookAt(targetLookAt)
시점 변화의 핵심은
실제 PerspectiveCamera 위치 변화로 발생해야 한다.
========================================
9. Shader uViewOffset 역할 변경
========================================
기존 uViewOffset은 완전히 제거하지 않는다.
다만 역할을 변경.
기존:
uViewOffset이 시점 변화를 거의 전부 흉내냄
수정:
실제 Camera 이동이 기본 시점 변화를 담당
uViewOffset:
유리 표면 자체의 굴절 변화 보조용
즉,
Camera movement
=
피사체의 parallax
Glass uViewOffset
=
유리 렌즈를 비스듬히 보는 효과
이 두 가지를 분리.
========================================
10. Mosaic Shader 수정 방향
========================================
현재 Mosaic Shader가
단순하게 texture UV를 grid 단위로 왜곡한다면
구조를 수정.
중요:
이제 Glass Shader가 직접 원본 petTexture만 sampling하는 구조는
가능하면 줄인다.
최종 목표는:
PetDepthMesh가 먼저
카메라 기준으로 렌더링되고,
Glass 효과는
그 렌더 결과를 굴절시키는 구조.
즉:
PetDepth Scene
↓
Render Target
↓
Glass Shader
↓
Screen
========================================
11. RenderTarget 기반 구조
========================================
가능하면 이번 수정에서
WebGLRenderTarget 기반 2-pass rendering을 도입.
권장 파이프라인:
Pass 1:
PetDepthMesh + Background 렌더링
→ RenderTarget
Pass 2:
GlassPlane 또는 Fullscreen Quad
→ RenderTarget texture를 입력받아
굴절 처리
→ Screen
구조:
Scene A
- PetDepthMesh
- Background
↓ render
THREE.WebGLRenderTarget
↓ texture
Scene B
- Glass surface / fullscreen plane
↓ render
Screen
이 방식이 중요한 이유:
Glass Shader가
"원본 사진 Texture"를 직접 왜곡하는 것이 아니라
현재 카메라 시점에서 이미
Depth / parallax가 반영된 장면을
굴절시키게 됨.
========================================
12. SceneManager 수정
========================================
SceneManager를 필요하면 아래와 같이 분리.
- contentScene
- glassScene
또는
- contentScene
- postProcessScene
예:
private contentScene: THREE.Scene;
private glassScene: THREE.Scene;
private renderTarget: THREE.WebGLRenderTarget;
render loop:
renderer.setRenderTarget(renderTarget);
renderer.render(contentScene, camera);
renderer.setRenderTarget(null);
glassMaterial.uniforms.uSceneTexture.value =
renderTarget.texture;
renderer.render(glassScene, postCamera);
========================================
13. Glass Shader 입력 변경
========================================
기존:
uTexture = Pet Texture
수정 권장:
uSceneTexture = RenderTarget Texture
예:
uniform sampler2D uSceneTexture;
vec4 color =
texture2D(
uSceneTexture,
refractedUv
);
이렇게 해야
Depth 기반 시점변화가 적용된 결과 전체가
유리를 통해 보임.
========================================
14. Mosaic Lens 모델
========================================
Mosaic 셀마다
작은 독립 lens처럼 동작시키는 방향은 유지.
다만 기존처럼
"cell마다 petTexture UV 이동"
이 아니라
"현재 camera view가 반영된 scene texture를
cell마다 다른 refracted UV로 sample"
하도록 변경.
핵심:
vec2 gridUv = vUv * uGridSize;
vec2 cell = floor(gridUv);
vec2 local = fract(gridUv);
vec2 centered =
local - 0.5;
lens distortion 계산
+
view-dependent refraction
↓
sample uSceneTexture
========================================
15. 실제 시점 변화 확인 기준
========================================
아래 현상이 실제로 보여야 한다.
예:
강아지 얼굴에서
코:
depth 값 큼
눈:
중간
귀:
조금 뒤
몸:
더 뒤
사용자가 카메라를 왼쪽으로 이동하면
코가 가장 크게 이동
눈이 그보다 작게 이동
귀/몸이 더 작게 이동
그 결과
모자이크 셀 안에서
눈과 코가 서로 다른 위치에 나타남.
이 현상이 나오지 않으면
이번 수정 목적을 달성한 것이 아님.
========================================
16. Depth Debug Mode 추가
========================================
개발 편의를 위해
Debug UI 추가.
옵션:
View Mode:
- Final
- RGB
- Depth
- No Glass
- Glass Only
특히 Depth Map을
화면에 직접 확인할 수 있어야 함.
또한:
- depthScale
- gridSize
- glassStrength
- cameraOffset
값을 실시간 조절 가능하게 한다.
========================================
17. Background 처리
========================================
1차 구현에서는
사진 전체를 하나의 Depth Mesh로 사용해도 됨.
다만 구조적으로 향후:
Background
+
Pet Foreground
분리가 가능하도록 작성.
향후 개선:
Pet segmentation mask
↓
Pet Depth Mesh
Background
↓
Separate plane
지금은 필수 구현 아님.
========================================
18. Depth Map 생성 방식
========================================
이번 수정 범위에서는
DepthProvider 인터페이스만 정의.
예:
interface DepthProvider {
getDepthMap(
image: HTMLImageElement
): Promise<THREE.Texture>;
}
구현체:
StaticDepthProvider
- 현재는 사용자가 제공한 depth 이미지
- 또는 test asset 사용
향후:
AiDepthProvider
- Depth Anything
- MiDaS
- DPT
- ONNX Runtime Web
- Transformers.js
등으로 교체할 수 있도록 구조화.
========================================
19. AI Depth Estimation은 후순위
========================================
이번 작업에서
AI 모델 integration 때문에
렌더링 구조 완성이 늦어지지 않도록 한다.
우선순위:
1.
RGB + Depth Texture 입력
2.
PetDepthMesh 생성
3.
Camera parallax 확인
4.
RenderTarget 생성
5.
Glass shader 적용
6.
실제 Mosaic 시점 변화 확인
7.
그 후 AI Depth Provider 검토
========================================
20. Video 처리
========================================
동영상은 이번 수정에서
기존 VideoTexture 기능을 유지.
단,
동영상에 대해 매 프레임 depth estimation하는 것은
이번 범위에서 제외.
Video는 우선:
- RGB VideoTexture
- Flat depth
또는
- 사용자가 지정한 static depth map
으로 처리 가능.
동영상 depth estimation은
향후 별도 기능으로 남긴다.
========================================
21. 성능
========================================
Depth Mesh segment는
모바일 성능을 고려.
기본:
128 x 128
선택적으로:
64 x 64
128 x 128
256 x 256
Quality 설정 가능하게 해도 됨.
RenderTarget resolution도
devicePixelRatio 그대로 쓰지 말고
성능을 고려해 제한.
예:
Math.min(
window.devicePixelRatio,
1.5
)
========================================
22. 모바일 시점 제한
========================================
사진 기반 2.5D이므로
과도한 시점 이동 금지.
카메라 좌우 회전/이동은
약 ±5° ~ ±15° 범위 느낌으로 제한.
목표는
"옆에서 보는 느낌"
이지
"사진 주변을 3D 모델처럼 회전"
하는 것이 아님.
========================================
23. 기존 효과 재검증
========================================
Mosaic:
가장 중요
Reeded:
RenderTarget texture를 대상으로
X 방향 굴절
Frosted:
RenderTarget texture를 대상으로
blur/noise distortion
모든 효과는
원본 Texture가 아니라
가능하면 Scene RenderTarget을 기준으로 동작하도록 변경.
========================================
24. 완료 기준
========================================
수정 작업 완료 조건:
1.
기존 프로젝트가 정상 실행됨
2.
RGB Texture 로딩 가능
3.
Depth Texture 로딩 가능
4.
PetDepthMesh 생성됨
5.
Depth Map에 따라 Z displacement가 실제 적용됨
6.
Glass OFF 상태에서
카메라 좌우 이동 시
피사체 내부에 parallax가 보임
7.
코/눈/몸처럼 서로 다른 depth 영역이
다르게 이동함
8.
RenderTarget 기반 2-pass 구조 적용
9.
Mosaic Glass가
RenderTarget texture를 굴절시킴
10.
카메라 이동 시
Mosaic 셀 내부에 보이는 피사체가 동적으로 변경됨
11.
기존 단순 UV translate보다
확실한 깊이감 차이가 보임
12.
Depth Scale UI 동작
13.
Debug Depth View 제공
14.
기존 사진 업로드 기능 유지
15.
기존 동영상 기능이 깨지지 않음
16.
TypeScript 오류 없음
17.
콘솔 반복 오류 없음
========================================
25. 이번 수정에서 하지 않을 것
========================================
이번 작업에서는 아래는 후순위로 둔다.
- 완전한 3D pet reconstruction
- NeRF
- Gaussian Splatting
- multi-view reconstruction
- 실시간 video depth estimation
- 서버 AI 구축
- 복잡한 segmentation
- 물리적으로 완벽한 ray tracing
목표는
Monocular Depth 기반 2.5D로
실제 사진에서 느껴지는
"눈/코/얼굴의 서로 다른 깊이에 따른
시점별 유리 굴절 변화"
를 재현하는 것.
========================================
26. 가장 중요한 검증 포인트
========================================
이번 수정의 핵심은 아래다.
기존:
사진 전체가 평면이므로
카메라를 움직여도
눈/코/얼굴이 사실상 같은 깊이로 움직임.
수정 후:
Depth Map으로
코/눈/얼굴/몸의 Z 위치가 달라지고,
카메라 이동 시
각 영역에 서로 다른 parallax가 발생하며,
그 결과를
Mosaic Glass가 다시 굴절시킨다.
즉,
Depth
+
Perspective
+
Glass Refraction
세 요소가 실제로 연결되어야 한다.
========================================
27. 작업 완료 후 보고
========================================
작업 완료 후 아래를 정리해서 보고한다.
- 수정한 파일 목록
- 신규 파일 목록
- 기존 구조에서 변경된 부분
- PetDepthMesh 구현 방식
- RenderTarget 구성 방식
- Camera 이동 방식
- Mosaic Shader 변경점
- Depth Map 규칙
- 현재 한계
- AI Depth Estimation을 붙일 위치
- 다음 작업 추천
-43
View File
@@ -1,43 +0,0 @@
# Depth 수정 검증 기록
## Stage 1 — Glass OFF 선행 검증
사용자가 제공한 `public/assets/sample-pet.png`와 동일 크기의 수작업 `sample-pet-depth.png`를 사용했다. 테스트 depth는 AI 추정이나 실측 데이터가 아니다.
브라우저 No Glass에서 Depth Scale 0.3, 실제 camera x=-0.6/+0.6, z=3, canvas 폭 985 CSS px 조건으로 확인했다. 같은 카메라 조건에서 scale=0 대조군도 확인했다. 사진의 코/얼굴/몸이 서로 다르게 움직이는 것을 좌우 화면에서 관찰했고, Depth 보기의 코와 눈 영역이 사진 방향과 일치하는 것을 확인했다.
| 지점 | depth .3 왼쪽 NDC x | depth .3 오른쪽 NDC x | depth 0 왼쪽 NDC x | depth 0 오른쪽 NDC x |
|---|---:|---:|---:|---:|
| 코 | .116 | .004 | .054 | .055 |
| 눈 사이 중앙부 | .069 | -.004 | .031 | .031 |
| 몸 | -.125 | -.154 | -.137 | -.134 |
깊이로 추가된 좌우 이동은 `(오른쪽-왼쪽 at .3) - (오른쪽-왼쪽 at 0)`으로 계산한다. NDC에 canvas 폭/2를 곱하면 CSS px이다. 코와 몸의 추가 상대 이동 차이는 약 39.9 CSS px이다. 화면 수치는 NDC 소수 3자리 반올림이므로 근삿값이다.
초기 진단의 'eyes'는 실제 양쪽 눈 대신 눈 사이 중앙부였으므로, 이를 실제 눈의 측정 결과로 보고하지 않는다. 최종 진단에서는 양쪽 눈 좌표를 따로 사용해야 한다.
Astra는 위 실제 화면 관찰과 flat 대조 결과를 근거로 Stage 2 착수를 승인했다. 이 검증 후에만 RenderTarget/Glass 연결 작업을 시작했다.
## 최종 검증
Stage 2 후 같은 사진과 실제 양쪽 눈 좌표로 확인한 CSS pixel 진단 결과다. 화면 크기가 달라 앞선 Stage 1과 절대 픽셀 크기는 다르다.
| 지점 | .3 왼쪽 | .3 오른쪽 | 0 왼쪽 | 0 오른쪽 |
|---|---:|---:|---:|---:|
| 코 | 542.938 | 501.965 | 520.336 | 520.504 |
| 왼쪽 눈 | 506.793 | 479.203 | 493.428 | 493.445 |
| 오른쪽 눈 | 546.265 | 519.088 | 530.441 | 530.820 |
| 몸 | 454.311 | 443.692 | 449.908 | 450.902 |
Depth로 추가된 좌우 이동은 코 -41.141px, 왼쪽 눈 -27.607px, 오른쪽 눈 -27.556px, 몸 -11.613px다. 코와 양쪽 눈 평균 차이는 약 13.56px, 코와 몸 차이는 약 29.53px다. Depth 데이터/실제 카메라의 투영 진단값이며 GPU 메시 삼각형 보간 및 화면 특징점 추적과는 작은 차이가 있을 수 있다.
확인한 항목:
- 사용자 사진이 RenderTarget을 통해 Mosaic/Reeded/Frosted로 렌더됨.
- Mosaic 강도 0 Final과 No Glass의 위치·색상이 육안상 일치. 픽셀 단위 이미지 차이 검사는 수행하지 않음.
- Depth Scale 0/.3, camera offset ±.6, reset, 시점 이동, 효과 전환.
- 별도 RGB와 Depth PNG 업로드 성공. 비율이 다른 Depth 입력은 오류 표시.
- 390×844 모바일 viewport에서 가로 넘침 없음(document width 375, viewport width 390; 세로 스크롤바 포함). 물리적 터치 기기 테스트는 아님.
- 최종 효과 전환 중 브라우저 error 로그 빈 배열 확인. TypeScript와 Vite production build 통과.
미검증: 실제 영상 재생/loop, 실기기 gyro/권한 UI, 60fps 실측. 영상·자원 수명·요청 경쟁은 코드 검토했으며 모든 경쟁 타이밍을 런타임 테스트했다고 주장하지 않는다.
-23
View File
@@ -1,23 +0,0 @@
# 유리와 피사체 간격에 따른 선명도 — 이전 실험 기록
> 현재 동작: 사용자의 Ref1 방향 결정에 따라 가까운 표면의 색면 효과를 약화하던 거리 감쇠는 제거됐다. 아래 설계와 브라우저 관찰은 감쇠가 있던 이전 구현의 기록이며, 현재 동작 설명으로 사용하지 않는다. 유효성 검사만 남아 유효한 표면에는 유리 간격과 무관하게 설정된 색면 강도가 적용된다.
## 목표
당시 목표는 표면 간격으로 색면 강도를 달리하는 것이었다. 이 거리 의존성은 제거 결정으로 폐기됐다.
## 설계
이전 구현은 장면 RenderTarget의 깊이를 DepthTexture로 전달하고 최종 굴절 RGB 샘플과 같은 화면 좌표의 깊이를 역투영했다. 복원한 유리-표면 간격에 따라 색면 강도를 정했으나, 현재는 간격에 따른 혼합 강도를 적용하지 않는다. 깊이 텍스처는 범위 밖 UV, 배경, 역투영 실패, 유리 앞 또는 접촉 표면을 걸러내는 데만 사용한다.
색면 0은 기존 굴절 출력을 유지하고 Distortion 0은 무효과 출력을 유지한다. 배경 깊이는 유효한 피사체로 취급하지 않는다. 카메라 이동 시 현재 렌더링 깊이로 다시 계산한다.
이는 화면에 보이는 깊이 기반 근사다. 유리 두께의 입출사 양면이나 굴절 광선과 전체 입체 표면의 정확한 교차를 계산하는 광선 추적은 아니다. 한 장의 RGB+상대 depth로 가려진 옆면을 새로 생성하지 않는다. 거리 단위는 장면 내부 값이며 실제 센티미터가 아니다.
## 브라우저 확인
2026-09-14, 5173의 멍멍이3/자동 AI depth/Grid10/Distortion0.9/색면1/광택0.3/사진확대1/팬0으로 확인했다. 변경 전에는 두 얼굴 모두 강하게 양자화됐다. 변경 후 앞쪽 왼쪽 강아지의 눈·코 명암은 유지되고 뒤쪽 강아지와 배경은 강하게 단순화된다.
Depth Scale을 0으로 바꾸어 전체 사진을 최소 간격에 놓으면 색면1에서도 사진 전반의 세부가 복원된다. 0.25로 복원하면 깊이에 따른 대비가 다시 나타난다. 수평 드래그 후에도 깊이에 맞는 처리와 렌더링이 유지됐으며 오류 로그는 비어 있었다. 시점 변화가 항상 큰 흐림 변화를 일으키는 것은 아니다. 정량적인 동영상 안정성이나 실제 모바일 성능은 이번 검증에 포함하지 않았다.
위 관찰은 거리 감쇠가 활성화돼 있던 이전 버전의 결과다. 현재 구현에서는 이 곡선과 거리 기반 선명도 설명이 더는 적용되지 않는다.
-10
View File
@@ -1,10 +0,0 @@
# 고정 창틀 검증
2026-09-11. Astra 설계, GPT-5.6 Luna 구현.
- `src/main.ts`: 유리판의 `uPaneSize`와 같은 월드 좌표에 두께가 있는 갈색 창틀 4개를 배치했다. 사진 zoom/pan과 독립적이다.
- Glass 합성 후 같은 원근 카메라로 별도 frameScene을 렌더링한다. 창틀이 굴절 입력에 포함되지 않는다. 공유 geometry/material은 teardown에서 dispose한다.
- Final/Glass Only에서 표시하며 No Glass/RGB/Depth 진단 모드에는 표시하지 않는다.
- Luna의 `npm.cmd run build` 통과.
- 실제 브라우저: 샘플 사진 기본 화면, 우측 드래그, 사진 확대 1.8 및 가로 위치 0.5 변경에서 창틀과 Mosaic 경계 결합 확인. No Glass에서 창틀 제거 확인. 390×844 모바일 표시 확인.
- 카메라 제어 및 기존 셰이더는 변경하지 않았다. Reeded/Frosted의 기존 fullscreen 출력은 창틀 외부까지 이어질 수 있다. 목재 텍스처나 그림자는 구현하지 않고 면별 갈색 명암으로 깊이를 표현한다.
-42
View File
@@ -1,42 +0,0 @@
# 유리 바로 뒤의 피사체 배치
2026-10-06: 고정 간격 0.04를 기본으로 보존하면서 사진–유리 거리 슬라이더(0–100%, 장면 간격 0.04–0.60)를 추가했다. RGB/Depth 공통 배치와 자동 구도 금지를 유지한다. 최신 구현과 검증은 [거리 조절 기록](qa-photo-glass-distance-2026-10-06/report.md)을 따른다. 아래 고정 근접 배치와 자동 구도 실험은 과거 기록이다.
최신 변경: 사용자가 자동 구도 조정을 제외하도록 요청했다. 아래 자동 구도 보완·검증은 과거 실험 기록이다. 현재 동작에서는 사진별 확대 프리셋과 AI 앞쪽 영역 자동 확대/팬을 제거하고, 입력 사진 구도와 실제 유리 근접 배치만 유지한다. 수동 구도 조절은 가능하다.
제거 검증: `src/main.ts`와 `src/depth/DepthProcessor.ts`의 자동 구도 경로·미사용 분석 도우미를 제거했다. 기존 수작업 샘플도 확대1/팬0으로 입력된다. 실제5173에서 sample의 간격0.040 유지, dog3 자동 AI 완료 후에도 확대1/가로0/세로0 및 Final 유지, 오류 로그없음을 확인했다. 빌드·타입 검사 통과.
사용자 정정: 얼굴 확대 프리셋이 목적이 아니다. 사진을 넣었을 때 강아지 얼굴이 유리 바로 뒤에 가까이 있는 공간감을 원한다. 이전 구도 확대만의 처리를 충분한 해결로 보지 않는다.
이번 구현 기준:
- 유리 z=0.6은 유지하고 추정 깊이의 가장 앞 표면을 z=0.56에 배치한다. baseZ=0.56-(maxDepth-depthBias)*depthScale.
- RGB/depth는 같은 변환을 적용한다. 평면 사진의 정면 크기는 거리 보정으로 유지하여 단순 확대와 구분한다.
- 세 유리 효과의 굴절 교차 기준도 실제 배치한 기준 평면으로 옮긴다. 색면 처리와 곡면 법선은 유지한다.
- 새 정지 사진에 기존 브라우저 로컬 AI depth를 자동 적용한다. 수작업 depth가 있는 샘플과 영상은 제외한다. 자동 완료는 사용자가 선택한 보기 모드를 바꾸지 않는다.
- dog1만의 고정 확대 프리셋은 제거한다. 기존 일반 구도 조절과 수작업 샘플 프리셋은 유지한다.
검증 기준: No Glass에서 앞 표면 위치와 눈·코 시차를 먼저 확인, depth강도0/0.25/0.4에서 유리 관통 없음, 이후 Final/다른 유리 효과 확인, 사진 교체 시 오래된 AI 결과 배제, 오류 시 평면 배치와 상태 안내.
모델은 상대 깊이를 추정하며 얼굴 검출기는 아니다. 가장 가까운 부분이 앞발이나 다른 물체라면 얼굴만을 선택해 붙이는 것을 보장하지 않는다. 실제 굴절은 여전히 깊이 표면의 정밀 광선 교차가 아니라 기준 평면을 이용한 근사다. 이 두 한계를 숨기지 않는다.
## 실제 배치 검증
- No Glass 수작업 샘플: depth0.25에서 baseZ0.315, 코0.560, 눈0.485, 몸0.389. 유리와 가장 앞 표면 간격0.040.
- depth0에서는 모든 표면0.560, depth0.4에서는 baseZ0.168/앞표면0.560. 관통 없음.
- 카메라X0→0.051에서 코 투영X329.980→326.555, 왼눈264.823→261.986, 몸167.359→165.523. 같은 평행 이동이 아닌 깊이별 시차를 확인했다.
- 이후 Mosaic/Reeded/Frosted의 실제 출력과 오류 로그없음을 확인했다.
- dog1은 버튼 추가 클릭 없이 자동 AI 추정 완료 후 Final 유지. 추정 중 dog2→수작업 sample 교체 시 sample 상태가 유지되어 오래된 결과가 덮어쓰지 않았다.
## 자동 구도 보완
거리 배치만 바꾸고 원래 전신 구도를 유지한 첫 결과는 여전히 얼굴이 작아 시각적 목표가 부족했다. 따라서 고정 dog1 프리셋 대신 자동 AI 깊이의 앞쪽 연결 영역을 찾아, 그 영역 중심으로 구도를 맞추는 보완을 한다. 실제 유리 근접 배치와 자동 구도를 함께 적용한다. 앞쪽의 작은 코만 잘리는 것을 피하려고 여유를 두고 최대 확대를 제한한다. 사용자가 추정 대기 중 구도를 직접 바꾸면 자동 구도는 적용하지 않는다.
## 최종 구현과 검증
- 변경: `src/main.ts`, `src/depth/DepthProcessor.ts`, `src/depth/DepthDebug.ts`, 세 유리 fragment shader. 원본 사진은 변경하지 않았다.
- 자동 구도: 깊이 최대값에서 전체 범위의12% 이내인 표본을 사용한다. 최대변128의 분석 격자에서 가장 큰8연결영역을 선택한다. 깊이범위0.1 미만 또는 면적0.5% 미만이면 자동 구도를 건너뛴다. 경계에1.25배 여유, 깊이 원근 보정, 최대3배 확대를 적용한다.
- 실제 5173에서 dog1을 다시 불러오는 것만으로 로컬 AI 처리와 Final 유지를 확인했다. 수동 확대 없이 약2.05배/가로-0.21/세로-0.31이 계산되었고 얼굴이 유리 대부분을 채웠다. 색면1/Grid14에서 정면·드래그·복귀 화면을 확인했다.
- 별도 탭에서 dog2 추정 대기 중 확대1.5/세로0.17 및 No Glass를 선택했다. AI 완료 후에도 세 설정이 유지됐다. 오류 로그없음.
- 구현 에이전트의 실제 DepthProcessor 임시 실행 검증: 평면→null, 위쪽 연결영역의 좌표/범위 정확성, 작은 이상치가 있는 경우, 분리된 여러 영역 중 큰 영역 선택을 확인했다. 별도 테스트 프레임워크는 추가하지 않았다.
- 최종 빌드·타입 검사 통과. 기존 큰 번들 안내는 남는다. AI 실패의 안내/폴백은 소스 검토했고 네트워크 장애를 강제로 만들어 재현하지 않았다. 실기기 모바일과 영상 성능은 이번 턴에서 다시 측정하지 않았다.
- 현재 사용자 화면은 자동 AI·자동 구도가 적용된 dog1 Final이다. 이 결과는 단순 확대 프리셋이 아니라 실제 깊이 근접 배치와 깊이에 따른 자동 구도의 조합이다. 얼굴 인식을 보장하는 모델로 소개하지 않는다.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

-240
View File
@@ -1,240 +0,0 @@
import * as THREE from 'three';
import './style.css';
import vertexShader from './shaders/common.vert?raw';
import mosaicShader from './shaders/mosaic.frag?raw';
import reededShader from './shaders/reeded.frag?raw';
import frostedShader from './shaders/frosted.frag?raw';
import { MediaLoader } from './input/MediaLoader';
import { PointerController } from './input/PointerController';
import { CameraController } from './input/CameraController';
import { GyroController } from './input/GyroController';
import { StaticDepthProvider } from './depth/StaticDepthProvider';
import { DepthProcessor } from './depth/DepthProcessor';
import { PetDepthMesh } from './objects/PetDepthMesh';
import { DepthDebug } from './depth/DepthDebug';
import glassScreenVertex from './shaders/glassScreen.vert?raw';
const wrap = document.querySelector('#canvas-wrap') as HTMLElement;
const status = document.querySelector('#status') as HTMLElement;
const depthProvenance = document.querySelector('#depth-provenance') as HTMLElement;
const empty = document.querySelector('#empty') as HTMLElement;
const hintElement = document.querySelector('#hint') as HTMLElement;
hintElement.style.opacity = '0';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
wrap.append(renderer.domElement);
const scene = new THREE.Scene();
const glassScene = new THREE.Scene();
const frameScene = new THREE.Scene();
scene.background = new THREE.Color(0x111923);
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
camera.position.z = 3;
const geometry = new THREE.PlaneGeometry(1, 1);
const emptyTex = new THREE.DataTexture(new Uint8Array([38, 48, 62, 255]), 1, 1);
emptyTex.needsUpdate = true;
const petMat = new THREE.MeshBasicMaterial({ map: emptyTex });
const pet = new THREE.Mesh(geometry, petMat);
scene.add(pet);
const depthMap: THREE.Texture = DepthProcessor.flatTexture();
const petDepth = new PetDepthMesh(emptyTex, depthMap, 0.25, 0);
const petDepthMaterial = petDepth.material;
const depthUniforms = petDepthMaterial.uniforms;
scene.add(petDepth);
const uniforms = { uSceneTexture: { value: emptyTex as THREE.Texture }, uContentViewProjection: { value: new THREE.Matrix4() }, uContentInverseViewProjection: { value: new THREE.Matrix4() }, uContentCameraPosition: { value: new THREE.Vector3() }, uLightPosition: { value: new THREE.Vector3(0.6, 0.8, 2.6) }, uBackgroundColor: { value: scene.background.clone() }, uGlassZ: { value: 0.6 }, uGridSize: { value: 10 }, uMosaicStyle: { value: 0 }, uStrength: { value: 0.9 }, uGlossStrength: { value: 0.3 }, uCurvature: { value: 0.65 }, uViewOffset: { value: new THREE.Vector2() }, uPaneRect: { value: new THREE.Vector4(0, 0, 1, 1) }, uResolution: { value: new THREE.Vector2() }, uTime: { value: 0 }, uPetSize: { value: new THREE.Vector2(1, 1) }, uPaneSize: { value: new THREE.Vector2(1, 1) } };
const glassMat = new THREE.ShaderMaterial({ uniforms, vertexShader: glassScreenVertex, fragmentShader: mosaicShader });
const glass = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), glassMat);
glassScene.add(glass);
const frameGeometry = new THREE.BoxGeometry(1, 1, 1);
const frameMaterials = [
new THREE.MeshBasicMaterial({ color: 0x3a2419 }),
new THREE.MeshBasicMaterial({ color: 0x4a2e20 }),
new THREE.MeshBasicMaterial({ color: 0x2b1a13 }),
new THREE.MeshBasicMaterial({ color: 0x513326 }),
new THREE.MeshBasicMaterial({ color: 0x321f17 }),
new THREE.MeshBasicMaterial({ color: 0x43291d })
];
const frameLeft = new THREE.Mesh(frameGeometry, frameMaterials);
const frameRight = new THREE.Mesh(frameGeometry, frameMaterials);
const frameTop = new THREE.Mesh(frameGeometry, frameMaterials);
const frameBottom = new THREE.Mesh(frameGeometry, frameMaterials);
frameScene.add(frameLeft, frameRight, frameTop, frameBottom);
const postCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const renderTarget = new THREE.WebGLRenderTarget(1, 1, { colorSpace: THREE.NoColorSpace, type: THREE.HalfFloatType, depthBuffer: true });
let active: THREE.Texture = emptyTex;
let aspect = 1;
let baseW = 1;
let baseH = 1;
let photoZoom = 1;
let photoPanX = 0;
let photoPanY = 0;
let pendingSampleFraming = false;
let mode = 'noglass';
let hintTimeout = 0;
let pendingPairDepth: THREE.Texture | null = null;
let mediaRequest = 0;
let depthRequest = 0;
let disposed = false;
const listenerAbort = new AbortController();
function clearPendingPair(): void { if (pendingPairDepth) pendingPairDepth.dispose(); pendingPairDepth = null; }
function applyFraming(): void {
const x = baseW * (photoZoom - 1) * 0.5 * photoPanX;
const y = baseH * (photoZoom - 1) * 0.5 * photoPanY;
pet.scale.set(baseW * photoZoom, baseH * photoZoom, 1);
pet.position.set(x, y, 0);
petDepth.scale.copy(pet.scale);
petDepth.position.copy(pet.position);
}
function resize(): void {
const bounds = wrap.getBoundingClientRect();
renderer.setSize(bounds.width, bounds.height, false);
camera.aspect = bounds.width / bounds.height;
camera.updateProjectionMatrix();
const height = 1.7;
const scale = Math.min(height * bounds.width / bounds.height / aspect, height);
baseW = scale * aspect;
baseH = scale;
applyFraming();
uniforms.uPetSize.value.set(baseW, baseH);
uniforms.uResolution.value.set(bounds.width, bounds.height);
const glassHeight = 2 * (camera.position.z - 0.6) * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
uniforms.uPetSize.value.set(glassHeight * camera.aspect, glassHeight);
uniforms.uPaneSize.value.set(baseW * (3 - uniforms.uGlassZ.value) / 3, baseH * (3 - uniforms.uGlassZ.value) / 3);
const frameW = uniforms.uPaneSize.value.x;
const frameH = uniforms.uPaneSize.value.y;
const frameThickness = Math.min(frameW, frameH) * 0.035;
const frameDepth = frameThickness * 1.5;
const frameZ = uniforms.uGlassZ.value + frameDepth * 0.25;
frameLeft.position.set(-(frameW + frameThickness) * 0.5, 0, frameZ);
frameRight.position.set((frameW + frameThickness) * 0.5, 0, frameZ);
frameTop.position.set(0, (frameH + frameThickness) * 0.5, frameZ);
frameBottom.position.set(0, -(frameH + frameThickness) * 0.5, frameZ);
frameLeft.scale.set(frameThickness, frameH, frameDepth);
frameRight.scale.set(frameThickness, frameH, frameDepth);
frameTop.scale.set(frameW + frameThickness * 2, frameThickness, frameDepth);
frameBottom.scale.set(frameW + frameThickness * 2, frameThickness, frameDepth);
const neutralGlassHeight = 2 * 3 * Math.tan(THREE.MathUtils.degToRad(40 / 2));
const neutralGlassWidth = neutralGlassHeight * camera.aspect;
uniforms.uPaneRect.value.set(Math.max(0, 0.5 - baseW / (2 * neutralGlassWidth)), Math.max(0, 0.5 - baseH / (2 * neutralGlassHeight)), Math.min(1, 0.5 + baseW / (2 * neutralGlassWidth)), Math.min(1, 0.5 + baseH / (2 * neutralGlassHeight)));
renderTarget.setSize(Math.max(1, Math.floor(bounds.width * Math.min(devicePixelRatio, 1.5))), Math.max(1, Math.floor(bounds.height * Math.min(devicePixelRatio, 1.5))));
}
function hint(): void {
window.clearTimeout(hintTimeout);
hintElement.style.opacity = '1';
hintTimeout = window.setTimeout(() => { hintElement.style.opacity = '0'; }, 2000);
}
function applyMedia(texture: THREE.Texture, mediaAspect: number, video: HTMLVideoElement | null): void {
if (active !== emptyTex) active.dispose();
active = texture; aspect = mediaAspect;
if (pendingSampleFraming) { photoZoom = 2.4; photoPanX = -0.10; photoPanY = -0.35; pendingSampleFraming = false; } else { photoZoom = 1; photoPanX = 0; photoPanY = 0; }
syncPhotoControls();
texture.colorSpace = THREE.SRGBColorSpace;
depthUniforms.uTexture.value = texture;
petMat.map = texture; petMat.needsUpdate = true;
empty.style.display = 'none'; resize();
}
function setDepth(texture: THREE.Texture): void {
if (depthUniforms.uDepthMap.value !== depthMap) depthUniforms.uDepthMap.value.dispose();
depthUniforms.uDepthMap.value = texture; texture.colorSpace = THREE.NoColorSpace;
if (mode === 'depth') { petMat.map = texture; petMat.needsUpdate = true; }
}
function setMode(next: string): void {
mode = next;
if (modeInput) modeInput.value = next;
pet.visible = next === 'rgb' || next === 'depth';
petDepth.visible = next === 'noglass' || next === 'final';
glass.visible = next === 'final' || next === 'glass';
petMat.map = next === 'depth' ? depthUniforms.uDepthMap.value : active;
petMat.needsUpdate = true;
petDepthMaterial.uniforms.uTexture.value = active;
}
const retry = document.createElement('button');
retry.textContent = '재생'; retry.hidden = true;
(document.querySelector('aside') as HTMLElement).append(retry);
const debugOutput = document.createElement('pre');
debugOutput.id = 'depth-debug';
debugOutput.hidden = true;
(document.querySelector('aside') as HTMLElement).append(debugOutput);
const depthDebug = new DepthDebug();
const loader = new MediaLoader((texture, mediaAspect, video) => { const hasSampleDepth = pendingSampleFraming && pendingPairDepth !== null; applyMedia(texture, mediaAspect, video); petDepth.setVideo(Boolean(video)); setDepth(pendingPairDepth ?? DepthProcessor.flatTexture()); pendingPairDepth = null; depthProvenance.hidden = !hasSampleDepth; if (hasSampleDepth) depthProvenance.textContent = '수작업 테스트 Depth · AI 추정 아님'; setMode('final'); hint(); }, (message) => { status.textContent = message; retry.hidden = !message.includes('자동'); });
retry.onclick = () => loader.retry();
const sampleDepthProvider = new StaticDepthProvider('/assets/sample-pet-depth.png');
const cameraController = new CameraController(camera);
const pointer = new PointerController(renderer.domElement, (x, y) => cameraController.add(x, y));
const gyro = new GyroController((x, y) => cameraController.target.set(THREE.MathUtils.clamp(x, -1, 1), THREE.MathUtils.clamp(y, -1, 1)), (message) => { status.textContent = message; });
const imageInput = document.querySelector('#image') as HTMLInputElement;
const videoInput = document.querySelector('#video') as HTMLInputElement;
const depthFileInput = document.querySelector('#depth-image') as HTMLInputElement;
const sampleButton = document.querySelector('#sample') as HTMLButtonElement | null;
const effectInput = document.querySelector('#effect') as HTMLSelectElement;
const modeInput = document.querySelector('#view-mode') as HTMLSelectElement | null;
const strengthInput = document.querySelector('#strength') as HTMLInputElement;
const sensitivityInput = document.querySelector('#sensitivity') as HTMLInputElement;
const depthInput = document.querySelector('#depth-scale') as HTMLInputElement | null;
const cameraOffsetInput = document.querySelector('#camera-offset') as HTMLInputElement;
const gridInput = document.querySelector('#grid-size') as HTMLInputElement;
const gridOutput = document.querySelector('#gridOut') as HTMLOutputElement;
const mosaicStyleInput = document.querySelector('#mosaic-style') as HTMLSelectElement;
const glossControls = document.querySelector('#gloss-controls') as HTMLElement | null;
const curvatureInput = document.querySelector('#curvature') as HTMLInputElement | null;
const glossInput = document.querySelector('#gloss-strength') as HTMLInputElement | null;
const lightXInput = document.querySelector('#light-x') as HTMLInputElement | null;
const lightYInput = document.querySelector('#light-y') as HTMLInputElement | null;
const glossOutput = document.querySelector('#glossOut') as HTMLOutputElement | null;
const lightXOutput = document.querySelector('#lightXOut') as HTMLOutputElement | null;
const lightYOutput = document.querySelector('#lightYOut') as HTMLOutputElement | null;
const cameraOutput = document.querySelector('#cameraOut') as HTMLOutputElement;
const strengthOutput = document.querySelector('#strengthOut') as HTMLOutputElement;
const sensitivityOutput = document.querySelector('#sensitivityOut') as HTMLOutputElement;
const depthOutput = document.querySelector('#depthOut') as HTMLOutputElement | null;
const photoZoomInput = document.querySelector('#photo-zoom') as HTMLInputElement | null;
const photoZoomOutput = document.querySelector('#photoZoomOut') as HTMLOutputElement | null;
const photoPanXInput = document.querySelector('#photo-pan-x') as HTMLInputElement | null;
const photoPanXOutput = document.querySelector('#photoPanXOut') as HTMLOutputElement | null;
const photoPanYInput = document.querySelector('#photo-pan-y') as HTMLInputElement | null;
const photoPanYOutput = document.querySelector('#photoPanYOut') as HTMLOutputElement | null;
const photoResetButton = document.querySelector('#photo-reset') as HTMLButtonElement | null;
const resetButton = document.querySelector('#reset') as HTMLButtonElement;
const gyroButton = document.querySelector('#gyro') as HTMLButtonElement;
const effects = [mosaicShader, reededShader, frostedShader];
let sensitivity = 1;
function setGlossControlsVisibility(): void {
const enabled = effectInput.value === 'mosaic';
if (glossControls) { glossControls.hidden = !enabled; glossControls.style.display = enabled ? 'grid' : 'none'; }
[glossInput, lightXInput, lightYInput, curvatureInput].forEach((input) => { if (input) input.disabled = !enabled; });
}
function syncPhotoControls(): void {
if (photoZoomInput) photoZoomInput.value = photoZoom.toFixed(2);
if (photoZoomOutput) photoZoomOutput.value = photoZoom.toFixed(2);
if (photoPanXInput) photoPanXInput.value = photoPanX.toFixed(2);
if (photoPanXOutput) photoPanXOutput.value = photoPanX.toFixed(2);
if (photoPanYInput) photoPanYInput.value = photoPanY.toFixed(2);
if (photoPanYOutput) photoPanYOutput.value = photoPanY.toFixed(2);
}
imageInput.onchange = (event) => { const input = event.target as HTMLInputElement; pendingSampleFraming = false; mediaRequest += 1; depthRequest += 1; clearPendingPair(); depthDebug.clear(); if (input.files?.[0]) loader.load(input.files[0]); input.value = ''; };
videoInput.onchange = (event) => { const input = event.target as HTMLInputElement; pendingSampleFraming = false; mediaRequest += 1; depthRequest += 1; clearPendingPair(); depthDebug.clear(); if (input.files?.[0]) loader.load(input.files[0]); input.value = ''; };
photoZoomInput?.addEventListener('input', (event) => { photoZoom = Number((event.target as HTMLInputElement).value); applyFraming(); syncPhotoControls(); }, { signal: listenerAbort.signal });
photoPanXInput?.addEventListener('input', (event) => { photoPanX = Number((event.target as HTMLInputElement).value); applyFraming(); syncPhotoControls(); }, { signal: listenerAbort.signal });
photoPanYInput?.addEventListener('input', (event) => { photoPanY = Number((event.target as HTMLInputElement).value); applyFraming(); syncPhotoControls(); }, { signal: listenerAbort.signal });
photoResetButton?.addEventListener('click', () => { photoZoom = 1; photoPanX = 0; photoPanY = 0; applyFraming(); syncPhotoControls(); }, { signal: listenerAbort.signal });
sampleButton?.addEventListener('click', async () => { const request = ++mediaRequest; depthRequest += 1; clearPendingPair(); depthDebug.clear(); let url = ''; try { const [rgb, depth] = await Promise.all([fetch('/assets/sample-pet.png').then((r) => r.blob()), fetch('/assets/sample-pet-depth.png').then((r) => r.blob())]); if (disposed || request !== mediaRequest) return; url = URL.createObjectURL(depth); const image = new Image(); image.src = url; await image.decode(); if (disposed || request !== mediaRequest) return; const loadedDepth = await sampleDepthProvider.getDepthMap(image); if (disposed || request !== mediaRequest) { loadedDepth.dispose(); return; } clearPendingPair(); pendingPairDepth = loadedDepth; depthDebug.setSample(image); pendingSampleFraming = true; loader.load(new File([rgb], 'sample-pet.png', { type: 'image/png' })); status.textContent = '수작업 테스트 Depth · AI추정 아님'; } catch { if (!disposed && request === mediaRequest) status.textContent = '샘플 depth map을 읽지 못했습니다.'; } finally { if (url) URL.revokeObjectURL(url); } }, { signal: listenerAbort.signal });
effectInput.addEventListener('change', (event) => { const index = ['mosaic', 'reeded', 'frosted'].indexOf((event.target as HTMLSelectElement).value); glassMat.fragmentShader = effects[index]; glassMat.needsUpdate = true; setGlossControlsVisibility(); }, { signal: listenerAbort.signal });
modeInput?.addEventListener('change', (event) => setMode((event.target as HTMLSelectElement).value), { signal: listenerAbort.signal });
strengthInput.oninput = (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uStrength.value = value; strengthOutput.value = value.toFixed(2); };
sensitivityInput.oninput = (event) => { const value = Number((event.target as HTMLInputElement).value); sensitivity = value; sensitivityOutput.value = value.toFixed(2); };
depthInput?.addEventListener('input', (event) => { const value = Math.min(Number((event.target as HTMLInputElement).value), 0.4); depthUniforms.uDepthScale.value = value; if (depthOutput) depthOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
cameraOffsetInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); cameraController.setMaxOffset(value); cameraOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
gridInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGridSize.value = value; gridOutput.value = value.toFixed(0); }, { signal: listenerAbort.signal });
mosaicStyleInput.addEventListener('change', (event) => { const style = Number((event.target as HTMLSelectElement).value); uniforms.uMosaicStyle.value = style; uniforms.uGridSize.value = style === 0 ? 10 : 28; uniforms.uStrength.value = style === 0 ? 0.9 : 0.85; gridInput.value = String(uniforms.uGridSize.value); gridOutput.value = String(uniforms.uGridSize.value); strengthInput.value = String(uniforms.uStrength.value); strengthOutput.value = uniforms.uStrength.value.toFixed(2); }, { signal: listenerAbort.signal });
curvatureInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uCurvature.value = value; const output = document.querySelector('#curvatureOut') as HTMLOutputElement | null; if (output) output.value = value.toFixed(2); }, { signal: listenerAbort.signal });
glossInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGlossStrength.value = value; if (glossOutput) glossOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
lightXInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uLightPosition.value.x = value; if (lightXOutput) lightXOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
lightYInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uLightPosition.value.y = value; if (lightYOutput) lightYOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
depthFileInput?.addEventListener('change', async () => { const request = ++depthRequest; const file = depthFileInput.files?.[0]; if (!file) return; depthDebug.clear(); const url = URL.createObjectURL(file); let texture: THREE.Texture | null = null; try { const image = new Image(); image.src = url; await image.decode(); if (disposed || request !== depthRequest) return; if (aspect > 0 && Math.abs(image.width / image.height - aspect) > 0.02) throw new Error('aspect'); texture = DepthProcessor.toTexture(image); if (disposed || request !== depthRequest) { texture.dispose(); texture = null; return; } setDepth(texture); texture = null; depthProvenance.hidden = true; status.textContent = '수동 depth map 적용됨 (0=뒤, 1=앞)'; } catch { if (!disposed && request === depthRequest) status.textContent = 'RGB와 종횡비가 다른 depth map입니다.'; } finally { texture?.dispose(); URL.revokeObjectURL(url); depthFileInput.value = ''; } }, { signal: listenerAbort.signal });
resetButton.onclick = () => { cameraController.reset(); gyro.reset(); };
gyroButton.onclick = async () => { gyroButton.textContent = `Gyroscope: ${(await gyro.toggle()) ? 'ON' : 'OFF'}`; };
const observer = new ResizeObserver(resize); observer.observe(wrap); resize(); setMode('final'); setGlossControlsVisibility();
let last = performance.now(); let raf = 0;
function frame(now: number): void { const dt = Math.min((now - last) / 1000, 0.1); last = now; uniforms.uViewOffset.value.copy(cameraController.update(dt, sensitivity)); camera.updateMatrixWorld(true); petDepth.updateMatrixWorld(true); uniforms.uContentViewProjection.value.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); uniforms.uContentInverseViewProjection.value.copy(uniforms.uContentViewProjection.value).invert(); uniforms.uContentCameraPosition.value.copy(camera.position); uniforms.uTime.value = now / 1000; const diagnostic = depthDebug.update(camera, petDepth, depthUniforms.uDepthScale.value, depthUniforms.uDepthBias.value, wrap.clientWidth); debugOutput.textContent = diagnostic; debugOutput.hidden = !diagnostic; if (mode === 'final' || mode === 'glass') { renderer.setRenderTarget(renderTarget); renderer.render(scene, camera); renderer.setRenderTarget(null); uniforms.uSceneTexture.value = renderTarget.texture; renderer.render(glassScene, postCamera); const previousAutoClear = renderer.autoClear; renderer.autoClear = false; renderer.clearDepth(); renderer.render(frameScene, camera); renderer.autoClear = previousAutoClear; } else { renderer.setRenderTarget(null); renderer.render(scene, camera); } raf = requestAnimationFrame(frame); }
raf = requestAnimationFrame(frame);
function teardown(): void { disposed = true; mediaRequest += 1; depthRequest += 1; clearPendingPair(); listenerAbort.abort(); cancelAnimationFrame(raf); window.clearTimeout(hintTimeout); observer.disconnect(); imageInput.onchange = null; videoInput.onchange = null; effectInput.onchange = null; strengthInput.oninput = null; sensitivityInput.oninput = null; resetButton.onclick = null; gyroButton.onclick = null; retry.onclick = null; pointer.dispose(); gyro.dispose(); loader.dispose(); renderTarget.dispose(); if (active !== emptyTex) active.dispose(); if (depthUniforms.uDepthMap.value !== depthMap) depthUniforms.uDepthMap.value.dispose(); depthMap.dispose(); petDepth.geometry.dispose(); petDepthMaterial.dispose(); geometry.dispose(); petMat.dispose(); glass.geometry.dispose(); glassMat.dispose(); frameGeometry.dispose(); frameMaterials.forEach((material) => material.dispose()); emptyTex.dispose(); renderer.domElement.remove(); renderer.dispose(); hintElement.style.opacity = '0'; retry.remove(); debugOutput.remove(); }
const hot = import.meta as ImportMeta & { hot?: { dispose(fn: () => void): void } }; hot.hot?.dispose(teardown);
-48
View File
@@ -1,48 +0,0 @@
uniform sampler2D uSceneTexture;
uniform mat4 uContentViewProjection,uContentInverseViewProjection;
uniform vec3 uContentCameraPosition,uLightPosition,uBackgroundColor;
uniform float uGlassZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlossStrength;
uniform vec2 uViewOffset,uPetSize,uPaneSize;
uniform vec4 uPaneRect;
varying vec2 vUv;
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;}
vec4 sampleScene(vec2 uv){return texture2D(uSceneTexture,clamp(uv,0.,1.));}
vec4 samplePane(vec2 pane){return sampleScene(projectUv(vec3((pane-.5)*uPaneSize,uGlassZ)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2 hash2(vec2 p){return vec2(hash(p),hash(p+vec2(19.19,73.73)));}
float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;}
vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
float softLimit(float value,float limit){return value*limit/(limit+abs(value));}
void main(){
if(uStrength<=.001){gl_FragColor=sampleScene(vUv);
#include <colorspace_fragment>
return;}
vec4 f=uContentInverseViewProjection*vec4(vUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid,random=hash2(cell);float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
if(paneUv.x<0.||paneUv.x>1.||paneUv.y<0.||paneUv.y>1.){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}
// Positive height bulges toward the observer (+Z); negative height is concave.
float curvature=clamp(uCurvature,-1.,1.),excess=max(curvature-.65,0.),surfaceCurvature=curvature-(.25/.35)*excess*excess; float height=.009*cellWorld*surfaceCurvature*mix(.92,1.08,hash(cell+vec2(41.7,17.3))); vec2 q=2.*local; vec2 q2=q*q,q3=q2*q,b=1.-q2*q2; vec2 skew=(hash2(cell+vec2(23.8,61.2))-.5)*.28; float asym=1.+dot(skew,q); vec2 dhdq=height*vec2(-4.*q3.x*b.y*asym+b.x*b.y*skew.x,-4.*q3.y*b.x*asym+b.x*b.y*skew.y); vec2 cellDimensions=uPaneSize/grid; vec2 grad=-2.*dhdq/cellDimensions; vec3 nCurved=normalize(vec3(grad+(random-.5)*.012,1.)),nFacet=normalize(vec3((random-.5)*.012,1.));
vec3 straightHit=gh+i*(-gh.z/safeZ(i.z));vec3 facetRay=bend(i,nFacet),curvedRay=bend(i,nCurved);vec3 facetHit=gh+facetRay*(-gh.z/safeZ(facetRay.z)),curvedHit=gh+curvedRay*(-gh.z/safeZ(curvedRay.z));vec2 base=facetHit.xy-straightHit.xy,curve=curvedHit.xy-facetHit.xy;vec2 worldDelta=vec2(softLimit(base.x,cellWorld*.08),softLimit(base.y,cellWorld*.08));vec2 d=curve/cellWorld;worldDelta+=vec2(.3*d.x/(.3+abs(d.x)),.3*d.y/(.3+abs(d.y)))*cellWorld;
vec2 sourceCenter=(cell+.5)/grid+(random-.5)*.16*cellSize;
vec2 widths;
if(style>.5){
widths=mix(vec2(.82),vec2(1.12),hash2(cell+vec2(11.4,9.8)));
}else{
float widthSeed=hash(cell+vec2(53.1,27.4));
if(widthSeed<.6)widths=mix(vec2(.90),vec2(1.08),hash2(cell+vec2(7.2,3.1)));
else if(widthSeed<.9)widths=mix(vec2(.46),vec2(.70),hash2(cell+vec2(7.2,3.1)));
else widths=mix(vec2(1.10),vec2(1.28),hash2(cell+vec2(7.2,3.1)));
}
float angle=(random.x-.5)*.08,c=cos(angle),s=sin(angle);
vec2 warpedLocal=local*cellSize+worldDelta/uPaneSize;
vec2 rawSampleUv=sourceCenter+(mat2(c,-s,s,c)*warpedLocal)*widths;
// Fade cell displacement near its edges to keep neighboring samples continuous.
vec2 cellEdge=.5-abs(local);
float interiorWeight=smoothstep(0.,.085,cellEdge.x)*smoothstep(0.,.085,cellEdge.y);
vec2 sampleUv=paneUv+(rawSampleUv-paneUv)*interiorWeight;
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*.75,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;vec2 blurStep=cellSize*mix(.006,.012,hash(cell+vec2(5.)));vec2 centerUv=mix(paneUv,sampleUv,strength);vec4 color=samplePane(centerUv)*.4;color+=samplePane(mix(paneUv,sampleUv+vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(paneUv,sampleUv-vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(paneUv,sampleUv+vec2(0.,blurStep.y),strength))*.15;color+=samplePane(mix(paneUv,sampleUv-vec2(0.,blurStep.y),strength))*.15;color.a=1.;
vec3 v=normalize(uContentCameraPosition-gh),reflected=reflect(-v,nCurved); float lightT=(uLightPosition.z-gh.z)/safeZ(reflected.z); vec2 lightHit=gh.xy+reflected.xy*lightT; vec2 halfSize=vec2(.32,.42)*min(uPaneSize.x,uPaneSize.y); vec2 windowCoord=abs(lightHit-uLightPosition.xy)/halfSize; vec2 windowMask=1.-smoothstep(vec2(.45),vec2(1.15),windowCoord); float spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float gloss=spec*clamp(uGlossStrength,0.,1.)*.12*edgeWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),gloss); gl_FragColor=color;
#include <colorspace_fragment>
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

-21
View File
@@ -1,21 +0,0 @@
# 1차 수정본 QA 및 2차 개선 계획
2026-09-13. 사용자 요청: 1차 수정본 QA, 개선점을 보고하고 구현. karpathy-guidelines 적용.
## 재현한 문제
1. 샘플 확대3/X-.15/Y-.35/Grid8/Distortion.9에서 오른눈을 가로지르는 흰 선과 코/입 아래 뾰족한 이음새. gloss0 및 curvature0에서도 남는다. 현재 edge product weight가 두 축의 UV 변위를 원본으로 복귀시켜 경계 위치를 원본에 고정하는 구조가 유력 원인이다.
2. Reeded에서 Mosaic 스타일을 촘촘 유리로 변경하면 Grid28/Distortion.85로 덮어쓴다. Mosaic 전용 UI가 타 효과에도 활성화되어 있다.
## 개선 명세
- 경계에서 원본 좌표로 되돌리는 1차 방식 제거. 중심 셀과 가까운 이웃 최대4개에서 affine UV, 배율/회전 행렬, 표면 기울기를 연속 가중치로 혼합한다. 내부는 1개, 모서리2개, 코너4개 셀 평가를 목표로 한다.
- 혼합한 곡면/facet 법선으로 기존 굴절을 1회 계산하고, 동일 곡면 법선을 반사에 공유한다. 기존 확대분포, seed, jitter.16, signed default+.65, curve0 셀효과,5tap,2pass,외곽,Distortion0는 유지한다.
- 전이 half-width .16셀에서 시작. affine 위치는 전이구간만큼 이웃셀 밖으로 연장하고, 곡면 q만 [-1,1]로 제한한다. 전역 단조성을 보장하는 광학 해법은 아니므로 접힘/늘어짐을 시각 검증한다.
- Mosaic 스타일 label은 Mosaic에서만 표시, select disabled와 handler guard를 함께 적용한다. 효과 전환 시 기존 스타일/Grid/Distortion을 초기화하지 않는다.
## 검증
변경 전 NoGlass 코/눈/몸 카메라 시차를 먼저 재확인했다(cameraX0→.092). 같은 샘플/구도/광택0에서 눈의 선과 뾰족한 이음새 감소, 큰눈코/셀정보 차이 보존을 확인한다. 이후 signed0/±값,drag,mobile,compact스타일,RT효과전환,브라우저shader오류와 build를 확인한다. UI는 Grid20/Distortion.72로 맞춘 후 Reeded→Frosted→Mosaic에서 설정 비활성/복원과 값 보존을 확인한다.
추가 texture fetch는 없지만 셀별 ALU 연산은 증가한다. 실제 기기 성능 측정 없이 모바일 성능 동등성을 주장하지 않는다. 원본/샘플 depth를 변경하거나 외부에 게시하지 않는다. 기존 Distortion0 액자밖 출력 계약은 이번 수정에서 유지한다.
-38
View File
@@ -1,38 +0,0 @@
# 1차 수정본 QA 및 2차 개선 결과
2026-09-13. karpathy-guidelines 적용. Astra 설계/독립 정적·이미지 검토, Luna 두 에이전트가 각각 shader와 UI 구현, 주 에이전트 실제 브라우저 검증.
## QA 결과와 해결
1. **눈의 가는 절개선과 입/목의 뾰족한 이음새**: 1차 구현의 원본 UV 복귀가 유력 원인. 광택0, 곡률0, 드래그에서 재현했다. 셀 경계에서 원본 UV로 되돌리는 방식은 제거하고, 가까운 이웃 셀의 affine UV/회전·배율/표면 기울기를 최대4개까지 보간했다. 같은 혼합 법선으로 굴절과 반사를 계산한다. 사진 색상을 이중 혼합하지 않으며 5회 texture fetch는 유지한다.
2. **Reeded/Frosted에 Mosaic 스타일이 간섭**: 전용 label을 숨기고 select disabled 및 change handler guard를 적용했다. 일반 Grid/Distortion 조절과 효과 전환 시 값 보존은 유지한다.
변경 소스: src/shaders/mosaic.frag, src/main.ts. 원본은 before.frag / before-main.ts에 보존. 추가 UI/새 자산/새 라이브러리/AI depth/사진 리라이팅 없음. 기존 signed default+.65, 확대 seed/widths, jitter.16,곡률0facet,Depth/RT,외곽,Distortion0 계약 유지.
## 시각 판단
before-close.png와 after-close.png는 같은 샘플 확대3/X-.15/Y-.35/Grid8/Distortion.9/곡률+.65/광택0이다. 눈 가로 절개선과 입/목의 날카로운 연결이 크게 완화된다. 일부 늘어남과 셀별 배율 차이는 남으며, 완전한 물리 유리 재현이나 결함 전부 제거를 주장하지 않는다.
기본 확대2.4/X-.10/Y-.35/Grid10/.9/+.65/광택.3은 docs/natural-glass/after-default.png가 1차, 이 폴더 after-default.png가 2차다. 이마/몸의 사각 조각감은 약해지는 절충이 있다. 전이 half-width .12도 비교했으나 명확한 조각감 개선이 없어 .16을 최종 채택했다. narrow-*.png는 미채택 .12 후보다. Astra도 .16 수용 가능, 정적 차단 결함 없음으로 판단했다.
## 검증 증거
- 변경 전 Glass OFF cameraX0→.092: 코531.509→528.803px, 왼눈459.283→457.560px, 몸350.477→350.467px. 수작업 Depth 기반 실제 부위별 카메라 시차 확인.
- 최종 .16에서 광택0/곡률0,-1,+1, 기본/근접 구도 비교. 곡률0에서도 셀 샘플 효과가 남음. 같은 법선 사용은 정적 검토와 Glass Only 반사 드래그로 확인.
- 390×844 근접 화면에서 눈·코 식별과 액자/유리 경계 고정, 드래그cameraX .123 관측. 최대시점 도달 테스트가 아님. 뷰포트 override 복원. after-mobile.png는 광택.15이다.
- Glass Only 광택1/조명0,0에서 사각 반사 영역과 드래그에 따른 위치변화 확인. 이 진단에서 일부 셀 반복은 보인다.
- Distortion0 원본 출력 확인. 기존 액자밖 원본 노출 한계는 계약에 따라 유지.
- 조각/촘촘 스타일, Reeded/Frosted 실제 렌더 확인.
- UI: 촘촘 선택 후 Grid20/Distortion.72 설정 → Reeded에서 styleVisible=false/styleEnabled=false → Frosted → Mosaic 복귀 시 촘촘 선택과20/.72 유지. CSS label display:grid에 hidden만 사용하는 문제를 피하도록 display를 명시했다.
- Luna 최종 npm.cmd run build: tsc-b와 Vite production build 성공.
- 작업 중 누적 로그에서 중간 저장본의 evaluateCell 대각 인수 순서 오류 1건을 발견. 최종 파일은 올바른 순서이며, **새 탭**에서 최종 샘플을 다시 로딩/렌더한 후 error logs=[] 확인. 중간 오류를 최종 오류로 보고하거나 누적 로그 전체가 없었다고 주장하지 않는다.
## 남은 개선과 한계
- 가장 큰 미완료는 레퍼런스처럼 넓게 늘어나는 셀과 특징이 보이는 셀 사이의 정보량 차이. 현재는 선 제거와 경계 연속성을 우선했고 주변 털/배경이 비교적 많이 남는다.
- 셀 경계 보간은 광학적으로 완전한 렌즈 해법이 아니며 모든 사진/시점에서 접힘 없는 UV를 보장하지 않는다. 과도한 배율차와 극단 설정의 한계는 별도이다.
- 인접 셀 평가로 ALU 연산 증가. texture fetch 개수는 동일하지만 실제 모바일 FPS/배터리/GPU별 성능은 측정하지 않았다.
- 영상 재생, 실기기 자이로/터치, 장시간 메모리, 일반 사용자 사진 여러 장 QA는 이번에 실행하지 않았다. 샘플은 수작업 Depth이며 일반 사진 자동 깊이를 의미하지 않는다.
- 모바일 시작 안내문 줄바꿈도 관찰했으나 이번 유리 경계 및 효과 전용 컨트롤 범위에서는 변경하지 않았다.
실행: npm.cmd run dev -- --host 127.0.0.1, http://127.0.0.1:5173/ 에서 샘플 로드. 최종 미리보기는 확대3/-.15/-.35/Grid8/.9/+.65/광택.15, 나머지 기본값, 정면이다. 원본 사진/Depth 변경 및 외부 게시 없음.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

-32
View File
@@ -1,32 +0,0 @@
# 유리 투과 해상감 보정 · 2026-09-14
사용자 요청:
- 레퍼런스처럼 유리를 통과하면서 해상도가 크게 깨져 보이는 표현을 추가한다.
- 기존 `Ref1 | Ref2` 위쪽 비교 영역과 아래 작업 이미지 배치를 복구한다.
해석 및 구현 기준:
- Ref2를 주 기준으로, 셀마다 털 등의 세부 정보가 줄고 눈·코 조각이 서로 다른 크기와 위치로 보이게 한다.
- 셀별 확대·압축 및 국소 샘플 퍼짐으로 표현한다. 원본 이미지나 전체 RenderTarget의 해상도를 낮추거나 단색 pixelation으로 대체하지 않는다.
- 기존 과도한 셀 샘플 이동 제한을 완화해 확대 효과가 실제 출력에 나타나도록 한다.
- Depth/AI 입력, 시점 이동, 유리판 기준 격자, 부호 있는 굴곡과 반사의 공통 법선을 보존한다.
- Distortion 0에서는 무효과 출력을 보존하며, 광택 0에서도 굴곡 및 셀별 정보 손실은 남는다.
- 비교 영역은 개발 모드와 로컬 배포 미리보기 모두에 표시한다. 이 변경은 외부 게시를 포함하지 않는다.
검증:
- 같은 샘플·구도의 정지 화면에서 변경 전후 비교.
- Ref1/Ref2 로드 및 작업 이미지 위 배치, 모바일 가로 넘침 확인.
- 눈·코 식별, 셀별 정보량 차이, 드래그 시 좌표 일관성 확인.
- TypeScript/빌드와 실제 브라우저 시각 검증을 구분해 보고.
## 구현 및 검증 결과
- 수정 파일: `src/main.ts`(비교 영역 개발 모드 제한 제거), `index.html`(Ref 캡션), `src/shaders/mosaic.frag`(조각 유리 샘플링).
- 조각 유리의 과도한 샘플 이동 제한을 제거했다. 셀별 확대·압축 비율과 중심 이동량을 변경하고, 9개 주변 샘플로 국소 세부 정보 손실을 강화했다. 촘촘 유리의 기존 샘플 범위와 5개 샘플 필터는 보존했다.
- `npm run build` 통과. 기존 번들 크기 안내는 남는다.
- 로컬 배포 미리보기 `http://127.0.0.1:5175/`에서 Ref1/Ref2 실제 이미지 로드 확인.
- 동일 1440×1000 화면과 샘플·구도의 변경 전후 비교: `glass-resolution-before.png`, `glass-resolution-after-matched.png`. 후자는 비교 조건을 맞추기 위해 테스트 페이지에서만 Ref 영역을 잠시 숨겨 캡처했으며 실제 앱은 Ref를 표시한다.
- 실제 비교 UI: `glass-resolution-after.png`. 눈·코를 식별할 수 있고 셀별 얼굴 조각의 확대·정보량 차이가 이전보다 커졌다.
- 광택 0에서도 조각화와 흐림 유지. 굴곡 -1/0/+1 렌더링 및 드래그, Mosaic/Reeded/Frosted 전환에서 셰이더·페이지 오류 없음.
- Distortion 0과 No Glass의 중앙 80×80 캡처 일치. 전체 프레임 경계의 픽셀 일치를 검사한 것은 아니다.
- 390px 화면에서 문서 너비 390px, Ref 영역 하단과 작업 이미지 상단이 모두 y=200. 가로 넘침 없음. `glass-resolution-mobile.png` 참조.
- 이번 검증은 수작업 샘플 depth를 사용했다. AI 추론 코드는 변경하지 않았으며 이번 보정에서 모델 추론 속도 및 실제 모바일 GPU 성능을 재측정하지 않았다.
-14
View File
@@ -1,14 +0,0 @@
{
"errors": [],
"distortionZeroCenterMatchesNoGlass": true,
"mobile": {
"width": 390,
"scrollWidth": 390,
"refs": [
1160,
1387
],
"refsBottom": 200,
"stageTop": 200
}
}
-22
View File
@@ -1,22 +0,0 @@
# Mosaic 셀 광택 MVP 검증
2026-09-11. Astra 설계·정적 검토, GPT-5.6 Luna 코드 구현, 조정 에이전트 브라우저 검증.
## 변경
- src/shaders/mosaic.frag: 기존 셀 법선과 월드 유리 교차점, 실제 카메라 방향으로 half-vector 반사 계산. 공통 광원 기본 (0.6, 0.8, 2.6), 반사 지수 64, 선형 색상에서 흰색으로 약하게 합성.
- src/main.ts / index.html: 광택 0~1(기본 .55), 조명 X/Y -2~2. 조명 Z는 고정. Mosaic 외 효과에서 조절 그룹 숨김/비활성화. 이벤트는 기존 AbortSignal 사용.
- 사진 리라이팅 없음. 기존 Depth/RT/굴절과 Reeded/Frosted 셰이더 보존.
## 실제 확인
- npm.cmd run build 성공(Luna 실행): TypeScript 및 Vite production build.
- 사용자 sample-pet 사진으로 두 Mosaic 스타일 확인. 광택 0/.55 비교에서 셀 내부 밝기 변화, 조명 X +.6/-.6 및 Y +.8/-.8에 따른 반사 영역 이동 확인. 검은 격자선 추가 없음.
- 실제 좌우 드래그 시 카메라 진단 X 양수/음수 및 반사 변화 확인. Reset view 후 X=0과 원래 구도 복귀.
- Distortion 0 Final/No Glass 시각 비교에서 원본 구도와 색상 일치. 픽셀 단위 차이 검사는 하지 않음.
- Reeded/Frosted 전환 후 광택 그룹 숨김 확인. 브라우저 error 로그 빈 배열.
- Grid 96, 광택 1, 조명 X 2 입력 경로 확인.
## 한계
물리적 면광원 또는 환경맵 반사가 아닌 간단한 넓은 하이라이트 근사다. 어두운 사진에서는 반사가 회색 밝은 면처럼 보일 수 있으며 광택 강도로 조절한다. 영상/실기기 gyro/성능 측정은 이번 수정에서 재검증하지 않았다.
-103
View File
@@ -1,103 +0,0 @@
# Pet Glass MVP 상세 구현 지시
원문은 `task-brief.txt`이며 이 문서는 구현 방법과 검증 기준을 구체화한다.
## 범위와 가정
- Vite + strict TypeScript + Three.js + GLSL, Vanilla TS. 백엔드와 React는 사용하지 않는다.
- 업로드 미디어는 브라우저 내부에서만 처리한다. 외부 샘플 이미지가 없어도 안내 placeholder로 실행된다.
- 실제 PetPlane과 GlassPlane 사이 거리를 갖는 단일 표면 굴절 근사를 구현한다. 물리적으로 정확한 두꺼운 유리 시뮬레이션이나 여러 렌더 패스는 MVP 범위가 아니다.
- 구조는 원문 예상 폴더를 참고하되, 빈 관리자나 한 번만 쓰는 기능을 위한 추가 추상화를 만들지 않는다.
## 구현 순서
1. Scene, PerspectiveCamera, renderer와 이미지 업로드, PetPlane을 구현하고 비율 보존을 확인한다.
2. 실제 GlassPlane, pointer drag, Mosaic 셰이더를 구현하여 시점 변화에 따른 서로 다른 셀 굴절을 눈으로 검증한다.
3. VideoTexture와 미디어 수명 관리를 추가한다.
4. Reeded, Frosted, gyro와 반응형 컨트롤을 완성한다.
5. 타입 검사와 빌드, 브라우저 동작을 검증하고 제한사항을 기록한다.
## Scene과 굴절
- PerspectiveCamera는 대략 fov 40도, (0,0,3)에서 시작하고 원점을 바라본다.
- PetPlane은 z=0, GlassPlane은 z=0.2에 배치한다. 실제 PlaneGeometry 두 개를 scene에 추가한다. 셀이나 ridge마다 mesh를 만들지 않는다.
- 미디어 texture는 두 plane에서 공유하되 MediaLoader만 texture의 수명을 소유한다.
- vertex shader에서 유리 표면 world position을 전달한다. fragment shader는 cameraPosition에서 표면으로 향하는 입사 ray를 계산하고, 셀 내부 위치에 따라 변화하는 +z 방향 normal로 굴절시킨다.
- 유리 위치에서 굴절 ray와 pet z=0의 교차점을 구한 뒤 pet의 실제 width/height로 UV를 계산한다. 이렇게 해야 유리와 미디어 사이 거리, 카메라 시점, 셀 normal이 함께 샘플 위치를 결정한다.
- strength=0은 기본 직진 ray, strength가 증가하면 굴절 ray가 반영되도록 한다. ray.z는 교차점 계산에서 0이 되지 않도록 제한한다.
- 원문의 uTexture, uGridSize, uStrength, uViewOffset, uResolution, uTime uniform을 유지한다. uViewOffset는 작은 셀별 normal 또는 굴절 응답 변화에 사용할 수 있지만 모든 UV에 동일한 이동만 주는 구현은 허용하지 않는다.
- 컬러 텍스처 색공간과 fragment 출력 색공간 처리를 맞추고 이미지·비디오에서 방향과 색상이 일치하는지 확인한다.
## Contain과 겹침 처리
- 원본 가로세로 비율을 항상 보존한다. 캔버스의 실제 컨테이너 크기와 카메라 frustum으로 크기를 계산한다.
- 유리 평면이 카메라에 더 가까우므로 **앞쪽 GlassPlane의 가시 영역**을 기준으로 두 평면 크기를 정한다. 약 15% 여백과 최대 약 0.15 world-unit의 카메라 이동 제한을 시작값으로 사용한다.
- resize 시 두 plane 크기와 shader의 pet 크기, 해상도 uniform을 함께 갱신한다. 드래그 중 이미지 크기가 수시로 변하는 자동 확대는 피한다.
- 같은 크기의 앞쪽 유리는 정면 투영에서 뒤쪽 PetPlane을 덮는다. 카메라 이동 극단과 portrait/landscape viewport에서도 뒤쪽 원본 사진이 가장자리에 중복 노출되지 않는지 반드시 확인한다. 필요하면 유리의 면적을 소폭 넓히되 pet 크기 기반 sampling 좌표는 유지한다.
- GlassPlane에서 texture를 샘플하여 최종 색상을 출력할 때 opaque 출력으로 시작한다. 원본 사진과 반투명 합성하여 얼굴이 이중으로 보이는 결과를 만들지 않는다.
- 굴절된 UV가 미디어 범위를 벗어나면 scene과 맞는 배경색 또는 부드러운 가장자리 fade를 사용한다. clamp만으로 사진 가장자리를 길게 늘이지 않는다.
- contain은 배치상 원본 미디어가 잘리지 않는다는 의미다. 굴절 때문에 경계의 일부가 다른 샘플로 바뀌는 것은 효과에 포함되지만, 드래그 시 평면 자체가 viewport 밖으로 잘리면 안 된다.
## 효과별 요구사항
### Mosaic
- vUv를 grid로 나누고 cell=floor(scaled), local=fract(scaled)-0.5를 계산한다.
- local에 따라 변화하는 볼록/오목 렌즈 normal과 cell hash 기반 약 ±15% 강도 차이를 사용한다.
- 카메라 이동 시 서로 다른 cell에서 얼굴의 눈·코 등 feature가 다르게 움직여야 한다. cell 중심의 단일 texel로 양자화하는 pixelation은 금지한다.
- 셀 경계는 은은하게 표현한다. 종횡비가 극단적인 사진에서도 사각형 렌즈 셀 형태가 과도하게 찌그러지지 않도록 grid의 축별 비율을 고려한다.
### Reeded
- x 방향으로 반복되는 원통 렌즈 normal을 만든다. y 방향 변형은 거의 없도록 한다.
- local ridge 위치와 약한 ridge별 차이가 굴절에 반영되어야 한다. 좌우 시점 변화가 전체 사진 이동에 그치면 안 된다.
### Frosted
- 안정된 공간 noise로 normal 또는 UV를 왜곡하고 5~9개의 주변 texture 샘플을 평균한다.
- 대략적인 형태는 남기고 디테일을 흐리게 한다. 시점 변화에 굴절 방향이 반응하게 하되 시간만으로 noise가 끓는 효과는 피한다.
## 입력과 애니메이션
- pointerdown/move/up/cancel, pointer capture를 사용하고 canvas에만 touch-action:none을 설정한다.
- drag delta를 canvas 크기로 정규화하여 target에 누적한 뒤 [-1,1]로 clamp한다.
- pointer와 gyro는 하나의 target/current 상태를 공유한다. sensitivity는 한 곳에서만 적용한다.
- requestAnimationFrame에서 시간 기반 보간으로 카메라와 shader 상태를 갱신한다. VideoTexture를 매 프레임 새로 만들지 않는다.
- Reset은 target/current를 중앙으로 돌리고 gyro 사용 중이면 기준 자세도 재설정한다.
- ResizeObserver로 실제 canvas container 크기를 반영하고 DPR은 최대 2로 제한한다.
## 미디어 수명과 오류
- jpg/jpeg/png/webp 이미지와 브라우저에서 재생할 수 있는 mp4/webm 영상을 지원한다.
- 새 미디어가 준비된 다음 기존 미디어를 교체한다. 로드 실패 시 기존 화면을 보존하고 읽을 수 있는 오류를 표시한다.
- 증가하는 요청 ID를 사용하여 연속 업로드 중 늦게 준비된 이전 파일이 최신 파일을 덮지 않게 한다. stale 결과도 dispose/revoke한다.
- 비디오는 muted, loop, playsInline을 설정하고 metadata와 첫 frame을 준비한 뒤 VideoTexture를 적용한다. play() 실패 시 사용자용 재생 버튼과 상태 메시지를 제공한다.
- 교체/실패/해제 시 texture.dispose, video.pause, video src 해제와 load, URL.revokeObjectURL을 빠뜨리지 않는다. 활성 미디어와 로드 중 미디어의 정리를 구분한다.
- scene 해제 시 RAF, observer, DOM/sensor 이벤트, material, geometry, renderer와 미디어를 정리한다. material 교체 시 이전 material을 dispose한다.
## Gyroscope
- 기본 OFF이며 사용자 버튼 클릭에서 권한을 요청한다. iOS requestPermission은 클릭 핸들러에서 다른 비동기 대기 전에 호출한다.
- 권한 거절, 미지원, 비보안 컨텍스트를 표시하고 drag는 계속 사용 가능하게 한다.
- 권한 API가 없는 환경에서도 orientation 이벤트를 연결하되 null beta/gamma는 무시한다.
- 첫 유효 reading을 기준 자세로 삼고 약 30도 기울기를 [-1,1]로 변환한다. 화면 회전 방향을 보정한다.
- 이벤트가 없으면 센서값 대기 상태로 표시한다. OFF 또는 해제 시 listener를 제거한다. 권한 허용만으로 실제 센서 동작이 검증됐다고 보고하지 않는다.
## UI
- Dark UI, 큰 canvas, 모바일에서 접근 가능한 반투명 컨트롤 패널을 사용한다.
- 사진/영상 업로드, Mosaic/Reeded/Frosted, strength, sensitivity, Reset, gyro toggle, 현재 효과와 상태 표시를 제공한다.
- 초기에는 “반려동물 사진 또는 영상을 선택하세요”, 미디어 준비 후 약 2초간 drag 안내를 표시한다.
- 패널 스크롤과 canvas drag가 충돌하지 않도록 한다. WebGL 초기화 실패 시 화면에 설명한다.
## 완료 검증
- npm install, npm run build, npm run dev가 동작하고 TypeScript 오류가 없다.
- 가로/세로 이미지 업로드, canvas resize, 모바일 viewport에서 비율과 contain이 유지된다.
- 드래그 전후 Mosaic의 서로 다른 셀 내부 feature가 서로 다르게 움직인다. Reeded는 x 중심 굴절, Frosted는 흐림과 시점 반응을 보인다.
- strength 0부터 최대값, sensitivity, Reset, pointer cancel을 확인한다.
- 영상 loop 및 재생 실패 UI, 영상→이미지→영상 교체, 빠른 연속 업로드를 확인한다.
- 카메라 이동 극단에서 원본 사진이 유리 옆에 중복 노출되지 않고, 유리/미디어 평면이 viewport 밖으로 잘리지 않는다.
- gyro 미지원/거절 시 drag가 유지된다. 실제 센서와 성능 수치를 검증하지 못하면 미검증으로 표시한다.
- 브라우저에 지속적인 콘솔 오류나 GLSL 컴파일 오류가 없다. 빌드 성공만으로 WebGL 셰이더 정상 동작을 주장하지 않는다.
- 결과 보고에는 생성/수정 파일, 구현 기능, 미완료 또는 미검증 항목, 실행 방법, 셰이더 원리와 다음 개선점을 포함한다.
-22
View File
@@ -1,22 +0,0 @@
# Mosaic 시각 보정
사용자 레퍼런스에 따라 원형 물결과 검은 격자 대신 사각 셀별 이미지 분절을 목표로 했다. Astra가 명세·검토, GPT-5.6 Luna가 코드를 수정했다. 이전 구현은 `before-mosaic-reference/`에 보존했다.
수정 파일: `src/shaders/mosaic.frag`, `src/main.ts`, `index.html`. 프로젝트 지침과 README도 갱신했다.
- 셀별 샘플 중심, 확대율, 미세 회전, 카메라 굴절 보조를 적용한다.
- 조각 유리의 샘플 폭은 셀의 .35~.50, 촘촘 유리는 .03~.10이며 5-tap 샘플을 사용한다. 단일 색 texel을 고정하는 pixelation은 아니다.
- 검은 셀 경계선과 원형 bulge를 제거했다.
- 중립 사진 사각형 밖은 원 RT를 유지하고 가장자리 약 .75셀에서 굴절 강도를 줄인다. 큰 카메라 이동에서는 실제 사진 경계와 정적 패널 경계가 다를 수 있다.
- 기본 Final, 프리셋별 Grid/Strength 동기화, grid 범위 6~96.
브라우저 확인:
- 동일 사용자 사진·동일 구도에서 두 스타일 비교. 셀 경계에서 눈·코·얼굴이 분절되고, 이전 검은 격자·원형 물결이 사라짐을 확인했다.
- 좌우 drag에서 셀 내부의 눈/코 조각이 달라짐을 확인했다.
- 강도 0 Final과 No Glass의 위치·색상을 육안 비교했다. 픽셀 단위 차이 검사는 수행하지 않았다.
- 새 검증 탭에서 Mosaic 두 스타일 및 Reeded/Frosted를 전환하고 error 로그 빈 배열을 확인했다. 개발 중 HMR로 발생한 이전 오류는 새 세션 검증과 구분했다.
- 390×844 및 844×390 viewport에서 가로 넘침 없이 레이아웃이 유지됨을 확인했다.
- TypeScript/production build 통과.
사진 속 실제 유리의 광학 물성을 추정·복원한 결과가 아니라, 레퍼런스의 인상을 목표로 한 시각 근사다. 사진 구도·얼굴 크기·격자 수에 따라 인상이 달라진다. 이번 변경에서는 실제 영상·물리 gyro·성능 실측을 추가 수행하지 않았다.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

-32
View File
@@ -1,32 +0,0 @@
uniform sampler2D uSceneTexture;
uniform mat4 uContentViewProjection,uContentInverseViewProjection;
uniform vec3 uContentCameraPosition,uLightPosition,uBackgroundColor;
uniform float uGlassZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlossStrength;
uniform vec2 uViewOffset,uPetSize,uPaneSize;
uniform vec4 uPaneRect;
varying vec2 vUv;
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;}
vec4 sampleScene(vec2 uv){return texture2D(uSceneTexture,clamp(uv,0.,1.));}
vec4 samplePane(vec2 pane){return sampleScene(projectUv(vec3((pane-.5)*uPaneSize,uGlassZ)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2 hash2(vec2 p){return vec2(hash(p),hash(p+vec2(19.19,73.73)));}
float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;}
vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
float softLimit(float value,float limit){return value*limit/(limit+abs(value));}
void main(){
if(uStrength<=.001){gl_FragColor=sampleScene(vUv);
#include <colorspace_fragment>
return;}
vec4 f=uContentInverseViewProjection*vec4(vUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid,random=hash2(cell);float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
if(paneUv.x<0.||paneUv.x>1.||paneUv.y<0.||paneUv.y>1.){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}
// Positive height bulges toward the observer (+Z); negative height is concave.
float curvature=clamp(uCurvature,-1.,1.),excess=max(curvature-.65,0.),surfaceCurvature=curvature-(.25/.35)*excess*excess; float height=.009*cellWorld*surfaceCurvature*mix(.92,1.08,hash(cell+vec2(41.7,17.3))); vec2 q=2.*local; vec2 q2=q*q,q3=q2*q,b=1.-q2*q2; vec2 skew=(hash2(cell+vec2(23.8,61.2))-.5)*.28; float asym=1.+dot(skew,q); vec2 dhdq=height*vec2(-4.*q3.x*b.y*asym+b.x*b.y*skew.x,-4.*q3.y*b.x*asym+b.x*b.y*skew.y); vec2 cellDimensions=uPaneSize/grid; vec2 grad=-2.*dhdq/cellDimensions; vec3 nCurved=normalize(vec3(grad+(random-.5)*.012,1.)),nFacet=normalize(vec3((random-.5)*.012,1.));
vec3 straightHit=gh+i*(-gh.z/safeZ(i.z));vec3 facetRay=bend(i,nFacet),curvedRay=bend(i,nCurved);vec3 facetHit=gh+facetRay*(-gh.z/safeZ(facetRay.z)),curvedHit=gh+curvedRay*(-gh.z/safeZ(curvedRay.z));vec2 base=facetHit.xy-straightHit.xy,curve=curvedHit.xy-facetHit.xy;vec2 worldDelta=vec2(softLimit(base.x,cellWorld*.08),softLimit(base.y,cellWorld*.08));vec2 d=curve/cellWorld;worldDelta+=vec2(.3*d.x/(.3+abs(d.x)),.3*d.y/(.3+abs(d.y)))*cellWorld;
vec2 sourceCenter=(cell+.5)/grid+(random-.5)*.32*cellSize+worldDelta/uPaneSize;vec2 widths;if(style>.5){widths=mix(vec2(.82),vec2(1.12),hash2(cell+vec2(11.4,9.8)));}else{float widthSeed=hash(cell+vec2(53.1,27.4));if(widthSeed<.6)widths=mix(vec2(.90),vec2(1.08),hash2(cell+vec2(7.2,3.1)));else if(widthSeed<.9)widths=mix(vec2(.46),vec2(.70),hash2(cell+vec2(7.2,3.1)));else widths=mix(vec2(1.10),vec2(1.28),hash2(cell+vec2(7.2,3.1)));}float angle=(random.x-.5)*.08,c=cos(angle),s=sin(angle);vec2 sampleUv=sourceCenter+mat2(c,-s,s,c)*(local*cellSize)*widths;
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*.75,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;vec2 blurStep=cellSize*mix(.006,.012,hash(cell+vec2(5.)));vec2 centerUv=mix(paneUv,sampleUv,strength);vec4 color=samplePane(centerUv)*.4;color+=samplePane(mix(paneUv,sampleUv+vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(paneUv,sampleUv-vec2(blurStep.x,0.),strength))*.15;color+=samplePane(mix(paneUv,sampleUv+vec2(0.,blurStep.y),strength))*.15;color+=samplePane(mix(paneUv,sampleUv-vec2(0.,blurStep.y),strength))*.15;color.a=1.;
vec3 v=normalize(uContentCameraPosition-gh),reflected=reflect(-v,nCurved); float lightT=(uLightPosition.z-gh.z)/safeZ(reflected.z); vec2 lightHit=gh.xy+reflected.xy*lightT; vec2 halfSize=vec2(.32,.42)*min(uPaneSize.x,uPaneSize.y); vec2 windowCoord=abs(lightHit-uLightPosition.xy)/halfSize; vec2 windowMask=1.-smoothstep(vec2(.45),vec2(1.15),windowCoord); float spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float gloss=spec*clamp(uGlossStrength,0.,1.)*.12*edgeWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),gloss); gl_FragColor=color;
#include <colorspace_fragment>
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

-13
View File
@@ -1,13 +0,0 @@
# 자연스러운 셀 경계 1차 보정
2026-09-13. 사용자 구현 승인. karpathy-guidelines 적용.
가정: 레퍼런스2의 큰 눈·코 조각을 유지하면서 cut-paste 인상을 줄인다. 전면적인 물리 유리 재구현이 아닌 기존 Mosaic 최소 수정이다. 비교 구도는 기본 샘플 2.4/-.10/-.35/Grid10/.90/곡률+.65 및 얼굴 근접3/-.15/-.35/Grid8/.90/곡률+.65/광택0.
첫 후보: 곡면에 의한 worldDelta를 셀의 회전/축별 배율 변환 내부에서 처리한다. 셀 가장자리6.5% 구간에서 최종 UV 변위를 smoothstep으로0에 수렴시킨다. 원본 색과 효과 색을 이중 혼합하지 않는다. 기존 seed/샘플 폭/곡면 법선/반사/5tap 필터/Distortion0/외곽처리는 유지한다.
리스크: 좁은 구간에 변형이 집중되면 늘어진 띠나 두꺼운 격자가 보일 수 있다. 실제 화면 비교 후 폭을 조정하거나 방식을 재검토한다. 연속적인 UV가 물리적으로 정확한 굴절을 의미하지는 않는다.
검증: 변경 전 Glass OFF 부위별 카메라 시차 → 같은 구도의 전후 정지 비교 → 광택0에서 곡률0,±.65,±1 비교 → 드래그 유리좌표 → 모바일 → 기존 효과/Distortion0와 shader error → TypeScript/Vite build. 영상/실기기gyro/성능 측정은 별도.
기존 셰이더는 before.frag, 기준 이미지 before-default.png / before-close.png로 보존했다. 제품 기본값 변경, 미디어 게시, AI depth 추가는 하지 않는다.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

-41
View File
@@ -1,41 +0,0 @@
# 자연스러운 셀 경계 1차 구현 결과
2026-09-13. Astra 설계 및 최종 정적/이미지 검토, Luna 소스 구현, 주 에이전트 실제 브라우저 검증. 적용 스킬 karpathy-guidelines.
## 변경
소스 변경은 src/shaders/mosaic.frag 한 파일이다.
- 곡면에 따른 worldDelta를 셀 로컬 좌표에 더한 후 기존 회전/축별 배율을 적용한다. 굴절 변위가 셀 확대·압축과 함께 변한다.
- 셀별 독립 이동 계수 .32→.16. 최대 이동 ±.16셀에서 ±.08셀로 감소. 기존 큰 확대/압축의 seed와 widths 분포는 보존.
- 셀 경계에서 rawSampleUv의 변위를 paneUv로 수렴시킨다. 최종 전이폭은 각 축 .085셀. 첫 .065 후보에서 가는 뾰족한 이음새를 보고 jitter 감소 후 폭을 조정했다.
- 색상 두 겹 혼합이나 전체 블러 증가 없이 UV 변위를 연결한다. 추가 texture fetch 없음.
- 기본 굴곡+.65, signed 높이/공유 법선, 사각 창 반사, 5tap 필터, outer pane fade, Distortion0 우회, Depth/RT/UI는 유지했다.
## 시각 결과
동일 사진/동일 구도 before-default→after-default, before-close→after-close 비교. 이마·뺨·목의 일부 사각 절단이 완만해지고 큰 눈·코·입 조각을 유지한다. close는 광택0이므로 반사로 가린 개선이 아니다. Astra 독립 이미지 검토도 1차 변경 수용 가능으로 판단했다.
레퍼런스 수준의 완전한 유리 질감은 아직 아니다. 오른눈을 가로지르는 가는 선, 입/목과 상단 검은 배경의 일부 이음새가 남는다. 최종 UV 전이는 렌즈의 물리 표면을 정확히 재구성한 것이 아니며, 기존 반사 법선에 이 전이 자체를 반영한 것도 아니다. 곡면·배율 연결의 시각 근사이고 후속 개선 여지가 있다. 촘촘 유리에서도 세부가 유지되나 배경 격자 반복은 보인다.
## 실행한 검증
- 변경 전 Glass OFF: cameraX0→.095, 코531.509→528.716px, 왼눈459.283→457.506px, 몸350.477→350.471px. 부위별 카메라 시차 확인. 수작업 테스트 Depth이며 AI 추정 아님.
- 1280×720 기본 샘플 확대2.4/X-.10/Y-.35/Grid10/Distortion.90/굴곡+.65/광택.30 전후.
- 근접 확대3/X-.15/Y-.35/Grid8/Distortion.90/굴곡+.65/광택0 전후.
- 최종 근접 화면에서 광택0, 곡률0/-1/+1 시각 비교. 평탄에서도 셀 확대/이동이 남고 부호에 따른 윤곽 변화가 나타남. 중간 후보에서는 -.65도 비교했다.
- 390×844 최종 근접 화면: 눈·코 식별, 드래그 시 액자/유리 경계 함께 유지. cameraX .127 관측. 최대 이동 한계에 도달했다고 주장하지 않는다. 뷰포트 override 복원.
- Distortion0에서 기존 무효과 출력 확인. 사진이 액자 밖까지 보이는 기존 계약/한계는 유지된다.
- Mosaic 조각/촘촘 스타일, Reeded, Frosted 전환과 정면 화면 확인.
- 최종 브라우저 error logs 빈 배열.
- Luna 최종 npm.cmd run build 통과: TypeScript 및 Vite. 자동 시각 테스트나 성능 테스트 통과를 의미하지 않는다.
미검증: 영상 재생, 실제 모바일 센서/터치, GPU별 호환성, FPS와 장시간 메모리. 새 반사 모델/방향성 퍼짐은 이번에 추가하지 않았다. Astra는 이번 범위에서 더 많은 변수를 동시에 바꾸지 않고 비교 가능한 1차 결과로 유지하는 것을 권했다.
## 재현과 보존
npm.cmd run dev -- --host 127.0.0.1 후 http://127.0.0.1:5173/ 에서 샘플 로드. 기본 또는 위 근접값 적용.
브라우저 전달 화면은 근접값, 광택.15, 굴곡+.65, Camera Offset.30/감도1의 정면이다. 저장된 after-close는 비교용 광택0, after-mobile은 광택.30이다. 원본 사진/Depth 변경 및 외부 게시 없음. 소스 기본값은 변경하지 않았다.
before.frag와 before-*.png는 작업 전, candidate-*.png는 .32/.065 후보, tuned-*.png는 .16/.065 후보, after-*.png는 최종 .16/.085 결과다. Git 저장소를 사용할 수 없어 원본 파일 백업과 직접 대조로 변경을 검토했다.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-72
View File
@@ -1,72 +0,0 @@
# 레퍼런스 재비교 QA / 설정 / 제품 제시 검토
2026-09-13. 소스 변경 없이 수행. karpathy-guidelines 적용, GPT-6 Astra 독립 정적 검토와 주 에이전트 실제 브라우저 비교. 코드 변경이 없으므로 Luna 구현 작업은 없음.
## 기준과 결론
지정 세션 `01a08e32-c332-7262-b2f2-537cb07bac41`에서 첨부 경로를 회수하고 원본 레퍼런스 1·2를 직접 열었다. 주 기준은 두 번째 `codex-clipboard-8f482fae-74a8-4a4f-9f11-e7be24c6638f.png`. 레퍼런스와 테스트는 서로 다른 사진이다. 같은 테스트 사진/구도에서 설정을 비교하되 픽셀 일치나 물리 재현도를 수치로 주장하지 않는다.
핵심 동작은 확인되지만 레퍼런스의 시각적 완성도에는 미달한다. 레퍼런스는 일부 눈·코가 확대되고 주변 털·배경은 넓게 늘어나며 매끈한 유리 면을 이룬다. 현재는 눈·코 식별은 가능하나 사진을 사각형으로 어긋나게 붙인 느낌이 강하고, 털 세부가 많은 셀과 날카롭게 끊기는 경계가 여전히 눈에 띈다. 샘플의 어두운 차량 배경과 좁고 긴 원본 비율도 레퍼런스의 밝고 넓은 화면과 다르다. 설정만으로 해결되는 차이와 셰이더/원본 차이를 구분해야 한다.
## 관찰한 설정 후보
아래는 수동으로 비교한 후보이며 전 범위 최적화 결과가 아니다. 공통 Final / Mosaic / 조각 유리 / 사진 Y -0.35 / Depth .25 / 조명 .60,.80 / 정면.
| 후보 | 확대/X | Grid | Distortion | 굴곡 | 광택 | 판단 |
|---|---|---|---|---|---|---|
| 현재 기본 | 2.4 / -.10 | 10 | .90 | +.65 | .30 | 표정은 보이나 얼굴 대비 조각이 작고 배경 비중이 큼 |
| 완만한 큰 셀 | 2.4 / -.10 | 8 | .80 | +.30 | .15 | 덜 거칠지만 레퍼런스의 확대된 얼굴 인상이 약함 |
| 오목 비교 | 2.4 / -.10 | 10 | 1.00 | -.45 | .15 | 표면 경계는 달라져도 핵심 조각 배율 차이는 해결되지 않음 |
| 강한 굴절 비교 | 2.4 / -.10 | 10 | 1.00 | +.65 | .15 | 코·입의 사각 단절이 남음 |
| 촘촘한 확대 | 3 / -.15 | 12 | .90 | +.35 | .15 | 작은 절편이 많아져 목표에서 멀어짐 |
| 중간 확대 | 2.8 / -.10 | 8 | .85 | +.35 | .15 | 무난하지만 눈·코가 셀 경계에 걸리는 배치 |
| 가장 큰 셀 | 2.8 / -.10 | 6 | .85 | +.65 | .15 | 큰 조각은 생기지만 눈이 눌리고 배치가 덜 안정적 |
| **추천 후보** | **3 / -.15** | **8** | **.90** | **+.35** | **.15** | 비교 후보 중 큰 눈·코와 표정 식별의 균형이 가장 나음 |
추천의 나머지 값: Camera Offset .20, View sensitivity .70, Gyroscope OFF, Reset view로 정면 복귀. 카메라/감도 값은 약한 조작감을 위한 선택이며 정면 정지 화면 유사도에는 기여하지 않는다. 조명 위치는 기존 유지값으로, 광원 위치 전 범위 최적화를 수행하지 않았다. 확대3은 현재 UI 상한이며 귀/얼굴 외곽이 잘리므로 얼굴 중심 효과용이다. 이 값을 일반 사진의 보편적 프리셋으로 적용하면 안 된다. 권장 탭에만 적용했고 소스의 기본 +.65를 포함한 기본값은 변경하지 않았다.
## 실제 QA
- npm.cmd run build: TypeScript + Vite 성공. 테스트 스위트 성공을 의미하지 않는다.
- Glass OFF를 먼저 확인. 샘플 기본 구도에서 cameraX 0→.101: 코 projectedX 531.509→528.550, 왼눈 459.283→457.405, 몸 350.477→350.486 px. 부위별 이동 차이가 있는 실제 카메라 parallax를 진단 출력에서 확인.
- Final/No Glass, Mosaic/Reeded/Frosted, 굴곡 -.45/+.30/+.35/+.65, 여러 Grid/확대/굴절 값을 화면 비교.
- 추천 후보를 1280×720 및 390×844에서 확인. 모바일 눈·코 식별 가능. 모바일 드래그에서 액자/유리 경계 유지, cameraX .055 관측. 최대 이동 범위 검증은 아님. 뷰포트 override 복원.
- 모바일 광택0에서도 사진 굴절이 남음. 다만 곡면 기여와 셀별 샘플 변형 기여를 정량적으로 분리한 검증은 아님.
- 현재 Reeded/Frosted 정면 화면은 액자 안에 유지된다. 이전 QA의 외곽 노출 결함을 그대로 재보고하지 않는다. 타 효과의 드래그 좌표 일관성은 이번에 검증하지 않음.
- 새 탭 첫 Tab으로 사진 업로드 input에 초점 도달. 업로드 키보드 접근의 이전 문제가 개선됨. 파일 선택/업로드 완료 전체 흐름은 재실행하지 않음.
- 수작업 Depth 안내가 샘플 로딩 후 계속 표시됨.
- 브라우저 error 로그 빈 배열. 영상, 실제 모바일 센서/터치, 프레임률, GPU 호환성, 장시간 메모리는 미검증.
## 남은 문제와 구현상 한계
1. 실제 재현: Distortion .01→0에서 확대 사진이 액자 밖까지 펼쳐진다. 원본 무효과 출력을 유지하는 기존 계약의 결과이므로 무조건 셰이더 bypass를 제거할 사안은 아니다. 사용자용 전후 비교는 동일 구도/동일 액자 영역을 유지하는 별도 표현이 필요하다.
2. 실제 재현: Reeded 상태에서도 Mosaic 스타일 선택이 활성화되어 있고 촘촘 유리로 바꾸면 Grid28 / Distortion.85로 덮어쓴다. 해당 컨트롤을 Mosaic에만 노출하는 것이 명확하다.
3. 정적 확인: 일반 사진은 구도1/0/0 및 평탄 Depth로 로딩된다. 샘플은 전용 확대와 수작업 Depth가 있다. 일반 사진에서 Depth Scale을 높여도 코·눈의 상대 깊이를 생성하지 않는다. 샘플 수준의 입체감을 일반 업로드 결과로 약속할 수 없다.
4. 정적 확인: 셀 확대/압축은 고정 난수이며 눈·코를 인식하지 않는다. 원하는 부위가 큰 셀에 들어가는지는 구도/Grid에 의존한다. 현재 값으로 모든 사진을 레퍼런스처럼 만들 수 없다.
5. 제품상 부족: 저장/내보내기/프리셋 영속 저장이 없다. 화면을 만져보고 끝나는 상태라 공유형 제품의 핵심 완료 행동이 비어 있다.
셰이더는 Depth mesh를 RT로 그린 뒤 유리판 기준 셀별 이동·축별 배율·회전/주변 샘플을 적용하며, 얕은 사각 곡면의 법선을 굴절과 창 반사에 공유한다. 다음 시각 개선은 광택 증가보다 셀 내부의 연속적인 늘어남과 경계 전이, 특징이 남는 셀과 넓은 색면 셀의 정보량 차이에 집중해야 한다. 모든 셀 흐림이나 단색 픽셀화는 권하지 않는다.
## 보여주는 방식
권장 포지셔닝은 ‘내 반려동물이 유리창 뒤에서 빼꼼 보는 사진 효과’. 현재 기능 범위에서는 로컬 사진 효과/감상 도구로 소개하고, 고급 깊이 편집기는 뒤로 둔다.
첫 화면: 결과 예시 크게 → 짧은 드래그 안내 → 사진 선택. 사용자의 첫 결과는 사진 선택 → 얼굴 구도 → 프리셋 2~3개 → 강도 조절 순서. Depth, RGB, Glass Only, 조명XY, 진단 수치는 고급/개발 패널로 이동하는 설계가 좋다. 모바일은 결과를 보면서 얼굴 구도/강도를 바꿀 수 있게 하단 패널과 미리보기를 결합하는 편이 현재의 긴 세로 슬라이더 목록보다 적합하다.
스타일명은 ‘Classic glass / Soft glass / Chunky glass’처럼 결과 중심으로 제안한다. 실제 구현/검증한 프리셋 이름은 아니며, 본 QA가 새로운 스타일을 추가한 것은 아니다. 주된 결과는 큰 눈·코가 남는 조각 유리로 두고 과도한 왜곡은 선택지로 제공한다. 공유 기능을 추가할 경우 9:16 짧은 좌우 시점 루프와 정지 이미지가 후보지만 현재 미구현이다. 정지 이미지 자체는 시점 상호작용을 보존하지 못하므로 제품 소개에서 구분한다.
## 타깃 판단: 검증할 가설
‘해외 남성이 주 수요층’이라는 결론을 이 QA로 확인할 수 없다. 성별별 취향을 전제하기보다 제작·저장·공유하려는 반려인과 시각 효과를 실험하는 사람을 나누어 본다. 영어권에서 신기한 WebGL 장난감으로 초기 반응을 얻는 것과, 반복 제작/저장/결제 수요는 서로 다르다. 여성 타깃을 지금 버릴 근거도 없다.
외부 맥락: Pew의 2025 미국 성인 조사에서 Instagram 이용률은 여성55%/남성44%, Reddit과 X는 남성이 더 높다. 이는 채널 선택 참고이며 이 효과의 수요나 해외 전체 성향을 입증하지 않는다. 출처: https://www.pewresearch.org/internet/2025/11/20/americans-social-media-use-2025/
작은 검증을 권한다. 같은 언어·채널 안에서 같은 결과 영상을 쓰고 ‘귀여운 내 반려동물 결과 만들기’와 ‘직접 움직여 보는 유리 효과’ 메시지만 나눈다. 채널 간 성과 차이를 성별 효과로 해석하지 않는다. 클릭보다 자기 사진 선택→만족스러운 결과 완성→두 번째 사진 시도, 향후 저장/공유 완료와 재방문을 본다. 실제 내보내기 구현 전엔 내보내기 완료율을 측정했다고 주장할 수 없다. 현재 판단은 영어권 테스트는 열되, 남성 전용 브랜드로 좁히지 않는 것이다.
## 보존물
- default-desktop.png: 기본 시각값 재적용 정면. Camera Offset/.감도는 추천값이나 정면 정지에는 영향 없음.
- recommended-desktop.png: 권장 후보 데스크톱.
- recommended-mobile.png: 같은 후보 모바일.
모든 이미지와 샘플 사용은 로컬 QA이며 게시하지 않았다. 실행은 npm.cmd run dev, 브라우저 localhost:5173에서 샘플 로드 후 표의 값 적용. 개선 우선순위는 일반 사진 첫 결과/구도 조절 → 셀 내부 굴절 질감 → 저장/공유 완료 흐름 → 타깃 실험이다.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

@@ -1,18 +0,0 @@
{
"camera-offset": "0.9",
"curvature": "1",
"depth-scale": "0.4",
"effect": "mosaic",
"gloss-strength": "0.35",
"grid-size": "20",
"light-x": "2",
"light-y": "0.8",
"mosaic-style": "2",
"photo-glass-distance": "0",
"photo-pan-x": "0",
"photo-pan-y": "0",
"photo-zoom": "1",
"sensitivity": "0.2",
"test-photo": "dog1",
"view-mode": "depth"
}
@@ -1,30 +0,0 @@
{
"sourceSize": [
400,
500
],
"snapshotSize": [
400,
500
],
"results": [
{
"nominalSize": 518,
"tensorDimensions": [
1,
3,
518,
420
]
},
{
"nominalSize": 770,
"tensorDimensions": [
1,
3,
770,
616
]
}
]
}
-41
View File
@@ -1,41 +0,0 @@
# Small 모델의 770 입력 추론
2026-10-06 사용자 요청에 따라 기본 모델을 바꾸지 않고 추론 전처리 기준 크기를 518에서 770으로 높였다.
## 변경
- `src/constants/depth.ts`: `AI_DEPTH.inputSize = 770`.
- `src/depth/aiDepth.worker.ts`: 모델을 로드한 뒤 `processor.image_processor.size`를 `{ width: 770, height: 770 }`으로 지정한다. 새 ONNX 모델이나 의존성을 추가하지 않았다. WebGPU와 WASM 재시도는 같은 로더를 사용하므로 모두 이 설정을 적용한다.
- `src/main.ts`: AI 완료 표시에 `입력 770`을 표시한다.
- 기존 모델 설정의 종횡비 유지와 14배수 정렬은 보존한다. 긴 변 1024의 사진 스냅샷 상한, 8비트 정규화, 128×128 Depth mesh, 거리·광학·사진 구도 처리도 유지한다.
## 검증
TypeScript와 Vite 빌드 통과: `npm.cmd run build -- --outDir test-results/ai-depth-770-build`. 기존 큰 번들 경고는 유지됐다. 검증용 빌드 출력은 제거했다.
설치된 Transformers.js의 같은 ImageProcessor 설정으로 dog1을 전처리했다(`preprocessor-check.json`). 원본과 스냅샷은 모두 **400×500**이며 입력 텐서는 다음과 같다. DPTImageProcessor는 이 ImageProcessor를 상속하며 별도의 크기 처리 재정의가 없다.
| 기준 크기 | 입력 텐서 [batch, channel, height, width] |
| --- | --- |
| 518 | [1, 3, 518, 420] |
| 770 | [1, 3, 770, 616] |
이는 출력 그림을 확대하기만 하는 설정 변경이 아니라 모델에 들어가는 실제 전처리 텐서를 키우는 변경이다. 최종 DepthMap은 pipeline에서 사진 스냅샷 크기로 다시 보간하므로 이 사진에서는 여전히 400×500이다.
Windows Codex in-app browser에서 기존 518의 dog1 Depth 화면을 시점 초기화 후 저장했다. 변경 후 같은 사진을 다시 불러와 자동 770 추론이 완료되는 것을 확인했다. 표시: `AI 추정 · Depth Anything V2 Small · 입력 770`, 상태: `자동 AI 추정 depth를 적용했습니다.`. console error는 없었다.
Depth 출력 후 No Glass 사진과 드래그 출력, Final 유리 출력을 확인하고 원래 Depth 보기로 돌아왔다. 현재 비교는 같은 화면 크기와 정면 시점에서 수행했다. 770 결과에서 머리·몸·발의 Depth 형상과 외곽이 달라지지만 얼굴의 미세한 굴곡은 여전히 부드럽다. 고해상도 입력이 눈·코·털의 정확한 3D 구조를 복원했다고 판정하지 않았다.
기존 설정은 `original-settings.json`과 `restored-settings.json`에서 모두 일치한다: dog1 / Depth / Grid 20 / 굴곡 +1 / 광택 0.35 / 거리 0% / Camera Offset 0.9 / Depth Scale 0.4 / 조명 X 2, Y 0.8 / sensitivity 0.2 / 확대 1, 팬 0/0. 비교를 위해 실제 드래그 시점은 초기화했다.
화면: `dog1-depth-518.png`, `dog1-depth-770.png`, `dog1-depth-770-detail.png`, `dog1-noglass-770.png`, `dog1-noglass-770-drag.png`, `dog1-final-770.png`.
## 한계와 실행
현재 dog1 원본은 400×500이므로 추론 입력을 높여도 원본에 없는 세부 정보가 생기지는 않는다. 모델은 단안 상대 깊이를 추정한다. 기존 8비트 저장과 128×128 표면 해상도는 별개의 제한이며 이번 요청에서 함께 변경하지 않았다.
실제 770 추론 성공은 확인했으나 WebGPU/WASM 경로를 강제로 나눠 실행하거나 기기별 처리 시간·메모리를 계측하지 않았다. 물리 모바일 기기의 추론 성능도 측정하지 않았다. 518과 770의 속도 차이에 대한 수치 주장은 하지 않는다.
프로젝트 루트에서 `npm.cmd run dev -- --host 127.0.0.1 --port 5174`로 실행한다. 현재 `http://127.0.0.1:5174/`에는 770 추론을 마친 dog1의 Depth 보기를 남겨두었다.
![770 입력으로 생성한 Depth](dog1-depth-770-detail.png)
@@ -1,18 +0,0 @@
{
"camera-offset": "0.9",
"curvature": "1",
"depth-scale": "0.4",
"effect": "mosaic",
"gloss-strength": "0.35",
"grid-size": "20",
"light-x": "2",
"light-y": "0.8",
"mosaic-style": "2",
"photo-glass-distance": "0",
"photo-pan-x": "0",
"photo-pan-y": "0",
"photo-zoom": "1",
"sensitivity": "0.2",
"test-photo": "dog1",
"view-mode": "depth"
}
@@ -1,8 +0,0 @@
{
"distance": "50",
"panX": "0",
"panY": "0",
"point": "픽셀 (209, 236)\n모델 상대 깊이: 1.000000\n8비트 깊이: 1.000000\n최대 표면 간격: 0.3200\n선택 지점 간격: 0.3200\n뒤로 물러난 양: 0.0000",
"provenance": "AI 추정 · Depth Anything V2 Base · 입력 770 (상대 깊이, 0=뒤 · 1=앞)",
"zoom": "1"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

@@ -1,59 +0,0 @@
{
"summary": "400×500 · 최대 depth=1.00000\n모델 최대 1: 1px (0.0005%)\n8비트 depth=1: 26px (0.0130%)\ndepth≥0.99: 126px (0.0630%)\n최대 위치: (209, 236)",
"samples": [
{
"name": "nose-tip",
"x": 207,
"y": 239,
"text": "픽셀 (207, 239)\n모델 상대 깊이: 0.996319\n8비트 깊이: 0.996078\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0410\n뒤로 물러난 양: 0.0010"
},
{
"name": "nose-upper",
"x": 210,
"y": 235,
"text": "픽셀 (210, 235)\n모델 상대 깊이: 0.999133\n8비트 깊이: 1.000000\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0400\n뒤로 물러난 양: 0.0000"
},
{
"name": "forehead",
"x": 210,
"y": 175,
"text": "픽셀 (210, 175)\n모델 상대 깊이: 0.958692\n8비트 깊이: 0.956863\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0508\n뒤로 물러난 양: 0.0108"
},
{
"name": "left-eye",
"x": 181,
"y": 210,
"text": "픽셀 (181, 210)\n모델 상대 깊이: 0.854450\n8비트 깊이: 0.854902\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0763\n뒤로 물러난 양: 0.0363"
},
{
"name": "right-eye",
"x": 236,
"y": 218,
"text": "픽셀 (236, 218)\n모델 상대 깊이: 0.847300\n8비트 깊이: 0.847059\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0782\n뒤로 물러난 양: 0.0382"
},
{
"name": "muzzle",
"x": 190,
"y": 240,
"text": "픽셀 (190, 240)\n모델 상대 깊이: 0.963951\n8비트 깊이: 0.964706\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0488\n뒤로 물러난 양: 0.0088"
},
{
"name": "tongue",
"x": 204,
"y": 267,
"text": "픽셀 (204, 267)\n모델 상대 깊이: 0.881356\n8비트 깊이: 0.882353\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0694\n뒤로 물러난 양: 0.0294"
},
{
"name": "body",
"x": 270,
"y": 320,
"text": "픽셀 (270, 320)\n모델 상대 깊이: 0.616040\n8비트 깊이: 0.615686\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.1361\n뒤로 물러난 양: 0.0961"
},
{
"name": "front-paw",
"x": 183,
"y": 414,
"text": "픽셀 (183, 414)\n모델 상대 깊이: 0.506277\n8비트 깊이: 0.505882\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.1635\n뒤로 물러난 양: 0.1235"
}
]
}
@@ -1,6 +0,0 @@
{
"nearPoint": "픽셀 (209, 236)\n모델 상대 깊이: 1.000000\n8비트 깊이: 1.000000\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0400\n뒤로 물러난 양: 0.0000",
"midPoint": "픽셀 (209, 236)\n모델 상대 깊이: 1.000000\n8비트 깊이: 1.000000\n최대 표면 간격: 0.3200\n선택 지점 간격: 0.3200\n뒤로 물러난 양: 0.0000",
"farPoint": "픽셀 (209, 236)\n모델 상대 깊이: 1.000000\n8비트 깊이: 1.000000\n최대 표면 간격: 0.6000\n선택 지점 간격: 0.6000\n뒤로 물러난 양: 0.0000",
"farFace": "픽셀 (210, 175)\n모델 상대 깊이: 0.958692\n8비트 깊이: 0.956863\n최대 표면 간격: 0.6000\n선택 지점 간격: 0.6108\n뒤로 물러난 양: 0.0108"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

@@ -1,74 +0,0 @@
# V2 Base 770 / 최대 깊이 표면 기준 거리 검증
2026-10-06. 요청: Base로 depth=1 영역이 앞쪽 부위를 명확하게 추정하는지 확인하고, 그 최대 깊이 표면을 유리와 피사체 거리 기준으로 사용한다.
## 구현
- `개발 비교 → AI 깊이 모델`에서 Small/Base 선택 후 기존 `AI 깊이 생성` 버튼으로 추론한다. 두 모델 모두 입력 770, 종횡비/14 배수 정렬, 스냅샷 긴 변 상한 1024를 공유한다. 기본값 Small, Base는 비상업적 테스트 옵션이다. 새 의존성 없음.
- 단일 worker estimator를 유지하고 다른 모델을 선택해 추론할 때 이전 estimator를 dispose한다. 기존 WebGPU 우선/WASM fallback을 유지한다.
- 기존 8비트 텍스처와 128×128 Depth mesh를 유지한다. 양자화 이전 정규화 Float32 값은 진단용으로 함께 전달한다.
- `최대 깊이 영역 확인`은 원본 사진 위에 Float32 최대 1(분홍), 8비트 255/255(청록), Float32 ≥0.99(노랑)를 표시한다. 픽셀 수/비율/첫 최대 좌표와 선택 지점 값을 표시한다. 사진 클릭 또는 X/Y 숫자 입력으로 검사한다.
- 기존 `contentBaseZ`/`applyFraming`을 사용한다. 거리 도움말을 최대 depth 표면 기준으로 명시하고 진단에 최대 표면/선택 지점 간격을 추가했다. RGB/Depth 공통 변환, 굴절 기준 평면, 프레임 가림을 함께 갱신한다. 자동 확대/팬 변경 없음.
## 같은 사진의 실제 추론 결과
원본 `public/assets/qa/dog1.jpeg`는 400×500이다. 입력 770은 이전 전처리 검증에서 실제 텐서 `[1,3,770,616]`으로 확인했으며 이번에도 같은 전처리 설정을 사용한다. 반환 깊이는 스냅샷 400×500으로 보간된다. 좌표는 좌상단 원점, X 오른쪽/Y 아래쪽이다. 지점은 원본을 보고 수동으로 고른 비교 좌표이며 모델의 의미적 코/눈 검출 결과가 아니다.
| 항목 | Small 770 | Base 770 |
|---|---:|---:|
| 정규화 Float32 최대=1 | 1px | 1px |
| 8비트 depth=1 | 24px | 26px |
| 정규화 depth≥0.99 | 153px | 126px |
| 최대 좌표 | (210,235) | (209,236) |
| 코 중앙 부근 (207,239) | 0.992057 | 0.996319 |
| 코 위쪽 (210,235) | 1.000000 | 0.999133 |
| 이마 (210,175) | 0.942435 | 0.958692 |
| 왼눈 (181,210) | 0.845839 | 0.854450 |
| 오른눈 (236,218) | 0.834167 | 0.847300 |
| 주둥이 (190,240) | 0.958051 | 0.963951 |
| 혀 (204,267) | 0.909496 | 0.881356 |
| 몸 (270,320) | 0.579217 | 0.616040 |
| 앞발 (183,414) | 0.481518 | 0.506277 |
두 모델 모두 최대 좌표는 원본 사진의 코 주변에 위치한다. Base의 ≥0.99 영역은 더 작지만 8비트 최대 영역은 약간 더 크다. 이것만으로 Base가 실제 형상을 더 정확하게 복원했다고 판정할 수 없다. 코와 이마/눈 사이의 값 차이는 이미 Small에도 존재했고, 흑백 화면에서는 모두 밝아 읽기 어려웠다. Base Depth 화면도 부드러운 형태이며 얼굴 내부 굴곡이 크게 개선되었다고 단정하지 않는다.
각 사진/모델별 정규화 기준이 다르므로 표의 수치를 모델 간 실제 거리 차이처럼 해석하지 않는다. Float32 최대 1은 비상수 예측을 min/max 정규화하면 항상 만들어진다. 이번 코 위치 결과는 한 사진의 검증이며 다른 사진의 최대값이 항상 코라는 보장은 없다. 정규화는 전체 사진을 대상으로 하고 피사체 마스크나 이상값 제거는 하지 않는다.
수치 원본: [Small](small-values.json), [Base](base-values.json). 동일 뷰/설정의 화면: [Small](small-inspection.png), [Base](base-inspection.png). 최대 영역은 진단 색상만 적용하며 실제 깊이값을 강조/변경하지 않는다.
## 거리 기준
```
baseZ = glassZ - requestedGap - (maxDepth - depthBias) * depthScale
surfaceZ(pixel) = baseZ + (byteDepth(pixel) - depthBias) * depthScale
gap(pixel) = requestedGap + (maxDepth - byteDepth(pixel)) * depthScale
```
Base 최대 픽셀 (209,236)의 byteDepth는 1. depthScale=0.25에서 검사:
| UI 거리 | 최대 표면 간격 | 이마 간격 | 이마의 상대적 후퇴 |
|---|---:|---:|---:|
| 0% | 0.0400 | 0.0508 | 0.0108 |
| 50% | 0.3200 | 0.3308 (같은 식) | 0.0108 |
| 100% | 0.6000 | 0.6108 | 0.0108 |
최대 픽셀의 0/50/100%, 이마 100%는 실제 UI 진단에서 읽었다. 이마 0%는 동일 위치 Base 수치 JSON에 기록돼 있다. 이마 50%는 수식 계산값이다. 거리와 깊이 차이는 장면 단위이며 cm가 아니다. `≥0.99`는 영역 가시화용이고 배치 기준은 항상 최대값이므로 가까운 표면의 관통을 막는다.
진단 간격은 텍스처 픽셀의 계산값이다. 128×128 mesh의 버텍스 샘플/삼각형 보간이 최대 픽셀을 정확히 통과하지 않을 수 있으므로 실제 mesh 최소 간격은 요청값보다 클 수 있다. 선형 텍스처 보간과 mesh 보간이 원본 최대값보다 더 앞선 값을 생성하지는 않으므로 전체 표면 기준 안전 여유는 유지된다.
[거리 수치](distance-values.json). No Glass [근접](base-noglass-near.png), [드래그](base-noglass-near-drag.png), [먼 거리](base-noglass-far.png). No Glass에서 RGB와 깊이 형상 및 시점에 따른 변화가 렌더링되는 것을 확인한 뒤 [Final 50%](base-final-mid.png)를 확인했다. 픽셀별 GPU projected 좌표/실제 측정 3D 정답과의 오차는 이번 테스트에서 측정하지 않았다.
Base 재추론은 같은 최대 좌표/픽셀 수를 반환했고 거리 50%, 수동 zoom=1/pan=0/0을 유지했다. [재추론 후 설정](after-reinference.json).
## 실행 검증과 한계
- `npm.cmd run build -- --outDir test-results/base-depth-build`: TypeScript/Vite 빌드 통과. 기존 큰 chunk 경고만 있었다. 생성한 임시 빌드 결과는 정리했다.
- `node --experimental-strip-types scripts/test-depth-refraction.mjs`: 통과.
- `node --experimental-strip-types scripts/test-window-reveal-coverage.mjs`: 통과. 두 거리/세 깊이 스케일/두 종횡비/17 시점의 프레임 가림 검사 포함.
- Node 22.13에서 옵션 없이 테스트를 실행한 첫 시도는 `.ts` 확장자 로딩 오류였다. TypeScript strip 옵션으로 재실행해 통과했다.
- 실제 브라우저에서 Small/Base 모두 추론 완료. Base는 첫 다운로드 후 같은 estimator로 재추론도 확인했다. 콘솔 error 0건. 강제 WASM-only/실제 모바일 GPU 추론 시간/메모리는 측정하지 않았다.
- 모바일 390×844에서 문서 너비 375, 가로 넘침 없음. [모바일 진단](mobile-inspection.png). 임시 viewport는 원복했다.
- 기본 뷰포트의 미리보기는 Base 770, Depth, 거리 0%, 최대 지점 선택 상태로 남겼다. [최종 화면](base-result.png).
- 원본이 400×500이므로 770 입력이 원본에 없는 디테일을 보장하지 않는다. 더 나은 모델/더 높은 입력에서도 코의 실제 돌출 길이 복원을 보장할 수 없다.
후속 판단: 현재 최대 깊이 표면 기준 거리 방식은 사용자 의도와 일치한다. 이 사진에서는 Base와 Small 모두 코 주변이 최대이므로 Base 교체만으로 거리 기준 문제가 해결됐다고 설명하지 않는다. 향후 여러 사진에서 최대 위치가 피사체가 아닌 배경/노이즈로 이동하는지 확인한 뒤 피사체 범위 제한이나 이상값 처리를 별도로 판단한다.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

@@ -1,59 +0,0 @@
{
"summary": "400×500 · 최대 depth=1.00000\n모델 최대 1: 1px (0.0005%)\n8비트 depth=1: 24px (0.0120%)\ndepth≥0.99: 153px (0.0765%)\n최대 위치: (210, 235)",
"samples": [
{
"name": "nose-tip",
"x": 207,
"y": 239,
"text": "픽셀 (207, 239)\n모델 상대 깊이: 0.992057\n8비트 깊이: 0.992157\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0420\n뒤로 물러난 양: 0.0020"
},
{
"name": "nose-upper",
"x": 210,
"y": 235,
"text": "픽셀 (210, 235)\n모델 상대 깊이: 1.000000\n8비트 깊이: 1.000000\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0400\n뒤로 물러난 양: 0.0000"
},
{
"name": "forehead",
"x": 210,
"y": 175,
"text": "픽셀 (210, 175)\n모델 상대 깊이: 0.942435\n8비트 깊이: 0.941176\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0547\n뒤로 물러난 양: 0.0147"
},
{
"name": "left-eye",
"x": 181,
"y": 210,
"text": "픽셀 (181, 210)\n모델 상대 깊이: 0.845839\n8비트 깊이: 0.847059\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0782\n뒤로 물러난 양: 0.0382"
},
{
"name": "right-eye",
"x": 236,
"y": 218,
"text": "픽셀 (236, 218)\n모델 상대 깊이: 0.834167\n8비트 깊이: 0.835294\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0812\n뒤로 물러난 양: 0.0412"
},
{
"name": "muzzle",
"x": 190,
"y": 240,
"text": "픽셀 (190, 240)\n모델 상대 깊이: 0.958051\n8비트 깊이: 0.956863\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0508\n뒤로 물러난 양: 0.0108"
},
{
"name": "tongue",
"x": 204,
"y": 267,
"text": "픽셀 (204, 267)\n모델 상대 깊이: 0.909496\n8비트 깊이: 0.909804\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.0625\n뒤로 물러난 양: 0.0225"
},
{
"name": "body",
"x": 270,
"y": 320,
"text": "픽셀 (270, 320)\n모델 상대 깊이: 0.579217\n8비트 깊이: 0.580392\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.1449\n뒤로 물러난 양: 0.1049"
},
{
"name": "front-paw",
"x": 183,
"y": 414,
"text": "픽셀 (183, 414)\n모델 상대 깊이: 0.481518\n8비트 깊이: 0.482353\n최대 표면 간격: 0.0400\n선택 지점 간격: 0.1694\n뒤로 물러난 양: 0.1294"
}
]
}
-123
View File
@@ -1,123 +0,0 @@
uniform sampler2D uSceneTexture;
uniform mat4 uContentViewProjection,uContentInverseViewProjection;
uniform vec3 uContentCameraPosition,uLightPosition,uBackgroundColor;
uniform float uGlassZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlossStrength;
uniform vec2 uViewOffset,uPetSize,uPaneSize;
uniform vec4 uPaneRect;
varying vec2 vUv;
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;}
vec4 sampleScene(vec2 uv){return texture2D(uSceneTexture,clamp(uv,0.,1.));}
vec4 samplePane(vec2 pane){return sampleScene(projectUv(vec3((pane-.5)*uPaneSize,uGlassZ)));}
vec4 sampleCell(vec2 pane,vec2 sourceUv,float strength,vec2 blurStep){
vec4 color=samplePane(mix(pane,sourceUv,strength))*.4;
color+=samplePane(mix(pane,sourceUv+vec2(blurStep.x,0.),strength))*.15;
color+=samplePane(mix(pane,sourceUv-vec2(blurStep.x,0.),strength))*.15;
color+=samplePane(mix(pane,sourceUv+vec2(0.,blurStep.y),strength))*.15;
color+=samplePane(mix(pane,sourceUv-vec2(0.,blurStep.y),strength))*.15;
return color;
}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2 hash2(vec2 p){return vec2(hash(p),hash(p+vec2(19.19,73.73)));}
float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;}
vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
float softLimit(float value,float limit){return value*limit/(limit+abs(value));}
void evaluateCell(vec2 cell,vec2 local,vec2 grid,vec2 cellSize,vec2 cellDimensions,float cellWorld,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)));
}else{
float widthSeed=hash(cell+vec2(53.1,27.4));
if(widthSeed<.3)widths=mix(vec2(.90),vec2(1.08),hash2(cell+vec2(7.2,3.1)));
else if(widthSeed<.9)widths=mix(vec2(.46),vec2(.70),hash2(cell+vec2(7.2,3.1)));
else widths=mix(vec2(1.10),vec2(1.28),hash2(cell+vec2(7.2,3.1)));
}
float angle=(random.x-.5)*.08,c=cos(angle),s=sin(angle);
transform=mat2(widths.x*c,-widths.y*s,widths.x*s,widths.y*c);
vec2 sourceCenter=(cell+.5)/grid+(random-.5)*.16*cellSize;
affineUv=sourceCenter+transform*(local*cellSize);
// Positive height bulges toward the observer (+Z); negative height is concave.
float curvature=clamp(uCurvature,-1.,1.),excess=max(curvature-.65,0.),surfaceCurvature=curvature-(.25/.35)*excess*excess;
float height=.009*cellWorld*surfaceCurvature*mix(.92,1.08,hash(cell+vec2(41.7,17.3)));
vec2 q=clamp(2.*local,-1.,1.),q2=q*q,q3=q2*q,b=1.-q2*q2;
vec2 skew=(hash2(cell+vec2(23.8,61.2))-.5)*.28;float asym=1.+dot(skew,q);
vec2 dhdq=height*vec2(-4.*q3.x*b.y*asym+b.x*b.y*skew.x,-4.*q3.y*b.x*asym+b.x*b.y*skew.y);
vec2 grad=-2.*dhdq/cellDimensions;
curvedSlope=grad+(random-.5)*.012;facetSlope=(random-.5)*.012;
}
void main(){
if(uStrength<=.001){gl_FragColor=sampleScene(vUv);
#include <colorspace_fragment>
return;}
vec4 f=uContentInverseViewProjection*vec4(vUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid;float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
if(paneUv.x<0.||paneUv.x>1.||paneUv.y<0.||paneUv.y>1.){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}
vec2 cellDimensions=uPaneSize/grid;
vec2 affineUv,curvedSlope,facetSlope;mat2 transform;
evaluateCell(cell,local,grid,cellSize,cellDimensions,cellWorld,style,affineUv,transform,curvedSlope,facetSlope);
vec2 edge=.5-abs(local),neighborSign=mix(vec2(-1.),vec2(1.),step(vec2(0.),local));
// Keep a broad shared surface-normal transition; color footprints are blended separately.
vec2 blendAxis=1.-smoothstep(vec2(-.16),vec2(.16),edge);
vec2 weights=blendAxis;
vec2 centerAffine=affineUv;mat2 centerTransform=transform;
vec2 xAffine=vec2(0.),yAffine=vec2(0.),diagonalAffine=vec2(0.);
mat2 xTransform=mat2(0.),yTransform=mat2(0.),diagonalTransform=mat2(0.);
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);
vec2 colorAxis=1.-smoothstep(vec2(-.06),vec2(.06),edge);
float colorXWeight=colorAxis.x*(1.-colorAxis.y),colorYWeight=(1.-colorAxis.x)*colorAxis.y;
float colorDiagonalWeight=colorAxis.x*colorAxis.y,colorCenterWeight=(1.-colorAxis.x)*(1.-colorAxis.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,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
xAffine=neighborAffine;xTransform=neighborTransform;
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,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
yAffine=neighborAffine;yTransform=neighborTransform;
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,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
diagonalAffine=neighborAffine;diagonalTransform=neighborTransform;
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*(-gh.z/safeZ(i.z));vec3 facetRay=bend(i,nFacet),curvedRay=bend(i,nCurved);vec3 facetHit=gh+facetRay*(-gh.z/safeZ(facetRay.z)),curvedHit=gh+curvedRay*(-gh.z/safeZ(curvedRay.z));vec2 base=facetHit.xy-straightHit.xy,curve=curvedHit.xy-facetHit.xy;vec2 worldDelta=vec2(softLimit(base.x,cellWorld*.08),softLimit(base.y,cellWorld*.08));vec2 d=curve/cellWorld;worldDelta+=vec2(.3*d.x/(.3+abs(d.x)),.3*d.y/(.3+abs(d.y)))*cellWorld;
vec2 sampleUv=affineUv+transform*(worldDelta/uPaneSize);
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*.75,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;vec2 blurStep=cellSize*mix(.006,.012,hash(cell+vec2(5.)));
vec4 color;
// The .06-.16 band must retain the independent center transform to avoid reintroducing UV compression.
if(style<=.5&&(weights.x>0.||weights.y>0.)){
color=vec4(0.);
if(colorCenterWeight>0.)color+=sampleCell(paneUv,centerAffine+centerTransform*(worldDelta/uPaneSize),strength,blurStep)*colorCenterWeight;
if(colorXWeight>0.){
vec2 xBlurStep=cellSize*mix(.006,.012,hash(cell+vec2(neighborSign.x,0.)+vec2(5.)));
color+=sampleCell(paneUv,xAffine+xTransform*(worldDelta/uPaneSize),strength,xBlurStep)*colorXWeight;
}
if(colorYWeight>0.){
vec2 yBlurStep=cellSize*mix(.006,.012,hash(cell+vec2(0.,neighborSign.y)+vec2(5.)));
color+=sampleCell(paneUv,yAffine+yTransform*(worldDelta/uPaneSize),strength,yBlurStep)*colorYWeight;
}
if(colorDiagonalWeight>0.){
vec2 diagonalBlurStep=cellSize*mix(.006,.012,hash(cell+neighborSign+vec2(5.)));
color+=sampleCell(paneUv,diagonalAffine+diagonalTransform*(worldDelta/uPaneSize),strength,diagonalBlurStep)*colorDiagonalWeight;
}
}else{
color=sampleCell(paneUv,sampleUv,strength,blurStep);
}
color.a=1.;
vec3 v=normalize(uContentCameraPosition-gh),reflected=reflect(-v,nCurved); float lightT=(uLightPosition.z-gh.z)/safeZ(reflected.z); vec2 lightHit=gh.xy+reflected.xy*lightT; vec2 halfSize=vec2(.32,.42)*min(uPaneSize.x,uPaneSize.y); vec2 windowCoord=abs(lightHit-uLightPosition.xy)/halfSize; vec2 windowMask=1.-smoothstep(vec2(.45),vec2(1.15),windowCoord); float spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float gloss=spec*clamp(uGlossStrength,0.,1.)*.12*edgeWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),gloss); gl_FragColor=color;
#include <colorspace_fragment>
}
-131
View File
@@ -1,131 +0,0 @@
uniform sampler2D uSceneTexture;
uniform mat4 uContentViewProjection,uContentInverseViewProjection;
uniform vec3 uContentCameraPosition,uLightPosition,uBackgroundColor;
uniform float uGlassZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlossStrength;
uniform vec2 uViewOffset,uPetSize,uPaneSize;
uniform vec4 uPaneRect;
varying vec2 vUv;
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;}
vec4 sampleScene(vec2 uv){return texture2D(uSceneTexture,clamp(uv,0.,1.));}
vec4 samplePane(vec2 pane){return sampleScene(projectUv(vec3((pane-.5)*uPaneSize,uGlassZ)));}
vec4 sampleCell(vec2 pane,vec2 sourceUv,float strength,vec2 blurStep,vec2 cellSize){
vec2 center=mix(pane,sourceUv,strength);
// Limit Mosaic shifts to reduce missing strips at cell edges.
if(uMosaicStyle<=.5){
vec2 d=(center-pane)/cellSize;
vec2 dSafe=d/sqrt(vec2(1.)+(d/.18)*(d/.18));
center=pane+dSafe*cellSize;
}
vec2 blurOffset=strength*blurStep;
vec4 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 hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2 hash2(vec2 p){return vec2(hash(p),hash(p+vec2(19.19,73.73)));}
float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;}
vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
float softLimit(float value,float limit){return value*limit/(limit+abs(value));}
void evaluateCell(vec2 cell,vec2 local,vec2 grid,vec2 cellSize,vec2 cellDimensions,float cellWorld,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)));
}else{
float widthSeed=hash(cell+vec2(53.1,27.4));
if(widthSeed<.3)widths=mix(vec2(.90),vec2(1.08),hash2(cell+vec2(7.2,3.1)));
else if(widthSeed<.9)widths=mix(vec2(.46),vec2(.70),hash2(cell+vec2(7.2,3.1)));
else widths=mix(vec2(1.10),vec2(1.28),hash2(cell+vec2(7.2,3.1)));
}
float angle=(random.x-.5)*.08,c=cos(angle),s=sin(angle);
transform=mat2(widths.x*c,-widths.y*s,widths.x*s,widths.y*c);
vec2 sourceCenter=(cell+.5)/grid+(random-.5)*.16*cellSize;
affineUv=sourceCenter+transform*(local*cellSize);
// Positive height bulges toward the observer (+Z); negative height is concave.
float curvature=clamp(uCurvature,-1.,1.),excess=max(curvature-.65,0.),surfaceCurvature=curvature-(.25/.35)*excess*excess;
float height=.009*cellWorld*surfaceCurvature*mix(.92,1.08,hash(cell+vec2(41.7,17.3)));
vec2 q=clamp(2.*local,-1.,1.),q2=q*q,q3=q2*q,b=1.-q2*q2;
vec2 skew=(hash2(cell+vec2(23.8,61.2))-.5)*.28;float asym=1.+dot(skew,q);
vec2 dhdq=height*vec2(-4.*q3.x*b.y*asym+b.x*b.y*skew.x,-4.*q3.y*b.x*asym+b.x*b.y*skew.y);
vec2 grad=-2.*dhdq/cellDimensions;
curvedSlope=grad+(random-.5)*.012;facetSlope=(random-.5)*.012;
}
void main(){
if(uStrength<=.001){gl_FragColor=sampleScene(vUv);
#include <colorspace_fragment>
return;}
vec4 f=uContentInverseViewProjection*vec4(vUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid;float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
if(paneUv.x<0.||paneUv.x>1.||paneUv.y<0.||paneUv.y>1.){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}
vec2 cellDimensions=uPaneSize/grid;
vec2 affineUv,curvedSlope,facetSlope;mat2 transform;
evaluateCell(cell,local,grid,cellSize,cellDimensions,cellWorld,style,affineUv,transform,curvedSlope,facetSlope);
vec2 edge=.5-abs(local),neighborSign=mix(vec2(-1.),vec2(1.),step(vec2(0.),local));
// Keep a broad shared surface-normal transition; color footprints are blended separately.
vec2 blendAxis=1.-smoothstep(vec2(-.16),vec2(.16),edge);
vec2 weights=blendAxis;
vec2 centerAffine=affineUv;mat2 centerTransform=transform;
vec2 xAffine=vec2(0.),yAffine=vec2(0.),diagonalAffine=vec2(0.);
mat2 xTransform=mat2(0.),yTransform=mat2(0.),diagonalTransform=mat2(0.);
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);
vec2 colorAxis=1.-smoothstep(vec2(-.06),vec2(.06),edge);
float colorXWeight=colorAxis.x*(1.-colorAxis.y),colorYWeight=(1.-colorAxis.x)*colorAxis.y;
float colorDiagonalWeight=colorAxis.x*colorAxis.y,colorCenterWeight=(1.-colorAxis.x)*(1.-colorAxis.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,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
xAffine=neighborAffine;xTransform=neighborTransform;
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,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
yAffine=neighborAffine;yTransform=neighborTransform;
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,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
diagonalAffine=neighborAffine;diagonalTransform=neighborTransform;
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*(-gh.z/safeZ(i.z));vec3 facetRay=bend(i,nFacet),curvedRay=bend(i,nCurved);vec3 facetHit=gh+facetRay*(-gh.z/safeZ(facetRay.z)),curvedHit=gh+curvedRay*(-gh.z/safeZ(curvedRay.z));vec2 base=facetHit.xy-straightHit.xy,curve=curvedHit.xy-facetHit.xy;vec2 worldDelta=vec2(softLimit(base.x,cellWorld*.08),softLimit(base.y,cellWorld*.08));vec2 d=curve/cellWorld;worldDelta+=vec2(.3*d.x/(.3+abs(d.x)),.3*d.y/(.3+abs(d.y)))*cellWorld;
vec2 sampleUv=affineUv+transform*(worldDelta/uPaneSize);
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*.75,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;vec2 blurStep=cellSize*mix(.006,.012,hash(cell+vec2(5.)));
vec4 color;
// The .06-.16 band must retain the independent center transform to avoid reintroducing UV compression.
if(style<=.5&&(weights.x>0.||weights.y>0.)){
color=vec4(0.);
if(colorCenterWeight>0.)color+=sampleCell(paneUv,centerAffine+centerTransform*(worldDelta/uPaneSize),strength,blurStep,cellSize)*colorCenterWeight;
if(colorXWeight>0.){
vec2 xBlurStep=cellSize*mix(.006,.012,hash(cell+vec2(neighborSign.x,0.)+vec2(5.)));
color+=sampleCell(paneUv,xAffine+xTransform*(worldDelta/uPaneSize),strength,xBlurStep,cellSize)*colorXWeight;
}
if(colorYWeight>0.){
vec2 yBlurStep=cellSize*mix(.006,.012,hash(cell+vec2(0.,neighborSign.y)+vec2(5.)));
color+=sampleCell(paneUv,yAffine+yTransform*(worldDelta/uPaneSize),strength,yBlurStep,cellSize)*colorYWeight;
}
if(colorDiagonalWeight>0.){
vec2 diagonalBlurStep=cellSize*mix(.006,.012,hash(cell+neighborSign+vec2(5.)));
color+=sampleCell(paneUv,diagonalAffine+diagonalTransform*(worldDelta/uPaneSize),strength,diagonalBlurStep,cellSize)*colorDiagonalWeight;
}
}else{
color=sampleCell(paneUv,sampleUv,strength,blurStep,cellSize);
}
color.a=1.;
vec3 v=normalize(uContentCameraPosition-gh),reflected=reflect(-v,nCurved); float lightT=(uLightPosition.z-gh.z)/safeZ(reflected.z); vec2 lightHit=gh.xy+reflected.xy*lightT; vec2 halfSize=vec2(.32,.42)*min(uPaneSize.x,uPaneSize.y); vec2 windowCoord=abs(lightHit-uLightPosition.xy)/halfSize; vec2 windowMask=1.-smoothstep(vec2(.45),vec2(1.15),windowCoord); float spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float gloss=spec*clamp(uGlossStrength,0.,1.)*.12*edgeWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),gloss); gl_FragColor=color;
#include <colorspace_fragment>
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Some files were not shown because too many files have changed in this diff Show More