Render layer overrides only reached disk on an explicit File > Save -- SaveDirtyRenderLayers() was called from SaveUsdFile/SaveUsdFileAs and nowhere else. Everything a user authored after switching to a render layer (property values, shader parameters, material assignments) sat in a dirty in-memory SdfLayer marked with an orange asterisk, and a crash took the whole layer's overrides with it. Application::Update() now polls the active render layer's IsDirty() and saves it 0.5s after the user goes idle, gated on IsAnyItemActive() and the left mouse button so a slider or gizmo drag produces one write on release rather than one per frame. Polling rather than hooking CommandHistory::Push/Undo/Redo is deliberate: many of the writes this is meant to catch never build a command and author straight to the ambient edit target -- PropertyPanel's live WriteTranslate/Rotate/Scale and its generic attribute widgets, TransformManipulator's gizmo drags, and MaterialEditorPanel::RenderInputWidget's shader input writes. A command hook would have silently missed exactly the property and shader-parameter edits at issue. IsDirty() catches every path, commanded or not. Scope is the active layer only. Muted render layers still rely on SaveDirtyRenderLayers() at save time, and the root layer is never auto-written -- so the persisted active-layer choice (custom layer data on the root layer) still only survives a reopen once the stage itself is saved. Auto-writing the user's main scene file was ruled out. - SaveRenderLayerIfDirty() refreshes LayerManager after a successful save, or the cached isDirty snapshot leaves a stale asterisk in StageEditorPanel and the Scene Hierarchy sublayer list. A failed save latches the layer id so a read-only file can't spam the log twice a second. - Switching layers flushes the outgoing one, and Shutdown() flushes before the panels are destroyed, so edits inside the debounce window aren't stranded. - RefreshManagers() clears the auto-save state, whose layer ids belong to the outgoing stage. - New "Auto-save active layer" checkbox in the Render Layer panel, on by default, persisted as AutoSaveRenderLayer in preferences.ini. See docs/adr/0002-render-layer.md D9-D11 for the rationale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
22 KiB
ADR 0002 — Render Layer 面板(Maya 風格,建構於 USD Sublayer 架構之上)
- Status: Proposed(尚未實作)
- Date: 2026-07-12
- Component:
src/core/RenderLayerManager(新)、src/ui/RenderLayerPanel(新)、src/core/commands/RenderLayerCommands(新)、src/core/commands/ConnectShaderAttrsCommand、src/core/commands/DisconnectShaderAttrCommand、src/ui/MaterialEditorPanel、src/core/UsdStageManager、src/ui/StageEditorPanel、src/ui/SceneHierarchyPanel、src/ui/Application - Commit: (尚未實作,無對應 commit)
Context
使用者希望在本應用中加入一個「Render Layer」面板,行為比照 Maya 的 Render Layers:使用者可以把場景中的燈光與可渲染物件加入某個具名圖層, 該圖層記錄對這些物件的屬性調整——包含 shading 連線的修改——而不影響 場景的基礎資料;切換目前作用中的圖層時,viewport 套用該圖層的所有覆寫, 並且只顯示該圖層的成員燈光與物件;預設圖層(Default)則等同於目前 未經任何 render layer 修改的 base stage。
為了先弄清楚該怎麼設計,再動手實作,我們先派出三個平行的研究/探索
subagent:一個研究 Maya Render Layers(含舊版 Render Layers 與後續的
Render Setup)的實際資料模型與行為語意;一個深入探索本專案既有的
layer/stage/command 架構(LayerManager、UsdStageManager、
CommandHistory、既有的 command 範本、PropertyManager、
StageEditorPanel);一個探索 viewport 可見度控制與既有的材質覆寫機制
(UsdSceneRenderer/UsdImagingGLEngine、SceneHierarchyPanel 的
眼睛圖示隱藏機制、MaterialEditorPanel 的材質綁定與 shader 連線
command)。三份研究都完成後,再交由一個 Plan agent 讀取實際原始碼,
驗證並修正整體設計——這個過程中抓到兩個既有程式碼裡的真實正確性問題
(見下方 D3、D4)。本 ADR 記錄這輪研究與規劃後定案的設計決策。
與真實 Maya 行為的刻意偏離: Maya 切換 render layer 預設不會 自動 isolate viewport——那只是純粹的 render-time/override-time 概念, 非成員物件只有在該圖層明確加了 visibility override 時才會被隱藏。 但本專案的使用者明確要求「切換到圖層時,只顯示該圖層內的燈光與物件」, 這是經與使用者確認過的刻意設計,而非誤解 Maya 行為。
Goals / Non-Goals
Goals:
- 非破壞性地記錄每個圖層的屬性覆寫(未來階段含 shading connection 與 material binding 覆寫),不修改場景的基礎資料。
- 切換作用中圖層時,viewport 即時反映該圖層的所有覆寫。
- 切換作用中圖層時,viewport 只顯示該圖層的成員燈光/物件(isolate)。
- 正確的存檔/重新開檔 round-trip(圖層清單、成員資格、覆寫內容、目前 作用中的圖層都要能還原)。
- 圖層切換與圖層內的編輯都要能正確 Undo/Redo。
Non-Goals(本階段,MVP 範圍之外,留待後續 Phase):
- Shading connection 覆寫的使用者介面(底層機制在 Phase 0 就會修正到 可正確運作,但本階段不在 Render Layer 面板上開放這個操作)。
- Material binding(材質指派)覆寫的使用者介面。
- 每圖層覆寫清單的檢視/還原(revert)UI,比照 Maya Render Setup 的 Property Editor 覆寫堆疊視圖。
- 從 Scene Hierarchy 拖放物件到 Render Layer 面板做成員編輯(MVP 先用 「加入所選」按鈕取代)。
這些項目會在後續 Phase(見 Consequences 與另一份實作計畫
misty-watching-stardust.md)補齊,此處先列出以留紀錄,避免日後誤以為
被遺漏。
Decision
D1 — 每個 Render Layer 對應一個獨立、檔案化的 SdfLayer
決策: 每建立一個 render layer,就建立一個實體(檔案化,而非
anonymous)的 SdfLayer,掛載為 stage root layer 的 sublayer,並以
SdfLayer::SetCustomLayerData 標記
(例如 usdLayerManager:renderLayer = true、
usdLayerManager:renderLayerName = "<名稱>"),藉此與一般內容 sublayer
區分。絕對不可放進 session layer。
理由: UsdStageManager::MergeSessionLayerIntoRoot() 會在每次存檔
時把 session layer 的內容攤平合併進 root layer、再清空 session
layer——若 render layer 的資料放在 session layer,存檔當下就會被摧毀。
另外,anonymous SdfLayer 的 identifier(anon:0x...)只在執行期有效,
寫進 subLayerPaths 之後無法在 SaveStageAs/重新開檔時被正確解析,
因此每個 render layer 必須是真正的檔案(沿用
LayerManager::CreateNewSublayer 既有的「建立檔案 → 存檔 → 插入
sublayer」流程)。
D2 — Default 圖層是虛擬的,沒有對應的 SdfLayer
決策: 「Default」不是一個真正的 render layer 物件,選取它只代表 「把所有 render-layer sublayer 全部靜音(mute)」。
理由: 這正好等同於今天應用程式既有的行為——沒有任何 render layer 生效時,viewport 顯示的就是未經修改的 base stage。不需要為 Default 另外設計一套資料結構或程式碼路徑。
D3 — 成員資格以 UsdCollectionAPI 表示,並修正「非現用圖層無法讀寫」的問題
決策: 每個 render layer 的成員資格,用 UsdCollectionAPI
(instance name 固定為 "members")表示,掛在該圖層自己 SdfLayer
內部一個固定保留路徑(/RenderLayerData)的 over-only admin prim
上。這個 prim 只有 over、沒有任何 def,所以是合法、可定址的
UsdPrim,但 IsDefined() == false,不會出現在
UsdStage::Traverse()、Scene Hierarchy 面板、或
PropertyManager::GetPrimPaths() 的預設列舉結果中,不需要額外過濾。
規劃過程中發現一個關鍵問題:由於同一時間只有一個 render layer 是
「現用」(unmuted)的,其餘全部處於 muted 狀態,而
UsdStage::MuteLayer 會讓該圖層完全退出 stage 的合成
(composition)——也就是說,對一個目前被 mute 的圖層呼叫
stage->GetPrimAtPath("/RenderLayerData") 會拿到一個無效的
UsdPrim,Usd 層級的 UsdCollectionAPI 便完全無法讀寫該圖層的成員
資料(例如使用者想在 Render Layer 面板編輯一個目前非現用圖層的成員
清單時)。
修正做法: 對於非現用圖層的成員資格讀寫,一律改走Sdf 層級 API,
直接對該圖層的 SdfLayerHandle 操作(SdfCreatePrimInLayer
建立/取得 /RenderLayerData 的 prim spec,再用 SdfRelationshipSpec
操作 collection:members:includes 這個 relationship 的
target path 清單)——這條路徑完全不經過 stage 合成,因此不受 mute
狀態影響,而且寫入的 opinion 名稱與 UsdCollectionAPI 完全一致。
只有在計算「目前現用圖層」的 isolate 用成員集合時,才使用一般
Usd 層級 API(UsdCollectionAPI::GetCollection +
ComputeMembershipQuery + UsdComputeIncludedObjectsFromCollection),
因為此時該圖層必然已經是 unmuted 的。兩條路徑存取的是同一份
authored opinion,只是兩種不同的入口,不是兩套 schema。
D4 — 重用既有的 edit-target 機制做屬性覆寫,並修正三個既有的正確性缺陷
決策: 切換作用中的 render layer 時,把 stage 的
ambient edit target 指向該圖層的 SdfLayer(沿用
LayerManager::SetEditTarget)。
理由與驗證: 重新閱讀 PropertyManager.cpp 與 TransformCommand
後確認,PropertyManager::SetPropertyValue(Property Panel 一般屬性
編輯的路徑)與 TransformCommand(viewport 操作桿與 Property Panel
的 Transform 編輯路徑)都是在編輯提交的當下,從 stage 目前的
ambient edit target 解析要寫入哪個 SdfLayer。因此,只要把 ambient
edit target 換成 render layer 的 SdfLayer,這兩條既有的寫入路徑
完全不需要修改任何程式碼,就會自動把編輯正確記錄到對應的 render
layer 裡。
但在驗證過程中,發現另外三個既有的寫入路徑目前沒有捕捉/套用
UsdEditContext,而是在 Execute()/Undo() 真正執行的當下才讀取
ambient edit target:ConnectShaderAttrsCommand、
DisconnectShaderAttrCommand、以及
MaterialEditorPanel::BindMaterialToTarget 的材質綁定/解除綁定
closure。具體會出錯的情境:使用者在 Render Layer A 生效時連接了一條
shader 連線(此時正確寫入 A);接著切換到 Render Layer B(ambient
edit target 也隨之換成 B);此時按下復原(Undo),Undo() 會在
B 這個圖層上執行「移除連線」,而不是回到原本執行 Execute()
時的 A——結果是 A 留下一條沒被正確復原的連線 opinion,B 卻多了一條
不該存在的覆寫。這其實是既有程式碼中已經存在、但目前很少被觸發到的
潛在 bug(任何在「連線」與「復原連線」之間發生 edit target 變更的
情境都會中招),必須在導入 render layer 之前先修正,否則問題會被
render layer 的切換行為放大成使用者能輕易踩到的日常錯誤。
修正做法: 比照 TransformCommand 既有的寫法,為
ConnectShaderAttrsCommand、DisconnectShaderAttrCommand
新增一個建構參數 SdfLayerHandle editLayer,並在 Execute()/
Undo() 內以 UsdEditContext(m_stage, m_editLayer)
包裹實際的連線/解除連線呼叫;MaterialEditorPanel 在建立這些
command 與綁定/解除綁定的 closure 時,一併在建構當下捕捉
stage->GetEditTarget().GetLayer() 並傳入。這個修正不影響現有
(尚無 render layer 時)的行為——沒有明確傳入圖層時,維持原本讀取
ambient edit target 的行為不變。
D5 — Viewport Isolate 採用「authored visibility opinion」,而非重建渲染引擎或整個 stage 的 population mask
決策: 圖層啟用、或該圖層成員資格變動時,對場景做一次由上而下的
遍歷:若某個子樹完全不含任何成員,就在該子樹最上層的 prim 上,
用 UsdGeomImageable(與 Scene Hierarchy 面板既有的眼睛圖示隱藏
機制完全相同的原語)authoring 一個 visibility = invisible,並且
停止往下遞迴(子孫節點靠繼承取得隱藏效果);若子樹內含成員,則不
寫入任何東西、繼續往下遞迴。規則上永遠不主動 authoring
visible——只利用預設的「inherited」語意,避免蓋掉場景中原本就刻意
隱藏的物件。所有自動寫入的路徑,記錄在該圖層自己的
customLayerData(例如 usdLayerManager:autoHiddenPaths)中,
以便下次重新計算前可以精準只清除這些自動產生的 opinion,不誤刪
使用者手動authoring 的覆寫。
理由(排除的替代方案見下方 Alternatives Considered):
重新閱讀
third_party/OpenUSD-v25.05/include/pxr/usdImagingGL/engine.h
確認,UsdImagingGLEngine::Parameters::invisedPaths
只能在建構時指定,整個類別沒有任何執行期可用的
setter——每次切換圖層都重建 engine 的代價太高(會丟失 Hydra
scene-index 狀態,以及 Arnold/Cycles/Embree 等漸進式渲染委派已累積
的取樣結果,並造成 viewport 明顯的畫面閃爍)。而
UsdStage::SetPopulationMask()/SetLoadRules()
雖然可在執行期呼叫,但作用範圍是整個 stage 的合成,會連帶讓
Scene Hierarchy 面板、Property Panel 的選取、viewport 的
pick 都看不到/選不到非成員物件——這遠超出「只讓 viewport
isolate」的需求,等於把物件從整個場景圖裡「拿掉」而非「隱藏」。
以真正的 authored opinion 達成 isolate,則是可檢視、可除錯的
USD 資料(能在 Stage Editor 等既有工具中直接看到),而且已經證實
Hydra 能對這類編輯即時反應而不需要重建 engine(既有的 Property
Panel 編輯、Scene Hierarchy 的眼睛圖示隱藏,都是同一套機制)。
開銷與被隱藏的「子樹根節點」數量成正比,而非與場景總物件數成正比,
對一般場景規模而言可忽略。
D6 — 圖層切換本身也是一個可 Undo 的操作
決策: 新增 RenderLayerActivateCommand,和其他編輯一樣推入
CommandHistory。建構時就先記錄「切換前的 edit target」,以便
Undo 或切回 Default 時正確還原。
理由: 切換圖層會 authoring 真正的狀態(isolate 用的 visibility opinion、edit target 的變更),如果不納入 Undo 堆疊,使用者連續按 Ctrl+Z 時會「跳過」圖層切換這一步,卻仍然復原了在該圖層內做的編輯, 造成不一致、難以理解的復原歷史。此設計已與使用者確認。
D7 — 圖層生效期間,鎖定既有的 edit-target 相關 UI
決策: 當某個非 Default 的 render layer 是現用狀態時,停用 Stage Editor 面板的 edit-target checkbox,以及 Scene Hierarchy 面板的圖層下拉選單,並加上提示文字說明原因。
理由: 這兩個既有的 UI 都是直接讀寫 stage 的 ambient edit target。如果放任使用者在 render layer 生效期間透過這些既有 UI 把 edit target 改到別的地方,「編輯會正確落在目前作用中的 render layer」這個核心不變量就會被悄悄破壞,且沒有任何提示。鎖定雖然犧牲 一些彈性,但能避免使用者在不知情的狀況下把覆寫寫錯地方。此設計已 與使用者確認(相對於「保留可操作、只顯示警告」的替代方案)。
D8 — 存檔與重新開檔的持久化
決策: UsdStageManager::SaveStage/SaveStageAs
除了既有的存檔呼叫之外,額外明確逐一存下每一個 render-layer
SdfLayer(不論目前是否被 mute)。哪個圖層目前是「現用」這件事,
額外以 root layer 的 custom layer data 記錄
(例如 usdLayerManager:activeRenderLayer),並在
Application::RefreshManagers()/開啟 stage 時讀回,重新套用對應的
MuteLayer/UnmuteLayer 與 SetEditTarget。
理由: UsdStage::Save()
只會走訪目前在合成(composition)中的 layer 存檔,而同一時間必然有
N-1 個 render-layer sublayer 處於 muted、不在合成中的狀態——若不
額外處理,這些圖層的編輯內容在存檔時會被靜默遺漏。另外,mute 狀態
本身只是 UsdStage 的執行期旗標,並不會被序列化進任何檔案,因此
「目前是哪個圖層生效」這件事也必須另外顯式持久化,否則重新開檔後
會遺失,退回成 Default 生效的狀態。
Consequences
正面:
- 幾乎完全重用既有的
SdfLayer/UsdEditContext/CommandHistory/LayerManager機制,不需要引入新的渲染或合成機制,實作與既有 程式碼風格高度一致。 - 每個圖層的覆寫都是真正、可檢視的 USD opinion,能在 Stage Editor 等既有工具中直接檢視、除錯,不是一個只存在於應用程式記憶體裡的 「影子」狀態。
- Isolate 邏輯的開銷與「被隱藏的子樹根節點數」成正比,不隨場景總 物件數線性增加。
- 圖層切換、圖層內編輯,都能正確納入既有的 Undo/Redo 體系,使用者 操作心智模型一致。
負面/取捨:
- 成員資格需要維護兩條存取路徑(現用圖層走
Usd層級 API、非現用 圖層走Sdf層級 API),增加一些實作複雜度與日後維護成本。 - 鎖定既有 edit-target UI 犧牲了一部分操作彈性。
- 每個 render layer 都對應一個實體檔案(而非 anonymous layer), 會在專案目錄下產生額外的附屬檔案,需要一致的檔名/存放規則。
- 本階段(MVP)尚未支援 shading connection、material binding 的 每圖層覆寫 UI,也還沒有覆寫清單的檢視/還原介面——這些留待後續 Phase,在此之前 Render Layer 面板的覆寫能力僅限一般屬性數值與 Transform。
ConnectShaderAttrsCommand/DisconnectShaderAttrCommand/BindMaterialToTarget的修正屬於既有程式碼的行為調整,雖然設計上 刻意保持向下相容(未傳入圖層時行為不變),仍需要在導入 render layer 之前完成並個別驗證,不能與 render layer 本身的功能驗證 混在一起。
Alternatives Considered
- 用
UsdStage::SetPopulationMask()/SetLoadRules()做 isolate。 否決:這兩者作用在整個 stage 的合成層級,會讓 Scene Hierarchy 面板、Property Panel 的選取與 viewport 的 pick 一併看不到/選不到 非成員物件,遠超出「只讓 viewport isolate」的實際需求。 - 用
UsdImagingGLEngine建構期的invisedPaths,每次切換圖層就 重建 engine。 否決:代價過高——會丟失 Hydra scene-index 狀態, 以及漸進式渲染委派(Arnold/Cycles/Embree)已累積的取樣結果,並造成 viewport 明顯閃爍,不適合互動式的頻繁圖層切換。 - 用 anonymous
SdfLayer存放每個 render layer 的資料。 否決:anonymous layer 的 identifier 只在執行期有效,寫進subLayerPaths後,在SaveStageAs/重新開檔時無法被正確解析, 資料會在存檔/重載之間遺失。 - 用
UsdVariantSet表示 render layer 之間互斥的選擇。 否決:Maya 的 collection + override 模型本質上更接近可疊加、 可獨立排序的 sublayer/override 合成,而非彼此互斥、一次只能選一 個的 variant 切換,語意上不夠貼合(尤其考慮到未來允許同一物件 同時屬於多個圖層的情境)。
Verification
本 ADR 記錄的是實作前的設計決策——目前尚未進行任何程式碼變更,
因此本節暫不回報建置或手動驗證結果。待對應的實作計畫
(misty-watching-stardust.md 中記錄的 Phase 0/Phase 1 MVP)實際
執行完成後,應在本節或另外新增一則後續紀錄中,補上實際的建置結果與
手動驗證步驟/結果(圖層建立與切換、viewport isolate 是否正確、
屬性覆寫是否正確落在對應圖層且不外洩到其他圖層、存檔重新開檔的
round-trip、跨圖層切換的 Undo/Redo 行為),寫法比照
0001-viewport-color-correction.md 文末的「Supersession Note」
——以追加的方式記錄後續進展或設計變動,而非直接覆寫、抹除本次
決策當下的紀錄。
後續紀錄 — 現用 Render Layer 的自動存檔(2026-08-05)
- Component:
src/ui/Application、src/ui/RenderLayerPanel
Context
D8 讓 render layer 的內容只有在使用者明確存檔(File > Save /
Save As)時才會寫入磁碟——Application::SaveDirtyRenderLayers()
的呼叫點就只有 SaveUsdFile() 與 SaveUsdFileAs() 兩處。在那之前,
使用者切換到某個 render layer 後所做的所有編輯(屬性數值、shader
參數、材質指派)都只存在於記憶體中的 dirty SdfLayer 裡,
Render Layer 面板僅以橘色 * 標示;一旦當掉,該圖層的全部覆寫
就會遺失。使用者要求:切換到現用 render layer 之後,只要編輯屬性、
shader 參數或材質指派,該圖層的 sublayer 就應該自動存檔。
D9 — 以「去彈跳的 dirty 輪詢」實作,而非掛在 CommandHistory 上
決策: 在 Application::Update() 每一幀輪詢現用 render layer 的
SdfLayer::IsDirty();當(a)沒有任何 ImGui item 處於 active、
且(b)滑鼠左鍵未按下、且(c)已維持 0.5 秒,才呼叫
SdfLayer::Save()。實作為
Application::TickRenderLayerAutoSave() /
FlushActiveRenderLayerSave() / SaveRenderLayerIfDirty()。
理由: 規劃階段清查全部的寫入路徑後發現,使用者點名的編輯裡有
相當大一部分根本不經過 CommandHistory,而是直接對 ambient
edit target authoring:PropertyPanel::WriteTranslate/WriteRotate/ WriteScale、PropertyPanel 的一般屬性 widget 與 variant 選擇、
TransformManipulator::ApplyTranslate/Rotate/Scale(操作桿拖曳期間
的即時寫入)、以及 MaterialEditorPanel::RenderInputWidget 的
shader input 即時寫入。若把自動存檔掛在
CommandHistory::Push/Undo/Redo,這些路徑會被靜默漏掉,正好
涵蓋使用者最在意的「編輯屬性與 shader 參數」情境。輪詢
IsDirty() 則不管走不走 command 都能捕捉到。
(b) 的滑鼠條件是必要的:viewport 操作桿與 node editor 的拖曳是自行
hit-test 的,不是 ImGui item,IsAnyItemActive() 對它們回傳 false;
少了這個條件,拖曳期間會每幀寫檔一次。
D10 — 只自動存現用的那一個 render layer
決策: 自動存檔只寫現用的 render layer;root layer、一般內容 sublayer、以及其餘被 mute 的 render layer 都維持原狀,仍需 Ctrl+S。 D8 完全不變、且仍然必要。
已知取捨: PersistActiveLayerId() 是把「目前哪個圖層生效」寫進
root layer 的 custom layer data,而 root layer 不在自動存檔範圍內
——因此「重新開檔後回到同一個現用圖層」這件事,仍然只有在使用者
明確存檔過之後才成立。刻意不自動寫 root layer:那等於在使用者沒有
要求的情況下改寫他的主場景檔案。
另有兩個附帶行為:切換圖層(或切回 Default)時會先把前一個圖層
flush 掉,避免在 0.5 秒去彈跳視窗內切走而漏存;Shutdown()
在面板被解構前也會 flush 一次。Undo/Redo 同樣會弄髒圖層、因而觸發
自動存檔,這是預期行為。
D11 — 開關放在 Render Layer 面板,預設開啟
決策: RenderLayerPanel 提供「Auto-save active layer」
checkbox(預設勾選),狀態以 AutoSaveRenderLayer 鍵持久化到
preferences.ini。面板自己持有該旗標,Application 每幀讀回——
與 MaterialEditorPanel 的 column widths 採同一種
setter/getter 模式,不需要額外的 callback。
理由: 自動寫檔是行為變更,預設開啟才能滿足使用者的要求,但仍 保留關閉的退路。
注意: 沿用既有慣例,preferences.ini 只在 Shutdown() 時寫出,
所以這個開關的狀態在當掉時不會被保存——這是既有偏好設定共通的
行為,本次未一併變更。