第 6 週:從 Blueprint 到程式碼
透過 PuerTS 將檔案環形選單從 Blueprint 遷移到 TypeScript,取得佈局、透明度、縮放和手勢互動的程式化控制。
2026年3月9日
概述
這週我用 PuerTS(一個在 Unreal Engine 裡執行 TypeScript/JavaScript 的插件)把整個檔案環形選單重寫了。Blueprint 版本可以用,但每次修改都要拖節點、重新接線。TypeScript 版本讓我可以用寫程式的方式迭代佈局數學、透明度曲線和互動邏輯,幾秒就能改完,不用幾分鐘。
本週目標
- 在 UE5 專案中設定 PuerTS
- 驗證 TypeScript 能生成 WidgetComponent + WBP_CarouselCard
- 重建環形選單為 TS_WidgetCarousel(球形佈局、環繞、透明度、尺寸縮放)
- 微手勢輸入橋接(BP 可呼叫函數)
- 卡片互動(點擊/懸停事件委派)
- 動態縮放(鍵盤 + pinch-to-zoom API)
- 雙手 pinch 偵測接線(BP_MRPawn IsdkHandRigComponent)
- 載入真實世界照片到卡片(21 張照片 + 地點標籤)
- ISDK 遷移(IsdkInteractableWidgetComponent,支援射線 + 觸碰互動)
- 卡片點擊觸發 Level Streaming(淡出、載入 3DGS 關卡、淡入)
- HMD 定位(環形選單打開時出現在頭部前方)
- 手掌選單切換整合
- Tab 系統(Travel/Personal/Dynamic)搭配上下滑切換
- 動態佈局(依資料量自動調整環數、弧度)
- Tab 切換動畫(滑動+淡出)
- Masked blend mode(讓卡片/tab 背景色可見)
- 4DGS CSGaussianSequenceActor 整合(Dynamic tab)
為什麼用 PuerTS
Blueprint 版的環形選單(BP_ArchiveCarousel)可以用,但有真實的限制:
- BP 裡做數學很痛苦:計算球面位置、角度環繞、透明度曲線,全部透過視覺節點。改一個公式要重接 10+ 個節點。
- 迭代速度:每次微調都要在 Blueprint 編輯器裡導航、找到對的節點、改值、編譯。TypeScript 裡改個數字重新編譯不到一秒。
- 版本控制:Blueprint 是二進位檔。沒辦法 diff、沒辦法 review、沒辦法追蹤什麼改了。TypeScript 是純文字。
PuerTS 讓我寫 TypeScript,編譯成 JavaScript 在 UE5 裡執行。Actor 的生命週期(BeginPlay、Tick、EndPlay)直接對應到 class 的方法。UE API 透過型別綁定可以使用。
代價:PuerTS 不是什麼都能存取。Enhanced Input 的 BindAction 沒有暴露,所以手勢輸入還是要經過 Blueprint。模式變成:Blueprint 負責偵測,TypeScript 負責邏輯。
重建過程
球形佈局
環形選單把 18 張卡片(3 環各 6 張)排在 180 度的球面弧上。每環在不同高度,球面幾何自然地讓上下環的半徑收窄,給佈局一個曲面螢幕的感覺。
關鍵參數,全部可在程式碼中調整:
- CarouselRadius:80 cm
- RingVerticalSpacing:30 cm
- ArcDegrees:180 度
- CardDrawSize:640 x 360(對應 WBP_CarouselCard 的 SizeBox)
環繞
卡片在水平和垂直方向都會環繞。滾過邊緣,卡片從另一側重新出現。這從有限的卡片集合中創造出無限檔案庫的錯覺。
透明度與尺寸
中央的卡片完全不透明、完整尺寸。往邊緣移動,透明度降到 70%、尺寸縮到 90%。接近環繞邊界時,淡到零並變成不可見。這在不需要景深 shader 的情況下創造出深度感。
透明度的實作是比較困難的問題之一。在 Blueprint 裡會設定材質透明度。但 WidgetComponent 不用材質。測試了多種方法(SetTintColorAndOpacity、SetColorAndOpacity、在 UserWidget 上 SetRenderOpacity)後,解法是:快取每張卡片的根 SizeBox widget(透過 WidgetTree.RootWidget),然後在上面呼叫 SetRenderOpacity。根 widget 在生成後不會立刻可用,所以程式碼等待 30 幀後才快取。
縮放
環形選單支援 1x 到 5x 的動態縮放。縮放時半徑、垂直間距和卡片大小一起等比放大,整個結構均勻成長。鍵盤控制(U/O)用於桌面測試。VR 用的是 pinch-to-zoom API:Blueprint 偵測雙手 pinch 並每幀傳送指尖距離;TypeScript 從初始 pinch 距離計算縮放比例。
輸入架構
PuerTS 不能直接綁定 Enhanced Input Action。解法:TypeScript 暴露 BP 可呼叫的函數,Blueprint 的 Event Graph 去呼叫它們。
| 手勢 | Blueprint 偵測 | TypeScript 函數 |
|---|---|---|
| 左滑 | IA_MG_L_LeftSwipe | SwipeLeft() |
| 右滑 | IA_MG_L_RightSwipe | SwipeRight() |
| 上滑 | IA_MG_L_ForwardSwipe | SwipeUp() |
| 下滑 | IA_MG_L_BackwardSwipe | SwipeDown() |
| 捏合縮放 | 雙手 pinch + 距離 | UpdatePinchZoom(dist) / EndPinchZoom() |
卡片互動(點擊、懸停)完全在 TypeScript 裡處理,透過 WBP_CarouselCard 的事件委派,PuerTS 可以用多播委派上的 Add() 方法綁定。
PuerTS 踩坑筆記
文件裡不明顯的事情:
- 一定要手動編譯:每次改動後
npx tsc。沒有熱重載。忘記這件事導致了好幾小時在 debug「改了沒反應」。 - 在 BeginPlay 初始化,不是 Constructor:PuerTS 在 UE 物件建構時會清除屬性值。所有初始化必須在 ReceiveBeginPlay() 裡做。
- Widget 根節點需要延遲:WidgetTree.RootWidget 在 widget 建立後立刻回傳 null。要等大約 30 幀才能快取。
- BP 變數可以存取:
(widget as any).TXT_Location可以存取 widget 實例上 Blueprint 暴露的變數。 - SlateColor 需要兩個參數:
new UE.SlateColor(color, ESlateColorStylingMode.UseColor_Specified),不是只傳顏色。
ISDK 遷移
週中把 WidgetComponent 遷移到 Meta Interaction SDK(IsdkInteractableWidgetComponent)。這讓卡片原生支援射線和觸碰互動,不需要手動設定碰撞。
重點:
- WidgetScale 預設是 0.02,卡片幾乎看不見,必須手動設為 1.0。
- 圓角材質:
bUseRoundedBoxMaterial=true,CornerRadius=(30,30,30,30)。BlendMode 必須是 Transparent(Masked 在邊緣會鋸齒)。 - PointablePlane 大小:預設 20x20 太小,必須設成
CardDrawWidth * BaseWorldScale * 0.85才能對齊卡片實際大小。 - Poke 不能用:手動用 SetWidget 建立的 widget 無法觸發 poke(ISDK 已知限制)。射線 + 捏合正常。ThumbTap 透過 BP 橋接可用。
- 點擊去重:0.2 秒同卡過濾,用
GetRealTimeSeconds()防止重複觸發。
照片載入與 Level Streaming
21 張世界照片載入環形選單,每張都有地點標籤(「冰川,冰島」、「阿嬤家,台南」等)。每張卡片對應一個 3DGS 關卡。
完整互動流程:點卡片 → HideCarousel() → pawn 淡出 → 卸載當前關卡 → 串流載入新的 3DGS 關卡 → pawn 淡入。使用 Unreal 的 LatentActionInfo callback 模式處理非同步載入/卸載。
虛擬捲動從 3 環擴展到 7 個資料列,只有 3 環的卡片同時可見,但使用者可以捲動瀏覽全部 21 個項目,並支援環繞。
HMD 定位與手掌選單
環形選單打開時會定位到玩家頭部前方。計算 HMD 的前方方向,將環形選單置中在視野前方。打開後固定不動,讓手部互動更穩定。
透過手掌選單切換:WBP_PalmMenu 觸發 pawn 上的 Event Dispatcher,呼叫 TS_WidgetCarousel 的 ToggleCarousel()。
Tab 系統與動態佈局
週末加入了分類系統,把檔案庫分成三個 tab:Travel(15 張照片)、Personal(3 張)、Dynamic(1 張,4DGS 序列)。
沒有用可點擊的 tab 按鈕,而是純手勢操作:上下滑切換 tab。切換時螢幕中央會短暫顯示當前 tab 名稱(大寫粗體),然後在 3 秒內淡出。保持 UI 乾淨的同時提供分類回饋。
每個 tab 的項目數量不同,所以佈局會動態調整。RecalcLayout() 根據資料量調整環數、每環項目數和弧度。少量項目(1-3 張)用單環,中等用 2-3 環,弧度也會縮窄(每張卡約 30 度),避免少量卡片在 180 度上分散太開。所有環都可見時禁用垂直捲動。
Tab 切換帶有滑動+淡出動畫:卡片淡出同時垂直滑動,中間點換內容,新卡片從反方向淡入。
其他改動:卡片和 tab 從 Transparent 改成 Masked blend mode。Transparent blend 導致背景色不可見(ISDK rounded box 材質會乘以 widget 背景色)。Masked 讓背景色正常顯示。同時移除了垂直亮度漸變,所有環統一亮度。
展示:Widget 環形選單與 Level Streaming
測試完成的 widget 環形選單:捲動照片卡片、選擇項目、載入不同 3DGS 關卡。
已解決的問題
- 從環形選單進入空間:卡片點擊觸發 level streaming,帶淡入淡出過場。端到端可用。
- Pinch-to-zoom 接線:放在 BP_MRPawn,用 IsdkHandRigComponent 取得雙手指尖位置。
- 預覽貼圖:生成時全部預載。21 張 640x360 的照片夠輕量,可以全部載入。
待解問題
- 返回檔案庫流程:探索完一個關卡後如何回到環形選單
- 全 VR 頭盔測試(21 個關卡)
接下來
Demo Day(3 月 11 日)
- 返回檔案庫流程(離開關卡後顯示環形選單)
- 4DGS plugin 整合
- 打磨時間感和視覺感受
- 完整 VR 流程測試
第 6 週 | 2026-03-03 ~ 2026-03-09