first commit

This commit is contained in:
2026-10-06 12:16:46 +09:00
commit 619d980098
415 changed files with 22283 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/node_modules
@@ -0,0 +1,38 @@
# 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.
@@ -0,0 +1,22 @@
# 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.
@@ -0,0 +1,52 @@
# 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.
@@ -0,0 +1,20 @@
# 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.
@@ -0,0 +1,19 @@
# 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.
@@ -0,0 +1,45 @@
# 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.
+70
View File
@@ -0,0 +1,70 @@
# Pet Glass / Pet Behind Glass 프로젝트 지침
## 원본 요구사항
- 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`.
- 코딩 전에 가정과 완료 기준을 명시하고 불확실한 중요한 요구사항을 드러낸다.
- 요구사항을 충족하는 최소 구현을 우선하며 불필요한 추상화와 범위 밖 기능은 추가하지 않는다.
- 기존 코드가 생긴 이후에는 작업과 직접 관련 있는 부분만 수정한다.
- 빌드·타입 검사와 실제 동작 검증을 구분하고, 실행하지 못한 검증을 통과했다고 보고하지 않는다.
- 주요 단계와 최종 결과에 수정 파일, 구현 기능, 미완료 항목, 실행 방법, 셰이더 원리, 다음 개선점을 간단히 보고한다.
+51
View File
@@ -0,0 +1,51 @@
# 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 복원은 현재 범위가 아닙니다.
+17
View File
@@ -0,0 +1,17 @@
Pet Behind Glass · AI depth third-party notices
Depth Anything V2 Small
Model: onnx-community/depth-anything-v2-small-ONNX
Model card: https://huggingface.co/onnx-community/depth-anything-v2-small-ONNX
Upstream project: https://github.com/DepthAnything/Depth-Anything-V2
License: Apache License 2.0
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.
Transformers.js
Package: @huggingface/transformers 3.8.1
Project: https://github.com/huggingface/transformers.js
License: Apache License 2.0
The full Apache License 2.0 text is provided in licenses/Apache-2.0.txt.
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
@@ -0,0 +1 @@
*{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.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+1
View File
@@ -0,0 +1 @@
const e="/assets/reference1-D4rK2azX.png";export{e as default};
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+1
View File
@@ -0,0 +1 @@
const e="/assets/reference2-6y7JVIce.png";export{e as default};
+56
View File
@@ -0,0 +1,56 @@
{
"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.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

+114
View File
@@ -0,0 +1,114 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pet Behind Glass</title>
<script type="module" crossorigin src="/assets/index-DOC1JJ07.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JVljdMQi.css">
</head>
<body>
<div id="app">
<main>
<div class="visual-column">
<div id="reference-strip" class="reference-strip" hidden role="group" aria-label="레퍼런스 비교 이미지">
<figure aria-labelledby="reference-caption-1">
<img id="reference-image-1" alt="">
<figcaption id="reference-caption-1">Ref1 · 주 기준</figcaption>
</figure>
<figure aria-labelledby="reference-caption-2">
<img id="reference-image-2" alt="">
<figcaption id="reference-caption-2">Ref2 · 보조</figcaption>
</figure>
<p id="reference-status" role="status" aria-live="polite" hidden>레퍼런스 이미지를 불러오지 못했습니다.</p>
</div>
<section class="stage">
<div id="canvas-wrap"><div id="empty">반려동물 사진 또는 영상을 선택하세요</div></div>
<p id="hint">화면을 드래그해서 시점을 움직여보세요</p>
</section>
</div>
<aside>
<h1>Pet Behind Glass</h1>
<label class="upload">사진 선택<input id="image" type="file" accept="image/jpeg,image/png,image/webp"></label>
<button id="ai-depth" data-testid="ai-depth" disabled>AI 깊이 생성</button>
<p id="depth-provenance" hidden aria-live="polite"></p>
<label>테스트 사진<select id="test-photo">
<option value="sample">기존 샘플 · 흰 강아지 얼굴</option>
<option value="dog1">멍멍이 1 · 잔디 위 흰 포메라니안</option>
<option value="dog2">멍멍이 2 · 실내에 엎드린 검정·갈색 강아지</option>
<option value="dog3">멍멍이 3 · 후드 옷을 입은 강아지 두 마리</option>
<option value="dog4">멍멍이 4 · 벤치 위 갈색 닥스훈트</option>
</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>
</select></label>
<label>Glass<select id="effect">
<option value="mosaic">Mosaic</option><option value="reeded">Reeded</option><option value="frosted">Frosted</option>
</select></label>
<label>Mosaic 스타일<select id="mosaic-style">
<option value="0">조각 유리</option><option value="1">촘촘 유리</option>
</select></label>
<label>Depth Scale <output id="depthOut">0.25</output>
<input id="depth-scale" type="range" min="0" max="0.4" step="0.01" value="0.25">
</label>
<label>조명 가로 <output id="lightXOut">0.60</output>
<input id="light-x" type="range" min="-2" max="2" step="0.05" value="0.6">
</label>
<label>조명 세로 <output id="lightYOut">0.80</output>
<input id="light-y" type="range" min="-2" max="2" step="0.05" value="0.8">
</label>
<label>View sensitivity <output id="sensitivityOut">1.00</output>
<input id="sensitivity" type="range" min="0.2" max="2" step="0.05" value="1">
</label>
</div>
</details>
<p id="status" role="status"></p>
</aside>
</main>
</div>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

+34
View File
@@ -0,0 +1,34 @@
# 사진용 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 조건으로 검토했으며 배포 시 라이선스 및 관련 고지를 보존한다.
+27
View File
@@ -0,0 +1,27 @@
# 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.
The source image is resized proportionally so its longest edge is at most 1024 pixels. Transformers.js preserves the image aspect ratio when preparing its model input and resizes the prediction to that same snapshot. The relative depth output is normalized per image to grayscale bytes: **0 is far/back and 255 is near/front**. The shader samples those bytes as normalized red-channel values from 0 to 1. Depth Anything V2 predicts relative inverse depth, so larger raw prediction values map to nearer points. This is an estimate, not measured distance or a complete 3D reconstruction.
AI depth can be generated for still photos only. While the photo is changing, a manual depth map is loading, or inference is running, the button is disabled. A source change cancels the worker request. A successful result replaces the active depth and switches to **No Glass** for parallax review; choose **Final** to inspect the Mosaic effect. A failure leaves the current depth in place and the button can be used to retry.
## Model and license
- Model: [`onnx-community/depth-anything-v2-small-ONNX`](https://huggingface.co/onnx-community/depth-anything-v2-small-ONNX), Apache-2.0.
- Runtime: [`@huggingface/transformers` 3.8.1](https://github.com/huggingface/transformers.js), Apache-2.0.
- 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.
+13
View File
@@ -0,0 +1,13 @@
# 비대칭 곡면·창 반사 보정
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
@@ -0,0 +1,62 @@
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
@@ -0,0 +1,56 @@
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
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,172 @@
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
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,152 @@
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
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,175 @@
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
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,26 @@
# 인접 셀 연결성 재보정
## 요구와 원인
유리는 공통 사진의 눈·코 윤곽을 셀 내부에서 확대/압축하되, 인접 셀이 서로 무관한 사진 조각을 가져오는 인상을 만들면 안 된다. 자동 확대·팬·크롭과 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
@@ -0,0 +1,11 @@
# 셀 곡면 보정 설계
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
@@ -0,0 +1,24 @@
# 셀 곡면 수정 검증
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
@@ -0,0 +1,13 @@
# 얼굴이 유리를 채우는 구도
사용자 요청: 멀리 떨어진 강아지 사진을 유리 너머로 보는 인상보다, 강아지가 유리 바로 뒤에 있고 얼굴이 크게 보이는 구도를 원한다.
현재 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
@@ -0,0 +1,18 @@
# 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
@@ -0,0 +1,50 @@
# 근접 배치 이후 시점 변형 약화 검토
## 수정 완료 — 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
@@ -0,0 +1,26 @@
# 현재 상태 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
@@ -0,0 +1,56 @@
# 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
@@ -0,0 +1,904 @@
[수정 작업지시서]
프로젝트명
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
@@ -0,0 +1,43 @@
# 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
@@ -0,0 +1,23 @@
# 유리와 피사체 간격에 따른 선명도 — 이전 실험 기록
> 현재 동작: 사용자의 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
@@ -0,0 +1,10 @@
# 고정 창틀 검증
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 출력은 창틀 외부까지 이어질 수 있다. 목재 텍스처나 그림자는 구현하지 않고 면별 갈색 명암으로 깊이를 표현한다.
+40
View File
@@ -0,0 +1,40 @@
# 유리 바로 뒤의 피사체 배치
최신 변경: 사용자가 자동 구도 조정을 제외하도록 요청했다. 아래 자동 구도 보완·검증은 과거 실험 기록이다. 현재 동작에서는 사진별 확대 프리셋과 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.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+240
View File
@@ -0,0 +1,240 @@
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
@@ -0,0 +1,48 @@
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.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+21
View File
@@ -0,0 +1,21 @@
# 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
@@ -0,0 +1,38 @@
# 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.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

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

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

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

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

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

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

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

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+72
View File
@@ -0,0 +1,72 @@
# 레퍼런스 재비교 QA / 설정 / 제품 제시 검토
2026-09-13. 소스 변경 없이 수행. karpathy-guidelines 적용, GPT-6 Astra 독립 정적 검토와 주 에이전트 실제 브라우저 비교. 코드 변경이 없으므로 Luna 구현 작업은 없음.
## 기준과 결론
지정 세션 `01a08e32-c332-7262-b2f2-537cb07bac41`에서 첨부 경로를 회수하고 원본 레퍼런스 1·2를 직접 열었다. 주 기준은 두 번째 `codex-clipboard-8f482fae-74a8-4a4f-9f11-e7be24c6638f.png`. 레퍼런스와 테스트는 서로 다른 사진이다. 같은 테스트 사진/구도에서 설정을 비교하되 픽셀 일치나 물리 재현도를 수치로 주장하지 않는다.
핵심 동작은 확인되지만 레퍼런스의 시각적 완성도에는 미달한다. 레퍼런스는 일부 눈·코가 확대되고 주변 털·배경은 넓게 늘어나며 매끈한 유리 면을 이룬다. 현재는 눈·코 식별은 가능하나 사진을 사각형으로 어긋나게 붙인 느낌이 강하고, 털 세부가 많은 셀과 날카롭게 끊기는 경계가 여전히 눈에 띈다. 샘플의 어두운 차량 배경과 좁고 긴 원본 비율도 레퍼런스의 밝고 넓은 화면과 다르다. 설정만으로 해결되는 차이와 셰이더/원본 차이를 구분해야 한다.
## 관찰한 설정 후보
아래는 수동으로 비교한 후보이며 전 범위 최적화 결과가 아니다. 공통 Final / Mosaic / 조각 유리 / 사진 Y -0.35 / Depth .25 / 조명 .60,.80 / 정면.
| 후보 | 확대/X | Grid | Distortion | 굴곡 | 광택 | 판단 |
|---|---|---|---|---|---|---|
| 현재 기본 | 2.4 / -.10 | 10 | .90 | +.65 | .30 | 표정은 보이나 얼굴 대비 조각이 작고 배경 비중이 큼 |
| 완만한 큰 셀 | 2.4 / -.10 | 8 | .80 | +.30 | .15 | 덜 거칠지만 레퍼런스의 확대된 얼굴 인상이 약함 |
| 오목 비교 | 2.4 / -.10 | 10 | 1.00 | -.45 | .15 | 표면 경계는 달라져도 핵심 조각 배율 차이는 해결되지 않음 |
| 강한 굴절 비교 | 2.4 / -.10 | 10 | 1.00 | +.65 | .15 | 코·입의 사각 단절이 남음 |
| 촘촘한 확대 | 3 / -.15 | 12 | .90 | +.35 | .15 | 작은 절편이 많아져 목표에서 멀어짐 |
| 중간 확대 | 2.8 / -.10 | 8 | .85 | +.35 | .15 | 무난하지만 눈·코가 셀 경계에 걸리는 배치 |
| 가장 큰 셀 | 2.8 / -.10 | 6 | .85 | +.65 | .15 | 큰 조각은 생기지만 눈이 눌리고 배치가 덜 안정적 |
| **추천 후보** | **3 / -.15** | **8** | **.90** | **+.35** | **.15** | 비교 후보 중 큰 눈·코와 표정 식별의 균형이 가장 나음 |
추천의 나머지 값: Camera Offset .20, View sensitivity .70, Gyroscope OFF, Reset view로 정면 복귀. 카메라/감도 값은 약한 조작감을 위한 선택이며 정면 정지 화면 유사도에는 기여하지 않는다. 조명 위치는 기존 유지값으로, 광원 위치 전 범위 최적화를 수행하지 않았다. 확대3은 현재 UI 상한이며 귀/얼굴 외곽이 잘리므로 얼굴 중심 효과용이다. 이 값을 일반 사진의 보편적 프리셋으로 적용하면 안 된다. 권장 탭에만 적용했고 소스의 기본 +.65를 포함한 기본값은 변경하지 않았다.
## 실제 QA
- npm.cmd run build: TypeScript + Vite 성공. 테스트 스위트 성공을 의미하지 않는다.
- Glass OFF를 먼저 확인. 샘플 기본 구도에서 cameraX 0→.101: 코 projectedX 531.509→528.550, 왼눈 459.283→457.405, 몸 350.477→350.486 px. 부위별 이동 차이가 있는 실제 카메라 parallax를 진단 출력에서 확인.
- Final/No Glass, Mosaic/Reeded/Frosted, 굴곡 -.45/+.30/+.35/+.65, 여러 Grid/확대/굴절 값을 화면 비교.
- 추천 후보를 1280×720 및 390×844에서 확인. 모바일 눈·코 식별 가능. 모바일 드래그에서 액자/유리 경계 유지, cameraX .055 관측. 최대 이동 범위 검증은 아님. 뷰포트 override 복원.
- 모바일 광택0에서도 사진 굴절이 남음. 다만 곡면 기여와 셀별 샘플 변형 기여를 정량적으로 분리한 검증은 아님.
- 현재 Reeded/Frosted 정면 화면은 액자 안에 유지된다. 이전 QA의 외곽 노출 결함을 그대로 재보고하지 않는다. 타 효과의 드래그 좌표 일관성은 이번에 검증하지 않음.
- 새 탭 첫 Tab으로 사진 업로드 input에 초점 도달. 업로드 키보드 접근의 이전 문제가 개선됨. 파일 선택/업로드 완료 전체 흐름은 재실행하지 않음.
- 수작업 Depth 안내가 샘플 로딩 후 계속 표시됨.
- 브라우저 error 로그 빈 배열. 영상, 실제 모바일 센서/터치, 프레임률, GPU 호환성, 장시간 메모리는 미검증.
## 남은 문제와 구현상 한계
1. 실제 재현: Distortion .01→0에서 확대 사진이 액자 밖까지 펼쳐진다. 원본 무효과 출력을 유지하는 기존 계약의 결과이므로 무조건 셰이더 bypass를 제거할 사안은 아니다. 사용자용 전후 비교는 동일 구도/동일 액자 영역을 유지하는 별도 표현이 필요하다.
2. 실제 재현: Reeded 상태에서도 Mosaic 스타일 선택이 활성화되어 있고 촘촘 유리로 바꾸면 Grid28 / Distortion.85로 덮어쓴다. 해당 컨트롤을 Mosaic에만 노출하는 것이 명확하다.
3. 정적 확인: 일반 사진은 구도1/0/0 및 평탄 Depth로 로딩된다. 샘플은 전용 확대와 수작업 Depth가 있다. 일반 사진에서 Depth Scale을 높여도 코·눈의 상대 깊이를 생성하지 않는다. 샘플 수준의 입체감을 일반 업로드 결과로 약속할 수 없다.
4. 정적 확인: 셀 확대/압축은 고정 난수이며 눈·코를 인식하지 않는다. 원하는 부위가 큰 셀에 들어가는지는 구도/Grid에 의존한다. 현재 값으로 모든 사진을 레퍼런스처럼 만들 수 없다.
5. 제품상 부족: 저장/내보내기/프리셋 영속 저장이 없다. 화면을 만져보고 끝나는 상태라 공유형 제품의 핵심 완료 행동이 비어 있다.
셰이더는 Depth mesh를 RT로 그린 뒤 유리판 기준 셀별 이동·축별 배율·회전/주변 샘플을 적용하며, 얕은 사각 곡면의 법선을 굴절과 창 반사에 공유한다. 다음 시각 개선은 광택 증가보다 셀 내부의 연속적인 늘어남과 경계 전이, 특징이 남는 셀과 넓은 색면 셀의 정보량 차이에 집중해야 한다. 모든 셀 흐림이나 단색 픽셀화는 권하지 않는다.
## 보여주는 방식
권장 포지셔닝은 ‘내 반려동물이 유리창 뒤에서 빼꼼 보는 사진 효과’. 현재 기능 범위에서는 로컬 사진 효과/감상 도구로 소개하고, 고급 깊이 편집기는 뒤로 둔다.
첫 화면: 결과 예시 크게 → 짧은 드래그 안내 → 사진 선택. 사용자의 첫 결과는 사진 선택 → 얼굴 구도 → 프리셋 2~3개 → 강도 조절 순서. Depth, RGB, Glass Only, 조명XY, 진단 수치는 고급/개발 패널로 이동하는 설계가 좋다. 모바일은 결과를 보면서 얼굴 구도/강도를 바꿀 수 있게 하단 패널과 미리보기를 결합하는 편이 현재의 긴 세로 슬라이더 목록보다 적합하다.
스타일명은 ‘Classic glass / Soft glass / Chunky glass’처럼 결과 중심으로 제안한다. 실제 구현/검증한 프리셋 이름은 아니며, 본 QA가 새로운 스타일을 추가한 것은 아니다. 주된 결과는 큰 눈·코가 남는 조각 유리로 두고 과도한 왜곡은 선택지로 제공한다. 공유 기능을 추가할 경우 9:16 짧은 좌우 시점 루프와 정지 이미지가 후보지만 현재 미구현이다. 정지 이미지 자체는 시점 상호작용을 보존하지 못하므로 제품 소개에서 구분한다.
## 타깃 판단: 검증할 가설
‘해외 남성이 주 수요층’이라는 결론을 이 QA로 확인할 수 없다. 성별별 취향을 전제하기보다 제작·저장·공유하려는 반려인과 시각 효과를 실험하는 사람을 나누어 본다. 영어권에서 신기한 WebGL 장난감으로 초기 반응을 얻는 것과, 반복 제작/저장/결제 수요는 서로 다르다. 여성 타깃을 지금 버릴 근거도 없다.
외부 맥락: Pew의 2025 미국 성인 조사에서 Instagram 이용률은 여성55%/남성44%, Reddit과 X는 남성이 더 높다. 이는 채널 선택 참고이며 이 효과의 수요나 해외 전체 성향을 입증하지 않는다. 출처: https://www.pewresearch.org/internet/2025/11/20/americans-social-media-use-2025/
작은 검증을 권한다. 같은 언어·채널 안에서 같은 결과 영상을 쓰고 ‘귀여운 내 반려동물 결과 만들기’와 ‘직접 움직여 보는 유리 효과’ 메시지만 나눈다. 채널 간 성과 차이를 성별 효과로 해석하지 않는다. 클릭보다 자기 사진 선택→만족스러운 결과 완성→두 번째 사진 시도, 향후 저장/공유 완료와 재방문을 본다. 실제 내보내기 구현 전엔 내보내기 완료율을 측정했다고 주장할 수 없다. 현재 판단은 영어권 테스트는 열되, 남성 전용 브랜드로 좁히지 않는 것이다.
## 보존물
- default-desktop.png: 기본 시각값 재적용 정면. Camera Offset/.감도는 추천값이나 정면 정지에는 영향 없음.
- recommended-desktop.png: 권장 후보 데스크톱.
- recommended-mobile.png: 같은 후보 모바일.
모든 이미지와 샘플 사용은 로컬 QA이며 게시하지 않았다. 실행은 npm.cmd run dev, 브라우저 localhost:5173에서 샘플 로드 후 표의 값 적용. 개선 우선순위는 일반 사진 첫 결과/구도 조절 → 셀 내부 굴절 질감 → 저장/공유 완료 흐름 → 타깃 실험이다.
+123
View File
@@ -0,0 +1,123 @@
uniform sampler2D uSceneTexture;
uniform mat4 uContentViewProjection,uContentInverseViewProjection;
uniform vec3 uContentCameraPosition,uLightPosition,uBackgroundColor;
uniform float uGlassZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlossStrength;
uniform vec2 uViewOffset,uPetSize,uPaneSize;
uniform vec4 uPaneRect;
varying vec2 vUv;
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;}
vec4 sampleScene(vec2 uv){return texture2D(uSceneTexture,clamp(uv,0.,1.));}
vec4 samplePane(vec2 pane){return sampleScene(projectUv(vec3((pane-.5)*uPaneSize,uGlassZ)));}
vec4 sampleCell(vec2 pane,vec2 sourceUv,float strength,vec2 blurStep){
vec4 color=samplePane(mix(pane,sourceUv,strength))*.4;
color+=samplePane(mix(pane,sourceUv+vec2(blurStep.x,0.),strength))*.15;
color+=samplePane(mix(pane,sourceUv-vec2(blurStep.x,0.),strength))*.15;
color+=samplePane(mix(pane,sourceUv+vec2(0.,blurStep.y),strength))*.15;
color+=samplePane(mix(pane,sourceUv-vec2(0.,blurStep.y),strength))*.15;
return color;
}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2 hash2(vec2 p){return vec2(hash(p),hash(p+vec2(19.19,73.73)));}
float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;}
vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
float softLimit(float value,float limit){return value*limit/(limit+abs(value));}
void evaluateCell(vec2 cell,vec2 local,vec2 grid,vec2 cellSize,vec2 cellDimensions,float cellWorld,float style,out vec2 affineUv,out mat2 transform,out vec2 curvedSlope,out vec2 facetSlope){
vec2 random=hash2(cell),widths;
if(style>.5){
widths=mix(vec2(.82),vec2(1.12),hash2(cell+vec2(11.4,9.8)));
}else{
float widthSeed=hash(cell+vec2(53.1,27.4));
if(widthSeed<.3)widths=mix(vec2(.90),vec2(1.08),hash2(cell+vec2(7.2,3.1)));
else if(widthSeed<.9)widths=mix(vec2(.46),vec2(.70),hash2(cell+vec2(7.2,3.1)));
else widths=mix(vec2(1.10),vec2(1.28),hash2(cell+vec2(7.2,3.1)));
}
float angle=(random.x-.5)*.08,c=cos(angle),s=sin(angle);
transform=mat2(widths.x*c,-widths.y*s,widths.x*s,widths.y*c);
vec2 sourceCenter=(cell+.5)/grid+(random-.5)*.16*cellSize;
affineUv=sourceCenter+transform*(local*cellSize);
// Positive height bulges toward the observer (+Z); negative height is concave.
float curvature=clamp(uCurvature,-1.,1.),excess=max(curvature-.65,0.),surfaceCurvature=curvature-(.25/.35)*excess*excess;
float height=.009*cellWorld*surfaceCurvature*mix(.92,1.08,hash(cell+vec2(41.7,17.3)));
vec2 q=clamp(2.*local,-1.,1.),q2=q*q,q3=q2*q,b=1.-q2*q2;
vec2 skew=(hash2(cell+vec2(23.8,61.2))-.5)*.28;float asym=1.+dot(skew,q);
vec2 dhdq=height*vec2(-4.*q3.x*b.y*asym+b.x*b.y*skew.x,-4.*q3.y*b.x*asym+b.x*b.y*skew.y);
vec2 grad=-2.*dhdq/cellDimensions;
curvedSlope=grad+(random-.5)*.012;facetSlope=(random-.5)*.012;
}
void main(){
if(uStrength<=.001){gl_FragColor=sampleScene(vUv);
#include <colorspace_fragment>
return;}
vec4 f=uContentInverseViewProjection*vec4(vUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid;float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
if(paneUv.x<0.||paneUv.x>1.||paneUv.y<0.||paneUv.y>1.){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}
vec2 cellDimensions=uPaneSize/grid;
vec2 affineUv,curvedSlope,facetSlope;mat2 transform;
evaluateCell(cell,local,grid,cellSize,cellDimensions,cellWorld,style,affineUv,transform,curvedSlope,facetSlope);
vec2 edge=.5-abs(local),neighborSign=mix(vec2(-1.),vec2(1.),step(vec2(0.),local));
// Keep a broad shared surface-normal transition; color footprints are blended separately.
vec2 blendAxis=1.-smoothstep(vec2(-.16),vec2(.16),edge);
vec2 weights=blendAxis;
vec2 centerAffine=affineUv;mat2 centerTransform=transform;
vec2 xAffine=vec2(0.),yAffine=vec2(0.),diagonalAffine=vec2(0.);
mat2 xTransform=mat2(0.),yTransform=mat2(0.),diagonalTransform=mat2(0.);
float xSplitWeight=weights.x*(1.-weights.y),ySplitWeight=(1.-weights.x)*weights.y;
float diagonalSplitWeight=weights.x*weights.y,centerSplitWeight=(1.-weights.x)*(1.-weights.y);
vec2 colorAxis=1.-smoothstep(vec2(-.06),vec2(.06),edge);
float colorXWeight=colorAxis.x*(1.-colorAxis.y),colorYWeight=(1.-colorAxis.x)*colorAxis.y;
float colorDiagonalWeight=colorAxis.x*colorAxis.y,colorCenterWeight=(1.-colorAxis.x)*(1.-colorAxis.y);
if(weights.x>0.||weights.y>0.){
vec2 neighborAffine,neighborCurved,neighborFacet;mat2 neighborTransform;
vec2 xWeight=vec2(xSplitWeight,0.);
vec2 yWeight=vec2(0.,ySplitWeight);
affineUv*=centerSplitWeight;transform*=centerSplitWeight;curvedSlope*=centerSplitWeight;facetSlope*=centerSplitWeight;
if(xWeight.x>0.){
vec2 offset=vec2(neighborSign.x,0.);
evaluateCell(cell+offset,local-offset,grid,cellSize,cellDimensions,cellWorld,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
xAffine=neighborAffine;xTransform=neighborTransform;
affineUv+=neighborAffine*xWeight.x;transform+=neighborTransform*xWeight.x;curvedSlope+=neighborCurved*xWeight.x;facetSlope+=neighborFacet*xWeight.x;
}
if(yWeight.y>0.){
vec2 offset=vec2(0.,neighborSign.y);
evaluateCell(cell+offset,local-offset,grid,cellSize,cellDimensions,cellWorld,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
yAffine=neighborAffine;yTransform=neighborTransform;
affineUv+=neighborAffine*yWeight.y;transform+=neighborTransform*yWeight.y;curvedSlope+=neighborCurved*yWeight.y;facetSlope+=neighborFacet*yWeight.y;
}
if(diagonalSplitWeight>0.){
vec2 offset=neighborSign;
evaluateCell(cell+offset,local-offset,grid,cellSize,cellDimensions,cellWorld,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
diagonalAffine=neighborAffine;diagonalTransform=neighborTransform;
affineUv+=neighborAffine*diagonalSplitWeight;transform+=neighborTransform*diagonalSplitWeight;curvedSlope+=neighborCurved*diagonalSplitWeight;facetSlope+=neighborFacet*diagonalSplitWeight;
}
}
vec3 nCurved=normalize(vec3(curvedSlope,1.)),nFacet=normalize(vec3(facetSlope,1.));
vec3 straightHit=gh+i*(-gh.z/safeZ(i.z));vec3 facetRay=bend(i,nFacet),curvedRay=bend(i,nCurved);vec3 facetHit=gh+facetRay*(-gh.z/safeZ(facetRay.z)),curvedHit=gh+curvedRay*(-gh.z/safeZ(curvedRay.z));vec2 base=facetHit.xy-straightHit.xy,curve=curvedHit.xy-facetHit.xy;vec2 worldDelta=vec2(softLimit(base.x,cellWorld*.08),softLimit(base.y,cellWorld*.08));vec2 d=curve/cellWorld;worldDelta+=vec2(.3*d.x/(.3+abs(d.x)),.3*d.y/(.3+abs(d.y)))*cellWorld;
vec2 sampleUv=affineUv+transform*(worldDelta/uPaneSize);
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*.75,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;vec2 blurStep=cellSize*mix(.006,.012,hash(cell+vec2(5.)));
vec4 color;
// The .06-.16 band must retain the independent center transform to avoid reintroducing UV compression.
if(style<=.5&&(weights.x>0.||weights.y>0.)){
color=vec4(0.);
if(colorCenterWeight>0.)color+=sampleCell(paneUv,centerAffine+centerTransform*(worldDelta/uPaneSize),strength,blurStep)*colorCenterWeight;
if(colorXWeight>0.){
vec2 xBlurStep=cellSize*mix(.006,.012,hash(cell+vec2(neighborSign.x,0.)+vec2(5.)));
color+=sampleCell(paneUv,xAffine+xTransform*(worldDelta/uPaneSize),strength,xBlurStep)*colorXWeight;
}
if(colorYWeight>0.){
vec2 yBlurStep=cellSize*mix(.006,.012,hash(cell+vec2(0.,neighborSign.y)+vec2(5.)));
color+=sampleCell(paneUv,yAffine+yTransform*(worldDelta/uPaneSize),strength,yBlurStep)*colorYWeight;
}
if(colorDiagonalWeight>0.){
vec2 diagonalBlurStep=cellSize*mix(.006,.012,hash(cell+neighborSign+vec2(5.)));
color+=sampleCell(paneUv,diagonalAffine+diagonalTransform*(worldDelta/uPaneSize),strength,diagonalBlurStep)*colorDiagonalWeight;
}
}else{
color=sampleCell(paneUv,sampleUv,strength,blurStep);
}
color.a=1.;
vec3 v=normalize(uContentCameraPosition-gh),reflected=reflect(-v,nCurved); float lightT=(uLightPosition.z-gh.z)/safeZ(reflected.z); vec2 lightHit=gh.xy+reflected.xy*lightT; vec2 halfSize=vec2(.32,.42)*min(uPaneSize.x,uPaneSize.y); vec2 windowCoord=abs(lightHit-uLightPosition.xy)/halfSize; vec2 windowMask=1.-smoothstep(vec2(.45),vec2(1.15),windowCoord); float spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float gloss=spec*clamp(uGlossStrength,0.,1.)*.12*edgeWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),gloss); gl_FragColor=color;
#include <colorspace_fragment>
}
+131
View File
@@ -0,0 +1,131 @@
uniform sampler2D uSceneTexture;
uniform mat4 uContentViewProjection,uContentInverseViewProjection;
uniform vec3 uContentCameraPosition,uLightPosition,uBackgroundColor;
uniform float uGlassZ,uGridSize,uStrength,uMosaicStyle,uCurvature,uGlossStrength;
uniform vec2 uViewOffset,uPetSize,uPaneSize;
uniform vec4 uPaneRect;
varying vec2 vUv;
vec2 projectUv(vec3 p){vec4 c=uContentViewProjection*vec4(p,1.);float w=abs(c.w)<.0001?(c.w<0.?-.0001:.0001):c.w;return c.xy/w*.5+.5;}
vec4 sampleScene(vec2 uv){return texture2D(uSceneTexture,clamp(uv,0.,1.));}
vec4 samplePane(vec2 pane){return sampleScene(projectUv(vec3((pane-.5)*uPaneSize,uGlassZ)));}
vec4 sampleCell(vec2 pane,vec2 sourceUv,float strength,vec2 blurStep,vec2 cellSize){
vec2 center=mix(pane,sourceUv,strength);
// Limit Mosaic shifts to reduce missing strips at cell edges.
if(uMosaicStyle<=.5){
vec2 d=(center-pane)/cellSize;
vec2 dSafe=d/sqrt(vec2(1.)+(d/.18)*(d/.18));
center=pane+dSafe*cellSize;
}
vec2 blurOffset=strength*blurStep;
vec4 color=samplePane(center)*.4;
color+=samplePane(center+vec2(blurOffset.x,0.))*.15;
color+=samplePane(center-vec2(blurOffset.x,0.))*.15;
color+=samplePane(center+vec2(0.,blurOffset.y))*.15;
color+=samplePane(center-vec2(0.,blurOffset.y))*.15;
return color;
}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
vec2 hash2(vec2 p){return vec2(hash(p),hash(p+vec2(19.19,73.73)));}
float safeZ(float z){return abs(z)<.0001?(z<0.?-.0001:.0001):z;}
vec3 bend(vec3 i,vec3 n){vec3 r=refract(i,n,.75);return length(r)<.0001?i:r;}
float softLimit(float value,float limit){return value*limit/(limit+abs(value));}
void evaluateCell(vec2 cell,vec2 local,vec2 grid,vec2 cellSize,vec2 cellDimensions,float cellWorld,float style,out vec2 affineUv,out mat2 transform,out vec2 curvedSlope,out vec2 facetSlope){
vec2 random=hash2(cell),widths;
if(style>.5){
widths=mix(vec2(.82),vec2(1.12),hash2(cell+vec2(11.4,9.8)));
}else{
float widthSeed=hash(cell+vec2(53.1,27.4));
if(widthSeed<.3)widths=mix(vec2(.90),vec2(1.08),hash2(cell+vec2(7.2,3.1)));
else if(widthSeed<.9)widths=mix(vec2(.46),vec2(.70),hash2(cell+vec2(7.2,3.1)));
else widths=mix(vec2(1.10),vec2(1.28),hash2(cell+vec2(7.2,3.1)));
}
float angle=(random.x-.5)*.08,c=cos(angle),s=sin(angle);
transform=mat2(widths.x*c,-widths.y*s,widths.x*s,widths.y*c);
vec2 sourceCenter=(cell+.5)/grid+(random-.5)*.16*cellSize;
affineUv=sourceCenter+transform*(local*cellSize);
// Positive height bulges toward the observer (+Z); negative height is concave.
float curvature=clamp(uCurvature,-1.,1.),excess=max(curvature-.65,0.),surfaceCurvature=curvature-(.25/.35)*excess*excess;
float height=.009*cellWorld*surfaceCurvature*mix(.92,1.08,hash(cell+vec2(41.7,17.3)));
vec2 q=clamp(2.*local,-1.,1.),q2=q*q,q3=q2*q,b=1.-q2*q2;
vec2 skew=(hash2(cell+vec2(23.8,61.2))-.5)*.28;float asym=1.+dot(skew,q);
vec2 dhdq=height*vec2(-4.*q3.x*b.y*asym+b.x*b.y*skew.x,-4.*q3.y*b.x*asym+b.x*b.y*skew.y);
vec2 grad=-2.*dhdq/cellDimensions;
curvedSlope=grad+(random-.5)*.012;facetSlope=(random-.5)*.012;
}
void main(){
if(uStrength<=.001){gl_FragColor=sampleScene(vUv);
#include <colorspace_fragment>
return;}
vec4 f=uContentInverseViewProjection*vec4(vUv*2.-1.,1.,1.);float fw=abs(f.w)<.0001?(f.w<0.?-.0001:.0001):f.w;vec3 farWorld=f.xyz/fw;vec3 i=normalize(farWorld-uContentCameraPosition);float gt=(uGlassZ-uContentCameraPosition.z)/safeZ(i.z);vec3 gh=uContentCameraPosition+i*gt;
vec2 paneUv=gh.xy/uPaneSize+.5;vec2 grid=vec2(uGridSize,max(1.,uGridSize*uPaneSize.y/uPaneSize.x));vec2 gridUv=paneUv*grid,cell=floor(gridUv),local=fract(gridUv)-.5,cellSize=1./grid;float style=clamp(uMosaicStyle,0.,1.),cellWorld=min(uPaneSize.x/grid.x,uPaneSize.y/grid.y);
if(paneUv.x<0.||paneUv.x>1.||paneUv.y<0.||paneUv.y>1.){gl_FragColor=vec4(uBackgroundColor,1.);
#include <colorspace_fragment>
return;}
vec2 cellDimensions=uPaneSize/grid;
vec2 affineUv,curvedSlope,facetSlope;mat2 transform;
evaluateCell(cell,local,grid,cellSize,cellDimensions,cellWorld,style,affineUv,transform,curvedSlope,facetSlope);
vec2 edge=.5-abs(local),neighborSign=mix(vec2(-1.),vec2(1.),step(vec2(0.),local));
// Keep a broad shared surface-normal transition; color footprints are blended separately.
vec2 blendAxis=1.-smoothstep(vec2(-.16),vec2(.16),edge);
vec2 weights=blendAxis;
vec2 centerAffine=affineUv;mat2 centerTransform=transform;
vec2 xAffine=vec2(0.),yAffine=vec2(0.),diagonalAffine=vec2(0.);
mat2 xTransform=mat2(0.),yTransform=mat2(0.),diagonalTransform=mat2(0.);
float xSplitWeight=weights.x*(1.-weights.y),ySplitWeight=(1.-weights.x)*weights.y;
float diagonalSplitWeight=weights.x*weights.y,centerSplitWeight=(1.-weights.x)*(1.-weights.y);
vec2 colorAxis=1.-smoothstep(vec2(-.06),vec2(.06),edge);
float colorXWeight=colorAxis.x*(1.-colorAxis.y),colorYWeight=(1.-colorAxis.x)*colorAxis.y;
float colorDiagonalWeight=colorAxis.x*colorAxis.y,colorCenterWeight=(1.-colorAxis.x)*(1.-colorAxis.y);
if(weights.x>0.||weights.y>0.){
vec2 neighborAffine,neighborCurved,neighborFacet;mat2 neighborTransform;
vec2 xWeight=vec2(xSplitWeight,0.);
vec2 yWeight=vec2(0.,ySplitWeight);
affineUv*=centerSplitWeight;transform*=centerSplitWeight;curvedSlope*=centerSplitWeight;facetSlope*=centerSplitWeight;
if(xWeight.x>0.){
vec2 offset=vec2(neighborSign.x,0.);
evaluateCell(cell+offset,local-offset,grid,cellSize,cellDimensions,cellWorld,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
xAffine=neighborAffine;xTransform=neighborTransform;
affineUv+=neighborAffine*xWeight.x;transform+=neighborTransform*xWeight.x;curvedSlope+=neighborCurved*xWeight.x;facetSlope+=neighborFacet*xWeight.x;
}
if(yWeight.y>0.){
vec2 offset=vec2(0.,neighborSign.y);
evaluateCell(cell+offset,local-offset,grid,cellSize,cellDimensions,cellWorld,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
yAffine=neighborAffine;yTransform=neighborTransform;
affineUv+=neighborAffine*yWeight.y;transform+=neighborTransform*yWeight.y;curvedSlope+=neighborCurved*yWeight.y;facetSlope+=neighborFacet*yWeight.y;
}
if(diagonalSplitWeight>0.){
vec2 offset=neighborSign;
evaluateCell(cell+offset,local-offset,grid,cellSize,cellDimensions,cellWorld,style,neighborAffine,neighborTransform,neighborCurved,neighborFacet);
diagonalAffine=neighborAffine;diagonalTransform=neighborTransform;
affineUv+=neighborAffine*diagonalSplitWeight;transform+=neighborTransform*diagonalSplitWeight;curvedSlope+=neighborCurved*diagonalSplitWeight;facetSlope+=neighborFacet*diagonalSplitWeight;
}
}
vec3 nCurved=normalize(vec3(curvedSlope,1.)),nFacet=normalize(vec3(facetSlope,1.));
vec3 straightHit=gh+i*(-gh.z/safeZ(i.z));vec3 facetRay=bend(i,nFacet),curvedRay=bend(i,nCurved);vec3 facetHit=gh+facetRay*(-gh.z/safeZ(facetRay.z)),curvedHit=gh+curvedRay*(-gh.z/safeZ(curvedRay.z));vec2 base=facetHit.xy-straightHit.xy,curve=curvedHit.xy-facetHit.xy;vec2 worldDelta=vec2(softLimit(base.x,cellWorld*.08),softLimit(base.y,cellWorld*.08));vec2 d=curve/cellWorld;worldDelta+=vec2(.3*d.x/(.3+abs(d.x)),.3*d.y/(.3+abs(d.y)))*cellWorld;
vec2 sampleUv=affineUv+transform*(worldDelta/uPaneSize);
float edgeDistance=min(min(paneUv.x,paneUv.y),min(1.-paneUv.x,1.-paneUv.y));float edgeWeight=smoothstep(0.,max(cellSize.x,cellSize.y)*.75,edgeDistance);float strength=clamp(uStrength,0.,1.)*edgeWeight;vec2 blurStep=cellSize*mix(.006,.012,hash(cell+vec2(5.)));
vec4 color;
// The .06-.16 band must retain the independent center transform to avoid reintroducing UV compression.
if(style<=.5&&(weights.x>0.||weights.y>0.)){
color=vec4(0.);
if(colorCenterWeight>0.)color+=sampleCell(paneUv,centerAffine+centerTransform*(worldDelta/uPaneSize),strength,blurStep,cellSize)*colorCenterWeight;
if(colorXWeight>0.){
vec2 xBlurStep=cellSize*mix(.006,.012,hash(cell+vec2(neighborSign.x,0.)+vec2(5.)));
color+=sampleCell(paneUv,xAffine+xTransform*(worldDelta/uPaneSize),strength,xBlurStep,cellSize)*colorXWeight;
}
if(colorYWeight>0.){
vec2 yBlurStep=cellSize*mix(.006,.012,hash(cell+vec2(0.,neighborSign.y)+vec2(5.)));
color+=sampleCell(paneUv,yAffine+yTransform*(worldDelta/uPaneSize),strength,yBlurStep,cellSize)*colorYWeight;
}
if(colorDiagonalWeight>0.){
vec2 diagonalBlurStep=cellSize*mix(.006,.012,hash(cell+neighborSign+vec2(5.)));
color+=sampleCell(paneUv,diagonalAffine+diagonalTransform*(worldDelta/uPaneSize),strength,diagonalBlurStep,cellSize)*colorDiagonalWeight;
}
}else{
color=sampleCell(paneUv,sampleUv,strength,blurStep,cellSize);
}
color.a=1.;
vec3 v=normalize(uContentCameraPosition-gh),reflected=reflect(-v,nCurved); float lightT=(uLightPosition.z-gh.z)/safeZ(reflected.z); vec2 lightHit=gh.xy+reflected.xy*lightT; vec2 halfSize=vec2(.32,.42)*min(uPaneSize.x,uPaneSize.y); vec2 windowCoord=abs(lightHit-uLightPosition.xy)/halfSize; vec2 windowMask=1.-smoothstep(vec2(.45),vec2(1.15),windowCoord); float spec=windowMask.x*windowMask.y*step(.0001,reflected.z)*step(0.,lightT); float gloss=spec*clamp(uGlossStrength,0.,1.)*.12*edgeWeight*clamp(uStrength,0.,1.); color.rgb=mix(color.rgb,vec3(1.),gloss); gl_FragColor=color;
#include <colorspace_fragment>
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

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