Apple Vision Pro에서 사진 한 장으로 기억속 공간을 몰입형 3D 공간으로 만드는 R&D 프로젝트
Apple Research가 2025년 12월 공개한 SHARP 모델로 단일 이미지를 1.18M개의 3D Gaussian Splats로 변환하고, Vision Pro에서 몰입형 공간으로 렌더링/인터랙션할 수 있는 visionOS 앱입니다.
⚠️ 개인 R&D 프로젝트입니다. visionOS에서의 single-image 3DGS 통합과 1.18M Splats 환경의 GPU 렌더링 안정화를 탐구하는 과정의 결과물입니다.
- Demo
- Project Scope
- Technical Deep Dive
- Roadmap
- Architecture
- Tech Stack
- References
- Getting Started
- License
SharpMemoryDemo-2.mp4
- 사진 선택 → 변환 진행 → 몰입형 공간 배치 → 핸드 제스처 조작
이 프로젝트에서 개발,통합한 영역:
- visionOS 클라이언트 앱: Gallery UI, 변환 진행률 표시(SSE), MetalSplatter 통합, Hand Gesture
- FastAPI 추론 서버: SHARP 모델 wrapper, 비동기 작업 큐, 진행률 스트리밍
- MetalSplatter 최적화: visionOS 환경에서의 렌더링 안정화, Sort 파이프라인 개선
활용한 외부 자산:
- SHARP Model: Apple Research가 공개한 single-image 3DGS 생성 모델
- MetalSplatter Library: scier가 공개한 Apple 플랫폼용 3DGS 렌더러 (로컬 패키지로 적용 후 수정)
앱 렌더링 (GPU) → Compositor → Reprojection → 디스플레이 (90Hz)
↓ ↓
색상 + depth 버퍼 생성 머리 움직임에 맞춰
이전 프레임을 depth 기반으로 보정
문제: 1.18M Splats 렌더링 시 머리 움직임에 따라 모델이 떨리고 일시적으로 사라지는 현상 발생.
진단:
- 화면 녹화로 떨림이 잡히지 않음 → 렌더링된 프레임 자체는 정상
- → Compositor의 Reprojection 단계에서 발생하는 아티팩트로 판단
- 프레임 예산 11.11ms (90fps) 대비 프레임 처리 시간 55 ~ 91ms로 5~9배 초과
visionOS는 앱 렌더 시점과 디스플레이 시점 사이의 머리 움직임을 보정하기 위해 depth 버퍼 기반으로 픽셀 위치를 조정(Reprojection). GPU가 렌더링에 너무 오래 걸리면 오래된 depth가 reprojection에 사용되어 보정이 부정확해지고, 이것이 떨림으로 나타남.
MetalSplatter 라이브러리의 내부 설정과 SampleApp을 비교하며 파라미터 차이를 추적.
비교한 항목:
highQualityDepth(depth 버퍼 정밀도): true/false 전환 → 둘 다 떨림sortTimeout(정렬 완료 대기 시간): 0.0/0.1 등 변경 → 효과 없음encodePresent(프레임 제출 조건): 렌더 스킵 시에도 제출하도록 변경 → 효과 없음
다른 환경 변수도 배제:
- SampleApp 로컬 빌드도 동일하게 떨림 → 라이브러리 코드 차이 아님
- TestFlight 배포 빌드도 동일 → App Store 빌드 파이프라인 문제 아님
이 단계에서 파라미터 레벨 해결이 불가능하다고 판단하고 렌더링 부하 자체를 줄이는 방향으로 전환.
가설: 렌더링 대상 Splat 수를 줄이면 GPU 부하가 감소하여 떨림 해결.
| 접근 | 방법 | 결과 | 한계 |
|---|---|---|---|
| A. Scale-based Pruning | 로드 시 size³ 기준으로 작은 Splat 영구 제거 (1.18M → 500K) | 떨림 해결, FPS 30 → 90 | 디테일 손실 |
| B. View-Dependent Importance Capping | 매 sort마다 opacity / distance² 기준 상위 Splat만 GPU 전달 |
가까운 디테일 복원 | 먼 배경 사라짐 |
핵심 발견: 떨림의 근본 원인은 "GPU 렌더링 처리 시간이 프레임 예산을 초과하는 것" 이며, Splat 수 감축이 해결책. 그러나 "무엇을 기준으로 제거할지" 가 새로운 문제.
왜 Scale Pruning이 디테일을 잃었나: 텍스트를 구성하는 splat은 크기가 작고 불투명도가 높은 특성이 있는데, 순수 볼륨 기준 pruning에서 가장 먼저 제거되는 대상이었음.
왜 opacity / distance²가 배경을 잃었나: Inverse-square law(1/r²)는 거리 제곱에 반비례하므로, 10m 거리의 splat은 1m 거리의 splat보다 importance가 100배 낮게 계산됨. 결과적으로 가까운 splat이 예산을 독점하여 먼 배경 splat이 완전히 밀려남.
방향 전환: "크기" 와 "거리" 중 하나가 아닌 "화면상 실제 차지하는 면적" 을 기준으로 삼음.
접근 A: size³ (volume)
접근 B: opacity / distance²
통합: opacity × size² / distance²
수학적 근거: 3D 공간의 splat이 화면(2D)에 투영될 때 차지하는 면적은 원근 효과(perspective projection)에 의해 size² / distance²에 비례. 거리가 2배 멀어지면 화면 면적은 1/4로 감소.
Covariance trace로 size² 근사: 3D Gaussian Splatting에서 각 splat의 모양은 3D covariance 행렬로 인코딩되어 있음. 원래의 scale 값은 covariance에 합쳐져 저장되므로 대신 trace(대각합) = s_x² + s_y² + s_z²로 size²의 합을 얻을 수 있음.
let covTrace = Float(splat.covA.x) + Float(splat.covB.x) + Float(splat.covB.z)
let importance = Float(splat.colorSH0.a) * covTrace / max(distSq, 0.01)| Splat 유형 | 기존 metric | 새 metric |
|---|---|---|
| 큰 배경 (먼 거리) | 낮음 (제거됨) | 높음 (보존) |
| 작은 디테일 (가까이) | 높음 | 여전히 높음 |
| 작고 먼 splat | 낮음 | 가장 낮음 (우선 제거) |
결과: 근거리 디테일 유지 + 배경 보존을 동시에 개선.
기존 Swift Array.sort의 closure 기반 비교는 매 비교마다 witness table을 경유하는 간접 호출 발생. 1.18M 비교 × indirect call 오버헤드.
C++ std::sort로 교체하고 UInt64 packed key를 설계하여 5~6x 개선.
flipFloat — Float을 정수 비교 가능하게 변환:
private static func flipFloat(_ f: Float) -> UInt32 {
let u = f.bitPattern
let mask: UInt32 = (u & 0x8000_0000 != 0)
? 0xFFFF_FFFF // 음수: 모든 비트 반전
: 0x8000_0000 // 양수: 부호 비트만 반전
return u ^ mask
}IEEE 754 Float의 비트 패턴은 양수 범위에서는 정수 비교와 순서가 일치하지만, 음수에서는 뒤집힘. flipFloat으로 변환하면 모든 범위에서 UInt32 정수 비교만으로 Float 정렬이 가능.
UInt64 packed key — 정렬 기준 + 인덱스를 8바이트에 압축:
let bits = UInt64(flipFloat(importance))
let key = (bits << 32) | UInt64(flatIndex)상위 32비트에 importance, 하위 32비트에 원본 인덱스를 packing. Sort 후 하위 비트만 추출하여 인덱스 복원. 별도 인덱스 배열 재배치가 불필요.
캐시 효율 개선: 기존 32B struct × 1.18M = 37.8MB (M2 L2 캐시 16MB 초과) → 8B UInt64 × 1.18M = 9.4MB (L2에 fit). Cache thrashing 해소.
| 항목 | Before | After |
|---|---|---|
| Sort 시간 | 122~175ms | 26~29ms |
| 비교 단위 | 32B struct + closure | 8B UInt64 정수 비교 |
| 캐시 적합성 | 37.8MB (L2 초과) | 9.4MB (L2 fit) |
ARKit HandTrackingProvider를 ARSession에 등록하면 시스템이 매 카메라 프레임마다 ML 파이프라인(손 감지 + 26개 관절 추정)을 실행. 이 파이프라인이 GPU compute unit을 점유.
진단 과정:
- HandTrackingProvider 비활성화 → 떨림 큰 폭 개선 (원인 확인)
- 별도 Task로 분리해서 읽기 → 여전히 떨림 → ARSession 등록 자체가 부하 원인
해결: visionOS Compositor 내장의 onSpatialEvent API를 사용. 시스템이 이미 수행하고 있는 핸드 트래킹을 재사용하므로 추가 ML 파이프라인 활성화 없이 핀치 이벤트 수신 가능.
| Stage | Total | 비율 | Max Duration |
|---|---|---|---|
| Vertex | 1.13 min | 49% | 25.37 ms |
| Fragment | 55.93 s | 40% | 15.74 ms |
| Compute | 15.23 s | 11% | 18.70 ms |
Vertex와 Fragment 복합 병목. 어느 한쪽만 최적화해서는 정면 전체 시야(0% culled)에서 90 FPS 안정 달성 불가능.
Compute Pre-Pass: Vertex shader의 per-splat 중복 연산을 compute kernel로 분리하는 가설을 세우고 226MB ring buffer + per-splat 연산 분리를 구현. Instruments 측정 결과 FPS 변화 없음을 확인하고 롤백. Apple GPU의 vertex caching이 이미 중복 연산을 최적화하고 있었음.
- RunPod 등 클라우드 GPU 서버 연동 (현재 Mac 로컬 MPS → NVIDIA CUDA로 추론 속도 개선)
graph LR
A["Vision Pro<br/>(Photos)"] -->|Upload Image| B["FastAPI Server"]
B -->|SHARP Inference| C["3D Gaussians<br/>(.ply)"]
C -->|Download| D["SharpMemory App"]
D -->|MetalSplatter| E["Immersive 3D View"]
E -->|Hand Tracking| F["Rotate / Scale / Move"]
Phase 1 — 변환 (네트워크): 이미지 업로드 → SHARP 추론 → .ply 다운로드 Phase 2 — 렌더링 (로컬): .ply 로드 → MetalSplatter → 몰입형 공간 + Hand Gesture
| Layer | Technologies |
|---|---|
| Client | Swift 6, visionOS 2+, Metal, MetalSplatter, ARKit, CompositorServices, SwiftUI |
| Server | Python, FastAPI, PyTorch, MPS (Apple Silicon) |
| ML Model | SHARP (Apple Research) — single-image to 3D Gaussian Splatting |
- SHARP: Sharp Monocular View Synthesis in Less Than a Second — Apple Research
- MetalSplatter — 3D Gaussian Splatting renderer for Apple platforms
로컬에서 실행하려면 펼쳐보기
- Client: Xcode 16+, visionOS 2+ SDK, Apple Vision Pro (or Simulator)
- Server: Python 3.11+, Apple Silicon Mac (MPS) or NVIDIA GPU (CUDA)
- ML Model: ml-sharp cloned locally
cd server
# Create virtual environment
python3 -m venv venv
source venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# Set path to ml-sharp source (required)
export ML_SHARP_PATH=/path/to/ml-sharp/src
# Start server
uvicorn main:app --host 0.0.0.0 --port 8000The model checkpoint (~700MB) downloads automatically on first run.
- Open
client/SharpMemory.xcodeprojin Xcode - Select your development team in Signing & Capabilities
- Simulator: Server URL defaults to
http://localhost:8000 - Device: Create
client/SharpMemory/DevConfig.swift(gitignored) to point at your server:Find your Mac's IP via System Settings → Wi-Fi → Details → IP Address.enum DevConfig { static let serverBaseURL: String? = "http://<YOUR_MAC_IP>:8000" }
- Build and run (use Release mode for optimal rendering performance)
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/predict |
Upload image, returns job_id |
GET |
/api/status/{job_id} |
Poll processing status (0.0–1.0 progress) |
GET |
/api/stream/{job_id} |
Server-Sent Events for real-time progress |
GET |
/api/download/{job_id} |
Download generated PLY file |
GET |
/health |
Health check |
This project is licensed under the MIT License — see LICENSE for details.
The SHARP model and ml-sharp code are subject to Apple's license. MetalSplatter is used under its original license.