refactor(srp): add renderer data invalidation seam

Introduce ScriptableRendererData dirty/invalidation support so renderer and feature caches can be released and rebuilt within the same managed asset runtime.

Add managed probes and scripting coverage for non-public dirty APIs and for renderer rebuild after invalidation, then archive the completed phase plan.
This commit is contained in:
2026-04-20 02:48:16 +08:00
parent 5e88449e3d
commit d196ec9264
5 changed files with 466 additions and 24 deletions

View File

@@ -0,0 +1,139 @@
# SRP RendererData 失效与重建接缝计划 2026-04-20
## 1. 阶段目标
上一阶段已经把 renderer feature setup 正式归位到:
`ScriptableRendererData -> ScriptableRenderer`
但现在还缺最后一条关键闭环:
当同一个 `ScriptableRendererData` 被修改后,旧的 renderer cache 该怎么失效,以及下一次使用时怎么重建。
当前系统只在更大的生命周期边界清缓存:
1. asset runtime 释放
2. bridge generation 变化
3. managed pipeline dispose
这意味着“同一个 asset 还活着,但 renderer data 已经变了”的情况,还没有正式 contract。
这一阶段的目标就是补上 Unity 风格的 renderer data invalidation seam。
---
## 2. 当前问题
### 2.1 renderer cache 只能在 runtime release 时整体释放
现在 `ScriptableRendererData` 已经缓存:
1. renderer instance
2. renderer feature cache
但没有一个正式的“我变脏了,请把这套 renderer setup 丢掉”的入口。
### 2.2 同 asset、同 runtime 内部缺少 renderer rebuild 语义
对于后续编辑器和 SRP/URP 包来说,很常见的场景是:
1. 还在用同一个 pipeline asset
2. 只是改了 renderer data
3. 希望下一次录制时重建 renderer
这一层现在还只能靠 asset/runtime 整体重建绕过去。
### 2.3 测试还没锁住“同一个 runtime 内触发 renderer rebuild”
现有测试已经锁了:
1. renderer reuse
2. 生命周期 release
3. setup seam
但还没锁“同一个 asset runtime 内dirty 之后旧 renderer dispose新 renderer rebuild”。
---
## 3. 实施方案
### 3.1 给 `ScriptableRendererData` 增加正式 invalidation seam
新增非 public / protected 语义:
1. `SetDirty()`
2. `isInvalidated`
`SetDirty()` 负责:
1. 释放当前 renderer cache
2. 释放当前 feature cache
3. 标记 data 已失效
### 3.2 renderer 下一次解析时自动重建
保持调用面不变:
`GetRendererInstance()`
但在 dirty 之后,下一次解析 renderer 时应:
1. 重新创建 renderer
2. 重新 setup renderer
3. 清掉 invalidated 状态
### 3.3 用 probe 锁住同 asset runtime 内的 rebuild 行为
新增一个专用 probe
1. 第一次 `SupportsStageRenderGraph` 创建 renderer
2. 运行时通过同一个 managed asset 调用 renderer data invalidation
3. 第二次 `SupportsStageRenderGraph` 在同一个 asset runtime 里重建 renderer
---
## 4. 实施步骤
### Step 1补 core invalidation seam
目标:
1. 修改 `ScriptableRendererData`
2. 引入 renderer/feature cache 的单独释放辅助逻辑
3. 增加 `SetDirty()``isInvalidated`
### Step 2补 invalidation probe
目标:
1.`RenderPipelineApiProbe.cs` 增加 renderer invalidation 专用 asset / renderer data / renderer / feature / 观察脚本
2. 用同一个 managed asset handle 触发 invalidation
### Step 3补 API probe 与脚本测试
目标:
1. 更新 `ScriptableRenderContextApiSurfaceProbe.cs`
2. 更新 `test_mono_script_runtime.cpp`
3. 锁住 dirty 之后的 create/setup/dispose 计数
### Step 4验证与收口
目标:
1. 编译 `rendering_unit_tests``scripting_tests``XCEditor`
2. 跑测试
3. 跑旧版 `editor/bin/Debug/XCEngine.exe` 10s 冒烟并检查新的 `SceneReady`
4. 归档 plan提交并推送
---
## 5. 验收标准
完成后应满足:
1. `ScriptableRendererData` 存在正式 dirty / invalidated seam
2. dirty 之后旧 renderer cache 和 feature cache 会被释放
3. 同一个 asset runtime 内下一次解析 renderer 时会自动重建
4. probe / scripting test 锁住重建行为
5. old editor 编译、测试、冒烟全部通过

View File

@@ -898,6 +898,76 @@ namespace Gameplay
}
}
internal static class ManagedRendererInvalidationProbeState
{
public static int CreateRendererCallCount;
public static int SetupRendererCallCount;
public static int CreateFeatureCallCount;
public static int DisposeRendererCallCount;
public static int DisposeFeatureCallCount;
public static int InvalidateRendererCallCount;
public static void Reset()
{
CreateRendererCallCount = 0;
SetupRendererCallCount = 0;
CreateFeatureCallCount = 0;
DisposeRendererCallCount = 0;
DisposeFeatureCallCount = 0;
InvalidateRendererCallCount = 0;
}
}
internal sealed class ManagedRendererInvalidationProbeFeature
: ScriptableRendererFeature
{
protected override void ReleaseRuntimeResources()
{
ManagedRendererInvalidationProbeState.DisposeFeatureCallCount++;
}
}
internal sealed class ManagedRendererInvalidationProbeRenderer
: ProbeSceneRenderer
{
protected override void ReleaseRuntimeResources()
{
ManagedRendererInvalidationProbeState.DisposeRendererCallCount++;
}
}
internal sealed class ManagedRendererInvalidationProbeRendererData
: ProbeRendererData
{
protected override ScriptableRenderer CreateProbeRenderer()
{
ManagedRendererInvalidationProbeState.CreateRendererCallCount++;
return new ManagedRendererInvalidationProbeRenderer();
}
protected override void SetupRenderer(
ScriptableRenderer renderer)
{
ManagedRendererInvalidationProbeState.SetupRendererCallCount++;
base.SetupRenderer(renderer);
}
protected override ScriptableRendererFeature[] CreateRendererFeatures()
{
ManagedRendererInvalidationProbeState.CreateFeatureCallCount++;
return new ScriptableRendererFeature[]
{
new ManagedRendererInvalidationProbeFeature()
};
}
public void InvalidateForTest()
{
ManagedRendererInvalidationProbeState.InvalidateRendererCallCount++;
SetDirty();
}
}
internal sealed class ManagedPlannedFullscreenRenderPipelineProbeRendererData
: ProbeRendererData
{
@@ -1235,6 +1305,33 @@ namespace Gameplay
}
}
public sealed class ManagedRendererInvalidationProbeAsset
: RendererBackedRenderPipelineAsset
{
private readonly ManagedRendererInvalidationProbeRendererData
m_rendererData;
public ManagedRendererInvalidationProbeAsset()
{
ManagedRendererInvalidationProbeState.Reset();
m_rendererData = new ManagedRendererInvalidationProbeRendererData();
rendererDataList = new ScriptableRendererData[]
{
m_rendererData
};
}
public void InvalidateDefaultRendererForTest()
{
if (m_rendererData == null)
{
return;
}
m_rendererData.InvalidateForTest();
}
}
public sealed class ManagedPlannedFullscreenRenderPipelineProbeAsset
: UniversalRenderPipelineAsset
{
@@ -1517,6 +1614,57 @@ namespace Gameplay
}
}
public sealed class ManagedRendererInvalidationRuntimeSelectionProbe
: MonoBehaviour
{
public void Start()
{
GraphicsSettings.renderPipelineAsset =
new ManagedRendererInvalidationProbeAsset();
}
}
public sealed class ManagedRendererInvalidationObservationProbe
: MonoBehaviour
{
public int ObservedCreateRendererCallCount;
public int ObservedSetupRendererCallCount;
public int ObservedCreateFeatureCallCount;
public int ObservedDisposeRendererCallCount;
public int ObservedDisposeFeatureCallCount;
public int ObservedInvalidateRendererCallCount;
private bool m_requestedInvalidation;
public void Update()
{
ManagedRendererInvalidationProbeAsset selectedAsset =
GraphicsSettings.renderPipelineAsset
as ManagedRendererInvalidationProbeAsset;
if (!m_requestedInvalidation &&
selectedAsset != null &&
ManagedRendererInvalidationProbeState
.CreateRendererCallCount > 0)
{
selectedAsset.InvalidateDefaultRendererForTest();
m_requestedInvalidation = true;
}
ObservedCreateRendererCallCount =
ManagedRendererInvalidationProbeState.CreateRendererCallCount;
ObservedSetupRendererCallCount =
ManagedRendererInvalidationProbeState.SetupRendererCallCount;
ObservedCreateFeatureCallCount =
ManagedRendererInvalidationProbeState.CreateFeatureCallCount;
ObservedDisposeRendererCallCount =
ManagedRendererInvalidationProbeState.DisposeRendererCallCount;
ObservedDisposeFeatureCallCount =
ManagedRendererInvalidationProbeState.DisposeFeatureCallCount;
ObservedInvalidateRendererCallCount =
ManagedRendererInvalidationProbeState.InvalidateRendererCallCount;
}
}
public sealed class RenderPipelineApiProbe : MonoBehaviour
{
public bool InitialAssetWasNull;

View File

@@ -35,6 +35,8 @@ namespace Gameplay
public bool HasRendererBackedRenderPipelineType;
public bool HasRendererDrivenRenderPipelineType;
public bool HasRendererDataSetupRenderer;
public bool HasRendererDataSetDirty;
public bool HasRendererDataIsInvalidated;
public bool HasRendererSupportsRendererRecording;
public bool HasRendererRecordRenderer;
public bool HasPublicRendererSupportsStageRenderGraph;
@@ -180,6 +182,17 @@ namespace Gameplay
"SetupRenderer",
BindingFlags.Instance |
BindingFlags.NonPublic) != null;
HasRendererDataSetDirty =
rendererDataType.GetMethod(
"SetDirty",
BindingFlags.Instance |
BindingFlags.NonPublic) != null;
HasRendererDataIsInvalidated =
rendererDataType.GetProperty(
"isInvalidated",
BindingFlags.Instance |
BindingFlags.NonPublic |
BindingFlags.Public) != null;
HasRendererSupportsRendererRecording =
rendererType.GetMethod(
"SupportsRendererRecording",

View File

@@ -8,6 +8,7 @@ namespace XCEngine.Rendering.Universal
{
private ScriptableRendererFeature[] m_rendererFeatures;
private ScriptableRenderer m_rendererInstance;
private bool m_rendererInvalidated;
protected ScriptableRendererData()
{
@@ -26,6 +27,7 @@ namespace XCEngine.Rendering.Universal
if (m_rendererInstance != null)
{
SetupRendererInstance(m_rendererInstance);
m_rendererInvalidated = false;
}
}
@@ -48,6 +50,11 @@ namespace XCEngine.Rendering.Universal
SetupRenderer(renderer);
}
internal void SetDirtyInstance()
{
SetDirty();
}
internal string GetPipelineRendererAssetKeyInstance()
{
return GetPipelineRendererAssetKey();
@@ -55,31 +62,9 @@ namespace XCEngine.Rendering.Universal
internal void ReleaseRuntimeResourcesInstance()
{
if (m_rendererInstance != null)
{
m_rendererInstance.ReleaseRuntimeResourcesInstance();
m_rendererInstance = null;
m_rendererFeatures = null;
ReleaseRuntimeResources();
return;
}
if (m_rendererFeatures != null)
{
for (int i = 0; i < m_rendererFeatures.Length; ++i)
{
ScriptableRendererFeature rendererFeature =
m_rendererFeatures[i];
if (rendererFeature != null)
{
rendererFeature.ReleaseRuntimeResourcesInstance();
}
}
m_rendererFeatures = null;
}
ReleaseRendererSetupCache();
ReleaseRuntimeResources();
m_rendererInvalidated = false;
}
internal void ConfigureCameraRenderRequestInstance(
@@ -163,6 +148,14 @@ namespace XCEngine.Rendering.Universal
{
}
protected bool isInvalidated
{
get
{
return m_rendererInvalidated;
}
}
protected bool HasDirectionalShadow(
CameraRenderRequestContext context)
{
@@ -185,6 +178,12 @@ namespace XCEngine.Rendering.Universal
context.nativeHandle);
}
protected void SetDirty()
{
ReleaseRendererSetupCache();
m_rendererInvalidated = true;
}
protected void AddRendererFeature(
ScriptableRenderer renderer,
ScriptableRendererFeature rendererFeature)
@@ -227,6 +226,34 @@ namespace XCEngine.Rendering.Universal
return m_rendererFeatures;
}
private void ReleaseRendererSetupCache()
{
if (m_rendererInstance != null)
{
m_rendererInstance.ReleaseRuntimeResourcesInstance();
m_rendererInstance = null;
m_rendererFeatures = null;
return;
}
if (m_rendererFeatures == null)
{
return;
}
for (int i = 0; i < m_rendererFeatures.Length; ++i)
{
ScriptableRendererFeature rendererFeature =
m_rendererFeatures[i];
if (rendererFeature != null)
{
rendererFeature.ReleaseRuntimeResourcesInstance();
}
}
m_rendererFeatures = null;
}
}
}

View File

@@ -1179,6 +1179,8 @@ TEST_F(
bool hasRendererBackedRenderPipelineType = false;
bool hasRendererDrivenRenderPipelineType = false;
bool hasRendererDataSetupRenderer = false;
bool hasRendererDataSetDirty = false;
bool hasRendererDataIsInvalidated = false;
bool hasRendererSupportsRendererRecording = false;
bool hasRendererRecordRenderer = false;
bool hasPublicRendererSupportsStageRenderGraph = false;
@@ -1292,6 +1294,14 @@ TEST_F(
selectionScript,
"HasRendererDataSetupRenderer",
hasRendererDataSetupRenderer));
EXPECT_TRUE(runtime->TryGetFieldValue(
selectionScript,
"HasRendererDataSetDirty",
hasRendererDataSetDirty));
EXPECT_TRUE(runtime->TryGetFieldValue(
selectionScript,
"HasRendererDataIsInvalidated",
hasRendererDataIsInvalidated));
EXPECT_TRUE(runtime->TryGetFieldValue(
selectionScript,
"HasRendererSupportsRendererRecording",
@@ -1336,6 +1346,8 @@ TEST_F(
EXPECT_TRUE(hasRendererBackedRenderPipelineType);
EXPECT_TRUE(hasRendererDrivenRenderPipelineType);
EXPECT_TRUE(hasRendererDataSetupRenderer);
EXPECT_TRUE(hasRendererDataSetDirty);
EXPECT_TRUE(hasRendererDataIsInvalidated);
EXPECT_TRUE(hasRendererSupportsRendererRecording);
EXPECT_TRUE(hasRendererRecordRenderer);
EXPECT_FALSE(hasPublicRendererSupportsStageRenderGraph);
@@ -3143,6 +3155,109 @@ TEST_F(
secondRecorder->Shutdown();
}
TEST_F(
MonoScriptRuntimeTest,
ManagedRenderPipelineBridgeRebuildsRendererAfterRendererDataInvalidation) {
Scene* runtimeScene =
CreateScene("ManagedRendererInvalidationScene");
GameObject* selectionObject =
runtimeScene->CreateGameObject(
"ManagedRendererInvalidationSelection");
ScriptComponent* selectionScript =
AddScript(
selectionObject,
"Gameplay",
"ManagedRendererInvalidationRuntimeSelectionProbe");
ASSERT_NE(selectionScript, nullptr);
GameObject* observationObject =
runtimeScene->CreateGameObject(
"ManagedRendererInvalidationObservation");
ScriptComponent* observationScript =
AddScript(
observationObject,
"Gameplay",
"ManagedRendererInvalidationObservationProbe");
ASSERT_NE(observationScript, nullptr);
engine->OnRuntimeStart(runtimeScene);
engine->OnUpdate(0.016f);
const XCEngine::Rendering::Pipelines::ManagedRenderPipelineAssetDescriptor descriptor =
XCEngine::Rendering::Pipelines::GetConfiguredManagedRenderPipelineAssetDescriptor();
ASSERT_TRUE(descriptor.IsValid());
ASSERT_NE(descriptor.managedAssetHandle, 0u);
EXPECT_EQ(descriptor.assemblyName, "GameScripts");
EXPECT_EQ(descriptor.namespaceName, "Gameplay");
EXPECT_EQ(descriptor.className, "ManagedRendererInvalidationProbeAsset");
const auto bridge =
XCEngine::Rendering::Pipelines::GetManagedRenderPipelineBridge();
ASSERT_NE(bridge, nullptr);
std::shared_ptr<const XCEngine::Rendering::Pipelines::ManagedRenderPipelineAssetRuntime>
assetRuntime = bridge->CreateAssetRuntime(descriptor);
ASSERT_NE(assetRuntime, nullptr);
std::unique_ptr<XCEngine::Rendering::RenderPipelineStageRecorder>
recorder = assetRuntime->CreateStageRecorder();
ASSERT_NE(recorder, nullptr);
const XCEngine::Rendering::RenderContext context = {};
ASSERT_TRUE(recorder->Initialize(context));
ASSERT_TRUE(
recorder->SupportsStageRenderGraph(
XCEngine::Rendering::CameraFrameStage::MainScene));
engine->OnUpdate(0.016f);
ASSERT_TRUE(
recorder->SupportsStageRenderGraph(
XCEngine::Rendering::CameraFrameStage::MainScene));
engine->OnUpdate(0.016f);
EXPECT_TRUE(runtime->GetLastError().empty()) << runtime->GetLastError();
int observedCreateRendererCallCount = 0;
int observedSetupRendererCallCount = 0;
int observedCreateFeatureCallCount = 0;
int observedDisposeRendererCallCount = 0;
int observedDisposeFeatureCallCount = 0;
int observedInvalidateRendererCallCount = 0;
EXPECT_TRUE(runtime->TryGetFieldValue(
observationScript,
"ObservedCreateRendererCallCount",
observedCreateRendererCallCount));
EXPECT_TRUE(runtime->TryGetFieldValue(
observationScript,
"ObservedSetupRendererCallCount",
observedSetupRendererCallCount));
EXPECT_TRUE(runtime->TryGetFieldValue(
observationScript,
"ObservedCreateFeatureCallCount",
observedCreateFeatureCallCount));
EXPECT_TRUE(runtime->TryGetFieldValue(
observationScript,
"ObservedDisposeRendererCallCount",
observedDisposeRendererCallCount));
EXPECT_TRUE(runtime->TryGetFieldValue(
observationScript,
"ObservedDisposeFeatureCallCount",
observedDisposeFeatureCallCount));
EXPECT_TRUE(runtime->TryGetFieldValue(
observationScript,
"ObservedInvalidateRendererCallCount",
observedInvalidateRendererCallCount));
EXPECT_EQ(observedCreateRendererCallCount, 2);
EXPECT_EQ(observedSetupRendererCallCount, 2);
EXPECT_EQ(observedCreateFeatureCallCount, 2);
EXPECT_EQ(observedDisposeRendererCallCount, 1);
EXPECT_EQ(observedDisposeFeatureCallCount, 1);
EXPECT_EQ(observedInvalidateRendererCallCount, 1);
recorder->Shutdown();
}
TEST_F(
MonoScriptRuntimeTest,
ManagedStageRecorderRecordsMainSceneThroughScriptableRenderContext) {