Replace project with current server release and Windows setup

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

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

Validation: npm ci, static and server builds, 106 arrival tests, and actual
local HTTP checks passed. The full suite passed 467 of 470 tests; three tests
still require QA fixtures omitted from the supplied release, documented in
README-서버실행.md.
This commit is contained in:
2026-10-09 16:43:35 +09:00
parent 71c8d3b7ff
commit e358e24436
642 changed files with 35202 additions and 17642 deletions
+13 -1
View File
@@ -1 +1,13 @@
/node_modules /node_modules
/.worktrees/
/deploy/arrival.local.env
*.tsbuildinfo
/.validation/
# Private analytics database files must never enter source/build artifacts.
*.sqlite
*.sqlite3
*.db
*-wal
*-shm
*-journal
+1
View File
@@ -0,0 +1 @@
24.19.0
@@ -1,38 +0,0 @@
# Final review package: Ref1 surface reflection
The workspace is not a Git repository. Use the preserved shader baseline and the exact file list below as the complete change set.
## Requirements
Read: `D:/project/behind_glass/.superpowers/sdd/surface-reflection/task-1-brief.md`.
## Changed product and regression files
- Pre-edit shader: `D:/project/behind_glass/docs/qa-surface-reflection/baseline-mosaic.frag`
- Final shader: `D:/project/behind_glass/src/shaders/mosaic.frag`
- New regression: `D:/project/behind_glass/scripts/test-surface-reflection.mjs`
The production shader differs from the baseline only in the final non-atlas reflection statement. Its SHA-256 is `5FB0AE93482AF46833B076EABFB0018E8BFEBA0A83BFD244DCD210371E450180`.
## Reports and review history
- Implementer/TDD report: `D:/project/behind_glass/docs/qa-surface-reflection/implementation-report.md`
- Controller browser QA: `D:/project/behind_glass/docs/qa-surface-reflection/visual-verification.md`
- Task review packages/results are summarized in `D:/project/behind_glass/.superpowers/sdd/surface-reflection/progress.md`.
Task review required two test-only fix rounds. The final scoped review approved complete amplitude/output binding, constant-white mutation rejection, unchanged refraction expressions, complete effective atlas-source comparison, and earlier atlas-reflection mutation rejection. Production shader did not change during either fix.
## Verification evidence
- Focused command: `node scripts/test-surface-reflection.mjs`
- Focused output: `surface reflection source/math regression passed; constant-output and atlas-reflection mutations rejected`
- The implementation report records six existing scripts passing and `npm.cmd run build` exit 0 immediately after the production shader change.
- Controller browser QA observed no new runtime errors; only the pre-existing `X3595` warning remained.
- Gloss 0 pane crop was pixel-identical before/after. Default final reflection widened the changed crop coverage from 12.80% to 15.81% relative to gloss 0; the final-vs-baseline mean absolute change was 0.3106/255 and concentrated around the shared top-right light.
## Known limits
- No GPU screenshot of the intermediate broad-reflection-only stage was captured.
- Default-strength visual change is subtle; `after-glass-only.png` isolates it, while `before-default.png` and `after-default.png` are the primary same-condition result.
- Real-device mobile performance, continuous-video flicker, touch, and gyro were not measured.
- Existing Vite large-chunk advisory and shader `X3595` warning remain.
@@ -1,22 +0,0 @@
# SDD ledger — plan: .superpowers/sdd/surface-reflection/task-1-brief.md
| Check | Produces / consumes | Result |
| --- | --- | --- |
| Task 1 internal consistency | Test invariants drive the requested shader-only behavior | Clean: the requested broad reflection and separated reflection edge weight are independently testable. |
| Task 1 file interfaces | `mosaic.frag` consumes existing camera, light, gloss, pane and normal uniforms; no new pass is required | Clean: current plan requires no interface change outside the shader. |
| Task 1 preservation constraints | New final-pass reflection must not enter atlas/palette/history or change refraction sampling | Clean: explicit write scope and invariants cover the boundary. |
Environment: `D:\project\behind_glass` is not a Git repository, so native/manual worktree setup and SHA-based review packages are unavailable. Work proceeds in place with an exact pre-edit shader snapshot and file-based review package.
Task 1 implementer: `01a0c9b1-382a-79e1-9f89-93e834836c4a` (Luna). Status: DONE_WITH_CONCERNS; automated verification reported green, GPU/browser visual comparison delegated to controller.
Task 1: fix round 1/5 dispatched for two Important regression-test findings; production shader unchanged.
Task 1: fix round 1/5 result (1 addressed, 1 open — complete atlas-path exclusion not covered; production shader unchanged).
Task 1: fix round 2/5 dispatched for the remaining complete atlas-path exclusion finding; production shader unchanged.
Task 1: fix round 2/5 result (1 addressed, 0 open — complete effective atlas path compared with baseline; production shader unchanged).
Task 1: complete (file-based review clean; no Git commits because this workspace is not a repository).
Final whole-change Astra review: clean; no Critical, Important, or Minor findings. Ready to retain in the non-Git workspace.
Ruling: retain this plan's `.superpowers/sdd/surface-reflection` workspace — no Git history exists to preserve the task brief, fix-round record, or review packages elsewhere — cost if wrong: hidden workspace clutter remains in the project.
@@ -1,52 +0,0 @@
# Task 1: Ref1 surface reflection
Source plan: final work instruction in Codex task `01a0c768-534f-7660-9d86-c5ae50f71b1f`.
## Goal
Keep Ref1 option 2's large rectangular color planes and dark eye/nose readability while making the whole glass pane, including grass, read as glass.
## Required implementation
- Preserve the existing rectangular light reflection in `src/shaders/mosaic.frag`.
- Add a broad, soft, low-strength reflection component derived from the existing curved normal and current view direction. Reuse the current light/intersection model where possible; do not add an environment texture, render pass, render target, UI, or dependency.
- Composite both reflections after color-plane simplification and before output color-space conversion. Do not put reflection into `REGION_ATLAS`, palette selection, or history.
- Control both reflections with the existing `uGlossStrength`. At gloss 0 the new reflection contribution must be exactly zero.
- Keep the same signed curved normal for refraction and reflection. Reflection must respond to view direction, be stable when still, and vary spatially rather than applying a uniform white mix.
- Keep the refraction `edgeWeight` and all existing world-delta/sample-boundary behavior unchanged. Add a separate surface-reflection edge weight, starting from `mix(0.35, 1.0, edgeWeight)`, and tune only if visual comparison shows a bright rim. Both existing and new reflection use the surface-reflection weight.
- Keep the pane bounds so reflection does not spill onto the wooden reveal/frame or outside the glass.
## Must remain unchanged
- Grid 13, curvature +1, gloss 0.30, region/distortion 1, camera 0.50 defaults and option ranges.
- Ref1 option 2 palette fitting, dark rectangular color-plane preservation, 70 ms history, view-dependent refraction, photo/depth placement, manual framing, and inner window reveal.
- No original eye/nose pixel restoration, global blur, stronger black grid, per-cell random brightness, time animation, grass/pet masks, automatic framing, or changes to Reeded/Frosted.
## TDD and automated verification
Before production shader edits, add one minimal source/math regression script that fails for the missing behavior and then passes. It must check only this change's invariants:
- gloss 0 removes both existing and broad reflection;
- surface reflection weight stays in range and does not replace the refraction edge weight;
- broad reflection is spatial/view dependent rather than a constant white mix;
- reflection composition remains outside `REGION_ATLAS` and before color-space conversion.
Record the exact RED and GREEN commands/output. Label source/math assertions honestly; they are not GPU visual verification. Run the existing six scripts and `npm.cmd run build` after implementation.
## Baseline and visual QA
- Preserve the exact pre-edit `src/shaders/mosaic.frag` under `docs/qa-surface-reflection/baseline-mosaic.frag` before editing.
- Use dog1 with completed AI depth and fixed values: Final/Mosaic, Grid 13, curvature +1, gloss 0.30, region/distortion 1, light X/Y 0.60/0.80, depth scale 0.25, camera offset 0.50, photo zoom/pan 1/0/0.
- Capture same-viewport images for baseline, broad reflection only, and final edge separation when practical. Also inspect gloss 0/0.30/0.40, curvature -1/0/+1, camera 0.90, a portrait viewport, and one indoor image.
- Passing visual result: grass shows distinct curved surface reflection rather than uniform brightening; adjacent cells respond coherently; pane edges retain material without a bright rim or leakage; eyes/nose remain dark and face/grass do not wash out.
- Gloss 0 must remain visually equivalent to the baseline. Distortion 0 and other glass modes must retain existing behavior.
## Write scope
- Required: `src/shaders/mosaic.frag`, one narrowly scoped script under `scripts/`, `docs/qa-surface-reflection/` baseline/QA/report files.
- `src/main.ts` may be edited only if inspection proves the existing uniforms cannot support the required shader behavior; explain first in the report.
- Do not touch palette/history shaders, UI, camera, photo/depth placement, or window reveal.
## Report contract
Write `docs/qa-surface-reflection/implementation-report.md` with changed files, final coefficients and rationale, RED/GREEN evidence, full verification results, visual comparison paths, and remaining limitations. Do not claim GPU or mobile behavior that was not observed.
@@ -1,20 +0,0 @@
# Task 1 fix round 1 review package
Review only the two Important findings from the first Astra review.
## Files
- Current regression (changed): `D:/project/behind_glass/scripts/test-surface-reflection.mjs`
- Updated report (changed): `D:/project/behind_glass/docs/qa-surface-reflection/implementation-report.md`
- Production shader (unchanged during fix): `D:/project/behind_glass/src/shaders/mosaic.frag`
- Preserved baseline: `D:/project/behind_glass/docs/qa-surface-reflection/baseline-mosaic.frag`
- Original task brief: `D:/project/behind_glass/.superpowers/sdd/surface-reflection/task-1-brief.md`
## Fix evidence
- Production shader SHA-256 remains `5FB0AE93482AF46833B076EABFB0018E8BFEBA0A83BFD244DCD210371E450180`.
- Current test SHA-256: `B924F91C82D0805C23C292BE613B21B6253E73C17C45F78D54E0AFAF6BC94A54`.
- Command: `node scripts/test-surface-reflection.mjs`
- Output: `surface reflection source/math regression passed; constant-output mutation rejected`
The test now extracts complete individual statements, evaluates the actual `specGloss`, `broadReflection`, and final mix expression for representative inputs, compares refraction expressions to the preserved baseline, inspects the effective atlas branch, and asserts that a constant 50% white-output mutation is rejected.
@@ -1,19 +0,0 @@
# Task 1 fix round 2 review package
Review only the remaining atlas-exclusion finding from fix round 1.
## Files
- Current regression: `D:/project/behind_glass/scripts/test-surface-reflection.mjs`
- Updated report: `D:/project/behind_glass/docs/qa-surface-reflection/implementation-report.md`
- Unchanged production shader: `D:/project/behind_glass/src/shaders/mosaic.frag`
- Preserved baseline: `D:/project/behind_glass/docs/qa-surface-reflection/baseline-mosaic.frag`
## Evidence
- Production shader SHA-256 unchanged: `5FB0AE93482AF46833B076EABFB0018E8BFEBA0A83BFD244DCD210371E450180`.
- Current test SHA-256: `E4430C19827D0253C5E3EF073D702170C9552E3CAD09997D00D1C0405A66ED8A`.
- Command: `node scripts/test-surface-reflection.mjs`
- Output: `surface reflection source/math regression passed; constant-output and atlas-reflection mutations rejected`
The test now compares the complete source segments executed while `REGION_ATLAS` is defined against the preserved baseline and applies a mutation that inserts gloss-dependent white mixing in an earlier atlas section. That mutation must be rejected.
@@ -1,45 +0,0 @@
# Task 1 review package
This workspace is not a Git repository. Review the exact files below as the file-based equivalent of a task diff.
## Required inputs
- Task brief: `D:/project/behind_glass/.superpowers/sdd/surface-reflection/task-1-brief.md`
- Implementer report: `D:/project/behind_glass/docs/qa-surface-reflection/implementation-report.md`
- Pre-edit shader: `D:/project/behind_glass/docs/qa-surface-reflection/baseline-mosaic.frag`
- Current shader: `D:/project/behind_glass/src/shaders/mosaic.frag`
- New regression: `D:/project/behind_glass/scripts/test-surface-reflection.mjs`
## Shader diff (`git diff --no-index --unified=10`)
```diff
@@ -258,17 +258,17 @@ void main(){
vec2 historyTexel=clamp(floor(fract(gridUv)*8.),vec2(0.),vec2(7.));
vec2 historyUv=(cell*8.+historyTexel+.5)/uRegionAtlasSize;
vec4 splitHistory=texture2D(uRegionHistory,historyUv);
vec3 splitColor=splitHistory.a>.00001?splitHistory.rgb/splitHistory.a:radialColor;
vec3 candidateColor=mix(radialColor,splitColor,splitHistory.a);
color.rgb=mix(color.rgb,candidateColor,clamp(uRegionStrength*clamp(uStrength,0.,1.)*regionSurfaceValid(finalSceneUv,gh),0.,1.));
}
#endif
#ifndef REGION_ATLAS
color.a=1.;
- vec3 v=normalize(uContentCameraPosition-gh),reflected=reflect(-v,nCurved); float lightT=(uLightPosition.z-gh.z)/safeZ(reflected.z); vec2 lightHit=gh.xy+reflected.xy*lightT; vec2 halfSize=vec2(.32,.42)*min(uPaneSize.x,uPaneSize.y); vec2 windowCoord=abs(lightHit-uLightPosition.xy)/halfSize; vec2 windowMask=1.-smoothstep(vec2(.45),vec2(1.15),windowCoord); float spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float gloss=spec*clamp(uGlossStrength,0.,1.)*.12*edgeWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),gloss); gl_FragColor=color;
+ vec3 v=normalize(uContentCameraPosition-gh),reflected=reflect(-v,nCurved); float lightT=(uLightPosition.z-gh.z)/safeZ(reflected.z); vec2 lightHit=gh.xy+reflected.xy*lightT; vec2 halfSize=vec2(.32,.42)*min(uPaneSize.x,uPaneSize.y); vec2 windowCoord=abs(lightHit-uLightPosition.xy)/halfSize; vec2 windowMask=1.-smoothstep(vec2(.45),vec2(1.15),windowCoord); float surfaceReflectionWeight=mix(.35,1.,edgeWeight); float glossStrength=clamp(uGlossStrength,0.,1.); float spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float specGloss=spec*glossStrength*.12*surfaceReflectionWeight*clamp(uStrength,0.,1.); vec2 broadWindowMask=1.-smoothstep(vec2(.15),vec2(1.75),windowCoord); float broadFacing=pow(max(dot(nCurved,v),0.),2.); float broadReflection=broadWindowMask.x*broadWindowMask.y*broadFacing*glossStrength*.035*surfaceReflectionWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),specGloss+broadReflection); gl_FragColor=color;
#include <colorspace_fragment>
#else
color.a=atlasEvidence;
gl_FragColor=color;
#endif
}
```
The test and report are new files and must be read in full. The exact shader baseline hash reported by the implementer is `CEE5F75C7A62A9146CBE451C661C2E0066EE57EB8963A7B0F56C281873F37E35`.
## Controller visual evidence so far
- `docs/qa-surface-reflection/before-default.png`
- `docs/qa-surface-reflection/after-default.png`
- Same 1280×720 dog1/AI-depth/default-condition capture.
- Frame-interior pixel comparison: mean absolute channel difference `0.3106/255`, max channel difference `14`, changed channels `13.85%`.
- This confirms a change but does not establish that the requested glass-surface improvement is visually sufficient.
-78
View File
@@ -1,78 +0,0 @@
# Pet Glass / Pet Behind Glass 프로젝트 지침
## 원본 요구사항
- 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 목표에 가장 근접한 두 번째 옵션(옵션 2)**으로 명시한다. 정확한 값은 `docs/ref1-option-2.md`를 따른다. 기존 기준안은 옵션 1로 보존하며, 옵션 2를 Ref2 목표로 해석하지 않는다. 이후 변경은 두 기준과 구분해 기록한다.
- 2026-09-14 최신 색면 보호 방식: 눈·코 원본 윤곽만 붙여 놓은 인상을 없애기 위해 최종 픽셀의 보호/원본 RGB 복원 분기를 제거한다. 앞쪽 depth와 어두운 RGB의 증거는 셀의 사각 색면 대표색 선택에만 사용한다. 어두운 색면의 존재를 보존하며 원래 픽셀 모양을 보존하지 않는다. 아래의 픽셀별 안전 palette/원본 fallback 지시보다 우선한다. `docs/ref1-dark-color-planes.md` 참고.
- 2026-09-14 최신 보호 범위 정정: 눈·코 보호는 원본 depth의 앞쪽(흰 부분)과 RGB의 어두운 특징이 겹치는 곳에 한정한다. 원본 상대 depth 0.80~0.95에서 보호를 부드럽게 증가시키며 기존 안전 palette/원본 fallback을 유지한다. 굴절 RGB와 같은 장면 위치에서 원본 depth를 읽는다. 색면 전체의 거리 감쇠 제거, 배경 단순화 및 시점 반응은 유지한다. 상세 구현·검증은 `docs/ref1-depth-protection.md`.
- 2026-09-14 최종 사용자 정정: 실제 결과를 확인한 사용자가 눈·코 보호 예외를 유지하도록 다시 지시했다. 아래의 보호 제거 지시는 이 정정으로 대체한다. 어두운 형태가 밝은 색면에 흡수될 때 기존 안전 palette 우선 보호와 필요한 픽셀의 원본색 fallback을 복원한다. 사진 배경의 색면 강화, SSE 분할 선택, 시점 반응과 70ms 전환 보간은 유지한다.
- 2026-09-14 최신 기준안 지정 및 추가 작업: 지금까지의 적용 내용과 비교 옵션을 **Ref1 목표에 가장 근접한 현재 기준안**으로 명시한다. 상세 값은 `docs/ref1-current-baseline.md`를 따른다. 사용자 최신 정정에 따라 눈·코의 어두운 픽셀/원본 RGB 보호 예외를 제거하고 얼굴과 사진 속 배경에 같은 색면 단순화를 적용한다. 눈·코 식별은 결과의 평가 기준이며 보호 로직을 강제하지 않는다. 아래 과거 눈·코 보호 지시보다 이 지시가 우선한다. 시점 반응, 70ms 색면 전환 보간, 자동 구도 조정 금지는 유지한다.
- 2026-09-14 최신 레퍼런스 변경: 사용자는 주 시각 기준을 **Ref1**로 변경했다. Ref2의 근접 선명도·거리 반응 재현을 주 목표로 삼지 않는다. Ref1의 큰 사각 색면, 과감하게 단순화된 얼굴 윤곽, 적은 정보로 식별되는 눈·코를 우선 평가한다. 아래의 Ref2 우선 지시보다 이 지시가 우선한다. 자동 구도 조정 금지와 인접 셀의 공통 장면 일관성은 유지한다. Ref1 | Ref2 비교 배치에서는 Ref1을 주 기준, Ref2를 보조로 표시한다. 상세 기준: `docs/ref1-direction.md`.
- 최신 사용자 요청: 자동 구도 조정을 하지 않는다. 사진 입력 및 자동 AI depth 완료 시 확대·팬·크롭을 자동 변경하지 않는다. 유리 근접 깊이 배치와 수동 구도 조절은 유지한다. 과거 자동 얼굴/앞쪽 영역 구도 지시보다 우선한다.
- 최신 근접 배치 정정: 사용자는 얼굴 확대 프리셋만의 해결을 거부했다. 사진 입력 후 깊이 표면의 앞쪽을 유리 바로 뒤에 배치하여 공간적으로 가까운 인상을 구현한다. RGB/Depth 공통 배치와 굴절 교차 기준을 함께 갱신하며 유리 관통을 방지한다. 확대만으로 실제 간격을 바꿨다고 설명하지 않는다. 상세 기록: `docs/glass-contact.md`.
- 최신 색면 실험: 일관된 굴절 위에 셀 내부 색상 양자화와 작은 색 영역의 경계 단순화를 추가한다. 전체 사진 블러나 저해상도 확대, 셀 전체 단색화로 대체하지 않는다. 눈·코의 작은 어두운 형태를 보호하고 색면 단순화 0에서 기존 출력을 복원한다. Ref1 | Ref2 비교 배치를 유지한다. 작업·검증 기록은 `docs/qa-regions/brief.md`.
- 최신 사용자 정정: 가까운 얼굴에서 색면 효과를 줄이는 거리 감쇠를 제거한다. 유효한 표면에는 유리와의 간격에 관계없이 설정된 색면 강도를 적용하고, 범위 밖 UV·배경 depth·역투영 실패·유리 앞/접촉 표면 검사는 유지한다.
- 최신 시점 굴절 보정: 기본 Mosaic에만 시점에 반응하는 셀 내부 굴절 이동을 더한다. 같은 현재 곡면 법선과 중앙 시점 굴절의 기울기 차이를 사용하고, 셀 경계는 고정하며 내부는 축별 cosine envelope로 움직인다. signed curvatureScale로 방향을 제어하고, atlas/final에 동일 계산을 적용한다. 이 항은 현재 평면 교점 광학 모델 안의 연출 근사다. 자동 구도, 입력 감도, 사진/Depth 배치와 다른 유리 효과는 바꾸지 않는다.
- 최신 Ref1 색면 보정: 큰 사각 얼굴·몸 색면과 적은 어두운 눈·코 형태를 우선한다. 기존 4색 palette에 셀별 최적 단일 축 분할(신뢰도 기반)과 radial grouping fallback을 사용한다. 어두운 눈·코는 palette 내 안전한 어두운 색으로 보호하고, 안전 palette 색이 없을 때만 해당 픽셀 원본색을 남긴다. 전체 셀 단색화, 원본 저해상도 확대, 전체 블러는 금지한다. region 0과 Distortion 0의 기존 동작을 유지하며, 시점 gain .18은 함께 유지한다. 상세 QA: `docs/ref1-shape-review.md`.
- 최신 시점/분할 전환 보정: Camera Offset 기본값은 .50, 최대값은 .90이며 입력 sensitivity 범위와 처리는 유지한다. 기본 Mosaic 외곽 taper는 1.25셀로 넓히고 다른 유리 모드는 .75셀을 유지한다. 셀 축 분할의 이산 메타데이터가 아니라 8×8 premultiplied 색·confidence field만 ping-pong history에서 70ms 지수 수렴시킨다. 카메라 이동은 history를 초기화하지 않으며 사진·depth·구도·격자·굴곡·강도·효과/모드·비활성화 변경은 초기화한다. 사진 구도 자동 변경은 금지한다. 실기기 FPS 및 영상 flicker 측정은 별도다. 상세 QA: `docs/ref1-shape-review.md`.
- 2026-09-14 최신 시점 일관성 지시: 셀마다 독립된 사진 정보를 가진 듯한 임의 확대·크롭 표현을 배제한다. 조각 유리는 공통 장면과 카메라 광선, 부호 있는 곡면 법선에서 굴절 샘플 위치를 계산한다. 과거의 셀별 무작위 샘플 배율·중심 이동 지시와 충돌하면 이 지시를 우선한다. 강한 블러로 불일치를 숨기지 않는다.
- 2026-09-14 재보정: 사용자는 과도한 흐림으로 사진/유리의 느낌이 사라지는 결과를 지적했다. 셀별 확대·위치 변화는 유지하되 눈·코 조각 내부 윤곽을 흐림으로 지우지 않는다. 해상감 저하를 일괄적인 강한 블러로 해석하지 않는다.
- 2026-09-14 추가: Ref2처럼 유리 투과 시 셀별 세부 정보가 크게 줄고 눈·코 조각의 크기와 위치가 달라지는 표현을 강화한다. 전체 출력 해상도 저하나 단색 pixelation으로 대체하지 않는다. `Ref1 | Ref2` 아래 작업 이미지 비교 배치는 개발 모드와 로컬 배포 미리보기 모두에서 유지한다. 상세 기준은 `docs/glass-resolution-task.md`.
- Mosaic 셀 굴곡은 관찰자 기준 -1=사진 쪽 오목, 0=셀 내부 평탄, +1=관찰자 쪽 볼록이다. 기본값 +0.65를 유지하며 부호가 있는 같은 곡면 법선을 굴절과 반사에 공유한다. 굴곡 0에서도 기존 셀별 기울기와 샘플링 효과는 유지한다.
- 과거 Ref2 주 기준 지시는 위 최신 Ref1 변경으로 대체되었다. 사진 구도는 RGB/Depth에 같은 변환을 적용하고 원본 파일을 바꾸지 않는다.
- 반복 쿠션형 인상을 줄이기 위해 중앙이 비교적 평탄하고 셀마다 약하게 비대칭인 곡면을 사용한다. 반사는 공통의 부드러운 직사각형 광원 형태를 근사하며 기존 조명 XY/광택 조절로 제어한다. 사진 리라이팅이나 시간 기반 반짝임은 추가하지 않는다.
- 셀 내부는 얕은 사각 곡면으로 표현하고 같은 표면 법선을 굴절과 광택에 공유한다. 광택을 꺼도 곡면에 의한 사진 윤곽 변화가 남아야 한다. 격자는 화면 너비가 아닌 유리판 기준이며, 셀 샘플 영역을 과도하게 줄여 단색 블록이 되는 결과를 피한다. 모바일/데스크톱 같은 설정, 정지 화면 눈·코 식별, 드래그 시 유리 좌표 일관성을 시각 검증한다.
- MVP에서는 사진 리라이팅을 제외한다. 사진의 기존 촬영 조명은 유지하고, 추가 광원은 Mosaic 유리 표면의 셀별 반사에만 적용한다. 공통 광원의 가로·세로 위치와 광택 강도를 조절하며, 셀별 반사는 시점에 따라 변하고 시간에 따른 임의 반짝임은 사용하지 않는다. 광택 0은 기존 Mosaic, Distortion 0은 기존 무효과 출력을 유지한다.
- 최신 시각 보정 목표: 참고 이미지처럼 사각 셀마다 눈·코·얼굴 조각의 위치와 배율이 달라져야 한다. 원형 물결 왜곡과 검은 격자선만 강해지는 결과는 완료로 인정하지 않는다.
- Mosaic 보정은 기존 Depth/RenderTarget을 유지하고 셀별 샘플 영역·확대·압축과 미세한 퍼짐을 조절한다. 단색 pixelation으로 대체하지 않는다. 같은 사진·같은 구도의 정지 화면에서 시각 목표를 먼저 비교하고 시점 변화도 재검증한다.
- 최신 수정 지시서는 `docs/depth-task-brief.txt`이다. 기존 평면 기반 설명과 충돌하면 이 수정 지시서를 우선한다.
- 기존 Mosaic/Reeded/Frosted 렌즈 효과를 보존하면서 `PetDepthMesh → WebGLRenderTarget → Glass Shader`의 2-pass 구조로 확장한다.
- **반드시 Glass OFF에서 코·눈·얼굴의 실제 카메라 parallax를 먼저 검증하고, 그 다음 RenderTarget과 Glass 효과를 연결한다.**
- 사용자가 지정한 테스트 사진은 `public/assets/sample-pet.png`이다. 테스트 depth는 수작업/절차적 근사임을 명시하며 AI 추정 또는 실제 측정값으로 표현하지 않는다. 사진은 로컬 테스트에만 사용하며 게시 권한으로 간주하지 않는다.
- Depth 내부 규칙은 0=뒤, 1=앞이다. 이번에는 RGB+Depth 입력과 정적 depth를 우선하며 AI 추정 및 영상 매 프레임 depth 추정은 후순위다.
- `docs/task-brief.txt`에 사용자 작업지시서를 보존한다.
- Vite + TypeScript + Three.js + GLSL, Vanilla TS를 사용한다. React와 별도 백엔드는 사용하지 않는다.
- 미디어 처리는 브라우저 로컬에서 수행한다.
- 핵심은 PetPlane 앞에 위치한 GlassPlane의 **시점 의존 굴절**이다. Mosaic는 단순 pixelation이나 이미지 전체 평행 이동으로 대체하지 않는다.
- 원문 구현 우선순위를 따른다. Scene → 이미지 Texture → PetPlane → GlassPlane → Pointer drag → Mosaic → 시점 굴절 → VideoTexture → Reeded → Frosted → Gyroscope → UI polish.
- 모바일 우선, 미디어 contain, 자원 dispose/revoke, DPR 상한, 부드러운 시점 보간을 지킨다.
## Karpathy Guidelines 적용
- 사용자가 요청한 `karpathy-guidelines`를 적용한다. 로컬 위치: `C:/Users/horoli/.codex/skills/karpathy-guidelines/SKILL.md`.
- 코딩 전에 가정과 완료 기준을 명시하고 불확실한 중요한 요구사항을 드러낸다.
- 요구사항을 충족하는 최소 구현을 우선하며 불필요한 추상화와 범위 밖 기능은 추가하지 않는다.
- 기존 코드가 생긴 이후에는 작업과 직접 관련 있는 부분만 수정한다.
- 빌드·타입 검사와 실제 동작 검증을 구분하고, 실행하지 못한 검증을 통과했다고 보고하지 않는다.
- 주요 단계와 최종 결과에 수정 파일, 구현 기능, 미완료 항목, 실행 방법, 셰이더 원리, 다음 개선점을 간단히 보고한다.
+137
View File
@@ -0,0 +1,137 @@
# Behind Glass 서버 배포본
- 버전: 2026-10-09 / 소스 7d82e3b1a9f083376bfd0b916568b1472dcb7f84
- 최신 세로 시점 보정, 사진 위치 조절, 홈 확인, 새 사진 입력 시 조작값 초기화 포함.
- `dist`는 Fastify 집계가 **활성화된 서버용 빌드**입니다. 바로 사용해도 됩니다.
- Vite/TypeScript 원본, Fastify/SQLite 코드, lockfile, 라이선스, 운영 예시를 포함합니다. public/참고 이미지 중 dist와 동일한 파일은 중복 보관하지 않으며 복원 스크립트가 원래 경로로 복사합니다. Blender 원본 제작 파일은 제외했습니다.
- DB·개인 설정·인증정보·node_modules·Git 이력은 포함하지 않습니다. 기존 DB가 있다면 외부 저장 위치를 그대로 유지하세요.
## 1. 설치
Node **24.x**가 필요합니다. 검증 버전은 `.node-version`의 24.19.0입니다.
ZIP을 풀고 `behind-glass` 폴더에서 실행하세요.
```sh
node --version
npm ci
node --input-type=module -e "import Database from 'better-sqlite3'; const db = new Database(':memory:'); console.log(db.prepare('select sqlite_version() as version').get()); db.close()"
```
다른 PC의 node_modules를 복사하지 마세요. better-sqlite3는 대상 OS/CPU에 맞춰 설치해야 합니다. 미리 빌드된 바이너리가 없는 환경에서는 해당 서버의 네이티브 빌드 도구가 필요할 수 있습니다.
### Windows / PowerShell 로컬 실행
이 PC는 nvm으로 Node 24.19.0을 설치·활성화했습니다. 다른 터미널에서도 `node --version`을 확인하세요. 빌드 명령은 Node 스크립트로 환경변수를 지정하므로 Windows와 Linux에서 동일하게 사용할 수 있습니다.
```powershell
nvm use 24.19.0
npm ci
node scripts/restore-source-assets.mjs
npm run build:server
npm run start:local
```
로컬 주소는 `http://127.0.0.1:3000`입니다. `start:local`은 `deploy/arrival.local.env`를 명시적으로 읽으며, 현재 PC의 DB는 `C:\Users\horoli\AppData\Local\BehindGlass\analytics.sqlite`에 저장합니다. 데이터 폴더는 현재 사용자와 SYSTEM만 접근하도록 설정했습니다. 이 설정 파일은 Git에서 제외됩니다. 실행 중인 터미널에서 Ctrl+C로 종료합니다.
새 PC에서는 아래처럼 해당 계정의 경로로 설정 파일을 만들고, 데이터 폴더의 Windows ACL을 확인하세요. 기존 DB가 있다면 새 경로를 만들기 전에 기존 `DB_PATH`를 유지하세요.
```powershell
$localDataPath = Join-Path $env:LOCALAPPDATA 'BehindGlass'
@(
'NODE_ENV=development'
'HOST=127.0.0.1'
'PORT=3000'
'PUBLIC_ORIGIN=http://127.0.0.1:3000'
"DB_PATH='$localDataPath\analytics.sqlite'"
) | Set-Content -LiteralPath 'deploy/arrival.local.env' -Encoding utf8
```
`localhost`와 `127.0.0.1`은 서로 다른 origin입니다. 집계가 필요하면 설정과 동일한 주소로 접속하세요. 로컬 HTTP 실행은 PC 확인용이며, 실제 모바일 센서 확인은 HTTPS 배포 후 진행합니다.
better-sqlite3 13.0.3은 플랫폼별 바이너리를 패키지에 포함합니다. npm이 이를 불필요하게 소스 빌드하지 않도록 lockfile에도 원래 패키지의 `gypfile: false`를 반영했습니다. npm의 설치 스크립트 허용 목록에는 고정된 ONNX Runtime 및 protobufjs 버전만 추가했습니다. 의존성 버전은 변경하지 않았습니다.
Windows 집계 테스트의 파일 링크 검증은 개발자 모드 또는 링크 생성 권한이 있는 터미널에서 실행하세요.
로컬 집계와 백업도 같은 설정 파일을 명시적으로 읽습니다. `npm ci`를 다시 실행하기 전에는 서버를 종료하세요. Windows에서는 실행 중인 서버가 SQLite DLL을 사용해 재설치 시 파일 삭제가 차단될 수 있습니다.
```powershell
node --env-file=deploy/arrival.local.env server/metrics.mjs --month 2026-10
node --env-file=deploy/arrival.local.env server/backup.mjs --output "$env:LOCALAPPDATA\BehindGlass\backups\analytics-2026-10-09.sqlite"
```
## 2. 실행
DB는 프로젝트 바깥의 영구 로컬 디스크에 둡니다. 아래는 현재 계정의 홈 폴더를 사용하는 예시입니다. **PUBLIC_ORIGIN은 실제 접속 HTTPS 주소로 바꾸세요.** 경로 없이 도메인(필요하면 포트)까지만 입력합니다.
```sh
(umask 077; mkdir -p "$HOME/behind-glass-data")
DB_PATH="$HOME/behind-glass-data/analytics.sqlite" \
PUBLIC_ORIGIN="https://your-domain.example" \
NODE_ENV=production HOST=127.0.0.1 PORT=3000 npm start
```
처음 실행하면 빈 SQLite DB와 스키마가 만들어집니다. 기본 포트는 3000이며 nginx 뒤에서 사용할 수 있도록 127.0.0.1에만 바인딩합니다. SSH 종료 후에도 유지하려면 기존 프로세스 관리자 또는 `deploy/behind-glass.service.example`을 서버 경로·사용자에 맞춰 적용하세요. **단일 프로세스**로 실행하고 PM2 cluster/multiple workers는 사용하지 마세요.
`deploy/arrival.env.example`은 예시입니다. 파일을 복사하는 것만으로 자동 로드되지는 않습니다. systemd의 EnvironmentFile 또는 Node의 명시적 `--env-file=/절대경로/arrival.env` 옵션을 사용하세요. 실제 설정 파일은 공개 폴더에 두지 마세요.
## 3. nginx / HTTPS
기존 HTTPS 도메인의 요청을 같은 Fastify 프로세스로 전달합니다. 정적 화면과 `/api`가 같은 origin이어야 합니다. 기존 server 블록 안의 location 예시:
```nginx
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
도메인·인증서·포트 충돌과 기존 location 설정을 확인한 뒤 적용하세요. 이 ZIP이 nginx나 시스템 설정을 자동 변경하지는 않습니다. 사진 처리는 브라우저 안에서 수행되며 AI 모델/WASM의 외부 다운로드에는 인터넷 연결이 필요합니다. 모바일 센서/보안 기능 확인은 실제 HTTPS 주소에서 진행하세요.
이 서버에는 자체 로그인 기능이 없습니다. 현재 비공개 테스트 범위를 유지하려면 HTTPS 프록시에서 소유자 전용 접근제어도 설정하세요. 외부 공개는 포함된 사진과 리소스의 공개 범위를 확인한 후 별도로 진행하세요.
현재 trustProxy는 false입니다. 리버스 프록시 뒤에서는 API 요청 제한(기본 60회/분)이 같은 연결 IP로 합산될 수 있으니 공개 트래픽을 늘리기 전에 프록시 신뢰 범위와 제한 정책을 검토하세요. Origin 검사는 로그인/인증을 대신하지 않습니다.
## 4. 수정 후 다시 빌드
처음 재빌드하기 전에 아래 복원 명령을 한 번 실행하세요. 기존 dist에 들어 있는 같은 파일을 복사하므로 인터넷 다운로드가 아닙니다. 복원 전 dist를 지우지 마세요.
```sh
node scripts/restore-source-assets.mjs
npm run build:server
```
주의: `npm run build`는 별도 정적 테스트 Site용이라 **집계가 비활성화**됩니다. 이 서버에서는 `build:server`를 사용하세요. 원본 소스에서 한 번 수정하더라도 각 서버의 재빌드·재배포는 별도로 해야 합니다.
## 5. 확인 / 집계 / 백업
```sh
npm run test:arrival
curl http://127.0.0.1:3000/api/arrivals/count
DB_PATH="$HOME/behind-glass-data/analytics.sqlite" npm run --silent metrics -- --month 2026-10
```
`test:arrival`은 임시 DB로 동작하며 운영 DB를 사용하지 않습니다. 실제 개인 사진의 성공한 도착만 누적하고 샘플 도착은 제외합니다. 방문 집계는 샘플 진입도 포함합니다.
백업·복원 및 집계 기준은 `docs/arrival-backend-operations.md`를 참고하세요. DB/WAL/SHM은 dist/public 및 프로젝트 밖에 보관하세요. 서비스 계정만 읽고 쓸 수 있도록 데이터 폴더와 DB 권한을 제한하세요.
## 검증 범위
### 현재 Windows 로컬 검증 (2026-10-09)
- Node 24.19.0 / npm 11.17.0에서 일반 `npm ci`, SQLite 로딩, 정적 빌드 및 서버용 빌드를 확인했습니다. 최종 `dist`는 서버용 빌드입니다.
- `npm run test:arrival`은 서버 33개 + 클라이언트 73개, 총 106개 통과했습니다.
- 기존 전체 테스트는 470개 중 467개 통과했습니다. 다음 3개 테스트는 원래 배포본에 포함되지 않은 QA 자료 때문에 실패하며, 해당 원본 자료 복원이 필요합니다. 통과로 처리하거나 임의 자료로 대체하지 않았습니다.
- `scripts/test-cutout-worker.mjs`: `docs/qa-countryside-cutout-2026-10-07/runtime/sample-320.rgba` 누락
- `scripts/test-original-photo-only.mjs`: `docs/reference-assets/countryside/open-house.png` 누락
- `scripts/test-surface-reflection.mjs`: `docs/qa-surface-reflection/baseline-mosaic.frag` 누락
- 실제 `127.0.0.1:3000` HTTP로 HTML, JS/CSS, 샘플 사진, GLB 및 ONNX 파일의 200 응답과 집계 API `{ "count": 0 }`를 확인했습니다. 다른 Origin은 403, 비공개 파일 경로는 404입니다.
- Windows 자산 경로 구분자를 정규화하고, 테스트 DB를 닫은 뒤 임시 파일을 삭제하도록 수정했습니다. AI 테스트의 파일 URL도 Windows에서 동작하도록 수정했습니다.
- `RELEASE-MANIFEST.json`은 수령한 원본 배포본의 체크섬 기록입니다. 이번 로컬 수정 파일의 체크섬과는 구분합니다.
### 수령한 원본 릴리스의 검증 기록
- 이 릴리스의 서버용 빌드와 방문/도착 테스트를 실행했습니다.
- 최신 소스의 전체 자동 테스트 470개 및 두 빌드는 통과한 상태입니다.
- ZIP 추출본에서 Fastify inject + 실제 임시 SQLite로 정적 페이지와 집계/중복 처리를 확인했습니다. 네트워크 포트를 통한 실서비스 검증과는 다릅니다.
- 사용자의 서버 OS/CPU, nginx/HTTPS, 실제 모바일/WebGL/센서, 외부 AI 다운로드는 배포 후 확인해야 합니다.
-51
View File
@@ -1,51 +0,0 @@
# Pet Behind Glass
사진과 Depth Map으로 얕은 2.5D 장면을 만들고 그 렌더 결과를 유리 셰이더로 굴절시키는 브라우저 로컬 MVP입니다. Vite, TypeScript, Three.js, GLSL을 사용하며 별도 백엔드는 없습니다. 선택 사항인 AI depth 추론도 브라우저 안에서 실행합니다.
```bash
npm install
npm run dev
npm run build
```
`샘플 반려동물 불러오기`를 누르면 사용자가 제공한 사진과 수작업 Depth Map이 **얼굴 확대 구도**의 Final로 표시됩니다. 사진 확대·가로 위치·세로 위치를 조절하거나 사진 구도 초기화로 원래 전체 구도로 돌아갈 수 있습니다. 일반 사진/영상 업로드는 전체 구도로 시작합니다. **Mosaic 스타일**에서 **조각 유리**(유리 가로 10칸, 강도 0.90)와 **촘촘 유리**(28칸, 강도 0.85)를 비교하세요. 셀마다 이미지 위치·확대율이 달라지며 검은 격자선과 원형 물결은 제거했습니다. Grid Size는 6~96으로 조절할 수 있습니다. **No Glass**는 깊이 시차를 확인하는 모드입니다. 직접 사용할 때는 사진 또는 영상 다음에 같은 종횡비의 Depth 이미지를 선택합니다. 새 RGB 미디어는 flat depth로 시작합니다.
사진을 불러온 뒤 **AI 깊이 생성**을 누르면 Depth Anything V2 Small이 로컬에서 상대 깊이를 계산합니다. 처음 실행할 때 모델 파일을 Hugging Face에서 내려받습니다. 사진 데이터는 추론을 위해 브라우저 밖으로 전송하지 않습니다. 결과는 AI 추정으로 표시되고 No Glass 모드로 전환됩니다. 영상에서는 이 버튼을 사용할 수 없습니다. 자세한 출처와 Playwright smoke 실행법은 [AI depth 안내](docs/ai-depth.md)를 참조하세요.
Mosaic의 **셀 굴곡**으로 얕은 곡면을 조절하고, **광택 강도**, **조명 가로**, **조명 세로**로 유리 표면의 반사를 조절할 수 있습니다. 하나의 공통 광원과 셀별 표면 방향으로 반사를 계산하며, 카메라 이동에도 반응합니다. 광택 0은 표면 반사만 끄고 곡면 굴절을 유지하며 Distortion 0에서는 광택도 꺼집니다. 사진 리라이팅은 MVP 범위에서 제외합니다. 조명 깊이는 고정이며 직사각형 광원을 향하는 반사 시선으로 창 모양을 근사합니다. 실제 환경맵이나 유리 내부 다중 반사는 없습니다.
## 변경된 구조
QA 개선으로 Reeded/Frosted도 활성 효과의 출력을 유리판 안으로 제한합니다. 파일 선택은 Tab과 Enter로 접근할 수 있으며, 샘플의 수작업 Depth 안내는 로딩 이후에도 유지됩니다. 굴곡 +0.65 초과 구간은 높이 증가폭을 완만하게 압축해 강한 볼록의 과도한 이동을 소폭 줄였습니다. 기본값과 음수 구간은 유지합니다.
셀 굴곡은 **−1 오목 / 0 평탄 / +1 볼록**으로 조절합니다. 양수는 관찰자 쪽으로 솟고 음수는 사진 쪽으로 들어가는 방향이며 기본값은 +0.65입니다. 0에서도 셀별 기울기와 조각별 확대·이동은 유지됩니다. 실제 유리 메시를 변형하는 대신 부호가 있는 높이함수의 법선으로 굴절과 반사를 함께 근사합니다.
- `PetDepthMesh`: 128×128 세그먼트, 정점 Z를 `(depth-bias)*scale`로 이동. 규칙은 **0=뒤, 1=앞**, 기본 scale 0.25, 상한 0.4입니다.
- 실제 PerspectiveCamera가 보간 이동하여 코·눈·몸 사이의 시차를 만듭니다. Camera Offset 상한 0.6, camera z=3입니다.
- Final은 `content scene → WebGLRenderTarget → fullscreen glass scene`의 2-pass 구조입니다. DPR 상한은 1.5이며 RT는 선형 색상, 화면 출력에서 색공간을 변환합니다.
- Mosaic는 월드 유리판 기준 셀별 위치·축별 배율·미세 회전과 5개 주변 샘플을 사용합니다. 중앙이 비교적 평탄한 사각 곡면에 셀별 고정 비대칭을 주고, 같은 법선으로 굴절과 창 모양 반사를 계산합니다. 주 기준은 레퍼런스 2번입니다. 조각 유리는 변화가 작은 셀과 확대·압축 셀을 섞어 특징점과 넓은 색면이 공존하도록 하고, 촘촘 유리는 기존 설정을 유지합니다. 입력은 `uSceneTexture`이며 기존 Depth/RT 파이프라인과 Reeded/Frosted는 유지합니다. 활성 Mosaic에서 유리판 밖은 배경색으로 가려 외곽을 일정하게 유지합니다. Distortion 0 및 No Glass는 진단용 전체 장면을 표시하므로 확대된 사진이 유리판 밖까지 보일 수 있습니다.
- View Mode는 Final, RGB(평면 원본), Depth(평면 지도), No Glass(깊이 메시), Glass Only(배경 위 유리 진단)입니다. Depth Scale, Grid Size, Distortion, Camera Offset, View Sensitivity를 조절할 수 있습니다.
## 주요 파일
- 수정: `src/main.ts`, `src/input/CameraController.ts`, 기존 Glass fragment 3개, `index.html`, `src/style.css`, `AGENTS.md`.
- 신규: `src/objects/PetDepthMesh.ts`, `src/depth/DepthMapLoader.ts`, `DepthProcessor.ts`, `StaticDepthProvider.ts`, `DepthDebug.ts`, `src/types/depth.ts`.
- 신규 셰이더: `src/shaders/petDepth.vert`, `petDepth.frag`, `glassScreen.vert`.
- 자산: `public/assets/sample-pet.png`, `sample-pet-depth.png`, `sample-pet-depth.json`. 재생성은 `scripts/create-sample-depth.ps1`.
- 명세와 검증: `docs/depth-task-brief.txt`, `depth-implementation-plan.md`, `depth-validation.md`. 기존 효과는 `docs/baseline-shaders/`에 비교용으로 보존했습니다.
## 확인 결과와 한계
TypeScript 검사와 production build를 통과했습니다. 사용자 사진으로 Glass OFF 시차를 먼저 검증한 뒤 Glass를 연결했습니다. 브라우저에서 세 효과, RGB/Depth 업로드, 비율 오류, 강도 0 Final/No Glass 정렬·색상, 390px viewport의 가로 넘침을 확인했습니다. 상세 진단은 검증 기록을 참조하세요.
테스트 Depth는 **수작업 가상 깊이**이며 AI 추정·실측 결과가 아닙니다. 보이지 않는 측면을 복원하지 않으므로 큰 이동에서는 사진이 늘어질 수 있습니다. 진단 좌표는 깊이 데이터와 카메라의 투영값으로, GPU 삼각형 보간과 작은 차이가 있을 수 있으며 화면 특징점 추적값은 아닙니다.
기존 VideoTexture와 flat/static depth 경로는 유지했습니다. 실제 영상 파일의 재생·loop, 실기기 gyro 및 60fps 성능 측정은 이번 브라우저 검증에서 수행하지 않았습니다. 요청 경쟁·미디어 수명·센서 경로는 코드 검토했습니다.
AI 추정은 `AiDepthProvider`와 별도 module worker로 연결했습니다. Depth Anything V2 Small의 상대 깊이는 실제 측정값이 아니며, 보이지 않는 측면이나 가려진 영역을 복원하지 않습니다. 실시간 영상 depth 추정과 완전한 3D 복원은 현재 범위가 아닙니다.
+252
View File
@@ -0,0 +1,252 @@
{
"source_commit": "7d82e3b1a9f083376bfd0b916568b1472dcb7f84",
"analytics_enabled": true,
"restorable_duplicate_assets": {
"public/THIRD-PARTY-NOTICES.txt": "dist/THIRD-PARTY-NOTICES.txt",
"public/fonts/BMKkubulim.woff": "dist/fonts/BMKkubulim.woff",
"public/licenses/Threejs-MIT.txt": "dist/licenses/Threejs-MIT.txt",
"public/licenses/HuggingFace-Jinja-MIT.txt": "dist/licenses/HuggingFace-Jinja-MIT.txt",
"public/licenses/Driverjs-MIT.txt": "dist/licenses/Driverjs-MIT.txt",
"public/licenses/ONNX-Runtime-ThirdPartyNotices.txt": "dist/licenses/ONNX-Runtime-ThirdPartyNotices.txt",
"public/licenses/Apache-2.0.txt": "dist/licenses/Apache-2.0.txt",
"public/licenses/ONNX-Runtime-MIT.txt": "dist/licenses/ONNX-Runtime-MIT.txt",
"public/licenses/BMKkubulim-OFL.txt": "dist/licenses/BMKkubulim-OFL.txt",
"public/assets/samples/dog3.jpg": "dist/assets/samples/dog3.jpg",
"public/assets/samples/dog5.jpg": "dist/assets/samples/dog5.jpg",
"public/assets/samples/dog2.jpg": "dist/assets/samples/dog2.jpg",
"public/assets/samples/dog4.jpg": "dist/assets/samples/dog4.jpg",
"public/assets/samples/dog1.jpg": "dist/assets/samples/dog1.jpg",
"public/assets/window/grandma-house-surround.glb": "dist/assets/window/grandma-house-surround.glb",
"public/assets/window/grandma-wood-sash.glb": "dist/assets/window/grandma-wood-sash.glb",
"public/models/u2netp/LICENSES.txt": "dist/models/u2netp/LICENSES.txt",
"public/models/u2netp/manifest.json": "dist/models/u2netp/manifest.json",
"public/models/u2netp/model.onnx": "dist/models/u2netp/model.onnx",
"docs/qa-five-photos/reference2.png": "dist/assets/reference2-6y7JVIce.png",
"docs/qa-five-photos/reference1.png": "dist/assets/reference1-D4rK2azX.png"
},
"files": {
".node-version": "7e8a2fa94951112b894a3dbe3d05efef5e9263741fa49125f0a70f40fedab4cc",
".gitignore": "e46e6a30de1c59f7c0dc7ffdd0b9c9188abfe37552e4b4bd5d4ff7e6bc4085bb",
"package.json": "ec1279ed591a049867763577a6eae4340dc85d639148d7500349ea280de5c21b",
"vite.config.ts": "24ea3953aff59683c53aced4b2e11909f27e5ef035586535f79fcb345ac6e886",
"index.html": "319cc210172cd81a8cd9544e5eb49847702dd7e9723e1b565cf07844e1420e05",
"tsconfig.json": "b41e76ea78ccc37d3b6e8370b80bb46b29227698a9bfed3efb3ca0fd97a696b4",
"package-lock.json": "43c0aa16b055aa974b28a55322b0e7164a55b9bb3e71c6cbae9b7604356175fc",
"docs/arrival-backend-operations.md": "17c3b79bc00dcb9a27abd02a7f65f4ae8e3f74122b46acb3ecfadfa657cf2f25",
"scripts/test-small-depth-ui.mjs": "70819a680e22974d824f7cf373c70e730e1bae0f340cd2f6ec4b17e631e845a2",
"scripts/test-render-demand-integration.mjs": "6165b4050ba9855e121b985800842be998a4446656fed8aeba73ed7f9492d1fc",
"scripts/test-ui-font.mjs": "79e328ba89aa55de50213b63a2abf36376341f475dd1b9b1814efd03f198f880",
"scripts/test-analytics-tracker.mjs": "501cf258206da83aabe14a56da2ffeaa0870b62f8c432d96e1dbf397ba2ad067",
"scripts/test-ai-depth-inference.mjs": "b2b588ff6b99b6432103344cf484af9810825f825dc74fc465fade46bc0a2abd",
"scripts/test-mesh-refraction.mjs": "7dede3518905823725062bd92c8b51e541d5a26b329e17d080980de16d465a48",
"scripts/test-arrival-count.mjs": "3fa213e578310a772c7de1b1334d39fde69707e356f6a55a0331111a0a609551",
"scripts/ai-depth-smoke.mjs": "1b3e91a3eb6bafbfcdbed6b5c9012c13a782b2c75ab134617c63d81af85941c5",
"scripts/test-peeking-grid.mjs": "ce5dd40612c1947cce53fe1672fb07d17288cd2981a910d4eb66816a94522382",
"scripts/test-pet-name.mjs": "fca07a2953ccb8116e4c84fbd17985dff772e62de8252322ebed452376ac9dd0",
"scripts/test-cutout-provider.mjs": "43e82268242a3ae15bb3bd245253d2009712c34570cc11d05566e636fde17dbf",
"scripts/test-house-surround.mjs": "8a559fc9d50bdf641cdf0dbca6356bcbc66fab1326bca2b04bfb326184d6c08f",
"scripts/test-export-ui.mjs": "3f0d5fb8208ffaeb780e39541377db9438a07748dc6d079e219d87bc43540028",
"scripts/test-arrival-db.mjs": "c13f6c7596c09297591ddb5411be2196ea93b4d09d056d28454350383226d78b",
"scripts/test-reveal-intersections.mjs": "dcbc84b40fe5d206ed6c3a48866664085e288f5a40544a93e3aadc37114b2620",
"scripts/test-portrait-framing.mjs": "e31ab043a8dc7c80fdc52888215cfaebdf02eb2fe5ff558f534a876c3dce1e07",
"scripts/test-image-paste.mjs": "c10b7a6e45b7e43384adb5ffef3ae64c10f66613ee76fbe9c5b167e66241e5d1",
"scripts/test-scene-hints.mjs": "f2398d0a2338002b75cbd019891a6da593b701d1708c1522eb126dfb01d834db",
"scripts/test-first-guide.mjs": "0219b92eb6b8842cfd7245c59310ca03c8ecd663b1decc672a8623e961e744c4",
"scripts/test-entry-notices.mjs": "2aa847ce95c26763094d08d812795273c74d2a9db4116e7faa9270f73400887c",
"scripts/test-peeking-ui.mjs": "1ed62dc54a3436331ac61034e03d7ff543d2458ea4b5a5c7c21b2a22fa9c4e75",
"scripts/test-window-reveal-sliding.mjs": "6f27adfa84597e1c0fbdc3db81cd77663324ce02a6698f83915435ca35523474",
"scripts/test-gyro-controller.mjs": "808011e15c2c525803e0ed3610b861d725ea24820468fe679227b357c26d8b57",
"scripts/test-depth-refraction.mjs": "ba6e99bba405dd568a09ad0648dcde244c0d20f64896a53f9fb6086e17ccfa5b",
"scripts/test-window-finish.mjs": "d9474e7f936c5c355bcf8ac9b580848b32fd182d5829ce8bc29bd46ec050db46",
"scripts/test-mobile-gyro-ui.mjs": "1ab9be89f49b16a9bf5a679296c919856780437910e4465fa9e516b1aab7cc74",
"scripts/test-region-axis-split.mjs": "727e2cfa4e733a03b4a1bc49b5f5eb444977736f079eea17799760996c2b8273",
"scripts/test-experimental-courtyard.mjs": "974c2d3efdfda92268df2890d35e0776b395ac9433a138d272187b84cfe6ac4a",
"scripts/test-room-peek.mjs": "8b98f9b76394760a719eb9dcfb9e2b507ea45141357bb9655121f9c851665d90",
"scripts/test-arrival-integration.mjs": "0746e2b555c17e707bc727e8fb56952faccd962e22d4f24e79ca0dcb76d4de2c",
"scripts/test-experience-ui.mjs": "283a888859e7fc4148980e79ef075324bd2fdc4f6a4e724a479b6b136a95e5c2",
"scripts/profile-photo-working-image.mjs": "6c4c07ce6a7ffe2076f891460bce23a0d8c2174ac649d758cfc9647d276c5a0a",
"scripts/test-region-atlas-dark-sampling.mjs": "e7b1f4a41f5c4dc426e40f1c6d3a3ab6f6fec414f879d79c91496e2282bb7050",
"scripts/test-camera-history.mjs": "be883741e70f9c69033bf3774d3edea2c6f4a280014908e7d1c4f8058b51e1e9",
"scripts/test-pocket-lining-clearance.mjs": "fa9afe45a83718050469a5c43aefbddfe90aaaf42464e9d0d7384ae6ef99d686",
"scripts/diagnose-stock-transmission.mjs": "1e5a4881d159a13cfac1e65fe663e526e756e985846666a2fcb2e418d7c90c4b",
"scripts/test-camera-envelope.mjs": "39bff56c2ee597538d8d34a8a647971554f7503bac73e28760b8f8ab108c52df",
"scripts/test-glass-controls.mjs": "322aab08624d63bad410962813e80007f98beb53df758c8f0b699f1538f7c46f",
"scripts/test-scene-zoom-materials.mjs": "9f3a84e8d5161dedbeb7d87d3a48ca448544b3bb52267f77c7a131b74e0dab4e",
"scripts/test-window-reveal-coverage.mjs": "8b1bdbbfa290a58e3d55f48049873dadddff0f055970552a97b53ea8c48376ab",
"scripts/test-peeking-composition.mjs": "da685dcfe486c7d5164226b18da08c5dbc1818a246de176fd5d4cd1d8460e6b5",
"scripts/test-peeking-integration.mjs": "dd54a27af0cb5a406c1b28b320e68077f0e9d1bce5887787ac0fd99cfab05178",
"scripts/test-cutout-mask.mjs": "2f6a23a7f80ab4aad0358a91b773e5d1b7f91d78f722a88a29d368f6b499740c",
"scripts/test-peeking-optics.mjs": "75fb8fc8ecbd569cacf9d219bd5cea882db141ae2ef43d1db33eb75fda7fc257",
"scripts/test-default-renderer.mjs": "bb2dbfd648f397014ac6af99aabe7b51680bed4bd1cad0700da594fc0297172e",
"scripts/test-scene-anchor.mjs": "93fe34a1d2a6f5cdc68e5e9b0082250406d844b4aa1fc4e1541bb61117416598",
"scripts/test-house-lighting.mjs": "26448032b216816211ab9988d9dc7c44db014c2fb88d991776703527eb5b9458",
"scripts/test-room-corner.mjs": "5721a55e233e3322501b3d5f9101060b0d8cc16867fb8e8308ef779ae01e211c",
"scripts/test-depth-evidence.mjs": "6ed26378435c1364376800226d199c5071e163e0a00a3a7f9a15f5bb2009a0e2",
"scripts/test-camera-composition.mjs": "de37bff5f51202d4301146f5ca3e421752c8c362be144adbb4d7db82bf3731de",
"scripts/test-car-journey.mjs": "a768a6d59cf7222a6f7abc57fa1485477fb31110809ff360e555b4b8a8b3ae19",
"scripts/test-render-recovery.mjs": "ed2387c8f688b15fc0255752c81c8c6fd05ed162727cd4f8273d05a6b28b7ae3",
"scripts/test-render-demand.mjs": "57a7a91723dac1f2967935c2e6ac2b423f90af520edf738eeeae35443c4c5268",
"scripts/test-blender-window-frame.mjs": "ebc4d2b40c553d970e4660ee084955f46f32af2291f5316c470ee0d68aeaa9a7",
"scripts/test-original-photo-only.mjs": "4ae95f68c87913b3fd760b2bc3299eaf8c0197c4edd2c4314a00dd5791cd548f",
"scripts/test-photo-working-image.mjs": "24036435b1cce57b28114a2dcbea134dfb04dddbbc3d5626e00593e6bdc9adbe",
"scripts/check-sites-build.mjs": "14656673a8661b4dc0a9bf5204cb0daca56229375229cea47c1b15177d506b8e",
"scripts/test-wall-peek-safety.mjs": "e51b34e0575d4c1f9abc11b45ff2624732edb5215e9f60dc738c1228f0989ff9",
"scripts/test-arrival-operations.mjs": "bac07b08d7736fc3aa488280a760ca8ca5d8fc58ba7e4c9f0c855fdca5f81d82",
"scripts/test-mosaic-depth-distance.mjs": "c8903e4e5abf6f7d74dd0152ddfae4a91e92d564aa276dc45c33d4c144acdb1b",
"scripts/test-surface-reflection.mjs": "63186186728847da329a8c42513e9cd6bf8ed9deaa59b4e56e72228871a48e33",
"scripts/test-arrival-api.mjs": "484724c5edb53f6c0c20ab5d0cb55d479562b4385e5831e920fb8716d3a3c004",
"scripts/test-cutout-preparation.mjs": "5dd08d221da4f1b5607bf2ccdfcacfe9d8328f5afd55c7f3301b13bd7fbaae47",
"scripts/test-mesh-refraction-material.mjs": "bca9c56de21ca6ac9d35bbb8e2e0e6499e9c361b391057efc6b77b15f78f5c91",
"scripts/test-photo-booth-ui.mjs": "05dbf52de72cec77f9527986f775dd8bbb38738f31c5cd7697f1134929e754e4",
"scripts/test-sample-loading.mjs": "26e9801c1358d8ce1db41b83bb5f3888a8598fd0f23aff283c30971d4a51d687",
"scripts/test-cutout-worker.mjs": "2a9dfe1c7de15aeb8ef7965a2ac483356fdc0d9799cc3329718c6bb75d1143b7",
"scripts/test-web-glass-comparison.mjs": "21b6928306134270baa26fe61c14bdc86a8899792f875a95db03761d97992d70",
"scripts/create-sample-depth.ps1": "cf1bff3f9d2f375c4f6b68810419d7454e564e5ef535cc03bcbf9adf535f3bc4",
"scripts/test-media-retry.mjs": "e3a5c54e74587eed6f7abd12af3276becd7aee7e4af030d878e2d5f169377484",
"scripts/test-analytics-transport.mjs": "aa1954ff5c73c5b6449fe86146307fe1c02b6d8555c92a228c6594575ecd5be7",
"scripts/test-mosaic-continuity.mjs": "d1357185518f670969933ff6d4b6212aca14705b2d846c88492b514e57d1873b",
"scripts/test-window-slide-controller.mjs": "d7e523da1ea87e9ac05eb90571327705a7ecb73087e75b60b73ddb9efac45bcd",
"scripts/create-fixtures.ps1": "3b4cf539f2e38b6e7b66b8dea74f1ce49308762e8ac2946d462c4c7fba8d9a7d",
"scripts/test-photo-metadata.mjs": "a01df0ffe8b1b5037722bd611dabf495b83176859d004e49c617786d557f782e",
"scripts/test-sliding-window-integration.mjs": "8c2d7ed068ff81f12b6b083e1847e5e4179e9f27ac060ec5923328204a4f4d2f",
"scripts/test-cutout-model.mjs": "1a2773517c40a40213d105d2a1ea9204e982e3c58245de0294314b4d8d0d5a58",
"scripts/test-media-working-image.mjs": "6afbec8379fe78bc804c5a6fa89074dee52e10428491908553e6b20937a10f39",
"scripts/test-tilt-integration.mjs": "1e795b668ed3f7155be0eb0dfb81f31dfd0107618c0aea183a0df614e0c97c3e",
"scripts/test-analytics-identity.mjs": "6092f5470bebf9ba20addfaafab9f3d1afe8c480169b822a3b5e35139d6fd963",
"scripts/test-journey-copy.mjs": "8c43709d317946fd3b56471fde2ff04ecbb619700d15d4a836acc65ef1936782",
"scripts/test-scene-gestures.mjs": "44562f453f488fefed36c6a57b70e57e0c0fad2b19c5852f1a95b5be1b21fb18",
"scripts/test-scene-export.mjs": "04e0369a708a47b705da60aa2265124f86948e92fa68ef9874f63277ada9ec85",
"deploy/arrival.env.example": "2b1a719560c2c8ca6ed1ae230bc467121d9e66ec67ff68c08f5f4d384da1c9c5",
"deploy/behind-glass.service.example": "091bb0f5a1af786ffbe95c44cc02cb9fccc195375498c4b9720bb65fe2fe41fe",
"deploy/README.md": "ab0ba976e4817a01a93b132a7e222469aca9dd17469c936c8eb13e00afe2aa3e",
"server/backup.mjs": "07677c4ec5e6b0607a377a58f39b81a5556fefe0e24b3c9c3aa33b66abcf1945",
"server/db.mjs": "258bbb7d7c615b8670f4b9f365aa68959057cc039ac5bfdc7f7370b32cb971c3",
"server/app.mjs": "22515f362223352a35fca636c80fe2112a1cdd3df6d4b2d5f82f86794fe2190d",
"server/config.mjs": "448e1ee5aa6bee188651fa2e6a76aa0c4801c04a99f1c6a0d909e486dd159ae6",
"server/metrics.mjs": "201cbdd47183ae2974943a459a7a1bb9eeb52b58facd1744f8074e39d5b93d0c",
"server/index.mjs": "2256dbb3dd1313e5e903e78d723629fdf3b6650e1f5801730f554eb9935f2bf2",
"dist/index.html": "b7fd8d6b089b2a26cb2a2e55c1f4f91b44060270923088c873423f5b8d3b75fb",
"dist/THIRD-PARTY-NOTICES.txt": "363444be9a1608577d2899b911c8908818bc64308e343e598503e7e17e7a4441",
"src/style.css": "971484e1a92c5253e7fcaea59d4ffc3253d935f36b4c0c94abd32a69fb814af4",
"src/main.ts": "c37e18efd3ee73ff6aca83a4297d42539954fc37e6c102b56771152ab2fc813e",
"src/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5",
"scripts/support/render-corner-preview.py": "3b562334104ce0309bd60663d4e5cb0c22fca56f87cc8be56da22890135edd79",
"scripts/support/render-wall-peek-preview.py": "3fbb7fe0ef8c2cf593ee529cdde0498dbb2d6a63479ac8c73272c62edd69c41a",
"scripts/support/photo-fixtures.py": "6795db053051d283eacd1c041b532df74e03203fa1c2489b7c84daa3900945be",
"scripts/support/load-frame-geometry.mjs": "4ba4308e4acb084c9d902940fe91ae70fd483ea00979c956019e596b47d0ccc8",
"scripts/support/load-typescript.mjs": "6006b8c6b03088fff181f410e25b4e9c435742c76edd97a09f9cc72a062c1ada",
"scripts/support/compile-refraction-shaders.py": "bc0d541e12542d766779c4bc80bb6f064451e94add314b96891f25ede369acf9",
"scripts/support/mesh-refraction-reference.mjs": "1f2fbbc3eb366a765310d102e05942a7e2fd44ddfe7cf52cb1ad699ed2e043a0",
"scripts/support/export-wall-peek-preview.mjs": "53a93de16e8fb53a8e4857b2e6269cf3ddd42a9f36e695c32e15ed4ab62c8781",
"scripts/support/capture-courtyard-shaders.mjs": "e48b4644adab246a504a7c88ca9cf9a2658bdf5fb0cbc792bb831390062c191f",
"scripts/support/capture-refraction-shaders.mjs": "b534c8ce32b04aed1b2b9f7e35997b8d3b7d7b509726436d7201aa684a15abba",
"scripts/support/check-refraction-float32.mjs": "783fce5a26926801b490b468e3276f103f17f4a7036a020718b89ad62000319e",
"scripts/support/export-corner-preview.mjs": "dd86065eee645b0411ea3adfb5c04ec9a8b840136be720763c9dff8ca5f8a6a8",
"tests/fixtures/landscape.png": "b21cd0cea273d266d71b4c596d207ef859161c61eb15e7d3570c4d1445ba1ff0",
"tests/fixtures/portrait.png": "7ea3ad8bf3b7758745fbc3f8ef87ae8b156f3db38443ac3bab8b7c59d8c8f9a9",
"server/migrations/001-initial.sql": "6c33866ec2d5a5d02f85f0df17cd83ade64444efef21859da66626c4c5284967",
"dist/assets/cutout.worker-D417j0Nw.js": "c064073206e1c00b85b1fa24d2ce1cf4f4da69473ce1abcdc9c81ffa8e44b6a0",
"dist/assets/index-CAEr_JzP.css": "7130f48d6baf3539ff72901335aa071ed2fda81c12d290e031ca214b336405d5",
"dist/assets/reference2-6y7JVIce.png": "49253f44fb9af431e4a989121d77ff0547d0d5d1d19eb3193e59f9b205e166e7",
"dist/assets/reference1-D4rK2azX.png": "b49b9b98bcf6021ffac1deb42b01a3ac71bb9d785386564eaa6b07e45a73eda1",
"dist/assets/reference1-BphJMCNh.js": "daad4d741dfc516676c3a5b3a0ae07bb7f30085b66f2b67ba042852859316839",
"dist/assets/aiDepth.worker-BZp93h00.js": "717267733e29439f480b48cb809a5017074d7177ae6f65c4e009dfb9376e6542",
"dist/assets/reference2-DHNjbprc.js": "35404847b61cb2c846176741412a1599cdd740eda5cdf35a5501ee848191d15e",
"dist/assets/index-BYcEjj7E.js": "e11eacbe19cdd306adf68c4223a3f9b9b99cb874822bce6a79bc23a75131eb0a",
"dist/assets/CutoutPreparation-9rCu4swh.js": "26cce73c418bf163a42d2e27b94b10556121503a923f796a77e1c3cfd0a4a1ba",
"dist/assets/OriginalGlassRenderer-DLs_logl.js": "f584dc3549be15ccdd99218e23b83e97391abe8b11e2e683adb5b4a04632b0f5",
"dist/assets/PhysicalGlassComparison-B37yDBlH.js": "62f49bc47fa545d8e3783e93c721a9b73b49c596978fcffb8049b36b9e455667",
"dist/fonts/BMKkubulim.woff": "dc1e920bfdc5fd3b90189687e3f1ec3335e7a009015bca11d5b2348c4f4257ec",
"dist/licenses/Threejs-MIT.txt": "4c40a1ef62450b857c3b2aaf294936304cd552d965fbcd9d32d4c5bcf4ba4454",
"dist/licenses/HuggingFace-Jinja-MIT.txt": "d6e7b19451c1b3d1d66353bb4b1274138953b3bfd9ae60c56e77b1f548796982",
"dist/licenses/Driverjs-MIT.txt": "ec3ce3a08736fefd6a03a6d5b52b0705e6919fe06de9d7bd3fb63dcfb492d76d",
"dist/licenses/ONNX-Runtime-ThirdPartyNotices.txt": "143764b952fdb1a7c69ce653bfba74a7744d6a8a573bfb73e235fba356c83de3",
"dist/licenses/Apache-2.0.txt": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
"dist/licenses/ONNX-Runtime-MIT.txt": "2f07c72751aed99790b8a4869cf2311df85a860b22ded05fa22803587a48922c",
"dist/licenses/BMKkubulim-OFL.txt": "169d46b6a1c2f6911e4ab47a02429712fb13056d113b28641bfd30f1890861a1",
"dist/assets/samples/dog3.jpg": "bf28194cd5fc710daad57de5ffed26debc82acc29dafebae35be391302899351",
"dist/assets/samples/dog5.jpg": "96aaf29ee2b7e2674a4010c48c954f22ec8a7fd015a810238836e0de93d12787",
"dist/assets/samples/dog2.jpg": "3ce13e277857a170c54742f0142e88d64086d7aa4297b6d082979d903d68c535",
"dist/assets/samples/dog4.jpg": "fbeb1b3784d30b7e124048d5dc842280142cbd0ec190ca9a65e6c9dd2eb64a7e",
"dist/assets/samples/dog1.jpg": "0fd66828d61bbe523507965ec78924be13c4768059a5175f6bbf384cf11386c2",
"dist/assets/window/grandma-house-surround.glb": "3008b5a3af10b7987b335b00b92eb28c34ca585a7c36f9b33ea9e1c63fb10661",
"dist/assets/window/grandma-wood-sash.glb": "a524a0cacf180ded8e093e18350909da3c615a5335c45851c697113ad3b0b84c",
"dist/models/u2netp/LICENSES.txt": "8b5a8937086e2b62183d6bd45c475a0b335537ead8e785286632e71d33825725",
"dist/models/u2netp/manifest.json": "b53e25a84ff25ec51de295067772a15494c9267e7233ec7ce9b26ce1c8e1a2a4",
"dist/models/u2netp/model.onnx": "309c8469258dda742793dce0ebea8e6dd393174f89934733ecc8b14c76f4ddd8",
"src/cutout/cutout.worker.ts": "77e9c152077ca62848e705a4cff2cba2908fbf86857bd0a384374105a747c0df",
"src/cutout/CutoutPreparation.ts": "c348268e58fcc1564db390435add9fc891e2e1a63f249505abba99ffbf62eaf8",
"src/cutout/types.ts": "b67b05aef29185376d6c29ebe97ef2353ed0c32539dbc6627dc00da0e74d1464",
"src/cutout/CutoutMask.ts": "092d72cf2ddc4d9ae9417210221a0a05e97ffacec728e5f6a293f2d55518da14",
"src/cutout/U2NetPreprocess.ts": "43884223419d87b28dafdce66e28256e5c970a8c80e3ba55a5d4b7dff7963440",
"src/cutout/CutoutProvider.ts": "d6783a4cc4480e3d49ae9f90cae6c961d0f7119138339b2b0d7ccd8085166845",
"src/depth/StaticDepthProvider.ts": "87aeda3148edf695babccf1bdf3af7f4487332f7aea5ea6a46bd428b691a0036",
"src/depth/DepthInspection.ts": "52e4a88bf41046ad4cfe6dc23c0ce951ce7e0be92e80d581257ad23e787dc2b5",
"src/depth/DepthMapLoader.ts": "ceb0edff78bf39de079174eb3cba6798238f003c4710c8aa4d948ac976ad8a6d",
"src/depth/AiDepthProvider.ts": "e37fee06480b309432faa318702ef6815bfc3c3f3709afdd5fdedb80e14c6fd7",
"src/depth/DepthDebug.ts": "841343e79fcb0e96992a3ac7b60e6c6830c11db7a9715d7eb8d3eef1c070e648",
"src/depth/DepthProcessor.ts": "53ce822e344f4a853ca17782d81408091aa5fd2004c63fe69a4277fcb9c84ffd",
"src/depth/aiDepth.worker.ts": "afa2e42a88c1806264618ab603035ab2484dc96f64559ae7c4ef390988c9273f",
"src/state/PetName.ts": "b3377ed0bf099f8c531ce5ddce94a72a7b8ceb584518b7b239288621bfee830f",
"src/constants/depth.ts": "085e6dfe3a5a502be05649c02cc970b12268eb369b75cd8322f973877d187d93",
"src/constants/glass.ts": "a9d6b8ee9aa568eb779fd8d7fe420711b1c993ccceff7f93419fded80cca8b66",
"src/input/GyroController.ts": "456be70412a11415ebff570d7e7bbebf0e9967c1b5b1534ba3cc92f285d6cd0f",
"src/input/PhotoSnapshot.ts": "094df411553cff6c62bee313fc8032ab0c4cb9a9c18089a6651278a9a372b790",
"src/input/ImagePaste.ts": "04113265e8b0b6d13e28318b0b2c9ccf02df8e7b1af03360ae8a7001c094b75e",
"src/input/WindowSlideController.ts": "831c8c4154557e4baf30bd9563e6eb163ccd287849b0f1a163a5932c1a78261d",
"src/input/CameraController.ts": "b9fc7abc9441dc0af937eec68d42db517001709f1e8b3c877fea7713354634f6",
"src/input/CameraComposition.ts": "9a80d406dcaf0b120a42e1f1c8dd507553d492d28e1f5f97b3118e13d88d5365",
"src/input/PhotoMetadata.ts": "e815498f65a5f23978c9c734d5364bfeaf411cbf47c92f4131238e23455c4efb",
"src/input/SceneZoomController.ts": "e548ff6fb95014cfaf219e170dc740e6bbcbff840c3cd457ecbe65a9f840796d",
"src/input/PointerController.ts": "2abfc465cd9d7012094815e07d6f5fe9b7c1e1a7709084817b9cb0ecc1f27ab7",
"src/input/MediaLoader.ts": "bf4d191ee3fcd116be943c6b6c41f1979f9cbbf0dc95a5d0b03afaa7b4a0f21e",
"src/input/WorkingPhotoImage.ts": "e700686a95985102281e8847beb426024e320df54100f3f2003d2e59251cb11f",
"src/shaders/region-palette.frag": "414d12582ab598c38f1b431056117125eb05c46706d834dc2bde46a8f7f3302c",
"src/shaders/petDepth.vert": "00f653dcec9d9435a5ee630b50f4ab3a568d255f0bafe74f290be5b0266daf48",
"src/shaders/glassScreen.vert": "8f2938072df24f26d087484ff3d2f2e8affb7fd40dc9c68bbd7f4690c4c7245f",
"src/shaders/petDepth.frag": "c1c6b07294adf7a6504a4a9a3edfd26cbc0199946774d988107173d8e453e52d",
"src/shaders/reeded.frag": "7956af6774777e3ef97e8e07d485a91ff5c5a2da53a8384c83d03fed4565a3e3",
"src/shaders/common.vert": "5a319aa462ca313decd834bc136835f78ed5e32d094b7343af7e8a3d3d12946c",
"src/shaders/frosted.frag": "7e4c66c9ed2dfffc49480d8ea0d93cbb409b46aa61fff637b793dda974cf15bc",
"src/shaders/region-history.frag": "44676af542f25ae786c34a86ca85427a910509b34460c57cb48f5f2c348dce34",
"src/shaders/mosaic.frag": "02b533a829508e400da4e4cebc74a5540c204745e3fa250a69366199ba0f3175",
"src/objects/ExperimentalCourtyard.ts": "9662075ea4e9a3875d754a05105f27254bc545477083e95abc53496c5fdbcdf0",
"src/objects/WindowFinish.ts": "b0bae9fb033cbe413ff7198000814a35363dfbd0b99eea04f0f025dc55fd37e3",
"src/objects/PetDepthMesh.ts": "6199a07a3ce585a2f8392ce67937710a44b5a2b6a8b10789e7dbe8052ebb462b",
"src/objects/BlenderHouseSurround.ts": "71d05c3782183152ebb4e271fa44ebc41586cea8a525c4ef23cd965a094d925a",
"src/objects/PeekingComposition.ts": "c47ebd0d588a12134140b5ff66a8bfcaa019dba19d6418d321367a42fe5f85c0",
"src/objects/RoomComposition.ts": "19f613777f47728871cd9df439b306ebce0e39c8dcff93b82d92ab9a55902872",
"src/objects/PeekingDoorGrid.ts": "734a70cc0177debd1dbe2b7b9cc8293c9ed0c5ccab50f2fd7b5c707519d573a6",
"src/objects/WindowReveal.ts": "0f9acb54dfc900a5e60bbc74d6fc64b1b447340a1732c4eda8e112ac4849ead9",
"src/objects/BlenderWindowFrame.ts": "ea13f446b079b04d17a6fbf61e53dee5fd0f094d97232e20f23bde843e2daa5e",
"src/analytics/ArrivalTracker.ts": "924cfc3064bb1252a2034ec795c5ad121fa03462666911ba2a1a996d1229ddef",
"src/analytics/Identity.ts": "2be8081d5b94d3a796857616793252b438b0daf660132cd3a36934e27bbbf10b",
"src/analytics/Transport.ts": "44db06c4014a3666d757bc4751ec4051a61f722fa824d0d7c60747b7212fb587",
"src/types/depth.ts": "0e30ce168d303cb24120312809a87a156a40331b90690ff490d0f2a85e15b987",
"src/types/photo.ts": "2db604c87ee26b2760e05f912f5e3772561fb007be7dde88dc9bd9ada258df96",
"src/rendering/PhysicalGlassComparison.ts": "24517dfe84eb56bbf8955041526907c828452bfcd05aef90338e01b5a6cea1cd",
"src/rendering/SceneExport.ts": "9d18b4d4c800bd4d12611ec46bc6b4107c1a3ecad3b8924947471312356b804d",
"src/rendering/MeshRefraction.ts": "d5d1506fd5fc66b133fc50ce97e1117f74f463450122fb2f73709e3ac92cf553",
"src/rendering/OriginalGlassRenderer.ts": "23aba0fe89b987cf1795b885ca2832a45661d237d4e9f88593c68abdedea9b08",
"src/rendering/ExportPixels.ts": "51cde0a03f259cb217211a95a3fe7e4090e4f0dca38cb440adea21874620892d",
"src/rendering/ComparisonLayout.ts": "54337aee7476ecfd5f534d8da3dcbae1a72c07de0b348b698ba9c91d7a46ddd1",
"src/rendering/RenderDemand.ts": "d0f08678ac9dc37076e378f22242be68ac1a08e09e9b11e52c08a4d4e17d9a19",
"src/rendering/PatternedGlassGeometry.ts": "4e42ecd1622be62e2179aae6c2695dacd934e0cfa14a3eb9de6002ec896931be",
"src/rendering/MeshGlassRenderer.ts": "315090edd7670bf453943d98b0bc405db3b8c7fb9a553bdcdda83368469a357f",
"src/rendering/ComparisonMetrics.ts": "b3839dd96d3a7701c6261dfb77baf442c7f299cf7e3706696dd22a997e6edb76",
"src/ui/ExportUI.ts": "1f3dfc40ac64e36425b2c9b3f6c6f5e8b095b3a3998395bd3467e3a6031866af",
"src/ui/PeekingExperimentUI.ts": "434ba00f64187f3b4da874130f812c004a2cc75be28a4f702291c612ceb13e94",
"src/ui/SceneHints.ts": "44ad4fb1133583ff574eb5b6ab0135ba3822fd4dfc4bc92136f20ad09fb5fd82",
"src/ui/EntryNotices.ts": "ababe4b8ffa25e0a981521c5370eb75a680b5b6d6edaab4fcc2e1df579af4435",
"src/ui/RenderRecovery.ts": "582ba3bf3856f3aac285335297e7d1fa8c0f8c93c9a7449e9bb39bd8c6214462",
"src/ui/ExperienceUI.ts": "3ddffc88ec7b2084b45adfb5e208def5d1d0c95afee3be05f546e2d13814883a",
"src/ui/PhotoBoothUI.ts": "39f6ce557b3c9de581f58fb9c7c098bbaf9cee829fc840bef7923633601b1cc2",
"src/ui/peeking-experiment.css": "7c098babbe0e0d6141e3049a4e089e7cfcd6b8a0472b26eeb7153276553c4bcb",
"src/ui/ArrivalCount.ts": "8629fed45610eb22f4537c7c1cecf0d935b6458f123cad451152617cd6abfdb0",
"src/ui/FirstGuide.ts": "f132f3504feebd8ee0539e7d6929f98b28815ca0edbd7f264d9d5cb1881871fc",
"src/ui/themes.ts": "5ce403537c6f99a35c1982c61226494d957b0642021405da0a922398177fde48",
"src/ui/MobileDevice.ts": "e3f977cd5d0139f5c581a1cf9c0c60e0cc6dd242373dd27936d1f504f780688f",
"README-서버실행.md": "713e1657a2cfbe49d0787ba90d211b3776604e9403dbb483ee9db48fcee60aac",
"scripts/restore-source-assets.mjs": "e8613c9414f1257ace9d08d5fcbda38519a8e5239d493974a70177d45e942a3b"
}
}
+17
View File
@@ -0,0 +1,17 @@
# Future server configuration examples
These files are templates only. No service, account, directory outside the workspace,
firewall, proxy, DNS, or production configuration has been created or changed.
1. Review the target OS/CPU and install the pinned Node 24 runtime.
2. Install source dependencies using the lockfile and build with `npm run build:server`.
3. Choose an existing or separately approved service account and private durable data directory.
4. Adapt `arrival.env.example`; production origin must be the approved real HTTPS origin.
5. Adapt `behind-glass.service.example` only if systemd is the approved process manager.
Check the actual Node executable path; `/usr/bin/node` is only an example.
6. Configure HTTPS and access controls separately after approval. Do not publish the existing
private sample/reference images more broadly than their approved owner-only scope.
7. Follow `docs/arrival-backend-operations.md` for backup, restore, metrics, and migration checks.
The DB must remain outside source, build, and public roots. Do not package, upload,
or commit a live database, WAL/SHM, backups, credentials, or node_modules.
+7
View File
@@ -0,0 +1,7 @@
# Example only. Do not place the active environment file in dist/public.
# Replace paths and origin after the target server is reviewed.
NODE_ENV=production
HOST=127.0.0.1
PORT=3000
PUBLIC_ORIGIN=https://your-approved-host.example
DB_PATH=/var/lib/behind-glass/analytics.sqlite
+20
View File
@@ -0,0 +1,20 @@
# Uninstalled example for a future Linux/systemd server.
# Confirm Node path, service account, directories, and permissions before use.
# Build with npm run build:server before starting this unit.
[Unit]
Description=Behind Glass static app and arrival API
After=network.target
[Service]
Type=simple
User=behind-glass
Group=behind-glass
WorkingDirectory=/opt/behind-glass/current
EnvironmentFile=/etc/behind-glass/arrival.env
ExecStart=/usr/bin/node server/index.mjs
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
+49 -2
View File
@@ -1,4 +1,4 @@
Pet Behind Glass · AI depth third-party notices Pet Behind Glass · third-party notices
Depth Anything V2 Small Depth Anything V2 Small
Model: onnx-community/depth-anything-v2-small-ONNX Model: onnx-community/depth-anything-v2-small-ONNX
@@ -10,8 +10,55 @@ 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
The full Apache License 2.0 text is provided in licenses/Apache-2.0.txt. The full Apache License 2.0 text is provided in licenses/Apache-2.0.txt.
배민 꾸불림체 / BM Kkubulim
Copyright: Woowa Brothers Corporation
Official font page: https://www.woowahan.com/fonts
Original font: https://woowahan-cdn.woowahan.com/static/fonts/BMKkubulim.otf?d35cc1bf
Official licensing notice: https://www.woowahan.com/fonts/license
License: SIL Open Font License 1.1
The complete font is self-hosted in fonts/BMKkubulim.woff. This is lossless WOFF
packaging of the official OTF, with all original font tables byte-preserved,
no subsetting, and no added WOFF metadata. The font is not sold separately.
The full copyright, reserved font names, intellectual-property notice and OFL
text are provided in licenses/BMKkubulim-OFL.txt.
Three.js 0.169.0
Project: https://github.com/mrdoob/three.js
License: MIT
Full copyright and license: licenses/Threejs-MIT.txt
ONNX Runtime Web / Common
Project: https://github.com/microsoft/onnxruntime
Source revision recorded by the installed web runtime: 8d85527a0
License: MIT, Copyright (c) Microsoft Corporation
Full license: licenses/ONNX-Runtime-MIT.txt
Upstream third-party notices: licenses/ONNX-Runtime-ThirdPartyNotices.txt
The upstream notice collection covers multiple build targets; inclusion here does
not mean every listed component is shipped in this browser build.
Hugging Face Jinja 0.5.10
Bundled by Transformers.js. License: MIT
Full copyright and license: licenses/HuggingFace-Jinja-MIT.txt
Hugging Face Tokenizers 0.2.0
Bundled by Transformers.js. License: Apache License 2.0
Full license: licenses/Apache-2.0.txt
Retained experimental U2Netp model / cutout resources
Model and source details, Apache-2.0 and rembg code MIT text:
models/u2netp/LICENSES.txt
The experiment is not exposed in the consumer settings. These notices are
retained because the resources remain in the application distribution.
Driver.js 1.9.0
Project: https://driverjs.com/
Copyright (c) Kamran Ahmed
License: MIT
Full copyright and permission text: licenses/Driverjs-MIT.txt
File diff suppressed because one or more lines are too long
+535
View File
@@ -0,0 +1,535 @@
import{a as e,c as t,d as n,f as r,h as i,i as a,l as o,m as s,n as c,o as l,p as u,r as ee,u as d}from"./index-BYcEjj7E.js";var f=`uniform sampler2D uSceneTexture,uSceneDepth;
uniform sampler2D uProtectionDepthMap;
uniform mat4 uContentViewProjection,uContentInverseViewProjection;
uniform mat4 uPetWorldToLocal;
uniform vec3 uContentCameraPosition,uLightPosition,uBackgroundColor;
uniform float uGlassZ,uSourceZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlossStrength;
uniform vec2 uViewOffset,uPetSize,uPaneSize;
uniform vec2 uDepthEvidenceRange,uDepthCorrection;
uniform float uEnvironmentStrength;
uniform vec4 uGobangSurface;
uniform float uGobangEnvironmentStrength;
uniform float uGobangBevelRound;
uniform vec4 uPaneRect;
uniform sampler2D uRegionPalette;
uniform sampler2D uRegionAtlas,uRegionHistory;
uniform vec2 uRegionAtlasSize,uRegionGridSize,uRegionPaletteSize;
uniform float uRegionStrength,uRegionAvailable;
varying vec2 vUv;
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;}
vec4 sampleScene(vec2 uv){return texture2D(uSceneTexture,clamp(uv,0.,1.));}
vec4 samplePane(vec2 pane){return sampleScene(projectUv(vec3((pane-.5)*uPaneSize,uGlassZ)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2 hash2(vec2 p){return vec2(hash(p),hash(p+vec2(19.19,73.73)));}
vec2 cellBlurStep(vec2 cell,vec2 cellSize,float style){
if(style<=.5)return cellSize*mix(.006,.018,hash(cell+vec2(5.)));
return cellSize*mix(.006,.012,hash(cell+vec2(5.)));
}
vec4 sampleCell(vec2 pane,vec2 sourceUv,float strength,vec2 cell,vec2 cellSize,float style){
vec2 center=mix(pane,sourceUv,strength);
vec2 blurOffset=strength*cellBlurStep(cell,cellSize,style);
vec4 color;
if(style<=.5){
color=samplePane(center)*.6;
color+=samplePane(center+vec2(blurOffset.x,0.))*.1;
color+=samplePane(center-vec2(blurOffset.x,0.))*.1;
color+=samplePane(center+vec2(0.,blurOffset.y))*.1;
color+=samplePane(center-vec2(0.,blurOffset.y))*.1;
}else{
color=samplePane(center)*.4;
color+=samplePane(center+vec2(blurOffset.x,0.))*.15;
color+=samplePane(center-vec2(blurOffset.x,0.))*.15;
color+=samplePane(center+vec2(0.,blurOffset.y))*.15;
color+=samplePane(center-vec2(0.,blurOffset.y))*.15;
}
return color;
}
float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;}
vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
float softLimit(float value,float limit){return value*limit/(limit+abs(value));}
float surfaceCurvature(){float curvature=clamp(uCurvature,-1.,1.),excess=max(curvature-.65,0.);return curvature-(.25/.35)*excess*excess;}
float signedCurvatureScale(){return clamp(surfaceCurvature()/.65,-1.25,1.25);}
float regionDistance(vec3 a,vec3 b){vec3 d=a-b;float y=dot(d,vec3(.299,.587,.114));return dot(d,d)+.35*y*y;}
float regionLuminance(vec3 c){return dot(c,vec3(.299,.587,.114));}
vec3 regionColor(vec2 cell,float index){vec2 uv=(vec2(cell.x*5.+index,cell.y)+.5)/uRegionPaletteSize;return texture2D(uRegionPalette,uv).rgb;}
float nearestRegion(vec3 color,vec2 cell){float best=0.,distance=regionDistance(color,regionColor(cell,0.));for(int i=1;i<4;i++){float candidate=regionDistance(color,regionColor(cell,float(i)));if(candidate<distance){distance=candidate;best=float(i);}}return best;}
float nearestRegion(vec3 color,vec3 p0,vec3 p1,vec3 p2,vec3 p3){float best=0.,distance=regionDistance(color,p0);float candidate=regionDistance(color,p1);if(candidate<distance){distance=candidate;best=1.;}candidate=regionDistance(color,p2);if(candidate<distance){distance=candidate;best=2.;}candidate=regionDistance(color,p3);if(candidate<distance)best=3.;return best;}
vec3 selectRegion(vec3 p0,vec3 p1,vec3 p2,vec3 p3,float index){if(index<.5)return p0;if(index<1.5)return p1;if(index<2.5)return p2;return p3;}
vec3 regionAtlasColor(vec2 cell,vec2 texel){vec2 uv=(cell*8.+clamp(texel,vec2(0.),vec2(7.))+.5)/uRegionAtlasSize;return texture2D(uRegionAtlas,uv).rgb;}
bool reconstructScenePoint(vec2 sceneUv,out vec3 worldPoint){
if(sceneUv.x<0.||sceneUv.x>1.||sceneUv.y<0.||sceneUv.y>1.)return false;
float depth=texture2D(uSceneDepth,sceneUv).x;
if(depth>=.999999)return false;
vec4 clip=vec4(sceneUv*2.-1.,depth*2.-1.,1.);
vec4 world=uContentInverseViewProjection*clip;
if(abs(world.w)<.0001)return false;
worldPoint=world.xyz/world.w;
return true;
}
float regionSurfaceValid(vec2 sceneUv,vec3 glassHit){
vec3 worldPoint;
if(!reconstructScenePoint(sceneUv,worldPoint))return 0.;
float axialGap=glassHit.z-worldPoint.z;
if(axialGap<=.0001)return 0.;
return 1.;
}
float regionProtectionWeight(vec2 sceneUv){
vec3 worldPoint;
if(!reconstructScenePoint(sceneUv,worldPoint))return 0.;
vec3 localPoint=(uPetWorldToLocal*vec4(worldPoint,1.)).xyz;
if(localPoint.x<-.5||localPoint.x>.5||localPoint.y<-.5||localPoint.y>.5)return 0.;
vec2 sourceUv=localPoint.xy+.5;
float sourceDepth=texture2D(uProtectionDepthMap,sourceUv).r;
if(uDepthEvidenceRange.y-uDepthEvidenceRange.x<.0001)return 0.;
return smoothstep(uDepthEvidenceRange.x,uDepthEvidenceRange.y,sourceDepth);
}
// Screen-depth refinement of an artistic ray. Occluded surfaces are unavailable;
// retain the original sample if the visible-depth solve cannot converge.
vec3 refineSceneHit(vec3 glassHit,vec3 ray,vec3 artisticHit,float cellWorld){
vec3 planeHit=glassHit+ray*((uSourceZ-glassHit.z)/safeZ(ray.z));
vec3 origin=glassHit+vec3(artisticHit.xy-planeHit.xy,0.);
vec3 hit=artisticHit,surface;
for(int iteration=0;iteration<3;iteration++){
if(!reconstructScenePoint(projectUv(hit),surface))return artisticHit;
vec3 localPoint=(uPetWorldToLocal*vec4(surface,1.)).xyz;
if(any(greaterThan(abs(localPoint.xy),vec2(.5)))||surface.z>=uGlassZ-.0001)return artisticHit;
hit=origin+ray*((surface.z-origin.z)/safeZ(ray.z));
}
if(!reconstructScenePoint(projectUv(hit),surface))return artisticHit;
if(surface.z>=uGlassZ-.0001)return artisticHit;
float confidence=1.-smoothstep(cellWorld*.05,cellWorld*.25,abs(hit.z-surface.z));
// Compare at the same reference Z, so only the projected sampling position changes.
vec3 viewRay=hit-uContentCameraPosition;
vec3 projected=uContentCameraPosition+viewRay*((uSourceZ-uContentCameraPosition.z)/safeZ(viewRay.z));
vec2 delta=projected.xy-artisticHit.xy;
float limit=cellWorld*uDepthCorrection.y;
delta*=min(1.,limit/max(length(delta),.00001));
return artisticHit+vec3(delta*uDepthCorrection.x*confidence,0.);
}
#ifdef REGION_ATLAS
float regionAtlasEvidence(vec2 sceneUv,vec3 color,vec3 glassHit){
if(regionSurfaceValid(sceneUv,glassHit)<=0.)return 0.;
return regionProtectionWeight(sceneUv)*(1.-smoothstep(.10,.20,regionLuminance(color)));
}
vec4 regionAtlasProbe(vec3 sceneHit,vec2 worldOffset,vec3 glassHit){
vec2 probeSceneUv=projectUv(sceneHit+vec3(worldOffset,0.));
vec4 probe=sampleScene(probeSceneUv);
return vec4(probe.rgb,regionAtlasEvidence(probeSceneUv,probe.rgb,glassHit));
}
vec4 regionAtlasScore(vec3 centerColor,float centerEvidence,vec3 probeColor,float probeEvidence){
float evidenceGain=probeEvidence-centerEvidence,darkGain=regionLuminance(centerColor)-regionLuminance(probeColor),blend=0.;
if(probeEvidence>.05&&evidenceGain>.02&&darkGain>.015)blend=min(.4,evidenceGain*2.)*min(1.,max(0.,darkGain*4.));
return vec4(blend,probeEvidence,0.,0.);
}
vec3 regionAtlasRepresentative(vec3 centerColor,float centerEvidence,vec3 sceneHit,vec3 glassHit,out float evidence){
vec3 bestColor=centerColor;float bestEvidence=centerEvidence,bestBlend=0.;
vec2 probeStep=uPaneSize/(uRegionGridSize*8.)*.25;
vec4 probe,score;
probe=regionAtlasProbe(sceneHit,vec2(probeStep.x,0.),glassHit);score=regionAtlasScore(centerColor,centerEvidence,probe.rgb,probe.a);if(score.x>bestBlend){bestColor=mix(centerColor,probe.rgb,score.x);bestEvidence=max(centerEvidence,probe.a);bestBlend=score.x;}
probe=regionAtlasProbe(sceneHit,vec2(-probeStep.x,0.),glassHit);score=regionAtlasScore(centerColor,centerEvidence,probe.rgb,probe.a);if(score.x>bestBlend){bestColor=mix(centerColor,probe.rgb,score.x);bestEvidence=max(centerEvidence,probe.a);bestBlend=score.x;}
probe=regionAtlasProbe(sceneHit,vec2(0.,probeStep.y),glassHit);score=regionAtlasScore(centerColor,centerEvidence,probe.rgb,probe.a);if(score.x>bestBlend){bestColor=mix(centerColor,probe.rgb,score.x);bestEvidence=max(centerEvidence,probe.a);bestBlend=score.x;}
probe=regionAtlasProbe(sceneHit,vec2(0.,-probeStep.y),glassHit);score=regionAtlasScore(centerColor,centerEvidence,probe.rgb,probe.a);if(score.x>bestBlend){bestColor=mix(centerColor,probe.rgb,score.x);bestEvidence=max(centerEvidence,probe.a);bestBlend=score.x;}
evidence=bestEvidence;
return bestColor;
}
#endif
void evaluateCell(vec2 cell,vec2 local,vec2 grid,vec2 cellSize,vec2 cellDimensions,float cellWorld,float opticalTravel,float style,out vec2 affineUv,out mat2 transform,out vec2 curvedSlope,out vec2 facetSlope){
vec2 random=hash2(cell),widths;
if(style>.5){
widths=mix(vec2(.82),vec2(1.12),hash2(cell+vec2(11.4,9.8)));
float angle=(random.x-.5)*.08,c=cos(angle),s=sin(angle);
transform=mat2(widths.x*c,-widths.y*s,widths.x*s,widths.y*c);
float centerJitter=.16;
vec2 sourceCenter=(cell+.5)/grid+(random-.5)*centerJitter*cellSize;
affineUv=sourceCenter+transform*(local*cellSize);
}else{
// Default style keeps a single continuous image footprint across cells.
transform=mat2(1.);
affineUv=(cell+.5)/grid+local*cellSize;
}
// Positive height bulges toward the observer (+Z); negative height is concave.
float curvature=clamp(uCurvature,-1.,1.),surfaceCurvatureValue=surfaceCurvature();
vec2 q=clamp(2.*local,-1.,1.),q2=q*q,q3=q2*q,b=1.-q2*q2;
if(style>.5){
float amplitude=mix(.92,1.08,hash(cell+vec2(41.7,17.3))),height=.009*cellWorld*surfaceCurvatureValue*amplitude;
vec2 skew=(hash2(cell+vec2(23.8,61.2))-.5)*.28;float asym=1.+dot(skew,q);
vec2 dhdq=height*vec2(-4.*q3.x*b.y*asym+b.x*b.y*skew.x,-4.*q3.y*b.x*asym+b.x*b.y*skew.y);
vec2 grad=-2.*dhdq/cellDimensions;
curvedSlope=grad+(random-.5)*.012;facetSlope=(random-.5)*.012;
}else{
curvedSlope=.30*cellDimensions/max(abs(opticalTravel),.6)*signedCurvatureScale()*sin(6.2831853*local);
facetSlope=vec2(0.);
}
}
#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(){
vec2 screenUv=vUv;
#ifdef REGION_ATLAS
vec2 atlasPixel=floor(vUv*uRegionAtlasSize),atlasCell=floor(atlasPixel/8.),atlasLocal=(mod(atlasPixel,8.)+.5)/8.;
vec2 atlasPaneUv=(atlasCell+atlasLocal)/uRegionGridSize;
screenUv=projectUv(vec3((atlasPaneUv-.5)*uPaneSize,uGlassZ));
#endif
if(uStrength<=.001){
#ifdef REGION_ATLAS
gl_FragColor=vec4(sampleScene(screenUv).rgb,0.);
#else
gl_FragColor=sampleScene(screenUv);
#include <colorspace_fragment>
#endif
return;}
vec4 f=uContentInverseViewProjection*vec4(screenUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid;float style=clamp(uMosaicStyle,0.,2.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
#ifdef REGION_ATLAS
paneUv=atlasPaneUv;grid= uRegionGridSize;gridUv=paneUv*grid;cell=floor(gridUv);local=fract(gridUv)-.5;cellSize=1./grid;cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
#endif
if(paneUv.x<0.||paneUv.x>1.||paneUv.y<0.||paneUv.y>1.){
#ifdef REGION_ATLAS
gl_FragColor=vec4(uBackgroundColor,0.);
#else
gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
#endif
return;}
#ifndef REGION_ATLAS
if(style>1.5){
gl_FragColor=renderGobang(i,gh,paneUv,grid,cellWorld);
#include <colorspace_fragment>
return;
}
#endif
vec2 cellDimensions=uPaneSize/grid;
float opticalTravel=-max(abs(uSourceZ-gh.z),.6);
vec2 affineUv,curvedSlope,facetSlope;mat2 transform;
evaluateCell(cell,local,grid,cellSize,cellDimensions,cellWorld,opticalTravel,style,affineUv,transform,curvedSlope,facetSlope);
if(style<=.5){
vec2 smoothFacet=.003*vec2(sin(6.2831853*paneUv.x),sin(6.2831853*paneUv.y));
curvedSlope+=smoothFacet;facetSlope=smoothFacet;
}else{
vec2 edge=.5-abs(local),neighborSign=mix(vec2(-1.),vec2(1.),step(vec2(0.),local));
float normalBlend=.16;
vec2 weights=1.-smoothstep(vec2(-normalBlend),vec2(normalBlend),edge);
float xSplitWeight=weights.x*(1.-weights.y),ySplitWeight=(1.-weights.x)*weights.y;
float diagonalSplitWeight=weights.x*weights.y,centerSplitWeight=(1.-weights.x)*(1.-weights.y);
if(weights.x>0.||weights.y>0.){
vec2 neighborAffine,neighborCurved,neighborFacet;mat2 neighborTransform;
vec2 xWeight=vec2(xSplitWeight,0.);
vec2 yWeight=vec2(0.,ySplitWeight);
affineUv*=centerSplitWeight;transform*=centerSplitWeight;curvedSlope*=centerSplitWeight;facetSlope*=centerSplitWeight;
if(xWeight.x>0.){
vec2 offset=vec2(neighborSign.x,0.);
evaluateCell(cell+offset,local-offset,grid,cellSize,cellDimensions,cellWorld,opticalTravel,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
affineUv+=neighborAffine*xWeight.x;transform+=neighborTransform*xWeight.x;curvedSlope+=neighborCurved*xWeight.x;facetSlope+=neighborFacet*xWeight.x;
}
if(yWeight.y>0.){
vec2 offset=vec2(0.,neighborSign.y);
evaluateCell(cell+offset,local-offset,grid,cellSize,cellDimensions,cellWorld,opticalTravel,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
affineUv+=neighborAffine*yWeight.y;transform+=neighborTransform*yWeight.y;curvedSlope+=neighborCurved*yWeight.y;facetSlope+=neighborFacet*yWeight.y;
}
if(diagonalSplitWeight>0.){
vec2 offset=neighborSign;
evaluateCell(cell+offset,local-offset,grid,cellSize,cellDimensions,cellWorld,opticalTravel,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
affineUv+=neighborAffine*diagonalSplitWeight;transform+=neighborTransform*diagonalSplitWeight;curvedSlope+=neighborCurved*diagonalSplitWeight;facetSlope+=neighborFacet*diagonalSplitWeight;
}
}
}
vec3 nCurved=normalize(vec3(curvedSlope,1.)),nFacet=normalize(vec3(facetSlope,1.));
vec3 straightHit=gh+i*((uSourceZ-gh.z)/safeZ(i.z));
vec3 curvedRay=bend(i,nCurved),curvedHit=gh+curvedRay*((uSourceZ-gh.z)/safeZ(curvedRay.z));
// Keep the artistic Mosaic optical path independent of physical glass-to-photo contact distance.
vec2 rawDelta=(curvedRay.xy/safeZ(curvedRay.z)-i.xy/safeZ(i.z))*opticalTravel;
vec2 worldDelta=vec2(softLimit(rawDelta.x,cellWorld*.55),softLimit(rawDelta.y,cellWorld*.55));
if(style>.5){
vec3 facetRay=bend(i,nFacet);vec3 facetHit=gh+facetRay*((uSourceZ-gh.z)/safeZ(facetRay.z));
vec2 base=facetHit.xy-straightHit.xy,curve=curvedHit.xy-facetHit.xy;
worldDelta=vec2(softLimit(base.x,cellWorld*.08),softLimit(base.y,cellWorld*.08));
vec2 d=curve/cellWorld;
worldDelta+=vec2(.3*d.x/(.3+abs(d.x)),.3*d.y/(.3+abs(d.y)))*cellWorld;
}else{
vec3 referenceI=normalize(gh-vec3(0.,0.,uContentCameraPosition.z)),referenceRay=bend(referenceI,nCurved);
vec2 incidenceDelta=curvedRay.xy/max(-curvedRay.z,.0001)-referenceRay.xy/max(-referenceRay.z,.0001);
vec2 boundedIncidence=incidenceDelta/sqrt(incidenceDelta*incidenceDelta+vec2(.025*.025));
vec2 cellEnvelope=.5+.5*cos(6.2831853*local);
worldDelta+=boundedIncidence*cellEnvelope*cellDimensions*signedCurvatureScale()*.18;
}
vec2 sampleUv=affineUv+transform*(worldDelta/uPaneSize);
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));float edgeTaper=style<=.5?1.25:.75;float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*edgeTaper,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;
vec4 color;vec3 sceneHit=straightHit+vec3(worldDelta*strength,0.);vec2 finalSceneUv=projectUv(sceneHit);
if(style<=.5){
sceneHit=refineSceneHit(gh,normalize(mix(i,curvedRay,strength)),sceneHit,cellWorld);
finalSceneUv=projectUv(sceneHit);
}
#ifdef REGION_ATLAS
vec3 atlasCenterColor=sampleScene(finalSceneUv).rgb;
float atlasEvidence=regionProtectionWeight(finalSceneUv)*(1.-smoothstep(.10,.20,regionLuminance(atlasCenterColor)));
#endif
#ifdef REGION_ATLAS
if(style<=.5){
float representativeEvidence=atlasEvidence;
color=vec4(regionAtlasRepresentative(atlasCenterColor,atlasEvidence,sceneHit,gh,representativeEvidence),representativeEvidence);
atlasEvidence=representativeEvidence;
}else{
color=sampleCell(paneUv,sampleUv,strength,cell,cellSize,style);
}
#else
if(style<=.5){
color=sampleScene(finalSceneUv);
}else{
color=sampleCell(paneUv,sampleUv,strength,cell,cellSize,style);
}
#endif
#ifndef REGION_ATLAS
if(uRegionAvailable>.5&&uRegionStrength>.001&&uMosaicStyle<.5){
vec3 p0=regionColor(cell,0.),p1=regionColor(cell,1.),p2=regionColor(cell,2.),p3=regionColor(cell,3.);
float ownLabel=nearestRegion(color.rgb,p0,p1,p2,p3);vec4 votes=vec4(0.);float totalWeight=0.,ownWeight=0.;
vec2 texelCoord=fract(gridUv)*8.-.5,anchor=floor(texelCoord+.5);
for(int y=-1;y<=1;y++)for(int x=-1;x<=1;x++){
vec2 offset=vec2(float(x),float(y)),delta=anchor+offset-texelCoord;
float weight=pow(max(0.,1.-length(delta)/1.5),2.);
if(weight>0.){
vec3 atlasColor=regionAtlasColor(cell,anchor+offset);float label=nearestRegion(atlasColor,p0,p1,p2,p3);
votes+=vec4(step(abs(label-vec4(0.,1.,2.,3.)),vec4(.1)))*weight;totalWeight+=weight;
ownWeight+=step(abs(label-ownLabel),.1)*weight;
}
}
float winningLabel=ownLabel,winningWeight=ownWeight;
if(votes.x>winningWeight){winningLabel=0.;winningWeight=votes.x;}if(votes.y>winningWeight){winningLabel=1.;winningWeight=votes.y;}if(votes.z>winningWeight){winningLabel=2.;winningWeight=votes.z;}if(votes.w>winningWeight){winningLabel=3.;winningWeight=votes.w;}
bool simplify=totalWeight>0.&&winningWeight/totalWeight>.55&&ownWeight/totalWeight<.40;
float radialLabel=simplify?winningLabel:ownLabel;vec3 radialColor=selectRegion(p0,p1,p2,p3,radialLabel);
vec2 historyTexel=clamp(floor(fract(gridUv)*8.),vec2(0.),vec2(7.));
vec2 historyUv=(cell*8.+historyTexel+.5)/uRegionAtlasSize;
vec4 splitHistory=texture2D(uRegionHistory,historyUv);
vec3 splitColor=splitHistory.a>.00001?splitHistory.rgb/splitHistory.a:radialColor;
vec3 candidateColor=mix(radialColor,splitColor,splitHistory.a);
color.rgb=mix(color.rgb,candidateColor,clamp(uRegionStrength*clamp(uStrength,0.,1.)*regionSurfaceValid(finalSceneUv,gh),0.,1.));
}
#endif
#ifndef REGION_ATLAS
color.a=1.;
vec3 v=normalize(uContentCameraPosition-gh),reflected=reflect(-v,nCurved); float lightT=(uLightPosition.z-gh.z)/safeZ(reflected.z); vec2 lightHit=gh.xy+reflected.xy*lightT; vec2 halfSize=vec2(.32,.42)*min(uPaneSize.x,uPaneSize.y); vec2 windowCoord=abs(lightHit-uLightPosition.xy)/halfSize; vec2 windowMask=1.-smoothstep(vec2(.45),vec2(1.15),windowCoord); float surfaceReflectionWeight=mix(.35,1.,edgeWeight); float glossStrength=clamp(uGlossStrength,0.,1.); float spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float specGloss=spec*glossStrength*.12*surfaceReflectionWeight*clamp(uStrength,0.,1.); vec2 broadWindowMask=1.-smoothstep(vec2(.15),vec2(1.75),windowCoord); float broadFacing=pow(max(dot(nCurved,v),0.),2.); float broadReflection=broadWindowMask.x*broadWindowMask.y*broadFacing*glossStrength*.035*surfaceReflectionWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),specGloss+broadReflection); gl_FragColor=color;
// Common, static room environment. Reflected direction varies with the same
// curved normal as transmission; no image relighting or timed sparkle.
float fresnel=.04+.96*pow(1.-clamp(dot(nCurved,v),0.,1.),5.);
float environmentWeight=uEnvironmentStrength*glossStrength*(.35+.65*fresnel)*surfaceReflectionWeight*clamp(uStrength,0.,1.);
vec3 environmentColor=mix(vec3(.10,.13,.18),vec3(.65,.72,.80),smoothstep(-.35,.65,reflected.y));
if(style<=.5)color.rgb=mix(color.rgb,environmentColor,environmentWeight);
gl_FragColor=color;
#include <colorspace_fragment>
#else
color.a=atlasEvidence;
gl_FragColor=color;
#endif
}
`,p=`uniform sampler2D uSceneTexture; uniform mat4 uContentViewProjection; uniform mat4 uContentInverseViewProjection; uniform vec3 uContentCameraPosition,uBackgroundColor; uniform float uSceneZoom; uniform float uGlassZ,uSourceZ,uGridSize,uStrength,uTime; uniform vec2 uSceneOffset,uViewOffset,uResolution,uPetSize,uPaneSize;
varying vec2 vUv;
// Preserve the existing 1x material coordinates as the common projection magnifies and translates.
vec2 patternScreenUv(vec2 uv){return (uv-.5-uSceneOffset*.5)/max(uSceneZoom,1.)+.5;}
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;} vec4 sampleScene(vec2 uv){if(any(lessThan(uv,vec2(0.)))||any(greaterThan(uv,vec2(1.))))return vec4(.005605,.009721,.016807,1.);return texture2D(uSceneTexture,uv);} float hash(float x){return fract(sin(x*91.7)*43758.5453);} float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;} vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
void main(){if(uStrength<=.001){gl_FragColor=texture2D(uSceneTexture,vUv);
#include <colorspace_fragment>
return;}vec2 q=vUv*2.-1.;vec4 fh=uContentInverseViewProjection*vec4(q,1.,1.);float fwDen=abs(fh.w)<.0001?(fh.w<0.?-.0001:.0001):fh.w;vec3 fw=fh.xyz/fwDen;vec3 i=normalize(fw-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;vec2 paneUv=gh.xy/uPaneSize+.5;if(any(lessThan(paneUv,vec2(0.)))||any(greaterThan(paneUv,vec2(1.)))){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}float count=max(1.,uGridSize*uPetSize.x/uPetSize.y),coord=patternScreenUv(vUv).x*count,cell=floor(coord),phase=fract(coord)*6.2831853,ridge=sin(phase+uViewOffset.x*.9),variation=mix(.92,1.08,hash(cell));vec3 r=bend(i,normalize(vec3(-ridge*.34*variation,0.,1.)));vec3 sh=gh+i*((uSourceZ-gh.z)/safeZ(i.z)),bh=gh+r*((uSourceZ-gh.z)/safeZ(r.z));vec2 ru=vUv+projectUv(bh)-projectUv(sh);vec2 uv=mix(vUv,ru,clamp(uStrength,0.,1.));vec4 c=sampleScene(uv);c.a=1.;gl_FragColor=c;
#include <colorspace_fragment>
}
`,m=`uniform sampler2D uSceneTexture; uniform mat4 uContentViewProjection; uniform mat4 uContentInverseViewProjection; uniform vec3 uContentCameraPosition,uBackgroundColor; uniform float uSceneZoom; uniform float uGlassZ,uSourceZ,uGridSize,uStrength,uTime; uniform vec2 uSceneOffset,uViewOffset,uResolution,uPetSize,uPaneSize;
varying vec2 vUv;
// Preserve the existing 1x material coordinates as the common projection magnifies and translates.
vec2 patternScreenUv(vec2 uv){return (uv-.5-uSceneOffset*.5)/max(uSceneZoom,1.)+.5;}
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;} vec4 sampleScene(vec2 uv){if(any(lessThan(uv,vec2(0.)))||any(greaterThan(uv,vec2(1.))))return vec4(.005605,.009721,.016807,1.);return texture2D(uSceneTexture,uv);} float hash(vec2 p){return fract(sin(dot(p,vec2(41.3,17.9)))*15731.743);} float noise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.)),f.x),f.y);} float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;} vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
void main(){if(uStrength<=.001){gl_FragColor=texture2D(uSceneTexture,vUv);
#include <colorspace_fragment>
return;}vec2 q=vUv*2.-1.;vec4 fh=uContentInverseViewProjection*vec4(q,1.,1.);float fwDen=abs(fh.w)<.0001?(fh.w<0.?-.0001:.0001):fh.w;vec3 fw=fh.xyz/fwDen;vec3 i=normalize(fw-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;vec2 paneUv=gh.xy/uPaneSize+.5;if(any(lessThan(paneUv,vec2(0.)))||any(greaterThan(paneUv,vec2(1.)))){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}vec2 st=patternScreenUv(vUv)*(uResolution/max(uResolution.y,1.)),np=st*9.+uViewOffset*.6,nn=vec2(noise(np),noise(np+vec2(23.7)))-vec2(.5);vec3 r=bend(i,normalize(vec3(nn*.32,1.)));vec3 sh=gh+i*((uSourceZ-gh.z)/safeZ(i.z)),bh=gh+r*((uSourceZ-gh.z)/safeZ(r.z));vec2 ru=vUv+projectUv(bh)-projectUv(sh),center=mix(vUv,ru,clamp(uStrength,0.,1.)),rad=vec2(.008)*uSceneZoom*clamp(uStrength,0.,1.);vec4 c=vec4(0.);c+=sampleScene(center+vec2(-rad.x,-rad.y));c+=sampleScene(center+vec2(0.,-rad.y));c+=sampleScene(center+vec2(rad.x,-rad.y));c+=sampleScene(center+vec2(-rad.x,0.));c+=sampleScene(center);c+=sampleScene(center+vec2(rad.x,0.));c+=sampleScene(center+vec2(-rad.x,rad.y));c+=sampleScene(center+vec2(0.,rad.y));c+=sampleScene(center+vec2(rad.x,rad.y));c/=9.;c.a=1.;gl_FragColor=c;
#include <colorspace_fragment>
}
`,h=`varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`,g=`uniform sampler2D uRegionAtlas;
uniform vec2 uRegionAtlasSize,uRegionGridSize;
varying vec2 vUv;
float regionLuminance(vec3 c){return dot(c,vec3(.299,.587,.114));}
float distanceColor(vec3 a,vec3 b){vec3 d=a-b;float y=dot(d,vec3(.299,.587,.114));return dot(d,d)+.35*y*y;}
float sideColorCost(vec4 votes,vec3 target,vec3 c0,vec3 c1,vec3 c2,vec3 c3){return votes.x*distanceColor(c0,target)+votes.y*distanceColor(c1,target)+votes.z*distanceColor(c2,target)+votes.w*distanceColor(c3,target);}
vec4 atlasSampleData(vec2 cell,vec2 local){vec2 uv=(cell*8.+local+.5)/uRegionAtlasSize;return texture2D(uRegionAtlas,uv);}
vec3 atlasSample(vec2 cell,vec2 local){return atlasSampleData(cell,local).rgb;}
float nearestLabel(vec3 color,vec3 c0,vec3 c1,vec3 c2,vec3 c3){float d0=distanceColor(color,c0),d1=distanceColor(color,c1),d2=distanceColor(color,c2),d3=distanceColor(color,c3);if(d0<=d1&&d0<=d2&&d0<=d3)return 0.;if(d1<=d2&&d1<=d3)return 1.;if(d2<=d3)return 2.;return 3.;}
float majorityLabel(vec4 votes){float label=0.,count=votes.x;if(votes.y>count){label=1.;count=votes.y;}if(votes.z>count){label=2.;count=votes.z;}if(votes.w>count)label=3.;return label;}
void main(){
vec2 pixel=floor(gl_FragCoord.xy);float paletteIndex=mod(pixel.x,5.);vec3 cell=vec3(floor(pixel.x/5.),pixel.y,0.);
vec3 darkest=vec3(1.),lightest=vec3(0.);float minLum=2.,maxLum=-1.;
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec3 c=atlasSample(cell.xy,vec2(float(x),float(y)));float l=regionLuminance(c);
if(l<minLum){minLum=l;darkest=c;}if(l>maxLum){maxLum=l;lightest=c;}
}
vec3 third=darkest;float thirdDistance=-1.;
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec3 c=atlasSample(cell.xy,vec2(float(x),float(y)));float d=min(distanceColor(c,darkest),distanceColor(c,lightest));
if(d>thirdDistance){thirdDistance=d;third=c;}
}
vec3 fourth=darkest;float fourthDistance=-1.;
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec3 c=atlasSample(cell.xy,vec2(float(x),float(y)));float d=min(distanceColor(c,darkest),min(distanceColor(c,lightest),distanceColor(c,third)));
if(d>fourthDistance){fourthDistance=d;fourth=c;}
}
vec3 c0=darkest,c1=lightest,c2=third,c3=fourth;
for(int iteration=0;iteration<2;iteration++){
vec3 sum0=vec3(0.),sum1=vec3(0.),sum2=vec3(0.),sum3=vec3(0.);float n0=0.,n1=0.,n2=0.,n3=0.;
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec3 c=atlasSample(cell.xy,vec2(float(x),float(y)));
float d0=distanceColor(c,c0),d1=distanceColor(c,c1),d2=distanceColor(c,c2),d3=distanceColor(c,c3);
if(d0<=d1&&d0<=d2&&d0<=d3){sum0+=c;n0+=1.;}
else if(d1<=d2&&d1<=d3){sum1+=c;n1+=1.;}
else if(d2<=d3){sum2+=c;n2+=1.;}
else{sum3+=c;n3+=1.;}
}
if(n0>0.)c0=sum0/n0;if(n1>0.)c1=sum1/n1;if(n2>0.)c2=sum2/n2;if(n3>0.)c3=sum3/n3;
}
vec4 metadata=vec4(0.);
if(paletteIndex>3.5){
vec4 wholeVotes=vec4(0.);float cellEvidence=0.;
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec4 sampleData=atlasSampleData(cell.xy,vec2(float(x),float(y)));vec3 c=sampleData.rgb;float label=nearestLabel(c,c0,c1,c2,c3);
wholeVotes+=vec4(step(abs(label-vec4(0.,1.,2.,3.)),vec4(.1)));
cellEvidence+=sampleData.a;
}
float singleMajority=max(max(wholeVotes.x,wholeVotes.y),max(wholeVotes.z,wholeVotes.w))/64.;
float bestCost=1e20,bestConfidence=0.,bestAxis=0.,bestCut=4.,bestA=0.,bestB=0.;
for(int axis=0;axis<2;axis++)for(int candidateIndex=0;candidateIndex<5;candidateIndex++){
float cut=4.;
if(candidateIndex==1)cut=3.;else if(candidateIndex==2)cut=5.;else if(candidateIndex==3)cut=2.;else if(candidateIndex==4)cut=6.;
vec4 votesA=vec4(0.),votesB=vec4(0.),weightedVotesA=vec4(0.),weightedVotesB=vec4(0.);
for(int y=0;y<8;y++)for(int x=0;x<8;x++){
vec4 sampleData=atlasSampleData(cell.xy,vec2(float(x),float(y)));vec3 c=sampleData.rgb;float label=nearestLabel(c,c0,c1,c2,c3);
vec4 oneHot=vec4(step(abs(label-vec4(0.,1.,2.,3.)),vec4(.1)));float sampleWeight=1.+3.*sampleData.a;
float coordinate=axis==0?float(x)+.5:float(y)+.5;
if(coordinate<cut){votesA+=oneHot;weightedVotesA+=oneHot*sampleWeight;}
else{votesB+=oneHot;weightedVotesB+=oneHot*sampleWeight;}
}
float labelA=0.,labelB=0.,pairCost=1e20,pairVotes=0.;
for(int a=0;a<4;a++)for(int b=0;b<4;b++){
if(a!=b&&votesA[a]>0.&&votesB[b]>0.){
vec3 colorA=a==0?c0:(a==1?c1:(a==2?c2:c3));
vec3 colorB=b==0?c0:(b==1?c1:(b==2?c2:c3));
float candidateCost=sideColorCost(weightedVotesA,colorA,c0,c1,c2,c3)+sideColorCost(weightedVotesB,colorB,c0,c1,c2,c3);
if(candidateCost<pairCost){pairCost=candidateCost;labelA=float(a);labelB=float(b);pairVotes=votesA[a]+votesB[b];}
}
}
if(pairCost<1e20){
float accuracy=pairVotes/64.,gain=accuracy-singleMajority;
float oldConfidence=smoothstep(.55,.75,accuracy)*smoothstep(.015625,.078125,gain);
float confidence=.85+.15*oldConfidence;
confidence=mix(confidence,1.,smoothstep(.02,.06,cellEvidence/64.));
if(pairCost<bestCost){bestCost=pairCost;bestConfidence=confidence;bestAxis=float(axis);bestCut=cut;bestA=labelA;bestB=labelB;}
}
}
metadata=vec4((bestAxis*8.+bestCut)/255.,bestA/255.,bestB/255.,bestConfidence);
}
vec3 result=c0;
if(paletteIndex>.5&&paletteIndex<1.5)result=c1;
else if(paletteIndex>1.5&&paletteIndex<2.5)result=c2;
else if(paletteIndex>2.5&&paletteIndex<3.5)result=c3;
gl_FragColor=paletteIndex>3.5?metadata:vec4(result,1.);
}
`,_=`uniform sampler2D uRegionPalette,uPreviousHistory;
uniform vec2 uRegionAtlasSize,uRegionGridSize,uRegionPaletteSize;
uniform float uHistoryBlend;
varying vec2 vUv;
vec3 paletteColor(vec2 cell,float label){vec2 uv=(vec2(cell.x*5.+label,cell.y)+.5)/uRegionPaletteSize;return texture2D(uRegionPalette,uv).rgb;}
void main(){
vec2 pixel=floor(vUv*uRegionAtlasSize),cell=floor(pixel/8.),local=(mod(pixel,8.)+.5)/8.;
vec2 metadataUv=(vec2(cell.x*5.+4.,cell.y)+.5)/uRegionPaletteSize;
vec4 metadata=texture2D(uRegionPalette,metadataUv);
float packed=floor(metadata.r*255.+.5),axis=floor(packed/8.),cut=packed-axis*8.;
float coordinate=axis<.5?local.x:local.y;
float label=coordinate<cut/8.?floor(metadata.g*255.+.5):floor(metadata.b*255.+.5);
float confidence=clamp(metadata.a,0.,1.);
vec4 current=vec4(paletteColor(cell,label)*confidence,confidence);
if(uHistoryBlend>=.9999){gl_FragColor=current;return;}
vec4 previous=texture2D(uPreviousHistory,vUv);
gl_FragColor=mix(previous,current,clamp(uHistoryBlend,0.,1.));
}
`;function v(v){let{renderer:y,scene:b,frameScene:te,camera:x,petDepth:S,uniforms:C,depthUniforms:w,emptyTex:ne,regionHistoryUniform:T,state:E}=v,D=new n,O=new r({uniforms:C,vertexShader:h,fragmentShader:f}),k=new l(new d(2,2),O);D.add(k);let A=new n,j=new r({uniforms:C,vertexShader:h,fragmentShader:f,defines:{REGION_ATLAS:1},toneMapped:!1}),M=new l(new d(2,2),j);A.add(M);let N=new n,P={uRegionAtlas:{value:null},uRegionAtlasSize:C.uRegionAtlasSize,uRegionGridSize:C.uRegionGridSize},F=new r({uniforms:P,vertexShader:h,fragmentShader:g,toneMapped:!1}),I=new l(new d(2,2),F);N.add(I);let L=new o(-1,1,1,-1,0,1),R=new i(1,1,{colorSpace:``,type:e,depthBuffer:!0});R.depthTexture=new a(1,1,s),R.depthTexture.format=ee,C.uSceneDepth.value=R.depthTexture;let z=new i(104,104,{colorSpace:``,type:e,depthBuffer:!1,minFilter:t,magFilter:t}),B=new i(65,13,{colorSpace:``,type:u,depthBuffer:!1,minFilter:t,magFilter:t}),V=[0,1].map(()=>new i(104,104,{colorSpace:``,type:e,depthBuffer:!1,minFilter:t,magFilter:t})),H=new n,U={uRegionPalette:{value:B.texture},uPreviousHistory:{value:V[0].texture},uRegionAtlasSize:C.uRegionAtlasSize,uRegionGridSize:C.uRegionGridSize,uRegionPaletteSize:C.uRegionPaletteSize,uHistoryBlend:{value:1}},W=new r({uniforms:U,vertexShader:h,fragmentShader:_,toneMapped:!1}),G=new l(new d(2,2),W);H.add(G),C.uRegionPalette.value=B.texture,P.uRegionAtlas.value=z.texture;let K=``,q=``,J=0,Y=!1;function X(){let e=Math.max(1,Math.ceil(C.uGridSize.value)),t=Math.max(1,C.uGridSize.value*C.uPaneSize.value.y/Math.max(C.uPaneSize.value.x,.001)),n=Math.ceil(t),r=e*8,i=n*8,a=e*5,o=n,s=Math.max(r,i,a,o)<=y.capabilities.maxTextureSize,c=C.uRegionAvailable.value!==+!!s||s&&(z.width!==r||z.height!==i||B.width!==a||B.height!==o||V[0].width!==r||V[0].height!==i)||C.uRegionGridSize.value.x!==C.uGridSize.value||C.uRegionGridSize.value.y!==t;s&&(z.width!==r||z.height!==i)&&z.setSize(r,i),s&&(B.width!==a||B.height!==o)&&B.setSize(a,o);for(let e of V)s&&(e.width!==r||e.height!==i)&&e.setSize(r,i);C.uRegionAvailable.value=+!!s,C.uRegionAtlasSize.value.set(s?r:z.width,s?i:z.height),C.uRegionGridSize.value.set(C.uGridSize.value,t),C.uRegionPaletteSize.value.set(s?a:B.width,s?o:B.height),P.uRegionAtlas.value=z.texture,C.uRegionPalette.value=B.texture,C.uRegionAtlas.value=z.texture,c&&(K=``)}function Z(){let{mode:e,active:t,activeIsVideo:n,effect:r,style:i}=E(),a=C.uContentViewProjection.value.elements.map(e=>e.toFixed(4)).join(`,`),o=S.matrixWorld.elements.map(e=>e.toFixed(4)).join(`,`),s=w.uDepthMap.value;return[e,t.uuid,s.uuid,a,o,C.uGridSize.value,C.uStrength.value,C.uCurvature.value,C.uMosaicStyle.value,C.uPaneSize.value.x,C.uPaneSize.value.y,w.uDepthScale.value,w.uDepthBias.value,R.width,R.height,z.width,z.height].join(`|`)}function re(){let{mode:e,active:t,effect:n,style:r}=E(),i=S.matrixWorld.elements.map(e=>e.toFixed(4)).join(`,`),a=w.uDepthMap.value;return[e,n,r,t.uuid,a.uuid,C.uRegionAvailable.value,C.uGridSize.value,C.uMosaicStyle.value,C.uStrength.value,C.uRegionStrength.value,C.uCurvature.value,C.uGlassZ.value,C.uSourceZ.value,C.uPaneSize.value.x,C.uPaneSize.value.y,i,w.uDepthScale.value,w.uDepthBias.value,R.width,R.height,z.width,z.height,B.width,B.height].join(`|`)}function ie(){let e=y.getRenderTarget(),t=y.getClearColor(new c),n=y.getClearAlpha();y.setClearColor(0,0);for(let e of V)y.setRenderTarget(e),y.clear(!0,!1,!1);y.setRenderTarget(e),y.setClearColor(t,n),J=0,Y=!1,T.value=V[0].texture}function Q(e){let{mode:t,effect:n,style:r}=E(),i=re();if(i!==q&&(ie(),q=i),!((t===`final`||t===`glass`)&&n===`mosaic`&&r===`0`&&C.uRegionAvailable.value>.5&&C.uRegionStrength.value>.001&&C.uStrength.value>.001))return;let a=1-J;U.uPreviousHistory.value=V[J].texture,U.uHistoryBlend.value=Y?1-Math.exp(-e/.07):1,y.setRenderTarget(V[a]),y.render(H,L),y.setRenderTarget(null),J=a,Y=!0,T.value=V[J].texture}function ae(){let{effect:e,style:t,activeIsVideo:n}=E();if(!(e===`mosaic`&&t===`0`&&C.uRegionAvailable.value>.5&&C.uRegionStrength.value>.001&&C.uStrength.value>.001))return;let r=Z();(n||r!==K)&&(y.setRenderTarget(z),y.render(A,L),y.setRenderTarget(B),y.render(N,L),y.setRenderTarget(null),K=r)}function oe(e,t=null){let{mode:n}=E();if(n===`final`||n===`glass`){y.setRenderTarget(R),y.render(b,x),C.uSceneTexture.value=R.texture,ae(),Q(e),y.setRenderTarget(t),y.render(D,L);let n=y.autoClear;y.autoClear=!1,y.clearDepth(),y.render(te,x),y.autoClear=n}else Q(e),y.setRenderTarget(t),y.render(b,x)}let $=!1;return{render:oe,setEffect(e){O.fragmentShader=e===`reeded`?p:e===`frosted`?m:f,O.needsUpdate=!0},resize(e,t){R.setSize(e,t),X()},invalidate(){K=``,q=``,Y=!1},dispose(){if(!$){$=!0,R.dispose(),z.dispose(),B.dispose(),V.forEach(e=>e.dispose());for(let e of[k,M,I,G])e.geometry.dispose();for(let e of[O,j,F,W])e.dispose();D.clear(),A.clear(),N.clear(),H.clear()}}}}export{v as createOriginalGlassRenderer};
+1
View File
@@ -0,0 +1 @@
import{a as e,d as t,h as n,l as r,n as i,o as a,s as o,u as s}from"./index-BYcEjj7E.js";var c=class{renderer;background;targetA=new n(1,1,{type:e});targetB=new n(1,1,{type:e});blitMaterial=new o({toneMapped:!1,depthTest:!1,depthWrite:!1});blit=new a(new s(2,2),this.blitMaterial);blitScene=new t;blitCamera=new r(-1,1,1,-1,0,1);disposed=!1;constructor(e,t){this.renderer=e,this.background=t,this.blitScene.add(this.blit)}resize(e,t){for(let n of[this.targetA,this.targetB])(n.width!==e||n.height!==t)&&n.setSize(e,t)}composite(e,t){this.renderer.setRenderTarget(null),this.renderer.setScissorTest(!1);let n=this.renderer.getClearColor(new i),r=this.renderer.getClearAlpha();this.renderer.setClearColor(this.background,1),this.renderer.clear(),this.renderer.setClearColor(n,r),this.renderer.setScissorTest(!0);try{for(let n=0;n<e.length;n++){let r=e[n];this.renderer.setViewport(r.x,t-r.y-r.height,r.width,r.height),this.renderer.setScissor(r.x,t-r.y-r.height,r.width,r.height),this.blitMaterial.map=n===0?this.targetA.texture:this.targetB.texture,this.renderer.render(this.blitScene,this.blitCamera)}}finally{this.renderer.setScissorTest(!1),this.renderer.setViewport(0,0,this.renderer.domElement.clientWidth,t)}}dispose(){this.disposed||(this.disposed=!0,this.renderer.getContext().isContextLost()||(this.renderer.setRenderTarget(null),this.renderer.setScissorTest(!1)),this.targetA.dispose(),this.targetB.dispose(),this.blit.geometry.dispose(),this.blitMaterial.dispose(),this.blitScene.clear())}};export{c as PhysicalGlassComparison};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4173
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-4317
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
*{box-sizing:border-box}body{margin:0;background:#0d1117;color:#e6edf3;font:14px system-ui,sans-serif}main{min-height:100vh;display:flex;flex-direction:column}.stage{position:relative;flex:1;min-height:60vh;background:#111923}#canvas-wrap{position:absolute;top:0;right:0;bottom:0;left:0;touch-action:none}canvas{width:100%;height:100%;display:block;cursor:grab}canvas.is-dragging{cursor:grabbing}#empty{position:absolute;top:0;right:0;bottom:0;left:0;display:grid;place-items:center;color:#82909f;pointer-events:none;font-size:1rem}#hint{position:absolute;bottom:12px;left:50%;transform:translate(-50%);margin:0;padding:8px 12px;border-radius:20px;background:#0008;color:#c8d1dc;transition:opacity .5s}aside{padding:18px;display:grid;gap:12px;background:#161d27;border-top:1px solid #293544;min-width:0;overflow:hidden}h1{font-size:1.1rem;margin:0 0 4px}label{display:grid;gap:5px;color:#aeb9c6}input,select,button{font:inherit;color:inherit;background:#202b38;border:1px solid #3b4a5c;border-radius:7px;padding:9px}#test-photo{min-width:0;width:100%;max-width:100%}input[type=range]{padding:0;accent-color:#72b6ff}.upload{position:relative;cursor:pointer;text-align:center;background:#25364a;border-radius:7px;padding:10px;color:#dcecff}.upload input[type=file]{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);clip-path:inset(50%);white-space:nowrap;border:0}.upload:focus-within{outline:2px solid #8bc5ff;outline-offset:2px}output{float:right;color:#8bc5ff}button{cursor:pointer}#status{min-height:20px;margin:0;color:#ffbe79}#depth-provenance{margin:0;color:#aeb9c6;font-size:12px}#depth-debug{max-width:100%;overflow-x:auto;white-space:pre-wrap;word-break:break-word;font-size:11px}@media (min-width:720px){main{height:100dvh;min-height:0;flex-direction:row}.stage{min-height:0}aside{width:280px;flex:0 0 280px;align-content:start;border-top:0;border-left:1px solid #293544;overflow-y:auto}}.visual-column{display:flex;flex:1;flex-direction:column;min-width:0;min-height:0}.reference-strip{position:relative;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;width:100%;height:200px;flex:0 0 200px;padding:10px 12px;background:#0d1117;border-bottom:1px solid #293544;overflow:hidden}.reference-strip[hidden]{display:none}.reference-strip figure{display:grid;grid-template-rows:minmax(0,1fr) auto;gap:5px;min-width:0;min-height:0;margin:0;padding:6px;border:1px solid #293544;border-radius:8px;background:#111923}.reference-strip img{display:block;width:100%;height:100%;min-width:0;min-height:0;object-fit:contain}.reference-strip figcaption{text-align:center;color:#aeb9c6;font-size:12px}#reference-status{position:absolute;left:50%;bottom:16px;transform:translate(-50%);margin:0;padding:5px 8px;border-radius:6px;background:#000b;color:#ffbe79;font-size:12px;white-space:nowrap}#reference-status[hidden]{display:none}@media (min-width:720px){.visual-column{min-height:0}.reference-strip{height:clamp(180px,30vh,300px);flex-basis:clamp(180px,30vh,300px)}}details{border:1px solid #344255;border-radius:8px;padding:10px 12px;background:#111923}details summary{cursor:pointer;color:#dce6f2;font-weight:600}details[open] summary{margin-bottom:8px}.details-content{display:grid;gap:12px;padding-top:4px}.control-note,.control-help{margin:0;color:#9eacbb;font-size:12px}.control-help{margin-top:-7px}.core-optics{gap:12px}
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

+1
View File
@@ -0,0 +1 @@
var e=`/assets/reference1-D4rK2azX.png`;export{e as default};
-1
View File
@@ -1 +0,0 @@
const e="/assets/reference1-D4rK2azX.png";export{e as default};
-1
View File
@@ -1 +0,0 @@
const e="/assets/reference2-6y7JVIce.png";export{e as default};
+1
View File
@@ -0,0 +1 @@
var e=`/assets/reference2-6y7JVIce.png`;export{e as default};
-56
View File
@@ -1,56 +0,0 @@
{
"width": 415,
"height": 739,
"convention": "0 = far/background, 1 = near/foreground",
"source": "Hand-authored procedural Gaussian ellipses for local testing; not AI-estimated or physically measured.",
"landmarks": [
{
"name": "nose",
"x": 235,
"y": 310,
"depth": 0.98
},
{
"name": "left-eye",
"x": 198,
"y": 278,
"depth": 0.68
},
{
"name": "right-eye",
"x": 249,
"y": 273,
"depth": 0.68
},
{
"name": "face",
"x": 221,
"y": 267,
"depth": 0.6
},
{
"name": "left-ear",
"x": 158,
"y": 213,
"depth": 0.43
},
{
"name": "right-ear",
"x": 262,
"y": 206,
"depth": 0.43
},
{
"name": "body",
"x": 139,
"y": 419,
"depth": 0.3
},
{
"name": "muzzle",
"x": 224,
"y": 305,
"depth": 0.82
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 813 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+257 -81
View File
@@ -1,16 +1,242 @@
<!doctype html> <!doctype html>
<html lang="ko"> <html lang="ko" data-theme="wood">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pet Behind Glass</title> <meta name="theme-color" content="#e6dfcf">
<script type="module" crossorigin src="/assets/index-DOC1JJ07.js"></script> <meta name="description" content="우리집 멍멍이, 할머니 집 유리문 너머로">
<link rel="stylesheet" crossorigin href="/assets/index-JVljdMQi.css"> <title>Behind Glass · 할머니집 창가</title>
<link rel="preload" href="/fonts/BMKkubulim.woff" as="font" type="font/woff" crossorigin>
<script type="module" crossorigin src="/assets/index-BYcEjj7E.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CAEr_JzP.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<main> <input hidden id="image" type="file" accept="image/jpeg,image/png,image/webp" aria-label="멍멍이 사진 선택">
<div class="visual-column"> <main id="experience" data-flow="entry">
<section id="pet-stage" class="stage" aria-label="유리문 너머의 멍멍이">
<div id="canvas-wrap" tabindex="0" aria-label="사진 미리보기. 드래그하거나 방향키로 시점을 움직이세요" aria-describedby="scene-instructions"><div id="empty" hidden></div><span id="door-handle" aria-hidden="true" hidden>↔</span></div>
<div id="guide-scene-target" aria-hidden="true"></div>
<div id="door-controls" hidden><button id="door-toggle" type="button">문 열기</button></div>
<button id="gyro-recenter" type="button" hidden aria-describedby="gyro-status">중앙 맞추기</button>
<button id="photo-adjust-open" type="button" hidden>사진 위치 조절</button>
<section id="photo-adjust-panel" hidden aria-label="사진 위치 조절">
<div class="photo-adjust-heading"><strong>사진 위치 조절 중</strong><button id="photo-adjust-done" type="button">완료</button></div>
<label for="photo-adjust-zoom">확대 <output id="photo-adjust-value">1.00</output><input id="photo-adjust-zoom" type="range" min="1" max="3" step="0.05" value="1"></label>
<p id="photo-adjust-help">사진을 확대한 뒤 드래그해서 위치를 맞춰주세요</p>
<button id="photo-adjust-reset" type="button">처음 구도로</button>
</section>
<p id="frame-notice" class="frame-notice" role="status" hidden></p>
<p id="hint" aria-hidden="true"></p>
</section>
<div id="comparison-overlay" hidden aria-label="동기화 유리 렌더러 비교">
<div id="comparison-label-a" class="comparison-label">A · 기존 코드</div>
<div id="comparison-label-b" class="comparison-label">굴절 보정 B · 맞춤 굴절</div>
<div id="comparison-metrics"><p id="comparison-timing">첫 프레임을 준비하는 중…</p><p id="comparison-memory"></p><p>GPU 시간·메모리 미측정</p></div>
</div>
<header id="scene-toolbar" class="scene-toolbar">
<a class="scene-brand" href="#app" aria-label="Behind Glass">behind glass</a>
<button id="scene-menu-open" type="button" hidden aria-haspopup="dialog" aria-controls="scene-menu" aria-expanded="false">메뉴</button>
<button id="sample-home" type="button" hidden aria-label="첫 화면으로 가기" title="첫 화면으로 가기" aria-haspopup="dialog" aria-controls="home-dialog"><svg viewBox="0 0 24 24" width="23" height="23" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3 10 9-7 9 7M5 9v12h5v-7h4v7h5V9"/></svg></button>
<div class="scene-actions"><button id="credits-open" type="button" aria-haspopup="dialog" aria-controls="credits-dialog">만든 사람</button></div>
<div id="scene-motion" class="scene-motion" hidden>
<button id="gyro" type="button" aria-pressed="false" aria-describedby="gyro-status gyro-privacy"><span id="gyro-label">기울기 연결</span></button>
<p id="gyro-notice" role="status" aria-live="polite" hidden></p>
<p id="gyro-status" class="sr-only" role="status" aria-live="polite">기울기는 꺼져 있어요. 드래그로 둘러볼 수 있어요.</p>
</div>
</header>
<section id="welcome" class="welcome" aria-labelledby="welcome-title">
<div class="entry-door" aria-hidden="true"><span></span></div>
<p class="entry-kicker">behind glass</p>
<h1 id="welcome-title">할머니 집에<br><span id="welcome-name">멍멍이가 왔어요</span></h1>
<button id="entry-name-edit" class="entry-samples" type="button" hidden>이름 바꾸기</button>
<div id="entry-name-step" class="entry-step">
<div class="pet-name-field entry-name-field">
<label for="entry-pet-name">멍멍이 이름이 뭐예요?</label>
<input id="entry-pet-name" type="text" autocomplete="off" maxlength="40" spellcheck="false" placeholder="예시 : 장준" aria-describedby="entry-name-help">
<small id="entry-name-help">비워두어도 괜찮아요 · 이 브라우저에만 저장돼요</small>
<small id="entry-name-notice" class="name-storage-notice" role="status" hidden>이름은 지금만 적용돼요. 이 브라우저에 저장하지 못했어요.</small>
</div>
<button id="entry-name-next" class="primary-upload" type="button">다음</button>
<button id="entry-name-skip" class="entry-samples" type="button">이름 없이 둘러보기</button>
</div>
<div id="entry-photo-step" class="entry-step" hidden>
<button id="entry-photo-button" class="upload primary-upload" type="button" data-pick-photo>멍멍이 사진 고르기</button>
<button id="entry-samples" class="entry-samples" type="button">샘플로 먼저 둘러보기</button>
</div>
<div id="entry-notices" hidden role="group" aria-label="사진 선택 도움말">
<button id="notice-prev" type="button" aria-label="이전 안내">‹</button>
<div class="notice-slides">
<p id="photo-tip" class="entry-notice-slide" data-active="true">정면 사진이면 더 귀여워요</p>
<p id="privacy-tip" class="entry-notice-slide" data-active="false" aria-hidden="true">사진은 이 브라우저 안에서만 처리해요</p>
<p id="paste-tip" class="entry-notice-slide" data-active="false" aria-hidden="true" hidden>복사한 사진도 붙여넣을 수 있어요<br><span>Ctrl+V · ⌘V</span></p>
</div>
<button id="notice-next" type="button" aria-label="다음 안내">›</button>
<span id="notice-position" aria-hidden="true">1 / 2</span>
<span id="notice-status" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></span>
</div>
<p class="arrival-count" data-arrival-count hidden></p>
</section>
<section id="flow-overlay" class="flow-overlay" hidden aria-labelledby="flow-title" aria-busy="false">
<div id="journey" class="journey" hidden>
<div class="journey-scenery" aria-hidden="true"><span class="journey-sun"></span><span class="journey-cloud"></span><span class="journey-hill"></span><span class="journey-tree"></span></div>
<svg class="journey-house" viewBox="0 0 140 160" aria-hidden="true" focusable="false">
<path d="M19 65h103v86H19z" fill="#e5d6b8" stroke="#735943" stroke-width="3"/>
<path d="M9 66 71 18l61 48z" fill="#87775c" stroke="#645640" stroke-width="4" stroke-linejoin="round"/>
<path d="M105 37V17h12v31" fill="#ae8870" stroke="#735943" stroke-width="3"/>
<path d="M76 88h30v63H76z" fill="#98734e" stroke="#715239" stroke-width="3"/>
<path d="M29 82h31v37H29z" fill="#c1d1bd" stroke="#7d684b" stroke-width="4"/>
<path d="M44 82v37M29 100h31" stroke="#7d684b" stroke-width="3"/>
<circle cx="99" cy="122" r="2.5" fill="#f3d495"/>
<path d="M11 152h121" stroke="#867858" stroke-width="5" stroke-linecap="round"/>
</svg>
<div class="journey-road" aria-hidden="true"></div>
<div class="journey-traveler">
<div class="journey-passenger"><img id="flow-photo" class="flow-photo" alt="선택한 멍멍이 사진" hidden></div>
</div>
<div class="journey-car">
<svg class="journey-car-body" viewBox="0 0 300 180" aria-hidden="true" focusable="false">
<path d="M48 104 62 55q4-15 20-15h88q13 0 20 15l27 49" fill="#d6e0ce" stroke="#785c42" stroke-width="5" stroke-linejoin="round"/>
<path d="M170 52h4q5 0 9 10l15 31h-28z" fill="#f3f1df" stroke="#8e9c8d" stroke-width="3"/>
<path d="M31 101h181l22 9h25q21 0 23 20v22H18v-27q0-20 13-24z" fill="#bc7250" stroke="#78513d" stroke-width="5" stroke-linejoin="round"/>
<path d="M27 129h245M156 104v42" stroke="#934e38" stroke-width="3"/>
<path d="M173 115h14" stroke="#f7dbb4" stroke-width="4" stroke-linecap="round"/>
<path d="M18 146h21m217 0h29" stroke="#e2cbb2" stroke-width="8" stroke-linecap="round"/>
<rect x="263" y="116" width="14" height="12" rx="3" fill="#ffe5a3"/>
<rect x="21" y="116" width="7" height="10" rx="2" fill="#e6a092"/>
<g class="journey-wheel"><circle cx="66" cy="150" r="24" fill="#514b43"/><circle cx="66" cy="150" r="12" fill="#d4cab3"/><path d="M66 141v18m-9-9h18" stroke="#8a806f" stroke-width="3"/></g>
<g class="journey-wheel"><circle cx="231" cy="150" r="24" fill="#514b43"/><circle cx="231" cy="150" r="12" fill="#d4cab3"/><path d="M231 141v18m-9-9h18" stroke="#8a806f" stroke-width="3"/></g>
</svg>
</div>
</div>
<h2 id="flow-title" role="status" aria-live="polite">멍멍이를 할머니 집으로 데려가는 중…</h2>
<p id="flow-detail" aria-live="polite"></p>
<div id="flow-actions" class="flow-actions"><button id="flow-retry" type="button" hidden>다시 시도하기</button><button id="flow-flat" type="button" hidden>평면 사진으로 보기</button><button class="upload" type="button" data-pick-photo>다른 사진 고르기</button><button id="flow-samples" type="button" hidden>다른 샘플 고르기</button></div>
</section>
</main>
<dialog id="home-dialog" aria-labelledby="home-title" aria-describedby="home-detail">
<h2 id="home-title">정말 첫 화면으로 갈까요?</h2>
<p id="home-detail">돌아오려면 사진을 다시 골라야 해요.</p>
<div class="home-actions"><button id="home-cancel" type="button" autofocus>계속 볼게요</button><button id="home-confirm" type="button">첫 화면으로</button></div>
</dialog>
<dialog id="scene-menu" aria-labelledby="scene-menu-title">
<div class="scene-menu-heading"><h2 id="scene-menu-title">메뉴</h2><button id="scene-menu-close" type="button" autofocus>닫기</button></div>
<div class="scene-menu-items">
<button id="export-open" type="button" hidden aria-haspopup="dialog" aria-controls="export-dialog">내려받기</button>
<button id="scene-photo-change" class="upload" type="button" hidden data-pick-photo>사진 바꾸기</button>
<button id="settings-open" type="button" hidden aria-haspopup="dialog" aria-controls="settings-dialog">설정</button>
<button id="scene-zoom-reset" type="button" hidden>전체 보기</button>
<button id="comparison-exit" type="button" hidden>비교 끝내기</button>
<button id="scene-guide-replay" type="button" hidden>사용법 다시 보기</button>
</div>
</dialog>
<dialog id="sample-dialog" aria-labelledby="sample-title" aria-describedby="sample-description">
<div class="sample-heading"><h2 id="sample-title">어떤 멍멍이를 만나볼까요?</h2><button id="sample-close" type="button" aria-label="샘플 사진 선택 닫기">닫기</button></div>
<p id="sample-description">사진을 고른 뒤 아래 버튼을 눌러주세요</p>
<div class="sample-choices" role="group" aria-label="샘플 사진">
<button type="button" data-sample-choice="dog5" aria-pressed="true"><img src="/assets/samples/dog5.jpg" alt="" loading="lazy"><span>누렁이 · 정면 인사</span><small>가장 추천해요</small></button>
<button type="button" data-sample-choice="dog1" aria-pressed="false"><img src="/assets/samples/dog1.jpg" alt="" loading="lazy"><span>호두 · 저녁 얼굴</span><small>얼굴 사진 추천</small></button>
<button type="button" data-sample-choice="dog2" aria-pressed="false"><img src="/assets/samples/dog2.jpg" alt="" loading="lazy"><span>두부 · 나무 아래</span></button>
<button type="button" data-sample-choice="dog3" aria-pressed="false"><img src="/assets/samples/dog3.jpg" alt="" loading="lazy"><span>보리 · 낮잠</span></button>
<button type="button" data-sample-choice="dog4" aria-pressed="false"><img src="/assets/samples/dog4.jpg" alt="" loading="lazy"><span>콩이 · 저녁 산책</span></button>
</div>
<div class="sample-footer"><button id="sample-confirm" type="button">이 사진으로 둘러보기</button></div>
</dialog>
<dialog id="render-recovery" role="alertdialog" aria-labelledby="render-recovery-title" aria-describedby="render-recovery-detail">
<h2 id="render-recovery-title">화면을 잠시 그리지 못했어요</h2>
<p id="render-recovery-detail">그래픽 연결이 끊겼어요. 브라우저가 복구하면 같은 사진으로 돌아가요.<br>계속 이 화면이면 새로고침해 주세요.</p>
<button id="render-reload" type="button" autofocus>새로고침</button>
<p class="control-help">새로고침하면 사진을 다시 선택해야 해요</p>
</dialog>
<dialog id="mesh-render-error" role="alertdialog" aria-labelledby="mesh-error-title">
<h2 id="mesh-error-title">B 유리를 그리지 못했어요</h2>
<p>사진과 설정은 그대로예요. B를 다시 시도하거나 A 진단 모드를 직접 선택할 수 있어요.</p>
<button id="mesh-retry" type="button" autofocus>B 다시 시도</button>
<button id="mesh-use-a" type="button">A 진단 모드로 보기</button>
</dialog>
<dialog id="first-guide" aria-hidden="true"></dialog>
<dialog id="export-dialog" aria-labelledby="export-title">
<div class="export-heading"><h2 id="export-title">현재 구도 저장</h2><button id="export-close" type="button">닫기</button></div>
<div class="export-choices"><button id="export-photo" type="button">굴절사진만 저장</button><button id="export-scene" type="button">방까지 함께 저장</button></div>
<p id="export-status" role="status" aria-live="polite">마음에 드는 현재 구도를 저장해 보세요</p>
<img id="export-preview" alt="저장 미리보기" hidden>
<div class="export-actions"><a id="export-download" hidden download>PNG 내려받기</a><button id="export-share" type="button" hidden>공유하기</button></div>
</dialog>
<dialog id="credits-dialog" aria-labelledby="credits-title">
<div class="credits-heading"><h2 id="credits-title">만든 사람</h2><button id="credits-close" type="button" aria-label="만든 사람 닫기">닫기</button></div>
<dl class="credits-info"><dt>제작</dt><dd>horoli</dd><dt>버그 제보</dt><dd><a href="mailto:sunha321@gmail.com">sunha321@gmail.com</a></dd></dl>
<a class="credits-licenses" href="/THIRD-PARTY-NOTICES.txt" target="_blank" rel="noopener">리소스·라이선스 안내</a>
</dialog>
<dialog id="settings-dialog" aria-labelledby="settings-title">
<aside aria-label="사진 꾸미기">
<div class="editor-heading"><h2 id="settings-title">창가 설정</h2><button id="settings-close" type="button" aria-label="설정 닫기">닫기</button></div>
<p class="arrival-count" data-arrival-count hidden></p>
<button id="frame-retry" type="button" hidden>나무 창틀 다시 불러오기</button>
<button id="house-retry" type="button" hidden>집 주변 공간 다시 불러오기</button>
<div class="pet-name-field settings-name-field">
<label for="settings-pet-name">멍멍이 이름</label>
<input id="settings-pet-name" type="text" autocomplete="off" maxlength="40" spellcheck="false" placeholder="예시 : 장준" aria-describedby="settings-name-help">
<small id="settings-name-help">언제든 바꿀 수 있어요 · 최대 20자</small>
<small id="settings-name-notice" class="name-storage-notice" role="status" hidden>이름은 지금만 적용돼요. 이 브라우저에 저장하지 못했어요.</small>
</div>
<label class="theme-setting" for="theme-select">테마<select id="theme-select"><option value="wood">할머니집 창가</option><option value="booth">통통 포토부스</option></select></label>
<div id="tool-tabs" role="tablist" aria-label="사진 도구">
<button id="tab-glass" role="tab" aria-controls="panel-glass" aria-selected="true" data-tool="glass"><span aria-hidden="true">▦</span> 유리</button>
<button id="tab-photo" role="tab" aria-controls="panel-photo" aria-selected="false" tabindex="-1" data-tool="photo"><span aria-hidden="true">▧</span> 사진</button>
<button id="tab-view" role="tab" aria-controls="panel-view" aria-selected="false" tabindex="-1" data-tool="view">시점 조절</button>
</div>
<section id="panel-glass" class="tool-panel" role="tabpanel" aria-labelledby="tab-glass" data-panel="glass">
<div id="pattern-cards" role="group" aria-label="유리 무늬">
<button type="button" class="pattern-card" data-pattern="2" aria-pressed="true"><span class="pattern-swatch swatch-gobang" aria-hidden="true"></span><span class="pattern-name">고방유리</span><span class="pattern-detail">볼록한 조각</span></button>
<button type="button" class="pattern-card" data-pattern="0" aria-pressed="false"><span class="pattern-swatch swatch-color" aria-hidden="true"></span><span class="pattern-name">사각 색면</span><span class="pattern-detail">컬러 모자이크</span></button>
<button type="button" class="pattern-card" data-pattern="1" aria-pressed="false"><span class="pattern-swatch swatch-fine" aria-hidden="true"></span><span class="pattern-name">촘촘 유리</span><span class="pattern-detail">작은 조각들</span></button>
</div>
<label class="sr-only" aria-hidden="true" for="mosaic-style">유리 패턴<select id="mosaic-style" tabindex="-1"><option value="2" selected>고방유리 · 표면 굴절</option><option value="0">Ref1 옵션 2 · 사각 색면</option><option value="1">기존 촘촘 유리</option></select></label>
<p class="control-help">고방유리의 크기와 굴곡을 바꿔보세요.</p>
<label class="range-control" for="grid-size"><span>유리 무늬 크기 <output id="gridOut">20</output></span><input id="grid-size" type="range" min="10" max="24" step="1" value="20"><span class="range-ends" aria-hidden="true"><span>큼직하게</span><span>촘촘하게</span></span></label>
<div id="curvature-controls" class="core-optics"><label class="inline-control" for="curvature">유리 굴곡<select id="curvature"><option value="1" selected>볼록</option><option value="-1">오목</option></select></label></div>
<label class="range-control" for="photo-glass-distance"><span>유리와의 거리 <output id="photoGlassDistanceOut">16%</output></span><input id="photo-glass-distance" type="range" min="0" max="21" step="1" value="16"><span class="range-ends" aria-hidden="true"><span>가까이</span><span>조금 멀리</span></span></label>
</section>
<section id="panel-photo" class="tool-panel" role="tabpanel" aria-labelledby="tab-photo" data-panel="photo" hidden>
<div class="section-intro compact"><h2>사진 구도</h2><p>마음에 드는 위치로 직접 맞춰보세요</p></div>
<label class="range-control" for="photo-zoom"><span>사진 확대 <output id="photoZoomOut">1.00</output></span><input id="photo-zoom" type="range" min="1" max="3" step="0.05" value="1"></label>
<label class="range-control" for="photo-pan-x"><span>가로 위치 <output id="photoPanXOut">0.00</output></span><input id="photo-pan-x" type="range" min="-1" max="1" step="0.01" value="0"></label>
<label class="range-control" for="photo-pan-y"><span>세로 위치 <output id="photoPanYOut">0.00</output></span><input id="photo-pan-y" type="range" min="-1" max="1" step="0.01" value="0"></label>
<p class="control-help">사진을 확대한 뒤 가로·세로 위치를 움직일 수 있어요</p>
<button id="photo-reset" class="quiet-button">처음 구도로</button>
</section>
<section id="panel-view" class="tool-panel" role="tabpanel" aria-labelledby="tab-view" data-panel="view" hidden>
<button id="guide-replay" type="button">사용법 다시 보기</button>
<label class="range-control" for="camera-offset"><span>움직임 범위 <output id="cameraOut">0.75</output></span><input id="camera-offset" type="range" min="0.35" max="0.90" step="0.01" value="0.75"><span class="range-ends" aria-hidden="true"><span>살짝</span><span>넓게</span></span></label>
<button id="reset" type="button">정면으로</button>
<p id="scene-instructions" class="control-help">드래그·방향키로 둘러보고, 휠·두 손가락으로 확대해요. 손잡이를 왼쪽으로 밀면 열리고 오른쪽으로 밀면 닫혀요. 문 버튼으로도 조작할 수 있어요.</p>
<p id="gyro-privacy" class="control-help">버튼을 누르면 필요할 때 브라우저가 센서 접근을 물어요. 센서값은 이 기기 안에서만 사용해요</p>
</section>
</aside>
</dialog>
<!-- Internal control adapters preserve renderer diagnostics and retry state, not settings UI. -->
<div id="internal-controls" hidden inert aria-hidden="true">
<label class="range-control" for="camera-composition"><span>창가 구도</span><select id="camera-composition" aria-describedby="camera-composition-note"><option value="wall">벽 옆에서 보기 · 실험</option><option value="legacy">기존 구도 · 넓은 방 보기</option></select></label>
<p id="camera-composition-note" class="control-help" role="status">기존 구도로 바로 돌아갈 수 있어요. 선택은 이 브라우저에 저장돼요.</p>
<details id="sample-browser" class="sample-browser"><summary>샘플로 둘러보기 <span aria-hidden="true">+</span></summary><div class="details-content">
<div class="sample-strip" aria-hidden="true"><img src="/assets/samples/dog5.jpg" alt="" loading="lazy"><img src="/assets/samples/dog1.jpg" alt="" loading="lazy"><img src="/assets/samples/dog2.jpg" alt="" loading="lazy"><img src="/assets/samples/dog3.jpg" alt="" loading="lazy"><img src="/assets/samples/dog4.jpg" alt="" loading="lazy"></div>
<label for="test-photo">샘플 사진<select id="test-photo"><option value="dog5">누렁이 · 정면 인사</option><option value="dog1">호두 · 저녁 얼굴</option><option value="dog2">두부 · 나무 아래</option><option value="dog3">보리 · 낮잠</option><option value="dog4">콩이 · 저녁 산책</option></select></label>
<button id="sample" class="tactile-button">이 사진으로 해보기</button>
</div></details>
<details id="developer-tools"><summary>개발 도구 <span aria-hidden="true">+</span></summary><div id="developer-content" class="details-content">
<label for="glass-renderer">유리 렌더러<select id="glass-renderer"><option value="b" selected>B · 맞춤 메시 굴절 (기본)</option><option value="a">A · 기존 코드 (진단)</option></select></label>
<p id="door-notice" class="control-note">나무 문 손잡이를 왼쪽으로 밀어 열 수 있어요.</p>
<p id="renderer-notice" class="control-note" role="status">기본 B · 실제 고방 메시 + 맞춤 굴절</p>
<section class="comparison-control" aria-labelledby="comparison-title">
<h3 id="comparison-title">웹 유리 렌더러 비교</h3>
<p class="control-note">A 기존 코드 / 굴절 보정 B · 실제 고방 메시 + 맞춤 굴절. 같은 사진·깊이·시점·확대·거리로 동시에 표시해요. AI 분석은 한 번만 공유해요.</p>
<p class="control-note">화면을 두 번 그려 GPU·메모리 부담이 커져요. 특히 모바일에서는 느려지거나 그래픽 연결이 끊길 수 있어요. 기본 화면은 B만 그려요. A는 별도 진단 모드로 보존돼요.</p>
<button id="comparison-toggle" type="button" aria-pressed="false">비교 켜기 · 추가 렌더 비용</button>
<p id="comparison-state" class="control-note" role="status">고방유리의 Final 또는 No Glass에서 사용할 수 있어요.</p>
<details><summary>비교 조건과 차이</summary><p class="control-note">공통: 사진·128×128 깊이 메시, 격자·굴곡, IOR 1.5, 전면 요철·뒷면 위치, 거리, 카메라와 확대. B는 닫힌 실제 메시의 진입점·법선과 뒷면·옆면을 사용해요. 조명 위치와 방 배경 방향을 맞췄지만 반사 계산은 달라요.</p><p class="control-note">B: Three.js r169 MeshPhysicalMaterial의 맞춤 확장. 실제 유리 표면에서 굴절하고 유리를 나올 때 다시 굴절한 뒤 사진의 128×128 깊이 삼각형과 만나는 곳을 찾아요. 얇은 유리를 임의로 두껍게 만들지 않아요. 표준 재질만 사용한 결과가 아니며, 사진 색은 교차한 위치에서 직접 읽고 반사는 기존 물리 재질을 사용해요. 일부 GPU에서 직사각 조명을 지원하지 않으면 B에 환경광만 사용했다고 표시해요. 전체 장면의 다중 반사·산란을 추적하는 렌더러는 아니에요. A의 깊이 보정·가장자리 완화·광택 0.30과 동일한 결과를 만드는 방식도 아니에요. 다른 유리 무늬나 진단 화면으로 바꾸면 비교가 종료돼요.</p><p class="control-note">CPU 제출 시간은 GPU 시간이 아니에요. 화면 간격은 A+B를 함께 그린 이 브라우저의 값이고, 각 방식 단독 FPS가 아니에요. GPU 메모리 용량은 측정하지 않아요. 시각·속도 우열은 실제 기기에서 확인하세요.</p></details>
</section>
<p id="region-fixed-note" class="control-note">고방유리 · 표면 요철에 따른 굴절 · 색면 처리 없음</p>
<div id="reference-strip" class="reference-strip" hidden role="group" aria-label="레퍼런스 비교 이미지"> <div id="reference-strip" class="reference-strip" hidden role="group" aria-label="레퍼런스 비교 이미지">
<figure aria-labelledby="reference-caption-1"> <figure aria-labelledby="reference-caption-1">
<img id="reference-image-1" alt=""> <img id="reference-image-1" alt="">
@@ -22,93 +248,43 @@
</figure> </figure>
<p id="reference-status" role="status" aria-live="polite" hidden>레퍼런스 이미지를 불러오지 못했습니다.</p> <p id="reference-status" role="status" aria-live="polite" hidden>레퍼런스 이미지를 불러오지 못했습니다.</p>
</div> </div>
<section class="stage"> <p class="control-note">AI 깊이: Depth Anything V2 Small · 입력 770</p>
<div id="canvas-wrap"><div id="empty">반려동물 사진 또는 영상을 선택하세요</div></div> <details id="depth-inspection">
<p id="hint">화면을 드래그해서 시점을 움직여보세요</p> <summary>최대 깊이 영역 확인</summary>
</section> <p class="control-note">모델 최대 1: 분홍 · 8비트 depth=1: 청록 · 모델 depth≥0.99: 노랑</p>
</div> <canvas id="depth-inspection-canvas" aria-label="최대 깊이 영역 · 사진을 클릭해 값 확인"></canvas>
<aside> <pre id="depth-inspection-summary">사진과 깊이를 불러오세요.</pre>
<h1>Pet Behind Glass</h1> <div class="depth-coordinates">
<label class="upload">사진 선택<input id="image" type="file" accept="image/jpeg,image/png,image/webp"></label> <label for="depth-inspection-x">픽셀 X<input id="depth-inspection-x" type="number" min="0" value="0"></label>
<button id="ai-depth" data-testid="ai-depth" disabled>AI 깊이 생성</button> <label for="depth-inspection-y">픽셀 Y<input id="depth-inspection-y" type="number" min="0" value="0"></label>
<p id="depth-provenance" hidden aria-live="polite"></p> </div>
<label>테스트 사진<select id="test-photo"> <pre id="depth-inspection-point"></pre>
<option value="sample">기존 샘플 · 흰 강아지 얼굴</option> <small class="control-note">1은 사진별 정규화 최댓값이며 실제 거리나 코 검출 결과가 아닙니다. 간격은 깊이맵 기준 장면 단위입니다.</small>
<option value="dog1">멍멍이 1 · 잔디 위 흰 포메라니안</option> </details>
<option value="dog2">멍멍이 2 · 실내에 엎드린 검정·갈색 강아지</option> <label class="upload" for="video">동영상 선택<input id="video" type="file" accept="video/mp4,video/webm"></label>
<option value="dog3">멍멍이 3 · 후드 옷을 입은 강아지 두 마리</option> <label class="upload" for="depth-image">Depth 이미지 선택<input id="depth-image" type="file" accept="image/png,image/webp"></label>
<option value="dog4">멍멍이 4 · 벤치 위 갈색 닥스훈트</option> <label for="view-mode">View Mode<select id="view-mode">
</select></label>
<button id="sample">선택한 테스트 사진 불러오기</button>
<p id="region-fixed-note" class="control-note">Ref1 기준 · 색면 단순화 1 · 굴절 1 (고정)</p>
<label>Grid Size <output id="gridOut">13</output>
<input id="grid-size" type="range" min="10" max="24" step="1" value="13">
</label>
<small class="control-help">큰 얼굴 12–16 · 작은 얼굴/여러 마리 20–24</small>
<div id="gloss-controls" class="core-optics">
<label>셀 굴곡 (−1 오목 · +1 볼록) <output id="curvatureOut">1.00</output>
<input id="curvature" type="range" min="-1" max="1" step="0.01" value="1">
</label>
<small class="control-help">|굴곡| 0.65–1 권장</small>
<label>광택 강도 <output id="glossOut">0.30</output>
<input id="gloss-strength" type="range" min="0" max="0.4" step="0.01" value="0.30">
</label>
</div>
<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">
</label>
<small class="control-help">권장 0.35–0.65 · 시점 이동 범위</small>
<button id="reset">시점 초기화</button>
<button id="gyro">Gyroscope: OFF</button>
<details>
<summary>사진 구도</summary>
<div class="details-content">
<label>사진 확대 <output id="photoZoomOut">1.00</output>
<input id="photo-zoom" type="range" min="1" max="3" step="0.05" value="1">
</label>
<label>사진 가로 위치 <output id="photoPanXOut">0.00</output>
<input id="photo-pan-x" type="range" min="-1" max="1" step="0.01" value="0">
</label>
<label>사진 세로 위치 <output id="photoPanYOut">0.00</output>
<input id="photo-pan-y" type="range" min="-1" max="1" step="0.01" value="0">
</label>
<button id="photo-reset">사진 구도 초기화</button>
</div>
</details>
<details>
<summary>개발 비교</summary>
<div class="details-content">
<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>View Mode<select id="view-mode">
<option value="final">Final</option><option value="noglass">No Glass</option><option value="rgb">RGB</option><option value="depth">Depth</option><option value="glass">Glass Only</option> <option value="final">Final</option><option value="noglass">No Glass</option><option value="rgb">RGB</option><option value="depth">Depth</option><option value="glass">Glass Only</option>
</select></label> </select></label>
<label>Glass<select id="effect"> <label for="effect">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"> <label for="depth-scale">Depth Scale <output id="depthOut">0.25</output>
<option value="0">조각 유리</option><option value="1">촘촘 유리</option>
</select></label>
<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>
<label>조명 가로 <output id="lightXOut">0.60</output> <label for="light-x">조명 가로 <output id="lightXOut">0.60</output>
<input id="light-x" type="range" min="-2" max="2" step="0.05" value="0.6"> <input id="light-x" type="range" min="-2" max="2" step="0.05" value="0.6">
</label> </label>
<label>조명 세로 <output id="lightYOut">0.80</output> <label for="light-y">조명 세로 <output id="lightYOut">0.80</output>
<input id="light-y" type="range" min="-2" max="2" step="0.05" value="0.8"> <input id="light-y" type="range" min="-2" max="2" step="0.05" value="0.8">
</label> </label>
<label>View sensitivity <output id="sensitivityOut">1.00</output> <label for="sensitivity">View sensitivity <output id="sensitivityOut">1.00</output>
<input id="sensitivity" type="range" min="0.2" max="2" step="0.05" value="1"> <input id="sensitivity" type="range" min="0.2" max="2" step="0.05" value="1">
</label> </label>
</div>
</details> </div></details>
<p id="status" role="status"></p> <section class="processing-card" aria-label="사진 처리 상태"><div class="processing-copy"><p id="status" role="status" aria-live="polite">사진을 선택하면 AI가 입체감을 준비해요</p><p id="depth-provenance" hidden></p></div><button id="ai-depth" data-testid="ai-depth" class="quiet-button" disabled>AI 입체감 만들기</button></section>
</aside> </div>
</main>
</div> </div>
</body> </body>
</html> </html>
+105
View File
@@ -0,0 +1,105 @@
Baemin Kkubulim font licensing
Official font source: https://www.woowahan.com/fonts
Official copyright and license page: https://www.woowahan.com/fonts/license
OFL text source: https://openfontlicense.org/documents/OFL.txt
Retrieved 2026-10-06. Font-specific introductory notice and copyright/RFN statement below are copied from Woowa Brothers. Full OFL body is copied from SIL's official text.
The intellectual property rights of Hanna, Hanna Air, Hanna Pro, Jua, Dohyeon, Yeonsung, KirangHaerang, Euljiro, and Euljiro 10 Years Later, Baemin Geullim, Baemin Euljiro OraeOrae, BM Kkubulim fonts are held by Woowa Brothers.
Fonts distributed free of charge by Baedal Minjok (Hanna, Jua, Dohyeon, Yeonsung, KirangHaerang, Hanna Air, Hanna Pro, Euljiro, Euljiro 10 Years Later, Baemin Geullim, Baemin Euljiro OraeOrae, BM kkubulim) can be modified and hanged freely and used by both individual and corporate users for commercial or non-profit purposes.
However, please note that sales of the font file (OTF/TTF) is prohibited.
Images of prints, advertisements (including online advertisements), and other products using free fonts distributed by Baemin (Hanna, Jua, Dohyeon, Yeonsung, KirangHaerang, Hanna Air, Hanna Pro, Euljiro, Euljiro 10 Years Later, Baemin Geullim, Baemin Euljiro OraeOrae, BM Kkubulim) may be used for the company’s data collection and research purposes. If you wish to opt out of such use, please feel free to contact our Customer Service Center (+82-1600-0987 / CS@woowahan.com).
Provided that the full text of the "Intellectual Property Guide and License" below is shown, the fonts may be bundled, redistributed, or sold with other software.
Copyright © 2013, Woowa Brothers Corporation (https://www.woowahan.com), with Reserved Font Name BM HANNA 11yrs old, BM HANNA 11yrs old OTF, BMHANNAAir_otf, BMHANNAAir_ttf, BMHANNAPro_otf, BMHANNAPro_ttf, BM JUA_TTF, BM JUA_OTF, BM DoHyeon, BM DoHyeon OTF, BM YEONSUNG, BM YEONSUNG OTF, BM KIRANGHAERANG, BM KIRANGHAERANG OTF, BMEULJIRO, BMEULJIROTTF, BM EULJITO 10 YEARS LATER, BM EULJITO 10 YEARS LATER TTF, BMEuljirooraeoraeOTF, BMkkubulimTTF, BMkkubulim.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) Kamran Ahmed
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Hugging Face
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2024 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+222
View File
@@ -0,0 +1,222 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
MIT License
Copyright (c) 2020 Daniel Gatis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+182
View File
@@ -0,0 +1,182 @@
{
"name": "u2netp",
"revision": "sha256:309c8469258dda742793dce0ebea8e6dd393174f89934733ecc8b14c76f4ddd8",
"bytes": 4574861,
"sha256": "309c8469258dda742793dce0ebea8e6dd393174f89934733ecc8b14c76f4ddd8",
"md5": "8e83ca70e441ab06c318d82300c84806",
"source": {
"url": "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx",
"release": "v0.0.0",
"releaseAssetId": 85857953,
"assetUpdatedAt": "2022-11-24T19:22:32Z",
"publisher": "danielgatis",
"rembgRevision": "202e42649a8492a7c49f808de36608a7d1cbbfe3",
"upstreamU2NetRevision": "ac7e1c817ecab7c7dff5ce6b1abba61cd213ff29"
},
"tensorContract": {
"input": [
{
"name": "input.1",
"isTensor": true,
"type": "float32",
"shape": [
1,
3,
320,
320
]
}
],
"outputs": [
{
"name": "1959",
"isTensor": true,
"type": "float32",
"shape": [
1,
1,
320,
320
]
},
{
"name": "1960",
"isTensor": true,
"type": "float32",
"shape": [
1,
1,
320,
320
]
},
{
"name": "1961",
"isTensor": true,
"type": "float32",
"shape": [
1,
1,
320,
320
]
},
{
"name": "1962",
"isTensor": true,
"type": "float32",
"shape": [
1,
1,
320,
320
]
},
{
"name": "1963",
"isTensor": true,
"type": "float32",
"shape": [
1,
1,
320,
320
]
},
{
"name": "1964",
"isTensor": true,
"type": "float32",
"shape": [
1,
1,
320,
320
]
},
{
"name": "1965",
"isTensor": true,
"type": "float32",
"shape": [
1,
1,
320,
320
]
}
],
"selectedOutput": "1959",
"resize": [
320,
320
],
"layout": "NCHW",
"rgbScaling": "divide by max(maxRGB,1e-6)",
"mean": [
0.485,
0.456,
0.406
],
"std": [
0.229,
0.224,
0.225
],
"alphaPostprocess": "finite min/max normalize first output; uint8; restore canonical size"
},
"conversionHistory": {
"status": "NOT FULLY VERIFIED",
"known": {
"1": [
6
],
"2": [
"pytorch"
],
"3": [
"1.9"
],
"8": [
[
[
2,
0,
11
]
]
]
},
"missing": [
"exact original checkpoint hash",
"export script/revision used for this release artifact",
"fully reproducible conversion chain"
]
},
"license": {
"upstreamU2Net": "Apache-2.0",
"rembgCode": "MIT",
"redistributionStatus": "NOT CLEARED FOR INTEGRATION: upstream license and publisher source verified, exact conversion chain remains unverified",
"licenseFiles": [
"u2net-LICENSE",
"rembg-LICENSE.txt"
]
},
"qualityGate": "FAIL",
"browserRuntimeGate": "NOT RUN",
"localCpuEvidence": "u2netp-runtime-results.json",
"report": "model-gate.md",
"deploymentScope": {
"mode": "owner-private experiment",
"requiresExplicitMaskReviewForEveryPhoto": true,
"strictQualityGate": "failed",
"browserMobileGate": "not run",
"userApprovalDate": "2026-10-07",
"limitations": [
"general foreground model, not dog-only semantic segmentation",
"body regions may disappear and background may remain",
"not a person-photo blocking mechanism"
],
"downloadBytesMeaning": "Uncompressed model payload bytes, not measured network transfer bytes"
}
}
BIN
View File
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 804 KiB

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

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

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

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

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

Before

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

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