はじめに
クリスマスイブの夜、ふと思いました。「Webカメラで撮った映像をリアルタイムで3D Gaussian Splatting (3DGS) に変換できたら面白いのでは?」
約24時間後、そのアイデアは動くアプリになっていました。本記事では、その開発の軌跡を振り返ります。
必要条件
ハードウェア
- Apple Silicon Mac(M4以降)
- Webカメラ(内蔵または外付け)
- 16GB RAM(最小)、32GB RAM(推奨)
ソフトウェア
- macOS 14.0(Sonoma)以降
- Xcode 15.0以降
- Python 3.10-3.13(PyTorch MPSサポート付き)
なぜこのアプリを作ったのか
3D Gaussian Splattingの衝撃
2023年8月、SIGGRAPHで発表された「3D Gaussian Splatting」は3D表現技術に革命をもたらしました。従来のNeRF(Neural Radiance Fields)と比較して:
| 項目 | NeRF | 3D Gaussian Splatting |
|---|---|---|
| レンダリング速度 | 数秒/フレーム | リアルタイム(60+ FPS) |
| 学習時間 | 数時間〜数日 | 数十分 |
| 編集性 | 困難 | 点群ベースで容易 |
| メモリ効率 | ニューラルネット依存 | 明示的な点群表現 |
しかし、3DGSには大きな課題がありました。複数視点からの画像が必要という点です。通常、対象物の周囲から数十〜数百枚の写真を撮影し、SfM(Structure from Motion)で点群を初期化してから学習します。
「1枚の写真から3D」という夢
そこに2025年12月に登場したのが、Appleの SHARP(Single-image-to-3D with Hyper-realistic Priors)です。2024年後半に発表されたこのモデルは、たった1枚の画像から3DGSを生成できます。
これを見た瞬間、直感しました:
「これ、リアルタイムでできるんじゃない?」
VR/ARの未来への布石
なぜリアルタイム化が重要なのか。それは 空間コンピューティングの未来 に直結するからです。
【現在の3Dコンテンツ制作】
撮影 → SfM処理 → 3DGS学習 → エクスポート → アプリ配信
(数分) (数十分) (手動) (数日)
【本アプリが実現する未来】
カメラを向ける → リアルタイム3D表示
(500ms)
Apple Vision Proの登場により、空間コンテンツへの需要は今後爆発的に増加します。しかし、3Dコンテンツの制作は依然として専門知識が必要で、時間もかかります。
本アプリは、「誰でも、どこでも、瞬時に3Dコンテンツを生成できる」 という未来への第一歩です。
技術的な挑戦として
また、純粋な技術的興味もありました:
- Swift ↔ Python の高速IPC: ネイティブアプリとMLモデルをどう効率的につなぐか
- Apple Silicon の限界に挑戦: M1/M2/M3チップでどこまでリアルタイム処理できるか
- 顔追跡によるインタラクション: 2Dディスプレイで3D体験をどう表現するか
これらの課題に24時間で挑戦することで、空間コンピューティング時代のアプリ開発の知見を得られると考えました。
完成したもの
RealtimeWebcam3DGS - macOS向けリアルタイム3DGS生成アプリ
主な機能
- 📷 Webカメラからの映像キャプチャ
- 🔄 SHARP モデルによる単一画像→3DGS変換
- 🎮 Metal による高速リアルタイムレンダリング
- 👤 顔追跡による視差パララックス効果
- 📂 静的画像のインポート&変換モード
アーキテクチャ
Day 1: 基盤構築 (12/24)
初期実装
まず、基本的なパイプラインを構築しました。
Swift側のコンポーネント
RealtimeWebcam3DGS/
├── Camera/CameraCaptureManager.swift # Webカメラキャプチャ
├── Coordinator/AppCoordinator.swift # 全体の統括
├── SHARPClient/SHARPClient.swift # Python サーバーとの通信
├── Renderer/SplatRenderManager.swift # Metalレンダリング管理
└── UI/
├── ContentView.swift # メインビュー
├── CameraPreviewView.swift # カメラプレビュー
├── ControlPanelView.swift # 操作パネル
└── SplatRenderView.swift # 3DGS表示
Swift ↔ Python 通信
Swift と Python 間の通信には Unix Domain Socket を採用しました。TCPよりオーバーヘッドが少なく、macOSのローカル環境に最適です。
// SHARPClient.swift
class SHARPClient {
private let socketPath = "/tmp/sharp_server.sock"
func generatePLY(from imagePath: String) async throws -> PLYResult {
let connection = NWConnection(
to: .unix(path: socketPath),
using: .tcp
)
// JSON-RPC風のプロトコルで通信
let request = ["action": "generate", "image_path": imagePath]
// ...
}
}
Python側: SHARPサーバー
Apple の SHARP (Single-image-to-3D with Hyper-realistic Priors) モデルをサービング:
# sharp_server.py
class SHARPServer:
def __init__(self):
self.model = ml_sharp.load_model()
async def handle_client(self, reader, writer):
data = await reader.read()
request = json.loads(data)
if request["action"] == "generate":
ply_data = self._generate_3dgs(request["image_path"])
response = {"success": True, "ply_path": ply_data}
writer.write(json.dumps(response).encode())
この段階では、1回の変換に 約2-3秒 かかっていました。
Phase 1: 最初の最適化
初期実装の問題点を分析:
- CPU-GPU同期のブロッキング: PLY読み込みとレンダリングが直列
- 不要な再ソート: 毎フレーム全Gaussianをソートしていた
- ファイルI/O: PLYファイルを一旦ディスクに書いて読み直し
改善: 非同期パイプライン
// AppCoordinator.swift - Before
func captureAndGenerate() async {
let image = await captureImage()
let plyPath = await sharpClient.generatePLY(from: imagePath)
await renderManager.loadPLY(from: plyPath) // ブロッキング
}
// After - パイプライン化
func captureAndGenerate() async {
let image = await captureImage()
// バックグラウンドで生成開始
Task.detached(priority: .userInitiated) {
let plyPath = await sharpClient.generatePLY(from: imagePath)
await MainActor.run {
// メインスレッドでUIのみ更新
self.renderManager.loadPLYAsync(from: plyPath)
}
}
}
Phase 2: パイプライン並列化
次のボトルネックはPython側の推論速度でした。
ダブルバッファリング
レンダリングしながら次のフレームを生成:
class SplatRenderManager {
// ダブルバッファ: 表示用と読み込み用を分離
private var frontBuffer: SplatData?
private var backBuffer: SplatData?
func swapBuffers() {
swap(&frontBuffer, &backBuffer)
}
}
FP16推論 (試行錯誤)
PyTorchでFP16で軽量化しようと試みましたが、SHARPモデルとの互換性問題に直面し、この方法は断念しました。
# 失敗パターン
model.half() # FP16化
outputs = model(image.half()) # → 異常な出力値
# 解決策: 入力のみFP16、中間処理はFP32維持
image = image.half()
with torch.cuda.amp.autocast(enabled=True):
outputs = model(image)
# Gaussians出力は明示的にFP32に戻す
この時点で、生成時間は 約1.5-2秒 に短縮。
Day 2: 高速化とUX改善 (12/25)
Phase 3: 劇的な高速化
Core ML変換
PyTorchからCore MLへの変換で、Apple Silicon のNeural Engineを活用:
# convert_to_coreml.py
import coremltools as ct
traced_model = torch.jit.trace(model, sample_input)
mlmodel = ct.convert(
traced_model,
inputs=[ct.TensorType(shape=sample_input.shape)],
compute_units=ct.ComputeUnit.ALL, # ANE含む全ユニット利用
)
mlmodel.save("sharp_model.mlpackage")
しかし、ANE (Apple Neural Engine) でランタイムエラーが発生:
ANEProgramProcessRequestDirect() Failed with status=0x15
解決策として、Core ML失敗時に自動でPyTorchへフォールバック:
def _run_inference(self, image):
if self.use_coreml:
try:
return self._run_coreml_inference(image)
except RuntimeError as e:
if "ANE" in str(e):
logger.warning("Core ML failed, falling back to PyTorch")
return self._run_pytorch_inference(image)
raise
return self._run_pytorch_inference(image)
ソケット直接転送
ファイルI/Oを完全に排除し、PLYデータをBase64でインラインで転送:
# Before: ファイル経由
ply_path = generate_ply(image)
response = {"ply_path": str(ply_path)}
# After: 直接転送
ply_data = generate_ply_bytes(image)
ply_base64 = base64.b64encode(ply_data).decode()
response = {"ply_data": ply_base64} # インライン
Swift側でも対応:
if let plyDataString = response["ply_data"] as? String,
let plyData = Data(base64Encoded: plyDataString) {
// ファイル保存せずに直接パース
try renderManager.loadPLY(from: plyData)
}
重要度ベースサンプリング
SHARPは約50万のGaussiansを生成しますが、すべてが等しく重要ではありません。
def sample_gaussians_by_importance(gaussians, target_count=10000):
"""不透明度と大きさに基づいてGaussianをサンプリング"""
# 重要度スコア = 不透明度 × スケール平均
importance = gaussians.opacities * np.mean(gaussians.scales, axis=1)
# 上位を確定選択 + 残りをランダムサンプリング
top_k = target_count // 2
top_indices = np.argsort(importance)[-top_k:]
remaining = np.random.choice(...)
return gaussians[np.concatenate([top_indices, remaining])]
これにより、50万 → 1万 Gaussians に削減しても視覚的品質を維持!
この最適化の結果、生成時間は 約500-800ms まで短縮されました。
顔追跡パララックス
「3DGSを見ているなら、頭を動かしたら視点も変わるべきでは?」という発想から実装。
Vision フレームワークで顔検出
// FaceTrackingManager.swift
class FaceTrackingManager: ObservableObject {
@Published var facePosition: SIMD3<Float> = .zero
private let faceDetectionRequest = VNDetectFaceRectanglesRequest()
func processCameraFrame(_ pixelBuffer: CVPixelBuffer) {
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer)
try? handler.perform([faceDetectionRequest])
guard let face = faceDetectionRequest.results?.first else { return }
// 顔の位置を正規化 (-1 〜 1)
let centerX = Float(face.boundingBox.midX) * 2 - 1
let centerY = Float(face.boundingBox.midY) * 2 - 1
// 顔のサイズから距離を推定
let faceSize = Float(face.boundingBox.width)
let estimatedZ = 0.15 / faceSize // 基準顔幅15cm
facePosition = SIMD3(centerX, centerY, estimatedZ)
}
}
回転ベースのパララックス
当初はオフアクシス投影を試みましたが、より直感的な「回転ベース」アプローチを採用:
// SplatRenderManager.swift
func updateHeadTrackingRotation() {
guard useHeadTracking, headPosition != .zero else { return }
// 頭の位置に応じてモデルを回転
let yawAngle = -headPosition.x * headTrackingSensitivity
let pitchAngle = headPosition.y * headTrackingSensitivity * 0.5
headTrackingRotation = simd_quatf(angle: yawAngle, axis: SIMD3(0, 1, 0)) *
simd_quatf(angle: pitchAngle, axis: SIMD3(1, 0, 0))
}
これにより、頭を左に傾けると3Dモデルが右から見えるような、自然なパララックス効果を実現!
静的画像モード
Webカメラだけでなく、任意の画像をインポートして3DGSに変換する機能を追加。
モード切替UI
// ControlPanelView.swift
struct ControlPanelView: View {
var body: some View {
VStack {
// モードピッカー
Picker("Mode", selection: $coordinator.appMode) {
Text("Webcam").tag(AppMode.webcam)
Text("Static").tag(AppMode.staticImage)
}
.pickerStyle(.segmented)
// モードに応じたUI
if coordinator.appMode == .webcam {
CameraControlsSection()
} else {
StaticModeSection()
}
}
}
}
画像インポート
// AppCoordinator.swift
func importImage() async {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.jpeg, .png, .heic, .tiff]
guard panel.runModal() == .OK, let url = panel.url else { return }
// プレビュー表示
importedImage = NSImage(contentsOf: url)?.cgImage(...)
// 3DGS変換
appState = .generating
let result = try await sharpClient.generatePLYDirect(from: url)
if result.success {
try await renderManager.loadPLY(from: result.plyData!)
appState = .rendering
}
}
技術的なポイント
1. Swift ↔ Python IPC
Swiftアプリ内でPythonを実行する方法はいくつかありますが、プロセス分離 + Unix Domain Socket を選んだ理由:
- PythonのGILやメモリリークがSwiftアプリに影響しない
- GPU メモリを独立して管理可能
- デバッグ時にPythonサーバーを単独で再起動可能
- 将来的にサーバーをリモート化する余地
2. MetalSplatter統合
MetalSplatterは3DGSのMetal実装です。統合で苦労したポイント:
// 正しい座標系変換が必要
// PLYのY-upをMetalのZ-upに変換
func convertCoordinates(_ point: SIMD3<Float>) -> SIMD3<Float> {
return SIMD3(point.x, point.z, -point.y)
}
3. 非同期処理のキャンセル
連続キャプチャ時、古い生成リクエストをキャンセルする処理:
private var currentGenerationTask: Task<Void, Never>?
func captureAndGenerate() async {
// 前のタスクをキャンセル
currentGenerationTask?.cancel()
currentGenerationTask = Task {
guard !Task.isCancelled else { return }
// 生成処理...
}
}
パフォーマンス結果
| Phase | 生成時間 | 改善点 |
|---|---|---|
| 初期実装 | ~3,000ms | - |
| Phase 1 | ~2,000ms | 非同期化 |
| Phase 2 | ~1,500ms | パイプライン並列化 |
| Phase 3 | ~500ms | 直接転送 + サンプリング |
約6倍の高速化 を達成!
まとめ
クリスマスの24時間で、Webカメラからリアルタイム3DGSを生成するmacOSアプリを作りました。
ポイント:
- Unix Domain Socket でSwift-Python間の低レイテンシ通信
- 段階的な最適化 で初期の6倍の速度を達成、現在は、3秒に1回3DGS化可能!
- Vision フレームワーク で顔追跡パララックス
- モジュラーアーキテクチャ でClaude Codeに指定してもらい、機能追加が容易
3DGSはまだ発展途上の技術ですが、リアルタイムアプリケーションの可能性を感じるプロジェクトでした。
リポジトリ
GitHub: ra9g16/RealtimeWebcam3DGS


