docs(editor): sync script assembly builder api docs

This commit is contained in:
2026-04-12 22:50:50 +08:00
parent a660fc489a
commit 89590242bd
24 changed files with 285 additions and 1813 deletions

View File

@@ -8,65 +8,100 @@
**描述**: 负责把 Editor 项目里的 C# 源码编译成 `Library/ScriptAssemblies` 下的脚本程序集。
## 概述
## 角色概述
`EditorScriptAssemblyBuilder` 是当前 Editor 脚本工作流里真正执行“编译”的那一层。
它会把两类源码分别编成:
`EditorScriptAssemblyBuilder` 是当前 Editor 托管脚本工作流里真正执行“编译”的那一层。它会把两组 C# 源码分别编成:
- `XCEngine.ScriptCore.dll`
- `GameScripts.dll`
并把结果统一落到:
并把输出统一落到:
- `<project>/Library/ScriptAssemblies/`
## 为什么这层设计重要
编辑器脚本系统要稳定工作,关键不只是“能调用 Roslyn”而是要把几件事情一起稳定下来
- 仓库根目录如何解析
- Mono 运行时依赖从哪里找
- `dotnet` / Roslyn / `.NET Framework 4.7.2` 引用程序集如何就位
- 输出目录里的 `mscorlib.dll` 如何避免被活动 Mono runtime 锁住后反复覆盖
`EditorScriptAssemblyBuilder` 正是在做这层工程收口。
## 公开方法
| 方法 | 说明 |
|------|------|
| [RebuildProjectAssemblies](RebuildProjectAssemblies.md) | 全量重建项目脚本程序集并返回结果消息。 |
这是当前唯一入口。返回 `EditorScriptAssemblyBuildResult`
当前这就是它唯一的公开入口。返回 `EditorScriptAssemblyBuildResult` 包含
- `succeeded`
- `message`
## 当前重建流程
`EditorScriptAssemblyBuilder.cpp` 当前实现,主流程大致
`EditorScriptAssemblyBuilder.cpp` 当前实现,主流程大致如下
1. 校验项目路径非空。
2. 解析仓库根与 Mono 根目录。
3. 创建 `<project>/Library/ScriptAssemblies`
4. `PATH` 上查找 `dotnet.exe`
5. 运行 `dotnet --list-sdks`,取最后一条 SDK 版本
6. 定位该 SDK 下的 `Roslyn/bincore/csc.dll`
7. 校验 `.NET Framework 4.7.2` 参考程序集存在
8. 收集 `managed/XCEngine.ScriptCore/**/*.cs`
9. 收集 `<project>/Assets/**/*.cs`
10. 若项目脚本为空,则生成 `Generated/EmptyProjectGameScripts.cs` 占位文件
11. 先编译 `XCEngine.ScriptCore.dll`
12. 仅当输出目录里还没有项目本地 `mscorlib.dll` 时,才从 Mono 目录复制一份;如果已经存在,则直接复用旧文件,避免在增量重建时覆盖仍可能被 Mono 映射的 corlib
13. 再引用 `XCEngine.ScriptCore.dll` 编译 `GameScripts.dll`
2. 解析仓库根目录。
3. 解析 Mono 根目录
4. 创建 `<project>/Library/ScriptAssemblies`
5. `PATH` 上查找 `dotnet.exe`
6. 运行 `dotnet --list-sdks`,取最后一条 SDK 版本
7. 定位该 SDK 下的 `Roslyn/bincore/csc.dll`
8. 校验 `.NET Framework 4.7.2` 引用程序集存在
9. 收集 `managed/XCEngine.ScriptCore/**/*.cs`
10. 收集 `<project>/Assets/**/*.cs`
11. 若项目脚本为空,则生成 `Generated/EmptyProjectGameScripts.cs` 占位文件
12. 先编译 `XCEngine.ScriptCore.dll`
13. 仅当输出目录里还没有项目本地 `mscorlib.dll` 时,才从 Mono 目录复制一份;如果已经存在,则直接复用
14. 再引用 `XCEngine.ScriptCore.dll` 编译 `GameScripts.dll`
## Mono 根目录解析顺序
这是本轮必须同步进文档的重点变化。
当前 `GetMonoRootDirectory()` 的真实优先级是:
1. 先从仓库根目录下找 bundled Mono
- 优先检查 `engine/third_party/mono/binary/mscorlib.dll`
- 若不存在,再扫描仓库一级子目录里的 `Fermion/Fermion/external/mono/binary/mscorlib.dll`
2. 如果没找到 bundled Mono再检查编译期配置的 `XCENGINE_EDITOR_MONO_ROOT_DIR`
3. 如果配置路径也无效,最后回退到 `<repositoryRoot>/managed/mono`
这意味着当前编辑器会优先使用项目内随仓库提供的 Mono而不是先依赖外部配置路径。这是一个非常实际的工程改进
- 减少对开发机环境的隐式依赖
- 让构建行为更可复现
- 更适合团队协作和 CI
## 仓库根目录解析
仓库根目录也不是写死的。当前顺序是:
1. 若定义了 `XCENGINE_EDITOR_REPO_ROOT`,直接使用它
2. 否则以可执行文件目录为起点,向上回退三级,得到 fallback repository root
这让同一套构建器既能支持配置化构建,也能支持开发环境里的相对部署。
## 真实使用位置
- `Application.cpp` 会在重建脚本程序集时调用它。
- `tests/Editor/test_editor_script_assembly_builder.cpp` 当前覆盖了两类关键语义
- 成功路径:`XCEngine.ScriptCore.dll``GameScripts.dll` 与项目本地 `mscorlib.dll` 都会落到 `Library/ScriptAssemblies`
- 锁语义:如果活动 Mono runtime 仍持有已加载的 `GameScripts.dll`,直接重建会失败;释放 runtime 后再重建则可以成功并看到新增脚本类型
- `Application.cpp` 会在编辑器触发脚本重建时调用它。
- `tests/Editor/test_editor_script_assembly_builder.cpp` 当前覆盖了成功路径与“活动 Mono runtime 持有程序集导致重建失败”的锁语义
## 当前实现边界
- 当前是全量重建,不是增量编译。
- 编译链明显依赖 Windows 路径和本机安装的 `dotnet` SDK。
- 参考程序集路径当前写死在 `.NET Framework 4.7.2` 目录
- 构建器本身不会主动卸载当前脚本运行时;如果调用方没有先释放 Mono app domain`GameScripts.dll` 仍可能因文件锁而建失败。
- 若外部环境缺少 `dotnet.exe`、Roslyn 或参考程序集,返回的只是一条失败消息,而不是更复杂的恢复策略。
- 编译链明显依赖 Windows本机安装的 `dotnet` SDK`.NET Framework 4.7.2` 引用程序集
- 它会优先消费仓库内 bundled Mono但不会主动管理 Mono runtime 生命周期
- 如果调用方没有先释放活动脚本运行时`GameScripts.dll` 仍可能因文件锁而建失败。
## 相关文档
- [Scripting](../Scripting.md)
- [RebuildProjectAssemblies](RebuildProjectAssemblies.md)
- [EditorScriptAssemblyBuilderUtils](../EditorScriptAssemblyBuilderUtils/EditorScriptAssemblyBuilderUtils.md)
- [EditorScriptRuntimeStatus](../EditorScriptRuntimeStatus/EditorScriptRuntimeStatus.md)

View File

@@ -14,53 +14,123 @@ static EditorScriptAssemblyBuildResult RebuildProjectAssemblies(const std::strin
## 作用
把指定项目下的托管脚本源码全量编译到 `Library/ScriptAssemblies` 目录,并返回成功/失败消息
把指定项目下的托管脚本源码全量编译到 `Library/ScriptAssemblies` 目录,并返回成功/失败结果
## 当前实现行为
## 当前实现流程
### 1. 解析路径与外部工具
### 1. 解析路径与基础环境
- 要求 `projectPath` 非空,否则直接返回失败结果
- 解析
- 仓库根目录
- Mono 根目录
- `managed/XCEngine.ScriptCore`
- `<project>/Library/ScriptAssemblies`
- 当前依赖系统 `PATH` 上可找到 `dotnet.exe`,并通过 `dotnet --list-sdks` 解析最新 SDK 版本,再定位对应 `Roslyn/bincore/csc.dll`
- 要求 `projectPath` 非空,否则直接返回失败。
- 解析 `projectRoot``repositoryRoot``monoRoot``managedRoot` 与输出目录。
- 创建 `<project>/Library/ScriptAssemblies`
### 2. 校验引用与源文件
### 2. 解析仓库根目录
- 校验 `.NET Framework 4.7.2` 参考程序集存在
- `mscorlib.dll`
- `System.dll`
- `System.Core.dll`
- 校验 Mono 自带 `binary/mscorlib.dll` 存在。
- 收集两组 C# 源文件:
- `managed/XCEngine.ScriptCore/**/*.cs`
- `<project>/Assets/**/*.cs`
- 若项目脚本为空,会生成 `Generated/EmptyProjectGameScripts.cs` 占位源码。
仓库根目录顺序如下
### 3. 编译输出
1.`XCENGINE_EDITOR_REPO_ROOT` 非空,优先使用该配置。
2. 否则以可执行文件目录为起点向上回退三级,得到 fallback repository root。
- 先编译 `XCEngine.ScriptCore.dll`
- 仅当输出目录里还没有项目本地 `mscorlib.dll` 时,才从 Mono 目录复制一份;如果已经存在,则直接复用旧文件。
- 然后引用 `XCEngine.ScriptCore.dll` 编译 `GameScripts.dll`
- 成功时返回:
- `succeeded = true`
- `message = "Rebuilt script assemblies in ..."`
### 3. 解析 Mono 根目录
### 4. 失败语义
当前 Mono 根目录的真实查找顺序是:
- 任意路径校验、进程启动、编译失败、首次 `mscorlib.dll` 复制失败,或目标程序集仍被活动 Mono runtime 持有时,都会返回 `succeeded = false`
- 这个函数本身不负责关闭现有脚本运行时;如果调用方在同一进程里仍持有已加载的 `GameScripts.dll`,重建可能因为文件锁失败。
- 函数内部还包了一层 `try/catch`,用于把标准异常和未知异常转成失败消息。
1. 优先查找仓库内 bundled Mono
- `<repositoryRoot>/engine/third_party/mono`
- 若未命中,再扫描仓库一级子目录下的 `Fermion/Fermion/external/mono`
2. 若未找到 bundled Mono则尝试 `XCENGINE_EDITOR_MONO_ROOT_DIR`
3. 若配置路径也无效,则回退到 `<repositoryRoot>/managed/mono`
## 当前实现边界
判定依据都是目标目录下是否存在 `binary/mscorlib.dll`
- 当前是全量重建,不做增量分析
- 平台和工具链路径明显偏向 Windows + 本机安装 `.NET SDK`
- 参考程序集版本当前固定为 `.NET Framework 4.7.2`
- 输出目录里的 `mscorlib.dll` 当前采用“首次复制、后续复用”的策略,不会在每次重建时强制覆盖。
这条顺序很关键,因为它决定了编辑器现在优先消费项目内置的 Mono 依赖,而不是先依赖开发机外部配置
### 4. 解析外部编译工具
-`PATH` 上查找 `dotnet.exe`
- 运行 `dotnet --list-sdks`
- 取输出中的最后一条 SDK 版本
- 组装 `C:\Program Files\dotnet\sdk\<sdkVersion>\Roslyn\bincore\csc.dll`
如果任一步失败,直接返回失败结果。
### 5. 校验引用程序集
当前固定要求 `.NET Framework 4.7.2` 引用程序集存在:
- `mscorlib.dll`
- `System.dll`
- `System.Core.dll`
同时还会校验 `monoRoot/binary/mscorlib.dll` 存在。
### 6. 收集源文件
- 收集 `managed/XCEngine.ScriptCore/**/*.cs`
- 收集 `<project>/Assets/**/*.cs`
- 若项目侧没有任何脚本,则生成 `Generated/EmptyProjectGameScripts.cs` 占位文件,确保仍能产出 `GameScripts.dll`
### 7. 执行两阶段编译
先编译:
- `XCEngine.ScriptCore.dll`
再编译:
- `GameScripts.dll`
第二阶段会把 `XCEngine.ScriptCore.dll` 放进引用列表。
### 8. 处理项目本地 mscorlib.dll
这是当前实现里一个很关键的工程细节:
- 输出目录里的 `mscorlib.dll` 只会在“第一次不存在”时,从 `monoRoot/binary/mscorlib.dll` 复制进来
- 如果该文件已经存在,则直接复用,不再每次重建都覆盖
源码里的注释已经说明原因Mono 可能在进程生命周期内持续映射项目本地 corlib。如果每次重建都强行覆盖文件锁会让流程更加脆弱。
## 返回值语义
成功时:
- `succeeded = true`
- `message` 类似 `Rebuilt script assemblies in ...`
失败时:
- `succeeded = false`
- `message` 会包含更具体的失败原因,例如缺少 `dotnet.exe`、Roslyn、引用程序集、Mono corlib或编译器进程本身失败
函数内部还包了一层 `try/catch`,会把标准异常与未知异常也转换成失败消息。
## 失败边界
以下情况都会导致失败:
- 项目路径为空
- 输出目录创建失败
- `dotnet.exe` 不在 `PATH`
- `dotnet --list-sdks` 无法执行或无法解析出 SDK 版本
- `csc.dll` 不存在
- `.NET Framework 4.7.2` 引用程序集缺失
- Mono corlib 缺失
- `ScriptCore` 源码为空
- 占位脚本生成失败
- 任意一次 C# 编译失败
- 目标程序集仍被活动 Mono runtime 持有导致写入失败
## 设计理解
这个函数不是一个通用 C# 构建系统,而是一个面向编辑器脚本热重建的工程化入口。它的目标是:
- 把编辑器脚本依赖解析收拢到一处
- 让项目内 bundled Mono 可以直接工作
- 让脚本重建失败时返回可读的诊断信息
- 尽量避免 `mscorlib.dll` 因重复覆盖而触发额外锁冲突
从商业级工具链视角看,这属于“先把本地开发和编辑器热重建跑稳,再逐步演进成更完整脚本构建系统”的典型路线。
## 相关文档

View File

@@ -1,6 +1,6 @@
# API 文档重构状态
**生成时间**: `2026-04-10 18:41:38`
**生成时间**: `2026-04-12 22:45:25`
**来源**: `docs/api/_tools/audit_api_docs.py`
@@ -8,10 +8,10 @@
- Markdown 页面数(全部): `4006`
- Markdown 页面数canonical: `3978`
- Public headers 数: `384`
- `XCEditor` public headers 数: `64`canonical 已覆盖 `64`
- `XCEngine` public headers 数: `320`canonical 已覆盖 `320`
- Editor source headers 数: `144`
- Public headers 数: `393`
- `XCEditor` public headers 数: `66`canonical 已覆盖 `64`
- `XCEngine` public headers 数: `327`canonical 已覆盖 `320`
- Editor source headers 数: `145`
- 有效头文件引用数(全部): `384`
- 有效头文件引用数canonical: `384`
- 无效头文件引用数: `0`
@@ -41,20 +41,20 @@
|------|----------------|--------|--------|
| `XCEditor/Collections` | `9` | `9` | `0` |
| `XCEditor/Fields` | `23` | `23` | `0` |
| `XCEditor/Foundation` | `4` | `4` | `0` |
| `XCEditor/Foundation` | `6` | `4` | `2` |
| `XCEditor/Shell` | `24` | `24` | `0` |
| `XCEditor/Widgets` | `4` | `4` | `0` |
| `XCEngine/Audio` | `11` | `11` | `0` |
| `XCEngine/Components` | `11` | `11` | `0` |
| `XCEngine/Core` | `48` | `48` | `0` |
| `XCEngine/Components` | `12` | `11` | `1` |
| `XCEngine/Core` | `49` | `48` | `1` |
| `XCEngine/Debug` | `10` | `10` | `0` |
| `XCEngine/Input` | `5` | `5` | `0` |
| `XCEngine/Memory` | `5` | `5` | `0` |
| `XCEngine/Platform` | `11` | `11` | `0` |
| `XCEngine/RHI` | `88` | `88` | `0` |
| `XCEngine/Rendering` | `42` | `42` | `0` |
| `XCEngine/Resources` | `29` | `29` | `0` |
| `XCEngine/Scene` | `4` | `4` | `0` |
| `XCEngine/Rendering` | `44` | `42` | `2` |
| `XCEngine/Resources` | `31` | `29` | `2` |
| `XCEngine/Scene` | `5` | `4` | `1` |
| `XCEngine/Scripting` | `7` | `7` | `0` |
| `XCEngine/Threading` | `10` | `10` | `0` |
| `XCEngine/UI` | `39` | `39` | `0` |
@@ -67,7 +67,7 @@
| `Actions` | `9` | `9` | `0` |
| `Commands` | `4` | `4` | `0` |
| `ComponentEditors` | `11` | `11` | `0` |
| `Core` | `20` | `20` | `0` |
| `Core` | `21` | `20` | `1` |
| `Layers` | `1` | `1` | `0` |
| `Layout` | `1` | `1` | `0` |
| `Managers` | `3` | `3` | `0` |
@@ -88,3 +88,19 @@
| `描述` | `618` |
| `头文件` | `1975` |
| `源文件` | `520` |
## 未覆盖的 public headers
- `XCEditor/Foundation/UIEditorRuntimeTrace.h`
- `XCEditor/Foundation/UIEditorTextMeasurement.h`
- `XCEngine/Components/GaussianSplatRendererComponent.h`
- `XCEngine/Core/Asset/ArtifactContainer.h`
- `XCEngine/Rendering/FrameData/VisibleGaussianSplatItem.h`
- `XCEngine/Rendering/Passes/BuiltinGaussianSplatPass.h`
- `XCEngine/Resources/Shader/ShaderCompilationCache.h`
- `XCEngine/Resources/Texture/TextureImportHeuristics.h`
- `XCEngine/Scene/ModelSceneInstantiation.h`
## 未覆盖的 Editor 源文件页
- `editor/src/Core/ProjectAssetWatcher.h`

View File

@@ -1,174 +0,0 @@
# Library启动Bootstrap与SourceHash校验解耦修复计划
日期2026-04-11
## 0. 计划定位
这份计划专门处理当前 `Library` 主线收口时暴露出来的新根因问题:
1. `SetResourceRoot()``BootstrapProjectAssets()` 接到了启动同步路径。
2. `BootstrapProjectAssets()` 进一步进入 `AssetDatabase::Refresh()`
3. `Refresh()` 在扫描 `Assets/cloud.nvdb` 时同步计算 `sourceHash`
4. `cloud.nvdb` 是超大源文件,导致 editor 启动直接卡在主线程。
本计划不回退单文件 artifact container不推翻 `@entry=main`,也不回退现有 `Library` 架构。
## 1. 当前根因
已经确认的实际阻塞链路如下:
1. `ResourceManager::SetResourceRoot()`
2. `BootstrapProjectAssets()`
3. `AssetImportService::BootstrapProject()`
4. `AssetDatabase::Refresh()`
5. `EnsureMetaForPath(Assets/cloud.nvdb)`
6. `ComputeFileHash(cloud.nvdb)`
结论:
1. 慢的不是 `ArtifactContainer``offset / entry` 读取。
2. 慢的不是 `VolumeField` artifact payload 本身。
3. 真正把时间炸掉的是“启动阶段同步执行大源文件内容级校验”。
## 2. 修复目标
本计划的目标不是取消 `Library` 启动检查,而是把检查做对。
目标如下:
1. 打开项目时仍然会检查 `Library`
2. 启动检查只做便宜元数据检查,不做大文件内容级哈希。
3. `Bootstrap``EnsureArtifact / Reimport` 的职责彻底拆开。
4. `sourceHash` 只在真正需要导入、重导入、显式全量重建时才计算。
5. `Volume``Shader``Model``Material` 在现有 container 架构下继续保持功能正确。
## 3. 核心原则
### 3.1 启动阶段只做便宜检查
启动阶段应该只检查:
1. `assets.db / artifacts.db` 是否存在、是否可读。
2. schema 是否匹配。
3. 源文件是否存在。
4. `fileSize / writeTime / importerVersion / metaHash` 是否变化。
5. 哪些资源只是 `DirtyCandidate`
启动阶段不应该做:
1. 对每个源文件重新算 `sourceHash`
2. 对所有资源同步 `ImportAsset`
3. 对大资源执行内容级证明。
### 3.2 sourceHash 只服务导入正确性
`sourceHash` 的职责应该下沉到真正需要它的地方:
1. `EnsureArtifact()`
2. `ReimportAsset()`
3. `ReimportAllAssets()`
4. 用户显式 `RebuildProjectAssetCache()`
### 3.3 日常启动和冷启动重建要分开
语义上必须区分:
1. 日常启动:
- 快速检查
- 只找出可能脏的资源
- 不同步重导所有大资源
2. 删除 `Library` 后的首次冷启动:
- 允许重建索引和数据库
- 但依然不应该把所有超大源文件的内容校验都塞进主线程同步路径
## 4. 计划拆解
### 阶段A冻结当前正确成果
目的:
1. 不回退 `ArtifactContainer`
2. 不回退 `@entry=main`
3. 不破坏 `ArtifactDB schema=2`
4. 不破坏 `runtimeLoadPath` 现有语义
交付标准:
1.`Library` 统一容器计划正式转入阶段归档。
2. 后续启动链路修复不影响现有 container 主线。
### 阶段B拆开 Bootstrap 与导入职责
动作:
1. 调整 `AssetImportService::BootstrapProject()` 的语义。
2.`AssetDatabase::Refresh()` 只负责 fast refresh。
3. 明确 `Refresh()` 不再承担内容级导入校验责任。
交付标准:
1. `SetResourceRoot()` 不再把大文件 `sourceHash` 强行拉进启动同步路径。
2. `assets.db` 仍能在启动时正确恢复。
### 阶段C把脏判断改成元数据优先
动作:
1.`EnsureMetaForPath()` 中优先使用:
- `sourceFileSize`
- `sourceWriteTime`
- `metaHash`
- `importerVersion`
2. 启动扫描阶段只更新这些便宜字段。
3. 明确区分“元数据变化”和“必须立刻重导”。
交付标准:
1. 打开项目时不会因为 `cloud.nvdb` 被同步全量哈希而长时间阻塞。
2. 资源脏状态仍可被发现。
### 阶段D把 sourceHash 下沉到真正需要的路径
动作:
1. `EnsureArtifact()` 真正需要生成或验证 artifact 时,再决定是否补做 `sourceHash`
2. `ReimportAllAssets()``RebuildProjectAssetCache()` 继续保留严格路径。
3. 对已有 artifact 且 `size/writeTime/meta/importerVersion` 未变化的资源,优先直接复用。
交付标准:
1. 日常启动快。
2. 显式重导仍然正确。
3. `VolumeFieldImporter` 不再在启动同步阶段把大文件成本炸出来。
### 阶段EVolume 与 Editor 回归
动作:
1. 回归 `VolumeFieldLoader.AssetDatabaseCreatesVolumeArtifactAndReusesItWithoutReimport`
2. 回归 `VolumeFieldLoader.ResourceManagerLoadsVolumeByAssetRefFromProjectAssets`
3. 回归 editor 打开 `project` 的启动路径
4. 回归 `asset / ui / shader / gaussian_splat`
交付标准:
1. `project/Library` 删除后可重新生成。
2. editor 打开项目不再出现 30s 级主线程阻塞。
3. `cloud.nvdb` 在真正请求时仍能正确导入并渲染。
## 5. 风险控制
1. 不能为了消掉启动卡顿,直接把 `sourceHash` 整体删掉。
2. 不能回退当前 `Library` 单文件 container 主线。
3. 不能把 `Bootstrap` 改成完全不检查 `Library`,否则会把错误拖到运行时爆炸。
4. 必须特别盯住 `Volume`,因为它是最能放大启动语义错误的大资源类型。
## 6. 完成定义
当以下条件全部满足时,本计划收口:
1. 打开项目时 `SetResourceRoot()` 不再同步对 `cloud.nvdb` 做内容级哈希。
2. `project/Library` 删除后重新打开项目,数据库能够恢复。
3. `cloud.nvdb` 在真正被请求时仍能正确生成 volume artifact。
4. `Volume / Asset / UI / Shader / GaussianSplat` 关键回归全部通过。
5. 启动检查、按需导入、显式全量重建三种语义边界清晰稳定。

View File

@@ -1,564 +0,0 @@
# Rendering 通用 Shader 多 Pass 执行重构计划
日期: `2026-04-12`
## 1. 文档定位
这份计划用于正式解决当前渲染系统里的一个根问题:
- `Shader` 资源层已经支持定义多个 `Pass`
- `UsePass` 也已经能被解析和导入
- 但主场景 `BuiltinForwardPipeline` 还没有把“同一个材质的多个 surface pass 按顺序执行”做成正式运行时能力
这不是 Nahida 特例,也不是某一个卡通 shader 的临时问题,而是当前 `Rendering` 模块在通用材质执行模型上的结构性缺口。
本计划的目标不是给 Nahida 加一个专用补丁而是把“Unity 式 shader 多 pass 执行”补成引擎正式能力。
## 2. 结论摘要
### 2.1 当前系统到底有没有多 pass
当前系统是“局部有多 pass通用 surface 没有多 pass”。
- 有:
- `Shader` 资源对象可以持有多个 `Pass`
- `UsePass` 可用
- `DepthOnly` / `ShadowCaster` / `ObjectId` / `SelectionMask` 这类专用 pass 会按 pass type 单独解析
- `PostProcess` / `FinalOutput` 这类 fullscreen pass sequence 也支持多 pass 串联
- 没有:
- 主场景里“同一个材质的多个 graphics surface pass 自动执行”
- 当前 `BuiltinForwardPipeline` 只会为一个材质挑一个主 surface pass 来画
### 2.2 当前根因
根因不是 shader authoring 语法不支持多 pass而是主场景 surface draw 路径还停留在“单 pass 材质模型”。
当前主路径的关键限制是:
- `TryResolveSurfacePassType()` 只认 `Unlit` / `ForwardLit`
- `ResolveSurfaceShaderPass()` 只返回一个 pass
- `DrawVisibleItems()` 对每个 `VisibleRenderItem` 只执行一次主 surface draw
所以:
- 你可以在 shader 里写 `ForwardLit + Outline`
- 资源也能读进来
- 但运行时不会自动再画第二遍 `Outline`
### 2.3 是否需要 Render Graph
这轮不需要 Render Graph而且不应该先上 Render Graph。
原因很明确:
- 当前问题是“主场景通用材质的多 pass 调度缺失”
- 不是“跨资源依赖分析 / 瞬态资源分配 / 全帧拓扑求解”问题
- 现有架构已经有显式的 `RenderPipeline` + `RenderPassSequence`
- 这次只需要把 `BuiltinForwardPipeline` 从“单 surface pass 执行器”升级成“多 surface pass 执行器”
结论:
- 先不用 Render Graph
- 先把主场景通用 multipass 执行能力补齐
- 后续如果将来做更复杂的 frame dependency再考虑 Render Graph
## 3. 当前现状拆解
## 3.1 Shader 资源层
当前 `Shader` 资源层已经具备以下能力:
- 一个 shader 可以拥有多个 `Pass`
- pass 有自己的:
- `name`
- `tags`
- `resources`
- `keywordDeclarations`
- `variants`
- `UsePass` 会在构建时导入引用 pass
这说明“shader 文件里写多个 pass”本身不是问题。
## 3.2 专用渲染 pass 层
当前系统已经有一些“按 pass type 单独拉取并执行”的路径:
- `DepthOnly`
- `ShadowCaster`
- `ObjectId`
- `SelectionMask`
- `Skybox`
- `GaussianSplat`
- `Volumetric`
它们说明当前引擎已经具备“识别 pass 元数据并挑一个对应 pass 执行”的机制,但这套机制目前没有扩展到主场景通用材质 surface 路径。
## 3.3 主场景 surface 路径
当前主场景 forward 渲染顺序是:
- `ExecuteForwardOpaquePass`
- `ExecuteForwardSkyboxPass`
- `BuiltinGaussianSplatPass`
- `BuiltinVolumetricPass`
- `ExecuteForwardTransparentPass`
`ExecuteForwardOpaquePass/TransparentPass` 内部依然是“每个物体只解析一个主 surface pass”的模型。
这意味着:
- 旧的单 pass lit/unlit 材质可以工作
- Unity 式 `Forward + Outline` 这类角色 shader 不能完整工作
## 3.4 当前缺口的精确定义
缺的不是“多 pass 文件格式”,而是下面这套正式能力:
1. 主场景 surface pass 的收集
2. 主场景 surface pass 的排序
3. 同一 `VisibleRenderItem` 的多次 graphics draw
4. 主 surface pass 与附加 surface pass 的阶段归属
5. 与现有 opaque / transparent / skybox / depth / shadow / objectId 的兼容
## 4. 本轮设计选择
## 4.1 选择的正式方案
本轮选择:
- 不做 Nahida 特判
- 不在某个 shader 上硬编码“再画一遍 outline”
- 不重写整个渲染框架为 Render Graph
- 直接把 `BuiltinForwardPipeline` 重构为“支持通用 surface multipass”
## 4.2 明确拒绝的方案
### 方案 A: Nahida / Toon 专用补丁
拒绝原因:
- 只解决一个案例
- 会把根因藏在角色特判里
- 后续别的 Unity shader 还是一样会坏
### 方案 B: 先全面 Render Graph 化
拒绝原因:
- 工作量过大
- 与当前问题不对焦
- 会把原本中等规模的结构重构,升级成高风险基础设施重写
### 方案 C: 继续维持单 surface pass只在 shader 层绕
拒绝原因:
- 不能从根上支持 `ForwardLit + Outline`
- 和 Unity 式多 pass 材质模型不一致
## 5. 目标架构
## 5.1 目标状态
主场景渲染的目标状态是:
- 一个材质可以在 shader 内声明多个 surface pass
- 渲染时会先收集这些 pass
- 再按引擎定义好的 scene phase 顺序执行
- 同一个 mesh / material 在一帧里可以被绘制多次
最小闭环至少要支持:
- `Unlit`
- `ForwardLit`
- `Outline`
并为后续保留扩展点:
- 更多角色附加 pass
- 深度依赖的 rim pass
- 特殊透明角色 pass
## 5.2 推荐的主场景 surface 阶段模型
本轮建议把主场景通用 surface pass 明确拆成以下阶段:
1. `OpaqueBase`
2. `Skybox`
3. `OpaqueAuxiliary`
4. `TransparentBase`
5. `TransparentAuxiliary`
其中:
- `Unlit` / `ForwardLit` 默认属于 `Base`
- `Outline` 默认属于 `Auxiliary`
- 本轮重点落地 `OpaqueBase + OpaqueAuxiliary`
这么拆的原因是:
- `Outline` 一般要在角色主表面之后绘制
- 又通常希望在透明物体之前完成
- 这和当前 forward pipeline 的大框架可以自然兼容
## 5.3 推荐的 pass 类型模型
当前 `BuiltinMaterialPass` 需要正式扩展,而不是继续只停留在:
- `ForwardLit`
- `Unlit`
建议新增:
- `Outline`
并在后续根据需要继续扩展。
这里的重点不是“枚举值多一个”本身,而是:
- 主场景 surface 路径终于承认“一个材质有多个可执行的主 graphics pass”
- 不再把 `Outline` 当作特殊插件逻辑,而是当作正式 pass type
## 6. 核心改造点
## 6.1 Pass 元数据层
涉及模块:
- `engine/include/XCEngine/Rendering/Builtin/BuiltinPassTypes.h`
- `engine/include/XCEngine/Rendering/Builtin/BuiltinPassMetadataUtils.h`
- `engine/include/XCEngine/Rendering/Builtin/BuiltinPassLayoutUtils.h`
任务:
- 新增 `BuiltinMaterialPass::Outline`
- 增加 `Outline` 的 canonical name 解析
-`Outline` 建立默认资源绑定规则
- 保持现有 `ForwardLit / Unlit / DepthOnly / ShadowCaster / ObjectId ...` 兼容
验收:
- shader 中 `Name "Outline"``Tags { "LightMode" = "Outline" }` 能稳定识别
- 不影响现有 builtin pass 匹配行为
## 6.2 主场景 surface pass 收集层
涉及模块:
- `engine/include/XCEngine/Rendering/Pipelines/BuiltinForwardPipeline.h`
- `engine/src/Rendering/Pipelines/Internal/BuiltinForwardPipelineResources.cpp`
当前问题:
- `ResolveSurfaceShaderPass()` 是单返回值模型
本轮需要改成:
- `CollectSurfaceShaderPasses()` 或等价结构
- 返回一组“已解析的 surface pass 列表”
每个条目至少包含:
- `passType`
- `shader`
- `shaderPass`
- `passName`
- `scenePhase`
- `effectiveRenderState`
验收:
- 同一材质可同时解析出 `ForwardLit + Outline`
- 旧的单 pass 材质仍只解析出一个条目
## 6.3 主场景 surface pass 排序与阶段归属
涉及模块:
- `BuiltinForwardPipeline.cpp`
- `BuiltinForwardPipelineResources.cpp`
任务:
- 不再只按 `opaque/transparent` 二元划分来理解主 surface
- 要引入“主场景 surface 子阶段”的概念
- 至少实现:
- opaque base
- opaque auxiliary
- transparent base
本轮建议的执行顺序:
1. `OpaqueBase`
2. `Skybox`
3. `OpaqueAuxiliary`
4. `GaussianSplat`
5. `Volumetric`
6. `TransparentBase`
7. `TransparentAuxiliary`
说明:
- `Outline` 先放在 `OpaqueAuxiliary`
- 本轮先不支持“透明物体 outline”的复杂排序
- 透明附加 pass 只保留接口,不要求首轮全部打通
## 6.4 Draw 级执行模型
当前问题:
- `DrawVisibleItems()` 对每个 item 只会 draw 一次主 surface
重构目标:
- 对每个 `VisibleRenderItem`,先收集其可执行 surface pass
- 再按当前 scene phase 过滤
- 每个 pass 单独:
- 解析 pipeline state
- 绑定 descriptor sets
- 执行 draw
这一步是整个重构的真正核心。
验收:
- 一个物体在同一帧可发生多次主场景 draw
- 每次 draw 都有独立 `passName + renderState`
- pipeline cache 仍然以 `shader + passName + renderState + keywordSignature + surface format` 为 key 稳定工作
## 6.5 资源绑定与 layout 缓存
当前系统在这方面基础是够的,因为:
- `PassLayoutKey` 已经包含 `shader + passName`
- `PipelineStateKey` 已经包含 `passName`
所以本轮不需要重写 cache 架构,只需要保证:
- 多 pass 场景下 cache key 继续区分不同 pass
- `Outline` 这种 pass 的资源绑定计划能正确建立
- `MaterialTexture` / `MaterialConstants` / `PerObject` 等绑定仍走现有机制
重点检查:
- `Outline` 是否需要 `Lighting`
- `Outline` 是否需要 `ShadowReceiver`
- `Outline` 是否只需 `PerObject + Material + MaterialTextures + Sampler`
## 6.6 Shader authoring 约束正式化
这轮需要把“主场景 surface multipass”的 authoring 约束写清楚,而不是默认靠猜。
建议明确约定:
- 主场景可执行的 surface pass 必须有明确 `Name``LightMode`
- pass 名称与 builtin canonical name 的映射规则固定
- `ForwardLit` / `Unlit` / `Outline` 属于主场景通用 surface pass
- `DepthOnly` / `ShadowCaster` / `ObjectId` / `SelectionMask` 继续属于专用路径
这样做的价值是:
- shader authoring 规则清晰
- 不会再出现“写了 pass 但没人知道该在哪个阶段执行”
## 6.7 XCCharacterToon.shader 的正式接入方式
在 multipass 正式能力完成后,`XCCharacterToon.shader` 的正确接法应为:
- `ForwardLit` 负责角色主表面
- `Outline` 负责描边
- `DepthOnly` / `ShadowCaster` 继续沿用已有专用 pass
本轮对 Nahida 的定位是:
- 不再作为特判对象
- 只作为 multipass 正式化后的第一个高价值验证样本
## 7. 分阶段执行计划
## Phase 0: 基线确认与测试样本准备
### 目标
在改主路径前固定当前行为,防止重构期间把旧材质全带坏。
### 任务
- 盘点当前所有依赖 `ForwardLit / Unlit` 的单 pass 集成测试
- 新建一个最小 multipass 测试场景:
- 一个简单 mesh
- 一个 `ForwardLit + Outline` 测试 shader
- 明确 Nahida 作为高复杂度回归样本,不作为最小开发起点
### 完成标准
- 有一个简单到足以定位多 pass 执行问题的专门测试场景
- 现有 lit/unlit 场景回归基线不丢
## Phase 1: 主场景通用 surface multipass 基础设施
### 目标
`BuiltinForwardPipeline` 具备“一个物体可执行多个主 surface pass”的正式能力。
### 任务
- 扩展 `BuiltinMaterialPass`
- 新增 `Outline` pass type
- 单 pass 解析模型改成 multi-pass collection 模型
- 引入主场景 surface phase
- 重写 `DrawVisibleItems()` 执行逻辑
### 完成标准
- 最小 multipass 测试 shader 能完成两次 draw
- 单 pass shader 行为不回归
## Phase 2: Outline 正式落地
### 目标
`Outline` 成为主场景正式 pass而不是外置补丁。
### 任务
-`Outline` 补齐 builtin metadata / layout / binding 规则
-`XCCharacterToon.shader` 中加入正式 `Outline` pass
- 验证 `Cull Front / ZTest / ZWrite / Blend` 等状态是否符合需求
- 首轮先以 static mesh + vertex color alpha 宽度控制闭环
### 明确暂缓
- `smoothNormal` 新顶点语义支持
- skinned mesh outline
- 透明角色 outline 排序
### 完成标准
- 最小 multipass 测试场景通过
- Nahida 在 `original` 模式里开始出现正确的独立 outline draw
## Phase 3: Nahida / Unity 风格角色卡通验证
### 目标
把 multipass 正式能力用于 Nahida验证这套方案确实能支撑 Unity 风格角色 shader。
### 任务
-`XCCharacterToon.shader``Outline` 接入正式主场景 multipass
- 重新生成 `nahida.png`
- 对比 `unlit``forward lit``original` 三种模式的画面差异
- 评估是否可以锁定新的 `GT.ppm`
### 完成标准
- Nahida 的描边不再依赖临时逻辑
- `original` 渲染链路进入可持续迭代状态
## Phase 4: 通用化与规则收口
### 目标
把这次重构从“够 Nahida 用”收口成“引擎正式通用能力”。
### 任务
- 补文档,明确 shader multipass authoring 规范
- 视情况支持更多主场景 surface pass type
- 清理旧的单 pass 假设与命名
- 审查编辑器 / 材质检查器 / shader 资源导入链路是否需要显示 pass 信息
### 完成标准
- Multipass 不再是隐式能力
- 规则、测试、运行时行为三者一致
## 8. 测试计划
## 8.1 单元测试
重点新增或补强:
- `BuiltinPassMetadataUtils`
- `Outline` canonical name 匹配
- `BuiltinPassLayoutUtils`
- `Outline` 资源绑定计划
- `BuiltinForwardPipeline`
- 单材质多 surface pass 收集
- scene phase 排序
- 单 pass 回归不变
## 8.2 集成测试
建议新增:
- `tests/Rendering/integration/multipass_outline_scene`
- 最小 multipass 样例
- 继续保留:
- `nahida_preview_scene`
- 现有 lit/unlit/backpack/material_state 等基础场景
## 8.3 人工验收
人工验收重点不只是“有没有画出来”,而是:
- 是否真的发生了两次 draw
- state / cull / depth 是否正确
- 单 pass 材质是否回归
- Nahida 的 outline 是否来自正式 pass而不是额外补丁
## 9. 风险与控制
## 9.1 最大风险
最大风险不是代码量,而是“把旧的单 pass 假设改坏”。
具体风险包括:
- 单 pass lit/unlit 材质回归
- opaque / transparent 分类被打乱
- pipeline cache 或 descriptor set 复用逻辑出错
- 新增 `Outline` 后错误进入 shadow/depth/objectId 路径
## 9.2 风险控制策略
- 先做最小 multipass 场景,不直接拿 Nahida 起步
- 先只开放 `Outline` 这一种主场景 auxiliary pass
- 暂缓透明 multipass 与 depth-driven rim
- 每个阶段都跑现有 forward 基础集成测试
## 10. 本轮不做的内容
本计划明确不把以下内容混进首轮 multipass 重构:
- Render Graph 化
- SkinnedMesh / 骨骼动画
- GPU skinning
- Transparent multipass 完整排序体系
- Scene depth texture 的通用相机绑定
- Unity 全量角色 shader 语义一次性补齐
这些都不是当前根因的第一优先级。
## 11. 完成判定
当满足以下条件时,才算这次“通用 shader 多 pass 执行重构”完成:
1. 主场景 surface 路径正式支持一个材质执行多个 pass
2. `Outline` 成为正式 builtin surface pass
3. 现有单 pass 材质与基础场景不回归
4. Nahida 的描边来自正式 multipass 执行,而不是特判
5. 文档、测试、实现三者一致
## 12. 下一步建议
这份计划写完后的下一步,不是直接去碰 Nahida 复杂 shader 细节,而是:
1. 先做 `Phase 0`
2. 新建最小 multipass outline 场景与测试 shader
3. 再开始 `BuiltinForwardPipeline` 的 multipass 基础设施改造
顺序不能反。
如果一上来就直接拿 Nahida 开刀,很容易把“结构性问题”和“角色 shader 细节问题”混在一起,最后继续变成补丁式推进。