DAZAI CHEN
← 所有週次

第 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_LeftSwipeSwipeLeft()
右滑IA_MG_L_RightSwipeSwipeRight()
上滑IA_MG_L_ForwardSwipeSwipeUp()
下滑IA_MG_L_BackwardSwipeSwipeDown()
捏合縮放雙手 pinch + 距離UpdatePinchZoom(dist) / EndPinchZoom()

卡片互動(點擊、懸停)完全在 TypeScript 裡處理,透過 WBP_CarouselCard 的事件委派,PuerTS 可以用多播委派上的 Add() 方法綁定。


PuerTS 踩坑筆記

文件裡不明顯的事情:

  1. 一定要手動編譯:每次改動後 npx tsc。沒有熱重載。忘記這件事導致了好幾小時在 debug「改了沒反應」。
  2. 在 BeginPlay 初始化,不是 Constructor:PuerTS 在 UE 物件建構時會清除屬性值。所有初始化必須在 ReceiveBeginPlay() 裡做。
  3. Widget 根節點需要延遲:WidgetTree.RootWidget 在 widget 建立後立刻回傳 null。要等大約 30 幀才能快取。
  4. BP 變數可以存取(widget as any).TXT_Location 可以存取 widget 實例上 Blueprint 暴露的變數。
  5. SlateColor 需要兩個參數new UE.SlateColor(color, ESlateColorStylingMode.UseColor_Specified),不是只傳顏色。

ISDK 遷移

週中把 WidgetComponent 遷移到 Meta Interaction SDK(IsdkInteractableWidgetComponent)。這讓卡片原生支援射線和觸碰互動,不需要手動設定碰撞。

重點:

  • WidgetScale 預設是 0.02,卡片幾乎看不見,必須手動設為 1.0。
  • 圓角材質bUseRoundedBoxMaterial=trueCornerRadius=(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