Implement patterned glass and maximum-depth distance controls

Add gobang surface refraction and shared photo/depth distance adjustment. Compare V2 Small and Base at input 770 with maximum-depth masks and pixel diagnostics. Record browser QA and update runtime dependencies and model notices.
This commit is contained in:
2026-10-06 14:33:47 +09:00
parent 619d980098
commit 71c8d3b7ff
82 changed files with 1602 additions and 1103 deletions
+8
View File
@@ -2,6 +2,14 @@
## 원본 요구사항 ## 원본 요구사항
- 2026-10-06 최신 Base 비교: 사용자는 Base 입력 770에서 depth=1 영역이 실제 앞쪽 부위를 명확하게 추정하는지 테스트하도록 요청했다. 개발 비교에서 Small/Base를 선택하며 기본 Small은 유지한다. 최대 깊이 진단은 정규화 Float32 최대 1, 8비트 최대 1, ≥0.99 영역을 구분하고 위치·픽셀값·장면 간격을 표시한다. 거리 조절은 전체 최대 depth 표면을 기준으로 유지하며 코 검출이나 실제 측정 거리로 설명하지 않는다. Base는 비상업적 테스트 옵션이다. 결과/한계는 `docs/qa-ai-depth-base-2026-10-06/report.md`를 따른다.
- 2026-10-06 최신 AI depth 입력: Depth Anything V2 Small의 추론 전처리 기준 크기를 770으로 사용한다. 종횡비와 14의 배수 정렬을 유지하며 WebGPU와 WASM 모두 공통 로더에서 적용한다. 사진 스냅샷의 긴 변 상한 1024, 8비트 출력, 128×128 Depth mesh, 사진 구도와 유리 설정은 유지한다. 검증 기록은 `docs/qa-ai-depth-770-2026-10-06/report.md`다.
- 2026-10-06 최신 거리 조절: `사진–유리 거리`를 0–100%로 조절한다. 사진의 가장 앞쪽과 유리 사이 간격은 장면 단위 0.04–0.60이며 초기 0%는 기존 근접 배치다. RGB와 Depth를 공통 이동하고 굴절 기준 평면과 프레임 안쪽 가림도 함께 갱신한다. 거리 값은 사진 교체·AI depth 완료 후에도 유지하며 확대·팬을 자동 변경하지 않는다. 구현·검증은 `docs/qa-photo-glass-distance-2026-10-06/report.md`를 따른다.
- 2026-10-06 최신 고방유리 작업: 첨부 실물 사진처럼 유리 자체의 반복 표면과 그 표면에 따른 사진 굴절을 우선한다. 확대 사진에 따른 후속 보정과 검증은 `docs/qa-gobang-facets-2026-10-06/report.md`를 따르며, 첫 구현 기록은 `docs/qa-gobang-surface-2026-10-06/report.md`에 보존한다. 둥근 사각 타일이나 삼각 보석 면을 목표로 삼지 않는다. 기본 고방유리는 색면 처리를 사용하지 않으며, 이전 색면 1 고정 지시는 보존된 Ref1 옵션 2 모드에 적용한다. 두 표현을 혼동하지 않는다. 자동 구도 변경 금지와 RGB/Depth 공통 배치는 유지한다.
- 2026-09-14 최신 Ref1 옵션 정리: 색면 단순화와 Distortion을 1로 고정하고 해당 조절 UI를 제거한다. 주 조절은 Grid 10~24(큰 얼굴 권장12~16, 작은 얼굴/여러 마리20~24), 굴곡 −1~1, 광택, 시점 범위다. 기타 효과·깊이·조명·감도는 접힌 개발 비교로 이동하고 수동 구도는 유지한다. 초기 기준값 Grid13/굴곡+1/광택.3/Camera.5. 상세와 한계는 `docs/ref1-option-ranges.md`. 이전 기본값·노출 UI 지시보다 우선한다. - 2026-09-14 최신 Ref1 옵션 정리: 색면 단순화와 Distortion을 1로 고정하고 해당 조절 UI를 제거한다. 주 조절은 Grid 10~24(큰 얼굴 권장12~16, 작은 얼굴/여러 마리20~24), 굴곡 −1~1, 광택, 시점 범위다. 기타 효과·깊이·조명·감도는 접힌 개발 비교로 이동하고 수동 구도는 유지한다. 초기 기준값 Grid13/굴곡+1/광택.3/Camera.5. 상세와 한계는 `docs/ref1-option-ranges.md`. 이전 기본값·노출 UI 지시보다 우선한다.
- 2026-09-14 최신 옵션 지정: 현재 어두운 사각 색면 보존 구현과 설정을 **현재 프로젝트에서 Ref1 목표에 가장 근접한 두 번째 옵션(옵션 2)**으로 명시한다. 정확한 값은 `docs/ref1-option-2.md`를 따른다. 기존 기준안은 옵션 1로 보존하며, 옵션 2를 Ref2 목표로 해석하지 않는다. 이후 변경은 두 기준과 구분해 기록한다. - 2026-09-14 최신 옵션 지정: 현재 어두운 사각 색면 보존 구현과 설정을 **현재 프로젝트에서 Ref1 목표에 가장 근접한 두 번째 옵션(옵션 2)**으로 명시한다. 정확한 값은 `docs/ref1-option-2.md`를 따른다. 기존 기준안은 옵션 1로 보존하며, 옵션 2를 Ref2 목표로 해석하지 않는다. 이후 변경은 두 기준과 구분해 기록한다.
+13 -2
View File
@@ -2,14 +2,25 @@
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. 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. 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.
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 **No Glass** for parallax review; choose **Final** to inspect the Mosaic effect. A failure leaves the current depth in place and the button can be used to retry. 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 and license
- Model: [`onnx-community/depth-anything-v2-small-ONNX`](https://huggingface.co/onnx-community/depth-anything-v2-small-ONNX), Apache-2.0. - Model: [`onnx-community/depth-anything-v2-small-ONNX`](https://huggingface.co/onnx-community/depth-anything-v2-small-ONNX), Apache-2.0.
- Runtime: [`@huggingface/transformers` 3.8.1](https://github.com/huggingface/transformers.js), 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 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. 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.
+2
View File
@@ -1,5 +1,7 @@
# 유리 바로 뒤의 피사체 배치 # 유리 바로 뒤의 피사체 배치
2026-10-06: 고정 간격 0.04를 기본으로 보존하면서 사진–유리 거리 슬라이더(0–100%, 장면 간격 0.04–0.60)를 추가했다. RGB/Depth 공통 배치와 자동 구도 금지를 유지한다. 최신 구현과 검증은 [거리 조절 기록](qa-photo-glass-distance-2026-10-06/report.md)을 따른다. 아래 고정 근접 배치와 자동 구도 실험은 과거 기록이다.
최신 변경: 사용자가 자동 구도 조정을 제외하도록 요청했다. 아래 자동 구도 보완·검증은 과거 실험 기록이다. 현재 동작에서는 사진별 확대 프리셋과 AI 앞쪽 영역 자동 확대/팬을 제거하고, 입력 사진 구도와 실제 유리 근접 배치만 유지한다. 수동 구도 조절은 가능하다. 최신 변경: 사용자가 자동 구도 조정을 제외하도록 요청했다. 아래 자동 구도 보완·검증은 과거 실험 기록이다. 현재 동작에서는 사진별 확대 프리셋과 AI 앞쪽 영역 자동 확대/팬을 제거하고, 입력 사진 구도와 실제 유리 근접 배치만 유지한다. 수동 구도 조절은 가능하다.
제거 검증: `src/main.ts`와 `src/depth/DepthProcessor.ts`의 자동 구도 경로·미사용 분석 도우미를 제거했다. 기존 수작업 샘플도 확대1/팬0으로 입력된다. 실제5173에서 sample의 간격0.040 유지, dog3 자동 AI 완료 후에도 확대1/가로0/세로0 및 Final 유지, 오류 로그없음을 확인했다. 빌드·타입 검사 통과. 제거 검증: `src/main.ts`와 `src/depth/DepthProcessor.ts`의 자동 구도 경로·미사용 분석 도우미를 제거했다. 기존 수작업 샘플도 확대1/팬0으로 입력된다. 실제5173에서 sample의 간격0.040 유지, dog3 자동 AI 완료 후에도 확대1/가로0/세로0 및 Final 유지, 오류 로그없음을 확인했다. 빌드·타입 검사 통과.
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

@@ -0,0 +1,18 @@
{
"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"
}
@@ -0,0 +1,30 @@
{
"sourceSize": [
400,
500
],
"snapshotSize": [
400,
500
],
"results": [
{
"nominalSize": 518,
"tensorDimensions": [
1,
3,
518,
420
]
},
{
"nominalSize": 770,
"tensorDimensions": [
1,
3,
770,
616
]
}
]
}
+41
View File
@@ -0,0 +1,41 @@
# 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)
@@ -0,0 +1,18 @@
{
"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"
}
@@ -0,0 +1,8 @@
{
"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.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

@@ -0,0 +1,59 @@
{
"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"
}
]
}
@@ -0,0 +1,6 @@
{
"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.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -0,0 +1,74 @@
# 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.

After

Width:  |  Height:  |  Size: 70 KiB

@@ -0,0 +1,59 @@
{
"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"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

@@ -0,0 +1,11 @@
{
"camera-offset": "0.13",
"curvature": "0.78",
"gloss-strength": "0.4",
"grid-size": "16",
"photo-pan-x": "0",
"photo-pan-y": "0",
"photo-zoom": "1",
"test-photo": "dog1",
"view-mode": "glass"
}
@@ -0,0 +1,34 @@
# 확대 사진에 따른 고방유리 표면 보정
2026-10-06 사용자가 추가한 300×300 실물 사진을 현재 고방유리의 시각 목표로 삼았다. `target.png`는 그 첨부 파일의 원본 복사다. 기존 Ref1/Ref2 비교와 색면 옵션은 별도의 과거 표현으로 보존한다.
## 판단과 변경
첫 구현은 평탄한 중심과 둥근 경사면의 곱으로 높이를 구성해 사각 쿠션/버튼처럼 보였다. 확대 사진에는 가로·세로 방향의 능선과 그 사이에서 늘어나고 꺾이는 투과 영상이 보인다. 후속 실험에서 X/Y 높이의 부드러운 최솟값으로 네 면을 만들었지만 대각선 삼각 조각이 두드러져 채택하지 않았다.
현재 높이는 `0.25 px + 0.55 py + 0.20 px py`로 구성한다. `px`와 `py`는 중심과 셀 경계 부근만 둥글고 중간은 기울기가 일정한 반복 프로파일이다. 가로 능선에 더 큰 비중을 두고, 연속적인 작은 물결을 더한다. 셀별 사진 크롭이나 사각 색면, 검은 격자선을 추가하지 않는다. 동일 높이의 미분으로 얻은 법선을 기존 새 모드의 두 경계 굴절과 반사에 공유한다. 경계의 높이가 항상 0인 초기 곱 방식과 달리, 교차 능선의 높이와 기울기가 인접 셀에 주기적으로 이어진다.
수정 코드는 `src/shaders/mosaic.frag`, `src/constants/glass.ts`, `src/main.ts`의 고방유리 표면 부분이다. 상수는 Grid 20, 높이/셀 크기 0.085, 중심 반폭 0.07셀, 경사 전환 둥글림 0.10, 굴절률 1.5, 기본 두께 0.008, 환경 반사 강도 0.45다. 기본 UI는 굴곡 +1 / 광택 0.3 / Camera 0.5를 유지한다. RGB/Depth 배치와 수동 구도는 변경하지 않았다.
## 검증
- Codex in-app browser에서 최신 페이지와 WebGL 출력을 직접 확인했다. 기존 탭의 갱신이 실패해 같은 브라우저에 새 로컬 검증 탭을 열었다.
- dog1의 자동 AI depth 완료 후 Grid 20 기본값과 사용자 기존 설정 Grid 16 / 굴곡 0.78 / 광택 0.4 / Camera 0.13을 비교했다. 넓은 사각 방향의 사진 변형과 능선 반복을 확인했다. 첫 화면의 과도한 삼각 조각은 현재 높이에서 줄었다.
- dog2의 실내 배경에서도 자동 AI depth 완료 후 가로 능선을 따라 배경과 몸 윤곽이 꺾이는 출력을 확인했다(`dog2-grid20.png`).
- 광택 0에서도 굴절이 남는다. 광택 0 / 굴곡 0에서는 반복 변형이 사라진다. 굴곡 −1의 반대 변형과 드래그 시 시점 반응도 출력했다.
- 사진 확대 1 / 가로 팬 0 / 세로 팬 0 유지. 1280×900과 390×844 출력 확인. 모바일 문서 폭 375px, viewport 390px로 가로 넘침 없음.
- console error 없음. 기존 공유 depth 교점 반복에 대한 ANGLE implicit derivative 경고는 첫 구현과 동일한 제한이다.
- `npm.cmd run build -- --outDir test-results/gobang-facets-build`: 타입 검사와 Vite 빌드 통과. 기존 큰 번들 경고는 유지된다.
- 기존 surface-reflection, mosaic-continuity, region-axis-split 검사 통과. 이 검사는 기존 비교 경로를 보호하며 새 표면의 실물 일치도를 보증하지 않는다.
비교 파일: `before-current-settings.png`는 변경 전 화면(기본 브라우저 폭), `dog1-current-settings.png`는 같은 재질 설정의 변경 후 화면(데스크톱 검증 폭)이다. `initial-controls.json`에 변경 전 값이 남아 있다. `dog1-directional-grid20.png`, `dog1-gloss0.png`, `dog1-flat-gloss0.png`, `dog1-concave.png`, `dog1-drag.png`, `dog1-final-desktop.png`, `dog1-final-pane.png`, `dog1-mobile.png`에 조건별 출력을 저장했다. 확대·팬 값은 모두 동일하지만 화면 크기가 다른 두 파일을 픽셀 단위 전후 비교로 취급하지 않는다.
## 현재 한계
실물의 측정된 표면을 복원한 재질은 아니다. 규칙적인 절차적 높이장으로 방향성 있는 굴절을 근사했다. 실물의 비대칭 경사, 미세한 능선 반사, 제품별 불규칙성은 더 보정할 여지가 있고 첨부 사진과 완전히 같다고 판정하지 않았다. 실물 사진은 뒤 배경과 조명 조건도 다르므로 강아지 사진의 색이나 명암이 같아질 것을 완료 기준으로 삼지 않는다. 실기기 FPS와 영상 flicker 계측은 수행하지 않았다.
실행: 프로젝트 루트에서 `npm.cmd run dev -- --host 127.0.0.1 --port 5174`, 로컬 미리보기 `http://127.0.0.1:5174/`. 유리 패턴은 `고방유리 · 표면 굴절`, 개발 비교의 View Mode는 `Final`로 확인한다.
![사용자 확대 레퍼런스](target.png)
![현재 기본값의 사진 투과 결과](dog1-final-pane.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

@@ -0,0 +1,57 @@
# 고방유리 표면과 투과 영상
이 문서는 첫 구현의 기록이다. 같은 날 사용자가 올린 300×300 확대 사진에 따른 후속 표면 보정과 현재 수치는 [후속 보고서](../qa-gobang-facets-2026-10-06/report.md)를 따른다.
2026-10-06 사용자 요청: 첨부 실물 고방유리처럼 반복 패턴에 맞춰 뒤쪽 사진이 굴절되는 표현을 구현한다. GPT 답변은 설명 자료로 검토했으며 유리문 상호작용 등의 추가 제안을 구현 지시로 취급하지 않았다.
## 적용
- 기본 패턴을 `고방유리 · 표면 굴절`로 바꿨다. Grid 20 / 굴곡 +1 / 광택 0.3 / Camera 0.5에서 시작한다. 주 화면의 패턴 선택에서 기존 Ref1 옵션 2(Grid 13, 색면 1)와 기존 촘촘 유리(Grid 24)로 전환할 수 있다. 사진 확대·팬은 바꾸지 않는다.
- `src/shaders/mosaic.frag`: 새로운 style 2 경로는 사각 셀의 평탄한 중심과 둥근 경사면을 반복 높이 함수로 정의한다. 셀 경계에서 높이와 첫 미분이 이어진다. 높이의 미분에서 얻은 동일 법선으로 굴절과 환경 반사를 계산한다. 별도 격자선이나 셀별 무작위 사진 크롭을 합성하지 않는다.
- 카메라 광선의 얕은 앞면 교점을 두 번 보정하고, 굴절률 1.5의 요철 앞면과 평탄한 뒷면을 거쳐 배경을 읽는다. 새 모드에는 기존의 최소 0.6 연출 광학 거리를 사용하지 않는다. 기존 visible-depth 교점 보정 함수를 재사용한다. 색면 atlas/palette/history는 새 모드에서 실행되지 않는다. 기존 Ref1/촘촘 유리의 광학 및 색면 셰이더 경로는 유지했다.
- `src/constants/glass.ts`: 높이/셀 크기 0.065, 평탄 중심 반폭 0.18셀, 굴절률 1.5, 기본 두께 0.008, 환경 반사 강도 0.65를 기록했다. 뒷면은 음의 굴곡도 수용하도록 기본 두께에 최대 셀 높이를 더해 배치한다. 실물 유리의 측정값이 아니라 장면 좌표의 초기 재질 설정이다.
- `src/main.ts`: 사진 미입력 시 넓은 명암 영역과 하나의 창 형태가 있는 중립 배경을 그린다. 배경에는 반복 격자가 없다. 업로드 전후 동일한 유리 셰이더가 사용되고 중립 배경 텍스처도 기존 teardown에서 dispose된다.
- `index.html`: 유리 패턴 선택을 주 화면으로 옮기고 현재 모드의 설명과 Grid 기본값을 맞췄다. 기존 비교 이미지와 수동 구도 조절을 유지했다.
- `src/depth/aiDepth.worker.ts`: 이미 설치된 Transformers.js 4.3의 출력 타입 `DepthEstimationOutput`을 기존 지역 타입 이름으로 import해 빌드 오류를 해결했다. 추론·정규화 알고리즘은 변경하지 않았다. 작업 전 존재한 package.json/package-lock.json 변경은 건드리지 않았다.
- 기존 axis-split 검사의 UI 기본값/선택 위치 기대값을 갱신했다. 기존 표면 반사 검사에는 Windows CRLF 정규화를 추가했다. 새 테스트 프레임워크나 의존성은 추가하지 않았다.
## 검증
검증 환경은 Windows의 Codex in-app browser다. 먼저 수작업 샘플을 Glass OFF로 확인한 후 사진과 Final 모드를 비교했다.
| 조건 | 확인 결과 |
| --- | --- |
| Glass OFF, 수작업 샘플 | 앞 표면 z 0.560, 유리 z 0.600, 간격 0.040 유지. 카메라 X 0→약 0.053에서 코 투영 X 520.448→516.916, 왼눈 493.625→490.661, 몸 453.585→451.358px로 서로 다른 시차 확인 |
| 사진 미입력 | 사각 경사면의 명암과 중립 배경의 굴절이 정지 화면에서 보임 |
| dog1, 자동 AI depth | AI 추정 완료 후 동일 사진·구도에서 고방유리와 Ref1 옵션 2 비교. 새 모드의 셀 내부 윤곽 늘어남/반복과 사각 경사면 확인 |
| dog1, Grid 13/20 | Grid 13에서 같은 크기의 기존 색면과 새 굴절을 비교. 기본 Grid 20의 더 작은 패턴도 확인 |
| 광택 0, 굴곡 +1 | 표면 반사가 사라져도 사진 윤곽의 굴절 유지 |
| 광택 0, 굴곡 0 | 반복적인 사진 변형이 사라지고 평탄 유리의 투과 영상으로 복귀 |
| 굴곡 −1 | 오목한 표면의 다른 굴절 결과 확인 |
| 드래그 | 패턴이 유리 좌표에 남고 뒤 영상과 반사가 시점에 반응함 |
| dog2, 자동 AI depth | 두 번째 사진에서도 같은 유리 재질과 AI depth 적용 성공 |
| 기존 Reeded / Frosted | 두 모드의 렌더링과 고방유리로 복귀 확인 |
| 1280×900 / 390×844 | 데스크톱·모바일 폭 출력 확인. 모바일 innerWidth 390, document.scrollWidth 375로 가로 넘침 없음 |
| 사진 구도 | dog1 AI 완료·모드 전환·모바일 크기 변경 뒤에도 확대 1 / 팬 X 0 / 팬 Y 0 |
브라우저 console error는 없었다. ANGLE 셰이더 컴파일러는 공유 depth 교점 반복의 텍스처 implicit derivative에 대한 warning을 출력했다. 이 경고와 모바일 실기기 성능 검증은 별도로 남는다.
기존 검사 9개가 통과했다: camera/history, depth evidence, depth refraction, Mosaic continuity, depth-distance, atlas dark sampling, axis split, surface reflection, window reveal coverage. 이들은 기존 비교 경로의 소스/수학/형상 검사이며 새 고방유리의 시각 품질은 위 실화면 검증으로 평가했다. Node 22에서 `.ts`를 import하는 검사는 `--experimental-strip-types`로 실행했다.
최종 TypeScript/Vite 빌드 통과: `npm.cmd run build -- --outDir test-results/gobang-build`. 최초 `npm.cmd run build`도 타입 호환 수정 후 통과했다. 기존 큰 번들 경고는 남는다. 빌드가 생성한 추적 dist 변경은 작업 전 상태로 복구하고 검증 산출물은 제거했다.
## 비교 이미지
- `empty-glass-desktop.png`: 사진 미입력
- `sample-no-glass.png`, `sample-no-glass-drag.png`: 수작업 depth의 실제 시차
- `dog1-ref1-option2-grid13.png`: 보존된 기존 옵션 2
- `dog1-gobang-grid13.png`, `dog1-gobang-grid20.png`: 새로운 표면 굴절
- `dog1-gobang-gloss0.png`, `dog1-gobang-flat-gloss0.png`: 반사와 굴절의 역할 분리
- `dog1-gobang-concave.png`, `dog1-gobang-drag.png`: 부호/시점 반응
- `dog1-gobang-mobile.png`, `dog2-gobang-grid20.png`: 모바일 폭 및 다른 사진
## 한계와 실행
사진의 depth는 상대 깊이다. 유리 형상은 첨부 스크린샷을 바탕으로 만든 절차적 근사이며 특정 제품의 실측 복원이 아니다. 앞면 교점은 얕은 높이장의 반복 근사이고, 배경 교점은 현재 시점에서 보이는 depth만 재사용한다. 가려진 영역을 복원하거나 다중 내부 반사·광학 caustics를 계산하지 않는다. 참고 사진과 완전히 동일한 재질이라는 판정, 영상 flicker 계측, 모바일 실기기 FPS 측정은 하지 않았다.
프로젝트 루트에서 `npm.cmd run dev -- --host 127.0.0.1 --port 5174`로 실행한다. 이번 작업의 로컬 미리보기는 `http://127.0.0.1:5174/`다. 사진은 브라우저 로컬에서 처리하며 외부 게시나 배포는 하지 않았다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -0,0 +1,8 @@
{
"curvature": "1",
"grid-size": "20",
"photo-glass-distance": "50",
"photo-pan-x": "-0.2",
"photo-pan-y": "0.25",
"photo-zoom": "1.4"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,50 @@
# 사진–유리 거리 조절
2026-10-06 사용자 요청: 고방유리 표면과 시점에 더해, 뒤 사진과 유리 사이 거리를 조절한다. 사진의 가장 앞쪽 표면을 기준으로 하며 사진의 확대·팬 값은 유지한다.
## 구현
- `index.html`: 주요 조절에 `사진–유리 거리` 슬라이더를 추가했다. 범위 0–100%, 기본값 0%. 실측 cm/m가 아닌 상대 조절값이다.
- `src/constants/glass.ts`: `PHOTO_GLASS_DISTANCE`에 장면 간격 최소 0.04 / 최대 0.60을 정의했다. 0%는 기존 근접 배치와 같다.
- `src/main.ts`: 고정 간격을 상태 `photoGlassGap`으로 교체했다. 기존 `contentBaseZ()`와 `applyFraming()`을 재사용해 `baseZ = glassZ - gap - (maxDepth - depthBias) * depthScale`로 배치한다. RGB 평면과 Depth mesh에 같은 위치·스케일을 적용하고, `uSourceZ` 및 `WindowReveal`도 함께 갱신한다. 기존 원근 크기 보정을 재사용하며 사진 확대·팬 값을 바꾸지 않는다. 유리 위치·Grid·표면 법선·광택·시점 설정도 바꾸지 않는다.
- 사진 교체와 AI depth 완료는 설정된 간격을 사용해 다시 배치한다. 사진 구도 초기화와 시점 초기화는 거리 조절을 초기화하지 않는다.
- 기존 region/history 시그니처에 물체 배치가 이미 포함되어 있어 추가 초기화 경로를 만들지 않았다. 광학 셰이더와 새 의존성도 추가하지 않았다.
- `scripts/test-window-reveal-coverage.mjs`: 기존 프레임 가림 검사를 거리 양 끝값으로 확장했다.
## 실제 화면 검증
Windows Codex in-app browser에서 먼저 수작업 샘플을 No Glass로 확인한 후 Final을 비교했다. 샘플 depth는 수작업 테스트이며 AI 추정 또는 실측값이 아니다.
| 거리 | 앞쪽 간격 | 기준 평면 Z | 가장 앞쪽 Z |
| --- | --- | --- | --- |
| 0% | 0.040 | 0.315 | 0.560 |
| 50% | 0.320 | 0.035 | 0.280 |
| 100% | 0.600 | −0.245 | 0.000 |
유리 Z는 0.600, 위 표의 Depth Scale은 0.25다. 가장 앞쪽 Z와 기준 평면 Z가 함께 감소하고 표면 간 상대 깊이는 유지된다. 거리를 늘려도 정면 사진 크기는 기존 원근 보정으로 거의 유지된다. Depth가 있는 앞쪽 표면의 투영은 거리 변경에 따라 소폭 달라질 수 있다.
- No Glass의 100%에서 Camera X 0→약 0.052로 드래그했다. 코 투영 X 520.100→520.088, 왼눈 493.698→494.076, 몸 453.812→454.658px로 깊이에 따른 서로 다른 시차가 남는다.
- 100% / Depth Scale 0.4: 앞쪽 Z 0.000 / 간격 0.600. 0% / Depth Scale 0: 전체 표면 Z 0.560 / 간격 0.040. 유리 관통 없음.
- 샘플과 dog1의 같은 사진·구도·Grid 20 / 굴곡 +1 / 광택 0.3에서 0%와 100%를 비교했다. 먼 배치에서 사진 조각의 굴절 이동과 반복이 더 커진다. dog1의 50%도 저장했다.
- 거리 50% / 확대 1.4 / 가로 팬 −0.2 / 세로 팬 0.25에서 dog1로 사진을 교체하고 자동 AI depth 완료를 기다렸다. 네 값과 Grid 20 / 굴곡 +1이 모두 유지됐다. 이후 테스트용 수동 구도만 1/0/0으로 복귀했다.
- 1280×900 데스크톱과 390×844 모바일에서 조작·출력을 확인했다. 모바일 document.scrollWidth 375px ≤ innerWidth 390px. 거리 50%와 구도 1/0/0 유지.
- 브라우저 console error 없음. 새 거리 기능에 셰이더를 수정하지 않았다. 기존 광학/visible-depth 근사와 ANGLE 경고의 한계는 이전 보고서와 같다.
수치 기록: `sample-depth-evidence.json`, `ai-framing-evidence.json`. 화면 기록: `sample-noglass-near.png`, `sample-noglass-far.png`, `sample-final-near.png`, `sample-final-far.png`, `dog1-near.png`, `dog1-far.png`, `dog1-mid.png`, `mobile-distance-control.png`.
## 자동 검사
- `npm.cmd run build -- --outDir test-results/photo-glass-distance-build`: TypeScript + Vite 빌드 통과. 기존 큰 번들 경고는 유지. 검증용 빌드 출력은 제거했다.
- `node --experimental-strip-types scripts/test-window-reveal-coverage.mjs`: 2종 종횡비 × 3종 Depth Scale × 2종 거리 × 17종 시점 × 49개 aperture 샘플에서 프레임 안쪽 기하 가림 통과. geometry 재사용·dispose도 통과했다.
- 기존 depth-refraction, camera-history 검사 통과.
- `git diff --check` 통과.
## 사용과 한계
주 화면에서 `사진–유리 거리`를 조절한다. 초기 0%는 가까운 기존 배치이고, 검증 후 미리보기는 차이를 보기 쉽게 50%에 두었다. 실행은 프로젝트 루트에서 `npm.cmd run dev -- --host 127.0.0.1 --port 5174`, 미리보기는 `http://127.0.0.1:5174/`다.
Depth는 상대 깊이이며, 간격의 기준점이 얼굴이나 코라는 의미적 보장은 없다. 가장 앞쪽 부분이 앞발이나 다른 물체일 수 있다. 큰 거리에서는 굴절이 강해져 반복과 프레임 안쪽의 어두운 영역이 더 보일 수 있다. 현재 RenderTarget에서 가려진 장면이나 화면 밖 영역을 새로 복원하지 않는다. 실물 광학의 정밀 시뮬레이션, 모바일 실기기 FPS 또는 영상 flicker 계측은 수행하지 않았다.
| 0% 가까움 | 100% 멀어짐 |
| --- | --- |
| ![가까운 배치](dog1-near-pane.png) | ![먼 배치](dog1-far-pane.png) |
@@ -0,0 +1,8 @@
{
"near": "cameraX=0.000 sample=sample\ncontentBaseZ=0.315 maxFrontZ=0.560 glassGap=0.040\nnose: depth=0.980 worldZ=0.560 projectedX=520.448px\nleft-eye: depth=0.678 worldZ=0.485 projectedX=493.625px\nright-eye: depth=0.678 worldZ=0.485 projectedX=529.752px\nface: depth=0.600 worldZ=0.465 projectedX=509.841px\nleft-ear: depth=0.431 worldZ=0.423 projectedX=466.121px\nright-ear: depth=0.431 worldZ=0.423 projectedX=538.027px\nbody: depth=0.298 worldZ=0.389 projectedX=453.585px",
"far": "cameraX=0.000 sample=sample\ncontentBaseZ=-0.245 maxFrontZ=0.000 glassGap=0.600\nnose: depth=0.980 worldZ=0.000 projectedX=520.100px\nleft-eye: depth=0.678 worldZ=-0.075 projectedX=493.698px\nright-eye: depth=0.678 worldZ=-0.075 projectedX=529.410px\nface: depth=0.600 worldZ=-0.095 projectedX=509.741px\nleft-ear: depth=0.431 worldZ=-0.137 projectedX=466.364px\nright-ear: depth=0.431 worldZ=-0.137 projectedX=537.754px\nbody: depth=0.298 worldZ=-0.171 projectedX=453.812px",
"farDrag": "cameraX=0.052 sample=sample\ncontentBaseZ=-0.245 maxFrontZ=0.000 glassGap=0.600\nnose: depth=0.980 worldZ=0.000 projectedX=520.088px\nleft-eye: depth=0.678 worldZ=-0.075 projectedX=494.076px\nright-eye: depth=0.678 worldZ=-0.075 projectedX=529.757px\nface: depth=0.600 worldZ=-0.095 projectedX=510.191px\nleft-ear: depth=0.431 worldZ=-0.137 projectedX=467.107px\nright-ear: depth=0.431 worldZ=-0.137 projectedX=538.366px\nbody: depth=0.298 worldZ=-0.171 projectedX=454.658px",
"farDepth04": "cameraX=0.000 sample=sample\ncontentBaseZ=-0.392 maxFrontZ=0.000 glassGap=0.600\nnose: depth=0.980 worldZ=0.000 projectedX=521.011px\nleft-eye: depth=0.678 worldZ=-0.121 projectedX=493.508px\nright-eye: depth=0.678 worldZ=-0.121 projectedX=530.296px\nface: depth=0.600 worldZ=-0.152 projectedX=509.998px\nleft-ear: depth=0.431 worldZ=-0.220 projectedX=465.739px\nright-ear: depth=0.431 worldZ=-0.220 projectedX=538.456px\nbody: depth=0.298 worldZ=-0.273 projectedX=453.229px",
"nearDepth0": "cameraX=0.000 sample=sample\ncontentBaseZ=0.560 maxFrontZ=0.560 glassGap=0.040\nnose: depth=0.980 worldZ=0.560 projectedX=518.582px\nleft-eye: depth=0.678 worldZ=0.560 projectedX=494.027px\nright-eye: depth=0.678 worldZ=0.560 projectedX=527.873px\nface: depth=0.600 worldZ=0.560 projectedX=509.291px\nleft-ear: depth=0.431 worldZ=0.560 projectedX=467.482px\nright-ear: depth=0.431 worldZ=0.560 projectedX=536.500px\nbody: depth=0.298 worldZ=0.560 projectedX=454.873px",
"mid": "cameraX=0.000 sample=sample\ncontentBaseZ=0.035 maxFrontZ=0.280 glassGap=0.320\nnose: depth=0.980 worldZ=0.280 projectedX=520.256px\nleft-eye: depth=0.678 worldZ=0.205 projectedX=493.665px\nright-eye: depth=0.678 worldZ=0.205 projectedX=529.564px\nface: depth=0.600 worldZ=0.185 projectedX=509.786px\nleft-ear: depth=0.431 worldZ=0.143 projectedX=466.255px\nright-ear: depth=0.431 worldZ=0.143 projectedX=537.877px\nbody: depth=0.298 worldZ=0.109 projectedX=453.710px"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+30 -7
View File
@@ -38,12 +38,17 @@
<option value="dog4">멍멍이 4 · 벤치 위 갈색 닥스훈트</option> <option value="dog4">멍멍이 4 · 벤치 위 갈색 닥스훈트</option>
</select></label> </select></label>
<button id="sample">선택한 테스트 사진 불러오기</button> <button id="sample">선택한 테스트 사진 불러오기</button>
<p id="region-fixed-note" class="control-note">Ref1 기준 · 색면 단순화 1 · 굴절 1 (고정)</p> <label>유리 패턴<select id="mosaic-style">
<option value="2" selected>고방유리 · 표면 굴절</option>
<option value="0">Ref1 옵션 2 · 사각 색면</option>
<option value="1">기존 촘촘 유리</option>
</select></label>
<p id="region-fixed-note" class="control-note">고방유리 · 표면 요철에 따른 굴절 · 색면 처리 없음</p>
<label>Grid Size <output id="gridOut">13</output> <label>Grid Size <output id="gridOut">20</output>
<input id="grid-size" type="range" min="10" max="24" step="1" value="13"> <input id="grid-size" type="range" min="10" max="24" step="1" value="20">
</label> </label>
<small class="control-help">큰 얼굴 12–16 · 작은 얼굴/여러 마리 20–24</small> <small class="control-help">고방유리 20–24 · 큰 셀 비교 12–16</small>
<div id="gloss-controls" class="core-optics"> <div id="gloss-controls" class="core-optics">
<label>셀 굴곡 (−1 오목 · +1 볼록) <output id="curvatureOut">1.00</output> <label>셀 굴곡 (−1 오목 · +1 볼록) <output id="curvatureOut">1.00</output>
<input id="curvature" type="range" min="-1" max="1" step="0.01" value="1"> <input id="curvature" type="range" min="-1" max="1" step="0.01" value="1">
@@ -53,6 +58,10 @@
<input id="gloss-strength" type="range" min="0" max="0.4" step="0.01" value="0.30"> <input id="gloss-strength" type="range" min="0" max="0.4" step="0.01" value="0.30">
</label> </label>
</div> </div>
<label>사진–유리 거리 <output id="photoGlassDistanceOut">0%</output>
<input id="photo-glass-distance" type="range" min="0" max="100" step="1" value="0">
</label>
<small class="control-help">최대 depth 표면 기준 · 0% 가까움 · 100% 멀어짐</small>
<label>Camera Offset <output id="cameraOut">0.50</output> <label>Camera Offset <output id="cameraOut">0.50</output>
<input id="camera-offset" type="range" min="0" max="0.9" step="0.01" value="0.5"> <input id="camera-offset" type="range" min="0" max="0.9" step="0.01" value="0.5">
</label> </label>
@@ -79,6 +88,23 @@
<details> <details>
<summary>개발 비교</summary> <summary>개발 비교</summary>
<div class="details-content"> <div class="details-content">
<label>AI 깊이 모델<select id="ai-depth-model">
<option value="small">V2 Small · 상업적 사용 가능</option>
<option value="base">V2 Base · 비상업적 테스트</option>
</select></label>
<small class="control-note">모델을 선택한 뒤 AI 깊이 생성을 누르세요 · 입력 770</small>
<details id="depth-inspection">
<summary>최대 깊이 영역 확인</summary>
<p class="control-note">모델 최대 1: 분홍 · 8비트 depth=1: 청록 · 모델 depth≥0.99: 노랑</p>
<canvas id="depth-inspection-canvas" aria-label="최대 깊이 영역 · 사진을 클릭해 값 확인"></canvas>
<pre id="depth-inspection-summary">사진과 깊이를 불러오세요.</pre>
<div class="depth-coordinates">
<label>픽셀 X<input id="depth-inspection-x" type="number" min="0" value="0"></label>
<label>픽셀 Y<input id="depth-inspection-y" type="number" min="0" value="0"></label>
</div>
<pre id="depth-inspection-point"></pre>
<small class="control-note">1은 사진별 정규화 최댓값이며 실제 거리나 코 검출 결과가 아닙니다. 간격은 깊이맵 기준 장면 단위입니다.</small>
</details>
<label class="upload">동영상 선택<input id="video" type="file" accept="video/mp4,video/webm"></label> <label class="upload">동영상 선택<input id="video" type="file" accept="video/mp4,video/webm"></label>
<label class="upload">Depth 이미지 선택<input id="depth-image" type="file" accept="image/png,image/webp"></label> <label class="upload">Depth 이미지 선택<input id="depth-image" type="file" accept="image/png,image/webp"></label>
<label>View Mode<select id="view-mode"> <label>View Mode<select id="view-mode">
@@ -87,9 +113,6 @@
<label>Glass<select id="effect"> <label>Glass<select id="effect">
<option value="mosaic">Mosaic</option><option value="reeded">Reeded</option><option value="frosted">Frosted</option> <option value="mosaic">Mosaic</option><option value="reeded">Reeded</option><option value="frosted">Frosted</option>
</select></label> </select></label>
<label>Mosaic 스타일<select id="mosaic-style">
<option value="0">조각 유리</option><option value="1">촘촘 유리</option>
</select></label>
<label>Depth Scale <output id="depthOut">0.25</output> <label>Depth Scale <output id="depthOut">0.25</output>
<input id="depth-scale" type="range" min="0" max="0.4" step="0.01" value="0.25"> <input id="depth-scale" type="range" min="0" max="0.4" step="0.01" value="0.25">
</label> </label>
+737 -1030
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
{"scripts":{"dev":"vite","build":"tsc -b && vite build","preview":"vite preview","ai-depth-smoke":"node scripts/ai-depth-smoke.mjs","test:mosaic-depth-distance":"node scripts/test-mosaic-depth-distance.mjs"},"dependencies":{"@huggingface/transformers":"3.8.1","three":"^0.169.0"},"devDependencies":{"@types/three":"^0.169.0","playwright-core":"1.56.1","typescript":"^5.6.3","vite":"^5.4.10"},"type":"module"} {"scripts":{"dev":"vite","build":"tsc -b && vite build","preview":"vite preview","ai-depth-smoke":"node scripts/ai-depth-smoke.mjs","test:mosaic-depth-distance":"node scripts/test-mosaic-depth-distance.mjs"},"dependencies":{"@huggingface/transformers":"^4.3.0","three":"^0.169.0"},"devDependencies":{"@types/three":"^0.169.0","playwright-core":"1.56.1","typescript":"^5.6.3","vite":"^8.3.2"},"type":"module"}
+9 -1
View File
@@ -6,11 +6,19 @@ Model card: https://huggingface.co/onnx-community/depth-anything-v2-small-ONNX
Upstream project: https://github.com/DepthAnything/Depth-Anything-V2 Upstream project: https://github.com/DepthAnything/Depth-Anything-V2
License: Apache License 2.0 License: Apache License 2.0
Depth Anything V2 Base (optional, noncommercial testing)
Model: onnx-community/depth-anything-v2-base-ONNX
Model card: https://huggingface.co/onnx-community/depth-anything-v2-base-ONNX
Upstream project: https://github.com/DepthAnything/Depth-Anything-V2
License: Creative Commons Attribution-NonCommercial 4.0 International
License text: https://creativecommons.org/licenses/by-nc/4.0/legalcode.en
The Base ONNX weights are an ONNX conversion of the upstream Base model.
The ONNX model weights are downloaded from Hugging Face by the browser when the The ONNX model weights are downloaded from Hugging Face by the browser when the
AI depth feature is first used. They are not included in this application bundle. AI depth feature is first used. They are not included in this application bundle.
Transformers.js Transformers.js
Package: @huggingface/transformers 3.8.1 Package: @huggingface/transformers 4.3.0
Project: https://github.com/huggingface/transformers.js Project: https://github.com/huggingface/transformers.js
License: Apache License 2.0 License: Apache License 2.0
+5 -5
View File
@@ -6,14 +6,14 @@ const paletteShader = await readFile(new URL('../src/shaders/region-palette.frag
const historyShader = await readFile(new URL('../src/shaders/region-history.frag', import.meta.url), 'utf8'); const historyShader = await readFile(new URL('../src/shaders/region-history.frag', import.meta.url), 'utf8');
const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8'); const main = await readFile(new URL('../src/main.ts', import.meta.url), 'utf8');
const html = await readFile(new URL('../index.html', import.meta.url), 'utf8'); const html = await readFile(new URL('../index.html', import.meta.url), 'utf8');
assert.match(main, /uGridSize: \{ value: 13 \}/); assert.match(main, /uGridSize: \{ value: GOBANG_GLASS.grid \}/);
assert.match(main, /uStrength: \{ value: 1 \}/); assert.match(main, /uStrength: \{ value: 1 \}/);
assert.match(main, /uRegionStrength: \{ value: 1 \}/); assert.match(main, /uRegionStrength: \{ value: 0 \}/);
assert.match(main, /uCurvature: \{ value: 1 \}/); assert.match(main, /uCurvature: \{ value: 1 \}/);
assert.match(main, /uRegionPaletteSize: \{ value: new THREE\.Vector2\(65, 13\) \}/); assert.match(main, /uRegionPaletteSize: \{ value: new THREE\.Vector2\(65, 13\) \}/);
assert.match(main, /new THREE\.WebGLRenderTarget\(65, 13/); assert.match(main, /new THREE\.WebGLRenderTarget\(65, 13/);
assert.match(main, /paletteWidth = gridX \* 5/); assert.match(main, /paletteWidth = gridX \* 5/);
assert.match(html, /id="grid-size" type="range" min="10" max="24" step="1" value="13"/); assert.match(html, /id="grid-size" type="range" min="10" max="24" step="1" value="20"/);
assert.match(html, /id="curvature" type="range" min="-1" max="1" step="0\.01" value="1"/); assert.match(html, /id="curvature" type="range" min="-1" max="1" step="0\.01" value="1"/);
assert.match(html, /id="gloss-strength" type="range" min="0" max="0\.4" step="0\.01" value="0\.30"/); assert.match(html, /id="gloss-strength" type="range" min="0" max="0\.4" step="0\.01" value="0\.30"/);
assert.match(html, /id="region-fixed-note"/); assert.match(html, /id="region-fixed-note"/);
@@ -22,11 +22,11 @@ assert.doesNotMatch(main, /strengthInput|strengthOutput|regionStrengthInput|regi
for (const [, id] of main.matchAll(/document\.querySelector\('#([^']+)'\)/g)) { for (const [, id] of main.matchAll(/document\.querySelector\('#([^']+)'\)/g)) {
assert.ok(html.includes(`id="${id}"`), `main DOM reference #${id} must exist in index.html`); assert.ok(html.includes(`id="${id}"`), `main DOM reference #${id} must exist in index.html`);
} }
assert.match(main, /uniforms\.uGridSize\.value = style === 0 \? 13 : 24; uniforms\.uStrength\.value = 1/); assert.match(main, /uniforms\.uGridSize\.value = style === 0 \? 13 : style === 2 \? GOBANG_GLASS.grid : 24; uniforms\.uRegionStrength\.value = style === 0 \? 1 : 0; uniforms\.uStrength\.value = 1/);
assert.match(main, /setMode\('final'\);\s*status\.textContent = automatic \?/); assert.match(main, /setMode\('final'\);\s*status\.textContent = automatic \?/);
assert.match(html, /<details>\s*<summary>사진 구도<\/summary>/); assert.match(html, /<details>\s*<summary>사진 구도<\/summary>/);
assert.match(html, /<details>\s*<summary>개발 비교<\/summary>/); assert.match(html, /<details>\s*<summary>개발 비교<\/summary>/);
assert.match(html, /<summary>개발 비교<\/summary>[\s\S]*id="video"[\s\S]*id="depth-image"[\s\S]*id="view-mode"[\s\S]*id="effect"[\s\S]*id="mosaic-style"[\s\S]*id="depth-scale"[\s\S]*id="light-x"[\s\S]*id="sensitivity"/); assert.match(html, /<summary>개발 비교<\/summary>[\s\S]*id="video"[\s\S]*id="depth-image"[\s\S]*id="view-mode"[\s\S]*id="effect"[\s\S]*id="depth-scale"[\s\S]*id="light-x"[\s\S]*id="sensitivity"/);
assert.match(shader, /cell\.x\*5\.\+index/); assert.match(shader, /cell\.x\*5\.\+index/);
assert.match(shader, /for\(int y=-1;y<=1;y\+\+\)for\(int x=-1;x<=1;x\+\+\)/); assert.match(shader, /for\(int y=-1;y<=1;y\+\+\)for\(int x=-1;x<=1;x\+\+\)/);
assert.match(shader, /candidateColor=mix\(radialColor,splitColor,splitHistory\.a\)/); assert.match(shader, /candidateColor=mix\(radialColor,splitColor,splitHistory\.a\)/);
+2 -2
View File
@@ -1,8 +1,8 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
const shader = await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8'); const shader = (await readFile(new URL('../src/shaders/mosaic.frag', import.meta.url), 'utf8')).replace(/\r\n/g, '\n');
const baseline = await readFile(new URL('../docs/qa-surface-reflection/baseline-mosaic.frag', import.meta.url), 'utf8'); const baseline = (await readFile(new URL('../docs/qa-surface-reflection/baseline-mosaic.frag', import.meta.url), 'utf8')).replace(/\r\n/g, '\n');
function statement(source, type, name) { function statement(source, type, name) {
const match = source.match(new RegExp(`\\b${type} ${name}=([^;]+);`)); const match = source.match(new RegExp(`\\b${type} ${name}=([^;]+);`));
+25 -21
View File
@@ -1,10 +1,12 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as THREE from 'three'; import * as THREE from 'three';
import { WindowReveal } from '../src/objects/WindowReveal.ts'; import { WindowReveal } from '../src/objects/WindowReveal.ts';
import { PHOTO_GLASS_DISTANCE } from '../src/constants/glass.ts';
const glassZ = 0.6; const glassZ = 0.6;
const cameraZ = 3; const cameraZ = 3;
const depthScales = [0, 0.25, 0.4]; const depthScales = [0, 0.25, 0.4];
const glassGaps = [PHOTO_GLASS_DISTANCE.minGap, PHOTO_GLASS_DISTANCE.maxGap];
const aspects = [0.7, 1.6]; const aspects = [0.7, 1.6];
const offsets = [ const offsets = [
[0, 0], [0, 0],
@@ -58,29 +60,31 @@ for (const aspect of aspects) {
const frontW = baseW * (3 - glassZ) / 3; const frontW = baseW * (3 - glassZ) / 3;
const frontH = baseH * (3 - glassZ) / 3; const frontH = baseH * (3 - glassZ) / 3;
for (const depthScale of depthScales) { for (const depthScale of depthScales) {
const backZ = glassZ - 0.04 - depthScale; for (const glassGap of glassGaps) {
const backW = baseW * (3 - backZ) / 3; const backZ = glassZ - glassGap - depthScale;
const backH = baseH * (3 - backZ) / 3; const backW = baseW * (3 - backZ) / 3;
const photo = makePhoto(backW, backH, backZ); const backH = baseH * (3 - backZ) / 3;
const priorFrame = makePriorFrame(frontW, frontH); const photo = makePhoto(backW, backH, backZ);
const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }); const priorFrame = makePriorFrame(frontW, frontH);
const reveal = new WindowReveal(material); const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide });
reveal.update(frontW, frontH, backW, backH, glassZ, backZ); const reveal = new WindowReveal(material);
reveal.meshes.forEach((mesh) => mesh.updateMatrixWorld(true)); reveal.update(frontW, frontH, backW, backH, glassZ, backZ);
for (const [offsetX, offsetY] of offsets) { reveal.meshes.forEach((mesh) => mesh.updateMatrixWorld(true));
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100); for (const [offsetX, offsetY] of offsets) {
camera.position.set(offsetX, offsetY, cameraZ); const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
camera.lookAt(0, 0, 0); camera.position.set(offsetX, offsetY, cameraZ);
for (const xRatio of samples) { camera.lookAt(0, 0, 0);
for (const yRatio of samples) { for (const xRatio of samples) {
const x = xRatio * frontW / 2; for (const yRatio of samples) {
const y = yRatio * frontH / 2; const x = xRatio * frontW / 2;
if (!coveredBy([photo, ...priorFrame.meshes], camera, x, y, glassZ)) baselineMisses += 1; const y = yRatio * frontH / 2;
assert.equal(coveredBy([photo, ...reveal.meshes], camera, x, y, glassZ), true, `uncovered aperture ray aspect=${aspect} depth=${depthScale} offset=${offsetX},${offsetY} sample=${xRatio},${yRatio}`); if (!coveredBy([photo, ...priorFrame.meshes], camera, x, y, glassZ)) baselineMisses += 1;
assert.equal(coveredBy([photo, ...reveal.meshes], camera, x, y, glassZ), true, `uncovered aperture ray aspect=${aspect} depth=${depthScale} gap=${glassGap} offset=${offsetX},${offsetY} sample=${xRatio},${yRatio}`);
}
} }
} }
photo.geometry.dispose(); photo.material.dispose(); priorFrame.geometry.dispose(); priorFrame.material.dispose(); reveal.dispose(); material.dispose();
} }
photo.geometry.dispose(); photo.material.dispose(); priorFrame.geometry.dispose(); priorFrame.material.dispose(); reveal.dispose(); material.dispose();
} }
} }
@@ -103,4 +107,4 @@ reusableReveal.dispose();
assert.deepEqual(disposeCounts.map((getCount) => getCount()), [1, 1, 1, 1], 'disposal emits exactly once per reveal geometry'); assert.deepEqual(disposeCounts.map((getCount) => getCount()), [1, 1, 1, 1], 'disposal emits exactly once per reveal geometry');
reuseMaterial.dispose(); reuseMaterial.dispose();
console.log(`Window reveal coverage passed: ${baselineMisses} previous-frame misses sealed across ${aspects.length} aspects, ${depthScales.length} depth scales, ${offsets.length} view offsets (including neutral) and ${samples.length ** 2} aperture samples; geometry reuse/disposal verified.`); console.log(`Window reveal coverage passed: ${baselineMisses} previous-frame misses sealed across ${aspects.length} aspects, ${depthScales.length} depth scales, ${glassGaps.length} glass gaps, ${offsets.length} view offsets (including neutral) and ${samples.length ** 2} aperture samples; geometry reuse/disposal verified.`);
+7
View File
@@ -1,3 +1,10 @@
// Relative-depth statistics, not semantic foreground detection or metric distance. // Relative-depth statistics, not semantic foreground detection or metric distance.
export const DEPTH_EVIDENCE = { lowerQuantile: 0.5, upperQuantile: 0.85, fallbackLowerQuantile: 0.05, fallbackUpperQuantile: 0.95, minimumSpan: 12 / 255 }; export const DEPTH_EVIDENCE = { lowerQuantile: 0.5, upperQuantile: 0.85, fallbackLowerQuantile: 0.05, fallbackUpperQuantile: 0.95, minimumSpan: 12 / 255 };
export const DEPTH_REFRACTION = { blend: 0.65, maxCellCorrection: 0.35 }; export const DEPTH_REFRACTION = { blend: 0.65, maxCellCorrection: 0.35 };
export const AI_DEPTH = { inputSize: 770 };
export const AI_DEPTH_MODELS = {
small: { id: 'onnx-community/depth-anything-v2-small-ONNX', name: 'Small' },
base: { id: 'onnx-community/depth-anything-v2-base-ONNX', name: 'Base' },
};
export type AiDepthModel = keyof typeof AI_DEPTH_MODELS;
export const DEPTH_INSPECTION = { nearThreshold: 0.99 };
+14
View File
@@ -1 +1,15 @@
export const ENVIRONMENT_REFLECTION_STRENGTH = 0.12; export const ENVIRONMENT_REFLECTION_STRENGTH = 0.12;
// Gap to the nearest photo surface, in scene units (UI maps 0–100%).
export const PHOTO_GLASS_DISTANCE = { minGap: 0.04, maxGap: 0.60 };
// Shallow patterned front surface and planar back surface, in scene units.
export const GOBANG_GLASS = {
grid: 20,
heightPerCell: 0.085,
flatHalfWidth: 0.07,
bevelRound: 0.10,
refractiveIndex: 1.5,
thickness: 0.008,
environmentStrength: 0.45,
};
+10 -6
View File
@@ -1,10 +1,11 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { DepthProcessor } from './DepthProcessor'; import { DepthProcessor } from './DepthProcessor';
import type { DepthProvider } from '../types/depth'; import type { DepthProvider } from '../types/depth';
import type { AiDepthModel } from '../constants/depth';
type WorkerReply = type WorkerReply =
| { type: 'status'; id: number; message: string } | { type: 'status'; id: number; message: string }
| { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer } | { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer; normalizedValues: ArrayBuffer }
| { type: 'error'; id: number; message: string }; | { type: 'error'; id: number; message: string };
type PendingRequest = { type PendingRequest = {
@@ -20,12 +21,12 @@ export class AiDepthProvider implements DepthProvider {
constructor(private readonly onStatus: (message: string) => void) {} constructor(private readonly onStatus: (message: string) => void) {}
async getDepthMap(image: HTMLImageElement): Promise<THREE.Texture> { async getDepthMap(image: HTMLImageElement, model: AiDepthModel = 'small'): Promise<THREE.Texture> {
if (this.pending) throw new Error('AI depth 추론이 이미 진행 중입니다.'); if (this.pending) throw new Error('AI depth 추론이 이미 진행 중입니다.');
const id = ++this.requestId; const id = ++this.requestId;
return new Promise<THREE.Texture>((resolve, reject) => { return new Promise<THREE.Texture>((resolve, reject) => {
this.pending = { id, resolve, reject }; this.pending = { id, resolve, reject };
void this.captureAndSend(image, id).catch((error: unknown) => { void this.captureAndSend(image, id, model).catch((error: unknown) => {
this.fail(id, error instanceof Error ? error : new Error('AI depth 생성에 실패했습니다.')); this.fail(id, error instanceof Error ? error : new Error('AI depth 생성에 실패했습니다.'));
}); });
}); });
@@ -40,7 +41,7 @@ export class AiDepthProvider implements DepthProvider {
pending?.reject(new Error('AI depth 요청이 취소되었습니다.')); pending?.reject(new Error('AI depth 요청이 취소되었습니다.'));
} }
private async captureAndSend(image: HTMLImageElement, id: number): Promise<void> { private async captureAndSend(image: HTMLImageElement, id: number, model: AiDepthModel): Promise<void> {
const width = image.naturalWidth || image.width; const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height; const height = image.naturalHeight || image.height;
if (!width || !height) throw new Error('사진 크기를 확인할 수 없습니다.'); if (!width || !height) throw new Error('사진 크기를 확인할 수 없습니다.');
@@ -56,7 +57,7 @@ export class AiDepthProvider implements DepthProvider {
} }
const worker = this.ensureWorker(); const worker = this.ensureWorker();
try { try {
worker.postMessage({ type: 'infer', id, image: bitmap }, [bitmap]); worker.postMessage({ type: 'infer', id, image: bitmap, model }, [bitmap]);
} catch (error) { } catch (error) {
bitmap.close(); bitmap.close();
throw error; throw error;
@@ -75,7 +76,10 @@ export class AiDepthProvider implements DepthProvider {
const pending = this.pending; const pending = this.pending;
this.pending = null; this.pending = null;
try { try {
pending.resolve(DepthProcessor.fromNormalizedDepth(new Uint8Array(message.values), message.width, message.height)); const texture = DepthProcessor.fromNormalizedDepth(new Uint8Array(message.values), message.width, message.height);
// Keep unquantized normalized values for inspection; geometry still uses the existing byte texture.
texture.userData.normalizedDepth = new Float32Array(message.normalizedValues);
pending.resolve(texture);
} catch (error) { } catch (error) {
pending.reject(error instanceof Error ? error : new Error('AI depth 결과를 읽지 못했습니다.')); pending.reject(error instanceof Error ? error : new Error('AI depth 결과를 읽지 못했습니다.'));
} }
+96
View File
@@ -0,0 +1,96 @@
import type * as THREE from 'three';
import { DEPTH_INSPECTION } from '../constants/depth';
/** Diagnostic image-space values; never changes the inferred surface or framing. */
export class DepthInspection {
private canvas = document.querySelector('#depth-inspection-canvas') as HTMLCanvasElement;
private summary = document.querySelector('#depth-inspection-summary') as HTMLElement;
private point = document.querySelector('#depth-inspection-point') as HTMLElement;
private xInput = document.querySelector('#depth-inspection-x') as HTMLInputElement;
private yInput = document.querySelector('#depth-inspection-y') as HTMLInputElement;
private image: HTMLImageElement | null = null;
private data: ArrayLike<number> | null = null;
private normalized: Float32Array | null = null;
private overlay: HTMLCanvasElement = document.createElement('canvas');
private max = 0;
private gap = 0;
private scale = 0;
constructor(signal: AbortSignal) {
this.canvas.addEventListener('click', (event) => {
const rect = this.canvas.getBoundingClientRect();
this.xInput.value = String(Math.min(this.canvas.width - 1, Math.floor((event.clientX - rect.left) / rect.width * this.canvas.width)));
this.yInput.value = String(Math.min(this.canvas.height - 1, Math.floor((event.clientY - rect.top) / rect.height * this.canvas.height)));
this.updatePoint();
}, { signal });
[this.xInput, this.yInput].forEach((input) => input.addEventListener('input', () => this.updatePoint(), { signal }));
}
setDepth(image: unknown, texture: THREE.Texture): void {
const { data, width, height } = (texture as THREE.DataTexture).image;
this.image = image instanceof HTMLImageElement ? image : null;
this.data = data && width * height > 1 ? data : null;
this.normalized = texture.userData.normalizedDepth ?? null;
this.canvas.hidden = !this.image || !this.data;
this.point.textContent = '';
if (this.canvas.hidden || !this.data) {
this.summary.textContent = '사진과 깊이를 불러오세요.';
return;
}
this.canvas.width = this.overlay.width = width;
this.canvas.height = this.overlay.height = height;
this.xInput.max = String(width - 1);
this.yInput.max = String(height - 1);
const context = this.overlay.getContext('2d')!;
const mask = context.createImageData(width, height);
let exact = 0, byteMax = 0, near = 0, peak = 0;
this.max = 0;
for (let i = 0; i < width * height; i += 1) {
if (Number(this.data[i * 4]) / 255 > this.max) {
this.max = Number(this.data[i * 4]) / 255;
peak = i;
}
}
let peakValue = -Infinity;
for (let i = 0; i < width * height; i += 1) {
const rendered = Number(this.data[i * 4]) / 255;
const value = this.normalized?.[i] ?? rendered;
if (value > peakValue) { peakValue = value; peak = i; }
if (value === 1) exact += 1;
if (rendered === 1) byteMax += 1;
if (value >= DEPTH_INSPECTION.nearThreshold) near += 1;
const color = value === 1 ? [255, 55, 180] : rendered === 1 ? [0, 240, 255] : value >= DEPTH_INSPECTION.nearThreshold ? [255, 210, 40] : null;
if (color) mask.data.set([...color, 210], i * 4);
}
context.putImageData(mask, 0, 0);
const percent = (count: number) => (100 * count / (width * height)).toFixed(4);
this.summary.textContent = `${width}×${height} · 최대 depth=${this.max.toFixed(5)}\n${this.normalized ? '모델' : '입력'} 최대 1: ${exact}px (${percent(exact)}%)\n8비트 depth=1: ${byteMax}px (${percent(byteMax)}%)\ndepth≥${DEPTH_INSPECTION.nearThreshold}: ${near}px (${percent(near)}%)\n최대 위치: (${peak % width}, ${Math.floor(peak / width)})`;
this.xInput.value = String(peak % width);
this.yInput.value = String(Math.floor(peak / width));
this.updatePoint();
}
updateDistance(gap: number, scale: number): void {
this.gap = gap;
this.scale = scale;
this.updatePoint();
}
private updatePoint(): void {
if (!this.data || !this.image) return;
const x = Math.max(0, Math.min(this.canvas.width - 1, Math.round(Number(this.xInput.value))));
const y = Math.max(0, Math.min(this.canvas.height - 1, Math.round(Number(this.yInput.value))));
const index = y * this.canvas.width + x;
const value = Number(this.data[index * 4]) / 255;
const gap = this.gap + (this.max - value) * this.scale;
this.point.textContent = `픽셀 (${x}, ${y})\n${this.normalized ? `모델 상대 깊이: ${this.normalized[index].toFixed(6)}\n` : ''}8비트 깊이: ${value.toFixed(6)}\n최대 표면 간격: ${this.gap.toFixed(4)}\n선택 지점 간격: ${gap.toFixed(4)}\n뒤로 물러난 양: ${((this.max - value) * this.scale).toFixed(4)}`;
const context = this.canvas.getContext('2d')!;
context.drawImage(this.image, 0, 0, this.canvas.width, this.canvas.height);
context.drawImage(this.overlay, 0, 0);
context.strokeStyle = '#ffffff';
context.lineWidth = Math.max(1, this.canvas.width / 250);
context.beginPath();
context.arc(x + 0.5, y + 0.5, Math.max(3, this.canvas.width / 50), 0, Math.PI * 2);
context.stroke();
}
}
+30 -16
View File
@@ -1,14 +1,15 @@
import { pipeline, type DepthEstimationPipelineOutput } from '@huggingface/transformers'; import { pipeline, type DepthEstimationOutput as DepthEstimationPipelineOutput } from '@huggingface/transformers';
import { AI_DEPTH, AI_DEPTH_MODELS, type AiDepthModel } from '../constants/depth';
const MODEL_ID = 'onnx-community/depth-anything-v2-small-ONNX';
type Device = 'webgpu' | 'wasm'; type Device = 'webgpu' | 'wasm';
type RequestMessage = { type: 'infer'; id: number; image: ImageBitmap }; type RequestMessage = { type: 'infer'; id: number; image: ImageBitmap; model: AiDepthModel };
type WorkerReply = type WorkerReply =
| { type: 'status'; id: number; message: string } | { type: 'status'; id: number; message: string }
| { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer } | { type: 'result'; id: number; width: number; height: number; values: ArrayBuffer; normalizedValues: ArrayBuffer }
| { type: 'error'; id: number; message: string }; | { type: 'error'; id: number; message: string };
type Estimator = { type Estimator = {
(image: OffscreenCanvas): Promise<DepthEstimationPipelineOutput | DepthEstimationPipelineOutput[]>; (image: OffscreenCanvas): Promise<DepthEstimationPipelineOutput | DepthEstimationPipelineOutput[]>;
processor: { image_processor: { size: { width: number; height: number } } };
dispose(): Promise<void>; dispose(): Promise<void>;
}; };
type Progress = { status: string; progress?: number }; type Progress = { status: string; progress?: number };
@@ -24,6 +25,7 @@ const workerScope = self as unknown as {
let estimator: Estimator | null = null; let estimator: Estimator | null = null;
let activeDevice: Device | null = null; let activeDevice: Device | null = null;
let activeModel: AiDepthModel | null = null;
function reply(message: WorkerReply, transfer: Transferable[] = []): void { function reply(message: WorkerReply, transfer: Transferable[] = []): void {
workerScope.postMessage(message, transfer); workerScope.postMessage(message, transfer);
@@ -33,9 +35,9 @@ function setStatus(id: number, message: string): void {
reply({ type: 'status', id, message }); reply({ type: 'status', id, message });
} }
async function loadEstimator(device: Device, id: number): Promise<Estimator> { async function loadEstimator(device: Device, id: number, model: AiDepthModel): Promise<Estimator> {
setStatus(id, 'AI 모델 준비 중…'); setStatus(id, 'AI 모델 준비 중…');
return createPipeline('depth-estimation', MODEL_ID, { const loaded = await createPipeline('depth-estimation', AI_DEPTH_MODELS[model].id, {
device, device,
progress_callback: (progress) => { progress_callback: (progress) => {
if (progress.status === 'progress') { if (progress.status === 'progress') {
@@ -45,13 +47,20 @@ async function loadEstimator(device: Device, id: number): Promise<Estimator> {
} }
}, },
}); });
loaded.processor.image_processor.size = { width: AI_DEPTH.inputSize, height: AI_DEPTH.inputSize };
return loaded;
} }
async function ensureEstimator(id: number): Promise<Estimator> { async function ensureEstimator(id: number, model: AiDepthModel): Promise<Estimator> {
if (estimator && activeModel !== model) {
await estimator.dispose();
estimator = null;
}
if (estimator) return estimator; if (estimator) return estimator;
activeModel = model;
if ('gpu' in navigator) { if ('gpu' in navigator) {
try { try {
estimator = await loadEstimator('webgpu', id); estimator = await loadEstimator('webgpu', id, model);
activeDevice = 'webgpu'; activeDevice = 'webgpu';
return estimator; return estimator;
} catch { } catch {
@@ -60,12 +69,12 @@ async function ensureEstimator(id: number): Promise<Estimator> {
setStatus(id, 'AI 모델을 준비하는 중입니다…'); setStatus(id, 'AI 모델을 준비하는 중입니다…');
} }
} }
estimator = await loadEstimator('wasm', id); estimator = await loadEstimator('wasm', id, model);
activeDevice = 'wasm'; activeDevice = 'wasm';
return estimator; return estimator;
} }
function normalizeDepth(output: DepthEstimationPipelineOutput): { values: Uint8Array; width: number; height: number } { function normalizeDepth(output: DepthEstimationPipelineOutput): { values: Uint8Array; normalizedValues: Float32Array; width: number; height: number } {
const { predicted_depth: prediction } = output; const { predicted_depth: prediction } = output;
const [height, width] = prediction.dims.slice(-2); const [height, width] = prediction.dims.slice(-2);
const data = prediction.data; const data = prediction.data;
@@ -83,19 +92,23 @@ function normalizeDepth(output: DepthEstimationPipelineOutput): { values: Uint8A
const range = max - min; const range = max - min;
const values = new Uint8Array(width * height); const values = new Uint8Array(width * height);
const normalizedValues = new Float32Array(width * height);
if (range <= 1e-8) { if (range <= 1e-8) {
values.fill(128); values.fill(128);
normalizedValues.fill(0.5);
} else { } else {
for (let i = 0; i < values.length; i += 1) { for (let i = 0; i < values.length; i += 1) {
const value = Number(data[i]); const value = Number(data[i]);
// Depth Anything V2 predicts relative inverse depth: larger values are nearer. // Depth Anything V2 predicts relative inverse depth: larger values are nearer.
values[i] = Number.isFinite(value) ? Math.round(((value - min) / range) * 255) : 128; const normalized = Number.isFinite(value) ? (value - min) / range : 0.5;
normalizedValues[i] = normalized;
values[i] = Math.round(normalized * 255);
} }
} }
return { values, width, height }; return { values, normalizedValues, width, height };
} }
async function infer({ id, image }: RequestMessage): Promise<void> { async function infer({ id, image, model: modelKey }: RequestMessage): Promise<void> {
try { try {
const canvas = new OffscreenCanvas(image.width, image.height); const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
@@ -103,7 +116,7 @@ async function infer({ id, image }: RequestMessage): Promise<void> {
context.drawImage(image, 0, 0); context.drawImage(image, 0, 0);
image.close(); image.close();
let model = await ensureEstimator(id); let model = await ensureEstimator(id, modelKey);
setStatus(id, 'AI 깊이를 계산하는 중입니다…'); setStatus(id, 'AI 깊이를 계산하는 중입니다…');
let output: DepthEstimationPipelineOutput; let output: DepthEstimationPipelineOutput;
try { try {
@@ -116,7 +129,7 @@ async function infer({ id, image }: RequestMessage): Promise<void> {
estimator = null; estimator = null;
activeDevice = null; activeDevice = null;
setStatus(id, 'AI 추론을 다시 시도하는 중입니다…'); setStatus(id, 'AI 추론을 다시 시도하는 중입니다…');
model = await loadEstimator('wasm', id); model = await loadEstimator('wasm', id, modelKey);
estimator = model; estimator = model;
activeDevice = 'wasm'; activeDevice = 'wasm';
const predicted = await model(canvas); const predicted = await model(canvas);
@@ -127,7 +140,8 @@ async function infer({ id, image }: RequestMessage): Promise<void> {
try { try {
const normalized = normalizeDepth(output); const normalized = normalizeDepth(output);
const values = normalized.values.buffer as ArrayBuffer; const values = normalized.values.buffer as ArrayBuffer;
reply({ type: 'result', id, width: normalized.width, height: normalized.height, values }, [values]); const normalizedValues = normalized.normalizedValues.buffer as ArrayBuffer;
reply({ type: 'result', id, width: normalized.width, height: normalized.height, values, normalizedValues }, [values, normalizedValues]);
} finally { } finally {
output.predicted_depth.dispose(); output.predicted_depth.dispose();
} }
+41 -11
View File
@@ -10,11 +10,12 @@ import { CameraController } from './input/CameraController';
import { GyroController } from './input/GyroController'; import { GyroController } from './input/GyroController';
import { StaticDepthProvider } from './depth/StaticDepthProvider'; import { StaticDepthProvider } from './depth/StaticDepthProvider';
import { DepthProcessor } from './depth/DepthProcessor'; import { DepthProcessor } from './depth/DepthProcessor';
import { DEPTH_REFRACTION } from './constants/depth'; import { AI_DEPTH, AI_DEPTH_MODELS, DEPTH_REFRACTION, type AiDepthModel } from './constants/depth';
import { ENVIRONMENT_REFLECTION_STRENGTH } from './constants/glass'; import { ENVIRONMENT_REFLECTION_STRENGTH, GOBANG_GLASS, PHOTO_GLASS_DISTANCE } from './constants/glass';
import { AiDepthProvider } from './depth/AiDepthProvider'; import { AiDepthProvider } from './depth/AiDepthProvider';
import { PetDepthMesh } from './objects/PetDepthMesh'; import { PetDepthMesh } from './objects/PetDepthMesh';
import { DepthDebug } from './depth/DepthDebug'; import { DepthDebug } from './depth/DepthDebug';
import { DepthInspection } from './depth/DepthInspection';
import { WindowReveal } from './objects/WindowReveal'; import { WindowReveal } from './objects/WindowReveal';
import glassScreenVertex from './shaders/glassScreen.vert?raw'; import glassScreenVertex from './shaders/glassScreen.vert?raw';
import regionPaletteShader from './shaders/region-palette.frag?raw'; import regionPaletteShader from './shaders/region-palette.frag?raw';
@@ -24,6 +25,7 @@ const wrap = document.querySelector('#canvas-wrap') as HTMLElement;
const status = document.querySelector('#status') as HTMLElement; const status = document.querySelector('#status') as HTMLElement;
const depthProvenance = document.querySelector('#depth-provenance') as HTMLElement; const depthProvenance = document.querySelector('#depth-provenance') as HTMLElement;
const aiDepthButton = document.querySelector('#ai-depth') as HTMLButtonElement; const aiDepthButton = document.querySelector('#ai-depth') as HTMLButtonElement;
const aiDepthModelInput = document.querySelector('#ai-depth-model') as HTMLSelectElement;
const empty = document.querySelector('#empty') as HTMLElement; const empty = document.querySelector('#empty') as HTMLElement;
const hintElement = document.querySelector('#hint') as HTMLElement; const hintElement = document.querySelector('#hint') as HTMLElement;
hintElement.style.opacity = '0'; hintElement.style.opacity = '0';
@@ -37,8 +39,22 @@ scene.background = new THREE.Color(0x111923);
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100); const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
camera.position.z = 3; camera.position.z = 3;
const geometry = new THREE.PlaneGeometry(1, 1); const geometry = new THREE.PlaneGeometry(1, 1);
const emptyTex = new THREE.DataTexture(new Uint8Array([38, 48, 62, 255]), 1, 1); // A neutral room behind the empty glass, with broad light/dark areas, not a grid.
emptyTex.needsUpdate = true; const emptyCanvas = document.createElement('canvas');
emptyCanvas.width = emptyCanvas.height = 512;
const emptyContext = emptyCanvas.getContext('2d')!;
const roomGradient = emptyContext.createLinearGradient(0, 0, 512, 512);
roomGradient.addColorStop(0, '#c5ced0');
roomGradient.addColorStop(0.55, '#788b91');
roomGradient.addColorStop(1, '#39474f');
emptyContext.fillStyle = roomGradient;
emptyContext.fillRect(0, 0, 512, 512);
emptyContext.fillStyle = '#e2e7df';
emptyContext.fillRect(75, 50, 115, 340);
emptyContext.fillStyle = '#52636b';
emptyContext.fillRect(320, 0, 28, 512);
const emptyTex = new THREE.CanvasTexture(emptyCanvas);
emptyTex.colorSpace = THREE.SRGBColorSpace;
const petMat = new THREE.MeshBasicMaterial({ map: emptyTex }); const petMat = new THREE.MeshBasicMaterial({ map: emptyTex });
const pet = new THREE.Mesh(geometry, petMat); const pet = new THREE.Mesh(geometry, petMat);
scene.add(pet); scene.add(pet);
@@ -47,11 +63,14 @@ const petDepth = new PetDepthMesh(emptyTex, depthMap, 0.25, 0);
const petDepthMaterial = petDepth.material; const petDepthMaterial = petDepth.material;
const depthUniforms = petDepthMaterial.uniforms; const depthUniforms = petDepthMaterial.uniforms;
scene.add(petDepth); scene.add(petDepth);
const uniforms = { uSceneTexture: { value: emptyTex as THREE.Texture }, uSceneDepth: { value: emptyTex as THREE.Texture }, uProtectionDepthMap: depthUniforms.uDepthMap, uPetWorldToLocal: { value: new THREE.Matrix4() }, uContentViewProjection: { value: new THREE.Matrix4() }, uContentInverseViewProjection: { value: new THREE.Matrix4() }, uContentCameraPosition: { value: new THREE.Vector3() }, uLightPosition: { value: new THREE.Vector3(0.6, 0.8, 2.6) }, uBackgroundColor: { value: scene.background.clone() }, uGlassZ: { value: 0.6 }, uSourceZ: { value: 0.56 }, uGridSize: { value: 13 }, uMosaicStyle: { value: 0 }, uStrength: { value: 1 }, uGlossStrength: { value: 0.3 }, uCurvature: { value: 1 }, uRegionStrength: { value: 1 }, uRegionAvailable: { value: 0 }, uRegionAtlasSize: { value: new THREE.Vector2(104, 104) }, uRegionGridSize: { value: new THREE.Vector2(13, 13) }, uRegionPaletteSize: { value: new THREE.Vector2(65, 13) }, uRegionPalette: { value: emptyTex as THREE.Texture }, uRegionAtlas: { value: emptyTex as THREE.Texture }, uViewOffset: { value: new THREE.Vector2() }, uPaneRect: { value: new THREE.Vector4(0, 0, 1, 1) }, uResolution: { value: new THREE.Vector2() }, uTime: { value: 0 }, uPetSize: { value: new THREE.Vector2(1, 1) }, uPaneSize: { value: new THREE.Vector2(1, 1) } }; const uniforms = { uSceneTexture: { value: emptyTex as THREE.Texture }, uSceneDepth: { value: emptyTex as THREE.Texture }, uProtectionDepthMap: depthUniforms.uDepthMap, uPetWorldToLocal: { value: new THREE.Matrix4() }, uContentViewProjection: { value: new THREE.Matrix4() }, uContentInverseViewProjection: { value: new THREE.Matrix4() }, uContentCameraPosition: { value: new THREE.Vector3() }, uLightPosition: { value: new THREE.Vector3(0.6, 0.8, 2.6) }, uBackgroundColor: { value: scene.background.clone() }, uGlassZ: { value: 0.6 }, uSourceZ: { value: 0.56 }, uGridSize: { value: GOBANG_GLASS.grid }, uMosaicStyle: { value: 2 }, uStrength: { value: 1 }, uGlossStrength: { value: 0.3 }, uCurvature: { value: 1 }, uRegionStrength: { value: 0 }, uRegionAvailable: { value: 0 }, uRegionAtlasSize: { value: new THREE.Vector2(104, 104) }, uRegionGridSize: { value: new THREE.Vector2(13, 13) }, uRegionPaletteSize: { value: new THREE.Vector2(65, 13) }, uRegionPalette: { value: emptyTex as THREE.Texture }, uRegionAtlas: { value: emptyTex as THREE.Texture }, uViewOffset: { value: new THREE.Vector2() }, uPaneRect: { value: new THREE.Vector4(0, 0, 1, 1) }, uResolution: { value: new THREE.Vector2() }, uTime: { value: 0 }, uPetSize: { value: new THREE.Vector2(1, 1) }, uPaneSize: { value: new THREE.Vector2(1, 1) } };
const regionHistoryUniform = { value: emptyTex as THREE.Texture }; const regionHistoryUniform = { value: emptyTex as THREE.Texture };
const depthEvidenceUniform = { value: new THREE.Vector2(1, 1) }; const depthEvidenceUniform = { value: new THREE.Vector2(1, 1) };
Object.assign(uniforms, { uRegionHistory: regionHistoryUniform, uDepthEvidenceRange: depthEvidenceUniform, Object.assign(uniforms, { uRegionHistory: regionHistoryUniform, uDepthEvidenceRange: depthEvidenceUniform,
uEnvironmentStrength: { value: ENVIRONMENT_REFLECTION_STRENGTH }, uEnvironmentStrength: { value: ENVIRONMENT_REFLECTION_STRENGTH },
uGobangSurface: { value: new THREE.Vector4(GOBANG_GLASS.heightPerCell, GOBANG_GLASS.flatHalfWidth, GOBANG_GLASS.refractiveIndex, GOBANG_GLASS.thickness) },
uGobangBevelRound: { value: GOBANG_GLASS.bevelRound },
uGobangEnvironmentStrength: { value: GOBANG_GLASS.environmentStrength },
uDepthCorrection: { value: new THREE.Vector2(DEPTH_REFRACTION.blend, DEPTH_REFRACTION.maxCellCorrection) } }); uDepthCorrection: { value: new THREE.Vector2(DEPTH_REFRACTION.blend, DEPTH_REFRACTION.maxCellCorrection) } });
const glassMat = new THREE.ShaderMaterial({ uniforms, vertexShader: glassScreenVertex, fragmentShader: mosaicShader }); const glassMat = new THREE.ShaderMaterial({ uniforms, vertexShader: glassScreenVertex, fragmentShader: mosaicShader });
const glass = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), glassMat); const glass = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), glassMat);
@@ -128,6 +147,7 @@ let baseH = 1;
let photoZoom = 1; let photoZoom = 1;
let photoPanX = 0; let photoPanX = 0;
let photoPanY = 0; let photoPanY = 0;
let photoGlassGap = PHOTO_GLASS_DISTANCE.minGap;
let pendingSampleFraming = false; let pendingSampleFraming = false;
let activeTestPhotoHasNoDepth = false; let activeTestPhotoHasNoDepth = false;
let mode = 'noglass'; let mode = 'noglass';
@@ -143,9 +163,11 @@ let manualDepthBusy = false;
let aiDepthBusy = false; let aiDepthBusy = false;
let disposed = false; let disposed = false;
const listenerAbort = new AbortController(); const listenerAbort = new AbortController();
const depthInspection = new DepthInspection(listenerAbort.signal);
function syncAIDepthButton(): void { function syncAIDepthButton(): void {
aiDepthButton.disabled = active === emptyTex || activeIsVideo || mediaBusy || manualDepthBusy || aiDepthBusy || disposed; aiDepthButton.disabled = active === emptyTex || activeIsVideo || mediaBusy || manualDepthBusy || aiDepthBusy || disposed;
aiDepthButton.textContent = aiDepthBusy ? 'AI 깊이 생성 중…' : 'AI 깊이 생성'; aiDepthButton.textContent = aiDepthBusy ? 'AI 깊이 생성 중…' : 'AI 깊이 생성';
aiDepthModelInput.disabled = mediaBusy || manualDepthBusy || aiDepthBusy || disposed;
} }
const aiDepthProvider = new AiDepthProvider((message) => { if (!disposed && aiDepthBusy) status.textContent = message; }); const aiDepthProvider = new AiDepthProvider((message) => { if (!disposed && aiDepthBusy) status.textContent = message; });
function invalidateDepthRequests(): void { function invalidateDepthRequests(): void {
@@ -157,7 +179,7 @@ function invalidateDepthRequests(): void {
} }
function clearPendingPair(): void { if (pendingPairDepth) pendingPairDepth.dispose(); pendingPairDepth = null; } function clearPendingPair(): void { if (pendingPairDepth) pendingPairDepth.dispose(); pendingPairDepth = null; }
let maxDepthValue = 0; let maxDepthValue = 0;
function contentBaseZ(): number { return uniforms.uGlassZ.value - 0.04 - (maxDepthValue - depthUniforms.uDepthBias.value) * depthUniforms.uDepthScale.value; } function contentBaseZ(): number { return uniforms.uGlassZ.value - photoGlassGap - (maxDepthValue - depthUniforms.uDepthBias.value) * depthUniforms.uDepthScale.value; }
function clearPendingTestState(): void { clearPendingPair(); pendingSampleDebugImage = null; pendingSampleFraming = false; pendingTestPhotoHasNoDepth = false; } function clearPendingTestState(): void { clearPendingPair(); pendingSampleDebugImage = null; pendingSampleFraming = false; pendingTestPhotoHasNoDepth = false; }
function applyFraming(): void { function applyFraming(): void {
const baseZ = contentBaseZ(); const baseZ = contentBaseZ();
@@ -170,6 +192,7 @@ function applyFraming(): void {
petDepth.position.copy(pet.position); petDepth.position.copy(pet.position);
uniforms.uSourceZ.value = baseZ; uniforms.uSourceZ.value = baseZ;
windowReveal.update(uniforms.uPaneSize.value.x, uniforms.uPaneSize.value.y, baseW * (3 - baseZ) / 3, baseH * (3 - baseZ) / 3, uniforms.uGlassZ.value, baseZ); windowReveal.update(uniforms.uPaneSize.value.x, uniforms.uPaneSize.value.y, baseW * (3 - baseZ) / 3, baseH * (3 - baseZ) / 3, uniforms.uGlassZ.value, baseZ);
depthInspection.updateDistance(photoGlassGap, depthUniforms.uDepthScale.value);
} }
function resize(): void { function resize(): void {
const bounds = wrap.getBoundingClientRect(); const bounds = wrap.getBoundingClientRect();
@@ -228,6 +251,7 @@ function setDepth(texture: THREE.Texture): void {
if (data && data.length >= 4) { if (data && data.length >= 4) {
for (let i = 0; i < data.length; i += 4) maxDepthValue = Math.max(maxDepthValue, Number(data[i]) / 255); for (let i = 0; i < data.length; i += 4) maxDepthValue = Math.max(maxDepthValue, Number(data[i]) / 255);
} }
depthInspection.setDepth(active.image, texture);
applyFraming(); applyFraming();
regionSignature = ''; regionSignature = '';
if (mode === 'depth') { petMat.map = texture; petMat.needsUpdate = true; } if (mode === 'depth') { petMat.map = texture; petMat.needsUpdate = true; }
@@ -297,6 +321,8 @@ const glossOutput = document.querySelector('#glossOut') as HTMLOutputElement | n
const lightXOutput = document.querySelector('#lightXOut') as HTMLOutputElement | null; const lightXOutput = document.querySelector('#lightXOut') as HTMLOutputElement | null;
const lightYOutput = document.querySelector('#lightYOut') as HTMLOutputElement | null; const lightYOutput = document.querySelector('#lightYOut') as HTMLOutputElement | null;
const cameraOutput = document.querySelector('#cameraOut') as HTMLOutputElement; const cameraOutput = document.querySelector('#cameraOut') as HTMLOutputElement;
const photoGlassDistanceInput = document.querySelector('#photo-glass-distance') as HTMLInputElement;
const photoGlassDistanceOutput = document.querySelector('#photoGlassDistanceOut') as HTMLOutputElement;
const sensitivityOutput = document.querySelector('#sensitivityOut') as HTMLOutputElement; const sensitivityOutput = document.querySelector('#sensitivityOut') as HTMLOutputElement;
const depthOutput = document.querySelector('#depthOut') as HTMLOutputElement | null; const depthOutput = document.querySelector('#depthOut') as HTMLOutputElement | null;
const photoZoomInput = document.querySelector('#photo-zoom') as HTMLInputElement | null; const photoZoomInput = document.querySelector('#photo-zoom') as HTMLInputElement | null;
@@ -312,11 +338,13 @@ const effects = [mosaicShader, reededShader, frostedShader];
let sensitivity = 1; let sensitivity = 1;
function setGlossControlsVisibility(): void { function setGlossControlsVisibility(): void {
const enabled = effectInput.value === 'mosaic'; const enabled = effectInput.value === 'mosaic';
const ref1Mosaic = enabled && mosaicStyleInput.value === '0';
if (mosaicStyleLabel) mosaicStyleLabel.style.display = enabled ? 'grid' : 'none'; if (mosaicStyleLabel) mosaicStyleLabel.style.display = enabled ? 'grid' : 'none';
mosaicStyleInput.disabled = !enabled; mosaicStyleInput.disabled = !enabled;
if (glossControls) { glossControls.hidden = !enabled; glossControls.style.display = enabled ? 'grid' : 'none'; } if (glossControls) { glossControls.hidden = !enabled; glossControls.style.display = enabled ? 'grid' : 'none'; }
if (regionFixedNote) regionFixedNote.hidden = !ref1Mosaic; if (regionFixedNote) {
regionFixedNote.hidden = !enabled;
regionFixedNote.textContent = mosaicStyleInput.value === '2' ? '고방유리 · 표면 요철에 따른 굴절 · 색면 처리 없음' : mosaicStyleInput.value === '0' ? 'Ref1 옵션 2 · 색면 단순화 1 · 굴절 1 (고정)' : '기존 촘촘 유리 · 굴절 1 (고정)';
}
[glossInput, lightXInput, lightYInput, curvatureInput].forEach((input) => { if (input) input.disabled = !enabled; }); [glossInput, lightXInput, lightYInput, curvatureInput].forEach((input) => { if (input) input.disabled = !enabled; });
} }
function syncPhotoControls(): void { function syncPhotoControls(): void {
@@ -331,12 +359,13 @@ async function generateAIDepth(automatic = false): Promise<void> {
if (aiDepthButton.disabled || !(active.image instanceof HTMLImageElement)) return; if (aiDepthButton.disabled || !(active.image instanceof HTMLImageElement)) return;
const sourceTexture = active; const sourceTexture = active;
const sourceImage = active.image; const sourceImage = active.image;
const model = aiDepthModelInput.value as AiDepthModel;
const request = ++depthRequest; const request = ++depthRequest;
aiDepthBusy = true; aiDepthBusy = true;
status.textContent = 'AI 모델을 준비하는 중입니다. 처음 실행하면 모델 파일을 다운로드합니다.'; status.textContent = 'AI 모델을 준비하는 중입니다. 처음 실행하면 모델 파일을 다운로드합니다.';
syncAIDepthButton(); syncAIDepthButton();
try { try {
const texture = await aiDepthProvider.getDepthMap(sourceImage); const texture = await aiDepthProvider.getDepthMap(sourceImage, model);
if (disposed || request !== depthRequest || active !== sourceTexture || activeIsVideo) { if (disposed || request !== depthRequest || active !== sourceTexture || activeIsVideo) {
texture.dispose(); texture.dispose();
return; return;
@@ -345,7 +374,7 @@ async function generateAIDepth(automatic = false): Promise<void> {
depthDebug.clear(); depthDebug.clear();
activeTestPhotoHasNoDepth = false; activeTestPhotoHasNoDepth = false;
depthProvenance.hidden = false; depthProvenance.hidden = false;
depthProvenance.textContent = 'AI 추정 · Depth Anything V2 Small (상대 깊이, 0=뒤 · 1=앞)'; depthProvenance.textContent = `AI 추정 · Depth Anything V2 ${AI_DEPTH_MODELS[model].name} · 입력 ${AI_DEPTH.inputSize} (상대 깊이, 0=뒤 · 1=앞)`;
setMode('final'); setMode('final');
status.textContent = automatic ? '자동 AI 추정 depth를 적용했습니다.' : 'AI 추정 depth를 적용하고 Final 뷰로 돌아왔습니다.'; status.textContent = automatic ? '자동 AI 추정 depth를 적용했습니다.' : 'AI 추정 depth를 적용하고 Final 뷰로 돌아왔습니다.';
showHint(); showHint();
@@ -375,8 +404,9 @@ modeInput?.addEventListener('change', (event) => setMode((event.target as HTMLSe
sensitivityInput.oninput = (event) => { const value = Number((event.target as HTMLInputElement).value); sensitivity = value; sensitivityOutput.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; applyFraming(); regionSignature = ''; if (depthOutput) depthOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal }); depthInput?.addEventListener('input', (event) => { const value = Math.min(Number((event.target as HTMLInputElement).value), 0.4); depthUniforms.uDepthScale.value = value; applyFraming(); regionSignature = ''; if (depthOutput) depthOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
cameraOffsetInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); cameraController.setMaxOffset(value); cameraOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal }); cameraOffsetInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); cameraController.setMaxOffset(value); cameraOutput.value = value.toFixed(2); }, { signal: listenerAbort.signal });
photoGlassDistanceInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); photoGlassGap = THREE.MathUtils.lerp(PHOTO_GLASS_DISTANCE.minGap, PHOTO_GLASS_DISTANCE.maxGap, value / 100); applyFraming(); photoGlassDistanceOutput.value = `${value.toFixed(0)}%`; }, { signal: listenerAbort.signal });
gridInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGridSize.value = value; gridOutput.value = value.toFixed(0); updateRegionTargetSizes(); }, { signal: listenerAbort.signal }); gridInput.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uGridSize.value = value; gridOutput.value = value.toFixed(0); updateRegionTargetSizes(); }, { signal: listenerAbort.signal });
mosaicStyleInput.addEventListener('change', (event) => { if (effectInput.value !== 'mosaic') return; const style = Number((event.target as HTMLSelectElement).value); uniforms.uMosaicStyle.value = style; uniforms.uGridSize.value = style === 0 ? 13 : 24; uniforms.uStrength.value = 1; gridInput.value = String(uniforms.uGridSize.value); gridOutput.value = String(uniforms.uGridSize.value); updateRegionTargetSizes(); setGlossControlsVisibility(); }, { signal: listenerAbort.signal }); mosaicStyleInput.addEventListener('change', (event) => { if (effectInput.value !== 'mosaic') return; const style = Number((event.target as HTMLSelectElement).value); uniforms.uMosaicStyle.value = style; uniforms.uGridSize.value = style === 0 ? 13 : style === 2 ? GOBANG_GLASS.grid : 24; uniforms.uRegionStrength.value = style === 0 ? 1 : 0; uniforms.uStrength.value = 1; gridInput.value = String(uniforms.uGridSize.value); gridOutput.value = String(uniforms.uGridSize.value); updateRegionTargetSizes(); setGlossControlsVisibility(); }, { signal: listenerAbort.signal });
curvatureInput?.addEventListener('input', (event) => { const value = Number((event.target as HTMLInputElement).value); uniforms.uCurvature.value = value; const output = document.querySelector('#curvatureOut') as HTMLOutputElement | null; if (output) output.value = value.toFixed(2); }, { signal: listenerAbort.signal }); 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 }); 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 }); 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 });
+86 -1
View File
@@ -7,6 +7,9 @@ uniform float uGlassZ,uSourceZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlos
uniform vec2 uViewOffset,uPetSize,uPaneSize; uniform vec2 uViewOffset,uPetSize,uPaneSize;
uniform vec2 uDepthEvidenceRange,uDepthCorrection; uniform vec2 uDepthEvidenceRange,uDepthCorrection;
uniform float uEnvironmentStrength; uniform float uEnvironmentStrength;
uniform vec4 uGobangSurface;
uniform float uGobangEnvironmentStrength;
uniform float uGobangBevelRound;
uniform vec4 uPaneRect; uniform vec4 uPaneRect;
uniform sampler2D uRegionPalette; uniform sampler2D uRegionPalette;
uniform sampler2D uRegionAtlas,uRegionHistory; uniform sampler2D uRegionAtlas,uRegionHistory;
@@ -159,6 +162,81 @@ void evaluateCell(vec2 cell,vec2 local,vec2 grid,vec2 cellSize,vec2 cellDimensio
facetSlope=vec2(0.); facetSlope=vec2(0.);
} }
} }
#ifndef REGION_ATLAS
// Broad prism faces, with narrow rounded transitions at the ridge and center.
// The height and slope meet at cell boundaries; no outline is painted on top.
float gobangProfile(float coordinate,out float derivative){
float span=.5-uGobangSurface.y;
float t=clamp((abs(coordinate)-uGobangSurface.y)/span,0.,1.);
float rounding=uGobangBevelRound,ramp=t-rounding*.5,slope=1.;
if(t<rounding){ramp=t*t/(2.*rounding);slope=t/rounding;}
else if(t>1.-rounding){ramp=1.-rounding-(1.-t)*(1.-t)/(2.*rounding);slope=(1.-t)/rounding;}
derivative=-slope*sign(coordinate)/(span*(1.-rounding));
return 1.-ramp/(1.-rounding);
}
void gobangSurface(vec2 paneUv,vec2 grid,vec2 dimensions,float cellWorld,out float height,out vec3 normal){
vec2 local=fract(paneUv*grid)-.5;
float dx,dy;
float px=gobangProfile(local.x,dx),py=gobangProfile(local.y,dy);
// Crossing directional ribs produce rectangular optical pieces. A minimum
// of X/Y profiles would instead produce conspicuous diagonal triangles.
float shape=.25*px+.55*py+.20*px*py;
vec2 shapeDerivative=vec2((.25+.20*py)*dx,(.55+.20*px)*dy);
// A small continuous ripple bends the ribs without independent cell crops.
vec2 phase=paneUv*grid*6.2831853;
float ripple=.008*sin(phase.x*3.+sin(phase.y))*sin(phase.y);
vec2 rippleDerivative=.008*6.2831853*vec2(3.*cos(phase.x*3.+sin(phase.y))*sin(phase.y),cos(phase.x*3.+sin(phase.y))*cos(phase.y)*sin(phase.y)+sin(phase.x*3.+sin(phase.y))*cos(phase.y));
float amplitude=uGobangSurface.x*cellWorld*clamp(uCurvature,-1.,1.);
height=amplitude*(shape+ripple);
vec2 gradient=amplitude*(shapeDerivative+rippleDerivative)/dimensions;
normal=normalize(vec3(-gradient,1.));
}
vec4 renderGobang(vec3 incident,vec3 glassHit,vec2 paneUv,vec2 grid,float cellWorld){
vec2 dimensions=uPaneSize/grid;
float height;vec3 normal;
gobangSurface(paneUv,grid,dimensions,cellWorld,height,normal);
// Two refinements locate the shallow front surface along this camera ray.
vec3 frontHit=glassHit;
for(int iteration=0;iteration<2;iteration++){
frontHit=uContentCameraPosition+incident*((uGlassZ+height-uContentCameraPosition.z)/safeZ(incident.z));
gobangSurface(frontHit.xy/uPaneSize+.5,grid,dimensions,cellWorld,height,normal);
}
float ior=uGobangSurface.z;
vec3 internalRay=refract(incident,normal,1./ior);
float backZ=uGlassZ-uGobangSurface.w-uGobangSurface.x*cellWorld;
vec3 backHit=frontHit+internalRay*((backZ-frontHit.z)/safeZ(internalRay.z));
vec3 exitRay=refract(internalRay,vec3(0.,0.,1.),ior);
float transmission=step(.0001,dot(exitRay,exitRay));
if(transmission<.5)exitRay=incident;
vec3 sourceHit=backHit+exitRay*((uSourceZ-backHit.z)/safeZ(exitRay.z));
vec3 straightSource=glassHit+incident*((uSourceZ-glassHit.z)/safeZ(incident.z));
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));
float paneWeight=smoothstep(0.,max(1./grid.x,1./grid.y)*.75,edgeDistance);
float amount=clamp(uStrength,0.,1.)*paneWeight;
sourceHit=mix(straightSource,sourceHit,amount);
// Reuse the visible-depth solve; hidden surfaces still fall back to the plane.
sourceHit=refineSceneHit(backHit,normalize(mix(incident,exitRay,amount)),sourceHit,cellWorld);
vec4 result=sampleScene(projectUv(sourceHit));
// One static room environment; the same surface normal controls its reflection.
vec3 viewDirection=-incident,reflected=reflect(incident,normal);
float fresnel=.04+.96*pow(1.-clamp(dot(normal,viewDirection),0.,1.),5.);
vec3 room=mix(vec3(.09,.13,.17),vec3(.78,.84,.86),smoothstep(-.4,.65,reflected.y));
float lightTravel=(uLightPosition.z-frontHit.z)/safeZ(reflected.z);
vec2 lightUv=(frontHit.xy+reflected.xy*lightTravel-uLightPosition.xy)/(min(uPaneSize.x,uPaneSize.y)*vec2(.38,.52));
vec2 window=1.-smoothstep(vec2(.75),vec2(1.1),abs(lightUv));
float windowLight=window.x*window.y*step(0.,lightTravel)*step(.001,reflected.z);
room=mix(room,vec3(.94,.97,1.),windowLight);
float mullion=1.-smoothstep(.015,.06,abs(reflected.x+.12));
room=mix(room,vec3(.12,.16,.20),mullion*.55);
float gloss=clamp(uGlossStrength,0.,1.);
float reflectionWeight=gloss*uGobangEnvironmentStrength*(.35+.65*fresnel)*amount;
reflectionWeight=mix(reflectionWeight,1.,(1.-transmission)*amount);
result.rgb=mix(result.rgb,room,reflectionWeight);
result.a=1.;
return result;
}
#endif
void main(){ void main(){
vec2 screenUv=vUv; vec2 screenUv=vUv;
#ifdef REGION_ATLAS #ifdef REGION_ATLAS
@@ -175,7 +253,7 @@ void main(){
#endif #endif
return;} return;}
vec4 f=uContentInverseViewProjection*vec4(screenUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt; vec4 f=uContentInverseViewProjection*vec4(screenUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid;float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y); 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.,2.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
#ifdef REGION_ATLAS #ifdef REGION_ATLAS
paneUv=atlasPaneUv;grid= uRegionGridSize;gridUv=paneUv*grid;cell=floor(gridUv);local=fract(gridUv)-.5;cellSize=1./grid;cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y); paneUv=atlasPaneUv;grid= uRegionGridSize;gridUv=paneUv*grid;cell=floor(gridUv);local=fract(gridUv)-.5;cellSize=1./grid;cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
#endif #endif
@@ -187,6 +265,13 @@ void main(){
#include <colorspace_fragment> #include <colorspace_fragment>
#endif #endif
return;} return;}
#ifndef REGION_ATLAS
if(style>1.5){
gl_FragColor=renderGobang(i,gh,paneUv,grid,cellWorld);
#include <colorspace_fragment>
return;
}
#endif
vec2 cellDimensions=uPaneSize/grid; vec2 cellDimensions=uPaneSize/grid;
float opticalTravel=-max(abs(uSourceZ-gh.z),.6); float opticalTravel=-max(abs(uSourceZ-gh.z),.6);
vec2 affineUv,curvedSlope,facetSlope;mat2 transform; vec2 affineUv,curvedSlope,facetSlope;mat2 transform;
+5
View File
@@ -3,3 +3,8 @@
details{border:1px solid #344255;border-radius:8px;padding:10px 12px;background:#111923}details summary{cursor:pointer;color:#dce6f2;font-weight:600}details[open] summary{margin-bottom:8px}.details-content{display:grid;gap:12px;padding-top:4px}.control-note,.control-help{margin:0;color:#9eacbb;font-size:12px}.control-help{margin-top:-7px} details{border:1px solid #344255;border-radius:8px;padding:10px 12px;background:#111923}details summary{cursor:pointer;color:#dce6f2;font-weight:600}details[open] summary{margin-bottom:8px}.details-content{display:grid;gap:12px;padding-top:4px}.control-note,.control-help{margin:0;color:#9eacbb;font-size:12px}.control-help{margin-top:-7px}
.core-optics{gap:12px} .core-optics{gap:12px}
#depth-inspection-canvas{width:100%;height:auto;margin:10px 0;cursor:crosshair}
#depth-inspection-canvas[hidden]{display:none}
#depth-inspection pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px;color:#b9c9da}
.depth-coordinates{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.depth-coordinates input{width:100%;min-width:0}