Tech Dev
空間記憶檔案的系統架構與實作筆記。
最後更新:2026年3月29日
系統架構
整個系統分成三層:先把空間「捕捉」下來,把素材「處理」成 3DGS 模型,最後在 Quest 3 上獨立執行的 VR runtime 把這些空間 render 出來。

整個架構裡最關鍵的決定,是把邏輯從 Blueprint 移出來、用 TypeScript 寫。Blueprint 只負責偵測手勢、處理硬體事件、跟引擎打交道;至於「接下來會發生什麼」這類邏輯(相簿排版、動畫、轉場、引導流程的狀態),全部寫在 TypeScript 裡,透過 PuerTS 跟引擎溝通。這個切法是整個專案最關鍵的生產力提升:TypeScript 改完馬上重新跑,引擎不用重編。
捕捉流程

素材來源沒有統一規格,有什麼就用什麼:能再回去的地方用 Insta360 X5 拍 360 影片,隨手記憶用 iPhone,已經回不去的地方就用單張舊照片。素材先用 ffmpeg 抽 frame,丟進 COLMAP 或 RealityScan 對位,然後在 PostShot 或 Luma AI 訓練成 3DGS。對只剩一張照片的舊記憶,我用 Sharp(Mescheder et al., 2025) 從單張影像生成 3DGS 場景。每個訓練好的場景輸出成 .ply,可以直接丟進 Unreal。
執行環境
整個體驗是 Quest 3 standalone,不需要接電腦。主要組成:
- 3DGS render — SplatRenderer,我自己寫的 UE5 開源插件。支援靜態
.ply跟動態.gsd(4DGS)序列,用 compute shader 跑,RTX 4080 上 200 萬 Gaussian 可以維持 100+ FPS。 - 手勢追蹤 — Meta Interaction SDK(ISDK)。捏指選卡片,左手抬起手掌朝臉就召喚出選單。
- 燈光 — UE5 的 Directional / Sphere / Spotlight。每個空間獨立調光,目標是「被記得的樣子」,而不是相機原本捕捉的當下。
- 空間音效 — UE5 Audio + 環境音 bed + ElevenLabs 生成的旁白。
- 場景串流 — UE5 World Partition。每個空間是一個獨立 level,選了卡片才載入。
- UI / 相簿 — UMG Widget,用 ISDK 的 ray-interactable 在 3D 空間裡可互動。
- 邏輯層 — TypeScript 透過 PuerTS。相簿的排版、動畫、淡入淡出、串流時序、引導流程的狀態,全部在這裡。
互動流程

完整的觀者旅程在 作品主頁。下面是驅動每個段落的實作筆記。
實作筆記
下面這部分是邊做邊記的工程筆記,技術細節跟程式碼為主,偏向開發參考用。完整版(包含原本所有 BP era 的細節)保留在 英文版 的 Implementation reference 區塊。
相簿(TS_WidgetCarousel via PuerTS)
整個相簿的邏輯在 TS_WidgetCarousel.ts。Blueprint 端只負責收手勢輸入,TypeScript 端負責所有的排版、動畫、選卡、轉場。
- 三個分類(Travel / Personal / Motion),上下滑切換。
- 卡片排成弧形(最多 18 張,3 ring × 6 cards),左右滑捲動。
- 用 ISDK 的
IsdkInteractableWidgetComponent,可以原生支援 ray + pinch。 - 兩手同時捏指可以 zoom in/out,調整相簿到自己舒服的距離。
- 開相簿時 3DGS 場景自動降亮度(透過
SetBrightness控制 SplatRenderer actor),讓卡片看得清楚。
引導(TS_Onboarding via PuerTS)
四個段落(welcome / palm / swipe / farewell),每段有對應的影片教學跟 ElevenLabs 配的旁白。
- 在每一個段落之間 fade 切換,每段持續 6–9 秒。
- 用
SnapToHMD()讓 widget 永遠跟著頭盔,距離鏡頭固定 X=120、Z=-30。 - ElevenLabs 配音用 ffmpeg 切段,每段透過
SpawnSound2D播放。 - Skip button 隨時可按,按了會 fade out 當下的音檔,跳過剩下的引導,直接打開相簿。
一些 PuerTS 上踩過的坑
- 在 constructor 裡初始化的 property 會被 UE 清掉,所有初始化要放
ReceiveBeginPlay()。 - 改完 TS 一定要
npx tsc,沒有 hot reload。 - BP widget 的變數要用
(widget as any).VarName才存取得到。 WidgetTree.RootWidget在 spawn 完之後不會立刻有,要等大概 30 frame。SetBrushFromTexture餵 MediaTexture 會 crash,要改用brush.ResourceObject = mediaTexture。SpawnSound2D會回傳 AudioComponent 可以控制;PlaySound2D是 fire-and-forget。- TArray out 參數要
import { $ref, $unref } from 'puerts'。
3DGS 跟 translucent material 的衝突
3DGS splat 跟 translucent material 在 render order 上會打架,把 translucent plane 放進 3DGS 場景裡會被 splat 蓋掉看不到。
修法:在 plane component 上設 Translucency Sort Priority,數字越大越上層。試過 DitheredTemporalAA 但會出現點狀 pattern,最後還是 translucent + sort priority 最乾淨。
把 2D 媒體放進 3DGS 的設計準則
3DGS 場景本身已經很碎、很有顆粒感,邊邊也不平整。如果直接放矩形照片進去,硬邊會跟周圍的有機質感打架,看起來很怪。
實際 work 的做法:用去背的照片。透明背景讓內容(一份食物、窗外的風景)融進 splat 環境裡,沒有矩形邊緣破壞錯覺。設計準則:放進 3DGS 場景的東西要乾淨、無邊框,3DGS 本身已經提供所有質感,插入的媒體只要呈現主體本身就好。
早期探索:把媒體嵌進空間
在做 carousel 之前,最早的版本是把照片跟影片直接嵌在一個被捕捉的空間裡(布魯克林公寓),讓記憶從它們發生的位置「浮現」出來。後來放棄這個方向,改成多個空間 + 相簿的結構。但這個探索留下了不少實作筆記(BP_Clip、Material:M_Clip、放置工作流程等),完整版保留在 英文版的 Implementation reference 裡。
最後更新:2026-03-29