Sync editor rendering and UI workspace updates

This commit is contained in:
2026-04-09 02:59:36 +08:00
parent 23b23a56be
commit d46bf87970
107 changed files with 10918 additions and 430 deletions

View File

@@ -13,6 +13,26 @@ project(XCEngine)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(XCENGINE_NANOVDB_INCLUDE_HINTS
"${CMAKE_SOURCE_DIR}/engine/third_party/nanovdb/include"
"$ENV{VCPKG_ROOT}/installed/x64-windows/include"
"D:/vcpkg/installed/x64-windows/include"
)
find_path(
XCENGINE_NANOVDB_INCLUDE_DIR
NAMES nanovdb/io/IO.h
HINTS ${XCENGINE_NANOVDB_INCLUDE_HINTS}
)
if(XCENGINE_NANOVDB_INCLUDE_DIR)
set(XCENGINE_HAS_NANOVDB ON)
message(STATUS "NanoVDB headers found: ${XCENGINE_NANOVDB_INCLUDE_DIR}")
else()
set(XCENGINE_HAS_NANOVDB OFF)
message(STATUS "NanoVDB headers not found; .nvdb source-file support will be disabled")
endif()
enable_testing()
option(XCENGINE_ENABLE_MONO_SCRIPTING "Build the Mono-based C# scripting runtime" ON)

View File

@@ -0,0 +1,484 @@
# XCUI NewEditor主线重建计划
日期2026-04-07
## 1. 文档定位
本文档用于收口 `XCUI / XCEditor / new_editor` 当前阶段的真实执行主线。
它不是重复定义 XCUI 全部架构,而是把“接下来到底怎么做”重新拍板,并覆盖之前已经不再成立的执行假设。
本文档与现有文档的关系如下:
- `docs/plan/XCUI完整架构设计与执行计划.md`
- 继续作为 XCUI 总体架构与三层设计的总纲参考。
- 本文档
- 作为 `new_editor` 主线重建的最新执行计划。
- 优先级高于旧的阶段状态快照与旧执行顺序说明。
---
## 2. 当前拍板结论
当前必须明确以下结论,并作为后续开发硬约束:
### 2.1 旧 `editor/` 不再作为 XCUI 替换目标
- `editor/` 当前 ImGui 版本继续保留。
- 它是参考实现、行为对照和视觉基线来源。
- 当前阶段不再把主要精力放在“把 `XCEditor` 回填进旧 `editor/` 并替掉 ImGui”这条路线。
### 2.2 `new_editor/` 是未来正式编辑器主线
- `new_editor/` 不再只是临时名字上的沙盒。
- 它应被视为未来正式编辑器的新主线工作区。
- 后续真正重建编辑器时,应以 `new_editor/` 为宿主和产品主线,而不是回头改旧 `editor/`
### 2.3 `tests/UI` 仍然是基础层唯一实验场
- `tests/UI/Core` 只验证 Core 共享能力。
- `tests/UI/Editor` 只验证 Editor 基础层能力。
- `tests/UI/Runtime` 只验证 Runtime 层能力。
- 所有基础层验证、交互试验、截图检查,都优先放在 `tests/UI`
### 2.4 `new_editor/` 当前不承担“实验面板堆场”
- `new_editor/` 当前只承载:
- `XCEditor` 基础层库
- `new_editor` 的真实宿主与产品装配代码
- 在 Editor 基础层收口前,禁止把 `new_editor/` 继续做成杂乱的试验场或验证面板集合。
- 需要人工操作检查的内容,仍然通过 `tests/UI/*/integration` 提供。
### 2.5 当前主线优先级是 `Editor`,不是 `Runtime`
- `Runtime` 继续按三层设计保留,但不是当前最高优先级。
- 当前最高优先级是先把 `UI Core + UI Editor` 做成熟,并让其具备支撑 `new_editor` 正式重建的能力。
### 2.6 Editor 视觉基线以旧 `editor/` 为准
- `XCEditor` 当前默认视觉还没有达到旧 `editor/` 的程度。
- 后续 `Editor UI` 的默认主题、控件密度、边框、分隔线、状态反馈强度,都要以旧 `editor/` 现有风格为基线。
- 目标不是做一套“看起来差不多的深色 UI”而是做出可以承载当前编辑器产品感的正式 Editor 风格。
---
## 3. 三层边界再次确认
### 3.1 Core
`Core` 负责:
- retained-mode UI 运行机制
- layout / input / focus / scroll / popup / text / render contract
- theme / token / style resolve 的通用机制
- 与 Editor / Runtime 都共享的基础能力
`Core` 不负责:
- Editor 风格语义
- Runtime screen/player 宿主策略
- 业务面板
### 3.2 Runtime
`Runtime` 负责:
- game UI 的 screen / layer / player / system
- runtime 输入路由、阻塞、导航、menu/hud/modal 语义
- 面向游戏开发者的运行时宿主层
`Runtime` 不负责:
- Editor 专用控件
- Editor 默认风格
- 新编辑器重建主线
### 3.3 Editor
`Editor` 负责:
- editor-only widget
- editor-only shell / dock / workspace / panel session / viewport shell
- editor 风格语义
- 默认 editor theme token 与资源化样式体系
`Editor` 不负责:
-`editor/` 的就地替换
- 直接承担游戏 Runtime UI
---
## 4. 当前状态评估
## 4.1 已有基础
当前 `XCEditor` 已经具备以下基础:
- shell 级模型与状态骨架:
- workspace
- dock host
- panel registry
- panel lifecycle
- layout persistence
- shortcut / command 基础路径
- editor 常用基础件:
- menu bar / popup / context menu
- tab strip
- tree view / list view
- scroll view
- property grid
- viewport shell / viewport slot
- bool / number / enum field
- `tests/UI/Editor` 已形成 `unit + integration` 的基础验证体系。
结论:
- `XCEditor` 已经不是 demo 级玩具。
- 它已经具备继续作为 `new_editor` 底座推进的资格。
## 4.2 当前最突出的缺口
当前离“可正式支撑 `new_editor` 重建”的差距主要在以下几点:
### 4.2.1 Editor 默认样式资源层太薄
- 当前 `.xctheme` 只能控制少量颜色、spacing、radius。
- 远不足以承载旧 editor 那种完整的产品级视觉体系。
- 目前很多控件仍然只是在通用深色皮肤上运行,不是正式 Editor 风格。
### 4.2.2 Widget 视觉参数仍未充分资源化
- 仍有大量 palette / metrics / font size / rounding / inset 硬编码在 C++ 里。
- 这会导致 `.xctheme` 只能“改一点颜色”,无法真正控制 Editor 外观。
### 4.2.3 视觉基线还未对齐旧 editor
- 当前 widget 默认密度偏松。
- 圆角偏大。
- 行高偏高。
- 分隔线与层级关系不够清晰。
- 菜单栏、面板框架、属性行、tab 等视觉结构还没有贴近旧 editor。
### 4.2.4 仍缺少若干 Editor 通用字段件与高级能力
- `TextField`
- `Vector2Field`
- `Vector3Field`
- `Vector4Field`
- `ColorField`
- 后续可扩展的 `AssetField / ObjectField`
### 4.2.5 collection / shell 高级能力还未收口
- multi-selection
- inline rename
- drag/drop contract
- virtualization
- icon / glyph 统一语义
- 更完整的 toolbar / status / shell chrome 体系
---
## 5. 当前阶段硬规则
### 5.1 先做基础层,不提前做业务面板
在 Editor 基础层完成收口前:
- 不开始复刻 Hierarchy / Inspector / Console / Project 这些完整业务面板。
- 只允许做承载这些面板将来必需的通用 Editor 基础件。
### 5.2 Core 能力缺口必须先回补 Core
凡是发现缺的是:
- layout
- input
- focus
- popup
- text
- render contract
- shared widget primitive
都必须优先回补到 `Core` 或 shared 层,而不是在 `Editor` 层写临时绕过实现。
### 5.3 Editor 风格必须资源化,但语义归 Editor 层管理
- 样式机制归 `Core`
- Editor 默认风格语义归 `Editor`
- 颜色 / spacing / radius / border / density / typography 应尽量走资源化 token
- 控件行为与结构语义仍由 `Editor` 代码层控制
### 5.4 `new_editor/` 只做正式产品装配,不做测试堆场
- 验证入口继续放在 `tests/UI`
- `new_editor/` 只保留未来正式编辑器真正需要的宿主、装配、资源与业务层结构
---
## 6. Editor基础层完成标准
只有当以下条件基本满足后,才允许进入 `new_editor` 业务面板重建阶段:
### 6.1 视觉与样式层
- 已建立足够厚的 Editor token 体系
- widget 的 palette / metrics 基本完成 theme 驱动
- menu / popup / tab / panel / property / list / tree / status / scrollbar / splitter 都已接入 Editor 主题
- 新主题能稳定逼近旧 editor 的视觉基线
### 6.2 字段件层
- `BoolField / NumberField / EnumField / TextField / Vector2/3/4Field / ColorField` 可稳定使用
- `PropertyGrid` 能复用这些字段件,而不是自己硬画假控件
### 6.3 collection / shell 层
- tree / list / tab / popup / scroll / dock / workspace 都具备稳定基础交互
- keyboard / focus / shortcut / popup / panel session 契约稳定
- 关键状态机已通过 `unit`
### 6.4 验证层
- 每个重要基础件都有对应 `unit`
- 每个重要交互点都有对应 `integration exe`
- 截图检查链路可用
- 中文操作指示和检查重点明确
---
## 7. 分阶段执行计划
## Phase AEditor主题系统重构
### 目标
`Editor` 当前“薄主题 + 硬编码 widget 视觉”的状态,重构为“厚 token + 统一默认 Editor Theme”。
### 任务
- 定义 Editor 主题 token 命名规范。
- 覆盖以下语义槽位:
- workspace
- panel
- header
- footer
- menu bar
- popup
- tab
- property row
- field label/value
- status bar
- splitter
- scrollbar
- selection / hover / active / focus
- 把现有 widget 中的默认 palette / metrics 尽量迁出为 Editor theme 可控项。
- 建立“旧 editor 风格对齐”的基准场景与截图检查。
### 完成标准
- 主题资源文件已足够控制主要 Editor 视觉。
- 修改主题时,不再需要频繁改 widget 绘制代码。
## Phase B字段件体系补齐
### 目标
补齐 Editor 通用字段件,为后续 Inspector / 面板表单重建打基础。
### 任务
- 正式完成 `TextField`
- 完成 `Vector2Field`
- 完成 `Vector3Field`
- 完成 `Vector4Field`
- 完成 `ColorField`
- 视需要评估 `AssetField / ObjectField` 的基础契约
-`PropertyGrid` 正式承接这些字段件
### 完成标准
- `PropertyGrid` 不再停留在基础标量字段件阶段。
- 复合字段件已有完整 `unit + integration`
### 当前检查点2026-04-08
- `TextField` 已完成正式接入:
- `XCEditor` 已提供 `UIEditorTextField``UIEditorTextFieldInteraction`
- `UIEditorTheme` 已补齐 `TextField` theme resolver 与 hosted builder
- `PropertyGrid``Text` 行已切到正式 `TextField` 复用链路
- `Vector2Field` 已完成第一批复合字段件接入:
- `XCEditor` 已提供 `UIEditorVector2Field``UIEditorVector2FieldInteraction`
- `UIEditorTheme` 已补齐 `Vector2Field` theme resolver 与 hosted builder
- `tests/UI/Editor` 已补齐 `Vector2Field` 的 layout / hit-test / interaction / theme 单测
- `editor_ui_vector2_field_basic_validation` 已可编译、运行、自动截图,并已接入 `editor_ui_integration_tests`
- 当前 `tests/UI/Editor` 中,这两批字段件都遵守同一条验证规范:
- 顶部必须明确写“这个测试验证什么功能”
- 试验面板只放当前批次要检查的控件
- 保持 Unity 向黑白灰风格,不把业务面板塞进 `new_editor`
- 因此 `Phase B` 当前已正式完成前两项:
1. `TextField`
2. `Vector2Field`
- 下一批主线顺序固定为:
1. `Vector3Field`
2. `Vector4Field`
3. `ColorField`
## Phase CCollection与交互高级能力收口
### 目标
让 tree/list/tab/menu/property 进入可长期复用的 Editor 基础件水平。
### 任务
- multi-selection
- inline rename/edit session
- drag/drop contract
- virtualization 基础设计与第一轮实现
- icon / glyph / disclosure / dropdown indicator 统一语义
- collection 与 keyboard navigation/focus 的进一步收口
### 完成标准
- collection 控件不再只是演示级原型。
- 已具备支撑真实 editor 面板的核心交互能力。
## Phase DShell与Workspace正式收口
### 目标
把 shell 基础层从“能演示”推进到“可作为新编辑器外壳底座”。
### 任务
- menu bar / popup / context menu 视觉与交互对齐旧 editor 基线
- panel frame / status bar / tab strip / dock host 进一步主题化和结构收口
- workspace session / panel lifecycle / shortcut / command / layout persistence 继续补齐
- viewport shell / viewport input bridge 与 Editor shell 契约继续稳定
### 完成标准
- shell 基础层达到可承载空业务编辑器外壳的程度。
## Phase E`new_editor` 空壳正式接管
### 目标
在不引入具体业务面板的前提下,让 `new_editor` 正式使用 `XCEditor` 搭建空编辑器壳层。
### 任务
- `new_editor` 中装配:
- 主菜单壳层
- 工具栏占位
- workspace / dock 壳层
- status bar
- viewport shell 占位
- 保持业务内容为空或极简占位,不提前混入具体面板逻辑。
### 完成标准
- `new_editor` 具备“正式产品空壳”形态。
-`editor/` 继续只承担参考作用。
## Phase F业务面板分批重建
### 前置条件
只有在 Phase A 到 Phase E 基本完成后,才进入本阶段。
### 执行原则
- 以旧 `editor/` 为行为和视觉参考
-`new_editor/` 为正式宿主
- 业务面板按独立垂直切片逐个迁入
### 候选顺序
- Inspector 基础表单链路
- Hierarchy
- Console
- Project
- Scene/Game 相关工具壳层
---
## 8. 测试与验收规则
### 8.1 Core 能力进 `tests/UI/Core`
凡是共享能力,一律在 `tests/UI/Core` 验证:
- popup overlay primitive
- scroll / focus / keyboard navigation
- text input / style resolve / layout
### 8.2 Editor-only 能力进 `tests/UI/Editor`
凡是 editor-only一律在 `tests/UI/Editor` 验证:
- field widgets
- property grid
- menu / popup / tab / dock / workspace
- shell state / panel lifecycle / viewport shell
### 8.3 `new_editor` 不承担测试职责
- `new_editor` 只做产品集成冒烟检查
- 不把验证场景继续塞到 `new_editor`
### 8.4 每批次收口要求
每推进一个 Editor 基础件批次,至少必须完成:
- 对应 `unit`
- 对应 `integration exe`
- 人工截图检查
- 风格与交互结论记录
---
## 9. 与其它计划的关系
### 9.1 继续有效的文档
- `docs/plan/XCUI完整架构设计与执行计划.md`
- 继续作为 XCUI 总体架构总纲
- `docs/plan/Material Inspector与Shader属性面板收口计划_2026-04-07.md`
- 继续作为未来业务层计划保留
- 但执行前提是本计划中的 Editor 基础层先收口
### 9.2 已被覆盖的旧结论
以下旧结论不再作为当前主线执行依据:
- “后续主要目标是把 XCUI 直接嵌回旧 `editor/` 并替掉 ImGui”
-`new_editor/` 长期只作为临时沙盒存在”
- “可以在 `new_editor/` 中继续堆各类试验面板作为主验证入口”
---
补充归档结论:
- `docs/plan/used/XCUI_Phase_Status_2026-04-05.md` 已转入归档,不再作为当前主线执行依据。
## 10. 当前结论
当前最重要的不是立刻复刻旧 editor 的具体业务面板,而是先把:
- `UI Core`
- `UI Editor`
- `new_editor` 正式宿主边界
这三者之间的职责彻底做对。
正确路径是:
1. 继续在 `tests/UI` 中把基础层做成熟
2. 以旧 `editor/` 为风格与行为参考
3.`new_editor/` 为未来正式编辑器主线
4. 待基础层达标后,再进入业务面板分批重建
这条路线比“直接替换旧 editor 中的 ImGui”风险更低也更符合当前工程实际。

View File

@@ -0,0 +1,904 @@
# 一 体积渲染入门
**原教程链接:** [https://www.scratchapixel.com/lessons/3d-basic-rendering/volume-rendering-for-developers/intro-volume-rendering.html](https://www.scratchapixel.com/lessons/3d-basic-rendering/volume-rendering-for-developers/intro-volume-rendering.html)
本课程聚焦于通过**光线步进ray-marching技术实现体绘制。虽然该方法未必过时但现代生产级渲染软件更倾向于使用delta tracking 方法**。不过,光线步进曾长期作为这些软件渲染体积的行业标准,因此将其作为入门学习内容并无不妥。我认为读者可以从此入手,因为它更简单易懂,能帮助大家更好地掌握后续高级方法所依赖的数学基础。
体绘制(严格来说,用 “参与介质” 替代 “体积” 这一术语更为准确)是一个与硬表面渲染同样庞大且复杂的领域。它拥有一套独立的方程体系,实际上可以看作是描述光线与固体物质相互作用方程的推广。对于不熟悉复杂数学公式的读者而言,这些内容可能会让人望而生畏。遵循 Scratchapixel 一贯的教学理念,我们采用**“自下而上” 的实践教学法**。也就是说,我们不会从复杂方程入手,而是先编写代码渲染一个简单的**体积球体**,在过程中以直观易懂的方式讲解相关概念,最后在课程末尾对所学内容进行总结和形式化梳理。
![](images/2026/02/08/20260208_131118_296.png)
---
# 一、体积渲染
## 引言
本课程前三章目标是学习如何渲染一个球形体积,该体积由单个光源照射,背景为纯色。这将帮助我们建立对体积的直观认知,并引入用于渲染体积的光线步进算法。在本章中,我们将仅渲染密度均匀的基础体积,暂时忽略物体外部或体积内部投射的阴影,以及密度变化的体积渲染。这些内容将在后续章节中探讨。我们将跳过大量关于体积定义和渲染方程的背景知识,**直接从实践入手**,通过实现过程逐步构建对体绘制的系统性理解。
## 内部透射率、吸收、粒子密度与比尔定律
当物体反射的光或光源发出的光穿过充满粒子的空间时,其能量会被吸收。体积内的粒子越多,体积就越不透明。基于这一简单观察,我们可以确立体绘制的几个核心概念:**吸收、透射**,以及**体积不透明度**与**内部粒子密度**的关系。本章中,我们假设体积内的粒子密度是均匀的。
![](images/2026/02/08/20260208_131118_342.webp)
当光线穿过体积射向人眼(这正是我们能看到物体成像的原理)时,部分光会被体积吸收,这一现象称为**吸收**。目前我们关注的是背景光穿过体积后的透射量,这一指标被称为**内部透射率**,即光线穿过体积时被吸收后的剩余光量。内部透射率的取值范围为 0 到 10 表示体积完全阻挡光线1 表示在真空环境中光线完全透射)。
光线穿过体积的透射量遵循**比尔 - 朗伯定律**(简称比尔定律)。在该定律中,密度的概念通过**吸收系数**(后续章节还会引入散射系数)来体现。本质上,“体积密度越大,吸收系数越高”,而吸收系数越高,体积的不透明度也随之增加。比尔 - 朗伯定律的表达式如下:
![](images/2026/02/08/20260208_131118_385.webp)
该定律指出,光线穿过体积的内部透射率($T$)与体积的吸收系数(希腊字母$\sigma$)和**光线在介质中传播的距离(即路径长度)**的乘积呈指数关系。
> 注意这里的distance是光线穿过介质的距离
由此可得比尔定律的一个性质:**对于分段路径,总透射率等于各段透射率的乘积**。
具体来说,如果光线在介质中传播的总距离为 $\text{distance}_2 = \text{distance}_0 + \text{distance}_1$,那么根据比尔定律:
$$
T_2 = e^{-\sigma_a \cdot \text{distance}_2} = e^{-\sigma_a \cdot (\text{distance}_0 + \text{distance}_1)} = e^{-\sigma_a \cdot \text{distance}_0} \cdot e^{-\sigma_a \cdot \text{distance}_1} = T_0 \cdot T_1
$$
即总透射率 $T_2$ 等于两段透射率 $T_0$ 和 $T_1$ 的乘积。这体现了光线在介质中传播时,吸收效应具有累积性和可乘性。
另外这些系数的单位是**倒数距离**(如$m^{-1}$或$cm^{-1}$),仅涉及尺度差异。理解这一点很重要,因为它能帮助我们直观理解这些系数的物理意义:**吸收系数(以及后续的散射系数)可视为光子在任意位置或距离上发生随机事件(如被吸收或散射)的概率密度。**
> 注意这里是“概率密度”后面推导体积渲染方程的时候会再次提到这一点。因为这里实际上是假设了在distance长度的传输距离中吸收系数是恒定的所以才可以直接用区间长度乘以吸收系数等到了后面就会发现在非均匀参与介质中这一项实际上是需要积分的。
吸收系数和散射系数作为概率密度值是不限制必须小于1的这也取决于测量单位。例如某介质的吸收系数以毫米为单位时可能为0.2换算为厘米和米则分别为2和20。因此实际应用中使用大于1的值是完全合理的。
> 系数与平均自由程的关系吸收系数和散射系数的单位为倒数距离这一点至关重要——对系数取倒数1除以吸收系数或散射系数即可得到一个距离值这个距离被称为平均自由程它代表光子发生随机事件吸收或散射的平均传播距离
> $$ \text{平均自由程} = \frac{1}{\sigma} $$
> 该值在模拟参与介质中的多重散射时具有重要作用,想了解更多相关内容,可参考次表面散射和高级体绘制课程。
![](images/2026/02/08/20260208_131118_565.webp)
*图1传播距离越长或密度越大内部透射率越低。*
吸收系数或传播距离越大,内部透射率$T$的值越小。比尔-朗伯定律的计算结果始终在0到1之间当距离或吸收系数为0时结果为1当距离或吸收系数极大时$T$趋近于0。在固定距离下吸收系数增大减小在固定吸收系数下传播距离增加$T$同样减小。简言之光线在体积内传播越远被吸收的光越多体积内粒子越多光被吸收的程度越高。这一规律在图1中得到了直观体现。
> 比尔定律与宝石仅存在吸收作用的介质是透明(而非半透明)的,但会使透过的图像变暗,例如啤酒、葡萄酒、宝石和有色玻璃等。
## 在纯色背景上渲染体积
我们可以从简单场景开始实践。假设有一块厚度为10、密度为0.1的体积板背景颜色例如墙面反射的光为background_color那么透过体积板看到的背景颜色可通过以下方式计算
```hlsl
vec3 background_color {xr, xg, xb}; // 背景颜色
float sigma_a = 0.1; // 吸收系数
float distance = 10; // 光线穿过体积的距离
float T = exp(-distance * sigma_a); // 计算内部透射率
vec3 background_color_through_volume = T * background_color; // 透过体积的背景色
```
就是这么简单。
## 散射
到目前为止,我们都假设体积是黑色的,也就是说,我们只是在体积板覆盖的区域将背景色变暗。但体积并非只能是黑色,与固体物体类似,体积也能反射(更准确地说是**散射**)光线。这也是为什么在晴朗的日子里,我们能清晰看到云朵的轮廓,仿佛它是一个固体物体。体积还可以自行发光(例如烛火),这一点我们仅作提及,第一章暂不讨论发光效果。
> 反射是 “有规律的定向反弹”,散射是 “无规律的四处散开”,用日常例子就能懂:反射:光线(或声波等)碰到光滑、均匀的表面(比如镜子、平静的水面、抛光的金属),会沿着固定方向 “弹回去”,方向明确、有规律。比如照镜子能看清自己,就是光线反射的结果。散射:光线(或声波等)碰到粗糙表面、微小颗粒(比如墙面、空气分子、灰尘、雾滴),会被拆成无数条 “小光线”,向四面八方无规则散开,没有固定方向。比如天空是蓝色(阳光被空气分子散射)、教室里开灯能照亮整个房间(灯光被墙面 / 灰尘散射),都是散射的效果。
假设我们的体积板具有特定颜色**volume_color**,暂时先不深究该颜色的来源,后面会详细解释。我们可以先将其理解为体积物体“反射”(实际并非反射,此处暂以固体物体的反射概念类比)照射光线后呈现的颜色。此时,代码可修改为:
```cpp
vec3 background_color {xr, xg, xb}; // 背景颜色
float sigma_a = 0.1; // 吸收系数
float distance = 10; // 光线穿过体积的距离
float T = exp(-distance * sigma_a); // 内部透射率
vec3 volume_color {yr, yg, yb}; // 体积自身颜色
// 透过体积的最终颜色 = 透射的背景色 + 体积散射的颜色
vec3 background_color_through_volume = T * background_color + (1 - T) * volume_color;
```
这类似于Photoshop等软件中的图像混合Alpha混合操作。例如将图像B叠加到图像A上其中A是背景图如蓝色墙面B是带有透明通道的红色圆盘混合公式为
$$ \text{最终颜色} = \text{透明度} \times A + (1-\text{透明度}) \times B $$
此处的“透明度”对应透射率$T$而B则是体积物体的颜色即体积散射后射向人眼/相机的光线颜色)。我们将在讲解光线步进算法时再次回顾这一概念,现在请先记住这一混合逻辑。
## 渲染第一个体积球体
利用上述知识,我们可以渲染出第一幅三维图像。我们将渲染一个充满粒子的球形体积,并将其置于背景之上。原理非常简单:首先检测相机光线与球体是否相交。若不相交,则直接返回背景色;若相交,则计算光线进入和离开球体表面的交点,进而求出光线穿过球体的距离,再应用比尔定律计算光线透过球体后的透射量。这里我们暂时假设球体“反射”(散射)的光是均匀的,光照效果将在后续内容中探讨。
![](images/2026/02/08/20260208_131118_782.webp)
*图2穿过体积物体的相机光线*
![](images/2026/02/08/20260208_131118_926.webp)
*图3利用相机光线与体积物体的交点计算光线沿路径上体积的不透明度*
**实现细节**
从技术上讲,我们无需计算光线进出球体的具体交点,只需用光线与球体相交的参数距离(沿相机光线的参数化距离)相减即可得到穿过距离。以下示例中我们仍计算交点,是为了强调我们关注的是这两个点之间的距离。
```cpp
class Sphere : public Object {
public:
// 计算光线与球体的交点
bool intersect(vec3 ray_origin, vec3 ray_direction, float &t0, float &t1) const {
// 光线与球体相交检测的具体实现
}
float sigma_a{ 0.1 }; // 吸收系数
vec3 scatter{ 0.8, 0.1, 0.5 }; // 散射颜色
vec3 center{ 0, 0, -4 }; // 球体中心坐标
float radius{ 1 }; // 球体半径
};
// 光线追踪场景
vec3 traceScene(vec3 ray_origin, vec3 ray_direction, const Sphere *sphere) {
float t0, t1;
vec3 background_color { 0.572, 0.772, 0.921 }; // 背景色(浅蓝色)
if (sphere->intersect(ray_origin, ray_direction, t0, t1)) {
vec3 p1 = ray_origin + ray_direction * t0; // 光线进入球体的点
vec3 p2 = ray_origin + ray_direction * t1; // 光线离开球体的点
float distance = (p2 - p1).length();
// 光线穿过球体的距离也可直接用t1 - t0计算
float transmission = exp(-distance * sphere->sigma_a); // 计算透射率
// 最终颜色 = 透射的背景色 + 球体散射的颜色
return background_color * transmission + sphere->scatter * (1 - transmission);
}
else {
return background_color; // 无交点时返回背景色
}
}
// 渲染图像
void renderImage() {
Sphere *sphere = new Sphere;
// 遍历图像的每一行和每一列
for (int y = 0; y < image_height; ++y) {
for (int x = 0; x < image_width; ++x) {
vec3 ray_dir = computeRay(x, y); // 计算当前像素对应的光线方向
vec3 pixel_color = traceScene(ray_origin, ray_dir, sphere); // 追踪光线获取像素颜色
image_buffer[y * image_width + x] = pixel_color; // 将颜色存入图像缓冲区
}
}
saveImage(image_buffer); // 保存图像
// 其他清理工作...
}
```
![](images/2026/02/08/20260208_131119_130.webp)
很明显,随着密度(吸收系数$\sigma_a$的增加透射率逐渐趋近于0这意味着体积球体的颜色将逐渐盖过背景色。
从渲染结果可以看出,球体中心区域的不透明度更高,因为光线穿过该区域的距离最长;同时,随着吸收系数$\sigma_a$增大,整个球体的不透明度都会提升。太棒了!你已经成功渲染出第一个体积球体,距离成为体绘制专家又近了一步。
## 添加光照!内散射
目前我们已经得到了体积球体的基础渲染效果,但光照效果该如何实现呢?当光线照射到体积物体上时,直接暴露在光线下的部分会比阴影区域更亮。与固体物体一样,体积也会被光源照亮,我们该如何在渲染中体现这一点?
原理其实很简单。想象光源发出的光线穿过体积时,其强度会因吸收而衰减,而衰减程度同样遵循比尔定律。换句话说,若已知光线在体积内传播的距离,就能计算出该距离处的光线强度:
```cpp
float light_intensity = 10; // 光源强度(任意数值)
// 光线在体积内传播的距离乘以吸收系数,计算透射率
float T = exp(-distance_travelled_by_light * volume->absorption_coefficient);
float light_intensity_attenuation = T * light_intensity; // 衰减后的光线强度
```
正如我们之前所学,光线在体积内传播时,能量会按照比尔定律逐渐衰减,这一点不难理解。但除此之外,光线沿初始方向传播时,除了被吸收,部分光线还会被散射到其他方向。如果散射方向恰好与人眼观察方向相反,那么这部分光线就会进入人眼或相机传感器。这一过程与光线从固体表面反射类似,但存在一些有趣的差异。
由于云朵并非固体物体,光线照射到粒子上时会发生两种情况:
* 被吸收;
* 被散射。对于体积,我们通常用“散射”而非“反射”来描述这一过程。
如果光线没有击中体积内的粒子,自然会沿原方向继续传播。我们暂时无需关注光线被吸收和被散射的概率,目前需要重点关注的是:如果光线被散射到与人眼观察方向相反的方向,那么即使光源发出的初始光线并非朝向人眼,这部分散射光最终仍能进入人眼。这种现象被称为**内散射**,指的是**穿过体积的光线因散射事件而被重新定向至人眼方向的效果**。
图4展示了这一过程散射事件是光子与介质/体积中的粒子或原子相互作用的结果。原子不会吸收光子,而是将其“弹射”到与入射方向不同的方向(如果体积由水或烟雾等原子构成);或者,若烟雾中含有灰尘、煤烟、海盐等微小固体粒子,光线会从这些粒子表面反射(本质也是散射)。后续章节将深入探讨这一现象。
![](images/2026/02/08/20260208_131119_367.webp)
*图4透过体积物体看到的光线既来自背景物体图中蓝色部分也来自光源。尽管光源发出的初始光线并非射向人眼但部分光线在穿过体积物体时会因内散射效应被重新定向至人眼。*
观察图4可以发现进入人眼的光线图中蓝色相机光线由两部分组成一部分是来自背景的光线另一部分是光源发出后经内散射射向人眼的光线图中黄色光线
体积的外观(即从特定视角观察到的体积形态)是光线吸收与散射共同作用的结果——这里的光线既包括沿视线方向直接穿过体积的背景光,也包括场景中各方向光源照射体积的光线。因此,要生成逼真的云朵图像,必须同时考虑这两种效应。我们已经探讨了吸收现象,接下来需要研究如何在内渲染中加入内散射效果。
我们的目标是找到一种方法,测量因内散射效应而沿相机光线射向人眼的光量。但问题在于:**内散射可能发生在光线进出球体的两个交点($t_0$和$t_1$)之间的任意位置,这并非一个离散过程**(即光线粒子不会只在该线段的特定位置发生散射),而是沿整个线段持续发生的吸收与散射现象。那么,我们该如何测量沿一段距离内持续入射的光能呢?
在探讨解决方案之前,我们先统一**符号定义:用$L_s(\omega)$表示沿人眼方向(用希腊字母$\omega$表示)散射的光量,其单位为**辐射度**,其中$x$代表线段$t_0$到$t_1$上的任意一点。
![](images/2026/02/08/20260208_131119_566.webp)
*图5需要对相机光线穿过体积物体的线段上所有因内散射重新定向至人眼的光线进行积分计算。*
在物理学中,当需要表示沿某段距离或某个体积的能量流入时,我们通常使用**积分**来描述。换句话说,我们需要对函数$L_s(\omega)$在区间$t_0$到$t_1$上进行积分,所得结果即为我们要测量的总散射光量。如果你对积分概念不太熟悉,可以将其理解为“收集沿方向$\omega$的光线在参数距离$t_0$到$t_1$之间的所有散射光能”。若这些量是离散的,我们只需求和即可;但对于$L_s(\omega)$这类连续函数,就需要用积分来表达这一思想。该积分的数学表达式如下:
![](images/2026/02/08/20260208_131119_751.webp)
问题在于,我们需要计算的这个积分没有解析解。对于球体等简单形状和简单光照场景,或许存在解析解,但我们需要的是一种适用于任意体积形状(包括后续将介绍的非均匀密度体积)和任意光照场景的通用解决方案,因此解析解并非可行路径。那我们该怎么办?
我们可以采用实验物理学和计算机图形学(至少在影视或游戏图像制作中,计算结果无需达到科研级精度)中常用的方法——用**黎曼和**来近似计算这个积分。
黎曼和的思想很简单我们可以将曲线下方的面积分解为多个已知面积的简单图形如矩形见图7。通过在曲线上按固定间隔间隔宽度为$\Delta t$)采样$L_s(\omega)$的值,每个矩形的面积即可表示为采样值$L_s(\omega)$乘以间隔宽度$\Delta t$采样点取间隔中点。将所有矩形的面积求和就能得到曲线下方面积的近似值如图7所示。需要注意的是这一结果并非精确的曲线下方面积而是一个近似值从图中可以直观看出误差的存在。不难理解矩形的宽度$\Delta t$越小,近似结果就越接近真实面积,但同时计算耗时也会增加。
![](images/2026/02/08/20260208_131119_976.webp)
*图6沿光线按固定步长推进用黎曼和近似计算积分。*
![](images/2026/02/08/20260208_131120_212.webp)
*图7可以用黎曼和估算代表相机光线散射光量的曲线下方面积。核心思路是将曲线下的面积分解为多个小矩形每个矩形的高度为采样点的$L_s(\omega)$值,宽度为用户定义的步长$\Delta t$。*
将这一方法应用到体绘制中,具体该如何操作?后面我们将详细讲解,但可以先简单剧透一下:本质上,我们需要将相机光线穿过球体的线段,分割为多个长度为$\Delta t$的子线段;对于每个子线段,计算函数$L_s(\omega)$的值具体步骤包括见图5
* 从子线段**中点**$p$向光源方向发射一条光线,确定光线离开体积物体的位置,从而计算出光线从光源传播到点$p$时穿过体积的距离;
* 利用该距离,通过比尔定律计算光线传播至点$p$时**剩余的能量**
* 将计算得到的$L_s(\omega)$乘以步长$\Delta t$,并累加所有子线段的贡献值,即可得到近似的积分结果。
不过,细心的读者可能会发现两个我们尚未解答的问题:
* 我们现在已经知道了从光源传播到采样点$p$的光量,但这并不能告诉我们其中有多少比例的光会最终沿$\omega$方向散射。说得没错,你还没有完全理解$L_s(\omega)$的完整含义。换句话说,我们已经考虑了光的传播衰减,但尚未用到$\omega$变量——正如你所想,它在计算散射比例时起着关键作用。(剧透:这个问题是通过后面介绍的“相位函数解决”的)
* 沿$\omega$方向散射的光线,从点$p$传播到$t_0$(光线进入球体的点)的过程中,难道不会被再次吸收吗?也就是说,既然$p$位于体积内部,光线仍需在体积内传播一段距离才能离开并进入人眼,那么这段传播过程中光线应该也会被吸收。确实如此!
如果你能意识到这两个问题,那恭喜你,也说明我们的教学起到了效果。这意味着你已经准备好进入下一章,我们将在那里详细解答这两个问题。
---
# 二、光线步进算法
## 万能的光线步进算法
为了对沿光线传播路径、由内散射产生的入射光进行积分计算我们需要将光线穿过的体积分解为多个小体积元然后汇总每个小体积元对整个体积物体的贡献——这有点类似于在二维图像编辑软件如Photoshop将带有蒙版或Alpha通道通常表示物体不透明度的图像相互叠加。这也是我们在第一章中讨论Alpha合成方法的原因。每个小体积元都对应第一章中提到的黎曼和中的一个采样点。
![](images/2026/02/08/20260208_131120_416.webp)
*图1反向光线步进。沿光线从t1向t0方向以固定的小步长推进。*
算法的工作流程如下:
* 计算t0和t1的值即相机/人眼光线进入和离开体积物体的交点对应的参数距离。
* 将t0-t1定义的线段分割为X个大小相同的小线段。通常我们通过选择“步长”来实现这一点——步长就是一个定义小线段长度的浮点数。例如若t0=2.5、t1=8.3,步长=0.25则将t0-t1线段分割为(8.3-2.5)/0.25=23个小线段为简化理解暂不考虑小数细节
* 接下来沿相机光线推进X次可从t0或t1开始见第6点
![](images/2026/02/08/20260208_131120_609.webp)
*图2计算Li(x)需要向光源方向发射一条光线,以确定光束到达采样点需穿过体积的距离。*
5. 每推进一步,从该步的**中点(即采样点)向光源发射一条“光线”,计算这条光线与体积元的交点(离开体积的位置)。需注意,光源发出的光线在传播到采样点的过程中会被体积吸收,因此利用**比尔定律计算该采样点因内散射产生的贡献。这就是上一章提到的黎曼和中的Li(x)值。别忘了我们需要将这个值乘以步长对应黎曼和中的dx项即矩形的宽度。伪代码如下
```hlsl
// 计算当前采样点x的Li(x)
float lgt_t0, lgt_t1; // 光线与球体交点的参数距离
volumeSphere->intersect(x, lgt_dir, t0, lgt_t1); // 计算光线与球体的交点
color Li_x = exp(-lgt_t1 * sigma_a) * light_color * step_size; // step_size即我们的dx
...
```
如图2所示光线与球体交点检测中的t0应始终为0因为光线从球内部发出而t1是从采样点x到光线与球体交点的参数距离。因此我们可将该值代入比尔定律计算光线在体积物体中传播过程中被吸收的距离对应的衰减量。
6. 当然,在图一中,穿过小体积元(采样点)的光线也会被该体积元衰减。因此,我们以**步长**作为光束穿过该体积元的距离,利用比尔定律计算采样点的透射率,再用该透射率对(内散射产生的)光量进行衰减(相乘)。
7. 最后我们需要汇总所有采样点的贡献以得到体积物体的整体不透明度和“颜色”。实际上若从后往前考虑这一过程如图1所示第一个采样点从t1开始会被第二个采样点遮挡第二个又会被第三个遮挡依此类推直到最后一个采样点紧邻t0。从相机光线的视角看紧邻t1的采样点会被其前方所有采样点遮挡紧邻t0的采样点的下一个采样点会被这个紧邻t0的采样点遮挡以此类推。
“光线步进”的名称如今就很容易理解了我们沿光线推进以固定的小步长移动如图1所示为反向光线步进的示例。需注意光线步进算法并非必须使用固定步长也可采用非固定步长但为简化理解我们先使用固定步长Ken Musgrave称之为“跨度”。使用固定步长时我们称之为“均匀光线步进”区别于“**自适应光线步进”**)。
汇总采样点的方式有两种反向从t1到t0推进或正向从t0到t1推进。其中一种方式相对更有优势某种程度上。下面我们将分别说明它们的工作原理。
## 反向光线步进
反向光线步进中,我们沿光线**从后往前推进即从t1到t0**。这会改变我们汇总采样点以计算最终像素不透明度和颜色的方式。
很自然地,由于我们从体积物体的背面(球体的后半部分)开始,理论上可以将像素颜色(相机光线对应的最终颜色)初始化为背景色(浅蓝色)。但在我们的实现中,会在整个计算过程结束后(得到体积物体的颜色和不透明度后)再将两者混合——这类似于在二维编辑软件中合成两张图像。
我们从t1开始计算体积中第一个采样点设为X0的贡献然后以固定步长向t0推进依次计算后续采样点的贡献。
![](images/2026/02/08/20260208_131120_859.webp)
*图3计算一个采样点时需要考虑来自背面的背景光和来自光源的内散射光再考虑小体积元对这些光贡献的吸收。这可以理解为背景色与光源色分别乘以小体积元透明度后相加。*
那么,一个采样点的贡献如何计算?
我们先按照上述第6点的方法计算内散射贡献光源的贡献Li(X0)向光源方向发射一条光线然后利用比尔定律衰减光贡献以考虑光线从进入体积物体球体到采样点X0的过程中被吸收的量。
之后将这个光量乘以采样点的透明度代表该采样点吸收光的比例。采样点的透明度同样通过比尔定律计算步长即为光束穿过该采样点的距离图3
```cpp
...
color Li_x0 = exp(-lgt_t1 * sigma_a) * light_color * step_size; // step_size即我们的dx
color x0_contrib = Li_x0 * exp(-step_size * sigma_a);
...
```
我们已经计算出了第一个采样点X0的贡献。接下来处理第二个采样点X1但此时需要考虑两个光源第一个采样点X0发出的光即上一步的计算结果以及第二个采样点X1的内散射光。前者已计算完成后者的计算方法与X0一致。我们将两者相加再乘以第二个采样点的透射率得到新的结果。重复这一过程处理X2、X3……直至到达t0。最终结果即为体积物体对当前相机光线对应像素颜色的贡献。这一过程如下所示。
![](images/2026/02/08/20260208_131121_079.webp)
从上图可以看出,我们需要计算两个值:体积的**整体颜色**存储在result中和**整体透明度**。我们将整体透明度初始化为1完全透明然后在沿光线推进的过程中用每个采样点的透明度对其进行衰减相乘。最后**利用这个整体透明度将体积物体与背景色混合**,公式如下:
```cpp
color final = background_color * transmission + result;
```
在合成术语中“result”项已预先乘以了体积的整体透明度。若你对此感到困惑我们将在下一章详细说明目前无需过度关注。
另需注意上图和下方代码中采样点的衰减项始终相同exp(-step_size * sigma_a)。显然,这样的实现不够高效——我们应计算一次该值并存储在变量中重复使用。但我们的目标是清晰易懂,而非编写高性能代码。此外,目前沿光线推进过程中该值是恒定的,但在下一章中我们会发现,不同采样点的衰减项可能不同。
对应的代码实现如下:
```cpp
constexpr vec3 background_color{ 0.572f, 0.772f, 0.921f };
vec3 integrate(const vec3& ray_orig, const vec3& ray_dir, ...) {
const Object* hit_object = nullptr;
IsectData isect;
for (const auto& object : objects) {
IsectData isect_object;
if (object->intersect(ray_orig, ray_dir, isectObject)) {
hit_object = object.get();
isect = isect_object;
}
}
if (!hit_object)
return background_color;
float step_size = 0.2;
float sigma_a = 0.1; // 吸收系数
int ns = std::ceil((isect.t1 - isect.t0) / step_size);
step_size = (isect.t1 - isect.t0) / ns;
vec3 light_dir{ 0, 1, 0 };
vec3 light_color{ 1.3, 0.3, 0.9 };
float transparency = 1; // 初始化透明度为1
vec3 result{ 0 }; // 初始化体积颜色为0
for (int n = 0; n < ns; ++n) {
float t = isect.t1 - step_size * (n + 0.5);
vec3 sample_pos= ray_orig + t * ray_dir; // 采样点位置(步长中点)
// 使用比尔定律计算采样点透明度
float sample_transparency = exp(-step_size * sigma_a);
// 用采样点透明度衰减全局透明度
transparency *= sample_transparency;
// 内散射:计算光线从光源传播到采样点穿过体积的距离,再应用比尔定律
IsectData isect_vol;
if (hitObject->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-isect_vol.t1 * sigma_a);
result += light_color * light_attenuation * step_size;
}
// 最终用采样点透明度衰减结果
result *= sample_transparency;
}
// 与背景色混合并返回
return background_color * transparency + result;
}
```
但请注意,这段代码尚未完全准确——它缺少一些我们将在下一章补充的项。目前,我们仅希望你理解光线步进的核心原理。即便如此,这段代码仍能生成具有说服力的图像。
![](images/2026/02/08/20260208_131121_318.webp)
本例中我们使用了一个沿y轴向上的顶视平行光球体的淡红色来自光源颜色。可以看到球体上半部分比下半部分更亮阴影效果已初步显现。
$$
\begin{array}{l} A &=& Li(X_0) * \color{red}{Att};\text{ // first iteration n = 0} \\ B &=& (A + Li(X_1)) * Att; \text{ // second iteration n = 1}\\ B &=& (Li(X_0) * Att + Li(X_1)) * Att;\\ B &=& (Li(X_0) * \color{red}{Att^2} + Li(X_1) * Att;\\ C &=& (B + Li(X_2)) * Att;\text{ // third iteration n = 2}\\ C &=& (Li(X_0) * Att^2 + Li(X_1) * Att + Li(X_2)) * Att;\\ C &=& (Li(X_0) * \color{red}{Att^3} + Li(X_1) * Att^2 + Li(X_2) * Att;\\ ... \end{array}
$$
观察循环过程中Li(X0)的变化可以发现它会被采样点衰减项的若干次方相乘。沿光线推进的步数越多指数越大从1到2再到3……结果就越小因为衰减项或采样点透明度小于1。换句话说第一个采样点对体积整体散射光的贡献会随着采样点的累积而逐渐减小。
## 正向光线步进
![](images/2026/02/08/20260208_131121_557.webp)
*图4正向光线步进。沿光线从t0向t1方向以固定的小步长推进。*
在计算Li(x)和采样点透射率方面正向光线步进与反向光线步进并无区别。两者的差异在于采样点的汇总方式——正向光线步进沿t0到t1方向从前到后推进。此时一个采样点的散射光贡献需要被当前已处理的所有采样点包括当前采样点的整体透射率透明度衰减Li(X1)会被采样点X0和X1的透射率衰减Li(X2)会被采样点X0、X1和X2的透射率衰减以此类推。算法描述如下
步骤1进入光线步进循环前初始化整体透射率透明度为1结果颜色变量存储当前相机光线对应的体积物体颜色为0`float transmission = 1; color result = 0;`
步骤2光线步进循环的每次迭代
* 计算当前采样点的内散射光Li(x)
* 将整体透射率乘以当前采样点的透射率,更新整体透射率:`transmission *= sample_transmission;`
* 将Li(x)乘以整体透射率(当前已处理的所有采样点会遮挡该采样点的散射光),并将结果累加到存储体积颜色的全局变量中:`result += Li(x) * transmission;`
对应的代码实现如下:
```cpp
...
vec3 integrate(const vec3& ray_orig, const vec3& ray_dir, ...) {
...
float transparency = 1; // 初始化透明度为1
vec3 result{ 0 }; // 初始化体积颜色为0
for (int n = 0; n < ns; ++n) {
float t = isect.t0 + step_size * (n + 0.5); //关键区别
vec3 sample_pos = ray_orig + t * ray_dir;
// 当前采样点的透明度
float sample_attenuation = exp(-step_size * sigma_a);
// 用当前采样点的透射率衰减体积物体的整体透明度
transparency *= sample_attenuation;
// 内散射:计算光线从光源传播到采样点穿过体积的距离,再应用比尔定律
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-isect_vol.t1 * sigma_a);
// 用累积的整体透射率衰减内散射贡献
result += transparency * light_color * light_attenuation * step_size;
}
}
// 混合背景色和体积物体颜色
return background_color * transparency + result;
}
```
但请注意,这段代码尚未完全准确——它缺少一些我们将在下一章补充的项。目前,我们仅希望你理解光线步进的核心原理。即便如此,这段代码仍能生成具有说服力的图像。
此处无需展示图像——若实现正确,反向和正向光线步进应得到相同的结果。当然,我们知道你可能不会轻易相信,因此以下是两种方法的渲染结果。
![](images/2026/02/08/20260208_131121_801.webp)
## 为什么正向光线步进比反向光线步进“更好”?
因为当体积的透明度非常接近0时当体积足够大或散射系数足够高时可能出现这种情况我们可以**停止**光线步进——而这只有在正向推进时才可行。
目前,渲染我们的体积球体速度较快,但随着章节推进,你会发现渲染速度会逐渐变慢。因此,若我们能在沿光线推进过程中,当体积变得完全不透明、后续采样点不再对像素颜色产生贡献时停止计算,这将是一项有效的优化。
我们将在下一章实现这一优化思路。
## 步长的选择
![](images/2026/02/08/20260208_131122_008.webp)
*图5由于步长过大我们无法捕捉到体积中的细节。当然这个例子比较极端旨在帮助你理解核心思想。*
![](images/2026/02/08/20260208_131122_279.webp)
*图6这个例子也很极端2个采样点可能永远无法正确渲染体积物体的光照但可以看出由于步长过大我们无法捕捉到被固体物体遮挡的体积部分。我们需要小得多的步长。*
请记住我们沿光线从t0到t1以小步长推进、执行光线步进的目的是利用黎曼和方法近似计算积分沿相机光线向人眼散射的内散射光量。正如上一章和《着色的数学原理》课程中所解释的用于近似积分的矩形越大此处矩形宽度由步长定义近似精度越低反之矩形越小步长越小近似精度越高但计算耗时也会增加。目前渲染体积球体的速度较快但随着课程推进你会发现渲染速度会变得非常慢。因此**步长的选择是速度与精度之间的权衡**。
目前我们假设体积密度是均匀的。在下一章中我们将学习渲染云、烟雾等密度随空间变化的体积。这类体积既包含低频特征也包含高频细节。若步长过大可能无法捕捉到部分高频细节图5。这属于**滤波**问题,是一个重要但复杂的独立主题。
步长选择不当还可能引发另一个问题阴影。若小型固体物体在体积物体上投射阴影步长过大会导致我们错过这些阴影图6
以上内容并未告诉我们如何选择合适的步长。理论上没有固定的规则——你需要大致了解体积物体的尺寸。例如若体积是充满某种均匀介质的房间你需要知道房间的大小以及所使用的单位比如1单位=10厘米。因此若房间尺寸为100单位步长0.1可能过小而1或2可能是一个合适的起点。之后如前所述你需要通过调整找到速度与精度的最佳平衡点。
但这并非绝对。除了根据场景中物体的大小凭经验选择步长外,还有更合理的方法。一种可行的方法是:计算相机光线进入体积处的像素投影尺寸,将步长设置为该投影尺寸。实际上,作为离散单元的像素,无法呈现比自身尺寸更小的场景细节。我们在此不展开详细讨论(滤波值得单独开设一门课程),目前只需记住:合适的步长应接近相机光线与体积交点处的像素投影尺寸。这可以通过以下公式估算:
```cpp
float projPixWidth = 2 * tanf(M_PI / 180 * fov / (2 * imageWidth)) * tmin;
```
你可以根据需要优化该公式其中tmin是相机光线与体积物体交点的距离。类似地你也可以计算光线离开体积处的像素投影宽度然后在tmin和tmax处的投影宽度之间进行线性插值以设置沿光线推进过程中的步长。
## 继续前进前的其他注意事项!
编写生产级代码时,需要将光线的不透明度和颜色与光线数据一起存储。这样,我们可以先对固体物体执行光线追踪,再对体积物体执行光线步进,并在过程中汇总结果(类似于我们在示例中将背景色与体积球体混合的方式)。
请注意,相机光线的传播路径上可能存在多个体积物体。因此,需要沿路径存储不透明度,并在光线步进过程中汇总连续体积物体的不透明度和颜色。
一个体积物体可能由多个相互重叠的物体(如立方体、球体)组合而成。在这种情况下,我们可能需要将它们组合成某种聚合结构。对这类聚合体执行光线步进时,需要特别注意计算构成聚合体的各个物体的交界面。
## 接下来:补充缺失的项,得到物理准确的结果
在本课程的第三章(下一章)中,我们将为当前实现补充缺失的项,以得到(更)符合物理规律的结果。我们还将向你展示,掌握这些知识后,你将能够阅读和理解其他人编写的渲染器代码。准备好了吗?
## 源代码
重现前两章图像的源代码(含嵌入在文件中的编译说明)可在课程的最后一章下载(与往常一样)。请注意,该代码与本章展示的代码片段略有不同,差异将在下一章中解释。
---
# 三、光线步进:精准实现!
## 内散射与外散射
在前几章中,我们只考虑了光束与介质粒子之间的两种相互作用:吸收和内散射。但要得到准确的结果,我们需要考虑四种相互作用。这些相互作用可分为两类:一类会削弱光束穿过介质到达人眼过程中的能量,另一类则会增加光束的能量。
光束穿过介质到达人眼时,能量会因以下两种作用而损失:
* **吸收Absorption**:部分光能被介质粒子吸收。若你是这个粒子,可以这样理解:“有一些光线正朝着你(观察者)传播,但很抱歉,我吸收了一部分,所以你接收到的光会减少。”
* **外散射Out-Scattering**:正如上一章所提及的,光线会被粒子散射。这会使原本不朝向人眼传播的光线被重新定向到人眼方向,这就是我们上一章讨论的内散射效应。**但原本朝向人眼传播的光线,在传播过程中也可能被散射到其他方向——这意味着光线的能量也会因此损失,这种现象被称为外散射(顾名思义)。**若你是这个粒子,可以这样理解:“有一些光线正朝着你(观察者)传播,但我把一部分散射到了随机方向,所以你接收到的光会减少。”
光束穿过介质到达人眼时,能量会因以下两种作用而增加:
* **发射Emission**:我们在第一章中提到过这种效应,但也说明暂时会忽略它。例如,火焰会发出炽热的光。
* **内散射In-Scattering**:我们对这种效应已经很熟悉了。**部分原本不朝向人眼传播的光线,会通过散射被重新定向到人眼方向,这种效应即为内散射。**若你是这个粒子,可以这样理解:“我收集了从各个方向射向我的光线,并将一部分朝着你(观察者)的方向发射出去,所以你会接收到一些原本不打算朝向你的光线。” 内散射可以看作是外散射的结果——光线会(或多或少地,后续介绍相位函数时会详细说明)向所有方向散射,而其中一个方向恰好是人眼观察方向(相机光线方向)。
这些效应如下图所示。
![](images/2026/02/08/20260208_131122_481.webp)
在计算光束穿过介质到达人眼过程中的**能量损失**时,我们必须同时考虑**吸收**和**外散射**。外散射和内散射均由同一类光-粒子相互作用(散射)引起——在上一章中,我们用变量$\sigma$(希腊字母西格玛)来定义散射。因此,由于散射($\sigma$)也是导致光束穿过介质到达人眼时能量损失的原因,我们需要将其与吸收系数$\sigma_a$一起纳入比尔定律方程中。请记住,该方程既用于计算$Li(x)$项,也用于计算采样点的透射率。因此,我们的代码需要修改如下(红色部分为修改内容):
```cpp
...
float sigma_a = 0.5; // 吸收系数
float sigma_s = 0.5; // 散射系数
// 计算采样点透射率
float sample_attenuation = exp(-step_size * (sigma_a + sigma_s));
transparency *= sample_attenuation;
// 内散射:计算光线穿过体积球体到达采样点的距离
// 然后使用比尔定律衰减内散射产生的光贡献
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-density * isect_vol.t1 * (sigma_a + sigma_s));
result += ...;
}
...
```
有时你会看到$\sigma_a$和$\sigma_s$的和被称为消光系数extinction coefficient通常用$\sigma_t$西格玛t表示。
$$ \sigma_t = \sigma_a + \sigma_s $$
这个消光系数同样可以用概率密度来解释,它可以理解为光子沿其路径传播时,**在单位长度内发生任何相互作用(无论是被吸收还是被散射)的概率密度**。它代表了光子“消失”(吸收)或“偏离”(外散射)其原始路径的总概率。例如,$\sigma_{t}= 0.5 m^{-1}$意味着光子平均每传播 2 米1 / 0.5)就会发生一次吸收或外散射。
这个时候再来理解比尔定律,会发现实际上比尔定律描述的是透射率,它可以用泊松分布来建模。
![](images/2026/02/08/20260208_131122_704.webp)
![](images/2026/02/08/20260208_131122_902.webp)
满足:
> 独立性:事件发生的概率不受之前发生过的事件影响(比如 “上一分钟没接到电话”,不影响 “这一分钟接到电话” 的概率);平稳性:单位时间 / 空间内事件发生的平均概率是固定的(比如每天医院急诊人数的平均值稳定,不会突然翻倍);稀有性:在极短时间 / 极小空间内,事件发生 2 次及以上的概率几乎为 0比如同一瞬间接到两个电话的概率可忽略
此时**透射率**$T$也就是对于**每个粒子**在distance的传输距离中一次都未发生吸收或散射的概率是泊松分布中的**零事件概率**。而前面代码中的**step_size * (sigma_a + sigma_s)是可以近似看作是每个粒子在step_size距离内发生“消失”或“偏离”的概率由于这里是“单个粒子”因此也可以看作是该粒子在step_size距离内发生“消失”或“偏离”的**期望值。
而**exp(-期望)** :这正是**泊松分布中的零事件概率**,即光子“幸存”下来,没有发生任何吸收或散射事件的概率,即下面公式中$P(X=0)=e^{-\lambda}$。
关于散射项,我们还没有完全讨论完……**内散射产生的、朝向人眼的光量也与散射项成正比。因此,我们还需要将内散射的光贡献乘以$\sigma_s$变量**。代码修改如下(红色部分为修改内容):
```cpp
...
float sigma_a = 0.5; // 吸收系数
float sigma_s = 0.5; // 散射系数
// 计算采样点透射率
float sample_attenuation = exp(-step_size * (sigma_a + sigma_s));
transparency *= sample_attenuation;
// 内散射:计算光线穿过体积球体到达采样点的距离
// 然后使用比尔定律衰减内散射产生的光贡献
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-isect_vol.t1 * (sigma_a + sigma_s));
result += transparency * light_color * light_attenuation * sigma_s * step_size;
}
...
```
详细的数学解释我们会在加入了相位函数之后说明这里暂时把sigma_s粗略地当成一依据概率的缩放因子即可。
## 密度项
我们将在下一章详细讨论这个术语。
到目前为止,我们假设用于控制体积“不透明度”的散射系数和吸收系数(请记住,这些系数的值越高,体积越不透明)在整个体积内是均匀的。在学术文献中,这通常被称为**均匀参与介质homogenous participating medium**。但现实世界中的“体积”(如云层、烟雾羽流)通常并非如此,它们的不透明度会随空间变化,这类介质被称为**非均匀参与介质heterogeneous participating medium**。
我们将在下一章中学习如何模拟密度变化的体积物体但目前我们先引入一个全局缩放散射系数和吸收系数的变量称之为密度density。我们将用它来缩放$\sigma_a$和$\sigma_s$,修改如下(红色部分为修改内容):
```cpp
...
float sigma_a = 0.5; // 吸收系数
float sigma_s = 0.5; // 散射系数
float density = 1; // 密度
// 计算采样点透射率
float sample_attenuation = exp(-step_size * density * (sigma_a + sigma_s));
transparency *= sample_attenuation;
// 内散射:计算光线穿过体积球体到达采样点的距离
// 然后使用比尔定律衰减内散射产生的光贡献
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-density * isect_vol.t1 * (sigma_a + sigma_s));
result += transparency * light_color * light_attenuation * sigma_s * density * step_size;
}
...
```
请记住,$\sigma_s$在代码中被使用了两次。我们将在下一章中解释如何实现空间变化的密度。
现在请注意一个有趣的点当密度为0时不会向result变量中添加任何值。换句话说在没有体积的区域空空间或密度=0不应该有任何光的累积。这对于以下代码行至关重要
```cpp
// 与背景色混合并返回
return background_color* transparency + result;
```
如果在没有体积的区域result的值不为0例如我们在计算内散射时忘记将散射项乘以密度值那么我们会在不应该出现光的区域看到光result>0。这就是上一章中我们提到result已经“预先乘以不透明度”的原因——它已经乘以了自身的“不透明度蒙版”在密度/不透明度大于0的区域result大于0否则为0。
## 相位函数
还记得第一章结尾我们留下的一个问题吗?
> 我们现在已经知道了从光源传播到采样点p 的光量,但这并不能告诉我们其中有多少比例的光会最终沿$\omega$ 方向散射。说得没错,你还没有完全理解$L_s(\omega)$ 的完整含义。换句话说,我们已经考虑了光的传播衰减,但尚未用到$\omega$ 变量——正如你所想,它在计算散射比例时起着关键作用。
相位函数就是用来解决这个问题的,它在变量的基础上又多考虑了$\omega$。
**内散射**贡献应使用以下方程计算:
![](images/2026/02/08/20260208_131123_151.webp)
其中,$Li$是内散射(辐射度)贡献,$x$是采样点位置,$\omega$是人眼观察方向(相机光线方向)。通常,$\omega$的方向始终与辐射度传播方向一致,即从物体指向人眼。$\omega'$表示光源方向(且$\omega'$应从物体指向光源)。
我们用文字描述这个方程:符号$S^2$(在文献中你也可能看到写作$\Omega_{4\pi}$)表示的积分意味着,内散射贡献可以通过考虑整个方向球$S^2$上所有方向的入射光来计算。
此处的$L(x, \omega')$就是我们在代码中计算的光贡献或**入射辐射度**项,在我们的这个示例中,由于我们只考虑了**一个光源**,而且是**点光源**,因此它就是以下代码片段计算的值:
```cpp
...
// 内散射:计算光线穿过体积球体到达采样点的距离
// 然后使用比尔定律衰减内散射产生的光贡献
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float light_attenuation = exp(-density * isect_vol.t1 * (sigma_a + sigma_s));
result += transparency * light_color * light_attenuation * sigma_s * density * step_size;
...
}
```
它表示在采样点$x$代码中的sample_pos来自特定光源方向$\omega'$代码中的light_dir的光量——该光线已穿过体积内的一定距离代码中的isect_vol.t1
但我们尚未引入积分符号后的项:$p(x, \omega', \omega)$。它被称为**相位函数**phase function本质上是**在球面立体角上归一化的条件概率密度函数**,它描述了在发生一次散射事件的**条件**下,光子从入射方向 ω' 被散射到出射方向 ω 的概率密度。我们接下来会解释其含义。
![](images/2026/02/08/20260208_131123_362.webp)
*图1各向同性光线在方向球上向所有方向均匀散射与各向异性相位函数光线在方向球上的分布不均匀*
当光子与粒子相互作用时,它会被散射到粒子周围所有可能的方向上,且每个方向被选中的概率相同。在这种情况下,我们称之为**各向同性散射体积isotropic scattering volume**。但各向同性散射并非普遍情况,大多数体积倾向于在有限的方向范围内散射光线,这类介质被称为**各向异性散射介质anisotropic scattering medium**或体积。相位函数是一个简单的数学方程,用于描述特定方向组合(观察方向$\omega$和入射光方向$\omega'$下的散射光量其返回值范围为0到1。从数学角度来说相位函数用于模拟光线或辐射度的**角分布**。
相位函数有几个特性。首先,**它在其定义域(方向球$S^2$上的积分必然为1**。实际上,组成体积的粒子会受到来自所有可能方向的光束照射,而这些可能的方向可以看作是以粒子为中心的球体。因此,如果我们考虑粒子周围所有可能的入射光方向,那么该粒子散射出的总光量不会超过所有入射光的总和——这就是相位函数需要在方向球上归一化的原因:
![](images/2026/02/08/20260208_131123_576.webp)
如果相位函数未归一化,它会导致光的“增加”或“减少”。相位函数的另一个特性是互易性:如果交换方程中的$\omega$和$\omega'$项,相位函数返回的结果相同。
$$ f_p(x, \omega, \omega') = f_p(x, \omega', \omega) $$
![](images/2026/02/08/20260208_131123_790.webp)
*图2相位函数仅考虑光线方向与观察方向之间的夹角$\theta$。*
**相位函数仅取决于观察方向和入射光方向之间的夹角。因此,它通常用**角度$\theta$(希腊字母西塔)来定义,即两个向量之间的夹角(而非$\omega$和$\omega'$本身)。如果我们计算方向$\omega$(观察方向)和$\omega'$(入射光方向)的点积,$\cos\theta = \omega \cdot \omega'$的取值范围为[-1, 1],因此$\theta$本身的取值范围为[0, $\pi$],如下列图像所示。
![](images/2026/02/08/20260208_131124_012.webp)
> 总之,相位函数用于告知你:对于任意特定的入射光方向($\omega'$),有多少光可能被散射到观察者方向($\omega$)。
闲话少说,相位函数具体是什么样子的?
最简单的是各向同性体积的相位函数。由于来自方向球上所有方向的光线会被均匀地散射到方向球上的所有方向因此相位函数请记住其在球域上的积分需归一化为1可简单表示为
$$ f_p(x, \theta) = \frac{1}{4\pi} $$
请注意,该函数与观察方向和入射光方向无关。函数定义中虽包含$\theta$角,但等式右侧(等号右边)并未使用该角——这符合预期,因为散射光子的出射方向与入射光方向无关(两者之间没有依赖关系,因此$\theta$无需出现在方程中),且所有出射方向被选中的概率相同(这就是方程为常数的原因)。这个方程不难理解:球体的表面积为$4\pi$球面度steradians因此如果你从微分立体角的角度考虑入射方向那么所有入射方向覆盖的表面积就是$4\pi$,因此相位函数必须为$1/(4\pi)$才能满足归一化特性:所有入射方向覆盖的表面积除以$4\pi$等于1。这里值得一提的是**相位函数的单位是1/球面度**1/srsr代表球面度
各向同性体积的相位函数非常简单。让我们再看另一个相位函数——亨耶-格林斯坦相位函数Henyey-Greenstein phase function其表达式如下
![](images/2026/02/08/20260208_131124_273.webp)
![](images/2026/02/08/20260208_131124_481.webp)
*图3不同非对称因子gg=0.3、0.5、0、-0.3、-0.5)下,亨耶-格林斯坦相位函数在极坐标系中的图像。角度$\theta$的取值范围为[0, $\pi$]。*
显然,它比各向同性相位函数更复杂。如你所见,它包含另一个变量$g$称为非对称因子asymmetry factor其中$g \in [-1, 1]$。这个参数用于控制光线的散射方向是向前还是向后:当$g>0$时,光线主要向前散射;当$g<0$时,光线主要向后散射;当$g=0$时,该函数等于$1/(4\pi)$即各向同性体积的相位函数。图3展示了不同$g$值下该函数的图像。
还存在其他相位函数如施里克相位函数Schlick、瑞利散射相位函数Rayleigh或洛伦兹-米散射相位函数Lorenz-Mie。这些函数被设计用于拟合不同类型粒子的散射行为。例如当你试图渲染由微小粒子小于光波长组成的体积时使用瑞利函数效果更好而对于较大的粒子灰尘、水滴等米函数更合适。亨耶-格林斯坦相位函数常用于影视行业的生产级渲染,因为它计算速度快(其他函数可能较慢)且易于采样(例如,参见《蒙特卡洛模拟》课程)。
最后,以下是将亨耶-格林斯坦相位函数添加到代码中的实现(你也可以自由实现其他函数):
```cpp
// 亨耶-格林斯坦相位函数
float phase(const float &g, const float &cos_theta) {
float denom = 1 + g * g - 2 * g * cos_theta;
return 1 / (4 * M_PI) * (1 - g * g) / (denom * sqrtf(denom));
}
vec3 integrate(...) {
...
float g = 0.8; // 相位函数的非对称因子
for (int n = 0; n < ns; ++n) {
...
// 内散射:计算光线穿过体积球体到达采样点的距离
// 然后使用比尔定律衰减内散射产生的光贡献
if (hit_object->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
float cos_theta = ray_dir * light_dir;
float light_attenuation = exp(-density * isect_vol.t1 * (sigma_a + sigma_s));
result += density * sigma_s * phase(g, cos_theta) * light_attenuation * light_color * step_size;
}
...
}
...
}
```
请注意,这与上述内散射项的正式数学定义更加接近。
![](images/2026/02/08/20260208_131124_796.webp)
上图展示了在两种不同的光照设置下,不同相位函数非对称因子$g$值对应的体积球体渲染结果:左侧为逆光(光线直接朝向相机),右侧为正光(光线和相机均直接朝向球体)。
亨耶-格林斯坦相位函数虽简单,但能很好地拟合真实世界的数据。例如,你可以通过组合$g=0.35$和$g$为负值或更大值的函数结果实现双瓣相位函数two-lobe phase function以获得更精确的拟合效果。请自由尝试。对于云层或薄雾等物体使用较大的$g$值约0.8)。课程末尾的参考资料部分提供了相关参考。
## 数学理解
如果继续从数学的角度来理解加入了相位函数之后的完整公式的话:
```cpp
result += density * sigma_s * phase(g, cos_theta) * light_attenuation * light_color * step_size;
```
首先因为我们的示例中只有一个不考虑体积的点光源因此对相位函数不需要进行积分直接取入射方向的光即可。而由于相位函数本质上是概率密度函数因此需要phase(g, cos_theta)乘以单位立体角才能表示概率。而对于light_color这个入射辐射度而言它本身就是单位立体角上的光通量。因此二者相乘就把这个单位立体角消掉了。
而由于sigma_s是在该路径上发生散射的概率密度因此需要乘以step_size才是发生散射的概率。同时又由于这个相位函数它本质上是在发生散射的条件下的条件概率密度函数因此它还需要乘以条件概率也就是sigma_s * step_size。所以这样乘下来density * sigma_s * phase(g, cos_theta) * light_attenuation * light_color实际上是step_size步长内的光贡献期望。最后用累加模拟积分即可。
> 同时这里可以发现在前面讨论比尔定律的时候我们用sigma_s乘以distance用来表示distance距离内发生光的外散射的期望。而这里我们用sigma_s相乘之后得到的是单位距离的光贡献期望。这是因为期望值的计算与我们关注的事件有关我们前面关注的是光因外散射发生能量损失这里关注的是光因内散射产生的光贡献。
## 采样点位置的抖动
![](images/2026/02/08/20260208_131125_081.webp)
到目前为止,我们始终将采样点定位在每个小线段的中点。使用均匀间隔的采样点类似于将体积切割成多个切片,这些切片可能会导致令人不适的条纹伪影(如上图所示,该效果经过人工放大)。为了“解决”这个问题,我们可以在每个小线段上随机选择一个位置放置采样点——换句话说,采样点可以位于小线段的任意边界范围内(当然,沿相机光线方向)。要实现这一点,我们需要将以下代码行:
```cpp
float t = isect.t0 + step_size * (n + 0.5);
vec3 sample_pos = ray_orig + t * ray_dir;
```
替换为:
```cpp
float t = isect.t0 + step_size * (n + rand());
vec3 sample_pos = ray_orig + t * ray_dir;
```
![](images/2026/02/08/20260208_131125_292.webp)
*图4为避免条纹伪影我们可以抖动采样点的位置而非使用均匀间隔的采样点。采样点可位于小线段的任意边界范围内。*
其中rand()是一个返回[0,1]范围内均匀分布随机数的函数。我们将这种方法称为随机采样stochastic sampling
随机采样是一种蒙特卡洛技术,在该技术中,我们在适当的非均匀间隔位置对函数进行采样,而非均匀间隔位置。
我们不能说这种方法“更好”(因此“解决问题”加上了引号),因为它用噪声替代了条纹,而噪声本身也是一个问题。尽管如此,从视觉效果来看,噪声比条纹更令人愉悦。你可以使用更复杂的“随机”数生成序列(例如拟蒙特卡洛方法)来减少噪声。然而,在本版本的课程中,我们将跳过这个主题——关于这个主题可以写一整本书(目前,你可以在《蒙特卡洛实践》课程中找到相关信息)。
## 体积不透明时退出光线步进循环(优化)
实际上如果你沿t0到t1距离的一半推进后体积的透明度例如低于1e-3你可能会认为计算剩余一半的采样点是不必要的如相邻图所示。你可以在检测到透明度变量低于这个最小阈值时直接退出光线步进循环见以下伪代码。考虑到光线步进是一种计算速度较慢的方法我们应该使用这种优化——尤其是当体积物体密度较大时密度越大透明度下降越快这将节省大量时间。我们在上一章中提到这是我们可能更倾向于使用正向积分而非反向积分的原因之一。
```cpp
...
float transparency = 1;
// 沿光线推进
for (int n = 0; n < ns; ++ns) {
...
if (transparency < 1e-3)
break;
}
```
![](images/2026/02/08/20260208_131125_507.webp)
*图5俄罗斯轮盘赌技术的可视化。当透明度低于某个阈值时我们退出光线步进循环但这意味着我们的结果被“截断”了。如何解决这个问题*
现在你可以在通过透明度测试时停止光线步进不再进行任何其他计算——但这在“统计上”是错误的会在渲染图像中引入偏差。通过查看xx图可以更轻松地理解这一点红线表示我们停止光线步进的阈值。如果我们在此处停止就相当于忽略了曲线下方和右侧沿x轴的体积贡献。当然这部分贡献在某种程度上是“可忽略的”——这也是我们最初决定实现这种截断方案的原因。然而如果你是一名试图模拟中子穿过板材过程的热核工程师这种方案是不可接受的。那么我们如何在利用这种优化的同时满足热核工程师的要求呢
我们将使用的方法称为俄罗斯轮盘赌Russian roulette——我们在专门介绍蒙特卡洛方法的课程中已经讨论过这种技术。其核心思想是当透明度值低于某个阈值例如1e-3应用俄罗斯轮盘赌技术。然后在[0,1]范围内随机选择一个均匀分布的数并测试该随机数是否大于1/d其中d是大于1的正实数可为整数但非必须。如果是则退出循环否则继续循环但将当前透明度值乘以d。此处的d表示通过测试的概率。例如当d=5时光线步进循环终止的“概率”为5分之4。
希望是合理的如果随机数小于1/d你可以认为光子被“杀死”了无法再进行任何处理。但作为“杀死”光子的交换我们会给存活下来的光子“增加能量”在我们的案例中实际上也就是增加透明度值——增加的比例与光子被杀死的概率成反比。以下是该思想的代码实现
```cpp
...
float transparency = 1;
// 沿光线推进
int d = 2; // d值越大退出步进循环的频率越高
for (int n = 0; n < ns; ++ns) {
...
if (transparency < 1e-3) {
if (rand() > 1.f / d) // 在此处停止
break;
else
transparency *= d; // 继续推进,但进行补偿
}
}
```
**俄罗斯轮盘赌Russian roulette**是一种蒙特卡洛技术,用于在模拟中无偏地终止采样路径。其核心思想是:
* 当某个事件(如光线继续传播)的概率很低时,可以以一定概率提前终止该事件,但需要对结果进行加权补偿,以保持期望值不变。
在体积渲染中用于优化光线步进过程当体积透明度低于某个阈值如1e-3它允许以一定的概率提前终止循环同时通过调整透明度值来保持统计无偏性。
* **无偏性保持**
* 假设当前光线透明度为$T$(已低于$\epsilon$),下一步的贡献(不考虑终止)为$C = T \times \Delta\mathbf{L}_{\text{next}}$$\Delta\mathbf{L}_{\text{next}}$是下一步的颜色贡献)。
* 以概率 $p = 1/d$ 继续循环,以概率 $1-p$ 终止循环。
* $\mathbb{E}[\text{贡献}] = p \times (T \times d \times \Delta\mathbf{L}_{\text{next}}) + (1-p) \times 0$
* 代入$p=1/d$$\mathbb{E}[\text{贡献}] = \frac{1}{d} \times (T \times d \times \Delta\mathbf{L}_{\text{next}}) = T \times \Delta\mathbf{L}_{\text{next}}$
* 这意味着在统计意义上,调整后的贡献值期望值等于原始值,因此最终颜色的期望值不变。
* **方差影响**
* 俄罗斯轮盘赌会增加结果的方差(因为引入了随机性),但避免了直接截断带来的系统性偏差。在蒙特卡洛渲染中,无偏性通常比方差更重要,因为方差可以通过增加采样数来减少。
## 阅读他人的代码!
本课程的前三章涵盖了开始渲染体积所需的全部知识。至此当你面对他人的代码时应该能够理解其核心逻辑。让我们一起进行这个练习我们将使用一个名为PBRT的开源项目并查看其体积渲染的实现——对你而言其中应该不再有任何秘密。
```cpp
Spectrum SingleScatteringIntegrator::Li(const Scene *scene,
const Renderer *renderer, const RayDifferential &ray,
const Sample *sample, RNG &rng, Spectrum *T,
MemoryArena &arena) const {
// [注释]
// 计算与体积物体的交界面t0, t1。如果光线未与体积物体相交
// 则将透射率设为1并返回颜色0。
// [/注释]
VolumeRegion *vr = scene->volumeRegion;
float t0, t1;
if (!vr || !vr->IntersectP(ray, &t0, &t1) || (t1-t0) == 0.f) {
*T = 1.f;
return 0.f;
}
// [注释]
// 如果存在交点将全局透射率透明度设为1
// 将存储最终颜色的变量此处命名为Lv设为0。
// 计算采样点数量,并相应调整步长。
// [/注释]
// 在_vr_中执行单散射体积积分
Spectrum Lv(0.);
// 准备体积积分的步进
int nSamples = Ceil2Int((t1-t0) / stepSize);
float step = (t1 - t0) / nSamples;
Spectrum Tr(1.f);
Point p = ray(t0), pPrev;
Vector w = -ray.d;
t0 += sample->oneD[scatterSampleOffset][0] * step;
// 计算单散射采样点的采样模式
float *lightNum = arena.Alloc<float>(nSamples);
LDShuffleScrambled1D(1, nSamples, lightNum, rng);
float *lightComp = arena.Alloc<float>(nSamples);
LDShuffleScrambled1D(1, nSamples, lightComp, rng);
float *lightPos = arena.Alloc<float>(2*nSamples);
LDShuffleScrambled2D(1, nSamples, lightPos, rng);
uint32_t sampOffset = 0;
// [注释]
// 正向光线步进:这是主循环,我们将遍历所有小线段,
// 计算每个采样点对最终体积透明度Tr和颜色Lv的不透明度和内散射贡献。
// [/注释]
for (int i = 0; i < nSamples; ++i, t0 += step) {
// 推进到t0处的采样点并更新_T_
// [注释]
// 更新采样点位置,然后评估该位置的体积密度。
// 我们尚未学习这部分内容,这是接下来两章的主题。
// 目前可将stepTau变量视为我们代码中的密度变量。
// 采样点位置经过抖动处理。
// 然后应用比尔定律用当前采样点的不透明度衰减全局透射率变量Tr
// [/注释]
pPrev = p;
p = ray(t0);
Ray tauRay(pPrev, p - pPrev, 0.f, 1.f, ray.time, ray.depth);
Spectrum stepTau = vr->tau(tauRay,
.5f * stepSize, rng.RandomFloat());
Tr *= Exp(-stepTau);
// [注释]
// 应用俄罗斯轮盘赌技术。
// [/注释]
// 如果透射率很小,可能终止光线步进
if (Tr.y() < 1e-3) {
const float continueProb = .5f;
if (rng.RandomFloat() > continueProb) {
Tr = 0.f;
break;
}
Tr /= continueProb;
}
// [注释]
// 采样点存活:计算该采样点...
// [/注释]
}
}
```
## 接下来是什么?
如果你能坚持到这里恭喜你你已经“毕业”了Scratchapixel将为你颁发虚拟荣誉证书。我们已经涵盖了这些算法的核心工作原理。剩余的章节主要是利用我们迄今为止所学和构建的知识享受乐趣并制作一些酷炫的图像。最后在最后一章中我们将汇总所有所学知识看看它们如何转化为描述光能穿过参与介质空气、烟雾、云层、水等并与之相互作用的通量的实际方程。

View File

@@ -0,0 +1,552 @@
# 三 体积渲染方程
## 从辐射传输方程到体绘制方程
在本章中,我们将学习支配体渲染的相关方程。
***
## 光如何与参与介质相互作用并在体积中传播?
大多数书籍和论文在渲染(尤其是参与介质渲染)中采用相同的约定。因此,熟悉这些约定是很有必要的。体积通常被表示为细小的**微分圆柱体**。**观察者从圆柱体的一端俯视,我们从另一端用准直光束照射**,如下图所示。我们需要求解的是光束穿过体积后到达观察者眼睛的光强(即有多少光到达观察者)。
![](images/2026/02/08/20260208_131632_832.webp)
这个光度量的专业术语是**辐射亮度radiance**,我们用字母 $L$ 表示。$L_i$ 是入射辐射亮度:照射到圆柱体上的光束强度。$L_o$ 是出射辐射亮度:从圆柱体另一端射出的光强。观察方向用 $\omega$(希腊字母欧米伽)表示。在代码中,这就是相机光线方向。
这是我们的基本设置。穿过圆柱体的窄(准直)光束与介质的相互作用有四种方式(假设这个微小圆柱体并非空的,而是充满了某些粒子):
* **吸收Absorption**:部分光被吸收,光束强度降低,辐射亮度减小。
* **外散射Out-scattering**:组成窄光束的光子沿 $-\omega$ 方向传播(直接朝向眼睛),但在传播过程中可能被散射到其他(随机)方向。外散射的光子不再属于原光束,因此光束强度也会降低,辐射亮度减小。
* **内散射In-scattering**:散射也可能使照射到体积上的部分光被重新定向到原光束的传播路径上,光束强度因此增加,辐射亮度增大。
* **发射Emission**:气体在达到特定温度时会发光。此时电子获得能量,并以光子的形式释放。这些光子的传播方向是随机的,但最终会有部分光子沿原光束的路径传播。因此,发射会使光束强度增加。
需要注意的是,外散射和吸收都会导致光能损失,而内散射和发射会使沿 $\omega$ 方向朝向眼睛传播的窄准直光束强度增加。此外,**内散射和外散射属于同一现象:光子与构成介质的粒子 "碰撞"**。
![](images/2026/02/08/20260208_131632_918.webp)
沿路径 $ds$ 的辐射亮度变化 $dL$ 等于沿方向 $\omega$ 上点 $x$ 处的入射辐射亮度 $L_i$ 与出射辐射亮度 $L_o$ 之差。这个辐射亮度变化也等于吸收、散射(内散射和外散射)和发射的净效应之和:
$$
dL(x,\omega) = \text{emission} + \text{in-scattering} - \text{out-scattering} - \text{absorption}
$$
这并非一个 "严格意义上" 的方程但在本章后续内容中我们将看到它如何最终推导为辐射传输方程RTE和体绘制方程VRE。在此之前我们需要先了解吸收系数、散射系数、比尔定律Beer's Law和相位函数。
***
## 吸收系数、散射系数与消光 / 衰减系数
吸收系数(及散射系数)最好结合其应用的方程来介绍,但将方程与系数定义混合讲解可能会造成混淆,因此我们现在单独介绍这些系数。
### 吸收系数
吸收系数(或吸收截面)$\sigma_a$ 表示光在介质中每传播单位距离被吸收的概率密度密度。吸收系数的单位是倒数距离(即 mm⁻¹、cm⁻¹ 或 m⁻¹
光被吸收的比例与入射光强无关。进入体积的光子数与射出体积的光子数之比(平均值)不随入射光子数变化。入射辐射亮度与吸收效应无关。总结一句话就是无记忆性。
此外吸收量与 $\sigma_a$、$ds$ 之间都存在线性关系:无论是将吸收系数加倍,还是将光在介质中的传播距离加倍,吸收量都会同等增加。
> **详细信息**
>
> 由于吸收系数的单位是倒数距离因此其倒数为距离称为平均自由程mean free path。平均自由程可理解为光子在与介质发生相互作用散射或吸收在体积中传播的平均距离。平均自由程是模拟多重散射的关键这一主题我们将在单独的课程中讲解
>
> $$
> \text{mean free path} = \frac{1}{\sigma}
> $$
>
> 请注意,平均自由程与吸收系数之间也存在线性关系:若吸收系数加倍,光子在与介质发生相互作用前的传播距离将减半。
### 散射系数
散射系数 $\sigma_s$ 与吸收系数类似,但表示光子在介质中每传播单位距离被散射的概率密度。内散射和外散射对辐射亮度变化的影响不同,因此我们对它们进行区分:内散射会为沿 $\omega$ 方向朝向眼睛传播的光束增加光强,外散射则会导致光束在朝向眼睛传播时能量损失。然而,两者都属于同一散射现象。光子被内散射或外散射的概率相同,均由单一系数定义:散射系数 $\sigma_s$(希腊字母西格玛)。
> **详细信息**
>
> 在部分文献中,散射系数和吸收系数用希腊字母 "缪"$\mu$)表示,这在物理学及研究中子等粒子在物质中运动的领域是常见约定。而在计算机图形学中,西格玛($\sigma$)已成为普遍采用的约定。
### 消光系数
如前所述,从外散射和吸收对辐射亮度变化的影响来看,两者是不可区分的 —— 它们都会导致沿 $-\omega$ 方向传播的光束辐射亮度降低。观察者所能感知到的只是光强的减弱:无论这种能量衰减是由光子吸收还是散射 / 反射引起的,都不会改变观察者的体验和观测结果。
> **详细信息**
>
> 当然,您可以通过设置探测器测量沿非 $-\omega$ 方向射出的光子,来区分吸收和外散射各自的贡献。这一点在后续介绍相位函数时会变得有用且有意义。
因此在计算光穿过介质时的辐射亮度损失时我们会将散射系数和吸收系数合并为一个系数称为消光系数extinction coefficient或衰减系数attenuation coefficient其表达式为
$$
\sigma_t = \sigma_a + \sigma_s
$$
下标 $t$ 代表总衰减total attenuation也可写作 $\sigma_e$。
***
## 比尔 - 朗伯定律的推导
我们在本课程的第一章就介绍了**比尔 - 朗伯定律Beer-Lambert Law**。之所以从该定律开始,是因为当您仅需计算光线透射率(而非辐射亮度)时,只需用到它。透射率与光穿过特定体积物体的比例有关,也可表述为 "物体的不透明度";而辐射亮度则定义了体积物体的亮度。我们将在后续更正式地介绍透射率的概念。
为了了解比尔 - 朗伯定律的来源,我们首先分析从点 $x$ 出发、沿方向 $\omega$ 传播距离 $s$ 的光束的辐射亮度导数。严格来说,方向应为 $-\omega$,但为简洁起见,我们使用 $\omega$(默认 $\omega$ 是观察者的观察方向,光束沿相反方向传播)。
辐射亮度的导数可表示为:
$$
dL = -\sigma_a L(x, \omega) ds
$$
这个很好理解,首先由于吸收作用是能量损失,因此有 $-$ 号。而又由于 $\sigma_a$ 是 $\omega$ 这条路径上单个粒子被吸收的概率密度,因此需要乘以 $ds$ 才是单个粒子被吸收的概率。最后再乘以在 $ds$ 这个微元上的辐射度本身 $L(x, \omega)$,这也就是辐射亮度的导数。
前文提到,外散射和吸收都会导致辐射亮度损失,但为简化起见,我们首先假设辐射亮度损失仅由吸收引起。后续我们会扩展并推广这一推理(引入散射项)。
该方程表示了在点 $x$ 沿方向 $\omega$ 传播时,辐射亮度因吸收而损失的速率。您可以将其类比为河床的坡度:沿河流的每个点,地面的坡度可能不同 —— 坡度反映了海拔降低的速度。同样,导数 $dL$ 反映了光在传播过程中辐射亮度降低的速度。
![](images/2026/02/08/20260208_131633_189.webp)
> 这个方程告诉我们什么?它表明,辐射亮度沿光线路径的衰减速率与该点的辐射亮度本身成正比。比例常数 $\sigma_a(x)$ 是吸收系数,其值可能随位置变化。这意味着光的吸收速度取决于介质的局部特性。换一种直观的理解方式:想象一条流向大海的河流,任意点的地形坡度决定了该点水流海拔下降的速度。同样,吸收系数就像是辐射亮度的 "坡度"—— 某点的吸收系数越高,该位置的辐射亮度衰减就越快。正如坡度会沿河流路径变化一样,吸收系数也会沿光线路径变化,进而逐步影响辐射亮度的衰减。
有一个重要细节需要说明,以帮助理解背后的原理:$L(x,\omega)$ 表示沿方向向量 $\omega$ 上点 $x$ 处的光束辐射亮度。正因为如此,人们往往忽略 $L(x,\omega)$ 是一个函数这一事实 —— 这个函数正是比尔 - 朗伯定律本身,也是我们要求解的目标。如果绘制这个函数的图像,会发现随着光在介质中传播距离的增加($x$ 离光束进入介质的点越来越远),函数值逐渐减小。下图展示了该函数(对于给定的吸收系数)随 $x$ 的变化曲线,蓝色线条代表某一特定位置处函数 $L(x,\omega)$ 的变化率(即斜率):
![](images/2026/02/08/20260208_131633_700.webp)
我们的目标是利用方程 $dL=-\sigma_a L(x,\omega)ds$ 求解 $L(x,\omega)$。为解决这个问题,我们首先将方程改写为关于 $s$(而非 $x$)的函数,其中 $s$ 表示光束从入射点 $x$ 开始在介质中传播的距离。通过将 $x$ 替换为 $x_s = x + s \omega$,方程转化为关于 $s$ 的函数:
$$
\frac{dL(s)}{ds} = -\sigma_a L(s)
$$
这里的导数(方程左侧)采用莱布尼茨符号表示。分母中的项至关重要:左侧应读作 "函数 $L(s)$ 对 $s$ 的导数",通俗地说,就是 "随着 $s$ 的变化,$L(s)$ 的变化速率是多少"。
求解 $L(s)$即是解一个常微分方程ordinary differential equations, ODE此处为一阶常微分方程因为仅涉及一阶导数
$$
\begin{array}{l}
\frac{dL(s)}{ds} &=& -\sigma_a L(s)\\
\frac{dL(s)}{L(s)} &=& -\sigma_a ds\\
\frac{1}{L(s)} dL(s) &=& -\sigma_a ds\\
\int \frac{1}{L(s)} dL(s) &=& \int -\sigma_a ds\\
\int \frac{1}{L(s)} dL(s) &=& -\sigma_a \int ds\\
\ln(L(s)) &=& -\sigma_a s + C\\
e^{\ln(L(s))} &=& e^{-\sigma_a s}\\
L(s) &=& e^{-\sigma_a s}
\end{array}
$$
> 我们在计算中省略了常数 $C$但下方体绘制方程VRE的完整推导会给出更全面的解。一阶齐次线性微分方程 $y'=-p(x)y$ 的通解为:
>
> $$
> \begin{array}{l}
> \int \frac{1}{y} y' &=& \int -p(x) dx \\
> \ln|y|&=& P(x) + C\\
> |y|&=& e^{P(x)} e^{C}\\
> |y|&=& \pm e^C e^{P(x)}\\
> |y|&=& A e^{P(x)}
> \end{array}
> $$
>
> 其中 $P(x)$ 是 $p(x)$ 的原函数。
这个结果就是比尔 - 朗伯定律的方程。该方程适用于均匀介质;对于非均匀介质,请参见下方的完整透射率方程。希望您能看出:$dL(s)$ 对应 $dy$$ds$ 对应 $dx$$L(s)$ 对应 $y$$-\sigma_a$ 对应 $c$。如前所述,我们目前仅考虑了吸收的影响,但可以通过将 $\sigma_a$ 替换为消光系数 $\sigma_t$,将外散射引起的衰减纳入比尔定律:
$$
L(s)=e^{-(\sigma_a+\sigma_s)s}
$$
$$
L(s)=e^{-\sigma_ts}
$$
其中 $\sigma_t=\sigma_a+\sigma_s$。
下图展示了消光系数对体积不透明度的影响,以及随着系数值的增加,光被吸收的程度如何变化:
![](images/2026/02/08/20260208_131633_968.webp)
***
## 比尔定律、透射率与光学厚度
这引出了透射率transmittance的概念。透射率可理解为体积不透明度的度量或者说是光能够穿过体积的比例。更正式地说透射率是穿过体积的光的比例
$$
T = \frac{L_o}{L_i}
$$
其中,如前所述,$L_i$ 是入射辐射亮度,$L_o$ 是出射辐射亮度。透射率也可以表示光在体积中两点之间传播的透过比例,可通过比尔定律计算:
$$
T = e^{-\sigma_t s}
$$
其中 $s$ 是体积中两点之间的距离。您也经常会看到该方程写作:
$$
T = e^{-\tau}
$$
其中 $\tau$希腊字母陶称为光学深度optical depth或光学厚度optical thickness。透射率有两种类型仅考虑吸收的透射率称为内部透射率internal transmittance而考虑吸收、外散射等所有衰减因素的透射率称为总透射率total transmittance。对于消光系数目前暂不考虑密度的概念随空间变化的非均匀体积我们需要沿光线对消光系数进行积分表达式为
$$
\tau = \int_{s=0}^d \sigma_t(x_s)ds
$$
其中 $d$ 是光线穿过体积的距离。比尔定律的最终通用形式为:
$$
T(d) = \exp \left(-\int_{s=0}^d \sigma_t(x_s) ds \right)
$$
如果您阅读了前面的章节可能会记得在《3D 密度场的体绘制》Volume Rendering of a 3D Density Field一章中提到过"陶tau"这个术语——在该章节中,我们用它来累积光线穿过非均匀介质时的消光系数值。
***
## 内散射与相位函数
最后要构建一个描述光能在介质中传播的全局方程我们还需要相位函数phase function这一关键部分。我们在《光线步进精准实现Ray Marching: Getting it Right!)一章中已经介绍过相位函数的概念。
![](images/2026/02/08/20260208_131634_246.webp)
*图 1只有部分入射光会被散射到眼睛方向具体比例取决于光线方向与观察方向之间的夹角。*
当光束中的光子与构成介质的粒子相互作用时,可能会被散射而非吸收,且散射方向是随机的——我们知道光子的入射方向,但无法预测其散射方向。当准直光束中的光子被散射时,光束会损失能量;然而,如果有其他光源从 $-\omega$ 方向照射圆柱体,那么该光源发出的、穿过圆柱体的部分光子可能会被散射到 $-\omega$ 方向,从而使沿 $-\omega$ 方向传播的光束能量增加——这就是内散射。问题在于,要知道内散射为光束增加了多少能量,我们需要确定:从某个倾斜角度穿过圆柱体的光束,有多少能量会被散射到 $-\omega$ 方向。这个比例由相位函数给出。
相位函数描述了沿方向 $\omega'$(注意此处的撇号)传播的入射光中,被散射到 $-\omega$ 方向的比例。请记住,这是一个三维过程,因此光会在整个方向球面上散射。散射光的分布当然取决于介质的特性,以及光线方向向量 $\omega'$ 与观察方向向量 $\omega$ 之间的夹角 $\theta$(希腊字母西塔)(这是文献中采用的约定)。
> **请注意**
>
> 相位函数的符号约定需要特别谨慎:$\omega$ 向量从点 $x$ 指向眼睛,$\omega'$ 向量从点 $x$ 指向光源(如图 1 所示)。经验法则:计算两个向量之间的夹角 $\theta$ 时,我们始终假设 $\omega$ 指向眼睛,$\omega'$ 指向光源。在代码中,当您使用光线方向向量和相机方向向量(可能与预期约定方向相反)计算夹角 $\theta$ 时,需要格外注意这一点。总而言之,相位函数描述了介质内任意点 $x$ 处光散射的角分布。
相位函数(记为 $f_p(x, \omega, \omega')$)具有以下特性:
* **互易性Reciprocal**$f_p(x, \omega', \omega) = f_p(x, \omega, \omega')$。交换两个向量,结果保持不变。因此,相位函数通常简记为 $f_p(x, \theta)$,其中 $\theta$ 是两个向量之间的夹角。
* **归一化到 1**:在方向球面(通常记为 $\mathbb{S}^2$)上的积分值为 1否则会在散射事件中增加或减少辐射亮度
$$
\int_{\mathbb{S}^2} f_p(x, \omega, \omega') d\theta = 1
$$
参与介质的散射行为分为两种类型:
* **各向同性Isotropic**:方向球面上的所有方向被选择的概率相同。
* **各向异性Anisotropic**:方向球面上的某些方向被选择的概率更高,优先向后方或前方散射,如下图所示。例如,云表现出强烈的前向散射特性。
![](images/2026/02/08/20260208_131634_531.webp)
各向同性介质的相位函数为:
$$
f_p(x, \omega, \omega') = \frac{1}{4 \pi}
$$
在《光线步进精准实现Ray Marching: Getting it Right!)一章中,我们已经介绍过亨耶-格林斯坦Henyey-GreensteinHG相位函数——这是最常用的各向异性相位函数之一。该函数仅依赖于夹角 $\theta$,定义为:
$$
f_p(x, \theta) = \frac{1}{4 \pi} \frac{1 - g^2}{(1 + g^2 - 2g \cos \theta)^{\frac{3}{2}}}
$$
> 关于该方程的归一化证明请参见《光线步进精准实现Ray Marching: Getting it Right!)。
该函数最初用于模拟星系间尘埃的光散射Henyey, L.C., and J.L. Greenstein. 1941. Diffuse radiation in the galaxy. Astrophysical Journal 93, 70-83但由于其简洁性也被广泛应用于模拟其他多种散射介质。在实际生产中尽管该函数简单但通常已足够此外模拟多重散射时需要对相位函数进行逆变换而该方程的逆变换易于实现
其中 $-1 < g < 1$$g$ 称为非对称参数asymmetry parameter
* 当 $g < 0$ 时,光优先向后散射(后向散射);
* 当 $g = 0$ 时,介质为各向同性(光向所有方向均匀散射);
* 当 $g > 0$ 时,光优先向前散射(前向散射)。
上图展示了 $g = -0.2$ 和 $g = 0.2$ 时的示例:$g$ 的绝对值越大,光越倾向于向光源后方或相机/眼睛前方散射。云表现出强烈的前向散射效应,$g \approx 0.8$J. E. Hansen. 1969. Exact and Approximate Solutions for Multiple Scattering by Cloudy and Hazy Planetary Atmospheres这导致云在逆光时边缘会出现光晕效应。
![](images/2026/02/08/20260208_131634_792.webp)
还可以使用其他相位函数如施利克Schlick、米氏Mie或瑞利Rayleigh相位函数。请关注我们后续关于参与介质多重散射的课程以了解更多这些模型的相关知识。
***
## 辐射传输方程与体绘制方程
现在我们已经掌握了构建最终方程所需的所有要素。第一个方程是辐射传输方程Radiative Transfer Equation, RTE。该方程的现代形式由苏布拉马尼扬·钱德拉塞卡Subrahmanyan Chandrasekhar在 1950 年出版的《辐射传输》Radiative Transfer一书中定义该书此后成为该领域的标志性著作至少是该主题的权威参考资料。我们不会在此花费过多时间深入探讨因为这本书确实……怎么说呢相当复杂您可以通过下面的快速浏览略知一二。
![](images/2026/02/08/20260208_131635_282.webp)
我们可能会在本课程的后续修订版或单独的课程中深入讲解辐射传输方程如果您感兴趣可以查阅有限元方法、辐射度方法以及论文《Modeling the Interaction of Light Between Diffuse Surfaces》和/或书籍《Radiosity and Realistic Image Synthesis》- Cohen, 1993。目前我们仅引用书中的一小段内容认为它很好地总结或介绍了我们迄今为止所学的所有知识
> "在本章中,我们将定义辐射传输领域涉及的基本物理量,并推导支配辐射在吸收、发射和散射介质中传播的基本方程——传输方程。"
好了,言归正传。辐射传输方程考虑了我们之前列出的、导致能量沿方向 $\omega$ 传播时辐射亮度变化的所有因素:吸收、内散射、外散射,以及发射(本课程中我们将忽略发射项)。请记住,该方程描述的是辐射亮度(导数)沿方向 $\omega$ 的变化:
$$
\frac{L(x+s \omega, \omega)}{ds} = \color{blue}{-\sigma_t L(x,\omega)} + \color{orange}{\sigma_s \int_{\mathbb{S}^2} f_p(x, \omega, \omega')L(x,\omega')d\omega'}
$$
标量 $s$ 表示沿方向 $\omega$ 的位置变化,这在本章开头已介绍过。
蓝色项代表吸收和外散射导致的损失橙色项是内散射项有时也称为源项source term。注意积分前的 $\sigma_s$ 项——这在概念上与我们之前介绍的、由吸收和外散射引起的能量损失方程类似:
$$
\begin{array}{l}
dL &= -\sigma_a L(x, \omega) \\
dL &= -\sigma_s L(x, \omega)
\end{array}
$$
内散射的强度与光被散射的概率成正比,该概率由散射系数 $\sigma_s$ 给出。内散射项的其余部分已在前面描述过:对方向球面 $\mathbb{S}^2$ 的积分意味着,内散射项需要考虑来自所有方向($\omega'$)的光,并通过相位函数 $f_p(x, \omega, \omega')$ 进行加权。
为简洁起见,我们令:
$$
L_s(x, \omega) = \int_{\mathbb{S}^2} f_p(x, \omega, \omega')L(x, \omega')d\omega'
$$
我们已经多次强调,辐射传输方程是一个积分-微分方程integro-differential equation它表达的是方向导数——点 $x$ 处沿方向 $\omega$ 的辐射亮度 $L$ 的导数。在文献中,您也可能看到该方程写作以下形式:
$$
(\omega \cdot \nabla_x)L(x, \omega) = \color{blue}{-\sigma_t L(x, \omega)} + \color{orange}{\sigma_s L_s(x, \omega)}
$$
其中 $\nabla_x$(数学中称为德尔塔或纳布拉符号)可理解为函数的梯度(即导数的多维概念)。我们添加下标 $x$ 以表示对 3D 空间中点 $x$ 的三个坐标求梯度;若省略下标,$\nabla$ 也可能表示对方向 $\omega$ 的变化求梯度。当您沿方向 $\omega$ 移动时,辐射亮度会局部变化(增加或减少),其变化率与吸收项和散射项成正比。与我们接下来要介绍的体绘制方程不同,这个积分-微分方程告诉我们:当沿光传播方向"迈出一步"时,辐射亮度的变化速率是多少。
然而,这个微分方程对我们来说并不是特别实用——作为计算机图形学开发者,我们需要的是测量体积物体边界处的辐射亮度:该辐射亮度是光线(或观察方向)穿过物体后,经吸收和/或外散射衰减、并经内散射增强后的结果,如下图所示(该图现已为您所熟悉)。
![](images/2026/02/08/20260208_131635_553.webp)
如前所述,辐射传输方程是一阶微分方程,其标准形式可表示为:
$$
y' + p(x)y = \color{red}{q(x)}
$$
在数学中,这被称为一阶非齐次线性微分方程。我们需要求解的方程中同时包含函数 $y$ 及其导数 $y'$。现在,我们将辐射亮度函数重新定义为距离 $s$ 的函数,其中 $s$ 是沿方向向量 $\omega$ 的光束上任意点到某一参考点的距离:
$$
\begin{array}{l}
y \rightarrow L(s) \\
y'(x) \rightarrow \frac{dL(s)}{ds} \\
\end{array}
$$
并且:
$$
\begin{array}{l}
y' + \color{blue}{p(x)} y = \color{red}{q(x)} \\
L'(s) + \color{blue}{\sigma_t} L(s) = \color{red}{\sigma_s L_s(s)}
\end{array}
$$
请记住,我们之前提到 $L_s$ 有时被称为源项——这是因为您可以将其理解为:光线沿途的某些位置会"出现"光,并被添加到光束的辐射亮度中,它是辐射亮度的"来源"。
这种标准形式的常微分方程有已知解(如果您感兴趣,可参见下方的推导):
$$
y(x) = \int_{t=0}^x \color{red}{q(t)} e^{-\int_t^x \color{blue}{p} dt'} dt + C_1 e^{-\int_{t=0}^x \color{blue}{p} dt}
$$
> **推导过程**
>
> 有多种方法可用于推导一阶非齐次线性微分方程的解我们采用积分因子法integrating factor method。该方法的核心思想是将常微分方程 $y' + p(x)y = q(x)$ 乘以一个函数 $I(x)$,得到:
>
> $\color{red}{I(x)y' + I(x)p(x)y} = I(x)q(x)$
>
> 我们称这个方程为修正后的常微分方程。要理解其意义,您需要知道两个函数乘积的导数公式(乘积法则):
>
> $\frac{d}{dx} [f(x) g(x)] = f'(x) g(x) + f(x) g'(x)$
>
> 现在,我们注意到:如果选择 $I'(x) = I(x)p(x)$,那么修正后的常微分方程左侧看起来就像是通过乘积法则计算的导数:
>
> $$
> \begin{array}{l}
> \color{red}{I(x)q(x)=I(x)y' + I(x)p(x)y} \\
> \color{red}{=I(x)y' + I'(x)y}\\
> =\frac{d}{dx} [I(x)y]
> \end{array}
> $$
>
> 两边积分得:
>
> $I(x)y = \int I(x)q(x)dx + C$
>
> 注意到 $I'(x) = I(x)p(x)$ 本身是一个一阶齐次微分方程,我们在本章前面已经给出了这类方程的通解:
>
> $I(x) = e^{ \int p(x) dx}$
>
> 现在,我们将积分因子 $I(x)$ 代入修正后的常微分方程:
>
> $e^{ \int p(x) dx}y = \int e^{ \int p(x) dx}q(x)dx + C$
>
> 即:
>
> $y(x) = e^{-\int p(x) dx} \left( \int e^{\int p(x') dx'} q(x) dx + C \right)$
要得到体绘制方程,只需将 $p$ 和 $q$ 分别替换为辐射传输方程中的 $\sigma_t$ 和 $\sigma_s L_s(x)$。
将辐射传输方程中的 $q$ 和 $p$ 替换为对应项(请阅读下方第二个注释),得到:
$$
L(x, \omega) = \int_{s'=0}^s \exp\left(-\int_{s'}^{s} \textcolor{blue}{\sigma_t(x_{s''})} \, ds''\right) \left[\textcolor{red}{\sigma_s(x_{s'}) L_s(x_{s'},\omega)}\right] ds' + L(0) \exp\left(-\int_{s'=0}^{s} \textcolor{blue}{\sigma_t(x_{s'})} ds'\right)
$$
光线沿 3D 空间中的位置 $x$ 由标量 $s'$ 和 $s''$ 重新参数化,这两个标量表示从入射点 $x$ 开始沿光线的距离。形式上,我们将 $x$ 替换为 $x_s = x + s \omega$。
外积分(变量为 $s'$)从光线进入体积的入射点 $x$ 延伸到光线离开体积的出射点 $x_s$(即我们想要获取光传播到该点的辐射亮度值的位置),积分距离为 $s$,表示沿光线路径累积的、被散射到光线方向的光。
内积分(变量为 $s''$)从当前散射点 $x_{s'}$ 延伸到光线的终点 $x_s$,积分距离为 $s'$,表示从散射点 $x_{s'}$ 到终点 $x_s$ 的光衰减。
项 $L(0) \exp\left(-\int_{s'=0}^{s} \textcolor{blue}{\sigma_t(x_{s'})} ds'\right)$ 表示初始辐射亮度 $L(0)$ 在整个路径长度 $s$ 上被体积透射率衰减后的结果。
正如您所猜测的这就是体绘制方程Volume Rendering Equation, VRE
![](images/2026/02/08/20260208_131635_887.webp)
> 如果您疑惑:在展开推导过程中给出的方程(见上方方框)时,前置的指数项为何出现在方程的第二部分,而第一部分中却消失了——这是一个很好的问题。原因如下:
>
> $\int_0^s \sigma_t(x) \, dx = \int_0^t \sigma_t(x) \, dx + \int_t^s \sigma_t(x) \, dx.$
>
> 换句话说,从 $0$ 到 $s$ 的积分可以拆分为从 $0$ 到 $t$ 的积分与从 $t$ 到 $s$ 的积分之和。因此:
>
> $\int_0^t \sigma_t(x) \, dx = \int_0^s \sigma_t(x) \, dx - \int_t^s \sigma_t(x) \, dx.$
>
> 对两侧取指数:
>
> $e^{\int_0^t \sigma_t(x) \, dx} = e^{\int_0^s \sigma_t(x) \, dx - \int_t^s \sigma_t(x) \, dx}.$
>
> 指数中的原始积分可拆分为两个积分,指数中的减法对应积分上下限的不同。将其应用于我们的方程:
>
> $L(x, \omega) = e^{-\int_0^s \sigma_t(x) \, dx} \left( \int_0^s e^{\int_0^s \sigma_t(x) \, dx - \int_t^s \sigma_t(x) \, dx} \sigma_s(t) L_s(t) \, dt + C \right).$
>
> 化简后得到:
>
> $L(x, \omega) = e^{-\int_0^s \sigma_t(x) \, dx} \left( e^{\int_0^s \sigma_t(x) \, dx} \int_0^s e^{-\int_t^s \sigma_t(x) \, dx} \sigma_s(t) L_s(t) \, dt + C \right).$
>
> 外层的指数项 $e^{-\int_0^s \sigma_t(x) , dx}$ 和 $e^{\int_0^s \sigma_t(x) , dx}$ 相互抵消:
>
> $L(x, \omega) = \int_0^s e^{-\int_t^s \sigma_t(x) \, dx} \sigma_s(t) L_s(t) \, dt + C e^{-\int_0^s \sigma_t(x) \, dx}.$
>
> 其中 $C$ 是表示初始条件 $L(0)$ 的常数:
>
> $L(x, \omega) = \int_0^s e^{-\int_t^s \sigma_t(x) \, dx} \sigma_s(t) L_s(t) \, dt + L(0) e^{-\int_0^s \sigma_t(x) \, dx}.$
最终,体绘制方程的形式为:
$$
\begin{aligned}
L(x, \omega) =& \int_{s'=0}^s \exp\left(-\int_{s'}^{s} \sigma_t(x_{s''}) \, ds''\right) \left[\sigma_s(x_{s'}) L_s(x_{s'})\right] ds' + \\
& L(0) \exp\left(-\int_{s'=0}^{s} \sigma_t(x_{s'}) \, ds'\right).
\end{aligned}
$$
> 需要说明的是,虽然我们不确定"体绘制方程"这一术语的首次提出者是谁,但该术语在 21 世纪初才开始被广泛使用。它出现在皮克斯研究院Pixar Research2017 年发布的《生产级体绘制》Volume Rendering for Production文档中但在此之前已有人使用。如果您有相关信息欢迎告知我们。
右侧的 $L_0$ 项对应于从观察者视角来看可能位于体积物体后方的物体发出的辐射亮度。如果体积物体后方有一个实体物体,那么该物体沿 $\omega$ 向量"反射"的辐射亮度 $L_0$ 将在穿过整个体积距离 $s$ 后,被体积的透射率衰减。
此外,如果考虑**发射项**,我们需要在内散射项旁添加发射项 $L_e$(注意发射源旁的 $\sigma_a$ 项)。
由此可得体积渲染方程的最终形式:
$$
\begin{aligned}
L(x, \omega) &= \int_{s'=0}^s \exp\left(-\int_{s'}^{s} \textcolor{blue}{\sigma_t(x_{s''})} , ds''\right)
\left[\textcolor{red}{\sigma_s(x_{s'})} L_s(x_{s'},\omega) + \textcolor{red}{\sigma_a(x_{s'})} L_e(x_{s'},\omega)\right] ds' + \\
&\quad L(0) \exp\left(-\int_{s'=0}^{s} \textcolor{blue}{\sigma_t(x_{s'})} ds'\right)
\end{aligned}
$$
另外别忘了:
$$
L_s(x, \omega) = \int_{\mathbb{S}^2} f_p(x, \omega, \omega')L(x, \omega')d\omega'
$$
> 体绘制方程对我们这些专注于计算机图形学的人来说更实用因为它将辐射传输方程转化为一个积分——尽管该积分没有解析解但至少可以通过黎曼和Riemann sum等技术求解这正是我们在前几章中实际采用的方法
用 $T(s)$ 表示透射率项,令:
$$
T(s) = \exp\left(-\int_{s'=0}^{s}\sigma_t(x_{s'}) ds'\right)
$$
如您现在所知,这表示光在介质中传播距离 $s$ 后的透射率,因此体渲染方程可写作:
$$
L(x, \omega) = \int_{s'=0}^s T(s')\left[\sigma_s(x_{s'}) L_s(x_{s'}, \omega)+\sigma_a(x_{s'}) L_e(x_{s'}, \omega)\right] ds' + T(s)L(0)
$$
* 外积分(变量为 $s'$)从体积的入射点延伸到出射点 $x_{s'} = x_s$,表示沿光线路径累积的、被散射到光线方向的光。
* 内积分 $T(s)$ 表示从散射点到出射点的光衰减。
* $s$ 是光穿过体积的总距离。
如果您能读到这里,恭喜您!您已经掌握了计算机图形学文献中最复杂的方程之一。
> **一段历史**
>
> 如果要为体绘制的介绍推荐一篇论文那一定是詹姆斯·T·卡吉亚James T. Kajiya1984 年发表的《光线追踪体积密度》Ray-Tracing Volume Density。这篇论文表明体积物体渲染绝非新兴技术——但当时的硬件性能甚至不足以将光线追踪应用于实体表面更不用说光线步进体积物体了。直到 20 世纪 90 年代末至 21 世纪初随着《超时空接触》Contact等电影的出现体绘制才开始在生产中应用因为大预算电影的制作成本终于可以承受这种技术。下图是该论文的截图展示了卡吉亚首次通过光线步进实现的体积物体渲染结果。 这篇论文可能是整个计算机图形学研究史上最重要的 10 篇论文之一。如果您有不同意见,欢迎告知我们。
现在的核心问题是:我们如何计算这个积分(答案当然不是 42我们在本课程中已经展示了一种解决方案——光线步进法但还存在其他方法。光线步进法曾经是主流但现在已被认为相当过时不过我们仍然认为它是学习体绘制的良好起点。如今主流方法是使用跟踪算法和随机采样方法。我们将在本课程的最后一章简要介绍这一主题。
***
## 从方程到代码
我们理解这些方程可能令人望而生畏,部分读者可能只关心它们如何转化为代码。本课程的前四章将带您完成这一过程,因此我们不会在此重复。如果您还没有阅读前几章,建议您先阅读。但这里提供一些线索,帮助您将方程的不同部分与各章节对应起来:
* $L(0)T(s)$ 项对应我们在本课程第一章学到的内容。$L(0)$ 简单表示实体物体(如下方图片中的红墙)反射的光,这些光穿过体积。该光(物体的颜色)仅被 $T(s)$ 衰减,其中 $s$ 是光穿过体积的距离,$T$ 就是比尔定律。如果物体是均匀的,则 $T(s) = \exp(-s * \sigma_t)$(见第一章);如果体积是非均匀的,则需要计算体积的光学厚度(见第三章),方程为 $T(s) = \exp(- \int_{t=0}^s \sigma_t(x_t) dt)$。如果仅考虑该项,体积球体将保持黑色——该项仅负责处理来自背景、穿过体积的光。
* 方程右侧的第一项 $\int_{t=0}^s T(t)\left[\sigma_s(x_t) L_s(x_t, \omega)\right] dt$ 对应单次散射项。要了解其如何转化为代码,请阅读第一章至第三章——该项负责球体的照明。
***
## 我们接下来将学习什么
本节课的大部分内容致力于讲解光线步进算法,但你需要了解的是:尽管该算法直到最近(至少在 21 世纪 10 年代中期之前)几乎是体渲染的唯一选择,但现代渲染引擎如今在处理体渲染时,通常会采用基于蒙特卡洛的随机方法。既然它已被视为过时技术,我们为何还要花费大量时间学习?一方面是出于历史原因;另一方面,对于 CGI 编程新手(尤其是几乎没有数学基础的人)而言,通过光线步进算法入门体渲染(及体渲染方程),远比通过复杂度高得多的随机方法更容易。
### 光线步进算法为何会被淘汰?主要有两个原因:
* 它无法准确模拟光线在真实世界中与体积介质相互作用的行为(我们稍后会详细说明);
* 随机方法对真实物理过程的模拟效果要好得多。
我们本可以从一开始就使用随机方法(该方法自 20 世纪 60 年代起就已为人熟知),但问题在于:这种方法的计算量是光线步进算法的无数倍,用它生成一张图像需要等待极长的时间(至少感觉上是这样)。光线步进算法虽然计算密集,但远不及随机方法 —— 这也是它直到最近仍作为体渲染首选解决方案的原因(即便如此,我们也是等到 20 世纪 90 年代末才开始在实际生产中应用光线步进算法,直到 21 世纪 00 年代末才开始普及。幸运的是随着计算能力的持续提升我们现在使用随机方法能够在合理时间内得到结果而且由于其生成的效果更优光线步进算法已逐步被基于随机方法的方案取代。证毕Quod erat demonstrandum
### 接下来我们看看光线步进算法的局限性所在
要回答这个问题,我们需要先理解光线在介质中的传播方式。光子进入体积介质后会发生这样的过程:它沿直线传播一段距离,最终与介质(例如构成体积的粒子)发生相互作用。正如我们所知,光子随后可能被散射(改变传播方向)或被吸收。如果发生散射,它会继续在介质中传播,但方向是随机的 —— 至少与撞击介质粒子前的传播方向大概率不同。这种 "传播 - 相互作用" 的循环会一直持续,直到光子被吸收或最终脱离介质。下图展示了这一过程:三个光子从顶部进入一个立方体体积介质后的不同命运。
![](images/2026/02/08/20260208_131636_111.webp)
其中两个光子(红色)最终被吸收,只有一个光子(绿色)脱离介质(脱离方向与进入立方体时的传播方向不同)。
光子的运动轨迹可以描述为一种 "随机游走"random walk—— 这也正是它的名称由来。我们还能观察到,光子在被吸收或脱离介质前,会与介质发生多次相互作用,即多次散射。而这正是光线步进算法的核心短板:它仅考虑了光子与体积介质之间的**单次相互作用**。
### 单次散射 vs 多次散射:低反照率 vs 高反照率体积物体
这种仅考虑单次相互作用的模式被称为 **"单次散射"single scattering**—— 我们只计算经过一次介质相互作用后,被重新导向观察者的光线。虽然有些体积介质的单次散射效应显著(例如蒸汽火车或火山喷发产生的黑烟),但许多其他类型的体积介质(尤其是云)表现出强烈的 **"多次散射"multiple scattering特性光子在脱离或被吸收会与物体发生无数次相互作用。这也是云呈现明亮白色的原因而蒸汽火车或火山产生的烟则是黑色的。我们称白色的云具有** "高反照率"high albedo而黑色的烟柱具有 **"低反照率"low albedo**。下图展示了低反照率和高反照率体积介质的差异:左侧的烟含有大量粒子,而云由水滴构成 —— 这也是两者视觉差异的主要原因。当然,黑烟之所以呈现黑色,也因为它会吸收大量光线。
![](images/2026/02/08/20260208_131636_379.webp)
总而言之,光线步进算法对于低反照率物体(如烟雾)的模拟效果尚可接受 —— 这类物体的外观主要由单次散射效应主导(如下图中橙色光线所示);但对于高反照率物体的模拟效果则很差 —— 这类物体的外观主要由多次散射效应主导(大多数脱离介质的光子都经过了多次相互作用,而非单次散射所假设的仅一次作用)。
![](images/2026/02/08/20260208_131636_688.webp)
顺便一提,在对比烟雾和云时还需注意:烟雾通常是**各向同性**isotropic而云则表现出强烈的前向散射特性。某种程度上我们可以将光线步进算法比作 "直接光照"direct lighting直接光照总比没有光照好这是显而易见的但显然不如同时包含直接光照和间接光照indirect lighting的场景渲染效果。使用光线步进算法时我们完全忽略了间接光照部分 —— 如下例所示,间接光照对于生成照片级真实感图像至关重要。因此,光线步进算法无法捕捉这一效应是一个严重的问题。
![](images/2026/02/08/20260208_131637_233.webp)
这带来了一个实际应用层面的问题:例如,要模拟云的外观,你必须向体积介质中注入更多光线(即在场景中添加额外光源)—— 这本质上是一种 "作弊" 手段,而非让计算机进行物理上准确的 "正确计算"。那么问题来了:替代方案是什么?如何才能实现 "正确计算"
### 基于随机的追踪方法
"正确计算" 的核心是让计算机模拟光子与介质的真实相互作用过程 —— 换句话说,模拟光子的随机游走行为。这类方法旨在追踪光子在体积介质中的传播路径,因此被称为 "追踪方法"tracking methods。这并非一种 "新" 方法:它于 20 世纪 60 年代被开发出来,用于模拟中子等粒子穿过板材的辐射过程。尽管该方法用途广泛、功能强大,但计算量也极大。如果你想自行深入了解相关主题,可以在互联网上搜索 "蒙特卡洛粒子输运"Monte Carlo particle transport, MCPT或 "蒙特卡洛光线 / 光子输运"Monte Carlo light/photon transport。我们在此不深入探讨该技术的细节首先我们已在本页面蒙特卡洛模拟提供了该方法的实际实现案例其次我们计划尽快2022 年)撰写相关课程 —— 请关注 "高级 3D 渲染" 板块的更新(课程名称暂定为《体素路径追踪》)。目前,我们只需了解其核心思想:模拟光子在体积介质中的传播路径。其目标仍是求解体渲染方程:
$$
L(s) = \int_{s'=0}^s T(s')\big[\sigma_s L_s(s') \big]ds' + T(s)L(0)
$$
(关于蒙特卡洛方法的更多内容,请参阅《蒙特卡洛方法的数学基础》和《蒙特卡洛方法实践》)。与光线追踪类似,我们不会采用 "正向模拟"(即追踪光子从光源到观察者 / 传感器的传播路径),而是采用 "反向追踪"—— 从观察者到光源。粒子在介质中的传播路径可由一系列 "步长" 构成,每一步都包含 "距离" 和 "方向" 两个参数。我们将利用对介质本身的认知(尤其是其散射系数、吸收系数和相位函数),通过随机采样确定光子的步长和方向,从而模拟这一行为。如前所述,基于随机的蒙特卡洛模拟或积分方法计算量极大。你可能听说过 "delta 追踪"delta tracking等技术它们可用于优化这一过程但会增加代码复杂度。delta 追踪也将在《体素路径追踪》课程中详细讲解。

View File

@@ -0,0 +1,924 @@
# 二 体积渲染实践
# 一、3D密度场的体积渲染
![](images/2026/02/08/20260208_131605_063.webp)
到目前为止,我们只学习了如何渲染均匀体积物体——即散射系数和吸收系数在空间中保持恒定的物体。这虽然可行,但略显单调,且与自然界中的实际情况并不相符。例如,观察蒸汽火车排出的烟柱,这类体积都是非均匀的,部分区域的不透明度高于其他区域。那么,我们该如何渲染非均匀体积物体呢?
在现实世界中,我们会说吸收系数或散射系数随空间变化:吸收系数越高,体积越不透明;且散射系数和吸收系数的空间变化可以相互独立。不过,我们通常会选择一种更实用的方法:为特定体积物体设定恒定的散射系数和吸收系数,然后使用 **密度参数** 来调节体积在空间中的外观。可以将密度参数(在编程中通常是 float 或 double 类型的实数)视为 **随空间变化** 的量,此时我们可以进行如下处理:
$$
\sigma_s' = \sigma_s * density(p)\\ \sigma_t = (\sigma_s + \sigma_a) * density(p)
$$
其中,$\sigma_s'$ 是经空间变化的密度参数调制后的散射系数;比尔定律方程中使用的消光系数 $\sigma_t$(吸收系数与散射系数之和)也会被空间变化的密度参数调制;$density(p)$ 是一个函数,返回空间中点 $p$ 处的密度值。通过这种方式,我们可以生成诸如上图中火山烟柱之类的体积物体图像。
现在的问题是:如何生成这种密度场?主要有两种技术:
* **程序化生成**:可以使用 3D 纹理(如佩林噪声函数)来程序化创建空间变化的密度场。
* **模拟生成**:也可以使用流体模拟程序(模拟烟雾等流体的运动)来生成空间变化的密度场。
本章将采用第一种方法;下一章将学习如何渲染流体模拟的结果。
## 使用噪声函数生成密度场
![](images/2026/02/08/20260208_131605_326.webp)
*图 1我们可以使用佩林噪声程序化生成密度场。该噪声函数以点为参数返回该点在 \[-1,1] 范围内的噪声值。*
本章将使用柏林噪声函数Perlin noise function程序化生成 3D 密度场。
什么是程序化噪声函数(此处特指佩林噪声函数)?从编程角度来说,它是一种在 3D 空间中程序化生成噪声图案的函数。我们可以利用这种图案生成密度随空间变化的密度场。该噪声函数接收一个空间点作为参数,返回该点处 3D 噪声纹理的值(通常是 float 或 double 类型的实数),其取值范围被限制在 \[-1,1] 之间。由于密度值只能是 0无体积或正数因此需要对噪声函数的输出值进行裁剪或重映射以获得正的密度值。在以下代码片段中我们将噪声值从 \[-1,1] 重映射到 \[0,1]
```cpp
float density = (noise(pSample) + 1) * 0.5;
```
其中pSample 是光线步进穿过体积时,相机光线上采样点的位置。
关于噪声函数我们将使用肯·佩林Ken Perlin本人提供的改进版佩林噪声实现。
在本章中,我们假设你已熟悉该函数;如果不熟悉也无需过度担心,你只需知道:向该函数传入需要评估的 3D 空间点位置,它就会返回该点处 \[-1,1] 范围内的噪声值。以下是参考代码(完整实现可在源代码部分提供的文件中获取):
```cpp
int p[512]; // 置换表(见源代码)
double fade(double t) { return t * t * t * (t * (t * 6 - 15) + 10); }
double lerp(double t, double a, double b) { return a + t * (b - a); }
double grad(int hash, double x, double y, double z){
int h = hash & 15;
double u = h<8 ? x : y,
v = h<4 ? y : h==12||h==14 ? x : z;
return ((h&1) == 0 ? u : -u) + ((h&2) == 0 ? v : -v);
}
double noise(double x, double y, double z){
int X = (int)floor(x) & 255,
Y = (int)floor(y) & 255,
Z = (int)floor(z) & 255;
x -= floor(x);
y -= floor(y);
z -= floor(z);
double u = fade(x),
v = fade(y),
w = fade(z);
int A = p[X ]+Y, AA = p[A]+Z, AB = p[A+1]+Z,
B = p[X+1]+Y, BA = p[B]+Z, BB = p[B+1]+Z;
return lerp(w, lerp(v, lerp(u, grad(p[AA ], x , y , z ),
grad(p[BA ], x-1, y , z )),
lerp(u, grad(p[AB ], x , y-1, z ),
grad(p[BB ], x-1, y-1, z ))),
lerp(v, lerp(u, grad(p[AA+1], x , y , z-1 ),
grad(p[BA+1], x-1, y , z-1 )),
lerp(u, grad(p[AB+1], x , y-1, z-1 ),
grad(p[BB+1], x-1, y-1, z-1 ))));
}
```
最后,以下是该函数在光线步进程序中的使用方式:
```cpp
Color integrate(const Ray& ray, ...){
float sigma_a = 0.1;
float sigma_s = 0.1;
float sigma_t = sigma_a + sigma_s;
...
float transmission = 1; // 初始为完全透明
for (size_t n = 0; n < numSteps; ++n) {
float t = tMin + stepSize * (n + 0.5);
Point p = ray.orig + ray.dir * t;
// 密度不再是恒定值,而是随空间变化
float density = (1 + noise(p)) / 2;
float sampleAtt = exp(-density * sigma_t * stepSize);
// 全局透射率被采样点不透明度衰减
transmission *= samplAtt;
...
}
}
```
![](images/2026/02/08/20260208_131605_541.webp)
*图 2密度沿光线方向变化。*
![](images/2026/02/08/20260208_131605_799.webp)
*图 3密度场由噪声函数生成的非均匀体积渲染结果。*
与上一章中渲染均匀体积物体的程序相比,你可以看到此处的修改相当简单:我们将密度变量的声明移到了光线步进循环内部,使其不再是恒定值,而是随空间变化的参数。图 2 直观展示了这一过程:我们沿光线步进时对密度场进行采样(密度场仍由噪声函数生成),对于光线上的每个采样点,都以该采样点的位置作为输入参数评估噪声函数,并将结果作为该点的密度值。图 3 展示了将该方法应用于体积球体的渲染结果。
为了确保你理解这一过程,我们绘制了噪声函数在某段距离上的取值,以及基于该噪声函数计算的透射率值;同时,我们还绘制了恒定密度体积(绿色曲线)与非均匀密度体积(红色曲线)的比尔-朗伯定律曲线对比。如图所示,绿色曲线(恒定密度)完全平滑,而红色曲线(非均匀密度)则不然。需要注意的是:当噪声函数返回值接近 0 时,透射率基本保持恒定;当噪声函数返回值较高时,透射率急剧下降(体积密度更大)。这些都是预期结果,但直观展示有助于理解。
```cpp
float stepSize = 1. / 51.2;
float sigma_t = 0.9;
float t = 0;
float Thomogeneous = 1;
float Theterogeneous = 1;
for (int x = 0; x < 512; x++, t += stepSize) {
float noiseVal = powf((1 + noise(t, 0.625, 0)) / 2.f, 2);
float samplAttHeterogeneous = exp(-noiseVal * stepSize * sigma_t);
Theterogeneous *= samplAttHeterogeneous;
float sampleAttHomogeneous = exp(-0.5 * stepSize * sigma_t);
Thomogeneous *= sampleAttHomogeneous;
fprintf(stderr, "%f %f %f\n", t, Theterogeneous, Thomogeneous);
}
```
![](images/2026/02/08/20260208_131606_084.webp)
但存在一个问题:这段代码可以正确计算体积的透射率值,但我们之前用于计算内散射贡献(还记得 Li 项吗?)的代码却无法直接使用。接下来,我们将解释原因,以及需要进行哪些修改才能使其适用于非均匀参与介质。
## 非均匀参与介质的内散射
![](images/2026/02/08/20260208_131606_354.webp)
*图 4光线穿过非均匀参与介质的示意图。*
通过观察图 4你应该能理解问题所在。对于均匀体积物体计算光线贡献时只需找到采样点到物体边界沿光线方向的距离记为 Dl然后结合该距离和体积消光系数sigma\_t = sigma\_a + sigma\_s应用比尔定律即可得到光线穿过体积到达采样点后的剩余光量透射光量。这很简单
```cpp
Color lighRayContrib = exp(-Dl * sigma_t * density) * lightColor;
```
非均匀体积的问题在于,这种方法不再适用——因为密度沿光线方向也会变化(如图 4 所示)。需要注意的是:我们此处要解决的问题,与第 2 章中使用正向光线步进解决的问题并不相同。到目前为止,我们使用光线步进的目的是估计相机光线上的内散射项;而此处,光线步进技术将再次发挥作用,用于估计内散射项以及相机光线和光线在非均匀参与介质中的 **透射率**。尽管问题不同(**估计内散射项 vs 估计光线透射率**),但所使用的技术相同(此处特指正向光线步进,一种随机采样方法)。我们需要 **将光线划分为一系列小线段,假设每个线段长度(由步长定义的小体积元)内的密度是均匀的(微元思想)**,然后沿光线推进时,**将全局透射率值与每个采样点的透射率相乘**。伪代码实现如下:
```cpp
// 计算非均匀介质中的光线透射率
float transmission = 1;
float stepSize = Dl / numSteps;
for (n = 0; n < numSteps; ++n) {
float t = stepSize * (n + 0.5);
float sampleAtt = exp(-evalDensity(t) * stepSize * sigma_t);
transmission *= samplAtt;
}
```
将这段代码与上一章中沿光线从 t0 到 t1 推进时计算相机光线透射率的代码进行对比:
```cpp
float sigma_t = sigma_a + sigma_s;
float density = 0.1; // 密度恒定用于缩放sigma_t
float transparency = 1; // 初始化透明度为1
for (int n = 0; n < ns; ++n) {
float t = isect.t1 - step_size * (n + 0.5);
vec3 sample_pos= ray_orig + t * ray_dir; // 采样点位置(步长中点)
// 使用比尔定律计算采样点透明度
float sample_transparency = exp(-step_size * sigma_t * density);
// 用采样点透明度衰减全局透明度
transparency *= sample_transparency;
// 内散射计算
if (hitObject->intersect(sample_pos, light_dir, isect_vol) && isect_vol.inside) {
...
result += ...
}
// 最终用采样点透明度衰减结果
result *= sample_transparency;
}
```
这两段代码的核心逻辑相同。我们可以利用一个数学技巧(现在正是使用它的好时机):
$$
e^a * e^b = e^{a + b}
$$
观察代码可知,透射率值本质上是一系列指数函数的乘积。如果展开光线步进循环(代码片段 2会得到如下形式
```cpp
// dx = 步长noise(x)的取值范围为[0,1]
float t0 = dx * (0.5); // n = 0
float t1 = dx * (1 + 0.5); // n = 1
float t2 = dx * (2 + 0.5); // n = 2
...
float transmission = exp(-dx * sigma_t * noise(t0)) *
exp(-dx * sigma_t * noise(t1)) *
exp(-dx * sigma_t * noise(t2)) *
...;
```
利用我们刚刚学到的指数函数数学性质,可将代码重写为:
```cpp
float tau = noise(t0) + noise(t1) + noise(t2) + ...;
float transmission = exp(-tau * sigma_t * dx);
```
换句话说,沿光线推进时(与沿相机光线推进完全相同),我们只需累加光线上每个采样点的密度值,然后利用该总和通过一次指数函数调用计算光线的衰减/透射率值(这确实能节省一些时间)。下图直观展示了这一概念。
> 从技术上讲,我们也可以对相机光线的透射率进行同样的优化,但请注意,在以下代码中,我们在沿相机光线推进时使用透射率值来衰减 Li 项。由于需要体积推进过程中的透射率中间值,因此我们不能像计算光线透射率那样,将密度值累加后在最后统一计算最终的光线透射率值。
```cpp
float transmission = 1; // set the camera ray transmission value (full transmission)
vec3 result = 0; // the camera ray radiance (light energy traveling from the volume to the eye)
for (n = 0; n < numSteps; ++n) {
float t = t0 + stepSize * (n + 0.5);
vec3 samplePos = ray.orig + t * ray.dir;
float sampleDensity = evalDensity(samplePos);
// we need this intermediate result to attenuate the Li term (see below)
transmission *= exp(-sampleDensity * sigma_t * stepSize);
// inscattering (Li(x))
if (density > 0 && hit_object->intersect(...) {
float tau = 0;
for (nl = 0; nl < numStepsLight; ++nl) {
float tLight = stepSize * (nl + 0.5);
vec samplePosLight = samplePos + tLight * lightDirection;
tau += evalDensity(samplePosLight);
}
// calculate light ray transmission value at the very end
float lightRayAtt = exp(-tau * sigma_t * stepSize);
result += lightColor * lightRayAtt * sigma_s * ... * transmission;
}
}
```
![](images/2026/02/08/20260208_131606_602.webp)
“tau” 这个名称并非随意选择——在学术文献中它常被用来表示“光学深度”optical depth这一物理量。有两个希腊字母常被用于表示该物理量tau$\tau$)或 rho$\rho$)。本章不会给出光学深度的正式定义(以免造成混淆),相关定义将在《体积渲染:总结、方程/理论》章节中详细说明。
就是这样!现在你已经掌握了渲染非均匀体积物体准确图像所需的全部知识。最后这幅图展示了特定噪声分布对应的透射率曲线。
![](images/2026/02/08/20260208_131606_846.webp)
## 实用(且可运行)的实现
让我们对程序进行必要的调整,以演示该方法的实际应用。请记住,算法现在的工作流程如下:
* **沿相机光线步进**:在相机光线上的每个采样点,估计该点的密度以计算采样点透射率,并估计内散射贡献。
* **沿光线步进**:此前我们只需沿相机光线步进,而现在还需要沿光线步进。对于均匀体积物体,我们直接沿相机光线步进即可估计内散射项;对于非均匀体积物体,沿相机光线步进的目的仍为估计内散射项(这一点没有变化),但还需要评估沿相机光线的密度项;同时,我们还需要沿光线步进,以评估这些光线上的密度函数。
换句话说,我们现在需要同时沿相机光线和光线步进,并在这些光线上的每个采样点评估密度函数(当前为柏林噪声函数)。这涉及大量运算,你会发现:将球体渲染为非均匀介质的耗时,远高于其均匀介质版本。
```cpp
// [注释]
// 该函数由integrate函数调用用于评估非均匀体积球体在采样点p处的密度
// 返回该3D位置处佩林噪声函数的值已重映射到[0,1]范围)
// [/注释]
float eval_density(const vec3& p){
float freq = 1;
return (1 + noise(p.x * freq, p.y * freq, p.z * freq)) * 0.5;
}
vec3 integrate(
const vec3& ray_orig,
const vec3& ray_dir,
const std::vector<std::unique_ptr<Sphere>>& spheres){
...
const float step_size = 0.1;
float sigma_a = 0.5; // 吸收系数
float sigma_s = 0.5; // 散射系数
float sigma_t = sigma_a + sigma_s; // 消光系数
float g = 0; // 亨耶-格林斯坦非对称因子
uint8_t d = 2; // 俄罗斯轮盘赌“概率”参数
int ns = std::ceil((isect.t1 - isect.t0) / step_size);
float stride = (isect.t1 - isect.t0) / ns;
vec3 light_dir{ -0.315798, 0.719361, 0.618702 };
vec3 light_color{ 20, 20, 20 };
float transparency = 1; // 初始化透射率为1完全透明
vec3 result{ 0 }; // 初始化体积球体颜色为0
// 主光线步进循环正向从t0推进到t1
for (int n = 0; n < ns; ++n) {
// 采样点位置抖动
float t = isect.t0 + stride * (n + distribution(generator));
vec3 sample_pos = ray_orig + t * ray_dir;
// [注释]
// 评估采样点位置的密度(空间变化的密度)
// [/注释]
float density = eval_density(sample_pos);
float sample_attenuation = exp(-step_size * density * sigma_t);
transparency *= sample_attenuation;
// 内散射计算
IsectData isect_light_ray;
if (density > 0 &&
hit_sphere->intersect(sample_pos, light_dir, isect_light_ray) &&
isect_light_ray.inside) {
size_t num_steps_light = std::ceil(isect_light_ray.t1 / step_size);
float stide_light = isect_light_ray.t1 / num_steps_light;
float tau = 0;
// [注释]
// 沿光线步进将密度值累加到tau变量中
// [/注释]
for (size_t nl = 0; nl < num_steps_light; ++nl) {
float t_light = stide_light * (nl + 0.5);
vec3 light_sample_pos = sample_pos + light_dir * t_light;
tau += eval_density(light_sample_pos);
}
float light_ray_att = exp(-tau * stide_light [... 655 chars omitted ...]
```
在该实现中,相机光线和光线使用的步长相同——但这并非必须。为了提高速度,你可以使用更大的步长来估计光线的透射率值。此外,由于我们使用了程序化纹理,可能会遇到滤波问题:如果采样噪声函数的频率过低,可能会丢失程序化纹理的细节,最终导致走样问题。同样,这是一个滤波问题,我们此处不深入探讨,但需要注意:步长、噪声频率和图像分辨率在采样层面是相互关联的。
你可能会感到惊讶:程序的输出结果(右图)看起来并不太像云层。然而,从噪声图案图像(左图)可以看出,默认情况下噪声图案的“块状结构”相当平滑,因此体积的外观也较为平滑。要生成类似云层的程序化噪声,需要通过多种方式调整噪声函数的结果,以获得视觉上更有趣的效果(如下文所示)。
以下是一些你可以立即尝试的简单变体去除噪声函数的负值左图以及取噪声函数的绝对值右图。“衰减参数”falloff parameter的说明见下文。
尽管效果不够惊艳,但正是这项技术被用于制作 1997 年电影《超时空接触》Contact开场片段中的壮观体积特效。这些图像由索尼图形图像运作公司Sony Picture Imageworks使用皮克斯Pixar的渲染器渲染生成。在当时这个片段是一项巨大的技术挑战。如前所述他们使用的技术与本章介绍的方法相同——不同之处在于他们使用了一些几何图形而非基础球体来定义体积物体的形状并使用分形图案赋予星云类似云层的纹理。我们将在本章的最后一节生产级体积渲染中触及前一种技术至于类似云层的纹理让我们看看可以如何实现……
## 调整密度函数(创建更有趣的外观和动画)
编写光线步进器是一回事,创建类似云层的程序化纹理则是另一回事:前者是科学,后者更偏向艺术——利用一系列数学工具塑造程序化纹理,并花费大量时间调整参数,直到获得满意的结果。我们的目标并非创建令人惊叹的逼真图像,而是为你提供工具和基础:首先帮助你理解原理,其次让你能够将这些工具重组为更复杂的系统(如果你愿意的话),从而制作出“惊艳”的图像。不过,这部分工作就交给你了……如果你创作了有趣的作品,记得和我们分享!
以下是一些可用于创建更类似云层的“球体”的技巧——这只是众多工具中的一小部分。
### 平滑步进函数Smoothstep
平滑步进函数是你已经熟悉的函数,我们已在多个场景中使用过它(包括噪声函数)。它能在两个值之间创建“平滑”的过渡。以下是该函数的一种可能实现:
```cpp
float smoothstep(float lo, float hi, float x){
float t = std::clamp((x - lo) / (hi - lo), 0.f, 1.f);
return t * t * (3.0 - (2.0 * t));
}
```
我们可以使用该函数在球体边界附近创建衰减效果。为此,需要修改 `eval_density` 函数,向其传入球体中心和半径作为参数。通过这种方式,我们可以计算采样点到球体中心的归一化距离,并利用该值调整球体的密度,如下所示:
```cpp
float eval_density(const vec3& sample_pos, const vec3& sphere_center, const float& sphere_radius){
vec3 vp = sample_pos - sphere_center;
float dist = std::min(1.f, vp.length() / sphere_radius);
float falloff = smoothstep(0.8, 1, dist); // 当距离从0.8到1时平滑过渡从0到1
return (1 - falloff);
}
```
这项技术适用于在体积到达球体边界前使其渐隐。我们使用平滑步进函数控制衰减开始的位置(距离边缘的距离)——对于衰减效果,过渡的终点应设为 1。
### 分形布朗运动fBm
fBm全称 Fractal Brownian Motion在早期也被称为“等离子体纹理”或“混沌纹理”是一种分形图案由多个程序化噪声层叠加而成不同层的频率和振幅各不相同。你可以在“程序化生成”章节的课程中找到关于该图案的更多信息。
典型的 fBm 程序化纹理代码可按如下方式构建:
```cpp
float eval_density(const vec3& p, ...){
vec3 vp = p - sphere_center;
...
// 构建fBm分形图案
float frequency = 1;
vp *= frequency; // 必要时缩放初始点坐标
size_t numOctaves = 5; // 层数
float lacunarity = 2.f; // 连续频率之间的间隔
float H = 0.4; // 分形增量参数
float value = 0; // fBm的结果用于密度计算
for (size_t i = 0; i < numOctaves; ++i) {
value += noise(vp) * powf(lacunarity, -H * i);
vp *= lacunarity;
}
// 裁剪负值
return std::max(0.f, value) * (1 - falloff);
}
```
构建 fBm 图案只需两行核心代码(循环叠加层)。基于这个基础版本,可以衍生出许多变体——例如取噪声函数的绝对值(一种称为“湍流”的图案)等。如果你想了解更多关于该主题的内容(以及如何正确滤波该图案),请查看“程序化生成”章节。
### 偏移Bias
偏移用于改变中点0.5)的位置,同时保持 0 和 1 的值不变。
```cpp
float eval_density(...) {
float bias = 0.2;
float exponent = (bias - 1.0) / (-bias - 1.0);
// 假设指数大于0
return powf(noisePattern, exponent);
}
```
### 利用图元局部坐标系旋转噪声图案
在早期(随着计算能力的提升,流体模拟成为主流之前),另一种常用技术是在体积图元(球体、立方体等)内部动画化噪声图案。我们传入密度函数的采样点位置是在世界空间中定义的,但我们也可以在依附于球体的坐标系中定义该点位置——换句话说,将采样点从世界空间转换到物体空间。要在球体坐标系中定义采样点,只需执行以下操作:
```cpp
vec3 sample_object_space = sample_pos - center;
```
更通用的解决方案是使用矩阵变换球体图元:通过物体到世界矩阵的逆矩阵,将采样点从世界空间转换到球体的局部坐标系。不过,为了简化示例程序,我们并未使用矩阵——但你可以利用 Scratchapixel 上提供的信息轻松实现这一点。
这项技术的实用价值在于:当我们移动球体时,在球体局部坐标系中定义的点坐标不会改变。这可以确保噪声图案始终“附着”在球体上,无论球体如何变换——类似你在手指间滚动的玻璃弹珠上的纹理。在我们选择的示例中(尽管与我们刚才解释的略有不同,但概念和结果一致):我们将在物体空间中围绕球体的 y 轴旋转采样点(更好的解决方案是使用物体到世界矩阵旋转球体,然后通过世界到物体矩阵将采样点从世界空间转换到物体空间,但我们在此处为了偷懒,直接在物体空间中旋转点)。这样,我们可以看到分形图案也随之旋转,有点像观察旋转的玻璃弹珠。通过这种方法,还可以应用更复杂的(线性或非线性)变形。
```cpp
float eval_density(const vec3& p, const vec3& center, const float& radius){
// 将点从世界空间转换到物体空间
vec3 vp = p - center;
vec3 vp_xform;
// 在物体空间中旋转采样点frame是全局变量范围从1到120
float theta = (frame - 1) / 120.f * 2 * M_PI;
vp_xform.x = cos(theta) * vp.x + sin(theta) * vp.z;
vp_xform.y = vp.y;
vp_xform.z = -sin(theta) * vp.x + cos(theta) * vp.z;
float dist = std::min(1.f, vp.length() / radius);
float falloff = smoothstep(0.8, 1, dist);
float freq = 0.5;
size_t octaves = 5;
float lacunarity = 2;
float H = 0.4;
vp_xform *= freq;
float fbmResult = 0;
float offset = 0.75;
for (size_t k = 0; k < octaves; k++) {
fbmResult += noise(vp_xform.x , vp_xform.y, vp_xform.z) * pow(lacunarity, -H * k);
vp_xform *= lacunarity;
}
return std::max(0.f, fbmResult) * (1 - falloff);
}
```
注意:现在我们可以清晰地看到分形图案的 3D 结构。
![](images/2026/02/08/20260208_131607_161.webp)
### 还有更多技巧……
“程序化”技巧的列表可以不断延伸:位移(使用噪声函数位移体积的边缘)、不同类型的噪声(波状噪声、时空噪声——一种可动画的噪声等)。我们将在本课程的未来修订版中扩展这个列表。
## 我们学到了什么?
关于本课程所学技术的总结,见最后一章。
到目前为止,你已经可以使用光线步进渲染单散射非均匀体积物体——这已经是一项重大成就。此外,你现在应该清楚:光线步进技术的核心是将体积物体分解为步长定义的小体积元,该技术之所以有效,是因为我们假设这些微小的体积元(或采样点)足够小,可以视为均匀的。简而言之,你将非均匀物体分解为多个“小砖块”,每个砖块都可视为“均匀的”——尽管不同砖块的属性可能不同,有点像用乐高积木搭建物体。
# 二、基于 3D 体素网格的体渲染详解
学完本章后,你将能够生成以下序列图像:
![](images/2026/02/08/20260208_131607_482.webp)
如前一章所述,创建密度场有两种技术:过程式生成或使用流体模拟软件。本章我们将聚焦后者。请注意,本课程不会讲解流体模拟的工作原理,而是学习如何渲染流体模拟生成的数据——流体模拟的相关内容我们会在后续课程中介绍,敬请期待。
## 步骤 1使用 3D 网格存储密度值
模拟流体(烟雾、水等)的技术有很多,但通常在模拟过程中的某个阶段,结果会被存储在 3D 网格中。为简化教学,本章假设这些网格在三个维度上的分辨率相同(例如 x、y、z 坐标均为 32×32×32且分辨率为 2 的幂8、16、32、64、128 等——这仅为教学方便实际应用中无需严格遵循。此外本章暂不涉及网格变换因此假设网格是轴对齐的立方体我们可将网格视为轴对齐包围盒AABB这会简化光线-立方体相交测试。若要支持非轴对齐网格,只需按照本课程讲解的方法,通过网格的世界空间到对象空间的变换矩阵,将相机光线转换到网格的对象空间即可。
![](images/2026/02/08/20260208_131607_741.webp)
*图 1存储密度值的 8×8×8 网格*
网格非常适合模拟流体运动:网格的体素(构成网格的小体积元素,也可称为“单元格”)会被赋予初始密度(可理解为填充烟雾),随着时间推移,密度会在相邻体素间传播。密度在体素间的移动遵循 **纳维-斯托克斯方程Navier-Stokes equation**——这一主题同样留到后续课程讲解。本章你只需了解:流体模拟的结果存储在由体素组成的 3D 网格中每个体素存储一个密度值0 或大于 0 的数值),代表“填充”该体素体积的密度。**3D 网格之于流体模拟,就如同位图之于图像,密度场被量化处理。** 在任何编程语言中,定义存储密度值的 3D 网格都相对简单,但本章末尾会探讨一些需要注意的细节。
除了网格分辨率(每个维度的体素数量,如 32×32×32我们还需定义网格在世界空间中的尺寸即场景中对象的大小。实现方式有多种为方便起见我们通过定义网格在世界空间中的最小边界和最大边界或范围来确定例如 (-2,-2,-2) 和 (2,2,2)。需注意,由于目前我们的网格是立方体,边界值不必相对于世界原点对称,但三个维度的世界空间尺寸必须相同:例如 (0.2,0.2,0.2) 与 (10.2,10.2,10.2) 是合法的,而 (-1.2,2.2,3.2) 与 (8.2,4.2,7.2) 则不合法。这种定义方式的优势在于,最小和最大边界可直接代入光线-立方体相交检测程序(我们在《极简光线追踪器:渲染简单图形(球体、立方体、圆盘、平面等)》课程中已学习过)。因此,我们已经掌握了计算任何与立方体相交的光线的 t0 和 t1 值的方法(与球体基元的计算方式类似)。
你可以修改前几章的代码,将渲染球体改为渲染立方体,同时使用矩阵从更有趣的视角渲染场景。最终应得到类似以下的代码:
```cpp
struct Grid {
float *density;
size_t resolution = 128; // 分辨率
Point bounds[2] = { (-30,-30,-30), (30, 30, 30) }; // 世界空间边界
};
// 光线-立方体相交检测
bool rayBoxIntersect(const Ray& ray, const Point bounds[2], float& t0, float& t1){
...
return true;
}
// 积分计算(光线步进核心)
void integrate(const Ray& ray, const Grid& grid, Color& radiance, Color& transmission){
Vector lightDir(-0.315798, 0.719361, 0.618702); // 光源方向
Color lightColor(20); // 光源颜色
float t0, t1;
if (rayBoxIntersect(ray, grid.bounds, t0, t1)) { // 光线与网格包围盒相交
// 从t0到t1执行光线步进
radiance = ...; // 辐射亮度
transmission = ...; // 透射率
}
}
// 相机到世界空间的变换矩阵
Matrix cameraToWorld{ 0.844328, 0, -0.535827, 0, -0.170907, 0.947768, -0.269306, 0, 0.50784, 0.318959, 0.800227, 0, 83.292171, 45.137326, 126.430772, 1 };
int main(){
Grid grid;
// 分配内存以读取缓存文件中的数据
size_t numVoxels = grid.resolution * grid.resolution * grid.resolution; // 体素总数
grid.density = new float[numVoxels]; // 也可使用unique_ptr管理内存示例程序采用此方式
std::ifstream cacheFile;
cacheFile.open("./cache.0100.bin", std::ios::binary); // 打开二进制缓存文件
// 读取密度值到内存
cacheFile.read((char*)grid.density, sizeof(float) * numVoxels);
Point origin(0); // 光线原点
for () {
Vector rayDir = ...; // 计算光线方向
Ray ray;
ray.orig = origin * cameraToWorld; // 转换原点到世界空间
ray.dir = rayDir * cameraToWorld; // 转换方向到世界空间
Color radiance = 0, transmission = 1;
integrate(ray, grid, radiance, transmission); // 执行光线步进积分
pixelColor = radiance; // 像素颜色 = 辐射亮度
pixelOpacity = 1 - transmission; // 像素不透明度 = 1 - 透射率
}
// 释放内存
delete[] grid.density;
}
```
目前无需过多关注缓存文件的内容(后续会详细说明)。从上述代码可以看出,核心操作是:创建连续的内存块存储与体素数量相等的浮点型密度值,然后将磁盘文件中的数据加载到内存中(第 36 行),逻辑十分简洁。
**预期效果**
![](images/2026/02/08/20260208_131607_977.webp)
这是你应该得到的图像。现在我们已经明确:将使用 3D 网格存储密度场数据,网格具有固定分辨率和尺寸,且已掌握光线与网格包围盒的相交点计算方法。接下来,我们将学习如何在光线步进过程中读取网格的密度值。
## 步骤 2在网格中执行光线步进
如《光线追踪加速结构》课程所述,使用网格作为加速结构较为复杂:需要找到光线相交的所有网格单元格,再判断每个单元格中的几何体(三角形)是否与光线相交。虽然不算特别复杂,但也绝非易事(尤其是追求高效时)。不过好消息是,体渲染中的光线步进完全不需要这一过程!
![](images/2026/02/08/20260208_131608_252.webp)
光线步进的核心是关注光线上的采样点(如下图所示)。由于我们知道这些采样点的位置,只需确定它们所在的网格体素,然后读取该体素的密度值即可——无需遍历网格,极其简单。接下来,我们将学习如何从采样点映射到体素坐标,并通过坐标从内存中读取密度值。
## 步骤 3光线步进过程中读取密度值
与前几章的代码相比,我们只需修改 `evalDensity` 函数:不再通过过程式噪声函数计算空间中任意点的密度,而是从网格中读取对应位置的密度值——这是一个极小的改动,其余逻辑完全不变。
通常情况下,采样点必然位于网格边界内(因为采样范围在光线与立方体的相交点之间),因此无需额外判断。但为了代码健壮性,建议添加采样点坐标与网格边界的校验,避免内存访问越界。
从网格中读取密度值的操作通常称为“查找lookup”。在 OpenVDB一款高效存储和读取体数据的库这一操作被称为“**探测probing**”(较为少见)。具体实现步骤如下:
![](images/2026/02/08/20260208_131608_486.webp)
**采样点到体素坐标的转换流程**
采样点最初定义在世界空间中,需将其转换为网格的离散坐标,步骤如下:
* **世界空间到对象空间转换**:将采样点坐标减去网格的最小边界值,得到相对于网格“左下角”的坐标。
* **归一化**:将对象空间坐标除以网格尺寸(示例中为 10得到归一化坐标范围在 \[0,1] 之间)。
* **体素空间映射**:将归一化坐标乘以网格分辨率,得到体素空间坐标(仍为浮点型)。为了读取内存中的密度值,需将浮点型坐标四舍五入为整数(即体素索引)。需注意:体素空间坐标的最大值不能超过“分辨率-1”避免归一化坐标恰好为 1 时索引越界)。完成以上步骤后,即可读取对应位置的密度值。
**2D 示例说明**
如上图的 2D 示例所示,采样点的体素空间坐标为 (3.36, 3.28)。此时该体素在内存中的索引计算方式为y 坐标的整数部分3乘以网格分辨率8再加上 x 坐标的整数部分3即 3×8+3=27。
**代码实现(最近邻插值)**
```cpp
float evalDensity(const Grid* grid, const Point& p){
Vector gridSize = grid->bounds[1] - grid->bounds[0]; // 计算网格尺寸
Vector pLocal = (p - grid->bounds[0]) / gridSize; // 世界空间 → 对象空间(归一化前)
Vector pVoxel = pLocal * grid->resolution; // 归一化 → 体素空间
// 取浮点坐标的整数部分使用floor避免负索引
int xi = static_cast<int>(std::floor(pVoxel.x));
int yi = static_cast<int>(std::floor(pVoxel.y));
int zi = static_cast<int>(std::floor(pVoxel.z));
// 最近邻插值:直接读取对应体素的密度值
return grid->density[(zi * grid->resolution + yi) * grid->resolution + xi];
}
```
**关键说明**
与 Perlin 噪声中将点转换到晶格空间的逻辑类似,此处使用 `std::floor` 函数而非直接强制类型转换,是为了避免归一化坐标略小于 0 时(后续会说明原因),索引变为 -1 导致内存访问错误—— `std::floor` 会确保负坐标的索引为 0从而返回 0 密度值。
这种方法称为“最近邻插值nearest-neighbor interpolation本质是直接获取采样点所在体素的密度值并未进行插值计算。后续我们会学习其他插值方法但所有方法的第一步都是计算采样点所在的体素坐标。
现在,你已经具备了渲染流体模拟结果的全部条件(源代码区提供了多个流体缓存文件,可与示例程序配合使用)。以下是最近邻插值的渲染效果:
![](images/2026/02/08/20260208_131608_717.webp)
**缓存文件格式说明**
为了避免依赖外部库,我们没有使用 OpenVDB或其前身 Field3D等工业级格式存储网格数据而是将流体模拟结果以最简洁的二进制格式存储代码如下
```cpp
// 二进制缓存文件的写入逻辑
for (size_t z = 0; z < grid.res; ++z) {
for (size_t y = 0; y < grid.res; ++y) {
for (size_t x = 0; x < grid.res; ++x) {
float density = grid.density[x][y][z];
cacheFile.write((char*)&density, sizeof(float));
}
}
}
```
OpenVDB 的核心原理与此类似,但支持压缩、多分辨率和稀疏体(本章末尾会简要介绍)。我们的方案优势在于简洁直观,非常适合教学场景。
![](images/2026/02/08/20260208_131608_935.webp)
需特别注意:上述代码中,体素沿 z 轴“从后到前”存储(切片方式)。在右手坐标系中,网格的最小边界位于“后方左下角”(下图中的品红色体素),体素空间中第一个体素的坐标为 (0,0,0);在 8×8×8 的网格示例中最后一个体素z 轴正方向前方右上角)的坐标为 (7,7,7)(下图中的黄色体素),索引为 8×8×8-1=5110-based 索引)。
**体素索引计算**
若要通过体素空间坐标xi, yi, zi计算其在内存数组中的索引公式如下
```cpp
size_t index = (zi * gridResolution + yi) * gridResolution + xi;
```
这是最基础的实现方式,但图像质量可通过“三线性插值”进一步提升。
## 步骤 4通过三线性插值优化结果
其核心思路如下:一个体素代表单个密度值,但采样点可能位于任意给定体素体积内的任意位置。因此,合理的假设是该位置的密度值应在某种程度上混合采样点所在体素的密度,以及与之直接相邻的体素的密度值(如下图所示)。
![](images/2026/02/08/20260208_131609_154.webp)
对于三线性插值,我们只需混合 8 个体素的值。具体该如何混合?方法很简单:计算采样点到这 8 个体素中每一个体素的距离,并利用这些距离来加权每个体素对最终结果的贡献度。
> 这个过程本质上是三维空间中的线性插值。我们用于插值两个值的公式为 $a * (1 - weight) + b * weight$,其中 $weight$ 的取值范围为 0 到 1。在二维空间中该过程称为双线性插值需要 4 个像素;在三维空间中,我们称之为三线性插值,需要 8 个体素。请注意,用于在 a 和 b 之间插值的公式被称为插值函数interpolant。在线性插值的情况下插值函数是一阶多项式。
我们假设存储在某个体素中的密度值,是当采样点恰好位于该体素正中心时应取的值。由于三线性插值方案的特性,要得到这一结果,我们需要将体素空间中的采样位置偏移 -0.5。我们先看代码,再解释其工作原理。
```cpp
float evalDensity(const Grid* grid, const Point& p){
Vector gridSize = grid->bounds[1] - grid->bounds[0];
Vector pLocal = (p - grid->bounds[0]) / gridSize;
Vector pVoxel = pLocal * grid->baseResolution;
Vector pLattice(pVoxel.x - 0.5, pVoxel.y - 0.5, pVoxel.z - 0.5); // 红色高亮行
int xi = static_cast<int>(std::floor(pLattice.x));
int yi = static_cast<int>(std::floor(pLattice.y));
int zi = static_cast<int>(std::floor(pLattice.z));
float weight[3];
float value = 0;
// 三线性插值
for (int i = 0; i < 2; ++i) {
weight[0] = 1 - std::abs(pLattice.x - (xi + i));
for (int j = 0; j < 2; ++j) {
weight[1] = 1 - std::abs(pLattice.y - (yi + j));
for (int k = 0; k < 2; ++k) {
weight[2] = 1 - std::abs(pLattice.z - (zi + k));
value += weight[0] * weight[1] * weight[2] * grid->operator()(xi + i, yi + j, zi + k);
}
}
}
return value;
}
```
接下来我们分析这段代码的工作方式及背后的原因。
![](images/2026/02/08/20260208_131609_471.webp)
在上图中,展示了两种插值技术。左上角是最近邻插值法的示例,原理非常简单:我们有 4 个体素(二维场景下,三维则为 8 个),采样点(蓝色圆点)落在其中一个体素内,因此采样点直接采用该体素中存储的密度值。
三线性插值则稍复杂一些。你可能会认同:如果采样点恰好位于四个体素的交叉中心,那么我们应该返回这四个体素中存储的密度值的平均值。也就是说,在这个特定示例中,体素密度值的总和需除以 4
$result = (0.9 + 0.14 + 0.08 + 0.63) / 4$
但这仅适用于采样点恰好位于四个体素交叉点的特殊情况,因此我们需要一个通用的解决方案。
首先(稍后解释为何需要这么做),我们需要将采样点在体素空间中偏移半个体素的距离:(-0.5, -0.5, -0.5)。以采样点恰好位于四个体素交叉中心的示例来说,应用偏移后,采样点会落在左下角体素的正中心。接下来,我们计算该点到每个体素边界的距离。在我们的二维示例图中,这一距离表现为 dx0采样点 x 坐标到左下角体素 x 坐标 x0 的距离、dy0采样点 y 坐标到左下角体素 y 坐标 y0 的距离、dx1采样点 x 坐标到右上角体素 x 坐标 x1 的距离)和 dy1采样点 y 坐标到右上角体素 y 坐标 y1 的距离)。这类距离在技术上被称为曼哈顿距离。
> 曼哈顿距离:沿直角坐标轴测量的两点间距离。在平面中,若 p1 坐标为 (x1, y1)p2 坐标为 (x2, y2),则曼哈顿距离为 $|x1 - x2| + |y1 - y2|$。
实际应用中,我们只需用到 dx0、dy0 和 dz0 即可(如下文所示)。以下再次梳理三线性插值的核心思路(若想了解更完整的解释,可参考专门讲解插值的章节)。
我们有一个 2×2×2 的体素块,将其命名为 v000、v100向右移动 1 个体素、v010向上移动 1 个体素、v110向右 1 个、向上 1 个、v001向前移动 1 个体素、v101以此类推、v011 和 v111。插值的思路是先以 x0 为权重,对 v000-v100、v010-v110、v001-v101、v011-v111 进行线性插值,得到 4 个值;再以 y0 为权重,将这 4 个值两两线性插值,得到 2 个值;最后以 z0 为权重,对这 2 个值进行线性插值,得到最终结果。请注意,无论先以 x0、y0 还是 z0 开始前 4 次线性插值,只要后续每次插值都使用不同维度的权重,结果都不会受影响。记住,我们的线性插值函数为(原文公式缺失),其中(参数)为权重。用代码表示如下:
```cpp
float result =
(1 - z0) * ( // 蓝色部分
(1 - y0) * (v000 * (1 - x0) + v100 * x0) + // 绿色和红色部分
y0 * (v010 * (1 - x0) + v110 * x0) // 绿色和红色部分
) +
z0 * ( // 蓝色部分
(1 - y0) * (v001 * (1 - x0) + v101 * x0) + // 绿色和红色部分
y0 * (v011 * (1 - x0) + v111 * x0)); // 绿色和红色部分
```
希望这样的代码排版能帮助你理解三层嵌套的插值结构:包含 4 次(红色)+ 2 次(绿色)+ 1 次(蓝色)插值操作。若我们将 (1-x0)、(1-y0)、(1-z0)、x0、y0、z0 分别替换为 wx0、wy0、wz0、wx1、wy1、wz1展开并重新整理上述代码片段片段 2中的项可得到
```cpp
float result =
v000 * wx0 * wy0 * wz0 +
v100 * wx1 * wy0 * wz0 +
v010 * wx0 * wy1 * wz0 +
v110 * wx1 * wy1 * wz0 +
v001 * wx0 * wy0 * wz1 +
v101 * wx1 * wy0 * wz1 +
v011 * wx0 * wy1 * wz1 +
v111 * wx1 * wy1 * wz1;
```
这正是代码片段 1 中的逻辑(尽管片段 1 中权重是在嵌套循环中实时计算的)。若以二维示例图中 dx0 = dy0 = 0.5 的情况代入计算,可得:
```cpp
result =
0.9 * (1-dx0) * (1-dy0) + 0.14 * dx0 * (1-dy0) + 0.08 * (1-dx0) * dy0 + 0.63 * dx0 * dy0 =
0.9 * 0.25 + 0.14 * 0.25 + 0.08 * 0.25 + 0.63 * 0.25
```
这正是我们期望的结果。由此可见,三线性插值(二维场景下为双线性插值)是有效的。你现在也能理解为何需要将采样点在体素空间中偏移 -0.5——这是让数学计算成立的必要条件。为了验证这一点,不妨考虑采样点恰好位于体素正中心的情况(偏移前):应用偏移后,该点会落在体素的左下角(二维场景下),此时 dx0 = dy0=dz0=0而其他所有距离均为 1。代入计算可得
```cpp
float result = grid->density(voxelX, voxelY, voxelZ) * (1-0) * (1-0) * (1-0) +
0 + // 0 * (1-0) * (1-0)
0 + // (1-0) * 0 * (1-0)
0 + // 0 * 0 * (1-0)
0 + // (1-0) * (1-0) * 0
0 + // 0 * (1-0) * 0
0 + // (1-0) * 0 * 0
0; // 0 * 0 * 0
```
换句话说,该采样点返回的值就是该体素中存储的值(若偏移前采样点位于左下角体素正中心,在我们的示例中即为 0.9),这完全符合预期。
以上就是三线性插值的全部原理。接下来我们对比其与最近邻插值法的结果差异。
![](images/2026/02/08/20260208_131609_754.webp)
可以看到,**右侧使用三线性插值渲染的缓存图像更平滑**。但这种优化是有代价的:使用该方法时,渲染时间会显著增加(相较于最近邻插值法)。在未做优化的情况下,三线性插值的耗时约为最近邻查找的 5 倍。
## 现在你可以阅读其他开发者的代码了——以 OpenVDB 为例:
```cpp
template<class ValueT, class TreeT, size_t N> inline bool BoxSampler::probeValues(ValueT (&data)[N][N][N], const TreeT& inTree, Coord ijk) {
bool hasActiveValues = false;
hasActiveValues |= inTree.probeValue(ijk, data[0][0][0]); //i, j, k
ijk[2] += 1;
hasActiveValues |= inTree.probeValue(ijk, data[0][0][1]); //i, j, k + 1
ijk[1] += 1;
hasActiveValues |= inTree.probeValue(ijk, data[0][1][1]); //i, j+1, k + 1
ijk[2] -= 1;
hasActiveValues |= inTree.probeValue(ijk, data[0][1][0]); //i, j+1, k
ijk[0] += 1;
ijk[1] -= 1;
hasActiveValues |= inTree.probeValue(ijk, data[1][0][0]); //i+1, j, k
ijk[2] += 1;
hasActiveValues |= inTree.probeValue(ijk, data[1][0][1]); //i+1, j, k + 1
ijk[1] += 1;
hasActiveValues |= inTree.probeValue(ijk, data[1][1][1]); //i+1, j+1, k + 1
ijk[2] -= 1;
hasActiveValues |= inTree.probeValue(ijk, data[1][1][0]); //i+1, j+1, k
return hasActiveValues;
}
template<class ValueT, size_t N> inline ValueT BoxSampler::trilinearInterpolation(ValueT (&data)[N][N][N], const Vec3R& uvw) {
auto _interpolate = [](const ValueT& a, const ValueT& b, double weight)
{
const auto temp = (b - a) * weight;
return static_cast<ValueT>(a + ValueT(temp));
};
// 三线性插值:
// 使用周围8个晶格点的值构造最终结果。
// result(x,y,z) =
// v000 (1-x)(1-y)(1-z) + v001 (1-x)(1-y)z + v010 (1-x)y(1-z) + v011 (1-x)yz
// + v100 x(1-y)(1-z) + v101 x(1-y)z + v110 xy(1-z) + v111 xyz
return _interpolate(
_interpolate(
_interpolate(data[0][0][0], data[0][0][1], uvw[2]),
_interpolate(data[0][1][0], data[0][1][1], uvw[2]),
uvw[1]),
_interpolate(
_interpolate(data[1][0][0], data[1][0][1], uvw[2]),
_interpolate(data[1][1][0], data[1][1][1], uvw[2]),
uvw[1]),
uvw[0]);
}
template<class TreeT> inline bool [... 428 chars omitted ...]
```
sample() 方法首先通过 probeValues 方法获取 8 个体素中存储的值,随后在 trilinearInterpolation 方法中按上述逻辑进行插值。\_interpolate 函数本质上就是线性插值(在代码中更常被称为 lerp
## 体数据缓存的进阶内容
我们在此列出一系列值得参考的主题。为控制本章篇幅,暂不展开详述。这些主题的大部分内容将在本章后续修订版本中补充,或在独立章节中讲解。
### 其他插值方案:三次插值与滤波核
如前所述,线性插值函数是一阶多项式。我们也可使用更高阶的多项式以获得更平滑的结果,例如三次插值。在二维场景下,双三次插值需要采样 4×4 个像素;在三维场景下,则需要 $2^4 = 64$ 个体素。可想而知,结果会更平滑,但渲染时间也会相应增加。
你也可以使用滤波核类似于图像滤波中使用的三角核、Mitchell 核或高斯滤波核)。与图像处理类似,滤波核的范围越大,处理耗时越长。
这两种技术将在本章后续修订版本中完整讲解。
### 密度之外的附加数据存储
生产环境中使用的体数据缓存格式通常支持在缓存中存储密度之外的任意通道数据,例如 **温度**(用于渲染火焰)、**速度**(用于渲染三维运动模糊)等。
### 滤波与砖块映射Brick Map
若你为生产环境渲染体数据缓存,那么滤波和 LOD细节层次是需要重点关注的问题。体数据缓存渲染面临的问题与纹理渲染类似纹理有固定分辨率如 512、1024 像素等),若从极远的距离渲染贴有该纹理的物体,物体在图像中仅占几个像素;当物体或相机移动时,每帧渲染都会用到纹理中不同的像素/纹素导致纹理在帧与帧之间出现抖动轻则如此重则直接呈现噪点效果。为缓解这一问题我们会生成原始纹理的低分辨率版本并与原纹理数据一起存储这些低分辨率版本被称为多级纹理mipmap。当物体距离较远在图像中尺寸较小使用低分辨率纹理可消除这种噪点技术上称为走样/aliasing。我们暂未深入讲解 mipmap但很快会展开。
如果你已了解 mipmap 的概念、用途及生成方法,那么需要知道:二维纹理的滤波问题同样存在于三维缓存(或三维纹理)中。因此,我们可将 mipmap 方法适配到三维缓存中——为此需要生成原始网格数据的降采样版本,并根据缓存在图像中的尺寸选择合适的层级(降采样版本)。尽管这一过程与 mipmap 非常相似,且你也可将这些降采样版本称为 mipmap但在三维场景中我们通常称之为砖块映射brick map其形态类似《我的世界》Minecraft中的方块。
> 我们认为 “砖块映射brick map” 这一术语是由皮克斯 RenderMan 团队创造的,但我们对其起源并不十分确定…… 如果有人了解相关情况,欢迎来信告知我们。但总体而言,让缓存存储其数据的多分辨率版本这一理念并非特别新颖或罕见,几乎所有生产级渲染器和格式(如 OpenVDB都可能支持这一理念。
砖块映射的生成过程与纹理 mipmap 层级的生成过程非常相似,但我们需要对 8 个体素取平均值(而非对 4 个像素/纹素取平均)。因此,分辨率为 2 的整数次幂的网格会更便于处理,因为这种情况下的降采样操作会非常简单。
以下代码展示了如何从原始流体模拟缓存生成这些层级(示例中基础分辨率为 128。这只是一个简易示例后续我们会专门讲解该主题
```cpp
size_t baseResolution = 128;
size_t numLevels = log2(baseResolution); /* 浮点型到size_t的隐式转换 */
std::unique_ptr<Grid []> gridLod = std::make_unique<Grid []>(numLevels - 2); // 忽略分辨率为2和4的层级
// 加载0级数据
gridLod[0].baseResolution = baseResolution;
std::ifstream ifs;
char filename[256];
sprintf_s(filename, "./grid.%d.bin", frame);
ifs.open(filename, std::ios::binary);
gridLod[0].densityData = std::make_unique<float[]>(baseResolution * baseResolution * baseResolution);
ifs.read((char*)gridLod[0].densityData.get(), sizeof(float) * baseResolution * baseResolution * baseResolution);
ifs.close();
for (size_t n = 1; n < numLevels - 2; ++n) {
baseResolution /= 2;
gridLod[n].baseResolution = baseResolution;
gridLod[n].densityData = std::make_unique<float[]>(baseResolution * baseResolution * baseResolution);
for (size_t x = 0; x < baseResolution; ++x) {
for (size_t y = 0; y < baseResolution; ++y) {
for (size_t z = 0; z < baseResolution; ++z) {
gridLod[n](x, y, z) =
0.125 * (gridLod[n - 1](x * 2, y * 2, z * 2) +
gridLod[n - 1](x * 2 + 1, y * 2, z * 2) +
gridLod[n - 1](x * 2, y * 2 + 1, z * 2) +
gridLod[n - 1](x * 2 + 1, y * 2 + 1, z * 2) +
gridLod[n - 1](x * 2, y * 2, z * 2 + 1) +
gridLod[n - 1](x * 2 + 1, y * 2, z * 2 + 1) +
gridLod[n - 1](x * 2, y * 2 + 1, z * 2 + 1) +
gridLod[n - 1](x * 2 + 1, y * 2 + 1, z * 2 + 1));
}
}
}
}
...
// 例如渲染缓存的3级数据分辨率16
trace(ray, L, transmittance, rc, gridLod[3]);
...
```
我们暂不讲解如何选择合适的层级,但会在本章后续修订版本中补充。至少你现在已了解该问题及其可能的解决方案。
以下是缓存不同层级的渲染效果0 级为原始缓存分辨率,示例中为 128
![](images/2026/02/08/20260208_131609_994.webp)
### 三维运动模糊
三维运动模糊该如何实现?要渲染这一效果,我们需要为体素存储流体的运动信息——通常用称为 **运动矢量** 的方向来表示。该矢量的方向代表流体在网格中移动的平均方向,其模长代表流体的移动速度。利用这些信息,可在渲染阶段模拟三维运动模糊。
该主题将在后续章节中讲解。
### 平流Advection
尽管平流与体渲染无直接关联(更多属于流体模拟范畴),但我们在此提及作为备忘(以便后续如有可能,专门撰写章节讲解)。平流可在渲染阶段执行,为现有流体模拟结果增加细节。
### 稀疏体数据Sparse Volumes是什么
多数情况下,网格中仅有一小部分体素存储的密度值大于 0这导致大量“空”体素的存在——存储这些体素会造成显著的空间浪费。此外可能存在 8 个体素存储相同密度值(例如 0.138)的情况,这同样是空间浪费。稀疏体数据的设计正是为了解决这一问题。
其核心思路如下:我们创建一个尺寸为 2×2×2 体素块大小的大体积素。因此,这个大体积素的尺寸是原始体素的两倍。接下来:若 2×2×2 体素块内的所有体素都具有相同的密度值(可以是 0 或任意大于 0的值例如 0.139),则删除该 2×2×2 体素块,并将单一值存储在大体积素中(例如示例中的 0.139);否则,该大体积素指向原始的 2×2×2 体素块。
这一过程是递归的从最高分辨率网格最高层级开始自底向上构建较低层级。指向更多体素块的体素称为内部节点internal node存储具体值的体素称为叶节点leaf node。下图展示了二维稀疏体数据的示例
![](images/2026/02/08/20260208_131610_290.webp)
当体素块未被合并时,我们需要存储 1 个(大体积素)+8 个2×2×2 体素块)体素;而当体素块被合并时,仅需存储 1 个体素。在大多数流体模拟中,原始体素的很大一部分要么为空,要么具有相似的密度值(例如云层场景中,云的核心区域通常高度均匀,密度变化主要出现在云的边缘)。因此,采用
![](images/2026/02/08/20260208_131610_598.webp)
稀疏表示方式编码这些体数据,可大幅减少缓存在磁盘和内存中的占用空间。
请注意,使用 2×2×2 的体素块并非强制要求。包括 OpenVDB 在内的许多系统,会在层级结构的前几层采用 **八叉树octree** 结构,而后将后续层级存储在 32×32×32 的体素块中(示例)。这种数据组织方式相较于更小的块尺寸,可能提升缓存一致性和数据访问效率。
最后,稀疏体数据在渲染中也十分有用:可跳过空的大体积素,将密度均匀的大体积素作为均质体渲染。这些优化组合可显著节省渲染时间。
稀疏体数据(与 LOD 和砖块映射有相似之处)是一个重要且内容丰富的主题,值得单独撰写章节讲解。若你对该主题感兴趣,也可查阅 Gigavoxels体素块八叉树相关资料。
**核外渲染Out-of-Core Rendering**
我们在稀疏体数据后提及核外渲染是有原因的:稀疏体数据和体素块八叉树的设计初衷(除上述优化外),正是为了能够渲染无法全部加载到内存中的超大体积数据缓存。
当无法将整个文件加载到内存中时(你不希望受限于模拟数据的大小),可通过缓存区机制仅加载所需的体素块(例如相机可见的体素块)。当然,这要求体数据缓存被组织为可按需实时加载的体素块集合。该主题将在后续章节中全面讲解。
**注意网格数据的遍历方式**
若你需要遍历体素,建议采用以下方式:
```cpp
for (size_t x = 0; x < resolution; ++x) {
for (size_t y = 0; y < resolution; ++y) {
for (size_t z = 0; z < resolution; ++z) {
...
}
}
}
```
而非:
```cpp
for (size_t z = 0; z < resolution; ++z) {
for (size_t y = 0; y < resolution; ++y) {
for (size_t x = 0; x < resolution; ++x) {
...
}
}
}
```
这是由数据在内存中的存储方式决定的:先遍历 x、最后遍历 z 的方式,可按内存中存储的顺序访问值;而先遍历 z、最后遍历 x 的方式会跳转到内存的不同位置极易导致大量缓存未命中cache miss从而降低性能。当然这也取决于数据格式我们的缓存数据是按先 x、再 y、最后 z 的顺序存储的,但其他格式可能采用不同的约定。因此,需留意代码的这一细节——这可能是性能优化的关键点。
### 光照烘焙
你可将光照烘焙到网格体素中在渲染阶段直接从体素中读取这些数据从而跳过在相机光线的每一步光线步进raymarching中计算 Li 项的过程,提升渲染速度。当然,你仍需在预处理阶段(烘焙阶段)为网格中的每个体素计算 Li 项,这一过程仍会耗时;此外,每当光照或模拟结果发生变化时,都需要重新烘焙。我们提及该技术主要是为了参考和历史溯源。
### 深度阴影图Deep Shadow Maps
阴影图正逐渐成为历史但为了内容完整和历史溯源我们认为有必要提及深度阴影图——其思路与将光照烘焙到体素中的方式有相似之处。该技术的核心是为场景中的每个光源计算一张阴影图但与传统阴影图存储从光源到场景中最近物体的深度不同深度阴影图存储的是体密度随距离变化的函数。换句话说深度阴影图中的每个像素存储一条曲线代表穿过体数据时的透射率变化。该技术由皮克斯Pixar的 Tom Lokovic 和 Eric Veach 于 2000 年提出(若想深入了解,可查阅其论文),目前已有多种衍生版本。
## 源代码
与往常一样,你可在本章的源代码部分找到对应代码。我们还提供了约 100 个缓存文件序列(下载 cachefiles.zip 并解压到程序源代码所在目录),可用于渲染本章开头展示的动画。

View File

@@ -0,0 +1,47 @@
# 第一章 绪论
## 1.1 课题背景
随着实时渲染技术的发展,渲染引擎所面对的应用场景已经逐步扩展到游戏、虚拟现实、数字内容生产和交互式可视化等多个方向,逐渐演变为集图形接口抽象、资源导入与管理、场景组织、材质与光照、脚本运行时以及编辑器工作流于一体的综合软件系统。围绕渲染引擎整体架构开展设计与实现,也更便于将运行时系统、工具链以及后续渲染扩展放在统一平台中加以组织。
在现代实时渲染中,云、雾、烟、火等体积特效已经成为常见而重要的视觉元素。与基于表面的传统渲染不同,体积渲染需要处理光线在参与介质中的吸收、散射和透射率累积过程,通常伴随着大量采样和较高的计算开销。特别是在实时应用环境下,如何在有限的帧长内兼顾体积效果的空间层次感、光照表现和运行效率是极具价值的技术问题。也正因如此,体积渲染既具有较强的理论背景,也具有较高的工程实现价值。
在体数据表达方面OpenVDB 为稀疏体积数据组织提供了成熟思路,而 NanoVDB 通过线性化数据结构进一步提升了 GPU 访问友好性使其更适合实时渲染场景。与此同时DirectX 12 提供了较底层的资源、命令和同步控制能力,便于开发者更直接地组织 GPU 数据上传、状态切换与渲染调度流程。将 NanoVDB 稀疏体数据组织方式与 DirectX 12 图形接口结合起来,不仅适合开展体积特效的实现研究,也能够作为扩展渲染引擎高级渲染能力的一条现实技术路径。
## 1.2 课题意义
从体积渲染理论与实现的角度看,本课题围绕 NanoVDB 稀疏体数据在 DirectX 12 环境下的实时渲染展开重点关注体数据加载、GPU 访问、光线步进、空域跳过和体积阴影等关键问题。相关工作的完成,有助于为云、雾、烟等体积特效的实时工程实现提供一条较清晰的技术路径,也有助于加深对参与介质渲染、体绘制方程简化以及性能优化方法的理解。
从系统设计与工程实践的角度看,本课题在体积渲染实现之外,还涵盖了渲染引擎主体架构、运行时模块和编辑器工作流的设计与实现。当前项目已经形成了包含 RHI 抽象、资源系统、场景与组件系统、渲染主链、材质与光照、C# 脚本系统以及编辑器工具链在内的主体框架,体积渲染是在这一基础上的重要高级扩展。这体现了渲染引擎开发中的模块协同关系,同时满足了系统性、完整性和可扩展性的要求。
## 1.3 本课题的主要内容
结合当前项目的实际进展,本文的研究与实现内容主要由渲染引擎主体部分和体积渲染扩展部分两方面构成。
渲染引擎主体部分围绕运行时系统与编辑器工作流展开。在运行时层面项目已经建立起平台层、图形接口抽象层、资源系统、场景与组件系统、渲染主链、模型与材质系统、多光源与简单阴影、C# 脚本运行时等核心模块,能够支持基础场景的组织、加载与实时渲染。在编辑器层面,项目已经形成 Scene 视口、Game 视口、Hierarchy、Inspector、Project、Console 等主要界面,并提供对象拾取、轮廓高亮、网格显示、变换 Gizmo 以及脚本构建与重载等辅助能力,具备较完整的开发与调试闭环。
体积渲染扩展部分建立在现有引擎主体之上,重点研究参与介质渲染的基本理论以及 NanoVDB 稀疏体数据在 DirectX 12 环境下的工程实现方式。其核心内容包括体积渲染基本物理量分析、体绘制方程的简化理解、光线步进流程、稀疏体数据加载与 GPU 上传、Shader 侧体数据访问、空域跳过优化和体积阴影等。当前该部分已经完成独立原型验证,正在向现有渲染引擎主线做进一步整合。
## 1.4 本文的主要工作
围绕上述目标,本文已开展并完成的主要工作如下。
1. 完成了渲染引擎总体架构的设计与模块划分构建了平台层、RHI 层、资源与场景层、渲染层、脚本层和编辑器层之间的基本组织关系。
2. 实现了渲染引擎运行时主体能力,完成了缓冲、纹理、资源视图、管线状态、交换链等核心图形对象封装,并在此基础上建立了渲染请求规划、场景提取和相机执行等主链流程。
3. 实现了资源导入与管理、场景与组件组织、OBJ 模型渲染、材质系统、多光源和简单阴影等基础渲染能力,使引擎具备了较完整的场景渲染闭环。
4. 实现了基于 Mono 的 C# 脚本系统以及编辑器工作界面支持脚本程序集构建、脚本运行时装载、Scene/Game 视口显示、Hierarchy 与 Inspector 联动、Project 资源浏览和 Console 调试输出等功能。
5. 完成了基于 NanoVDB 的体积渲染原型设计与实现,已经实现 `.nvdb` 数据加载、GPU Buffer 上传、HLSL 侧 PNanoVDB 访问、光线步进、HDDA 跳空和体积阴影等流程。
## 1.5 论文结构安排
全文共分为九章,各章安排如下。
1. 第1章为绪论主要说明课题背景、课题意义、本文的主要内容、已完成的主要工作以及全文结构安排。
2. 第2章介绍渲染引擎相关技术基础为后续引擎架构设计与核心模块实现提供技术铺垫。
3. 第3章介绍体积渲染理论基础重点说明参与介质、透射率、体绘制方程、光线步进和稀疏体数据等内容。
4. 第4章对渲染引擎总体架构进行设计说明给出系统分层、模块划分、数据流关系以及体积渲染模块在整体架构中的位置。
5. 第5章围绕渲染引擎核心模块展开说明 RHI、资源系统、场景与组件系统、渲染主链、材质光照和脚本系统等关键内容的设计与实现。
6. 第6章介绍编辑器与引擎工作流的设计与实现重点展示编辑器界面组织、视口接入方式以及调试辅助能力。
7. 第7章重点说明基于 NanoVDB 的体积渲染模块设计与实现给出体数据加载、GPU 访问、光线步进、跳空优化和体积阴影等关键流程。
8. 第8章对渲染引擎主体功能和体积渲染模块进行测试并结合实验结果对效果与性能进行分析。
9. 第9章对全文工作进行总结并对后续可继续完善和扩展的方向进行展望。

View File

@@ -0,0 +1,133 @@
# 第七章 基于 NanoVDB 的体积渲染模块设计与实现
在前文完成渲染引擎主体、编辑器与工作流分析之后,本章进一步聚焦当前课题中最重要的高级渲染扩展,即基于 NanoVDB 的体积渲染模块。与前几章偏向引擎主体不同本章的重点不再是通用运行时框架而是围绕体积数据加载、GPU 访问、光线步进、跳空优化和体积阴影等关键流程,说明当前项目中这部分功能是如何落地的。需要说明的是,按照当前工程进展,体积渲染部分已经完成了独立原型验证,并在主引擎中接入了体数据资源与组件接口;但正式渲染通道并入主线仍处于收尾推进阶段。因此,本章的写法将同时覆盖“已经完成的原型实现”和“已经进入主引擎的数据接口部分”。
## 7.1 模块设计目标
### 7.1.1 面向现有渲染引擎扩展体积渲染能力
体积渲染模块的设计目标并不是脱离现有渲染引擎单独实现一个演示程序,而是在已有图形接口抽象、资源系统、场景系统和编辑器验证能力的基础上,补充云、雾、烟等参与介质的实时渲染能力。从课题定位看,这一模块属于当前渲染引擎中的高级渲染扩展,它需要复用已有 DirectX 12 资源管理能力,也需要为后续进入场景资源流、组件系统和编辑器工作流预留接口。
结合当前项目实际实现,体积渲染部分采用了“两步推进”的工程路径。第一步是在 `mvs/VolumeRenderer` 中完成 NanoVDB 数据访问与实时体光线步进的独立原型验证,先把关键算法和数据通路跑通;第二步是在主引擎中补齐 `VolumeField``VolumeRendererComponent`、资源导入与场景提取等基础接口,为后续正式并入渲染主链做准备。这种组织方式既保证了算法验证效率,也减少了在主线尚未稳定时大规模改动引擎主体的风险。
### 7.1.2 模块实现需要解决的核心问题
从工程实现角度看,当前体积渲染模块主要需要解决四个问题。第一,如何把 `.nvdb` 文件中的稀疏体数据读取为适合 GPU 使用的连续数据,并保持 NanoVDB 原有的层级结构信息。第二,如何在 shader 中以较低代价访问这些稀疏体数据,并利用其层级特性跳过空区域。第三,如何在实时条件下完成体积颜色和透射率累积,使渲染结果在质量与性能之间保持可接受平衡。第四,如何把这部分能力逐步接到主引擎的资源系统和场景系统中,而不是长期停留在独立实验程序层面。
本章后续几个小节正是围绕这四个问题展开。其基本技术路线可以概括为:由 CPU 侧完成 NanoVDB 数据加载与 GPU 上传,在像素着色器中结合 `PNanoVDB` 访问接口完成体数据遍历,并在光线步进过程中叠加单次散射近似和体积阴影,最后通过主引擎中的资源与组件接口为正式主线并入提供接入点。
### 7.1.3 当前实现路线的取舍
需要特别说明的是,开题阶段曾将 Compute Shader 和 DXR 体积阴影作为重要扩展方向提出但从当前仓库中的真实实现看已经完成的核心原型首先落在基于图形管线的像素着色器体积渲染方案上。具体来说当前原型通过全屏四边形驱动像素着色器在像素着色器中完成相机光线构造、NanoVDB 访问和体积积分过程。这种方案的优点是接入 DirectX 12 图形管线较直接,便于先验证 NanoVDB 读取、稀疏遍历和单次散射近似是否成立;其局限则是后续若要进一步提升结构清晰度和扩展 DXR 体积遮挡,仍需要继续向更正式的渲染通道推进。
因此,当前体积渲染模块的设计目标可以概括为:先完成 NanoVDB 稀疏体数据实时渲染的关键原型,再把资源与场景侧接口逐步接入主引擎,最终为正式体积渲染 pass 的并入打下基础。
## 7.2 NanoVDB 数据加载与 GPU 上传
### 7.2.1 `.nvdb` 文件读取与 CPU 侧数据准备
当前原型工程中的体积数据加载由 `NanoVDBLoader` 完成。加载时,系统首先调用 `nanovdb::io::readGrid` 读取 `.nvdb` 文件,并通过 `nanovdb::GridHandle<nanovdb::HostBuffer>` 获取 NanoVDB 的连续内存缓冲区。与传统基于指针的树结构不同NanoVDB 的优势之一就在于其树结构已经被线性化为适合 GPU 访问的连续内存布局,因此在读取完成后,系统不需要在 CPU 侧重新构造复杂指针关系,而是直接按字节复制原始缓冲区内容即可。
在当前实现中,加载器会根据 `gridHandle.buffer().bufferSize()` 计算字节总量,并进一步换算为以 `uint32` 为单位的元素数量,随后在 CPU 侧申请一段连续内存,将 NanoVDB 原始缓冲区完整拷贝到本地缓存中。这样处理后,体数据在 CPU 侧已经具备了与 GPU 侧 `StructuredBuffer<uint>` 对应的线性布局,为后续上传和 shader 访问提供了基础。
### 7.2.2 元数据提取与体数据描述
除了主体 payload 外,体积渲染还需要体数据的边界范围、体素尺度等元数据,用于调试显示、体积定位和后续资源化处理。当前原型在 CPU 侧直接从 NanoVDB 线性缓冲区中提取 `worldBBox``voxelSize`,并把这些信息写入 `NanoVDBData` 结构体。该结构体中除 `cpuData``byteSize``elementCount` 外,还保存了 `worldBBox[6]`,从而能够在后续渲染阶段得到体数据在世界空间下的包围信息。
在主引擎中,体数据被进一步抽象为 `VolumeField` 资源。`VolumeField` 内部记录 `storageKind``bounds``voxelSize` 和原始 payload并由 `VolumeFieldLoader` 支持 `.nvdb``.xcvol` 两种加载路径。与此同时,`ArtifactFormats.h` 中还定义了 `VolumeFieldArtifactHeader`,为 `storageKind``boundsMin``boundsMax``voxelSize``payloadSize` 预留了专门的持久化字段。这说明当前主引擎已经在资源结构上为体数据正式并入做了准备。
### 7.2.3 GPU 上传流程与资源绑定方式
在获得 CPU 侧连续缓冲区之后,当前原型采用 DirectX 12 的默认堆加上传堆组合完成 GPU 上传。系统首先在默认堆中创建一块 `BUFFER` 类型资源,初始状态为 `COPY_DEST`;随后在上传堆中创建等大小的上传缓冲,将 CPU 侧 NanoVDB 数据拷贝到上传堆映射内存中,再通过 `CopyBufferRegion` 将数据复制到默认堆资源。复制完成后,系统再插入资源状态屏障,把体数据缓冲从 `COPY_DEST` 切换到 `GENERIC_READ`,使其可以被 shader 以只读方式访问。
在绑定方式上,当前原型为体积渲染单独建立了一套根签名。该根签名包含两个根参数:一个是绑定在 `b1` 的常量缓冲视图,用于传递逆视图投影矩阵、相机位置、步长、最大步数、光照方向等参数;另一个是绑定在 `t1` 的着色器资源视图,用于传递 NanoVDB 线性缓冲区。shader 端则把该缓冲声明为 `StructuredBuffer<uint>`,再借助 `PNanoVDB.hlsl` 中提供的读取函数和访问器完成数据解释。
此处建议插入图 7-1“NanoVDB 数据加载与 GPU 上传流程图”,展示 `.nvdb` 文件读取、CPU 连续缓冲准备、上传堆复制、默认堆持久化和 `StructuredBuffer<uint>` 绑定之间的关系。
## 7.3 体积渲染核心流程实现
### 7.3.1 基于全屏四边形的相机光线构造
当前原型的体积渲染 pass 采用全屏四边形驱动的方式执行。主程序先构建一个覆盖标准化设备坐标空间的四边形网格,并使用专门的 `volume.hlsl` 顶点着色器与像素着色器完成体积渲染。顶点着色器本身较为简单,它一方面把输入顶点直接输出到屏幕空间,另一方面利用逆视图投影矩阵把屏幕空间位置还原为世界空间位置,从而为像素着色器提供构造视线方向所需的信息。
在像素着色器中,系统通过 `normalize(input.worldPos - _CameraPos_Density.xyz)` 计算当前像素对应的相机射线方向。这样一来,虽然渲染入口是传统图形管线中的全屏四边形绘制,但真正的体积积分过程仍然以“每像素一条视线”的方式展开,本质上与常见的 ray marching 体渲染流程一致。
### 7.3.2 体积内部采样与颜色累积
在构造出相机光线之后shader 会先初始化 NanoVDB 访问结构。当前实现中定义了 `NanoVolume` 结构,并在 `initVolume` 中依次建立 `grid handle``tree handle``root handle` 以及 `read accessor`。完成初始化后shader 调用 `get_hdda_hit` 沿当前相机光线执行第一次 NanoVDB 层级遍历,以确定光线是否进入有效体积区域以及进入位置对应的参数值 `tmin`。如果光线未命中体数据,则该像素直接输出透明结果。
当光线命中体积后主循环开始按照最大步数限制进行步进。对每一个步进位置shader 都会计算当前采样点坐标,并利用 `get_value_coord` 读取该处的体素值,再乘以 `_DensityScale` 获得当前密度。随后程序以该密度作为散射项 `sigmaS` 的基础,并通过指数衰减形式更新透射率 `transmittance`。散射贡献部分则采用单次散射近似,将当前点的散射强度、光照衰减和相位函数结果组合为 `Sint`,再累积到颜色结果中。
从流程上看,这一实现与第三章中的体积积分离散化过程是一致的:沿相机光线前向步进,对每个采样点估计局部散射与透射率,再将结果累计为最终颜色。为了避免无意义的长尾积分,当前实现还设置了两类提前终止条件:其一是当累积密度 `acc_density` 超过阈值时停止步进,其二是当透射率衰减到足够低时直接终止。这些处理在保证运行效率方面起到了重要作用。
### 7.3.3 渲染参数组织与结果输出
当前体积渲染 pass 所需的运行参数统一存放在一块常量缓冲中。该常量缓冲依次包含逆视图投影矩阵、相机位置与密度缩放、体积边界最小值与步长、体积边界最大值与最大步数、绕 Y 轴旋转参数以及光照方向与阴影采样参数等内容。主程序会在每帧更新这些参数,并将其绑定到体积渲染根签名对应的 `CBV` 槽位。
从图形状态设置上看当前体积渲染管线采用了颜色混合开启、深度测试开启但深度写入关闭的方式。这种设置使体积结果能够作为半透明层叠加到已有场景结果上同时避免破坏前面已生成的深度缓冲。最终输出阶段shader 还会对结果执行一次 gamma 校正,并把累积密度作为 alpha 输出。这样既能得到较自然的颜色表现,也便于后续混合。
此处建议插入图 7-2“体积渲染结果示意图”可选用 `cloud.nvdb``bunny.nvdb` 的当前渲染结果截图。
## 7.4 稀疏体数据访问与跳空优化
### 7.4.1 `StructuredBuffer<uint>` 与 `PNanoVDB` 访问方式
NanoVDB 的核心优势在于其树结构已经被线性化,因此 GPU 侧不需要复杂指针跳转即可访问体数据。当前 shader 端把体数据统一表示为 `StructuredBuffer<uint> buf`,随后通过 `PNanoVDB.hlsl` 提供的缓冲读取函数、网格句柄、树句柄、根节点句柄以及访问器接口解释这段线性缓冲区。具体来说shader 会先读取 grid type再通过 `pnanovdb_grid_get_tree``pnanovdb_tree_get_root``pnanovdb_readaccessor_init` 建立访问器,之后即可通过统一接口查询给定位置的体素值和层级维度信息。
这种实现方式的重要意义在于,体数据访问不再依赖 CPU 端构造额外的数据映射表,也不需要在 GPU 端额外展开稠密体素网格。体渲染 pass 可以直接以 NanoVDB 原始线性布局为输入,从而较完整地保留其稀疏结构优势。
### 7.4.2 基于 HDDA 的层级遍历与空域跳过
为了避免对大量空区域执行逐体素采样,当前实现引入了 `PNanoVDB` 中的 HDDA 遍历能力。首先shader 通过 `get_hdda_hit` 调用 `pnanovdb_hdda_tree_marcher`,在层级结构上快速找到光线进入有效体积区域的位置。进入主循环后,又通过 `get_dim_coord` 查询当前位置所在节点的维度信息。若该维度值大于 1说明当前命中的是较粗层级的 tile 区域,此时程序并不执行逐体素积分,而是直接进行更大步长的跳跃,从而跳过大片空域。
除显式的层级跳过外,当前 shader 还根据密度阈值设置了第二层启发式优化。当当前采样位置的密度低于设定阈值时,系统会采用较大的步长直接向前推进;一旦重新接近有效区域,又会通过回退部分跳跃距离的方式减少错过细节的风险。虽然这部分处理带有一定工程经验性质,但从当前原型看,它与 NanoVDB 的层级跳过机制共同起到了减少无效采样的作用。
### 7.4.3 稀疏访问优化的作用与边界
对于体积渲染而言,真正耗时的部分往往不是单次采样本身,而是“在大片空区域中做了大量没有意义的采样”。当前实现通过 NanoVDB 层级结构与 HDDA 机制,把“先找到有效区域,再进入局部积分”的思路落实到了 shader 中,这也是原型能够在实时条件下运行的关键原因之一。与稠密三维纹理逐点扫描相比,这种方式更符合云、雾等稀疏参与介质的空间分布特征。
当然,当前实现中的具体跳跃步长和回退系数仍然带有明显的实验性质,它们尚未被完全整理为统一的引擎参数系统。后续若正式并入主引擎主线,还需要把这些策略进一步规范化,并与材质参数、质量档位和性能测试结果结合起来。
## 7.5 光照与体积阴影实现
### 7.5.1 单次散射近似
当前体积渲染模块在光照模型上采用的是单次散射近似,而不是完整的多次散射求解。其基本思路是:在相机光线上每个采样点处,只考虑该点受主光照方向直接照射后产生的一次散射贡献,再结合透射率把这部分贡献累积到观察结果中。这样做虽然牺牲了部分物理真实性,但显著降低了实时渲染开销,也符合当前项目作为工程型毕设的实现阶段。
在 shader 代码中,当前相位函数被简化为常量函数 `phase_function() = 1.0`,也就是把散射过程近似为各向同性响应。这意味着当前实现并未进一步展开如 Henyey-Greenstein 相位函数这样的方向性散射模型,而是先以结构正确、代价较低的形式完成体积光照闭环。对于现阶段的模块验证而言,这样的取舍是合理的。
### 7.5.2 沿光照方向的体积阴影估计
为了让体积结果不至于表现为完全均匀发亮的云团,当前实现还引入了沿光照方向的体积阴影估计。其核心函数为 `volumetric_shadow`。在该函数中,程序从当前采样点沿光照方向继续步进,对后续路径上的密度进行估计,并通过指数衰减累积出一条简化的光照透射率。随后,该透射率作为当前采样点的阴影系数参与颜色积分。
从实现细节看,当前阴影步进采用了指数增长的步长策略:每次采样后将 `step_size` 扩大一倍,以较少的采样次数快速覆盖更远距离的阴影路径。这样做能够在有限采样预算内得到一条近似可用的阴影衰减曲线,并降低阴影求解带来的额外成本。与此同时,主程序还通过常量缓冲向 shader 传递 `LightDir` 和阴影相关参数,使体积光照方向能够随运行状态变化。
### 7.5.3 当前光照模型的局限
虽然当前实现已经具备体积光照与阴影的基本效果,但其近似性质也十分明显。第一,当前只实现了单次散射,没有处理多次散射带来的能量回填和颜色传播,因此云体内部的柔和感仍然有限。第二,相位函数采用常量近似,没有体现前向散射或后向散射差异。第三,阴影积分仍采用简化步进,没有与真正的场景几何遮挡、级联阴影或 DXR 光线查询结合起来。也就是说,当前模块已经完成了“体积渲染能成立”的关键闭环,但距离更完整、更物理化的体积光照模型还有后续扩展空间。
此处建议插入图 7-3“体积阴影开启与关闭对比图”用于展示单次散射近似和阴影积分对体积层次感的改善作用。
## 7.6 当前实现状态分析
### 7.6.1 已完成的核心原型部分
从当前仓库中的真实实现看,体积渲染最核心的原型已经在 `mvs/VolumeRenderer` 中完成。该原型已经具备 NanoVDB 文件读取、CPU 连续缓冲准备、DirectX 12 默认堆上传、`StructuredBuffer<uint>` 绑定、`PNanoVDB` 层级访问、HDDA 进入测试、主光线步进积分以及简化体积阴影估计等关键能力。主程序能够直接加载 `cloud.nvdb` 等测试数据,并在独立窗口中实时输出体积渲染结果。就“关键算法与数据通路是否打通”这一问题而言,当前答案是肯定的。
### 7.6.2 已进入主引擎的资源与场景接口部分
除独立原型外,主引擎中与体积渲染相关的资源和场景接口也已经基本建立完成。资源层面,`VolumeField``VolumeFieldLoader` 已经支持 `.nvdb``.xcvol` 两类体数据资源,`AssetDatabase` 也已经加入了 `VolumeField` 的 artifact 生成流程;组件层面,`VolumeRendererComponent` 已经能够持有体数据资源和材质资源,并支持同步、异步与基于 `AssetRef` 的加载方式;场景提取层面,`RenderSceneExtractor``RenderSceneUtility` 已经能够把场景中的体积对象提取为 `VisibleVolumeItem`,并将其纳入 `RenderSceneData`
这说明体积渲染并不是完全游离于主引擎之外的孤立实验,而是已经在资源系统、组件系统和场景提取结构中获得了明确的位置。换言之,正式并入主渲染通道所需的很多前置数据接口已经准备好。
### 7.6.3 正在收尾与尚未正式并入主线的部分
当前仍在推进的部分主要集中在“把已有原型真正并入主引擎渲染主链”这一阶段。虽然 `RenderSceneData` 中已经预留了 `visibleVolumes`,但当前主渲染管线中尚未正式消费这一数据并生成对应的体积 pass`VolumeRendererComponent` 虽已进入组件系统,但编辑器端尚未形成对应的组件检查与参数编辑面板;主引擎资源管线虽然已经具备 `VolumeFieldArtifactHeader` 结构,但对 `.nvdb` 源数据中的边界和体素尺寸信息仍需继续工程化整理。除此之外,开题阶段提出的 DXR 体积阴影扩展,目前也尚未以正式代码路径并入现有主线。
因此,更准确的表述应当是:当前体积渲染已经完成了关键原型验证,并已在主引擎中接入资源和场景接口;而正式主线渲染接入、编辑器参数化支持以及更高质量光照扩展,仍处于最后的工程收尾阶段。
## 7.7 本章小结
本章围绕当前项目中的 NanoVDB 体积渲染模块实现进行了分析。可以看到,该模块已经在独立原型层面完成了从 `.nvdb` 文件读取、GPU 上传、`StructuredBuffer<uint>` 访问,到基于 `PNanoVDB` 的层级遍历、光线步进积分和体积阴影近似的完整闭环;与此同时,主引擎中也已经建立起 `VolumeField` 资源、`VolumeRendererComponent` 组件以及 `VisibleVolumeItem` 场景提取结构,为正式并入渲染主线提供了数据基础。
从当前工程状态看,体积渲染部分已经不再停留在理论分析层面,而是形成了可运行、可验证、可继续并入主引擎的实际模块。下一章将在此基础上进一步结合测试场景和实验结果,对当前渲染引擎主体与体积渲染扩展的功能表现和实现效果进行分析。

View File

@@ -0,0 +1,165 @@
# 第三章 体积渲染理论基础
第二章已经从渲染引擎角度说明了运行时系统、资源系统、场景组织和编辑器工作流等基础内容。本章进一步转入体积渲染本身的理论部分,重点讨论参与介质、光在介质中的衰减与散射、体绘制方程、光线步进以及稀疏体数据与空域跳过等关键概念。本章的目的不是对体积光传输进行过度抽象的数学展开,而是为后续基于 NanoVDB 的体积渲染模块设计与实现提供足够明确的理论基础。
## 3.1 参与介质与体积渲染基本概念
### 3.1.1 参与介质的定义
传统表面渲染主要关心光线在物体表面处发生的反射与折射而体积渲染关注的对象则是空间中的参与介质Participating Media。所谓参与介质是指光在传播过程中会与其内部粒子持续发生相互作用的介质例如云、雾、烟、火焰、水汽等。这类介质并不像三角形网格那样只在边界上发生光学变化而是会在体积内部对光线产生吸收、散射甚至发射作用因此它们的成像过程天然具有空间累积特征。
从工程角度看,参与介质通常可以分为均匀介质和非均匀介质两类。均匀介质在空间中的光学参数保持不变,便于分析和推导;非均匀介质的密度或光学参数随位置变化,更接近真实云层和烟雾的外观,也是实际应用中更常见的情况。在本文项目后续的体积渲染实现中,体数据本质上就是对这种空间变化进行离散表达,因此理解参与介质的基本性质是后续实现的前提。
### 3.1.2 吸收、散射与透射率
当光线穿过参与介质时,最基本的两类相互作用是吸收和散射。吸收表示光能被介质粒子消耗,导致沿原方向传播的光变弱;散射表示光线方向发生改变,其中一部分光离开原有传播方向,另一部分光可能从其他方向被重新散射到观察方向。通常用吸收系数 $\sigma_a$ 表示吸收强度,用散射系数 $\sigma_s$ 表示散射强度,两者之和称为消光系数或总衰减系数:
$$
\sigma_t = \sigma_a + \sigma_s
$$
在很多实时体积渲染实现中,密度场会被用来调制这些系数,使介质在空间上的不透明度和亮度分布发生变化。也就是说,密度并不是与吸收、散射并列的第三类光学现象,而更像是对局部光学参数的空间缩放。密度越大,局部吸收和散射通常也越强;密度越小,介质对光的影响则越弱。
透射率Transmittance描述的是光线在介质中传播一段距离后仍然保留下来的比例其取值范围在 0 到 1 之间。透射率越接近 1说明光几乎未被削弱透射率越接近 0说明光在介质中已被显著衰减。透射率是体积渲染中极为核心的量因为无论是背景光穿过体积后的结果还是光源传播到采样点的有效光照最终都要依赖透射率来表达。
### 3.1.3 体积颜色形成的基本原因
体积图像的形成并不是单纯“给体素上色”,而是光在介质中传播、衰减与散射共同作用的结果。从观察者方向看,体积颜色主要来自两个来源。其一是背景或后方物体发出的光在穿过介质时被衰减后剩余的部分;其二是来自光源的入射光在介质内部发生散射后,被重新导向观察方向所产生的内散射贡献。当前项目后续实现的重点主要也集中在这两部分,即背景透射与单次散射近似。
如果介质本身还会主动发光,例如火焰、高温气体等,则还需要考虑发射项。不过从本文当前项目的体积模块实现状态来看,重点仍然放在云、烟等主要受吸收和散射影响的体数据渲染上,因此本章后续分析将以吸收、散射和透射率累积为主。
## 3.2 比尔-朗伯定律与光线透射
### 3.2.1 比尔-朗伯定律的物理含义
比尔-朗伯定律Beer-Lambert Law描述了光在介质中传播时的指数衰减规律。对于均匀介质如果光线在介质中传播距离为 $d$,则透射率可写为:
$$
T(d) = e^{-\sigma_t d}
$$
该式说明,光的衰减并不是线性减少,而是随着传播距离和消光系数的增加呈指数下降。直观地说,当介质更“浓”或者光走得更远时,保留下来的光就会更少。这一定律在体积渲染中的意义非常直接,因为它给出了“光穿过介质后还能剩多少”的定量表达。
从算法视角看,比尔-朗伯定律还具有一个非常重要的性质,即分段可乘性。若一段路径可以分解为多个小区间,那么整条路径的透射率可以看成各区间透射率的乘积。这一性质使得连续体积的积分问题能够自然转化为离散光线步进中的逐步累积过程,也正是实时体积渲染中采用逐采样点更新透射率的理论依据。
### 3.2.2 消光系数、吸收系数与散射系数的关系
如前所述,吸收与外散射都会使沿当前方向传播的光减少,因此在透射率计算时通常统一用消光系数 $\sigma_t$ 表示总衰减。对于只考虑吸收的情况,可以仅使用 $\sigma_a$;而在更一般的参与介质渲染中,散射也会让光离开原传播方向,因此应把 $\sigma_s$ 一并纳入:
$$
\sigma_t = \sigma_a + \sigma_s
$$
需要注意的是,$\sigma_s$ 一方面参与透射率计算,因为散射会让光束损失能量;另一方面又会出现在内散射项中,因为正是散射作用把来自光源方向的入射光重新导向观察方向。也就是说,散射在体积渲染中同时承担“损失原方向能量”和“为观察方向提供贡献”两种角色,这也是体积渲染比单纯透明衰减更复杂的根本原因。
在实际工程实现中,常见做法是根据体数据提供的密度值对吸收系数和散射系数进行调制。例如,当某个采样点密度更大时,局部消光系数也更大,透射率会下降得更快;反之则衰减更弱。这种做法既便于统一控制体积外观,也便于将理论模型映射到离散体数据采样结果上。
### 3.2.3 透射率在体积渲染中的作用
透射率在体积渲染中至少有三类直接用途。第一,它用于描述背景光或后方物体颜色经过体积后的剩余比例,因此决定了体积区域是否会“遮住”背景。第二,它用于描述光源从入射方向传播到某个采样点时的衰减程度,因此直接影响采样点处的有效光照强度。第三,它用于前向积分时对已累积颜色进行权重控制,使离观察者更远处的散射贡献自动受到前方介质的遮挡。
对于均匀介质,透射率可以直接由闭式公式给出;但对于非均匀介质,消光系数会随空间变化,此时应采用积分形式:
$$
T(d)=\exp \left(-\int_0^d \sigma_t(x_t)\,dt \right)
$$
式中的积分项通常也记作光学厚度 $\tau$。这一定义说明非均匀体积的透射率本质上依赖整条路径上的密度分布而不是只依赖起点与终点之间的几何距离。后续第7章中光线步进和光照阴影估计实际上都是在用离散采样的方式近似这个积分。
## 3.3 体绘制方程与单次散射近似
### 3.3.1 体绘制方程的基本形式
为了完整描述光在参与介质中的传播理论上应从辐射传输方程出发。但对于工程实现而言更常使用的是其沿路径积分后的体绘制方程Volume Rendering EquationVRE。在忽略发射项并只关注背景透射和散射贡献的情况下体绘制方程可写成较常见的形式
$$
L_o = T(0,D)L_b + \int_0^D T(0,t)\,\sigma_s(x_t)\,L_i(x_t,\omega_i)\,p(\omega_i,\omega_o)\,dt
$$
其中,$L_o$ 表示观察方向上的出射辐射亮度,$L_b$ 表示体积后方背景或其他物体的辐射亮度,$T(0,D)$ 表示整段路径上的透射率,$L_i(x_t,\omega_i)$ 表示采样点从光源方向接收到的入射辐射亮度,$p(\omega_i,\omega_o)$ 为相位函数。这个方程的物理意义比较直观:最终看到的体积颜色,一部分来自背景光穿透体积后的结果,另一部分来自介质内部各个采样点把入射光散射到观察方向后的累积结果。
从工程实现角度看,这个积分方程往往没有简单解析解,特别是在介质非均匀、光源复杂或需要考虑阴影时更是如此,因此通常需要采用数值积分方法近似求解。也正因为如此,光线步进会成为体积渲染中最常见、也最直观的实现方式。
### 3.3.2 单次散射的含义
单次散射Single Scattering是指只考虑“光从光源出发经过一次介质散射后进入观察方向”的情况而忽略光在介质内部发生两次及以上散射的贡献。换句话说光子在进入观察者之前只被介质重新定向一次。这样做会忽略云层内部那种复杂的多次反弹与能量交换但可以显著降低计算复杂度。
在实时体积渲染中,单次散射近似非常常见。一方面,它能够较好描述烟雾、薄雾和部分低反照率介质的主要视觉特征;另一方面,它能够与光线步进、阴影步进和体数据采样自然结合,便于在图形管线或计算着色器中实现。当前项目后续的体积原型同样采用这一思路,因此本章理论分析将主要围绕单次散射展开。
### 3.3.3 相位函数的作用
即使在单次散射模型下也还需要回答一个问题某个采样点接收到的入射光中究竟有多少会被散射到观察方向这个角度分布由相位函数Phase Function描述。相位函数本质上是一个在方向球面上归一化的函数用于刻画散射光在不同方向上的分布情况。它通常依赖入射方向与观察方向之间的夹角 $\theta$。
最简单的相位函数是各向同性相位函数,它认为光会以相同概率向所有方向散射,此时:
$$
p(\theta)=\frac{1}{4\pi}
$$
但很多真实介质并不满足各向同性分布,云和雾等介质更常表现出明显的前向散射特征。工程中常用的近似模型是亨耶-格林斯坦Henyey-GreensteinHG相位函数
$$
p_{HG}(\cos\theta)=\frac{1-g^2}{4\pi(1+g^2-2g\cos\theta)^{3/2}}
$$
其中 $g \in [-1,1]$ 为非对称因子。当 $g=0$ 时退化为各向同性散射;当 $g>0$ 时表现为前向散射;当 $g<0$ 时表现为后向散射。后续第7章的工程实现虽然会采用简化形式但相位函数这一思想仍然是构成单次散射项的理论基础。
### 3.3.4 为什么在实时系统中常采用简化模型
如果严格考虑多次散射、复杂相位函数、多个动态光源和高精度体数据积分,那么体积渲染的计算量会迅速增大,很难满足实时应用对帧率的要求。因此,实时系统通常会在若干环节上做简化:例如只考虑单次散射、采用固定步长或分层步进、使用简化相位函数、对阴影进行近似积分,或者在必要时对空区域进行跳过。
这种简化并不意味着理论被放弃,而是意味着在已知完整物理模型的前提下,有选择地保留最影响画面结果的部分。对于工程设计类项目而言,这种从完整理论到可实时实现之间的取舍非常重要。后续章节的体积模块实现也正是在这一原则下,选择了适合当前项目阶段的实时方案。
## 3.4 光线步进算法原理
### 3.4.1 Ray Marching 的基本思想
由于体绘制方程通常难以直接求得解析解实际实现中往往采用数值积分近似而光线步进Ray Marching就是其中最典型的方法。其基本思想是先求出相机光线与体积包围区域的进入点和离开点再把这段区间划分为若干个小步长在每个采样点处估计局部密度、局部消光、局部散射贡献与透射率最后将这些局部结果累积起来近似连续积分。
若步长足够小,则每个小区间都可以视作局部均匀,这样就能用离散求和逼近连续积分。也正因为这一思想简单直接,光线步进非常适合作为体积渲染的工程实现起点。本文后续的 NanoVDB 体积原型同样以相机光线与体积边界求交为起点,然后在体积内部做离散采样累积。
### 3.4.2 正向步进与反向步进
按观察方向组织时光线步进通常可以分为正向步进和反向步进两种。正向步进一般指从靠近相机的一侧向远处推进也可理解为前向累积front-to-back反向步进则从远处向近处积分也可理解为后向累积back-to-front
从数值结果上看,两者都可以逼近同一个积分目标,但在工程实现上,正向步进往往更适合实时系统。原因在于,正向步进可以显式维护“当前剩余透射率”,当透射率已经很低时,后续更远处的采样贡献可以近似忽略,从而支持提前终止优化。反向步进虽然在某些推导上较直观,但不如正向步进方便做前端遮挡裁剪。当前项目后续的体积实现也更接近前向累积方式。
### 3.4.3 步长、最大步数与误差控制
步长是光线步进中的关键参数。步长越小,离散积分越接近连续积分,画面通常越平滑,细节也越稳定;但采样次数随之增加,运行开销也会显著变大。步长过大时,则容易出现条带感、细节丢失和阴影估计不稳定等问题。与步长相对应的另一个参数是最大步数,它决定了一条光线最多允许采样多少次,用于限制最坏情况下的开销。
因此,实时体积渲染本质上是在“精度”和“性能”之间寻找平衡。工程上通常会根据体素分辨率、包围盒大小、屏幕分辨率以及目标帧率来选择合适步长,并在必要时为主光线和阴影光线设置不同的采样密度。这种参数平衡在后续测试章节中也会体现出来。
### 3.4.4 提前终止、抖动等常见优化
在体积积分过程中并不是每一个采样点都同样重要因此常会配合若干优化策略。最常见的一类是提前终止Early Termination当累计透射率已经低于某个阈值时说明后续更远区域的贡献非常有限此时可以直接结束当前光线步进。对于较浓的烟雾或高密度区域这类优化能节省大量无效计算。
另一类常见方法是采样抖动Jitter。固定步长加固定采样起点容易带来规则性的条纹或分层感而在初始采样位置上引入轻微随机偏移可以打散这种结构化误差使图像在视觉上更平滑。除此之外还可以通过多分辨率步进、阴影光线使用更粗步长、分层包围盒裁剪等方式进一步优化。对于本文后续实现而言提前终止和空域跳过具有更直接的工程意义。
## 3.5 稀疏体数据与空域跳过思想
### 3.5.1 稠密体素网格的问题
如果直接使用规则三维网格存储体数据,那么无论某个区域是否真正包含有效密度值,都需要为其分配存储空间。当分辨率升高到 $N \times N \times N$ 时,存储量会按立方增长,显著增加显存和内存压力。更重要的是,若光线步进始终以固定步长穿过整个包围区域,那么大量采样都会落在密度接近 0 的空区域中,带来明显的计算浪费。
对于云、烟等典型体数据而言,真正有意义的部分往往只占整个包围空间的一小部分。也就是说,体数据天然具有“空间稀疏”的特点。如果仍然用稠密网格表示,就会在存储与计算两个层面同时承受不必要的代价。
### 3.5.2 稀疏体数据结构的意义
稀疏体数据结构的核心思想是只为真正包含有效信息的区域分配更细粒度的存储而对大量空区域或均匀区域采用更粗层级的表示。OpenVDB 及其面向 GPU 的 NanoVDB 就属于这一类思路。它们通过层级化节点结构组织体数据,使得体素值访问不再局限于简单的三维数组索引,而是能够根据当前区域是否活跃、当前层级分辨率以及节点类型进行更有选择性的访问。
对于实时渲染而言NanoVDB 的价值尤其明显。它在保留 VDB 层级稀疏表达思想的基础上,对数据进行了线性化组织,使 GPU 更容易访问。这样一来,体数据不但可以以更紧凑的形式存储在显存中,还能够在 Shader 中配合层级遍历或辅助访问器实现更高效的采样与判断。后续第7章的工程实现正是建立在这种 GPU 友好的稀疏表示之上。
### 3.5.3 空区域跳过对实时性的作用
空域跳过Empty Space Skipping的目标是尽量避免在无效区域上进行逐步长采样。其基本思想不是改变体绘制方程而是在数值积分过程中快速定位“哪些区域值得细采样哪些区域可以直接跳过”。如果能在光线进入空区域时一次跨过较长距离而不是继续做多个低价值采样那么体积渲染的实时性就会明显提升。
实现空域跳过的方式有很多,例如基于宏体素的占用标记、基于层级结构的活跃区判断,或者基于 DDA/HDDA 的层级步进方法。其共同点在于:当当前区域内不存在活跃密度,或者当前层级可以确认该区域为空时,光线可以直接前进到下一个可能有数据的位置。在 NanoVDB 场景下,这种思想与其层级节点结构天然契合,因此非常适合作为实时体积渲染的优化手段。
从后续工程实现角度看空域跳过并不是“附加优化”而是让稀疏体数据在实时环境中真正发挥价值的关键环节。只有把稀疏存储与跳空采样结合起来NanoVDB 的结构优势才能转化为实际帧时间上的收益。
## 3.6 本章小结
本章围绕体积渲染实现所需的核心理论进行了梳理,说明了参与介质的基本概念,以及吸收、散射、消光系数和透射率之间的关系;介绍了比尔-朗伯定律在均匀与非均匀介质中的表达形式,并说明了透射率在背景衰减、光照传播和颜色累积中的作用;进一步给出了体绘制方程的基本形式,解释了单次散射近似和相位函数的物理意义;最后分析了光线步进的数值积分思想,以及稀疏体数据与空域跳过对实时性的作用。
这些理论内容与后续工程实现之间是一一对应的:透射率累积将对应光线步进中的前向积分,单次散射与相位函数将对应体积光照近似,稀疏体数据与跳空思想将对应 NanoVDB 的 GPU 访问和 HDDA 优化。基于这些理论基础,下一章将开始转入渲染引擎总体架构设计。

View File

@@ -0,0 +1,70 @@
# 第二章 渲染引擎发展现状与本课题引擎概述
第一章已经对课题背景、研究意义和整体工作内容进行了简要说明。在展开具体的架构设计与模块实现之前,本章先对实时渲染引擎的发展脉络、当前主流引擎的能力对比,以及体积渲染特性在现代引擎中的应用现状进行梳理。同时,还将对本文所实现的实时渲染引擎作总体介绍,包括其主体结构、核心模块、编辑器工作流和体积渲染扩展。
## 2.1 渲染引擎的发展历程
### 2.1.1 从单一绘制程序到引擎平台
早期实时图形程序往往围绕单一渲染目标构建,重点在于完成特定场景的绘制与显示,系统结构相对集中,资源组织和工具支持能力较弱。随着实时图形程序应用复杂度的不断提高,图形程序需要面对的不再只是几何绘制本身,还包括模型与纹理导入、资源管理、场景组织、材质参数管理、光照与阴影、脚本行为控制以及可视化编辑等问题。在这一过程中,渲染系统逐步从面向单次绘制任务的程序形态,演变为面向完整开发流程的引擎平台形态。
【插图:插一个古早渲染程序图片即可】
### 2.1.2 从固定管线到可编程管线
图形硬件和图形 API 的演进推动了渲染引擎能力的持续升级。固定功能管线阶段,开发者控制的绘制流程较为有限,系统设计更偏向对既有图形功能的调用与组合。进入可编程管线阶段后,随着顶点处理、像素处理、屏幕后处理以及更复杂的光照模型逐步进入实时图形系统,材质系统、着色器管理和渲染流程组织的重要性也显著提高。也正因如此,渲染引擎开始形成更加清晰的资源层、场景层、渲染层和脚本层等架构分层。
### 2.1.3 从运行时绘制到完整工作流
现代渲染引擎的发展已经不再局限于运行时画面生成,而是越来越强调资源导入、场景搭建、参数调整、脚本扩展和结果验证之间的闭环关系。编辑器、资源数据库、脚本运行时和测试体系逐渐成为引擎的重要组成部分。现代渲染引擎的核心价值,已经扩展到内容组织、资源管理、工具协同和持续扩展能力等多个方面。
## 2.2 当前主流渲染引擎的特点
### 2.2.1 Unity
Unity 在实时图形与交互式应用开发中具有广泛影响力其突出特点在于组件化对象模型、成熟的编辑器工作流以及围绕资源导入、场景管理和脚本开发建立起来的完整开发环境。Unity 以 C# 脚本为主要行为扩展方式,在对象组织、资源引用和编辑器操作方面形成了较强的一致性。近年来其渲染体系也逐步向可配置、可扩展方向发展,说明现代渲染引擎不仅关注基础绘制能力,也重视渲染管线的工程组织方式。
### 2.2.2 Unreal Engine
Unreal Engine 在高质量实时渲染、复杂场景表现和大型工程工具链方面具有代表性。其整体体系强调底层 C++ 能力、高规格实时渲染效果以及可视化工具协同工作。相较于更偏轻量化的开发环境Unreal Engine 在渲染模块、编辑器体系、资源管理和运行时工具链方面更强调系统完整性,对高复杂度实时图形项目具有较强支撑能力。其发展方向说明,高级渲染效果通常不是孤立功能,而是建立在资源、场景、工具链和渲染主链共同配合的基础之上。
### 2.2.3 Godot 等开源引擎
除商业化程度较高的主流引擎外Godot 等开源引擎也在实时图形开发中占有重要位置。此类引擎通常更加重视开放性、可阅读性和模块化扩展能力,在场景组织、脚本系统和编辑器集成方面同样形成了较完整的工作流。虽然不同引擎在功能规模、定位和实现路线方面存在差异,但从系统构成角度看,资源、场景、渲染、脚本和编辑器的协同已经成为现代渲染引擎的普遍特征。
### 2.2.4 主流引擎能力的共性
综合来看,当前主流渲染引擎虽然在底层实现方式、目标平台和工具链风格上各有差异,但在整体结构上呈现出较为一致的趋势。首先,都以资源系统、场景组织和渲染主链作为运行时主体;其次,都通过脚本系统和编辑器工作流提高内容生产与调试效率;再次,都为高级渲染特性预留了较明确的扩展位置。因此,从工程角度理解渲染引擎,重点不应只放在某一个局部渲染效果上,而应放在“完整系统如何支持内容、渲染与工具协同”这一问题上。
## 2.3 主流引擎中体积特效的支持现状
### 2.3.1 体积特效在实时图形中的应用
云、雾、烟、火焰和体积光等效果已经成为现代实时图形表现中的重要组成部分。这类效果能够显著增强场景空间层次、光照氛围和视觉真实感,在游戏、数字场景展示和交互式图形应用中都有广泛需求。与传统表面渲染相比,体积特效处理的是空间中的参与介质,因此其实现通常涉及更复杂的采样、累积和光照近似问题。
### 2.3.2 主流引擎中体积特效的工程特点
从主流引擎的实际做法看,体积特效很少被组织为与引擎主体完全割裂的独立模块,而是通常与现有的资源系统、光照系统、材质系统、场景组织方式以及编辑器调试能力紧密结合。体积雾需要与相机和光照信息协同,体积云需要依赖体数据或程序化参数组织,体积光照则需要与阴影和后处理结果协调。这表明体积渲染在工程实现中本质上属于高级渲染扩展能力,而不是可以脱离引擎主体单独讨论的局部算法。
### 2.3.3 实时体积渲染面临的主要问题
尽管体积特效在现代引擎中已经较为常见,但实时体积渲染仍然面临明显挑战。一方面,参与介质中的吸收、散射和透射率累积会带来较高的计算开销;另一方面,体数据存储、采样精度、跳空优化和光照近似又直接影响效果质量与实时性能之间的平衡。因此,体积渲染既具有较强的理论性,也具有明显的工程实现难度。这也是本课题将其作为渲染引擎高级扩展重点展开的主要原因。
## 2.4 本课题渲染引擎的总体介绍
### 2.4.1 项目定位与整体组成
结合当前代码与工程组织方式,本文项目已经不再是围绕若干独立 sample 展开的图形实验集合,而是已经形成主体清晰的渲染引擎工作区。项目采用模块化 C++ 结构,`engine/` 负责引擎核心模块,`editor/` 负责桌面编辑器,`managed/` 负责脚本程序集,`project/` 负责示例工程与项目资源,`tests/` 负责主线模块测试。从当前主线结构看,项目已经建立起 `RHI -> Rendering -> Editor Viewport -> AssetDatabase/Library -> Mono Scripting` 的基本闭环,这说明系统已经具备较明确的引擎形态。
### 2.4.2 当前已实现的核心能力
从当前实现情况看,本文项目的渲染引擎主体已经覆盖多个关键部分。在图形接口层,系统已维护 `D3D12``OpenGL``Vulkan` 三种后端;在渲染层,已经形成以 `SceneRenderer``CameraRenderer``RenderPipeline` 为核心的主链结构;在资源层,已经建立 `Assets + .meta + Library` 风格的 `AssetDatabase`、artifact 缓存与运行时资源装载链路;在场景与组件层,已经形成 `Scene - GameObject - Component` 的组织方式;在渲染能力方面,已经支持 OBJ 模型渲染、材质系统、多光源、简单阴影以及编辑器视口离屏显示;在脚本与工具链方面,已经具备基于 Mono 的 C# 脚本系统,以及 `Scene``Game``Hierarchy``Inspector``Project``Console` 等编辑器工作界面,并支持对象拾取、轮廓高亮、网格和 Gizmo 等辅助能力。
### 2.4.3 当前阶段与后续扩展重点
基于上述主体能力,当前项目的后续重点已经集中到体积渲染扩展上。仓库中的体积渲染原型已经围绕 NanoVDB 数据读取、GPU 上传、体数据访问、光线步进、空域跳过和体积阴影等环节开展实现与验证,并正在向现有引擎主线进一步集成。也就是说,本文后续章节所讨论的体积渲染,并不是脱离现有项目背景的单独算法实现,而是建立在当前渲染引擎主体基础之上的高级扩展能力。
## 2.5 本章小结
本章从渲染引擎的发展脉络出发,说明了实时渲染引擎如何从单一绘制程序演进为集资源、场景、渲染、脚本和编辑器于一体的综合平台;随后对 Unity、Unreal Engine 和 Godot 等主流引擎的典型特点进行了概括,并分析了现代游戏引擎在体积特效支持方面的共同趋势;在此基础上,又结合当前项目的真实实现情况,对本文所涉及的渲染引擎主体能力和当前阶段重点进行了总体介绍。

View File

@@ -0,0 +1,143 @@
# 第五章 渲染引擎核心模块设计与实现
上一章已经对渲染引擎的总体分层、模块划分和数据流关系进行了说明。本章进一步下沉到运行时主体,围绕当前项目中已经形成的几个核心模块展开分析,重点说明这些模块在工程实现中的职责边界、关键数据结构和协同方式。结合现有代码实现,本章主要讨论 RHI 抽象层、资源系统、场景与组件系统、渲染主链、模型材质与着色器系统、多光源与简单阴影,以及 C# 脚本系统。它们共同构成了当前渲染引擎的主体能力,也是后续编辑器工作流和体积渲染模块接入的基础。
## 5.1 RHI 抽象层设计与实现
### 5.1.1 抽象目标与接口边界
渲染引擎的底层必须直接面对图形后端差异。不同图形 API 在资源创建方式、命令提交模型、描述符组织形式以及状态切换机制上均存在明显区别。如果上层渲染模块直接依赖某一个后端实现,那么渲染主链、资源绑定和管线状态组织都将与具体平台高度耦合,不利于后续扩展和维护。因此,本项目在底层建立了 RHIRendering Hardware Interface抽象层将图形设备能力统一为一组稳定接口使上层模块更多围绕“渲染什么”和“如何组织渲染阶段”来展开而不是反复处理后端 API 细节。
从当前实现看,`RHIDevice` 是这一抽象层的核心入口。它统一提供缓冲、纹理、交换链、命令列表、命令队列、着色器、管线状态、管线布局、同步栅栏、采样器、渲染通道、帧缓冲、描述符池、描述符集以及各类资源视图的创建接口。这样一来,渲染层在组织离屏纹理、深度表面、阴影图、材质资源绑定和绘制命令时,都可以基于统一对象模型展开。
### 5.1.2 多后端统一封装方式
当前项目的 RHI 已经形成了多后端组织结构。在工程构建层面,`engine/CMakeLists.txt` 中已经纳入了 `D3D12``OpenGL``Vulkan` 三套后端实现,以及与之对应的缓冲、纹理、资源视图、交换链、命令队列、命令列表、描述符、管线状态和截图支持等对象。对应地,`RHIFactory` 负责根据 `RHIType` 或字符串名称创建目标后端设备,从而将设备实例化过程与上层运行逻辑解耦。
这种设计并不是简单追求“支持多个 API”更重要的是建立统一的资源语义。例如上层不再分别讨论 D3D12 的描述符堆、OpenGL 的纹理单元或 Vulkan 的描述符集布局,而是通过统一的缓冲、纹理、采样器、描述符集和资源视图概念组织资源绑定;同样,上层也不直接处理各后端的原生命令对象,而是通过命令队列和命令列表接口完成绘制、状态切换与结果提交。当前项目的体积渲染研究阶段以 D3D12 为重点推进,但从引擎主体架构看,多后端 RHI 已经为引擎保留了较好的平台弹性。
### 5.1.3 RHI 对上层模块的支撑作用
RHI 抽象层在整个引擎中承担的是“运行时图形基础设施”的角色。资源系统最终生成的网格、纹理、材质常量和着色器变体,都需要落到 RHI 对象上;渲染主链中的主场景绘制、阴影绘制、对象 ID 绘制、后处理和最终输出,也都依赖 RHI 提供的离屏表面、命令提交和资源状态切换能力编辑器视口同样是通过对渲染表面的申请和复用接入渲染主链的。可以说RHI 为整个渲染引擎提供了统一而稳定的底层执行面,是后续所有高层模块成立的前提。
## 5.2 资源系统设计与实现
### 5.2.1 项目资源组织方式
一个可持续使用的渲染引擎不能只依赖运行时直接读取源文件,而必须建立源资源、导入信息和运行时产物之间的清晰关系。当前项目采用了 `Assets + .meta + Library` 的组织方式。`Assets` 目录保存模型、纹理、材质、着色器、场景等源文件;`.meta` 文件为每个资源记录稳定的 GUID 和导入相关信息;`Library` 目录则缓存导入后的 artifact供运行时和编辑器直接使用。
`AssetDatabase` 是这一组织方式的核心实现。它内部区分 `SourceAssetRecord``ArtifactRecord` 两类记录前者描述源资源路径、GUID、导入器名称、版本号、源文件哈希、`.meta` 哈希等信息,后者描述 artifactKey、主产物路径、资源类型、依赖项和主资源 localID 等内容。通过这种方式,资源的“工程身份”和“运行时形态”被明确分离,避免了项目规模扩大后路径变化、重复导入和资源引用失效等问题。
### 5.2.2 资源导入与 artifact 缓存
当前资源系统并不是简单地把文件复制到运行时目录,而是通过导入器将不同类型资源转换为适合引擎使用的中间产物。`AssetDatabase` 中已经实现了贴图、材质、模型、着色器、UI 文档等导入入口,并为每个 artifact 记录来源文件、依赖文件、文件尺寸和写入时间等信息。这样在执行 `Refresh``EnsureArtifact``ReimportAsset``ReimportAllAssets` 时,系统就可以根据源文件哈希、`.meta` 哈希、导入器版本以及依赖是否变化来判断是否需要重新导入。
在这一基础上,`AssetImportService` 对外进一步封装了项目资源维护流程。它负责初始化项目根目录、引导工程资源、刷新数据库、清理或重建缓存,并维护一份最近导入状态快照,包括当前操作、目标路径、成功与否、耗时以及本次导入和清理的资源数量。这样的封装一方面降低了上层模块直接操作 `AssetDatabase` 的复杂度,另一方面也为编辑器中的资源刷新、重导入和状态显示提供了统一接口。
### 5.2.3 运行时资源加载与缓存管理
在 artifact 已经准备完成后,运行时仍然需要一个统一入口将资源装载为引擎对象。当前项目中的 `ResourceManager` 承担了这一职责。它内部维护资源缓存、引用计数、资源路径映射、资源加载器注册表、异步加载器以及项目资源索引,并对外提供同步加载、异步加载、资源卸载、未使用资源回收和缓存刷新等能力。
值得注意的是,资源系统并没有把“项目资源定位”和“运行时资源加载”混在一起处理。前者主要由 `AssetImportService` 和项目索引负责,后者则由 `ResourceManager` 与具体 loader 负责执行。组件层中保存的通常是资源句柄、资源路径和 `AssetRef`,真正进入渲染阶段时,再由资源管理器解析为 `Mesh``Material``Shader``Texture` 等运行时对象。这样的分层使项目目录结构、资源导入规则和运行时访问机制能够各自独立演进,提高了工程实现的清晰度。
## 5.3 场景与组件系统设计与实现
### 5.3.1 Scene 与 GameObject 的层级组织
渲染引擎需要一种稳定的数据组织方式来承载场景内容。当前项目采用 `Scene - GameObject - Component` 的组织结构。其中,`Scene` 作为场景容器,负责对象创建与销毁、根节点管理、按名称或 ID 查找对象、场景序列化与反序列化,以及 `Update``FixedUpdate``LateUpdate` 等调度入口;同时,`Scene` 还提供对象创建和销毁事件,这一机制后续被脚本系统用来跟踪运行时脚本实例。
`GameObject` 则作为场景中的基本实体,维护对象 ID、UUID、名称、标签、层级、激活状态以及父子关系。每个对象默认持有 `TransformComponent`,从而形成统一的空间变换基础。通过 `SetParent``DetachFromParent``IsActiveInHierarchy` 等接口,项目已经能够表达典型的场景树结构。这样一来,无论是渲染系统进行层级遍历,还是编辑器显示 Hierarchy 结构,或者脚本逻辑查找父子对象,都可以建立在同一套对象模型之上。
### 5.3.2 基于组件的能力拼装方式
与把所有能力都堆叠到单一对象类中的做法相比,组件系统更适合渲染引擎这类需要持续扩展的工程。当前项目中的 `Component` 基类定义了 `Awake``Start``Update``FixedUpdate``LateUpdate``OnEnable``OnDisable``OnDestroy` 等基本生命周期接口,并提供启用状态控制。`GameObject` 则负责组件的添加、查找、删除以及层级中的递归查询。这样,对象本身只承担容器职责,而具体能力则由独立组件实现。
这种设计的直接收益体现在模块边界上。渲染系统只需要关注相机、光源、网格和材质相关组件;脚本系统只需要关注脚本组件和生命周期调度;编辑器则能够围绕组件列表构建属性面板。由于不同能力不再被硬编码到同一个对象类型中,引擎在扩展新组件时不会破坏既有对象模型,系统可维护性也更强。
### 5.3.3 关键渲染相关组件
当前项目中已经形成了较为完整的渲染相关组件集合。`CameraComponent` 用于描述观察参数和输出策略,包含透视与正交投影模式、视口矩形、近远裁剪面、清屏模式、相机深度、主相机标记、相机栈类型、裁剪掩码、天空盒材质以及后处理与最终颜色覆盖设置等内容。由此可见,相机在当前引擎中已经不只是一个“观察点”,而是一个完整的渲染请求配置载体。
`LightComponent` 当前支持方向光、点光和聚光三种类型,并提供颜色、强度、范围、聚光角和是否投射阴影等参数;`MeshFilterComponent` 负责维护网格资源句柄及其异步装载状态;`MeshRendererComponent` 则负责材质槽、材质路径、阴影投射与接收标记以及渲染层配置。通过 `MeshFilter + MeshRenderer` 的拆分,网格数据与绘制表现被明确分离,便于后续扩展更多绘制策略。除此之外,`ScriptComponent` 作为行为扩展入口,也已经被纳入组件体系之中,为脚本驱动场景对象提供了承载位置。
## 5.4 渲染主链设计与实现
### 5.4.1 从场景数据到渲染数据的提取
当前项目中的渲染主链并不是直接对场景树进行即时绘制,而是先将场景信息提取为更适合渲染阶段消费的中间数据。`RenderSceneExtractor` 在这一过程中承担核心职责。它首先根据当前场景和可用相机选择目标相机,构建 `RenderCameraData`,随后从场景根节点开始递归遍历对象,将满足激活状态与裁剪掩码条件的可见对象整理为 `VisibleRenderItem` 集合,并在提取完成后执行稳定排序。
除可见对象外,提取器还会同步整理场景光照信息。当前实现中,系统会先选出主方向光,再从其余光源中筛选附加光源并写入 `RenderLightingData`。这样,进入绘制阶段时,渲染系统面对的已经不是原始场景树,而是一份按当前相机视角整理好的渲染数据快照,从而避免在具体绘制过程中频繁穿透业务对象结构。
### 5.4.2 渲染请求规划与阶段拆分
在提取出场景数据之后,项目并没有立即绘制,而是进一步由 `SceneRenderRequestPlanner` 生成相机级别的渲染请求。该规划器首先收集可用相机,在处理 overrideCamera、主相机和叠加相机关系后为每个相机构造一个 `CameraRenderRequest`。这一请求对象不仅保存目标场景、相机、上下文和输出表面,还显式拆分出 `DepthOnly``ShadowCaster``MainScene``PostProcess``FinalOutput``ObjectId``OverlayPasses` 等多个阶段,使一帧的执行结构从一开始就是清晰可分的。
这种“先规划、后执行”的方式是当前渲染主链的重要特征。它带来的一个直接优势是,各种离屏渲染需求都可以在请求阶段明确下来,而不必散落在绘制代码中临时拼接。无论是阴影贴图、对象 ID、后处理链还是编辑器叠加层都能统一纳入相机请求结构中管理。对于后续继续接入体积渲染等新阶段而言这种阶段化请求模型也更容易扩展。
### 5.4.3 SceneRenderer、CameraRenderer 与当前管线
在执行层面,`SceneRenderer` 负责面向整个场景组织渲染流程,它能够根据场景、相机和目标表面构建请求列表,并为后处理和最终输出阶段准备必要的中间表面。之后,单个请求交由 `CameraRenderer` 继续处理。`CameraRenderer` 内部负责解析阴影绘制请求、调用 `RenderSceneExtractor` 生成本次绘制所需的 `RenderSceneData`,再将这些数据与目标表面一起提交给具体渲染管线执行。
当前主场景渲染采用 `RenderPipeline` 接口与 `BuiltinForwardPipeline` 实现相结合的方式。`RenderPipeline` 只规定初始化、销毁和渲染三个核心动作,而 `BuiltinForwardPipeline` 则给出了当前项目的具体实现形式。从代码结构看,这一前向渲染管线已经被进一步划分为不透明、天空盒和透明三个 pass并能配合对象 ID、阴影和后处理阶段完成完整的一帧输出。这说明当前项目的渲染主链已经脱离了早期单通道绘制模式形成了更接近实际引擎运行时的阶段化执行框架。
## 5.5 模型、材质与着色器系统
### 5.5.1 模型资源与网格数据组织
模型渲染能力是当前引擎主体功能中的重要组成部分。当前项目已经能够完成 OBJ 模型的导入和渲染,而运行时模型数据主要由 `Mesh` 资源承载。`Mesh` 内部保存顶点数据、索引数据、顶点属性标记、分段信息、包围盒以及与材质和纹理的关联关系。其中,`MeshSection` 进一步描述了每一段网格的顶点范围、索引范围和材质编号,这为多材质模型的绘制组织提供了基础。
这种网格表示方式并不依赖某一种具体模型格式,而是将导入结果统一整理为引擎内部可消费的数据结构。模型导入完成后,渲染阶段只需要根据 `MeshFilterComponent` 提供的网格资源句柄读取网格数据,再结合对应 `MeshRendererComponent` 的材质槽信息完成绘制。与此同时,网格包围盒还会参与阴影规划和可见对象组织,因此模型资源不仅服务于绘制本身,也服务于渲染流程中的其他阶段。
### 5.5.2 材质系统的参数化表达
材质系统承担的是“如何绘制对象”的职责。当前项目中的 `Material` 已经不仅仅保存一个着色器引用,而是同时维护渲染队列、渲染状态覆盖、标签、关键字集合、常量缓冲布局、纹理绑定和缓冲绑定等内容。材质可以按名称设置浮点、向量、整型、布尔、纹理和缓冲参数,并通过 `UpdateConstantBuffer` 将这些高层参数同步到运行时常量数据中。对于频繁变化的材质实例而言,`changeVersion` 还可以作为缓存失效和描述符重建的依据。
这种设计使材质在引擎中成为了连接“美术参数”和“渲染状态”的中间层。同一个着色器可以对应多份材质实例,不同实例可以在颜色、贴图、关键字、透明度、剔除方式和混合状态上表现不同,从而显著提高资源复用能力。由于材质本身也被纳入资源系统管理,因此它可以像模型和纹理一样参与导入、缓存、序列化和异步加载流程。
### 5.5.3 着色器通道与后端变体组织
着色器系统在当前项目中被设计为较强的数据驱动形式。`Shader` 资源内部包含属性描述、pass 列表、资源绑定描述、关键字声明以及不同阶段和不同后端的变体数据。每个 `ShaderPass` 可以携带固定功能状态、pass tag、资源绑定规则和关键字声明`ShaderStageVariant` 则记录着色器阶段、语言类型、后端类型、入口点、profile 以及源码或编译后的二进制数据。通过这种方式,材质属性、资源绑定和后端着色器变体不再是松散分离的,而是围绕同一份着色器资源组织起来。
在当前渲染主链中,`BuiltinForwardPipeline` 会根据材质的 render state、着色器、pass 名称和关键字签名解析或缓存管线状态对象,并按渲染队列区分不透明与透明对象绘制。与此同时,引擎还内置了 forward lit、unlit 和 skybox 等基础着色器资源。这种组织方式说明当前项目已经形成了“网格描述几何、材质描述参数与状态、着色器描述通道与资源布局”的清晰分工,能够较稳定地支撑当前模型渲染和后续功能扩展。
## 5.6 多光源与简单阴影实现
### 5.6.1 多光源数据组织
为了满足基本场景表现需求,当前引擎已经实现了多光源照明。`RenderSceneExtractor` 在提取光照数据时,会先根据场景和裁剪掩码选出主方向光,并将其写入 `RenderLightingData::mainDirectionalLight`。对于其余光源,系统会根据光源类型、影响程度和对象顺序进行排序,再从中选出最多 8 个附加光源写入 `additionalLights` 数组。当前附加光源支持方向光、点光和聚光三种类型,其中点光和聚光会额外携带位置、范围和聚光角等参数。
这种主光源加附加光源的组织方式比较符合当前项目的工程阶段。一方面,主方向光能够稳定承担场景中的整体照明和阴影来源;另一方面,附加光源数量被限制在一个可控范围内,便于前向渲染管线在常量数据和着色器循环中保持相对稳定的开销。对于偏工程设计类课题而言,这种方案在效果与复杂度之间取得了较合适的平衡。
### 5.6.2 简单方向光阴影实现
当前阴影系统以主方向光阴影为主,并未扩展到点光阴影、聚光阴影或级联阴影等更复杂形式。其核心流程由 `SceneRenderRequestPlanner` 完成规划,再由 `CameraRenderer` 与渲染主链执行。具体来说,规划器会在构建相机请求时检查当前相机是否需要规划方向光阴影,如果场景中存在可投影的主方向光,则根据相机视锥范围、观察方向和阴影投射对象的包围盒,计算出一个聚焦于当前相机可见区域的正交阴影相机,生成 `DirectionalShadowRenderPlan`,并同步填充 `ShadowCaster` 阶段的绘制请求。
在执行时,阴影阶段首先将投射阴影的对象绘制到深度表面,再在主场景绘制阶段以阴影贴图形式参与采样。`MeshRendererComponent` 中的 `castShadows``receiveShadows` 开关决定了对象是否参与阴影投射和接收。当前实现虽然属于“简单阴影”,但已经完成了从阴影请求规划、阴影图绘制到主场景采样使用的完整闭环,能够支持当前场景验证需求。
### 5.6.3 当前效果验证情况
从测试工程可以看出,当前引擎已经围绕典型渲染能力建立了一组集成测试场景。其中,`multi_light_scene``spot_light_scene` 用于验证多光源和聚光照明效果,`directional_shadow_scene` 用于验证当前方向光阴影流程,`transparent_material_scene` 用于验证透明材质绘制顺序,`backpack_scene``backpack_lit_scene` 则用于验证模型导入、材质绑定和基础光照结果。这些测试说明当前多光源与简单阴影并不是停留在接口层,而是已经能够进入实际场景输出环节。
## 5.7 C# 脚本系统设计与实现
### 5.7.1 托管运行时总体结构
在当前项目中,脚本系统已经成为渲染引擎主体的一部分,而不是附着在外部的独立工具。整个系统由 `ScriptEngine``IScriptRuntime``ScriptComponent``managed` 目录中的托管程序集共同构成。`ScriptEngine` 负责从引擎角度管理运行状态和生命周期调度,`IScriptRuntime` 用于抽象具体脚本运行时,当前实际实现为基于 Mono 的 `MonoScriptRuntime`。在托管侧,`managed/CMakeLists.txt` 会构建 `XCEngine.ScriptCore.dll``GameScripts.dll`,并将程序集与 `mscorlib.dll` 复制到输出目录以及项目的 `Library/ScriptAssemblies` 中,为运行时加载提供基础。
这一设计的关键点在于:原生引擎和托管脚本并不是彼此孤立的。原生侧维护场景对象、组件和渲染状态,托管侧则通过 `ScriptCore` 暴露出的包装类型访问这些对象。这样既保留了引擎主体在 C++ 侧的执行效率,也使场景行为逻辑具备了更灵活的扩展方式。
### 5.7.2 脚本组件与生命周期调度
`ScriptComponent` 是场景对象接入脚本系统的直接入口。它保存脚本程序集名、命名空间、类名、脚本组件 UUID 以及字段存储信息,从而能够稳定标识一个脚本实例。运行时启动后,`ScriptEngine` 会记录当前运行场景,订阅场景对象创建事件,收集场景中的脚本组件,并以“对象 UUID + 脚本组件 UUID”作为键建立脚本实例状态。之后引擎会按照 `Awake``OnEnable``Start``Update``FixedUpdate``LateUpdate` 的顺序驱动脚本生命周期;在脚本禁用、销毁或运行时停止时,再执行 `OnDisable``OnDestroy` 等清理流程。
从当前实现可以看到,脚本系统已经不只是简单地“能调一下 Update”。`ScriptEngine` 对脚本可运行状态、实例创建状态、Awake 是否调用、Start 是否待执行、是否处于启用状态等信息都做了显式跟踪,并能够在脚本类变化时重建对应状态。这使托管脚本与场景生命周期之间建立了相对完整的同步关系,也为后续编辑器字段检查、运行时重载和调试支持打下了基础。
### 5.7.3 Mono 桥接与托管 API 暴露
`MonoScriptRuntime` 负责原生引擎与托管程序集之间的真正桥接。它在初始化时加载核心程序集和应用程序集,发现继承自 `MonoBehaviour` 的脚本类,缓存生命周期方法和字段元数据,并在需要时创建、销毁和调用托管对象。当前实现还支持字段读写、字段默认值提取、`SerializeField` 标记识别,以及 `GameObject`、组件引用等对象的托管侧表示,这意味着脚本实例不只是一个黑盒,而是已经能够与原生对象系统双向同步数据。
与此同时,`XCEngine.ScriptCore` 已经提供了 `GameObject``Transform``Input``Camera``Light``MeshFilter``MeshRenderer``Debug``Time``Vector3``Quaternion` 等基础托管 API。这使场景脚本能够直接操纵对象变换、读取输入、访问常用组件并控制运行行为。对于当前渲染引擎而言C# 脚本系统的意义并不只是增加一种开发语言,而是使场景层逻辑与渲染引擎主体之间形成了更加完整的工程闭环。
## 5.8 本章小结
本章围绕当前项目已经完成的渲染引擎主体实现,对 RHI 抽象层、资源系统、场景与组件系统、渲染主链、模型材质与着色器系统、多光源与简单阴影,以及 C# 脚本系统进行了分析。可以看到,当前项目已经形成了较完整的运行时基础:底层具备多后端图形抽象能力,中层具备资源导入与缓存、场景组织与渲染请求规划能力,上层具备模型渲染、材质系统、基础光照阴影和脚本扩展能力。这些模块共同构成了当前渲染引擎的主体,也是后续编辑器工作流和体积渲染模块继续展开的工程基础。

View File

@@ -0,0 +1,123 @@
# 第六章 编辑器与引擎工作流设计与实现
上一章已经围绕渲染引擎运行时的核心模块展开分析,说明了底层图形抽象、资源管理、场景组织、渲染主链以及脚本系统的实现方式。在此基础上,本章进一步转向引擎的可视化工作界面与工程工作流部分,重点说明当前项目中的编辑器如何围绕场景编辑、资源浏览、参数调整、运行验证和脚本支撑等需求组织起来。对于本课题而言,编辑器是连接资源系统、场景系统、渲染系统和脚本系统的直接工作入口,也是展示整个系统工程完成度的重要部分。
## 6.1 编辑器在引擎中的定位
### 6.1.1 编辑器作为引擎工作界面
当前项目中的编辑器由 `Application``EditorLayer``EditorWorkspace` 以及各类面板共同构成,其初始化过程直接建立在现有引擎能力之上。编辑器启动时,系统首先完成窗口渲染器初始化,随后初始化 `ResourceManager` 并设置项目根目录,在此基础上创建 `EditorContext`、初始化脚本运行时、建立 ImGui 后端桥接,并由 `ViewportHostService` 接管编辑器视口所需的离屏渲染资源。完成这些准备后,`EditorLayer` 被挂接到应用层级系统中,编辑器界面才开始进入逐帧更新与绘制阶段。
从这一结构可以看出,编辑器直接复用引擎已有的资源系统、渲染链路、场景数据和脚本运行时能力。编辑器中的界面显示、视口输出和运行模式切换都以统一的上下文对象为中心展开,这使得编辑器天然具备“所见即当前引擎状态”的特点,也保证了论文中对编辑器功能的讨论能够真实反映项目的工程实现情况。
### 6.1.2 编辑器承担的核心任务
结合当前代码结构,编辑器主要承担以下几类任务。其一是场景编辑任务,包括场景层级浏览、对象选择、重命名、父子层级调整以及组件参数检查。其二是资源管理任务,包括 `Assets` 目录浏览、文件夹导航、资源创建、重命名、删除、移动以及导入状态反馈。其三是渲染验证任务,即通过 `Scene``Game` 两类视口把引擎渲染结果以不同用途展示出来,其中前者面向编辑过程,后者面向运行结果。其四是脚本与调试支撑任务,包括脚本程序集重建、脚本运行时重载、日志查看、错误定位以及运行模式控制。
为了支撑这些任务,编辑器内部通过 `EditorContext` 对事件总线、选择管理器、场景管理器、项目管理器、撤销管理器和视口宿主服务进行统一组织。这样一来,不同面板虽然在界面上彼此独立,但在数据层和事件层是相互联通的。例如层级面板中的对象选择会同步到 Inspector 面板Project 面板中的资源选择会切换 Inspector 的检查对象Game 视口中的输入帧又会通过事件总线进入运行时输入系统。这种组织方式使编辑器能够围绕统一编辑上下文协同工作,形成结构完整的界面系统。
### 6.1.3 编辑器与运行时的协同关系
`EditorWorkspace` 是编辑器运行阶段的核心组织者。在工作区附加阶段,系统会依次注册 `MenuBar``HierarchyPanel``SceneViewPanel``GameViewPanel``InspectorPanel``ConsolePanel``ProjectPanel` 等主要面板,同时创建 `DockLayoutController`,加载启动场景,并挂接 `PlaySessionController`。在逐帧更新阶段,工作区负责驱动异步资源加载更新、运行模式更新以及各面板自身的刷新;在界面绘制阶段,再由菜单栏、停靠布局和各面板共同完成整套编辑器界面的输出。
其中较为关键的一点,是编辑器不仅负责“编辑”,还负责把编辑状态与运行状态组织成闭环。`PlaySessionController` 在进入播放模式前会保存当前编辑态场景快照,停止播放时再恢复快照,从而把运行时验证和编辑态维护分离开来。这样既能保证 `Game` 视口中脚本逻辑、输入和运行时场景更新能够被真实执行,又能在停止运行后回到稳定的编辑状态。这一协同机制使编辑器成为引擎工作流的核心组成部分。
## 6.2 编辑器界面总体布局设计
### 6.2.1 主要界面面板组成
从当前实现来看,编辑器界面主要由顶部菜单栏与运行控制区、左侧层级面板、中部双视口区域、右侧属性检查面板以及底部控制台和项目资源面板构成。各部分职责划分明确:`MenuBar` 负责主菜单与运行模式控制,`HierarchyPanel` 用于展示当前场景对象树,`SceneViewPanel``GameViewPanel` 分别承担编辑视图与运行视图显示任务,`InspectorPanel` 负责组件和资源属性编辑,`ProjectPanel` 负责项目目录与资源浏览,`ConsolePanel` 则承担日志查看与调试辅助功能。
这种布局与当前项目已经具备的功能模块一一对应。由于本项目已经具备场景系统、组件系统、材质系统、脚本系统和基础运行模式切换能力,因此编辑器界面也自然围绕这些能力组织。用户在编辑器中完成的操作路径,基本可以概括为“在 `Project` 中选择资源,在 `Hierarchy` 中定位对象,在 `Inspector` 中调整参数,在 `Scene` 中观察编辑结果,在 `Game` 中验证运行结果,并通过 `Console` 反馈调试信息”。
### 6.2.2 停靠布局与空间组织方式
编辑器界面的整体停靠布局由 `DockLayoutController` 统一管理。当前默认布局中,左侧区域停靠 `Hierarchy`,中部区域停靠 `Scene``Game` 两个标签页,右侧区域停靠 `Inspector`,底部区域停靠 `Console``Project`。这一布局与当前工程任务之间具有较强的一致性:对象结构查看放在左侧,核心视口放在中央,参数编辑放在右侧,资源与调试信息放在底部,符合渲染引擎编辑器的基本操作习惯,也便于在论文中按照“对象编辑 - 视口验证 - 资源管理 - 调试反馈”的路径说明工作流。
在界面上方,`MenuBar` 除了主菜单外,还单独绘制了运行工具条。该工具条提供播放、暂停和单步执行三个核心按钮,并直接与当前运行模式状态联动。这样一来,场景编辑、运行控制和视口验证被组织在同一工作界面中,用户无需在不同程序之间切换即可完成从编辑到验证的全过程。
此处建议插入图 6-1“编辑器整体界面截图”重点展示顶部菜单与运行控制区、左侧 `Hierarchy`、中部 `Scene/Game` 双视口、右侧 `Inspector`、底部 `Console/Project` 的整体布局关系。
### 6.2.3 面板之间的联动关系
编辑器界面的价值不仅在于布局清晰,更在于各面板之间形成了稳定的数据联动。`HierarchyPanel` 中的选择结果会同步到选择管理器,随后 `InspectorPanel` 根据当前选中对象展示组件信息;`ProjectPanel` 中选中的材质资源则会切换 Inspector 的检查对象类型,使其进入材质资源编辑模式;`SceneViewPanel` 中通过拾取选中的对象同样会反馈到层级面板和 Inspector`ConsolePanel` 中的日志信息又能够反向服务于当前脚本和运行调试。
因此,界面布局设计本质上也是工作流设计。通过统一的编辑上下文,各面板既保持了职责分工,又避免了信息孤立,使编辑器能够围绕当前引擎形成完整的操作闭环。
## 6.3 Scene 与 Game 视口实现
### 6.3.1 视口离屏渲染接入方式
当前编辑器中的视口通过离屏渲染方式接入。`ViewportPanelContent` 在绘制 `Scene``Game` 面板时,会向 `IViewportHostService` 发起 `RequestViewport` 请求,请求中包含视口类型和当前窗口可用尺寸。`ViewportHostService` 接收到请求后,会根据目标尺寸检查或创建对应的离屏颜色缓冲、深度缓冲以及相关资源视图,并在后续渲染阶段返回可供 ImGui 显示的纹理句柄。
这种设计带来的直接好处是,编辑器视口与主窗口交换链输出相互分离。视口可以根据面板尺寸独立变化,也可以附加对象 ID 缓冲、叠加层和调试通道,而不必改变主窗口渲染流程。对于需要同时存在编辑视图和运行视图的渲染引擎而言,这种离屏接入方式具有较好的工程适配性。
### 6.3.2 Scene 视口的组织方式
`Scene` 视口服务于编辑过程,因此它采用独立于运行时场景相机的编辑器相机。`ViewportHostService` 内部维护了专用的 `SceneViewCameraState`,其中包含编辑器相机对象、相机组件和相机控制器。用户在 `SceneViewPanel` 中进行观察、缩放、平移、环绕和聚焦操作时,面板会构造 `SceneViewportInput` 并提交给 `ViewportHostService`,由后者更新场景视口相机状态。
在渲染阶段,`Scene` 视口会先围绕当前编辑器相机生成渲染请求,再结合对象选择结果和叠加信息构造额外的编辑器渲染计划。当前实现中,`Scene` 视口已经支持对象 ID 读回拾取、选中轮廓高亮、网格显示、变换 Gizmo、方向指示器以及编辑器叠加层缓存等能力。这说明 `Scene` 视口是一个建立在引擎渲染链路之上的可交互编辑视图。
### 6.3.3 Game 视口的组织方式
`Scene` 视口不同,`Game` 视口主要用于展示运行时场景输出结果。它不使用独立的编辑器相机,而是直接依赖当前场景中的可用相机,由 `SceneRenderer` 使用正常的运行时渲染流程完成输出。若场景中不存在可用相机,则 `Game` 视口会明确给出无法渲染的状态反馈。由此可见,`Game` 视口本质上是运行时渲染结果在编辑器中的嵌入显示窗口。
`GameViewPanel` 在显示画面的同时,还会把当前帧的键盘、鼠标位置、按键状态和滚轮状态整理为 `GameViewInputFrameEvent` 并发布到事件总线中。随后 `PlaySessionController` 在播放模式下接收该事件,并将其转换为 `InputManager` 可识别的输入数据。因此,`Game` 视口不仅承担画面展示功能,还承担了编辑器到运行时输入桥接的作用。
此处建议插入图 6-2“Scene 视口与 Game 视口对照截图”,用于展示二者在观察目的、交互方式和输出内容上的差异。
### 6.3.4 视口与渲染主链的关系
无论是 `Scene` 视口还是 `Game` 视口,当前项目都没有为其单独实现一套完全不同的渲染器。两类视口都通过 `ViewportHostService` 接入现有的 `SceneRenderer` 和渲染请求生成流程,只是在相机来源、附加渲染通道和输入处理方式上有所区别。这样做的意义在于,编辑器视口所展示的结果与引擎实际渲染能力保持一致,既减少了重复实现,也使视口验证具有更高的可信度。
## 6.4 编辑器交互与调试辅助能力
### 6.4.1 对象选择与场景编辑交互
在场景编辑过程中,对象选择是最基础也是最频繁的交互。当前项目同时提供了层级树选择和视口直接拾取两种方式。前者由 `HierarchyPanel` 完成,用户可以在对象树中展开、选中、重命名对象,并通过拖放改变父子层级关系;后者则由 `Scene` 视口中的对象 ID 拾取流程完成,系统通过离屏对象 ID 纹理读回鼠标位置对应的实体编号,再同步到选择管理器。
对象选中之后,相关状态会立即反馈到其他面板。`InspectorPanel` 会切换到对应对象的组件检查界面,`Scene` 视口会刷新轮廓高亮和 Gizmo层级面板也会保持当前选中状态。这种以统一选择状态为中心的交互方式使用户既可以从结构树编辑场景也可以直接在视口中完成对象定位与操作。
### 6.4.2 变换 Gizmo 与辅助显示
为了提高场景编辑效率,`Scene` 视口实现了较为完整的变换交互辅助能力。当前代码中已经包含平移、旋转、缩放三类 Gizmo以及用于协调三者状态的 `SceneViewportTransformGizmoCoordinator`。用户可在视口中直接拖动控制柄修改对象变换,并在交互过程中结合撤销管理器记录编辑操作。这使编辑器具备了较为明确的几何编辑能力,而不只是参数填写工具。
除变换 Gizmo 外,视口中还实现了场景网格、选中轮廓、方向指示器和 HUD 叠加层等辅助显示内容。网格用于增强空间尺度感选中轮廓用于强化对象定位方向指示器用于辅助相机朝向理解HUD 则用于补充编辑状态显示。这些辅助内容虽然不直接参与运行时渲染结果,但对于编辑器的可用性和调试效率具有重要作用。
此处建议插入图 6-3“Scene 视口交互界面截图”,重点展示对象选中轮廓、场景网格和变换 Gizmo 的叠加效果。
### 6.4.3 导航控制与交互状态管理
`SceneViewPanel` 还围绕编辑视口建立了较完整的导航状态管理机制。当前支持的交互包括观察、平移、环绕、聚焦选中对象以及基于键鼠组合的视角移动。面板内部会根据鼠标按键状态、快捷键状态、工具模式和当前是否存在激活中的 Gizmo 来判断当前交互应解释为视口导航还是对象编辑,并进一步决定是否由 ImGui 捕获鼠标或键盘输入。
这种交互状态管理对于编辑器而言十分关键。如果缺少这一层协调,视口观察、对象拖拽、快捷键切换和界面点击很容易相互冲突。当前项目通过专门的交互解析与导航状态更新流程,把不同输入意图区分开来,使 `Scene` 视口能够在复杂编辑状态下保持稳定响应。
## 6.5 资源与脚本工作流支撑
### 6.5.1 项目资源浏览与导入状态反馈
`ProjectPanel` 是当前编辑器中承担工程资源工作流的主要界面。该面板左侧以树状结构展示项目文件夹,右侧以资源网格方式显示当前目录内容,并提供面包屑导航、搜索、右键上下文菜单、拖放移动、重命名以及新建文件夹和材质资源等功能。对于图像资源,面板还可以直接显示缩略图预览,从而提高资源识别效率。
除了资源浏览本身,`ProjectPanel` 还会显示资源导入状态和场景加载状态。其工具栏可以读取 `ResourceManager::GetProjectAssetImportStatus()` 返回的导入快照,并把当前导入操作、耗时和成功与否展示出来;同时也会读取 `SceneManager` 维护的场景加载进度信息,用于反馈当前场景是否仍有异步资源尚未完成。这使资源管理不再停留在文件浏览层面,而是能够反映资源进入运行时之前的真实处理状态。
### 6.5.2 场景文档与运行模式工作流
编辑器中的场景工作流主要由 `SceneManager``PlaySessionController` 共同完成。`SceneManager` 负责场景创建、对象创建与删除、对象复制与粘贴、层级移动、场景加载与保存,以及场景脏标记维护;同时还支持场景快照捕获与恢复。对于编辑工作而言,这意味着场景既是可视化内容容器,也是可以被保存、恢复和回滚的文档对象。
在运行模式切换方面,`PlaySessionController` 提供了播放、停止、暂停、恢复和单步执行等控制能力,并通过事件总线与顶部运行工具条联动。进入播放模式前,控制器会保存当前编辑场景快照并清空撤销历史,然后启动运行时循环;停止播放时,再恢复编辑态快照并回到普通编辑模式。这一机制把“编辑场景”和“验证运行结果”衔接在一起,是当前编辑器工作流中最能体现工程闭环的一部分。
### 6.5.3 脚本程序集与调试支撑
当前项目已经具备基于 C# 的脚本系统,因此编辑器也必须承担脚本工作流支撑任务。`Application` 在初始化编辑器时会尝试从 `Library/ScriptAssemblies` 目录加载 `XCEngine.ScriptCore.dll``GameScripts.dll` 和相关运行时程序集;如果程序集不存在,编辑器会明确记录状态信息并禁用脚本类发现。与此同时,编辑器还提供了脚本程序集重建与脚本运行时重载能力,使脚本修改后能够重新进入编辑器工作流。
在对象检查层面,`InspectorPanel` 通过 `ComponentEditorRegistry``Transform``Camera``Light``MeshFilter``MeshRenderer``ScriptComponent` 等组件注册了对应的编辑器从而使对象属性面板不仅能显示组件数据还能承担组件参数编辑和脚本组件检查的任务。对于材质资源Inspector 还支持单独的材质资源检查与保存流程,这进一步体现了编辑器对资源系统的深入接入。
在调试支撑方面,`ConsolePanel` 提供了日志级别筛选、折叠显示、搜索、清空、错误暂停、详情查看和源位置打开等功能。控制台不仅能够查看普通日志,还能够在播放模式中配合错误暂停机制辅助脚本调试。这样一来,脚本编译、运行验证和错误定位便能够在同一个编辑器环境中完成。
此处建议插入图 6-4“Project、Inspector 与 Console 联动截图”,用于展示资源浏览、属性编辑与日志调试在同一工作流中的组织方式。
## 6.6 本章小结
本章围绕当前项目中的编辑器与工作流实现进行了分析。可以看到,编辑器是建立在渲染引擎运行时、资源系统、场景系统和脚本系统之上的统一工作界面。在结构上,系统通过 `Application``EditorContext``EditorWorkspace` 和各类面板完成了编辑器的整体组织在界面上形成了菜单栏、双视口、层级面板、属性面板、项目资源面板和控制台共同构成的工作布局在功能上又通过对象拾取、Gizmo 交互、离屏视口、播放控制、资源导入反馈、脚本重载和日志调试等能力,把编辑、验证与调试串联成完整流程。
对于本课题后续的体积渲染扩展而言,编辑器部分的意义在于提供了稳定的验证和展示平台。无论后续体积渲染模块以何种方式接入现有渲染主链,都可以借助当前编辑器的视口、资源工作流和调试能力完成参数调整、效果观察和结果记录。因此,编辑器与工作流部分不仅体现了当前渲染引擎的工程完整性,也为后续高级渲染模块的并入提供了必要支撑。

View File

@@ -0,0 +1,105 @@
# 第四章 渲染引擎总体架构设计
前两章已经分别给出了渲染引擎相关技术基础和体积渲染理论基础。在此基础上本章从系统设计层面对当前项目中的渲染引擎进行说明重点讨论引擎设计目标、总体分层方式、核心模块职责边界、模块之间的协同关系以及体积渲染扩展在整体架构中的位置。本章的任务是把整个系统的组织方式讲清楚为后续第5章、第6章和第7章的具体实现分析建立统一框架。
## 4.1 引擎设计目标
### 4.1.1 形成完整的渲染引擎工作框架
本项目的架构设计以渲染引擎为主体展开,目标是在统一框架下组织图形接口、资源管理、场景组织、渲染执行、脚本扩展和编辑器工作流等能力。这样的架构安排使系统能够围绕同一套运行时基础持续扩展,并将图形绘制、资源处理和工具能力组织在同一体系中。对工程设计类课题而言,完整框架本身就是系统价值的重要组成部分。
### 4.1.2 建立稳定的基础场景渲染闭环
渲染引擎的总体架构首先需要保证基础场景渲染闭环成立,即资源能够进入运行时,场景能够组织对象,相机与光照能够驱动渲染流程,最终结果能够稳定输出到窗口或视口。只有这一闭环清晰成立,模型渲染、材质绑定、多光源、阴影以及后续更高层渲染特性才有统一的承载基础。因此,基础场景渲染闭环是架构设计中的首要目标之一。
### 4.1.3 兼顾运行时能力与编辑器工作流
当前项目不仅包含运行时系统,也包含围绕资源浏览、场景编辑、参数调整和结果验证构建的编辑器工作流。因此,在总体架构设计中,需要同时考虑运行时执行链路和工具侧使用链路,使资源、场景、脚本和渲染结果能够在同一体系中流转。这样的结构有助于保持项目内部数据的一致性,也有助于后续论文从工程整体角度展开分析。
### 4.1.4 为高级渲染特性扩展预留架构空间
渲染引擎的架构设计不仅服务于当前已完成的基础能力,也需要为后续扩展保留空间。体积渲染作为当前项目中最重要的高级渲染扩展,需要依附既有资源系统、渲染主链和编辑器验证能力接入系统。因此,在总体架构层面,需要预留新资源类型、新渲染阶段和新调试入口能够自然接入的位置,使扩展能够沿既有资源、渲染和验证链路进入系统。
## 4.2 引擎总体分层架构
结合当前项目的代码结构和功能组织方式,本文将渲染引擎总体概括为平台层、图形接口抽象层、资源与场景层、渲染组织层以及脚本与编辑器层五个主要层级。在此之外,系统中还存在内存、线程、调试、音频和 UI 等支撑模块,但从论文主体展开角度看,上述五层构成了最核心的结构主线。
### 4.2.1 平台层
平台层位于整个系统的底部,负责窗口、消息循环、输入接入、时间管理和文件系统访问等基础运行环境。它决定了主循环如何驱动系统更新,也决定了渲染结果最终如何呈现到屏幕上。平台层本身不直接组织场景和资源,但它为上层所有模块提供统一的执行起点。
### 4.2.2 图形接口抽象层
图形接口抽象层建立在平台层之上,用于统一封装不同图形后端的资源模型和命令执行方式。其核心作用是把设备、缓冲、纹理、资源视图、命令列表、交换链和管线状态等底层对象收敛为稳定接口,使上层系统能够围绕统一的图形资源和执行语义展开设计。对于渲染引擎而言,这一层是保持后续模块结构清晰的重要基础。
### 4.2.3 资源与场景层
资源与场景层负责回答“系统中有哪些内容”以及“这些内容如何进入运行时”两个问题。资源部分承担模型、纹理、材质、着色器和场景文件等工程资源的导入、缓存和装载职责,场景部分则负责对象层级、组件挂接和运行时内容组织。通过这一层,工程目录中的资源可以被转化为可参与渲染和逻辑更新的场景内容。
### 4.2.4 渲染组织层
渲染组织层位于资源与场景层之上,负责把场景状态整理为可执行的渲染流程。它的职责并不在于保存内容本身,而在于围绕当前相机、光照和输出目标,对可见对象、渲染阶段和表面资源进行统一规划。这样一来,渲染过程就不再是临时的逐对象绘制,而是能够形成结构清晰的主链执行框架。
### 4.2.5 脚本与编辑器层
脚本与编辑器层更接近开发工作流。脚本系统为运行时对象提供行为扩展入口,编辑器则为场景编辑、资源管理和渲染结果验证提供可视化工作界面。这一层并不脱离引擎主体独立存在,而是建立在已有资源、场景和渲染能力之上,使系统从“能够运行”进一步扩展到“能够组织工作流和验证结果”。
## 4.3 核心模块职责划分
从当前项目的功能组织方式看,引擎主体的核心模块可以归纳为 `RHI``Resources / AssetDatabase``Scene / Components``Rendering``Scripting``Editor` 六个部分。它们之间的区别不在于是否都与渲染相关,而在于各自承担的职责边界不同。
### 4.3.1 `RHI`
`RHI` 模块承担底层图形接口抽象职责,负责统一不同图形后端的资源对象和命令执行语义。它解决的是“底层如何与 GPU 交互”的问题,是整个渲染引擎的图形基础设施。
### 4.3.2 `Resources / AssetDatabase`
资源模块负责源资源记录、导入产物生成、缓存管理和运行时资源定位。它解决的是“工程资源如何稳定进入引擎”的问题,是内容进入运行时之前的组织基础。
### 4.3.3 `Scene / Components`
场景与组件模块负责运行时对象的层级组织与能力挂接。它解决的是“运行时内容如何表达”的问题,为渲染系统、脚本系统和编辑器提供统一的对象模型。
### 4.3.4 `Rendering`
渲染模块负责把场景状态转换为阶段化的渲染执行过程。它解决的是“图像如何被生成”的问题,是场景内容通向最终画面的核心组织层。
### 4.3.5 `Scripting`
脚本模块负责托管逻辑与原生运行时之间的桥接。它解决的是“对象行为如何扩展”的问题,使运行时系统具备更灵活的逻辑组织能力。
### 4.3.6 `Editor`
编辑器模块负责围绕场景、资源、脚本和渲染结果建立可视化工作界面。它解决的是“系统如何被编辑、观察和验证”的问题,是当前项目工程完整性的重要体现。
## 4.4 模块协同与数据流
总体架构是否清晰,最终体现在模块之间的数据流是否顺畅。结合当前项目的组织方式,可以从资源流、渲染流、工具流和脚本联动四个角度理解模块协同关系。
### 4.4.1 资源从工程目录进入运行时的路径
工程目录中的模型、纹理、材质、着色器和场景文件首先经过资源系统的扫描、导入和缓存,随后被装载为运行时可直接使用的资源对象。这些资源再进一步被场景中的对象和组件引用,最终参与渲染和逻辑更新。由此形成从工程资源到运行时内容的第一条主线。
### 4.4.2 场景状态到渲染数据的转换关系
场景层负责维护对象层级、组件状态和运行时内容,渲染层则根据当前相机与光照条件,从场景状态中提取出本帧需要执行的渲染数据,并进一步组织为分阶段的渲染请求。经过这一转换,运行时内容状态被映射为可以提交给图形接口执行的绘制流程。
### 4.4.3 编辑器视口与渲染主链的接入关系
编辑器中的视口显示建立在现有渲染主链基础上的离屏接入方式之上。编辑器通过视口把场景画面、运行画面和调试叠加信息重新组织为可观察结果,再反馈到用户界面中。这样,工具侧能够直接复用引擎主体能力,同时保证编辑器中所见结果与系统实际渲染结果保持一致。
### 4.4.4 脚本更新、场景状态与渲染结果之间的联动
脚本系统在运行时对对象和组件状态进行更新,这些变化首先作用于场景层,随后在下一帧被渲染层提取并反映到最终画面中。由此形成“脚本驱动场景,场景驱动渲染”的联动关系。对编辑器而言,这种联动关系还意味着运行验证结果可以被直接观察和调试,从而进一步闭合整个工作流。
## 4.5 体积渲染模块在总体架构中的位置
从总体架构角度看,体积渲染模块位于渲染组织层的高级扩展位置。它一方面依赖图形接口抽象层提供缓冲、纹理、资源视图和命令执行能力,另一方面依赖资源系统完成体数据文件的组织与装载,同时还需要借助现有渲染主链和编辑器视口完成参数调试与结果验证。因此,体积渲染是建立在引擎主体基础之上的高级扩展。
结合当前项目进展,体积渲染部分已经完成独立原型阶段的关键流程验证,包括 NanoVDB 数据读取、GPU 数据上传、体数据访问、光线步进、空域跳过和体积阴影等内容。其后续目标是进一步并入现有渲染主链,使其成为渲染引擎高级渲染能力的一部分。这样的安排与当前总体架构保持一致,也使体积渲染章节能够自然建立在引擎主体章节之后展开。
## 4.6 本章小结
本章从系统设计层面对当前项目中的渲染引擎进行了分析,明确了引擎架构的主要目标,包括形成完整工作框架、建立基础场景渲染闭环、兼顾运行时能力与编辑器工作流,以及为高级渲染特性扩展预留空间。在此基础上,将系统概括为平台层、图形接口抽象层、资源与场景层、渲染组织层以及脚本与编辑器层五个主要层级,并进一步说明了 `RHI``Resources / AssetDatabase``Scene / Components``Rendering``Scripting``Editor` 六个核心模块的职责边界。
同时,本章还从资源进入运行时、场景状态转化为渲染数据、编辑器视口接入渲染主链以及脚本驱动场景与渲染结果联动四个角度说明了模块之间的协同关系,并明确了体积渲染模块在总体架构中的位置。基于这一总体架构,下一章将进一步转入渲染引擎核心模块设计与实现的具体分析。

View File

@@ -0,0 +1,513 @@
# 毕业设计论文正文大纲
## 一、正文总体写法
这篇论文的正文主线应当明确为:
1. 以渲染引擎的设计与实现为主体。
2. 以体积渲染作为渲染引擎当前阶段最重要的高级渲染扩展来展开。
3. 体积渲染理论必须单独成章。
4. NanoVDB 体积渲染模块的工程实现必须单独成章。
同时第1章绪论不能脱离现有开题报告另起炉灶。更合理的写法是
1. 继承开题报告中关于体积特效、实时体积渲染、NanoVDB 和 DirectX 12 的背景与问题意识。
2. 结合当前项目的真实工程进展,把开题阶段的目标落到“渲染引擎主体 + 体积渲染扩展”的实际形态上。
3. 让绪论看起来像开题报告的自然展开和工程落地版本,而不是完全换题重写。
因此,正文结构不能写成“只围绕体积渲染展开”,也不能把体积渲染理论简单塞进技术基础里带过去。更合适的结构是:
1. 绪论
2. 渲染引擎发展现状与本课题引擎概述
3. 体积渲染理论基础
4. 渲染引擎总体架构设计
5. 渲染引擎核心模块设计与实现
6. 编辑器与引擎工作流设计与实现
7. 基于 NanoVDB 的体积渲染模块设计与实现
8. 系统测试与结果分析
9. 总结与展望
---
## 二、字数分配建议
按正文约 2 万字估算,可按下表控制:
| 章节 | 建议字数 |
| ------------------------------------------- | ---------: |
| 第1章 绪论 | 1800 |
| 第2章 渲染引擎发展现状与本课题引擎概述 | 1600 |
| 第3章 体积渲染理论基础 | 2500 |
| 第4章 渲染引擎总体架构设计 | 2400 |
| 第5章 渲染引擎核心模块设计与实现 | 3500 |
| 第6章 编辑器与引擎工作流设计与实现 | 2900 |
| 第7章 基于 NanoVDB 的体积渲染模块设计与实现 | 3100 |
| 第8章 系统测试与结果分析 | 1800 |
| 第9章 总结与展望 | 700 |
| 合计 | 20000 左右 |
这个分配的含义是:
1. 引擎主体部分占正文大头。
2. 体积渲染理论单独给出完整章节。
3. 体积渲染实现章节作为高级扩展重点展开。
---
## 三、正文结构设计
## 第1章 绪论
### 1.1 课题背景
这一节应直接承接开题报告中的选题背景,建议写成下面三点:
1. 随着实时图形应用复杂度不断提升,渲染引擎已经从单一绘制程序发展为集图形接口抽象、资源管理、场景组织、材质与光照、脚本运行时、编辑器工具链于一体的综合系统,是支撑现代图形应用开发的重要基础。
2. 云、雾、烟、火等体积特效已经成为现代实时图形表现中的常见内容,但实时体积渲染涉及参与介质中的吸收、散射、透射率累积与大量采样计算,在效果质量与实时性能之间始终存在明显约束。
3. OpenVDB 与 NanoVDB 为稀疏体数据表达提供了较成熟的技术路径,其中 NanoVDB 更适合 GPU 访问同时DirectX 12 提供了更底层的资源与命令控制能力,适合作为体积特效实现与渲染引擎扩展的技术基础。
### 1.2 课题意义
这一节可以从两个层面展开:
1. 体积渲染层面的意义
将 NanoVDB 稀疏体数据与 DirectX 12 图形 API 相结合,探索实时体积特效渲染的实现路径,为云、雾、烟等参与介质效果的工程实现提供参考。
2. 渲染引擎层面的意义
在渲染引擎平台上完成资源、场景、材质、光照、脚本与编辑器协同设计,并在此基础上扩展体积渲染能力,有助于体现工程设计类毕业设计的系统性与完整性。
### 1.3 本课题的主要内容
这一节应在不偏离开题报告目标的前提下,明确当前课题已经形成的真实工作结构,建议写成两部分:
1. 渲染引擎主体部分
包括图形接口抽象层、渲染主链、资源系统、场景与组件系统、模型与材质系统、多光源与阴影、C# 脚本系统以及编辑器工作流支撑等内容。
2. 体积渲染扩展部分
包括体积渲染理论分析、NanoVDB 稀疏体数据加载、GPU Buffer 访问方式、体积光线步进流程、跳空优化和体积阴影实现等内容。
### 1.4 本文的主要工作
这一节可直接概括本文已经完成的主要工程工作,例如:
1. 完成了渲染引擎的总体架构设计与模块划分。
2. 实现了 RHI、资源系统、场景组件系统和渲染主链等核心运行时模块。
3. 实现了模型渲染、材质系统、多光源和简单阴影等基础渲染能力。
4. 实现了 C# 脚本系统以及编辑器视口与调试支撑能力。
5. 完成了基于 NanoVDB 的体积渲染模块原型设计与实现,当前正在向主引擎主线集成收尾推进。
### 1.5 论文结构安排
这一节可以自然说明全文安排逻辑:
1. 前两部分先交代渲染引擎发展现状与主流引擎分析,以及体积渲染理论基础。
2. 中间几章集中展开渲染引擎主体的架构设计、核心模块实现以及编辑器工作流实现。
3. 后续章节再集中说明基于 NanoVDB 的体积渲染模块设计、系统测试与实验分析。
---
## 第2章 渲染引擎发展现状与本课题引擎概述
这一章不再写成通用技术教材式基础,而是从发展脉络、主流引擎能力对比、体积特效支持现状和本课题引擎当前完成情况四个角度,先把“当前行业在做什么”以及“本课题已经做到了什么”交代清楚。
### 2.1 渲染引擎的发展历程与能力演进
建议写:
1. 从单一绘制程序到综合引擎平台的发展过程
2. 从固定管线到可编程管线后的能力扩展
3. 从运行时渲染到资源、脚本、编辑器协同工作流的演进
### 2.2 当前主流渲染引擎的特点与对比分析
建议写:
1. Unity 的组件化、资源工作流与编辑器体系
2. Unreal Engine 的高质量渲染能力与工具链体系
3. Godot 等开源引擎的开放性与轻量化特点
4. 主流引擎在架构、编辑器、脚本与渲染扩展方面的共性
### 2.3 主流引擎中体积特效的支持现状
建议写:
1. 体积雾、体积云、体积光等效果在实时图形中的应用
2. 主流引擎中体积特效与光照、材质、场景系统的结合方式
3. 实时体积渲染在效果质量与性能上的主要矛盾
### 2.4 本课题渲染引擎的总体介绍
建议写:
1. 本项目渲染引擎的定位与整体组成
2. 当前已实现的核心能力与工作流闭环
3. 体积渲染模块在当前项目阶段中的位置
### 2.5 本章小结
---
## 第3章 体积渲染理论基础
这一章必须单独成章。
建议这一章主要吸收 `docs/plan/毕设` 下三份体积渲染理论笔记的内容,但写法要服务于后续工程实现。
### 3.1 参与介质与体积渲染基本概念
建议写:
1. 参与介质的定义
2. 吸收、散射、透射率
3. 体积颜色形成的基本原因
### 3.2 比尔-朗伯定律与光线透射
建议写:
1. 比尔-朗伯定律的物理含义
2. 消光系数、吸收系数、散射系数的关系
3. 透射率在体积渲染中的作用
### 3.3 体绘制方程与单次散射近似
建议写:
1. 体绘制方程的基本形式
2. 单次散射的含义
3. 相位函数的作用
4. 为什么在实时系统中常采用简化模型
### 3.4 光线步进算法原理
建议写:
1. ray marching 的基本思想
2. 正向步进与反向步进
3. 步长、最大步数与误差控制
4. 提前终止和抖动等常见优化
### 3.5 稀疏体数据与空域跳过思想
建议写:
1. 稠密体素网格的问题
2. 稀疏体数据结构的意义
3. 空区域跳过对实时性的作用
### 3.6 本章小结
这一章的作用是为第7章的 NanoVDB 体积渲染实现做理论铺垫。
---
## 第4章 渲染引擎总体架构设计
这一章的任务是把整个渲染引擎的组织方式讲清楚,重点回答系统如何分层、各模块职责如何划分、运行时与编辑器如何协同,以及体积渲染扩展应当放在什么位置。
### 4.1 引擎设计目标
建议写:
1. 形成完整的渲染引擎工作框架。
2. 建立稳定的基础场景渲染闭环。
3. 兼顾运行时能力与编辑器工作流。
4. 为高级渲染特性扩展预留架构空间。
### 4.2 引擎总体分层架构
建议配图,按层写:
1. 平台层
2. 图形接口抽象层
3. 资源与场景层
4. 渲染组织层
5. 脚本与编辑器层
### 4.3 核心模块职责划分
建议围绕真实项目模块写其职责边界:
1. `RHI`
2. `Rendering`
3. `Resources / AssetDatabase`
4. `Scene / Components`
5. `Scripting`
6. `Editor`
### 4.4 模块协同与数据流
建议明确写清:
1. 资源从工程目录进入运行时的路径
2. 场景状态到渲染数据的转换关系
3. 编辑器视口与渲染主链的接入关系
4. 脚本更新、场景状态与渲染结果之间的联动
### 4.5 体积渲染模块在总体架构中的位置
这一节必须明确:
1. 体积渲染作为高级渲染扩展的接入层次。
2. 它对 RHI、资源系统、渲染主链与编辑器能力的依赖关系。
3. 当前原型验证与后续正式集成之间的关系。
### 4.6 本章小结
---
## 第5章 渲染引擎核心模块设计与实现
这一章集中写引擎主体能力,把“引擎真正做出来了什么”放在一章里讲清楚。
### 5.1 RHI 抽象层设计与实现
建议写:
1. 多后端抽象思路
2. `D3D12 / OpenGL / Vulkan` 的统一封装
3. Buffer、Texture、ResourceView、Pipeline 等关键对象
### 5.2 资源系统设计与实现
建议写:
1. `Assets + .meta + Library` 组织方式
2. `AssetDatabase`
3. artifact 缓存与运行时加载
### 5.3 场景与组件系统设计与实现
建议写:
1. `Scene`
2. `GameObject`
3. `Component`
4. `CameraComponent`
5. `LightComponent`
6. `MeshFilterComponent`
7. `MeshRendererComponent`
### 5.4 渲染主链设计与实现
建议写:
1. `SceneRenderer`
2. `CameraRenderer`
3. `RenderPipeline`
4. 渲染请求生成与执行流程
### 5.5 模型、材质与着色器系统
建议写:
1. OBJ 模型加载
2. `Mesh / Material / Shader` 的组织关系
3. 材质参数与 shader 资源绑定
### 5.6 多光源与简单阴影实现
建议写:
1. 主光与附加光组织方式
2. 光照计算流程
3. 当前简单阴影实现方式
### 5.7 C# 脚本系统设计与实现
建议写:
1. `ScriptEngine`
2. `ScriptComponent`
3. 托管与原生运行时桥接
4. 生命周期调度
### 5.8 本章小结
---
## 第6章 编辑器与引擎工作流设计与实现
这一章专门写引擎的可视化工作界面和工具链闭环,也是展示整个系统操作界面的主要章节。
### 6.1 编辑器在引擎中的定位
建议写:
1. 编辑器是引擎工作流中的可视化工作界面
2. 编辑器承担场景查看、资源管理、脚本调试和渲染验证等任务
### 6.2 编辑器界面总体布局设计
这里建议放整套界面截图,并说明:
1. 菜单栏
2. Hierarchy
3. Inspector
4. Project
5. Console
6. Scene / Game 视口
### 6.3 Scene 与 Game 视口实现
建议写:
1. 离屏渲染目标
2. 视口纹理接入方式
3. Scene/Game 视口与引擎渲染主链的关系
### 6.4 编辑器交互与调试辅助能力
建议写:
1. ObjectId Picking
2. Outline
3. Grid
4. Gizmo 和辅助显示能力
### 6.5 资源与脚本工作流支撑
建议写:
1. 资源重导入
2. 脚本程序集重建
3. 编辑器在资源与脚本调试中的作用
### 6.6 本章小结
---
## 第7章 基于 NanoVDB 的体积渲染模块设计与实现
这一章是体积渲染实现章节建立在第3章理论基础和前面引擎基础之上。
### 7.1 模块设计目标
建议写:
1. 在现有渲染引擎基础上扩展体积渲染能力。
2. 支持 NanoVDB 稀疏体数据。
3. 构建可实时运行的体积渲染原型。
4. 为后续正式并入主引擎提供实现基础。
### 7.2 NanoVDB 数据加载与 GPU 上传
建议写:
1. `.nvdb` 文件读取流程
2. CPU 侧元数据解析
3. world bbox、voxel size 等信息提取
4. 默认堆 GPU buffer 与上传流程
### 7.3 体积渲染核心流程实现
建议写:
1. 相机光线构造
2. 与体积包围盒求交
3. 体积内部采样与累积
4. 颜色与透明度输出
### 7.4 稀疏体数据访问与跳空优化
建议写:
1. `StructuredBuffer<uint>` 的访问方式
2. `PNanoVDB` 在 shader 中的使用
3. HDDA 或层级访问带来的空域跳过
### 7.5 光照与体积阴影实现
建议写:
1. 单次散射近似
2. 沿光照方向的阴影积分
3. 当前实现的近似性与局限性
### 7.6 当前实现状态分析
建议明确区分:
1. 已完成部分
2. 正在收尾部分
3. 尚未正式并入主引擎主线的部分
### 7.7 本章小结
---
## 第8章 系统测试与结果分析
这一章要同时验证“渲染引擎主体”和“体积渲染模块”。
### 8.1 测试环境与实验配置
写:
1. 硬件环境
2. 软件环境
3. 测试场景与测试数据
### 8.2 渲染引擎主体功能验证
建议写:
1. 模型加载与渲染验证
2. 材质系统验证
3. 多光源与阴影验证
4. 脚本系统验证
5. 编辑器视口验证
### 8.3 体积渲染模块验证
建议写:
1. NanoVDB 文件加载正确性
2. GPU 数据访问正确性
3. 步长、步数、阴影参数变化验证
### 8.4 效果与性能分析
建议写:
1. 体积渲染结果展示
2. 参数对效果与性能的影响
3. 跳空优化的作用
### 8.5 本章小结
---
## 第9章 总结与展望
### 9.1 工作总结
建议分两部分总结:
1. 渲染引擎主体完成情况
2. 体积渲染模块完成情况
### 9.2 当前不足
建议诚实写:
1. 体积渲染正式并入主引擎仍在推进
2. 更完整的资源化与工具化仍待完善
3. DXR 与更高质量体积光照仍可继续扩展
### 9.3 后续展望
建议写:
1. 继续完善渲染引擎整体能力
2. 推动体积渲染模块正式主线集成
3. 完善编辑器工作流和高级渲染扩展
---
## 四、这一版大纲的关键点
1. 渲染引擎是全文主体。
2. 体积渲染理论单独成章。
3. NanoVDB 体积渲染实现单独成章。
4. 引擎主体与体积渲染扩展的关系在结构上是清楚分开的。

View File

@@ -6,6 +6,7 @@
#include "ComponentEditors/MeshRendererComponentEditor.h"
#include "ComponentEditors/ScriptComponentEditor.h"
#include "ComponentEditors/TransformComponentEditor.h"
#include "ComponentEditors/VolumeRendererComponentEditor.h"
namespace XCEngine {
namespace Editor {
@@ -21,6 +22,7 @@ ComponentEditorRegistry::ComponentEditorRegistry() {
RegisterEditor(std::make_unique<LightComponentEditor>());
RegisterEditor(std::make_unique<MeshFilterComponentEditor>());
RegisterEditor(std::make_unique<MeshRendererComponentEditor>());
RegisterEditor(std::make_unique<VolumeRendererComponentEditor>());
RegisterEditor(std::make_unique<ScriptComponentEditor>());
}

View File

@@ -0,0 +1,122 @@
#pragma once
#include "AssetReferenceEditorUtils.h"
#include "IComponentEditor.h"
#include "Core/IUndoManager.h"
#include "UI/UI.h"
#include <XCEngine/Components/VolumeRendererComponent.h>
#include <string>
namespace XCEngine {
namespace Editor {
class VolumeRendererComponentEditor : public IComponentEditor {
public:
const char* GetComponentTypeName() const override {
return "VolumeRenderer";
}
const char* GetDisplayName() const override {
return "Volume Renderer";
}
bool Render(::XCEngine::Components::Component* component, IUndoManager* undoManager) override {
auto* volumeRenderer = dynamic_cast<::XCEngine::Components::VolumeRendererComponent*>(component);
if (!volumeRenderer) {
return false;
}
constexpr const char* kUndoLabel = "Modify Volume Renderer";
bool changed = false;
const ComponentEditorAssetUI::AssetReferenceInteraction volumeFieldInteraction =
ComponentEditorAssetUI::DrawAssetReferenceProperty(
"Volume Field",
volumeRenderer->GetVolumeFieldPath(),
"Drop Volume Asset",
{ ".nvdb" });
changed |= UI::ApplyPropertyChange(
volumeFieldInteraction.clearRequested && !volumeRenderer->GetVolumeFieldPath().empty(),
undoManager,
kUndoLabel,
[volumeRenderer]() {
volumeRenderer->ClearVolumeField();
});
changed |= UI::ApplyPropertyChange(
!volumeFieldInteraction.assignedPath.empty() &&
volumeFieldInteraction.assignedPath != volumeRenderer->GetVolumeFieldPath(),
undoManager,
kUndoLabel,
[volumeRenderer, assignedPath = volumeFieldInteraction.assignedPath]() {
volumeRenderer->SetVolumeFieldPath(assignedPath);
});
const ComponentEditorAssetUI::AssetReferenceInteraction materialInteraction =
ComponentEditorAssetUI::DrawAssetReferenceProperty(
"Material",
volumeRenderer->GetMaterialPath(),
"Drop Material Asset",
{ ".mat" });
changed |= UI::ApplyPropertyChange(
materialInteraction.clearRequested && !volumeRenderer->GetMaterialPath().empty(),
undoManager,
kUndoLabel,
[volumeRenderer]() {
volumeRenderer->ClearMaterial();
});
changed |= UI::ApplyPropertyChange(
!materialInteraction.assignedPath.empty() &&
materialInteraction.assignedPath != volumeRenderer->GetMaterialPath(),
undoManager,
kUndoLabel,
[volumeRenderer, assignedPath = materialInteraction.assignedPath]() {
volumeRenderer->SetMaterialPath(assignedPath);
});
bool castShadows = volumeRenderer->GetCastShadows();
changed |= UI::ApplyPropertyChange(
UI::DrawPropertyBool("Cast Shadows", castShadows),
undoManager,
kUndoLabel,
[volumeRenderer, castShadows]() {
volumeRenderer->SetCastShadows(castShadows);
});
bool receiveShadows = volumeRenderer->GetReceiveShadows();
changed |= UI::ApplyPropertyChange(
UI::DrawPropertyBool("Receive Shadows", receiveShadows),
undoManager,
kUndoLabel,
[volumeRenderer, receiveShadows]() {
volumeRenderer->SetReceiveShadows(receiveShadows);
});
return changed;
}
bool CanAddTo(::XCEngine::Components::GameObject* gameObject) const override {
return gameObject && !gameObject->GetComponent<::XCEngine::Components::VolumeRendererComponent>();
}
const char* GetAddDisabledReason(::XCEngine::Components::GameObject* gameObject) const override {
if (!gameObject) {
return "Invalid";
}
return gameObject->GetComponent<::XCEngine::Components::VolumeRendererComponent>()
? "Already Added"
: nullptr;
}
bool CanRemove(::XCEngine::Components::Component* component) const override {
return CanEdit(component);
}
};
} // namespace Editor
} // namespace XCEngine

View File

@@ -488,6 +488,7 @@ add_library(XCEngine STATIC
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Rendering/Passes/BuiltinFinalColorPass.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Rendering/Passes/BuiltinInfiniteGridPass.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Rendering/Passes/BuiltinObjectIdOutlinePass.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Rendering/Passes/BuiltinVolumetricPass.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Rendering/Pipelines/BuiltinForwardPipeline.h
${CMAKE_CURRENT_SOURCE_DIR}/src/Rendering/Execution/CameraRenderer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Rendering/Caches/DirectionalShadowSurfaceCache.cpp
@@ -502,6 +503,7 @@ add_library(XCEngine STATIC
${CMAKE_CURRENT_SOURCE_DIR}/src/Rendering/Passes/BuiltinFinalColorPass.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Rendering/Passes/BuiltinInfiniteGridPass.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Rendering/Passes/BuiltinObjectIdOutlinePass.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Rendering/Passes/BuiltinVolumetricPass.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Rendering/RenderSurface.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Rendering/Extraction/RenderSceneExtractor.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/Rendering/Extraction/RenderSceneUtility.cpp
@@ -650,6 +652,12 @@ target_include_directories(XCEngine PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/third_party/assimp/include
)
if(XCENGINE_HAS_NANOVDB)
target_include_directories(XCEngine PRIVATE
${XCENGINE_NANOVDB_INCLUDE_DIR}
)
endif()
target_link_libraries(XCEngine PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/third_party/assimp/lib/assimp-vc143-mt.lib
opengl32
@@ -729,3 +737,9 @@ target_compile_definitions(XCEngine PRIVATE
XCENGINE_SUPPORT_OPENGL
XCENGINE_SUPPORT_VULKAN
)
if(XCENGINE_HAS_NANOVDB)
target_compile_definitions(XCEngine PRIVATE
XCENGINE_HAS_NANOVDB=1
)
endif()

View File

@@ -26,6 +26,7 @@ public:
bool Compile(
const void* sourceData,
size_t sourceSize,
const wchar_t* sourcePath,
const D3D_SHADER_MACRO* macros,
const char* entryPoint,
const char* target);

View File

@@ -59,6 +59,9 @@ inline bool TryBuildBuiltinPassResourceBindingPlan(
case BuiltinPassResourceSemantic::PassConstants:
location = &outPlan.passConstants;
break;
case BuiltinPassResourceSemantic::VolumeField:
location = &outPlan.volumeField;
break;
case BuiltinPassResourceSemantic::BaseColorTexture:
location = &outPlan.baseColorTexture;
break;
@@ -305,6 +308,24 @@ inline bool TryBuildBuiltinPassDefaultResourceBindings(
return true;
}
if (ShaderPassMatchesBuiltinPass(shaderPass, BuiltinMaterialPass::Volumetric)) {
AppendBuiltinPassResourceBinding(
outBindings,
"PerObjectConstants",
Resources::ShaderResourceType::ConstantBuffer,
0u,
0u,
"PerObject");
AppendBuiltinPassResourceBinding(
outBindings,
"MaterialConstants",
Resources::ShaderResourceType::ConstantBuffer,
1u,
0u,
"Material");
return true;
}
if (ShaderPassMatchesBuiltinPass(shaderPass, BuiltinMaterialPass::PostProcess)) {
AppendBuiltinPassResourceBinding(
outBindings,
@@ -538,6 +559,9 @@ inline bool TryBuildBuiltinPassSetLayouts(
case BuiltinPassResourceSemantic::PassConstants:
setLayout.usesPassConstants = true;
break;
case BuiltinPassResourceSemantic::VolumeField:
setLayout.usesVolumeField = true;
break;
case BuiltinPassResourceSemantic::BaseColorTexture:
setLayout.usesTexture = true;
setLayout.usesBaseColorTexture = true;

View File

@@ -25,6 +25,8 @@ inline const char* GetBuiltinPassCanonicalName(BuiltinMaterialPass pass) {
return "objectid";
case BuiltinMaterialPass::Skybox:
return "skybox";
case BuiltinMaterialPass::Volumetric:
return "volumetric";
case BuiltinMaterialPass::PostProcess:
return "colorscale";
case BuiltinMaterialPass::FinalColor:
@@ -136,6 +138,11 @@ inline BuiltinPassResourceSemantic ResolveBuiltinPassResourceSemantic(
return BuiltinPassResourceSemantic::PassConstants;
}
if (semantic == Containers::String("volumefield") ||
semantic == Containers::String("volumedata")) {
return BuiltinPassResourceSemantic::VolumeField;
}
if (semantic == Containers::String("basecolortexture") ||
semantic == Containers::String("maintex")) {
return BuiltinPassResourceSemantic::BaseColorTexture;
@@ -196,6 +203,8 @@ inline const char* BuiltinPassResourceSemanticToString(BuiltinPassResourceSemant
return "Environment";
case BuiltinPassResourceSemantic::PassConstants:
return "PassConstants";
case BuiltinPassResourceSemantic::VolumeField:
return "VolumeField";
case BuiltinPassResourceSemantic::BaseColorTexture:
return "BaseColorTexture";
case BuiltinPassResourceSemantic::SourceColorTexture:
@@ -281,6 +290,9 @@ inline bool IsBuiltinPassResourceTypeCompatible(
type == Resources::ShaderResourceType::RawBuffer ||
type == Resources::ShaderResourceType::RWStructuredBuffer ||
type == Resources::ShaderResourceType::RWRawBuffer;
case BuiltinPassResourceSemantic::VolumeField:
return type == Resources::ShaderResourceType::StructuredBuffer ||
type == Resources::ShaderResourceType::RawBuffer;
case BuiltinPassResourceSemantic::BaseColorTexture:
case BuiltinPassResourceSemantic::SourceColorTexture:
case BuiltinPassResourceSemantic::SkyboxPanoramicTexture:

View File

@@ -19,6 +19,7 @@ enum class BuiltinMaterialPass : Core::uint32 {
ShadowCaster,
ObjectId,
Skybox,
Volumetric,
PostProcess,
FinalColor,
Forward = ForwardLit
@@ -42,6 +43,7 @@ enum class BuiltinPassResourceSemantic : Core::uint8 {
ShadowReceiver,
Environment,
PassConstants,
VolumeField,
BaseColorTexture,
SourceColorTexture,
SkyboxPanoramicTexture,
@@ -70,6 +72,7 @@ struct BuiltinPassResourceBindingPlan {
bool usesMaterialBuffers = false;
PassResourceBindingLocation perObject = {};
PassResourceBindingLocation material = {};
PassResourceBindingLocation volumeField = {};
PassResourceBindingLocation lighting = {};
PassResourceBindingLocation shadowReceiver = {};
PassResourceBindingLocation environment = {};
@@ -106,6 +109,7 @@ struct BuiltinPassSetLayoutMetadata {
bool usesEnvironment = false;
bool usesPassConstants = false;
bool usesMaterialBuffers = false;
bool usesVolumeField = false;
bool usesTexture = false;
bool usesBaseColorTexture = false;
bool usesSourceColorTexture = false;

View File

@@ -6,6 +6,7 @@
#include <XCEngine/RHI/RHITexture.h>
#include <XCEngine/Resources/Mesh/Mesh.h>
#include <XCEngine/Resources/Texture/Texture.h>
#include <XCEngine/Resources/Volume/VolumeField.h>
#include <unordered_map>
@@ -36,12 +37,24 @@ public:
RHI::RHIResourceView* resourceView = nullptr;
};
struct CachedVolumeField {
RHI::RHIBuffer* payloadBuffer = nullptr;
RHI::RHIResourceView* shaderResourceView = nullptr;
uint32_t elementStride = 0;
uint32_t elementCount = 0;
uint64_t payloadSize = 0;
Resources::VolumeStorageKind storageKind = Resources::VolumeStorageKind::Unknown;
};
~RenderResourceCache();
void Shutdown();
const CachedMesh* GetOrCreateMesh(RHI::RHIDevice* device, const Resources::Mesh* mesh);
const CachedTexture* GetOrCreateTexture(RHI::RHIDevice* device, const Resources::Texture* texture);
const CachedVolumeField* GetOrCreateVolumeField(
RHI::RHIDevice* device,
const Resources::VolumeField* volumeField);
const CachedBufferView* GetOrCreateBufferView(
RHI::RHIDevice* device,
RHI::RHIBuffer* buffer,
@@ -77,6 +90,10 @@ private:
bool UploadMesh(RHI::RHIDevice* device, const Resources::Mesh* mesh, CachedMesh& cachedMesh);
bool UploadTexture(RHI::RHIDevice* device, const Resources::Texture* texture, CachedTexture& cachedTexture);
bool UploadVolumeField(
RHI::RHIDevice* device,
const Resources::VolumeField* volumeField,
CachedVolumeField& cachedVolumeField);
bool CreateBufferView(
RHI::RHIDevice* device,
RHI::RHIBuffer* buffer,
@@ -86,6 +103,7 @@ private:
std::unordered_map<const Resources::Mesh*, CachedMesh> m_meshCache;
std::unordered_map<const Resources::Texture*, CachedTexture> m_textureCache;
std::unordered_map<const Resources::VolumeField*, CachedVolumeField> m_volumeFieldCache;
std::unordered_map<BufferViewCacheKey, CachedBufferView, BufferViewCacheKeyHash> m_bufferViewCache;
};

View File

@@ -36,6 +36,7 @@ Containers::String GetBuiltinShadowCasterShaderPath();
Containers::String GetBuiltinObjectIdShaderPath();
Containers::String GetBuiltinObjectIdOutlineShaderPath();
Containers::String GetBuiltinSkyboxShaderPath();
Containers::String GetBuiltinVolumetricShaderPath();
Containers::String GetBuiltinColorScalePostProcessShaderPath();
Containers::String GetBuiltinFinalColorShaderPath();
Containers::String GetBuiltinDefaultPrimitiveTexturePath();

View File

@@ -1,7 +1,9 @@
#pragma once
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace XCEngine {
namespace UI {
@@ -11,14 +13,26 @@ class UISelectionModel {
public:
bool HasSelection() const;
const std::string& GetSelectedId() const;
const std::vector<std::string>& GetSelectedIds() const;
std::size_t GetSelectionCount() const;
bool HasMultipleSelection() const;
bool IsSelected(std::string_view id) const;
bool SetSelection(std::string selectionId);
bool SetSelections(
std::vector<std::string> selectionIds,
std::string primarySelectionId = {});
bool SetPrimarySelection(std::string selectionId);
bool AddSelection(std::string selectionId, bool makePrimary = false);
bool RemoveSelection(std::string_view selectionId);
bool ClearSelection();
bool ToggleSelection(std::string selectionId);
private:
std::string m_selectedId = {};
static void NormalizeSelectionIds(std::vector<std::string>& selectionIds);
std::vector<std::string> m_selectedIds = {};
std::string m_primarySelectedId = {};
};
} // namespace Widgets

View File

@@ -79,6 +79,7 @@ bool CompileD3D12Shader(const ShaderCompileDesc& desc, D3D12Shader& shader) {
return shader.Compile(
desc.source.data(),
desc.source.size(),
desc.fileName.empty() ? nullptr : desc.fileName.c_str(),
macroPtr,
entryPointPtr,
profilePtr);
@@ -771,6 +772,7 @@ const RHIDeviceInfo& D3D12Device::GetDeviceInfo() const {
RHIBuffer* D3D12Device::CreateBuffer(const BufferDesc& desc) {
auto* buffer = new D3D12Buffer();
const BufferType bufferType = static_cast<BufferType>(desc.bufferType);
const BufferFlags bufferFlags = static_cast<BufferFlags>(desc.flags);
D3D12_HEAP_TYPE heapType = D3D12_HEAP_TYPE_DEFAULT;
D3D12_RESOURCE_STATES initialState = D3D12_RESOURCE_STATE_COMMON;
D3D12_RESOURCE_FLAGS resourceFlags = D3D12_RESOURCE_FLAG_NONE;
@@ -784,7 +786,14 @@ RHIBuffer* D3D12Device::CreateBuffer(const BufferDesc& desc) {
heapType = D3D12_HEAP_TYPE_UPLOAD;
initialState = D3D12_RESOURCE_STATE_GENERIC_READ;
} else if (bufferType == BufferType::Storage) {
resourceFlags = D3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESS;
const bool allowUnorderedAccess =
(bufferFlags & BufferFlags::AllowUnorderedAccess) == BufferFlags::AllowUnorderedAccess;
if (allowUnorderedAccess) {
resourceFlags = D3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESS;
} else {
heapType = D3D12_HEAP_TYPE_UPLOAD;
initialState = D3D12_RESOURCE_STATE_GENERIC_READ;
}
}
if (buffer->Initialize(m_device.Get(), desc.size, initialState, heapType, resourceFlags)) {
@@ -930,7 +939,13 @@ RHIShader* D3D12Device::CreateShader(const ShaderCompileDesc& desc) {
bool success = false;
if (!desc.source.empty()) {
success = shader->Compile(desc.source.data(), desc.source.size(), entryPointPtr, profilePtr);
success = shader->Compile(
desc.source.data(),
desc.source.size(),
desc.fileName.empty() ? nullptr : desc.fileName.c_str(),
nullptr,
entryPointPtr,
profilePtr);
} else if (!desc.fileName.empty()) {
success = shader->CompileFromFile(desc.fileName.c_str(), entryPointPtr, profilePtr);
}

View File

@@ -1,6 +1,11 @@
#include "XCEngine/RHI/D3D12/D3D12Shader.h"
#include <d3dcompiler.h>
#include <filesystem>
#include <string>
#include <unordered_map>
#include <vector>
static const IID IID_ID3D12ShaderReflection = {
0x8e5c5a69, 0x5c6a, 0x427d, {0xb0, 0xdc, 0x27, 0x63, 0xae, 0xac, 0xe3, 0x75}
};
@@ -8,6 +13,99 @@ static const IID IID_ID3D12ShaderReflection = {
namespace XCEngine {
namespace RHI {
namespace {
std::string NarrowAscii(const std::wstring& value) {
std::string result;
result.reserve(value.size());
for (wchar_t ch : value) {
result.push_back(static_cast<char>(ch));
}
return result;
}
class D3D12ShaderIncludeHandler final : public ID3DInclude {
public:
explicit D3D12ShaderIncludeHandler(const std::filesystem::path& sourcePath)
: m_rootDirectory(sourcePath.parent_path().lexically_normal()) {
}
HRESULT __stdcall Open(
D3D_INCLUDE_TYPE includeType,
LPCSTR fileName,
LPCVOID parentData,
LPCVOID* data,
UINT* bytes) override {
if (fileName == nullptr || data == nullptr || bytes == nullptr) {
return E_INVALIDARG;
}
std::filesystem::path baseDirectory = m_rootDirectory;
if (parentData != nullptr) {
const auto parentIt = m_parentDirectories.find(parentData);
if (parentIt != m_parentDirectories.end()) {
baseDirectory = parentIt->second;
}
}
std::filesystem::path resolvedPath;
if (includeType == D3D_INCLUDE_LOCAL || includeType == D3D_INCLUDE_SYSTEM) {
resolvedPath = (baseDirectory / fileName).lexically_normal();
} else {
return E_FAIL;
}
FILE* input = nullptr;
_wfopen_s(&input, resolvedPath.wstring().c_str(), L"rb");
if (input == nullptr) {
return E_FAIL;
}
if (fseek(input, 0, SEEK_END) != 0) {
fclose(input);
return E_FAIL;
}
const long fileSize = ftell(input);
if (fileSize < 0 || fseek(input, 0, SEEK_SET) != 0) {
fclose(input);
return E_FAIL;
}
std::vector<char> fileBytes(static_cast<size_t>(fileSize));
if (!fileBytes.empty() &&
fread(fileBytes.data(), 1, fileBytes.size(), input) != fileBytes.size()) {
fclose(input);
return E_FAIL;
}
fclose(input);
char* ownedBytes = new char[fileBytes.size()];
if (!fileBytes.empty()) {
std::memcpy(ownedBytes, fileBytes.data(), fileBytes.size());
}
*data = ownedBytes;
*bytes = static_cast<UINT>(fileBytes.size());
m_parentDirectories[*data] = resolvedPath.parent_path().lexically_normal();
return S_OK;
}
HRESULT __stdcall Close(LPCVOID data) override {
if (data != nullptr) {
m_parentDirectories.erase(data);
delete[] static_cast<const char*>(data);
}
return S_OK;
}
private:
std::filesystem::path m_rootDirectory;
std::unordered_map<LPCVOID, std::filesystem::path> m_parentDirectories;
};
} // namespace
D3D12Shader::D3D12Shader()
: m_type(ShaderType::Vertex), m_uniformsCached(false) {
}
@@ -17,7 +115,7 @@ D3D12Shader::~D3D12Shader() {
}
bool D3D12Shader::CompileFromFile(const wchar_t* filePath, const char* entryPoint, const char* target) {
HRESULT hResult = D3DCompileFromFile(filePath, nullptr, nullptr, entryPoint, target,
HRESULT hResult = D3DCompileFromFile(filePath, nullptr, D3D_COMPILE_STANDARD_FILE_INCLUDE, entryPoint, target,
D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION, 0, &m_bytecode, &m_error);
if (FAILED(hResult)) {
@@ -44,20 +142,30 @@ bool D3D12Shader::CompileFromFile(const wchar_t* filePath, const char* entryPoin
}
bool D3D12Shader::Compile(const void* sourceData, size_t sourceSize, const char* entryPoint, const char* target) {
return Compile(sourceData, sourceSize, nullptr, entryPoint, target);
return Compile(sourceData, sourceSize, nullptr, nullptr, entryPoint, target);
}
bool D3D12Shader::Compile(const void* sourceData,
size_t sourceSize,
const wchar_t* sourcePath,
const D3D_SHADER_MACRO* macros,
const char* entryPoint,
const char* target) {
std::string sourceName;
ID3DInclude* includeHandler = nullptr;
D3D12ShaderIncludeHandler localIncludeHandler(
sourcePath != nullptr ? std::filesystem::path(sourcePath) : std::filesystem::path());
if (sourcePath != nullptr && sourcePath[0] != L'\0') {
sourceName = NarrowAscii(sourcePath);
includeHandler = &localIncludeHandler;
}
HRESULT hResult = D3DCompile(
sourceData,
sourceSize,
nullptr,
sourceName.empty() ? nullptr : sourceName.c_str(),
macros,
nullptr,
includeHandler,
entryPoint,
target,
D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION,

View File

@@ -3,13 +3,26 @@
#include <XCEngine/RHI/RHIEnums.h>
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <functional>
#include <vector>
namespace XCEngine {
namespace Rendering {
namespace {
template <typename TValue>
TValue AlignUp(TValue value, TValue alignment) {
if (alignment == 0) {
return value;
}
const TValue remainder = value % alignment;
return remainder == 0 ? value : (value + alignment - remainder);
}
inline void HashCombine(size_t& seed, size_t value) {
seed ^= value + 0x9e3779b9u + (seed << 6u) + (seed >> 2u);
}
@@ -138,6 +151,20 @@ void ShutdownBufferView(RenderResourceCache::CachedBufferView& cachedBufferView)
}
}
void ShutdownVolumeField(RenderResourceCache::CachedVolumeField& cachedVolumeField) {
if (cachedVolumeField.shaderResourceView != nullptr) {
cachedVolumeField.shaderResourceView->Shutdown();
delete cachedVolumeField.shaderResourceView;
cachedVolumeField.shaderResourceView = nullptr;
}
if (cachedVolumeField.payloadBuffer != nullptr) {
cachedVolumeField.payloadBuffer->Shutdown();
delete cachedVolumeField.payloadBuffer;
cachedVolumeField.payloadBuffer = nullptr;
}
}
} // namespace
RenderResourceCache::~RenderResourceCache() {
@@ -155,6 +182,11 @@ void RenderResourceCache::Shutdown() {
}
m_textureCache.clear();
for (auto& entry : m_volumeFieldCache) {
ShutdownVolumeField(entry.second);
}
m_volumeFieldCache.clear();
for (auto& entry : m_bufferViewCache) {
ShutdownBufferView(entry.second);
}
@@ -205,6 +237,32 @@ const RenderResourceCache::CachedTexture* RenderResourceCache::GetOrCreateTextur
return &result.first->second;
}
const RenderResourceCache::CachedVolumeField* RenderResourceCache::GetOrCreateVolumeField(
RHI::RHIDevice* device,
const Resources::VolumeField* volumeField) {
if (device == nullptr ||
volumeField == nullptr ||
!volumeField->IsValid() ||
volumeField->GetPayloadData() == nullptr ||
volumeField->GetPayloadSize() == 0u) {
return nullptr;
}
const auto existing = m_volumeFieldCache.find(volumeField);
if (existing != m_volumeFieldCache.end()) {
return &existing->second;
}
CachedVolumeField cachedVolumeField = {};
if (!UploadVolumeField(device, volumeField, cachedVolumeField)) {
ShutdownVolumeField(cachedVolumeField);
return nullptr;
}
const auto result = m_volumeFieldCache.emplace(volumeField, cachedVolumeField);
return &result.first->second;
}
const RenderResourceCache::CachedBufferView* RenderResourceCache::GetOrCreateBufferView(
RHI::RHIDevice* device,
RHI::RHIBuffer* buffer,
@@ -367,6 +425,62 @@ bool RenderResourceCache::UploadTexture(
return true;
}
bool RenderResourceCache::UploadVolumeField(
RHI::RHIDevice* device,
const Resources::VolumeField* volumeField,
CachedVolumeField& cachedVolumeField) {
if (device == nullptr ||
volumeField == nullptr ||
volumeField->GetPayloadData() == nullptr ||
volumeField->GetPayloadSize() == 0u) {
return false;
}
constexpr uint32_t kVolumeWordStride = sizeof(uint32_t);
const size_t alignedPayloadSize = AlignUp(volumeField->GetPayloadSize(), static_cast<size_t>(kVolumeWordStride));
if (alignedPayloadSize == 0u || alignedPayloadSize > static_cast<size_t>(UINT64_MAX)) {
return false;
}
RHI::BufferDesc bufferDesc = {};
bufferDesc.size = static_cast<uint64_t>(alignedPayloadSize);
bufferDesc.stride = kVolumeWordStride;
bufferDesc.bufferType = static_cast<uint32_t>(RHI::BufferType::Storage);
bufferDesc.flags = 0;
cachedVolumeField.payloadBuffer = device->CreateBuffer(bufferDesc);
if (cachedVolumeField.payloadBuffer == nullptr) {
return false;
}
std::vector<uint8_t> uploadData(alignedPayloadSize, 0u);
std::memcpy(
uploadData.data(),
volumeField->GetPayloadData(),
volumeField->GetPayloadSize());
cachedVolumeField.payloadBuffer->SetData(uploadData.data(), uploadData.size());
cachedVolumeField.payloadBuffer->SetStride(kVolumeWordStride);
cachedVolumeField.payloadBuffer->SetBufferType(RHI::BufferType::Storage);
RHI::ResourceViewDesc viewDesc = {};
viewDesc.dimension = RHI::ResourceViewDimension::StructuredBuffer;
viewDesc.firstElement = 0u;
viewDesc.elementCount = static_cast<uint32_t>(alignedPayloadSize / kVolumeWordStride);
viewDesc.structureByteStride = kVolumeWordStride;
cachedVolumeField.shaderResourceView =
device->CreateShaderResourceView(cachedVolumeField.payloadBuffer, viewDesc);
if (cachedVolumeField.shaderResourceView == nullptr) {
return false;
}
cachedVolumeField.elementStride = kVolumeWordStride;
cachedVolumeField.elementCount = viewDesc.elementCount;
cachedVolumeField.payloadSize = bufferDesc.size;
cachedVolumeField.storageKind = volumeField->GetStorageKind();
return true;
}
bool RenderResourceCache::CreateBufferView(
RHI::RHIDevice* device,
RHI::RHIBuffer* buffer,

View File

@@ -4,6 +4,7 @@
#include <XCEngine/RHI/RHIEnums.h>
#include <XCEngine/RHI/RHIPipelineState.h>
#include <XCEngine/Rendering/Builtin/BuiltinPassLayoutUtils.h>
#include <XCEngine/Resources/BuiltinResources.h>
#include <XCEngine/Resources/Shader/Shader.h>
#include <regex>
@@ -358,19 +359,46 @@ inline void ApplyShaderStageVariant(
compileDesc.profile = ToWideAscii(variant.profile);
}
inline std::wstring ResolveRuntimeShaderSourcePath(const Containers::String& shaderPath) {
Containers::String resolvedPath = shaderPath;
if (resolvedPath.Empty()) {
return std::wstring();
}
if (Resources::IsBuiltinShaderPath(resolvedPath)) {
Containers::String builtinAssetPath;
if (!Resources::TryResolveBuiltinShaderAssetPath(resolvedPath, builtinAssetPath)) {
return std::wstring();
}
resolvedPath = builtinAssetPath;
}
return ToWideAscii(resolvedPath);
}
inline void ApplyShaderStageVariant(
const Containers::String& shaderPath,
const Resources::ShaderPass& pass,
Resources::ShaderBackend backend,
const Resources::ShaderStageVariant& variant,
RHI::ShaderCompileDesc& compileDesc) {
const std::string sourceText = BuildRuntimeShaderSource(pass, backend, variant);
compileDesc.source.assign(sourceText.begin(), sourceText.end());
compileDesc.fileName = ResolveRuntimeShaderSourcePath(shaderPath);
compileDesc.sourceLanguage = ToRHIShaderLanguage(variant.language);
compileDesc.entryPoint = ToWideAscii(variant.entryPoint);
compileDesc.profile = ToWideAscii(variant.profile);
InjectShaderBackendMacros(backend, compileDesc);
}
inline void ApplyShaderStageVariant(
const Resources::ShaderPass& pass,
Resources::ShaderBackend backend,
const Resources::ShaderStageVariant& variant,
RHI::ShaderCompileDesc& compileDesc) {
ApplyShaderStageVariant(Containers::String(), pass, backend, variant, compileDesc);
}
inline Containers::String BuildShaderKeywordSignature(
const Resources::ShaderKeywordSet& keywordSet) {
Resources::ShaderKeywordSet normalizedKeywords = keywordSet;

View File

@@ -81,6 +81,7 @@ RHI::GraphicsPipelineDesc CreatePipelineDesc(
shader.FindVariant(passName, Resources::ShaderType::Vertex, backend)) {
if (shaderPass != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
*shaderPass,
backend,
*vertexVariant,
@@ -91,6 +92,7 @@ RHI::GraphicsPipelineDesc CreatePipelineDesc(
shader.FindVariant(passName, Resources::ShaderType::Fragment, backend)) {
if (shaderPass != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
*shaderPass,
backend,
*fragmentVariant,

View File

@@ -161,6 +161,7 @@ RHI::GraphicsPipelineDesc CreatePipelineDesc(
if (const Resources::ShaderStageVariant* vertexVariant =
shader.FindVariant(passName, Resources::ShaderType::Vertex, backend, keywordSet)) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
shaderPass,
backend,
*vertexVariant,
@@ -169,6 +170,7 @@ RHI::GraphicsPipelineDesc CreatePipelineDesc(
if (const Resources::ShaderStageVariant* fragmentVariant =
shader.FindVariant(passName, Resources::ShaderType::Fragment, backend, keywordSet)) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
shaderPass,
backend,
*fragmentVariant,

View File

@@ -83,6 +83,7 @@ RHI::GraphicsPipelineDesc CreatePipelineDesc(
shader.FindVariant(passName, Resources::ShaderType::Vertex, backend)) {
if (shaderPass != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
*shaderPass,
backend,
*vertexVariant,
@@ -93,6 +94,7 @@ RHI::GraphicsPipelineDesc CreatePipelineDesc(
shader.FindVariant(passName, Resources::ShaderType::Fragment, backend)) {
if (shaderPass != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
*shaderPass,
backend,
*fragmentVariant,

View File

@@ -359,6 +359,7 @@ bool BuiltinInfiniteGridPass::CreateResources(const RenderContext& renderContext
const Resources::ShaderPass* shaderPass = m_builtinInfiniteGridShader->FindPass(infiniteGridPass->name);
if (shaderPass != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
m_builtinInfiniteGridShader->GetPath(),
*shaderPass,
backend,
*vertexVariant,
@@ -372,6 +373,7 @@ bool BuiltinInfiniteGridPass::CreateResources(const RenderContext& renderContext
const Resources::ShaderPass* shaderPass = m_builtinInfiniteGridShader->FindPass(infiniteGridPass->name);
if (shaderPass != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
m_builtinInfiniteGridShader->GetPath(),
*shaderPass,
backend,
*fragmentVariant,

View File

@@ -79,6 +79,7 @@ RHI::GraphicsPipelineDesc CreatePipelineDesc(
shader.FindVariant(passName, Resources::ShaderType::Vertex, backend)) {
if (shaderPass != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
*shaderPass,
backend,
*vertexVariant,
@@ -89,6 +90,7 @@ RHI::GraphicsPipelineDesc CreatePipelineDesc(
shader.FindVariant(passName, Resources::ShaderType::Fragment, backend)) {
if (shaderPass != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
*shaderPass,
backend,
*fragmentVariant,

View File

@@ -83,6 +83,7 @@ RHI::GraphicsPipelineDesc CreatePipelineDesc(
shader.FindVariant(passName, Resources::ShaderType::Vertex, backend)) {
if (shaderPass != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
*shaderPass,
backend,
*vertexVariant,
@@ -93,6 +94,7 @@ RHI::GraphicsPipelineDesc CreatePipelineDesc(
shader.FindVariant(passName, Resources::ShaderType::Fragment, backend)) {
if (shaderPass != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
*shaderPass,
backend,
*fragmentVariant,

View File

@@ -5,6 +5,7 @@
#include "RHI/RHICommandList.h"
#include "Rendering/Detail/ShaderVariantUtils.h"
#include "Rendering/Materials/RenderMaterialResolve.h"
#include "Rendering/Passes/BuiltinVolumetricPass.h"
#include "Rendering/RenderSurface.h"
#include "Resources/BuiltinResources.h"
@@ -151,21 +152,23 @@ RHI::GraphicsPipelineDesc CreateSkyboxPipelineDesc(
if (const Resources::ShaderStageVariant* vertexVariant =
shader.FindVariant(passName, Resources::ShaderType::Vertex, backend)) {
if (shaderPass != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
*shaderPass,
backend,
*vertexVariant,
pipelineDesc.vertexShader);
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
*shaderPass,
backend,
*vertexVariant,
pipelineDesc.vertexShader);
}
}
if (const Resources::ShaderStageVariant* fragmentVariant =
shader.FindVariant(passName, Resources::ShaderType::Fragment, backend)) {
if (shaderPass != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
*shaderPass,
backend,
*fragmentVariant,
pipelineDesc.fragmentShader);
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
*shaderPass,
backend,
*fragmentVariant,
pipelineDesc.fragmentShader);
}
}
@@ -177,6 +180,7 @@ RHI::GraphicsPipelineDesc CreateSkyboxPipelineDesc(
BuiltinForwardPipeline::BuiltinForwardPipeline() {
m_passSequence.AddPass(std::make_unique<Detail::BuiltinForwardOpaquePass>(*this));
m_passSequence.AddPass(std::make_unique<Detail::BuiltinForwardSkyboxPass>(*this));
m_passSequence.AddPass(std::make_unique<Passes::BuiltinVolumetricPass>());
m_passSequence.AddPass(std::make_unique<Detail::BuiltinForwardTransparentPass>(*this));
}

View File

@@ -79,6 +79,7 @@ RHI::GraphicsPipelineDesc CreatePipelineDesc(
shader.FindVariant(passName, Resources::ShaderType::Fragment, backend, keywordSet);
if (vertexVariant != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
shaderPass,
backend,
*vertexVariant,
@@ -86,6 +87,7 @@ RHI::GraphicsPipelineDesc CreatePipelineDesc(
}
if (fragmentVariant != nullptr) {
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
shader.GetPath(),
shaderPass,
backend,
*fragmentVariant,

View File

@@ -34,6 +34,7 @@ constexpr const char* kBuiltinShadowCasterShaderPath = "builtin://shaders/shadow
constexpr const char* kBuiltinObjectIdShaderPath = "builtin://shaders/object-id";
constexpr const char* kBuiltinObjectIdOutlineShaderPath = "builtin://shaders/object-id-outline";
constexpr const char* kBuiltinSkyboxShaderPath = "builtin://shaders/skybox";
constexpr const char* kBuiltinVolumetricShaderPath = "builtin://shaders/volumetric";
constexpr const char* kBuiltinColorScalePostProcessShaderPath =
"builtin://shaders/color-scale-post-process";
constexpr const char* kBuiltinFinalColorShaderPath = "builtin://shaders/final-color";
@@ -61,6 +62,8 @@ constexpr const char* kBuiltinObjectIdOutlineShaderAssetRelativePath =
"engine/assets/builtin/shaders/object-id-outline.shader";
constexpr const char* kBuiltinSkyboxShaderAssetRelativePath =
"engine/assets/builtin/shaders/skybox.shader";
constexpr const char* kBuiltinVolumetricShaderAssetRelativePath =
"engine/assets/builtin/shaders/volumetric.shader";
constexpr const char* kBuiltinColorScalePostProcessShaderAssetRelativePath =
"engine/assets/builtin/shaders/color-scale-post-process.shader";
constexpr const char* kBuiltinFinalColorShaderAssetRelativePath =
@@ -149,6 +152,9 @@ const char* GetBuiltinShaderAssetRelativePath(const Containers::String& builtinS
if (builtinShaderPath == Containers::String(kBuiltinSkyboxShaderPath)) {
return kBuiltinSkyboxShaderAssetRelativePath;
}
if (builtinShaderPath == Containers::String(kBuiltinVolumetricShaderPath)) {
return kBuiltinVolumetricShaderAssetRelativePath;
}
if (builtinShaderPath == Containers::String(kBuiltinColorScalePostProcessShaderPath)) {
return kBuiltinColorScalePostProcessShaderAssetRelativePath;
}
@@ -708,6 +714,10 @@ Shader* BuildBuiltinSkyboxShader(const Containers::String& path) {
return TryLoadBuiltinShaderFromAsset(path);
}
Shader* BuildBuiltinVolumetricShader(const Containers::String& path) {
return TryLoadBuiltinShaderFromAsset(path);
}
Shader* BuildBuiltinColorScalePostProcessShader(const Containers::String& path) {
return TryLoadBuiltinShaderFromAsset(path);
}
@@ -817,6 +827,10 @@ bool TryGetBuiltinShaderPathByShaderName(
outPath = GetBuiltinSkyboxShaderPath();
return true;
}
if (shaderName == "Builtin Volumetric") {
outPath = GetBuiltinVolumetricShaderPath();
return true;
}
if (shaderName == "Builtin Color Scale Post Process") {
outPath = GetBuiltinColorScalePostProcessShaderPath();
return true;
@@ -892,6 +906,10 @@ Containers::String GetBuiltinSkyboxShaderPath() {
return Containers::String(kBuiltinSkyboxShaderPath);
}
Containers::String GetBuiltinVolumetricShaderPath() {
return Containers::String(kBuiltinVolumetricShaderPath);
}
Containers::String GetBuiltinColorScalePostProcessShaderPath() {
return Containers::String(kBuiltinColorScalePostProcessShaderPath);
}
@@ -1002,6 +1020,8 @@ LoadResult CreateBuiltinShaderResource(const Containers::String& path) {
shader = BuildBuiltinObjectIdOutlineShader(path);
} else if (path == GetBuiltinSkyboxShaderPath()) {
shader = BuildBuiltinSkyboxShader(path);
} else if (path == GetBuiltinVolumetricShaderPath()) {
shader = BuildBuiltinVolumetricShader(path);
} else if (path == GetBuiltinColorScalePostProcessShaderPath()) {
shader = BuildBuiltinColorScalePostProcessShader(path);
} else if (path == GetBuiltinFinalColorShaderPath()) {

View File

@@ -1,49 +1,158 @@
#include <XCEngine/UI/Widgets/UISelectionModel.h>
#include <algorithm>
namespace XCEngine {
namespace UI {
namespace Widgets {
bool UISelectionModel::HasSelection() const {
return !m_selectedId.empty();
return !m_primarySelectedId.empty();
}
const std::string& UISelectionModel::GetSelectedId() const {
return m_selectedId;
return m_primarySelectedId;
}
const std::vector<std::string>& UISelectionModel::GetSelectedIds() const {
return m_selectedIds;
}
std::size_t UISelectionModel::GetSelectionCount() const {
return m_selectedIds.size();
}
bool UISelectionModel::HasMultipleSelection() const {
return m_selectedIds.size() > 1u;
}
bool UISelectionModel::IsSelected(std::string_view id) const {
return !m_selectedId.empty() && m_selectedId == id;
return std::find(m_selectedIds.begin(), m_selectedIds.end(), id) != m_selectedIds.end();
}
bool UISelectionModel::SetSelection(std::string selectionId) {
if (m_selectedId == selectionId) {
return SetSelections(
selectionId.empty() ? std::vector<std::string> {} : std::vector<std::string> { std::move(selectionId) });
}
bool UISelectionModel::SetSelections(
std::vector<std::string> selectionIds,
std::string primarySelectionId) {
NormalizeSelectionIds(selectionIds);
if (selectionIds.empty()) {
return ClearSelection();
}
if (primarySelectionId.empty()) {
primarySelectionId = selectionIds.back();
}
std::vector<std::string> normalizedSelections = std::move(selectionIds);
const auto preferredPrimaryIt =
std::find(normalizedSelections.begin(), normalizedSelections.end(), primarySelectionId);
if (preferredPrimaryIt == normalizedSelections.end()) {
primarySelectionId = normalizedSelections.back();
}
if (m_selectedIds == normalizedSelections &&
m_primarySelectedId == primarySelectionId) {
return false;
}
m_selectedId = std::move(selectionId);
m_selectedIds = std::move(normalizedSelections);
m_primarySelectedId = std::move(primarySelectionId);
return true;
}
bool UISelectionModel::SetPrimarySelection(std::string selectionId) {
if (!IsSelected(selectionId)) {
return false;
}
if (m_primarySelectedId == selectionId) {
return false;
}
m_primarySelectedId = std::move(selectionId);
return true;
}
bool UISelectionModel::AddSelection(std::string selectionId, bool makePrimary) {
if (selectionId.empty()) {
return false;
}
if (IsSelected(selectionId)) {
return makePrimary ? SetPrimarySelection(std::move(selectionId)) : false;
}
m_selectedIds.push_back(selectionId);
if (makePrimary || m_primarySelectedId.empty()) {
m_primarySelectedId = std::move(selectionId);
}
return true;
}
bool UISelectionModel::RemoveSelection(std::string_view selectionId) {
const auto it = std::find(m_selectedIds.begin(), m_selectedIds.end(), selectionId);
if (it == m_selectedIds.end()) {
return false;
}
const bool removedPrimary = m_primarySelectedId == selectionId;
m_selectedIds.erase(it);
if (m_selectedIds.empty()) {
m_primarySelectedId.clear();
} else if (removedPrimary) {
m_primarySelectedId = m_selectedIds.back();
}
return true;
}
bool UISelectionModel::ClearSelection() {
if (m_selectedId.empty()) {
if (m_selectedIds.empty()) {
return false;
}
m_selectedId.clear();
m_selectedIds.clear();
m_primarySelectedId.clear();
return true;
}
bool UISelectionModel::ToggleSelection(std::string selectionId) {
if (m_selectedId == selectionId) {
m_selectedId.clear();
return true;
if (selectionId.empty()) {
return false;
}
m_selectedId = std::move(selectionId);
return true;
if (IsSelected(selectionId)) {
return RemoveSelection(selectionId);
}
return AddSelection(std::move(selectionId), true);
}
void UISelectionModel::NormalizeSelectionIds(std::vector<std::string>& selectionIds) {
selectionIds.erase(
std::remove_if(
selectionIds.begin(),
selectionIds.end(),
[](const std::string& selectionId) {
return selectionId.empty();
}),
selectionIds.end());
std::vector<std::string> normalized = {};
normalized.reserve(selectionIds.size());
for (std::string& selectionId : selectionIds) {
if (std::find(normalized.begin(), normalized.end(), selectionId) != normalized.end()) {
continue;
}
normalized.push_back(std::move(selectionId));
}
selectionIds = std::move(normalized);
}
} // namespace Widgets
} // namespace UI
} // namespace XCEngine

View File

@@ -1,36 +1,36 @@
[Window][Hierarchy]
Pos=0,59
Size=189,1122
Size=189,981
Collapsed=0
DockId=0x00000003
[Window][Scene]
Pos=191,59
Size=2099,1122
Size=1800,981
Collapsed=0
DockId=0x00000005
[Window][Game]
Pos=191,59
Size=2099,1122
Size=1800,981
Collapsed=0
DockId=0x00000005
[Window][Inspector]
Pos=2292,59
Size=268,1122
Pos=1993,59
Size=567,981
Collapsed=0
DockId=0x00000006
[Window][Console]
Pos=0,1183
Size=2560,168
Pos=0,1042
Size=2560,309
Collapsed=0
DockId=0x00000002
[Window][Project]
Pos=0,1183
Size=2560,168
Pos=0,1042
Size=2560,309
Collapsed=0
DockId=0x00000002
@@ -40,8 +40,8 @@ Size=2560,1292
Collapsed=0
[Window][Debug##Default]
Pos=37,37
Size=255,255
Pos=29,29
Size=204,204
Collapsed=0
[Window][##MainMenuBar]
@@ -60,10 +60,10 @@ Collapsed=0
[Docking][Data]
DockSpace ID=0xA11B73D6 Window=0x1C358F53 Pos=0,59 Size=2560,1292 Split=Y
DockNode ID=0x00000001 Parent=0xA11B73D6 SizeRef=1262,503 Split=X
DockNode ID=0x00000001 Parent=0xA11B73D6 SizeRef=1262,981 Split=X
DockNode ID=0x00000003 Parent=0x00000001 SizeRef=189,503 NoTabBar=1 NoWindowMenuButton=1 NoCloseButton=1 Selected=0xBABDAE5E
DockNode ID=0x00000004 Parent=0x00000001 SizeRef=1071,503 Split=X
DockNode ID=0x00000005 Parent=0x00000004 SizeRef=801,503 CentralNode=1 NoTabBar=1 NoWindowMenuButton=1 NoCloseButton=1 Selected=0xE601B12F
DockNode ID=0x00000006 Parent=0x00000004 SizeRef=268,503 NoTabBar=1 NoWindowMenuButton=1 NoCloseButton=1 Selected=0x36DC96AB
DockNode ID=0x00000002 Parent=0xA11B73D6 SizeRef=1262,168 NoTabBar=1 NoWindowMenuButton=1 NoCloseButton=1 Selected=0xEA83D666
DockNode ID=0x00000005 Parent=0x00000004 SizeRef=1800,503 CentralNode=1 NoTabBar=1 NoWindowMenuButton=1 NoCloseButton=1 Selected=0xD1EB2482
DockNode ID=0x00000006 Parent=0x00000004 SizeRef=567,503 NoTabBar=1 NoWindowMenuButton=1 NoCloseButton=1 Selected=0x36DC96AB
DockNode ID=0x00000002 Parent=0xA11B73D6 SizeRef=1262,309 NoTabBar=1 NoWindowMenuButton=1 NoCloseButton=1 Selected=0xEA83D666

View File

@@ -0,0 +1,13 @@
{
"shader": "builtin://shaders/volumetric",
"renderQueue": "Transparent",
"properties": {
"_Tint": [1.0, 1.0, 1.0, 0.95],
"_DensityScale": 0.24,
"_StepSize": 0.75,
"_MaxSteps": 2400.0,
"_AmbientStrength": 0.02,
"_LightDirection": [0.5, 0.8, 0.3, 0.0],
"_LightSamples": 10.0
}
}

View File

@@ -0,0 +1,5 @@
fileFormatVersion: 1
guid: f6b4d8b3d1f94d08b3b9451d31a9c2e7
folderAsset: false
importer: MaterialImporter
importerVersion: 7

View File

@@ -29,7 +29,7 @@ gameobject_end
gameobject_begin
id=87
uuid=9751737136539126565
name=GameObject
name=CloudVolume
tag=Untagged
active=1
layer=0
@@ -160,3 +160,15 @@ component=MeshFilter;meshRef=;meshPath=builtin://meshes/capsule;
component=MeshRenderer;materialPaths=builtin://materials/default-primitive;materialRefs=;castShadows=1;receiveShadows=1;renderLayer=0;
gameobject_end
gameobject_begin
id=135
uuid=14577318963053235508
name=GameObject
tag=Untagged
active=1
layer=0
parent=0
transform=position=-0.434997,1.6,15.0434;rotation=0,0.58032,0,0.814389;scale=0.02,0.02,0.02;
component=VolumeRenderer;volumeRef=3e098ae1928fa7b547fb4f8d0bd34a24,1,16;materialRef=f6b4d8b3d1f94d08b3b9451d31a9c2e7,1,3;castShadows=1;receiveShadows=1;
gameobject_end

View File

@@ -39,6 +39,10 @@ target_include_directories(rhi_d3d12_tests PRIVATE
${PROJECT_ROOT_DIR}/engine/src
)
if(MSVC)
target_compile_options(rhi_d3d12_tests PRIVATE /FS)
endif()
enable_testing()
include(GoogleTest)
gtest_discover_tests(rhi_d3d12_tests)

View File

@@ -43,6 +43,10 @@ target_include_directories(rhi_opengl_tests PRIVATE
${PROJECT_ROOT_DIR}/engine/src
)
if(MSVC)
target_compile_options(rhi_opengl_tests PRIVATE /FS)
endif()
enable_testing()
include(GoogleTest)
gtest_discover_tests(rhi_opengl_tests)

View File

@@ -42,5 +42,9 @@ target_include_directories(rhi_vulkan_tests PRIVATE
${PROJECT_ROOT_DIR}/engine/src
)
if(MSVC)
target_compile_options(rhi_vulkan_tests PRIVATE /FS)
endif()
include(GoogleTest)
gtest_discover_tests(rhi_vulkan_tests)

View File

@@ -676,6 +676,78 @@ TEST(BuiltinForwardPipeline_Test, BuildsBuiltinPassResourceBindingPlanFromLoaded
delete shader;
}
TEST(BuiltinForwardPipeline_Test, BuiltinVolumetricShaderUsesAuthoringContract) {
ShaderLoader loader;
LoadResult result = loader.Load(GetBuiltinVolumetricShaderPath());
ASSERT_TRUE(result);
ASSERT_NE(result.resource, nullptr);
Shader* shader = static_cast<Shader*>(result.resource);
ASSERT_NE(shader, nullptr);
const ShaderPass* pass = shader->FindPass("Volumetric");
ASSERT_NE(pass, nullptr);
EXPECT_EQ(pass->resources.Size(), 3u);
EXPECT_TRUE(pass->hasFixedFunctionState);
EXPECT_EQ(pass->fixedFunctionState.cullMode, MaterialCullMode::None);
EXPECT_FALSE(pass->fixedFunctionState.depthWriteEnable);
EXPECT_EQ(pass->fixedFunctionState.depthFunc, MaterialComparisonFunc::LessEqual);
EXPECT_TRUE(pass->fixedFunctionState.blendEnable);
const ShaderResourceBindingDesc* volumeData =
shader->FindPassResourceBinding("Volumetric", "VolumeData");
ASSERT_NE(volumeData, nullptr);
EXPECT_EQ(volumeData->type, ShaderResourceType::StructuredBuffer);
EXPECT_EQ(volumeData->set, 2u);
EXPECT_EQ(volumeData->binding, 0u);
EXPECT_EQ(
ResolveBuiltinPassResourceSemantic(*volumeData),
BuiltinPassResourceSemantic::VolumeField);
delete shader;
}
TEST(BuiltinForwardPipeline_Test, BuildsBuiltinPassResourceBindingPlanFromLoadedVolumetricShaderContract) {
ShaderLoader loader;
LoadResult result = loader.Load(GetBuiltinVolumetricShaderPath());
ASSERT_TRUE(result);
ASSERT_NE(result.resource, nullptr);
Shader* shader = static_cast<Shader*>(result.resource);
ASSERT_NE(shader, nullptr);
const ShaderPass* pass = shader->FindPass("Volumetric");
ASSERT_NE(pass, nullptr);
BuiltinPassResourceBindingPlan plan = {};
String error;
EXPECT_TRUE(TryBuildBuiltinPassResourceBindingPlan(*pass, plan, &error)) << error.CStr();
ASSERT_EQ(plan.bindings.Size(), 3u);
EXPECT_TRUE(plan.perObject.IsValid());
EXPECT_TRUE(plan.material.IsValid());
EXPECT_TRUE(plan.volumeField.IsValid());
EXPECT_EQ(plan.perObject.set, 0u);
EXPECT_EQ(plan.material.set, 1u);
EXPECT_EQ(plan.volumeField.set, 2u);
EXPECT_EQ(plan.volumeField.binding, 0u);
std::vector<BuiltinPassSetLayoutMetadata> setLayouts;
ASSERT_TRUE(TryBuildBuiltinPassSetLayouts(plan, setLayouts, &error)) << error.CStr();
ASSERT_EQ(setLayouts.size(), 3u);
EXPECT_TRUE(setLayouts[0].usesPerObject);
EXPECT_TRUE(setLayouts[1].usesMaterial);
EXPECT_TRUE(setLayouts[2].usesVolumeField);
ASSERT_EQ(setLayouts[2].bindings.size(), 1u);
EXPECT_EQ(
static_cast<DescriptorType>(setLayouts[2].bindings[0].type),
DescriptorType::SRV);
EXPECT_EQ(
setLayouts[2].bindings[0].resourceDimension,
ResourceViewDimension::StructuredBuffer);
delete shader;
}
TEST(BuiltinForwardPipeline_Test, BuiltinFinalColorShaderUsesAuthoringContract) {
ShaderLoader loader;
LoadResult result = loader.Load(GetBuiltinFinalColorShaderPath());
@@ -867,6 +939,77 @@ TEST(BuiltinForwardPipeline_Test, VulkanRuntimeCompileDescRewritesAuthoringFinal
delete shader;
}
TEST(BuiltinForwardPipeline_Test, VulkanRuntimeCompileDescRewritesAuthoringVolumetricBindingsToDescriptorSpaces) {
ShaderLoader loader;
LoadResult result = loader.Load(GetBuiltinVolumetricShaderPath());
ASSERT_TRUE(result);
ASSERT_NE(result.resource, nullptr);
Shader* shader = static_cast<Shader*>(result.resource);
ASSERT_NE(shader, nullptr);
const ShaderPass* pass = shader->FindPass("Volumetric");
ASSERT_NE(pass, nullptr);
const ShaderStageVariant* d3d12Fragment = shader->FindVariant(
"Volumetric",
XCEngine::Resources::ShaderType::Fragment,
XCEngine::Resources::ShaderBackend::D3D12);
ASSERT_NE(d3d12Fragment, nullptr);
ShaderCompileDesc d3d12CompileDesc = {};
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
*pass,
XCEngine::Resources::ShaderBackend::D3D12,
*d3d12Fragment,
d3d12CompileDesc);
const std::string d3d12Source(
reinterpret_cast<const char*>(d3d12CompileDesc.source.data()),
d3d12CompileDesc.source.size());
EXPECT_TRUE(SourceContainsRegisterBinding(
d3d12Source,
"cbuffer PerObjectConstants",
"register(b0)"));
EXPECT_TRUE(SourceContainsRegisterBinding(
d3d12Source,
"cbuffer MaterialConstants",
"register(b1)"));
EXPECT_TRUE(SourceContainsRegisterBinding(
d3d12Source,
"StructuredBuffer<uint> VolumeData",
"register(t0)"));
const ShaderStageVariant* vulkanFragment = shader->FindVariant(
"Volumetric",
XCEngine::Resources::ShaderType::Fragment,
XCEngine::Resources::ShaderBackend::Vulkan);
ASSERT_NE(vulkanFragment, nullptr);
ShaderCompileDesc vulkanCompileDesc = {};
::XCEngine::Rendering::Detail::ApplyShaderStageVariant(
*pass,
XCEngine::Resources::ShaderBackend::Vulkan,
*vulkanFragment,
vulkanCompileDesc);
const std::string vulkanSource(
reinterpret_cast<const char*>(vulkanCompileDesc.source.data()),
vulkanCompileDesc.source.size());
EXPECT_TRUE(SourceContainsRegisterBinding(
vulkanSource,
"cbuffer PerObjectConstants",
"register(b0, space0)"));
EXPECT_TRUE(SourceContainsRegisterBinding(
vulkanSource,
"cbuffer MaterialConstants",
"register(b0, space1)"));
EXPECT_TRUE(SourceContainsRegisterBinding(
vulkanSource,
"StructuredBuffer<uint> VolumeData",
"register(t0, space2)"));
delete shader;
}
TEST(BuiltinForwardPipeline_Test, OpenGLRuntimeTranspilesFinalColorVariantToCombinedSourceSampler) {
ShaderLoader loader;
LoadResult result = loader.Load(GetBuiltinFinalColorShaderPath());

View File

@@ -37,6 +37,58 @@ TEST(UISelectionModelTest, ToggleSelectionSelectsAndDeselectsMatchingId) {
EXPECT_EQ(selection.GetSelectedId(), "treeMaterials");
EXPECT_TRUE(selection.ToggleSelection("treeUi"));
EXPECT_EQ(selection.GetSelectedId(), "treeUi");
ASSERT_EQ(selection.GetSelectedIds().size(), 1u);
EXPECT_EQ(selection.GetSelectedIds()[0], "treeUi");
}
TEST(UISelectionModelTest, MultiSelectionTracksMembershipAndPrimarySelection) {
UISelectionModel selection = {};
EXPECT_TRUE(selection.AddSelection("scene", true));
EXPECT_TRUE(selection.AddSelection("camera"));
EXPECT_TRUE(selection.AddSelection("lights"));
EXPECT_EQ(selection.GetSelectionCount(), 3u);
EXPECT_TRUE(selection.HasMultipleSelection());
EXPECT_TRUE(selection.IsSelected("scene"));
EXPECT_TRUE(selection.IsSelected("camera"));
EXPECT_TRUE(selection.IsSelected("lights"));
EXPECT_EQ(selection.GetSelectedId(), "scene");
EXPECT_TRUE(selection.SetPrimarySelection("lights"));
EXPECT_EQ(selection.GetSelectedId(), "lights");
EXPECT_FALSE(selection.SetPrimarySelection("missing"));
}
TEST(UISelectionModelTest, SetSelectionsNormalizesDuplicatesAndKeepsRequestedPrimary) {
UISelectionModel selection = {};
EXPECT_TRUE(selection.SetSelections(
{ "", "camera", "lights", "camera", "sun" },
"lights"));
ASSERT_EQ(selection.GetSelectedIds().size(), 3u);
EXPECT_EQ(selection.GetSelectedIds()[0], "camera");
EXPECT_EQ(selection.GetSelectedIds()[1], "lights");
EXPECT_EQ(selection.GetSelectedIds()[2], "sun");
EXPECT_EQ(selection.GetSelectedId(), "lights");
EXPECT_TRUE(selection.SetSelections({ "camera", "sun" }));
EXPECT_EQ(selection.GetSelectedId(), "sun");
}
TEST(UISelectionModelTest, RemovingPrimaryFallsBackToMostRecentlyAddedRemainingItem) {
UISelectionModel selection = {};
EXPECT_TRUE(selection.SetSelections({ "scene", "camera", "lights" }, "camera"));
EXPECT_TRUE(selection.RemoveSelection("camera"));
EXPECT_TRUE(selection.IsSelected("scene"));
EXPECT_TRUE(selection.IsSelected("lights"));
EXPECT_FALSE(selection.IsSelected("camera"));
EXPECT_EQ(selection.GetSelectedId(), "lights");
EXPECT_TRUE(selection.RemoveSelection("lights"));
EXPECT_EQ(selection.GetSelectedId(), "scene");
EXPECT_TRUE(selection.RemoveSelection("scene"));
EXPECT_FALSE(selection.HasSelection());
}
} // namespace

View File

@@ -53,6 +53,11 @@ if(TARGET editor_ui_tree_view_basic_validation)
editor_ui_tree_view_basic_validation)
endif()
if(TARGET editor_ui_tree_view_multiselect_validation)
list(APPEND EDITOR_UI_INTEGRATION_TARGETS
editor_ui_tree_view_multiselect_validation)
endif()
if(TARGET editor_ui_property_grid_basic_validation)
list(APPEND EDITOR_UI_INTEGRATION_TARGETS
editor_ui_property_grid_basic_validation)
@@ -98,6 +103,16 @@ if(TARGET editor_ui_list_view_basic_validation)
editor_ui_list_view_basic_validation)
endif()
if(TARGET editor_ui_list_view_multiselect_validation)
list(APPEND EDITOR_UI_INTEGRATION_TARGETS
editor_ui_list_view_multiselect_validation)
endif()
if(TARGET editor_ui_list_view_inline_rename_validation)
list(APPEND EDITOR_UI_INTEGRATION_TARGETS
editor_ui_list_view_inline_rename_validation)
endif()
add_custom_target(editor_ui_integration_tests
DEPENDS
${EDITOR_UI_INTEGRATION_TARGETS}

View File

@@ -29,7 +29,10 @@ Layout:
- `shell/enum_field_basic/`: EnumField previous/next switch, keyboard switch, hover/focus/selection feedback only
- `shell/status_bar_basic/`: status bar slot/segment/hit-test only
- `shell/tree_view_basic/`: TreeView row layout, indent, disclosure, selection, focus, hit-test only
- `shell/tree_view_multiselect/`: TreeView multi-selection contract only
- `shell/list_view_basic/`: ListView row layout, selection, focus, keyboard navigation, hit-test only
- `shell/list_view_multiselect/`: ListView multi-selection contract only
- `shell/list_view_inline_rename/`: ListView inline rename/edit session only
- `shell/tab_strip_basic/`: tab strip layout/state/hit-test/close/navigation only
- `shell/viewport_slot_basic/`: viewport slot chrome/surface/status only
- `shell/viewport_shell_basic/`: viewport shell request/state compose only
@@ -118,11 +121,31 @@ Scenarios:
Executable: `XCUIEditorTreeViewBasicValidation.exe`
Scope: TreeView 基础控件验证只检查行缩进、disclosure 展开/折叠、selection、hover/focus 和 hit-test不涉及业务面板
- `editor.shell.tree_view_multiselect`
Build target: `editor_ui_tree_view_multiselect_validation`
Executable: `XCUIEditorTreeViewMultiSelectValidation.exe`
Scope: TreeView 多选契约验证;只检查 Ctrl/Shift 多选、锚点、右键命中已选集合、键盘范围扩选、expanded/visible/current 同步,不涉及业务面板
- `editor.shell.tree_view_inline_rename`
Build target: `editor_ui_tree_view_inline_rename_validation`
Executable: `XCUIEditorTreeViewInlineRenameValidation.exe`
Scope: TreeView inline rename 契约验证;只检查 F2 开启、字符编辑、Enter 提交、Esc 取消、点击外部结束编辑,以及标签写回
- `editor.shell.list_view_basic`
Build target: `editor_ui_list_view_basic_validation`
Executable: `XCUIEditorListViewBasicValidation.exe`
Scope: ListView 基础控件验证;只检查 row 排列、selection、hover/focus、Up/Down/Home/End 键盘导航和 hit-test不涉及业务面板
- `editor.shell.list_view_multiselect`
Build target: `editor_ui_list_view_multiselect_validation`
Executable: `XCUIEditorListViewMultiSelectValidation.exe`
Scope: ListView 多选契约验证;只检查 Ctrl/Shift 多选、锚点、右键命中已选集合、键盘范围扩选、current/primary 同步,不涉及业务面板
- `editor.shell.list_view_inline_rename`
Build target: `editor_ui_list_view_inline_rename_validation`
Executable: `XCUIEditorListViewInlineRenameValidation.exe`
Scope: ListView inline rename 契约验证;只检查 F2 开启、字符编辑、Enter 提交、Esc 取消、点击外部结束编辑,以及标签写回
- `editor.shell.tab_strip_basic`
Build target: `editor_ui_tab_strip_basic_validation`
Executable: `XCUIEditorTabStripBasicValidation.exe`
@@ -225,8 +248,20 @@ Selected controls:
- `shell/tree_view_basic/`
先看顶部中文说明“这个测试在验证什么功能”,再点击 disclosure 和树节点行,检查 `Hover / Focused / Selected / Expanded / Visible / Result`,按 `重置``截图(F12)` 或直接按 `F12`
- `shell/tree_view_multiselect/`
先看顶部中文说明“这个测试在验证什么功能”,再分别执行 `单击``Ctrl+单击``Shift+单击``Shift+Up/Down/Home/End``Right Click`,检查 `Primary / Selected Count / Selected Ids / Anchor / Current / Expanded / Visible / Result`
- `shell/tree_view_inline_rename/`
先看顶部中文说明“这个测试在验证什么功能”,再依次执行 `单击``F2`、输入字符、`Enter / Esc / 点击外部`,检查 `Rename Active / Rename Item / Draft / Committed / Result`
- `shell/list_view_basic/`
先看顶部中文说明“这个测试在验证什么功能”,再点击列表行,并在列表获得 focus 后按 `Up / Down / Home / End`,检查 `Hover / Focused / Selected / Current / Result`,按 `重置``截图(F12)` 或直接按 `F12`
- `shell/list_view_multiselect/`
先看顶部中文说明“这个测试在验证什么功能”,再分别执行 `单击``Ctrl+单击``Shift+单击``Shift+Up/Down/Home/End``Right Click`,检查 `Primary / Selected Count / Selected Ids / Anchor / Current / Result`
- `shell/list_view_inline_rename/`
先看顶部中文说明“这个测试在验证什么功能”,再依次执行 `单击``F2`、输入字符、`Enter / Esc / 点击外部`,检查 `Rename Active / Rename Item / Draft / Committed / Result`
- `shell/tab_strip_basic/`
Click `Document A / B / C`, click `X` on closable tabs, click content to focus, press `Left / Right / Home / End`, press `Reset`, press `F12`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -2,8 +2,8 @@
#define NOMINMAX
#endif
#include <XCEditor/Core/UIEditorListViewInteraction.h>
#include <XCEditor/Widgets/UIEditorListView.h>
#include <XCEditor/Collections/UIEditorListView.h>
#include <XCEditor/Collections/UIEditorListViewInteraction.h>
#include "Host/AutoScreenshot.h"
#include "Host/NativeRenderer.h"

View File

@@ -0,0 +1,31 @@
add_executable(editor_ui_list_view_inline_rename_validation WIN32
main.cpp
)
target_include_directories(editor_ui_list_view_inline_rename_validation PRIVATE
${CMAKE_SOURCE_DIR}/new_editor/include
${CMAKE_SOURCE_DIR}/new_editor/app
${CMAKE_SOURCE_DIR}/engine/include
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
)
target_compile_definitions(editor_ui_list_view_inline_rename_validation PRIVATE
UNICODE
_UNICODE
XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}"
)
if(MSVC)
target_compile_options(editor_ui_list_view_inline_rename_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_list_view_inline_rename_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_list_view_inline_rename_validation PRIVATE
XCUIEditorLib
XCUIEditorHost
)
set_target_properties(editor_ui_list_view_inline_rename_validation PROPERTIES
OUTPUT_NAME "XCUIEditorListViewInlineRenameValidation"
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
add_executable(editor_ui_list_view_multiselect_validation WIN32
main.cpp
)
target_include_directories(editor_ui_list_view_multiselect_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
${CMAKE_SOURCE_DIR}/new_editor/include
${CMAKE_SOURCE_DIR}/new_editor/app
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_list_view_multiselect_validation PRIVATE
UNICODE
_UNICODE
XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}"
)
if(MSVC)
target_compile_options(editor_ui_list_view_multiselect_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_list_view_multiselect_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_list_view_multiselect_validation PRIVATE
XCUIEditorLib
XCUIEditorHost
)
set_target_properties(editor_ui_list_view_multiselect_validation PROPERTIES
OUTPUT_NAME "XCUIEditorListViewMultiSelectValidation"
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -0,0 +1,901 @@
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <XCEditor/Collections/UIEditorListView.h>
#include <XCEditor/Collections/UIEditorListViewInteraction.h>
#include "EditorValidationTheme.h"
#include "Host/AutoScreenshot.h"
#include "Host/InputModifierTracker.h"
#include "Host/NativeRenderer.h"
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/DrawData.h>
#include <XCEngine/UI/Style/Theme.h>
#include <XCEngine/UI/Widgets/UISelectionModel.h>
#include <windows.h>
#include <windowsx.h>
#include <algorithm>
#include <cstdint>
#include <filesystem>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT
#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "."
#endif
namespace {
using XCEngine::Input::KeyCode;
using XCEngine::Tests::EditorUI::EditorValidationShellMetrics;
using XCEngine::Tests::EditorUI::EditorValidationShellPalette;
using XCEngine::UI::UIDrawData;
using XCEngine::UI::UIDrawList;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIInputModifiers;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIPointerButton;
using XCEngine::UI::UIRect;
using XCEngine::UI::Widgets::UISelectionModel;
using XCEngine::UI::Editor::Host::AutoScreenshotController;
using XCEngine::UI::Editor::Host::InputModifierTracker;
using XCEngine::UI::Editor::Host::NativeRenderer;
using XCEngine::UI::Editor::UIEditorListViewInteractionFrame;
using XCEngine::UI::Editor::UIEditorListViewInteractionResult;
using XCEngine::UI::Editor::UIEditorListViewInteractionState;
using XCEngine::UI::Editor::UpdateUIEditorListViewInteraction;
using XCEngine::UI::Editor::Widgets::AppendUIEditorListViewBackground;
using XCEngine::UI::Editor::Widgets::AppendUIEditorListViewForeground;
using XCEngine::UI::Editor::Widgets::HitTestUIEditorListView;
using XCEngine::UI::Editor::Widgets::UIEditorListViewHitTarget;
using XCEngine::UI::Editor::Widgets::UIEditorListViewItem;
namespace Style = XCEngine::UI::Style;
constexpr const wchar_t* kWindowClassName = L"XCUIEditorListViewMultiSelectValidation";
constexpr const wchar_t* kWindowTitle = L"XCUI Editor | ListView MultiSelect";
enum class ActionId : unsigned char {
Reset = 0,
Capture
};
struct ButtonLayout {
ActionId action = ActionId::Reset;
const char* label = "";
UIRect rect = {};
};
struct ScenarioLayout {
UIRect introRect = {};
UIRect controlRect = {};
UIRect stateRect = {};
UIRect previewRect = {};
UIRect listRect = {};
std::vector<ButtonLayout> buttons = {};
};
std::filesystem::path ResolveRepoRootPath() {
std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT;
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
root = root.substr(1u, root.size() - 2u);
}
return std::filesystem::path(root).lexically_normal();
}
std::filesystem::path ResolveValidationThemePath() {
return (ResolveRepoRootPath() / "tests/UI/Editor/integration/shared/themes/editor_validation.xctheme")
.lexically_normal();
}
bool ContainsPoint(const UIRect& rect, float x, float y) {
return x >= rect.x &&
x <= rect.x + rect.width &&
y >= rect.y &&
y <= rect.y + rect.height;
}
std::int32_t MapListNavigationKey(UINT keyCode) {
switch (keyCode) {
case VK_UP:
return static_cast<std::int32_t>(KeyCode::Up);
case VK_DOWN:
return static_cast<std::int32_t>(KeyCode::Down);
case VK_HOME:
return static_cast<std::int32_t>(KeyCode::Home);
case VK_END:
return static_cast<std::int32_t>(KeyCode::End);
default:
return static_cast<std::int32_t>(KeyCode::None);
}
}
ScenarioLayout BuildScenarioLayout(
float width,
float height,
const EditorValidationShellMetrics& shellMetrics) {
const float margin = shellMetrics.margin;
constexpr float leftWidth = 470.0f;
const float gap = shellMetrics.gap;
ScenarioLayout layout = {};
layout.introRect = UIRect(margin, margin, leftWidth, 252.0f);
layout.controlRect = UIRect(margin, layout.introRect.y + layout.introRect.height + gap, leftWidth, 84.0f);
layout.stateRect = UIRect(
margin,
layout.controlRect.y + layout.controlRect.height + gap,
leftWidth,
(std::max)(260.0f, height - (layout.controlRect.y + layout.controlRect.height + gap) - margin));
layout.previewRect = UIRect(
leftWidth + margin * 2.0f,
margin,
(std::max)(520.0f, width - leftWidth - margin * 3.0f),
height - margin * 2.0f);
layout.listRect = UIRect(
layout.previewRect.x + 22.0f,
layout.previewRect.y + 72.0f,
layout.previewRect.width - 44.0f,
layout.previewRect.height - 104.0f);
const float buttonWidth = (layout.controlRect.width - 44.0f) * 0.5f;
const float buttonY = layout.controlRect.y + 32.0f;
layout.buttons = {
{ ActionId::Reset, "重置", UIRect(layout.controlRect.x + 14.0f, buttonY, buttonWidth, 36.0f) },
{ ActionId::Capture, "截图(F12)", UIRect(layout.controlRect.x + 26.0f + buttonWidth, buttonY, buttonWidth, 36.0f) }
};
return layout;
}
void DrawCard(
UIDrawList& drawList,
const UIRect& rect,
const EditorValidationShellPalette& shellPalette,
const EditorValidationShellMetrics& shellMetrics,
std::string_view title,
std::string_view subtitle = {}) {
drawList.AddFilledRect(rect, shellPalette.cardBackground, shellMetrics.cardRadius);
drawList.AddRectOutline(rect, shellPalette.cardBorder, 1.0f, shellMetrics.cardRadius);
drawList.AddText(
UIPoint(rect.x + 16.0f, rect.y + 14.0f),
std::string(title),
shellPalette.textPrimary,
shellMetrics.titleFontSize);
if (!subtitle.empty()) {
drawList.AddText(
UIPoint(rect.x + 16.0f, rect.y + 40.0f),
std::string(subtitle),
shellPalette.textMuted,
shellMetrics.bodyFontSize);
}
}
void DrawButton(
UIDrawList& drawList,
const ButtonLayout& button,
const EditorValidationShellPalette& shellPalette,
const EditorValidationShellMetrics& shellMetrics,
bool hovered) {
drawList.AddFilledRect(
button.rect,
hovered ? shellPalette.buttonHoverBackground : shellPalette.buttonBackground,
shellMetrics.buttonRadius);
drawList.AddRectOutline(button.rect, shellPalette.cardBorder, 1.0f, shellMetrics.buttonRadius);
drawList.AddText(
UIPoint(button.rect.x + 16.0f, button.rect.y + 10.0f),
button.label,
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
}
std::vector<UIEditorListViewItem> BuildListItems() {
return {
{ "scene", "SampleScene.unity", "Scene | 4 分钟前修改", 0.0f },
{ "lighting", "LightingProfile.asset", "Preset | 3 个 profile", 0.0f },
{ "material", "RobotBody.mat", "Material | Metallic Workflow", 0.0f },
{ "script", "PlayerController.cs", "C# Script | 3.4 KB", 0.0f },
{ "texture", "Checker_AO.png", "Texture2D | 2048x2048", 0.0f },
{ "prefab", "Robot.prefab", "Prefab | 9 个组件", 0.0f },
{ "anim", "Walk.anim", "Animation Clip | 1.2 s", 0.0f },
{ "shader", "Outline.shader", "Shader | URP compatible", 0.0f },
{ "mesh", "Robot.fbx", "Model | 38k triangles", 0.0f },
{ "audio", "FactoryLoop.wav", "AudioClip | 44.1 kHz", 0.0f },
{ "timeline", "IntroPlayable.playable", "Timeline | 7 tracks", 0.0f },
{ "profile", "GameplayProfile.asset", "Volume Profile | 6 overrides", 0.0f }
};
}
std::string JoinSelectedIds(const UISelectionModel& selectionModel) {
const std::vector<std::string>& selectedIds = selectionModel.GetSelectedIds();
if (selectedIds.empty()) {
return "(none)";
}
std::ostringstream stream = {};
for (std::size_t index = 0u; index < selectedIds.size(); ++index) {
if (index > 0u) {
stream << " | ";
}
stream << selectedIds[index];
}
return stream.str();
}
std::string DescribeHitTarget(
const UIEditorListViewHitTarget& hitTarget,
const std::vector<UIEditorListViewItem>& items) {
if (hitTarget.itemIndex >= items.size()) {
return "(none)";
}
return "row: " + items[hitTarget.itemIndex].primaryText;
}
std::string DescribeModifiers(const UIInputModifiers& modifiers) {
std::ostringstream stream = {};
stream << "Ctrl " << (modifiers.control ? "on" : "off")
<< " | Shift " << (modifiers.shift ? "on" : "off")
<< " | Alt " << (modifiers.alt ? "on" : "off");
return stream.str();
}
UIInputEvent MakePointerEvent(
UIInputEventType type,
const UIPoint& position,
const UIInputModifiers& modifiers,
UIPointerButton button = UIPointerButton::None) {
UIInputEvent event = {};
event.type = type;
event.position = position;
event.pointerButton = button;
event.modifiers = modifiers;
return event;
}
UIInputEvent MakeKeyEvent(
std::int32_t keyCode,
const UIInputModifiers& modifiers) {
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = keyCode;
event.modifiers = modifiers;
return event;
}
class ScenarioApp {
public:
int Run(HINSTANCE hInstance, int nCmdShow) {
if (!Initialize(hInstance, nCmdShow)) {
Shutdown();
return 1;
}
MSG message = {};
while (message.message != WM_QUIT) {
if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) {
TranslateMessage(&message);
DispatchMessageW(&message);
continue;
}
RenderFrame();
Sleep(8);
}
Shutdown();
return static_cast<int>(message.wParam);
}
private:
static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
if (message == WM_NCCREATE) {
const auto* createStruct = reinterpret_cast<CREATESTRUCTW*>(lParam);
auto* app = reinterpret_cast<ScenarioApp*>(createStruct->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(app));
return TRUE;
}
auto* app = reinterpret_cast<ScenarioApp*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
switch (message) {
case WM_SIZE:
if (app != nullptr && wParam != SIZE_MINIMIZED) {
app->OnResize(static_cast<UINT>(LOWORD(lParam)), static_cast<UINT>(HIWORD(lParam)));
}
return 0;
case WM_MOUSEMOVE:
if (app != nullptr) {
app->HandleMouseMove(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)),
static_cast<std::size_t>(wParam));
return 0;
}
break;
case WM_MOUSELEAVE:
if (app != nullptr) {
app->HandleMouseLeave();
return 0;
}
break;
case WM_LBUTTONDOWN:
if (app != nullptr) {
app->HandleLeftButtonDown(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)),
static_cast<std::size_t>(wParam));
return 0;
}
break;
case WM_LBUTTONUP:
if (app != nullptr) {
app->HandleLeftButtonUp(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)),
static_cast<std::size_t>(wParam));
return 0;
}
break;
case WM_RBUTTONDOWN:
if (app != nullptr) {
app->HandleRightButtonDown(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)),
static_cast<std::size_t>(wParam));
return 0;
}
break;
case WM_RBUTTONUP:
if (app != nullptr) {
app->HandleRightButtonUp(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)),
static_cast<std::size_t>(wParam));
return 0;
}
break;
case WM_KEYDOWN:
case WM_SYSKEYDOWN:
if (app != nullptr) {
app->HandleKeyDown(wParam, lParam);
return 0;
}
break;
case WM_KEYUP:
case WM_SYSKEYUP:
if (app != nullptr) {
app->HandleKeyUp(wParam, lParam);
return 0;
}
break;
case WM_PAINT:
if (app != nullptr) {
PAINTSTRUCT paintStruct = {};
BeginPaint(hwnd, &paintStruct);
app->RenderFrame();
EndPaint(hwnd, &paintStruct);
return 0;
}
break;
case WM_ERASEBKGND:
return 1;
case WM_DESTROY:
if (app != nullptr) {
app->m_hwnd = nullptr;
}
PostQuitMessage(0);
return 0;
default:
break;
}
return DefWindowProcW(hwnd, message, wParam, lParam);
}
bool Initialize(HINSTANCE hInstance, int nCmdShow) {
WNDCLASSEXW windowClass = {};
windowClass.cbSize = sizeof(windowClass);
windowClass.style = CS_HREDRAW | CS_VREDRAW;
windowClass.lpfnWndProc = &ScenarioApp::WndProc;
windowClass.hInstance = hInstance;
windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
windowClass.lpszClassName = kWindowClassName;
m_windowClassAtom = RegisterClassExW(&windowClass);
if (m_windowClassAtom == 0) {
return false;
}
m_hwnd = CreateWindowExW(
0,
kWindowClassName,
kWindowTitle,
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
CW_USEDEFAULT,
CW_USEDEFAULT,
1540,
940,
nullptr,
nullptr,
hInstance,
this);
if (m_hwnd == nullptr) {
return false;
}
ShowWindow(m_hwnd, nCmdShow);
UpdateWindow(m_hwnd);
if (!m_renderer.Initialize(m_hwnd)) {
return false;
}
m_captureRoot =
ResolveRepoRootPath() / "tests/UI/Editor/integration/shell/list_view_multiselect/captures";
m_autoScreenshot.Initialize(m_captureRoot);
const auto themeLoad =
XCEngine::Tests::EditorUI::LoadEditorValidationTheme(ResolveValidationThemePath());
if (themeLoad.succeeded) {
m_theme = themeLoad.theme;
m_themeStatus = "loaded";
} else {
m_themeStatus = themeLoad.error.empty() ? "fallback" : themeLoad.error;
}
m_modifierTracker.SyncFromSystemState();
ResetScenario();
return true;
}
void Shutdown() {
m_autoScreenshot.Shutdown();
m_renderer.Shutdown();
if (m_hwnd != nullptr && IsWindow(m_hwnd)) {
DestroyWindow(m_hwnd);
}
m_hwnd = nullptr;
if (m_windowClassAtom != 0) {
UnregisterClassW(kWindowClassName, GetModuleHandleW(nullptr));
m_windowClassAtom = 0;
}
}
ScenarioLayout GetLayout() const {
RECT clientRect = {};
GetClientRect(m_hwnd, &clientRect);
const float width = static_cast<float>((std::max)(1L, clientRect.right - clientRect.left));
const float height = static_cast<float>((std::max)(1L, clientRect.bottom - clientRect.top));
return BuildScenarioLayout(
width,
height,
XCEngine::Tests::EditorUI::ResolveEditorValidationShellMetrics(m_theme));
}
void ResetScenario() {
m_items = BuildListItems();
m_selectionModel = {};
m_selectionModel.SetSelections({ "material", "script" }, "script");
m_interactionState = {};
m_interactionState.listViewState.focused = true;
m_mousePosition = UIPoint(-1000.0f, -1000.0f);
m_hasHoveredAction = false;
m_hoveredAction = ActionId::Reset;
m_lastModifiers = {};
m_lastResult = "已重置到默认多选状态";
RefreshFrame();
}
void RefreshFrame() {
if (m_hwnd == nullptr) {
return;
}
const ScenarioLayout layout = GetLayout();
m_frame = UpdateUIEditorListViewInteraction(
m_interactionState,
m_selectionModel,
layout.listRect,
m_items,
{});
}
void OnResize(UINT width, UINT height) {
if (width == 0u || height == 0u) {
return;
}
m_renderer.Resize(width, height);
RefreshFrame();
}
void HandleMouseMove(float x, float y, std::size_t keyState) {
m_modifierTracker.SyncFromSystemState();
m_mousePosition = UIPoint(x, y);
const ScenarioLayout layout = GetLayout();
UpdateHoveredAction(layout, x, y);
TRACKMOUSEEVENT trackEvent = {};
trackEvent.cbSize = sizeof(trackEvent);
trackEvent.dwFlags = TME_LEAVE;
trackEvent.hwndTrack = m_hwnd;
TrackMouseEvent(&trackEvent);
const UIInputModifiers modifiers = m_modifierTracker.BuildPointerModifiers(keyState);
m_lastModifiers = modifiers;
PumpEvents({ MakePointerEvent(UIInputEventType::PointerMove, m_mousePosition, modifiers) });
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleMouseLeave() {
m_mousePosition = UIPoint(-1000.0f, -1000.0f);
m_hasHoveredAction = false;
PumpEvents({ MakePointerEvent(UIInputEventType::PointerLeave, m_mousePosition, {}) });
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleLeftButtonDown(float x, float y, std::size_t keyState) {
m_modifierTracker.SyncFromSystemState();
m_mousePosition = UIPoint(x, y);
const ScenarioLayout layout = GetLayout();
if (HitTestAction(layout, x, y) != nullptr) {
UpdateHoveredAction(layout, x, y);
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
const UIInputModifiers modifiers = m_modifierTracker.BuildPointerModifiers(keyState);
m_lastModifiers = modifiers;
PumpEvents({ MakePointerEvent(UIInputEventType::PointerButtonDown, m_mousePosition, modifiers, UIPointerButton::Left) });
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleLeftButtonUp(float x, float y, std::size_t keyState) {
m_modifierTracker.SyncFromSystemState();
m_mousePosition = UIPoint(x, y);
const ScenarioLayout layout = GetLayout();
const ButtonLayout* button = HitTestAction(layout, x, y);
if (button != nullptr) {
ExecuteAction(button->action);
UpdateHoveredAction(layout, x, y);
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
const bool insideList = ContainsPoint(layout.listRect, x, y);
const bool wasFocused = m_interactionState.listViewState.focused;
const UIInputModifiers modifiers = m_modifierTracker.BuildPointerModifiers(keyState);
m_lastModifiers = modifiers;
const UIEditorListViewInteractionResult result =
PumpEvents({ MakePointerEvent(UIInputEventType::PointerButtonUp, m_mousePosition, modifiers, UIPointerButton::Left) });
UpdateResultText(result, insideList, wasFocused);
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleRightButtonDown(float x, float y, std::size_t keyState) {
m_modifierTracker.SyncFromSystemState();
m_mousePosition = UIPoint(x, y);
const UIInputModifiers modifiers = m_modifierTracker.BuildPointerModifiers(keyState);
m_lastModifiers = modifiers;
PumpEvents({ MakePointerEvent(UIInputEventType::PointerButtonDown, m_mousePosition, modifiers, UIPointerButton::Right) });
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleRightButtonUp(float x, float y, std::size_t keyState) {
m_modifierTracker.SyncFromSystemState();
m_mousePosition = UIPoint(x, y);
const UIInputModifiers modifiers = m_modifierTracker.BuildPointerModifiers(keyState);
m_lastModifiers = modifiers;
const UIEditorListViewInteractionResult result =
PumpEvents({ MakePointerEvent(UIInputEventType::PointerButtonUp, m_mousePosition, modifiers, UIPointerButton::Right) });
UpdateResultText(result, true, m_interactionState.listViewState.focused);
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleKeyDown(WPARAM wParam, LPARAM lParam) {
const UIInputModifiers modifiers = m_modifierTracker.ApplyKeyMessage(UIInputEventType::KeyDown, wParam, lParam);
m_lastModifiers = modifiers;
if (wParam == VK_F12) {
m_autoScreenshot.RequestCapture("manual_f12");
m_lastResult = "已请求截图,输出到 captures/latest.png";
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
const std::int32_t keyCode = MapListNavigationKey(static_cast<UINT>(wParam));
if (keyCode == static_cast<std::int32_t>(KeyCode::None)) {
return;
}
const UIEditorListViewInteractionResult result =
PumpEvents({ MakeKeyEvent(keyCode, modifiers) });
UpdateResultText(result, true, true);
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleKeyUp(WPARAM wParam, LPARAM lParam) {
m_lastModifiers = m_modifierTracker.ApplyKeyMessage(UIInputEventType::KeyUp, wParam, lParam);
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void UpdateHoveredAction(const ScenarioLayout& layout, float x, float y) {
const ButtonLayout* button = HitTestAction(layout, x, y);
if (button == nullptr) {
m_hasHoveredAction = false;
return;
}
m_hoveredAction = button->action;
m_hasHoveredAction = true;
}
const ButtonLayout* HitTestAction(const ScenarioLayout& layout, float x, float y) const {
for (const ButtonLayout& button : layout.buttons) {
if (ContainsPoint(button.rect, x, y)) {
return &button;
}
}
return nullptr;
}
UIEditorListViewInteractionResult PumpEvents(std::vector<UIInputEvent> events) {
const ScenarioLayout layout = GetLayout();
m_frame = UpdateUIEditorListViewInteraction(
m_interactionState,
m_selectionModel,
layout.listRect,
m_items,
std::move(events));
return m_frame.result;
}
void UpdateResultText(
const UIEditorListViewInteractionResult& result,
bool insideList,
bool wasFocused) {
if (result.secondaryClicked && !result.selectedItemId.empty()) {
m_lastResult =
"右键命中: " + result.selectedItemId +
" | primary=" + m_selectionModel.GetSelectedId() +
" | count=" + std::to_string(m_selectionModel.GetSelectionCount());
return;
}
if (result.keyboardNavigated && result.selectionChanged) {
m_lastResult = "键盘导航更新选集: " + JoinSelectedIds(m_selectionModel);
return;
}
if (result.keyboardNavigated && !result.selectedItemId.empty()) {
m_lastResult = "键盘导航到: " + result.selectedItemId;
return;
}
if (result.selectionChanged) {
m_lastResult =
"选集已更新: primary=" + m_selectionModel.GetSelectedId() +
" | ids=" + JoinSelectedIds(m_selectionModel);
return;
}
if (!insideList && wasFocused && !m_interactionState.listViewState.focused) {
m_lastResult = "点击列表外空白focus 已清除selection 保留";
return;
}
if (insideList) {
m_lastResult = "点击列表内空白,只更新 hover / focus";
return;
}
m_lastResult = "等待交互";
}
void ExecuteAction(ActionId action) {
switch (action) {
case ActionId::Reset:
ResetScenario();
break;
case ActionId::Capture:
m_autoScreenshot.RequestCapture("manual_button");
m_lastResult = "已请求截图,输出到 captures/latest.png";
break;
}
}
void RenderFrame() {
if (m_hwnd == nullptr) {
return;
}
RECT clientRect = {};
GetClientRect(m_hwnd, &clientRect);
const float width = static_cast<float>((std::max)(1L, clientRect.right - clientRect.left));
const float height = static_cast<float>((std::max)(1L, clientRect.bottom - clientRect.top));
const auto shellMetrics = XCEngine::Tests::EditorUI::ResolveEditorValidationShellMetrics(m_theme);
const auto shellPalette = XCEngine::Tests::EditorUI::ResolveEditorValidationShellPalette(m_theme);
const ScenarioLayout layout = BuildScenarioLayout(width, height, shellMetrics);
RefreshFrame();
const UIEditorListViewHitTarget currentHit =
HitTestUIEditorListView(m_frame.layout, m_mousePosition);
UIDrawData drawData = {};
UIDrawList& drawList = drawData.EmplaceDrawList("EditorListViewMultiSelect");
drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), shellPalette.windowBackground);
DrawCard(
drawList,
layout.introRect,
shellPalette,
shellMetrics,
"这个测试在验证什么功能?",
"只验证 Editor ListView 多选 contractCtrl/Shift 选集、右键 primary 切换、键盘范围扩展,不涉及任何业务面板。");
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 72.0f),
"1. Ctrl+左键:对单行做 add/remove多选集合必须稳定保留。",
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 94.0f),
"2. Shift+左键:以 anchor 为起点扩展范围primary 应切到当前点击行。",
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 116.0f),
"3. 右键命中已选集合:只切换 primary/context target不应破坏当前多选集合。",
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 138.0f),
"4. 列表获得 focus 后按 Up/Down/Home/End按住 Shift 时应扩展范围,不按 Shift 时应回到单选。",
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 160.0f),
"5. 点击列表外空白只清除 focus点击列表内空白只更新 hover/focusF12 或按钮触发截图。",
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
DrawCard(drawList, layout.controlRect, shellPalette, shellMetrics, "操作");
for (const ButtonLayout& button : layout.buttons) {
DrawButton(
drawList,
button,
shellPalette,
shellMetrics,
m_hasHoveredAction && m_hoveredAction == button.action);
}
DrawCard(
drawList,
layout.stateRect,
shellPalette,
shellMetrics,
"状态摘要",
"重点检查 primary / count / ids / anchor / current / hit / result。");
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 70.0f),
"Hover: " + DescribeHitTarget(currentHit, m_items),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 94.0f),
std::string("Focused: ") + (m_interactionState.listViewState.focused ? "on" : "off"),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 118.0f),
"Primary Selected: " +
(m_selectionModel.HasSelection() ? m_selectionModel.GetSelectedId() : std::string("(none)")),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 142.0f),
"Selected Count: " + std::to_string(m_selectionModel.GetSelectionCount()),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 166.0f),
"Selected IDs: " + JoinSelectedIds(m_selectionModel),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 190.0f),
"Anchor ID: " + (m_interactionState.selectionAnchorId.empty()
? std::string("(none)")
: m_interactionState.selectionAnchorId),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 214.0f),
std::string("Current: ") +
(m_interactionState.keyboardNavigation.HasCurrentIndex()
? std::to_string(m_interactionState.keyboardNavigation.GetCurrentIndex())
: std::string("(none)")),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 238.0f),
"Modifiers: " + DescribeModifiers(m_lastModifiers),
shellPalette.textMuted,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 262.0f),
"Result: " + m_lastResult,
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
const std::string captureSummary =
m_autoScreenshot.HasPendingCapture()
? "截图排队中..."
: (m_autoScreenshot.GetLastCaptureSummary().empty()
? std::string("F12 -> tests/UI/Editor/integration/shell/list_view_multiselect/captures/")
: m_autoScreenshot.GetLastCaptureSummary());
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 286.0f),
captureSummary,
shellPalette.textWeak,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 310.0f),
"Theme: " + m_themeStatus,
shellPalette.textWeak,
shellMetrics.bodyFontSize);
DrawCard(
drawList,
layout.previewRect,
shellPalette,
shellMetrics,
"ListView 预览",
"这里只放一个 ListView。默认状态下已选中 material + script方便直接检查多选 contract。");
AppendUIEditorListViewBackground(
drawList,
m_frame.layout,
m_items,
m_selectionModel,
m_interactionState.listViewState);
AppendUIEditorListViewForeground(drawList, m_frame.layout, m_items);
const bool framePresented = m_renderer.Render(drawData);
m_autoScreenshot.CaptureIfRequested(
m_renderer,
drawData,
static_cast<unsigned int>(width),
static_cast<unsigned int>(height),
framePresented);
}
HWND m_hwnd = nullptr;
ATOM m_windowClassAtom = 0;
NativeRenderer m_renderer = {};
AutoScreenshotController m_autoScreenshot = {};
InputModifierTracker m_modifierTracker = {};
std::filesystem::path m_captureRoot = {};
Style::UITheme m_theme = {};
std::string m_themeStatus = "fallback";
std::vector<UIEditorListViewItem> m_items = {};
UISelectionModel m_selectionModel = {};
UIEditorListViewInteractionState m_interactionState = {};
UIEditorListViewInteractionFrame m_frame = {};
UIPoint m_mousePosition = UIPoint(-1000.0f, -1000.0f);
ActionId m_hoveredAction = ActionId::Reset;
bool m_hasHoveredAction = false;
UIInputModifiers m_lastModifiers = {};
std::string m_lastResult = {};
};
} // namespace
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return ScenarioApp().Run(hInstance, nCmdShow);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -2,14 +2,15 @@
#define NOMINMAX
#endif
#include <XCEditor/Core/UIEditorTabStripInteraction.h>
#include <XCEditor/Core/UIEditorPanelRegistry.h>
#include <XCEditor/Core/UIEditorWorkspaceController.h>
#include <XCEditor/Widgets/UIEditorTabStrip.h>
#include "Host/AutoScreenshot.h"
#include "Host/NativeRenderer.h"
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/DrawData.h>
#include <XCEngine/UI/Widgets/UITabStripModel.h>
#include <windows.h>
#include <windowsx.h>
@@ -27,12 +28,15 @@
namespace {
using XCEngine::Input::KeyCode;
using XCEngine::UI::UIColor;
using XCEngine::UI::UIDrawData;
using XCEngine::UI::UIDrawList;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIPointerButton;
using XCEngine::UI::UIRect;
using XCEngine::UI::Widgets::UITabStripModel;
using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController;
using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel;
using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack;
@@ -43,6 +47,9 @@ using XCEngine::UI::Editor::Host::AutoScreenshotController;
using XCEngine::UI::Editor::Host::NativeRenderer;
using XCEngine::UI::Editor::UIEditorPanelDescriptor;
using XCEngine::UI::Editor::UIEditorPanelRegistry;
using XCEngine::UI::Editor::UIEditorTabStripInteractionFrame;
using XCEngine::UI::Editor::UIEditorTabStripInteractionResult;
using XCEngine::UI::Editor::UIEditorTabStripInteractionState;
using XCEngine::UI::Editor::UIEditorWorkspaceCommand;
using XCEngine::UI::Editor::UIEditorWorkspaceCommandKind;
using XCEngine::UI::Editor::UIEditorWorkspaceCommandResult;
@@ -50,9 +57,9 @@ using XCEngine::UI::Editor::UIEditorWorkspaceController;
using XCEngine::UI::Editor::UIEditorWorkspaceModel;
using XCEngine::UI::Editor::UIEditorWorkspaceNode;
using XCEngine::UI::Editor::UIEditorWorkspaceNodeKind;
using XCEngine::UI::Editor::UpdateUIEditorTabStripInteraction;
using XCEngine::UI::Editor::Widgets::AppendUIEditorTabStripBackground;
using XCEngine::UI::Editor::Widgets::AppendUIEditorTabStripForeground;
using XCEngine::UI::Editor::Widgets::BuildUIEditorTabStripLayout;
using XCEngine::UI::Editor::Widgets::HitTestUIEditorTabStrip;
using XCEngine::UI::Editor::Widgets::ResolveUIEditorTabStripSelectedIndex;
using XCEngine::UI::Editor::Widgets::UIEditorTabStripHitTarget;
@@ -93,6 +100,39 @@ bool ContainsPoint(const UIRect& rect, float x, float y) {
y <= rect.y + rect.height;
}
std::int32_t MapTabNavigationKey(UINT keyCode) {
switch (keyCode) {
case VK_LEFT:
return static_cast<std::int32_t>(KeyCode::Left);
case VK_RIGHT:
return static_cast<std::int32_t>(KeyCode::Right);
case VK_HOME:
return static_cast<std::int32_t>(KeyCode::Home);
case VK_END:
return static_cast<std::int32_t>(KeyCode::End);
default:
return static_cast<std::int32_t>(KeyCode::None);
}
}
UIInputEvent MakePointerEvent(
UIInputEventType type,
const UIPoint& position,
UIPointerButton button = UIPointerButton::None) {
UIInputEvent event = {};
event.type = type;
event.position = position;
event.pointerButton = button;
return event;
}
UIInputEvent MakeKeyEvent(std::int32_t keyCode) {
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = keyCode;
return event;
}
void DrawCard(
UIDrawList& drawList,
const UIRect& rect,
@@ -240,9 +280,17 @@ private:
return 0;
}
break;
case WM_LBUTTONDOWN:
if (app != nullptr) {
app->HandleLeftButtonDown(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)));
return 0;
}
break;
case WM_LBUTTONUP:
if (app != nullptr) {
app->HandleClick(
app->HandleLeftButtonUp(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)));
return 0;
@@ -254,7 +302,10 @@ private:
if (wParam == VK_F12) {
app->m_autoScreenshot.RequestCapture("manual_f12");
} else {
app->HandleKeyDown(static_cast<UINT>(wParam));
const std::int32_t keyCode = MapTabNavigationKey(static_cast<UINT>(wParam));
if (keyCode != static_cast<std::int32_t>(KeyCode::None)) {
app->HandleKeyDown(keyCode);
}
}
return 0;
}
@@ -347,13 +398,30 @@ private:
void ResetScenario() {
m_controller =
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());
m_tabStripState = {};
m_layout = {};
m_interactionState = {};
m_tabStripFrame = {};
m_tabItems.clear();
m_hoverTarget = {};
m_mousePosition = UIPoint(-1000.0f, -1000.0f);
m_lastResult = "等待操作";
m_navigationModel = {};
}
UIRect GetTabStripRect() const {
RECT clientRect = {};
GetClientRect(m_hwnd, &clientRect);
const float width = static_cast<float>((std::max)(1L, clientRect.right - clientRect.left));
const float height = static_cast<float>((std::max)(1L, clientRect.bottom - clientRect.top));
const float outerPadding = 20.0f;
const float leftColumnWidth = 360.0f;
const UIRect previewCardRect(
leftColumnWidth + outerPadding * 2.0f,
outerPadding,
width - leftColumnWidth - outerPadding * 3.0f,
height - outerPadding * 2.0f);
return UIRect(
previewCardRect.x + 20.0f,
previewCardRect.y + 20.0f,
previewCardRect.width - 40.0f,
previewCardRect.height - 40.0f);
}
void OnResize(UINT width, UINT height) {
@@ -373,114 +441,97 @@ private:
TrackMouseEvent(&event);
m_resetButtonHovered = ContainsPoint(m_resetButtonRect, x, y);
RefreshHoverTarget();
PumpTabStripEvents({ MakePointerEvent(UIInputEventType::PointerMove, m_mousePosition) });
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleMouseLeave() {
m_mousePosition = UIPoint(-1000.0f, -1000.0f);
m_resetButtonHovered = false;
m_hoverTarget = {};
m_tabStripState.hoveredIndex = UIEditorTabStripInvalidIndex;
m_tabStripState.closeHoveredIndex = UIEditorTabStripInvalidIndex;
PumpTabStripEvents({ MakePointerEvent(UIInputEventType::PointerLeave, m_mousePosition) });
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleClick(float x, float y) {
void HandleLeftButtonDown(float x, float y) {
m_mousePosition = UIPoint(x, y);
if (ContainsPoint(m_resetButtonRect, x, y)) {
m_resetButtonHovered = true;
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
PumpTabStripEvents({ MakePointerEvent(UIInputEventType::PointerButtonDown, m_mousePosition, UIPointerButton::Left) });
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleLeftButtonUp(float x, float y) {
m_mousePosition = UIPoint(x, y);
if (ContainsPoint(m_resetButtonRect, x, y)) {
ResetScenario();
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
const UIEditorTabStripHitTarget hit =
HitTestUIEditorTabStrip(m_layout, m_tabStripState, UIPoint(x, y));
switch (hit.kind) {
case UIEditorTabStripHitTargetKind::CloseButton:
if (hit.index < m_tabItems.size()) {
DispatchCommand(
UIEditorWorkspaceCommandKind::ClosePanel,
m_tabItems[hit.index].tabId,
"Close " + m_tabItems[hit.index].title);
m_tabStripState.focused = true;
}
break;
case UIEditorTabStripHitTargetKind::Tab:
if (hit.index < m_tabItems.size()) {
DispatchCommand(
UIEditorWorkspaceCommandKind::ActivatePanel,
m_tabItems[hit.index].tabId,
"Activate " + m_tabItems[hit.index].title);
m_tabStripState.focused = true;
}
break;
case UIEditorTabStripHitTargetKind::HeaderBackground:
case UIEditorTabStripHitTargetKind::Content:
m_tabStripState.focused = true;
m_lastResult = "TabStrip 获得 focus";
break;
case UIEditorTabStripHitTargetKind::None:
default:
m_tabStripState.focused = false;
m_lastResult = "Focus cleared";
break;
}
RefreshHoverTarget();
const UIEditorTabStripInteractionResult result =
PumpTabStripEvents({ MakePointerEvent(UIInputEventType::PointerButtonUp, m_mousePosition, UIPointerButton::Left) });
ApplyInteractionResult(result, "Mouse");
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleKeyDown(UINT keyCode) {
if (!m_tabStripState.focused) {
return;
}
void HandleKeyDown(std::int32_t keyCode) {
const UIEditorTabStripInteractionResult result =
PumpTabStripEvents({ MakeKeyEvent(keyCode) });
ApplyInteractionResult(result, "Keyboard");
InvalidateRect(m_hwnd, nullptr, FALSE);
}
UIEditorTabStripInteractionResult PumpTabStripEvents(std::vector<UIInputEvent> events) {
RefreshTabItems();
bool handled = true;
bool changed = false;
std::string action = {};
std::string selectedTabId = m_controller.GetWorkspace().activePanelId;
m_tabStripFrame = UpdateUIEditorTabStripInteraction(
m_interactionState,
selectedTabId,
GetTabStripRect(),
m_tabItems,
events);
m_tabStripState = m_interactionState.tabStripState;
m_layout = m_tabStripFrame.layout;
m_hoverTarget = HitTestUIEditorTabStrip(m_layout, m_tabStripState, m_mousePosition);
return m_tabStripFrame.result;
}
switch (keyCode) {
case VK_LEFT:
action = "Keyboard Left";
changed = m_navigationModel.SelectPrevious();
break;
case VK_RIGHT:
action = "Keyboard Right";
changed = m_navigationModel.SelectNext();
break;
case VK_HOME:
action = "Keyboard Home";
changed = m_navigationModel.SelectFirst();
break;
case VK_END:
action = "Keyboard End";
changed = m_navigationModel.SelectLast();
break;
default:
handled = false;
break;
}
if (!handled) {
void ApplyInteractionResult(
const UIEditorTabStripInteractionResult& result,
std::string_view source) {
if (result.closeRequested && !result.closedTabId.empty()) {
DispatchCommand(
UIEditorWorkspaceCommandKind::ClosePanel,
result.closedTabId,
std::string(source) + " Close -> " + result.closedTabId);
PumpTabStripEvents({});
return;
}
if (!changed || !m_navigationModel.HasSelection()) {
m_lastResult = action + " -> NoOp";
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
const std::size_t selectedIndex = m_navigationModel.GetSelectedIndex();
if (selectedIndex < m_tabItems.size()) {
if ((result.selectionChanged || result.keyboardNavigated) &&
!result.selectedTabId.empty()) {
DispatchCommand(
UIEditorWorkspaceCommandKind::ActivatePanel,
m_tabItems[selectedIndex].tabId,
action + " -> " + m_tabItems[selectedIndex].title);
result.selectedTabId,
std::string(source) + " Activate -> " + result.selectedTabId);
PumpTabStripEvents({});
return;
}
InvalidateRect(m_hwnd, nullptr, FALSE);
if (result.hitTarget.kind == UIEditorTabStripHitTargetKind::HeaderBackground ||
result.hitTarget.kind == UIEditorTabStripHitTargetKind::Content) {
m_lastResult = "TabStrip 获得 focus";
return;
}
if (result.hitTarget.kind == UIEditorTabStripHitTargetKind::None &&
!m_interactionState.tabStripState.focused) {
m_lastResult = "Focus cleared";
}
}
void DispatchCommand(
@@ -527,31 +578,6 @@ private:
}
}
m_tabStripState.selectedIndex =
ResolveUIEditorTabStripSelectedIndex(m_tabItems, workspace.activePanelId);
m_navigationModel.SetItemCount(m_tabItems.size());
if (m_tabStripState.selectedIndex != UIEditorTabStripInvalidIndex) {
m_navigationModel.SetSelectedIndex(m_tabStripState.selectedIndex);
}
}
void RefreshHoverTarget() {
m_hoverTarget = HitTestUIEditorTabStrip(m_layout, m_tabStripState, m_mousePosition);
m_tabStripState.hoveredIndex = UIEditorTabStripInvalidIndex;
m_tabStripState.closeHoveredIndex = UIEditorTabStripInvalidIndex;
if (m_hoverTarget.kind == UIEditorTabStripHitTargetKind::CloseButton &&
m_hoverTarget.index < m_tabItems.size()) {
m_tabStripState.hoveredIndex = m_hoverTarget.index;
m_tabStripState.closeHoveredIndex = m_hoverTarget.index;
return;
}
if (m_hoverTarget.kind == UIEditorTabStripHitTargetKind::Tab &&
m_hoverTarget.index < m_tabItems.size()) {
m_tabStripState.hoveredIndex = m_hoverTarget.index;
}
}
void RenderFrame() {
@@ -569,15 +595,7 @@ private:
outerPadding,
width - leftColumnWidth - outerPadding * 3.0f,
height - outerPadding * 2.0f);
const UIRect tabStripRect(
previewCardRect.x + 20.0f,
previewCardRect.y + 20.0f,
previewCardRect.width - 40.0f,
previewCardRect.height - 40.0f);
RefreshTabItems();
m_layout = BuildUIEditorTabStripLayout(tabStripRect, m_tabItems, m_tabStripState);
RefreshHoverTarget();
PumpTabStripEvents({});
m_resetButtonRect = UIRect(
stateRect.x + 16.0f,
@@ -723,10 +741,11 @@ private:
std::filesystem::path m_captureRoot = {};
UIPoint m_mousePosition = UIPoint(-1000.0f, -1000.0f);
UIEditorWorkspaceController m_controller = {};
UIEditorTabStripInteractionState m_interactionState = {};
UIEditorTabStripInteractionFrame m_tabStripFrame = {};
UIEditorTabStripState m_tabStripState = {};
UIEditorTabStripLayout m_layout = {};
std::vector<UIEditorTabStripItem> m_tabItems = {};
UITabStripModel m_navigationModel = {};
UIEditorTabStripHitTarget m_hoverTarget = {};
UIRect m_resetButtonRect = {};
bool m_resetButtonHovered = false;

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -2,11 +2,12 @@
#define NOMINMAX
#endif
#include <XCEditor/Core/UIEditorTreeViewInteraction.h>
#include <XCEditor/Widgets/UIEditorTreeView.h>
#include <XCEditor/Collections/UIEditorTreeView.h>
#include <XCEditor/Collections/UIEditorTreeViewInteraction.h>
#include "Host/AutoScreenshot.h"
#include "Host/NativeRenderer.h"
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/DrawData.h>
#include <XCEngine/UI/Widgets/UIExpansionModel.h>
#include <XCEngine/UI/Widgets/UISelectionModel.h>
@@ -28,6 +29,7 @@
namespace {
using XCEngine::Input::KeyCode;
using XCEngine::UI::UIColor;
using XCEngine::UI::UIDrawData;
using XCEngine::UI::UIDrawList;
@@ -101,6 +103,25 @@ bool ContainsPoint(const UIRect& rect, float x, float y) {
y <= rect.y + rect.height;
}
std::int32_t MapTreeNavigationKey(UINT keyCode) {
switch (keyCode) {
case VK_UP:
return static_cast<std::int32_t>(KeyCode::Up);
case VK_DOWN:
return static_cast<std::int32_t>(KeyCode::Down);
case VK_LEFT:
return static_cast<std::int32_t>(KeyCode::Left);
case VK_RIGHT:
return static_cast<std::int32_t>(KeyCode::Right);
case VK_HOME:
return static_cast<std::int32_t>(KeyCode::Home);
case VK_END:
return static_cast<std::int32_t>(KeyCode::End);
default:
return static_cast<std::int32_t>(KeyCode::None);
}
}
ScenarioLayout BuildScenarioLayout(float width, float height) {
constexpr float margin = 20.0f;
constexpr float leftWidth = 430.0f;
@@ -204,10 +225,11 @@ std::string JoinVisibleItems(
constexpr std::size_t kMaxVisibleLabels = 5u;
std::ostringstream stream = {};
const std::size_t labelCount = (std::min)(layout.visibleItemIndices.size(), kMaxVisibleLabels);
for (std::size_t index = 0; index < labelCount; ++index) {
for (std::size_t index = 0u; index < labelCount; ++index) {
if (index > 0u) {
stream << " | ";
}
const std::size_t itemIndex = layout.visibleItemIndices[index];
if (itemIndex < items.size()) {
stream << items[itemIndex].label;
@@ -225,7 +247,7 @@ std::string DescribeHitTarget(
const UIEditorTreeViewHitTarget& hitTarget,
const std::vector<UIEditorTreeViewItem>& items) {
if (hitTarget.itemIndex >= items.size()) {
return "";
return "(none)";
}
const std::string& label = items[hitTarget.itemIndex].label;
@@ -236,7 +258,7 @@ std::string DescribeHitTarget(
return "row: " + label;
case UIEditorTreeViewHitTargetKind::None:
default:
return "";
return "(none)";
}
}
@@ -251,6 +273,13 @@ UIInputEvent MakePointerEvent(
return event;
}
UIInputEvent MakeKeyEvent(std::int32_t keyCode) {
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = keyCode;
return event;
}
class ScenarioApp {
public:
int Run(HINSTANCE hInstance, int nCmdShow) {
@@ -328,11 +357,19 @@ private:
case WM_KEYDOWN:
case WM_SYSKEYDOWN:
if (app != nullptr && wParam == VK_F12) {
app->m_autoScreenshot.RequestCapture("manual_f12");
app->m_lastResult = "已请求截图,输出到 captures/latest.png";
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
if (app != nullptr) {
if (wParam == VK_F12) {
app->m_autoScreenshot.RequestCapture("manual_f12");
app->m_lastResult = "已请求截图,输出到 captures/latest.png";
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
const std::int32_t keyCode = MapTreeNavigationKey(static_cast<UINT>(wParam));
if (keyCode != static_cast<std::int32_t>(KeyCode::None)) {
app->HandleNavigationKey(keyCode);
return 0;
}
}
break;
@@ -528,6 +565,14 @@ private:
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleNavigationKey(std::int32_t keyCode) {
const bool wasFocused = m_interactionState.treeViewState.focused;
const UIEditorTreeViewInteractionResult result =
PumpTreeEvents({ MakeKeyEvent(keyCode) });
UpdateResultText(result, wasFocused, true);
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void UpdateHoveredAction(const ScenarioLayout& layout, float x, float y) {
const ButtonLayout* button = HitTestAction(layout, x, y);
if (button == nullptr) {
@@ -558,7 +603,7 @@ private:
m_expansionModel,
layout.treeRect,
m_items,
events);
std::move(events));
return m_treeFrame.result;
}
@@ -566,6 +611,21 @@ private:
const UIEditorTreeViewInteractionResult& result,
bool wasFocused,
bool insideTree) {
if (result.keyboardNavigated && result.expansionChanged && !result.toggledItemId.empty()) {
m_lastResult = "键盘切换展开: " + result.toggledItemId;
return;
}
if (result.keyboardNavigated && !result.selectedItemId.empty()) {
m_lastResult = "键盘选择: " + result.selectedItemId;
return;
}
if (result.expansionChanged && result.selectionChanged && !result.selectedItemId.empty()) {
m_lastResult = "折叠后回收 selection: " + result.selectedItemId;
return;
}
if (result.expansionChanged && !result.toggledItemId.empty()) {
m_lastResult = "切换展开: " + result.toggledItemId;
return;
@@ -582,7 +642,7 @@ private:
}
if (insideTree) {
m_lastResult = "点击树内空白: 更新 focus / hover";
m_lastResult = "点击树内空白: 更新 focus / hover";
return;
}
@@ -624,31 +684,31 @@ private:
DrawCard(
drawList,
layout.introRect,
"这个测试在验证什么功能",
"只验证 Editor TreeView 基础控件,不涉及任何业务面板。");
"这个测试在验证什么功能",
"只验证 Editor TreeView 的单选、层级展开/折叠和键盘导航契约,不涉及任何业务面板。");
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 72.0f),
"1. 验证行缩进是否正确Scene 的子项右移一层Directional Light 再右移一层",
"1. 点击 row只切换 selectionhover / selected / focused 必须能明确区分",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 94.0f),
"2. 点击 disclosure 只切换展开/折叠,不应误改 selection。",
"2. 点击 disclosure只切换展开/折叠,不应误改 selection。",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 116.0f),
"3. 点击行只切换 selectionhover、selected、focused 三种视觉状态要能区分",
"3. 按 Left / Right验证折叠、展开以及父子层级跳转",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 138.0f),
"4. 点击树外空白后 focus 应清除,但 selection 不应丢失",
"4. 按 Up / Down / Home / End验证可见行导航以及 current 与 selection 同步",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 160.0f),
"5. F12 手动截图;设置 XCUI_AUTO_CAPTURE_ON_STARTUP=1 自动截图。",
"5. 点击树外空白清除 focusF12 手动截图XCUI_AUTO_CAPTURE_ON_STARTUP=1 自动截图。",
kTextPrimary,
12.0f);
@@ -660,7 +720,7 @@ private:
m_hasHoveredAction && m_hoveredAction == button.action);
}
DrawCard(drawList, layout.stateRect, "状态摘要", "重点检查 hit / focus / selection / expanded / visible。");
DrawCard(drawList, layout.stateRect, "状态摘要", "重点检查 hit / focus / selection / current / expanded / visible。");
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 70.0f),
"Hover: " + DescribeHitTarget(currentHit, m_items),
@@ -668,7 +728,7 @@ private:
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 94.0f),
std::string("Focused: ") + (m_interactionState.treeViewState.focused ? "" : ""),
std::string("Focused: ") + (m_interactionState.treeViewState.focused ? "on" : "off"),
kTextPrimary,
12.0f);
drawList.AddText(
@@ -679,17 +739,25 @@ private:
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 142.0f),
"Expanded: " + JoinExpandedItems(m_items, m_expansionModel),
std::string("Current: ") +
(m_interactionState.keyboardNavigation.HasCurrentIndex()
? std::to_string(m_interactionState.keyboardNavigation.GetCurrentIndex())
: std::string("(none)")),
kTextMuted,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 166.0f),
"Expanded: " + JoinExpandedItems(m_items, m_expansionModel),
kTextMuted,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 190.0f),
"Visible(" + std::to_string(m_treeFrame.layout.visibleItemIndices.size()) + "): " +
JoinVisibleItems(m_items, m_treeFrame.layout),
kTextMuted,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 190.0f),
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 214.0f),
"Result: " + m_lastResult,
kTextPrimary,
12.0f);
@@ -701,12 +769,12 @@ private:
? std::string("F12 -> tests/UI/Editor/integration/shell/tree_view_basic/captures/")
: m_autoScreenshot.GetLastCaptureSummary());
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 216.0f),
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 238.0f),
captureSummary,
kTextWeak,
12.0f);
DrawCard(drawList, layout.previewRect, "TreeView 预览", "这里只放一个 TreeView不混入 Hierarchy/Inspector 等业务内容。");
DrawCard(drawList, layout.previewRect, "TreeView 预览", "这里只放一个 TreeView不混入 Hierarchy / Inspector 等业务内容。");
AppendUIEditorTreeViewBackground(
drawList,
m_treeFrame.layout,

View File

@@ -0,0 +1,31 @@
add_executable(editor_ui_tree_view_inline_rename_validation WIN32
main.cpp
)
target_include_directories(editor_ui_tree_view_inline_rename_validation PRIVATE
${CMAKE_SOURCE_DIR}/new_editor/include
${CMAKE_SOURCE_DIR}/new_editor/app
${CMAKE_SOURCE_DIR}/engine/include
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
)
target_compile_definitions(editor_ui_tree_view_inline_rename_validation PRIVATE
UNICODE
_UNICODE
XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}"
)
if(MSVC)
target_compile_options(editor_ui_tree_view_inline_rename_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_tree_view_inline_rename_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_tree_view_inline_rename_validation PRIVATE
XCUIEditorLib
XCUIEditorHost
)
set_target_properties(editor_ui_tree_view_inline_rename_validation PROPERTIES
OUTPUT_NAME "XCUIEditorTreeViewInlineRenameValidation"
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,958 @@
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <XCEditor/Collections/UIEditorInlineRenameSession.h>
#include <XCEditor/Collections/UIEditorTreeView.h>
#include <XCEditor/Collections/UIEditorTreeViewInteraction.h>
#include <XCEditor/Fields/UIEditorTextField.h>
#include <XCEditor/Foundation/UIEditorTheme.h>
#include "EditorValidationTheme.h"
#include "Host/AutoScreenshot.h"
#include "Host/NativeRenderer.h"
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/DrawData.h>
#include <XCEngine/UI/Style/Theme.h>
#include <XCEngine/UI/Widgets/UIExpansionModel.h>
#include <XCEngine/UI/Widgets/UISelectionModel.h>
#include <windows.h>
#include <windowsx.h>
#include <algorithm>
#include <cstdint>
#include <filesystem>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT
#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "."
#endif
namespace {
using XCEngine::Input::KeyCode;
using XCEngine::Tests::EditorUI::EditorValidationShellMetrics;
using XCEngine::Tests::EditorUI::EditorValidationShellPalette;
using XCEngine::UI::UIDrawData;
using XCEngine::UI::UIDrawList;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIPointerButton;
using XCEngine::UI::UIRect;
using XCEngine::UI::Widgets::UIExpansionModel;
using XCEngine::UI::Widgets::UISelectionModel;
using XCEngine::UI::Editor::BuildUIEditorHostedTextFieldMetrics;
using XCEngine::UI::Editor::BuildUIEditorHostedTextFieldPalette;
using XCEngine::UI::Editor::BuildUIEditorInlineRenameTextFieldMetrics;
using XCEngine::UI::Editor::Host::AutoScreenshotController;
using XCEngine::UI::Editor::Host::NativeRenderer;
using XCEngine::UI::Editor::ResolveUIEditorPropertyGridMetrics;
using XCEngine::UI::Editor::ResolveUIEditorPropertyGridPalette;
using XCEngine::UI::Editor::ResolveUIEditorTextFieldMetrics;
using XCEngine::UI::Editor::ResolveUIEditorTextFieldPalette;
using XCEngine::UI::Editor::UIEditorInlineRenameSessionFrame;
using XCEngine::UI::Editor::UIEditorInlineRenameSessionRequest;
using XCEngine::UI::Editor::UIEditorInlineRenameSessionState;
using XCEngine::UI::Editor::UIEditorTreeViewInteractionFrame;
using XCEngine::UI::Editor::UIEditorTreeViewInteractionResult;
using XCEngine::UI::Editor::UIEditorTreeViewInteractionState;
using XCEngine::UI::Editor::UpdateUIEditorInlineRenameSession;
using XCEngine::UI::Editor::UpdateUIEditorTreeViewInteraction;
using XCEngine::UI::Editor::Widgets::AppendUIEditorTextFieldBackground;
using XCEngine::UI::Editor::Widgets::AppendUIEditorTextFieldForeground;
using XCEngine::UI::Editor::Widgets::AppendUIEditorTreeViewBackground;
using XCEngine::UI::Editor::Widgets::AppendUIEditorTreeViewForeground;
using XCEngine::UI::Editor::Widgets::FindUIEditorTreeViewItemIndex;
using XCEngine::UI::Editor::Widgets::HitTestUIEditorTreeView;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewHitTarget;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewHitTargetKind;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewInvalidIndex;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewItem;
using XCEngine::UI::Editor::Widgets::UIEditorTextFieldMetrics;
using XCEngine::UI::Editor::Widgets::UIEditorTextFieldPalette;
namespace Style = XCEngine::UI::Style;
constexpr const wchar_t* kWindowClassName = L"XCUIEditorTreeViewInlineRenameValidation";
constexpr const wchar_t* kWindowTitle = L"XCUI Editor | TreeView Inline Rename";
enum class ActionId : unsigned char { Reset = 0, Capture };
struct ButtonLayout { ActionId action = ActionId::Reset; const char* label = ""; UIRect rect = {}; };
struct ScenarioLayout {
UIRect introRect = {};
UIRect controlRect = {};
UIRect stateRect = {};
UIRect previewRect = {};
UIRect treeRect = {};
std::vector<ButtonLayout> buttons = {};
};
std::filesystem::path ResolveRepoRootPath();
std::filesystem::path ResolveValidationThemePath();
bool ContainsPoint(const UIRect& rect, float x, float y);
std::int32_t MapTreeKey(UINT keyCode);
ScenarioLayout BuildScenarioLayout(float width, float height, const EditorValidationShellMetrics& shellMetrics);
void DrawCard(UIDrawList& drawList, const UIRect& rect, const EditorValidationShellPalette& shellPalette, const EditorValidationShellMetrics& shellMetrics, std::string_view title, std::string_view subtitle = {});
void DrawButton(UIDrawList& drawList, const ButtonLayout& button, const EditorValidationShellPalette& shellPalette, const EditorValidationShellMetrics& shellMetrics, bool hovered);
std::vector<UIEditorTreeViewItem> BuildTreeItems();
std::string DescribeHitTarget(const UIEditorTreeViewHitTarget& hitTarget, const std::vector<UIEditorTreeViewItem>& items);
UIInputEvent MakePointerEvent(UIInputEventType type, const UIPoint& position, UIPointerButton button = UIPointerButton::None);
UIInputEvent MakeKeyEvent(std::int32_t keyCode);
UIInputEvent MakeCharacterEvent(wchar_t character);
class ScenarioApp {
public:
int Run(HINSTANCE hInstance, int nCmdShow);
private:
static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);
bool Initialize(HINSTANCE hInstance, int nCmdShow);
void Shutdown();
ScenarioLayout GetLayout() const;
void ResetScenario();
void RefreshTreeFrame();
void OnResize(UINT width, UINT height);
void HandleMouseMove(float x, float y);
void HandleMouseLeave();
void HandleLeftButtonDown(float x, float y);
void HandleLeftButtonUp(float x, float y);
void HandleKeyDown(UINT virtualKey);
void HandleCharacter(wchar_t character);
void UpdateHoveredAction(const ScenarioLayout& layout, float x, float y);
const ButtonLayout* HitTestAction(const ScenarioLayout& layout, float x, float y) const;
UIEditorTreeViewInteractionResult PumpTreeEvents(std::vector<UIInputEvent> events);
void BeginRename(const std::string& itemId);
void PumpRenameEvents(std::vector<UIInputEvent> events);
void ApplyRenameFrame(const UIEditorInlineRenameSessionFrame& frame);
void UpdateTreeResultText(const UIEditorTreeViewInteractionResult& result);
UIRect BuildRenameBoundsForActiveItem() const;
UIRect BuildRenameBounds(std::size_t itemIndex) const;
UIEditorInlineRenameSessionRequest BuildRenameRequest(bool beginSession) const;
UIEditorTextFieldMetrics ResolveHostedTextFieldMetrics() const;
UIEditorTextFieldMetrics ResolveInlineRenameMetrics(const UIRect& bounds) const;
UIEditorTextFieldPalette ResolveInlineRenamePalette() const;
void ExecuteAction(ActionId action);
void RenderFrame();
HWND m_hwnd = nullptr;
ATOM m_windowClassAtom = 0;
NativeRenderer m_renderer = {};
AutoScreenshotController m_autoScreenshot = {};
std::filesystem::path m_captureRoot = {};
Style::UITheme m_theme = {};
std::string m_themeStatus = "fallback";
std::vector<UIEditorTreeViewItem> m_items = {};
UISelectionModel m_selectionModel = {};
UIExpansionModel m_expansionModel = {};
UIEditorTreeViewInteractionState m_treeInteractionState = {};
UIEditorTreeViewInteractionFrame m_treeFrame = {};
UIEditorInlineRenameSessionState m_renameState = {};
UIEditorInlineRenameSessionFrame m_renameFrame = {};
UIPoint m_mousePosition = UIPoint(-1000.0f, -1000.0f);
ActionId m_hoveredAction = ActionId::Reset;
bool m_hasHoveredAction = false;
std::string m_lastCommittedItemId = {};
std::string m_lastCommittedValue = {};
std::string m_lastResult = {};
};
std::filesystem::path ResolveRepoRootPath() {
std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT;
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
root = root.substr(1u, root.size() - 2u);
}
return std::filesystem::path(root).lexically_normal();
}
std::filesystem::path ResolveValidationThemePath() {
return (ResolveRepoRootPath() / "tests/UI/Editor/integration/shared/themes/editor_validation.xctheme").lexically_normal();
}
bool ContainsPoint(const UIRect& rect, float x, float y) {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
std::int32_t MapTreeKey(UINT keyCode) {
switch (keyCode) {
case VK_UP: return static_cast<std::int32_t>(KeyCode::Up);
case VK_DOWN: return static_cast<std::int32_t>(KeyCode::Down);
case VK_LEFT: return static_cast<std::int32_t>(KeyCode::Left);
case VK_RIGHT: return static_cast<std::int32_t>(KeyCode::Right);
case VK_HOME: return static_cast<std::int32_t>(KeyCode::Home);
case VK_END: return static_cast<std::int32_t>(KeyCode::End);
case VK_BACK: return static_cast<std::int32_t>(KeyCode::Backspace);
case VK_DELETE: return static_cast<std::int32_t>(KeyCode::Delete);
case VK_RETURN: return static_cast<std::int32_t>(KeyCode::Enter);
case VK_ESCAPE: return static_cast<std::int32_t>(KeyCode::Escape);
case VK_F2: return static_cast<std::int32_t>(KeyCode::F2);
default: return static_cast<std::int32_t>(KeyCode::None);
}
}
ScenarioLayout BuildScenarioLayout(float width, float height, const EditorValidationShellMetrics& shellMetrics) {
const float margin = shellMetrics.margin;
constexpr float leftWidth = 470.0f;
const float gap = shellMetrics.gap;
ScenarioLayout layout = {};
layout.introRect = UIRect(margin, margin, leftWidth, 252.0f);
layout.controlRect = UIRect(margin, layout.introRect.y + layout.introRect.height + gap, leftWidth, 84.0f);
layout.stateRect = UIRect(margin, layout.controlRect.y + layout.controlRect.height + gap, leftWidth, (std::max)(260.0f, height - (layout.controlRect.y + layout.controlRect.height + gap) - margin));
layout.previewRect = UIRect(leftWidth + margin * 2.0f, margin, (std::max)(520.0f, width - leftWidth - margin * 3.0f), height - margin * 2.0f);
layout.treeRect = UIRect(layout.previewRect.x + 22.0f, layout.previewRect.y + 72.0f, layout.previewRect.width - 44.0f, layout.previewRect.height - 104.0f);
const float buttonWidth = (layout.controlRect.width - 44.0f) * 0.5f;
const float buttonY = layout.controlRect.y + 32.0f;
layout.buttons = {
{ ActionId::Reset, "重置", UIRect(layout.controlRect.x + 14.0f, buttonY, buttonWidth, 36.0f) },
{ ActionId::Capture, "截图(F12)", UIRect(layout.controlRect.x + 26.0f + buttonWidth, buttonY, buttonWidth, 36.0f) }
};
return layout;
}
void DrawCard(UIDrawList& drawList, const UIRect& rect, const EditorValidationShellPalette& shellPalette, const EditorValidationShellMetrics& shellMetrics, std::string_view title, std::string_view subtitle) {
drawList.AddFilledRect(rect, shellPalette.cardBackground, shellMetrics.cardRadius);
drawList.AddRectOutline(rect, shellPalette.cardBorder, 1.0f, shellMetrics.cardRadius);
drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 14.0f), std::string(title), shellPalette.textPrimary, shellMetrics.titleFontSize);
if (!subtitle.empty()) {
drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 40.0f), std::string(subtitle), shellPalette.textMuted, shellMetrics.bodyFontSize);
}
}
void DrawButton(UIDrawList& drawList, const ButtonLayout& button, const EditorValidationShellPalette& shellPalette, const EditorValidationShellMetrics& shellMetrics, bool hovered) {
drawList.AddFilledRect(button.rect, hovered ? shellPalette.buttonHoverBackground : shellPalette.buttonBackground, shellMetrics.buttonRadius);
drawList.AddRectOutline(button.rect, shellPalette.cardBorder, 1.0f, shellMetrics.buttonRadius);
drawList.AddText(UIPoint(button.rect.x + 16.0f, button.rect.y + 10.0f), button.label, shellPalette.textPrimary, shellMetrics.bodyFontSize);
}
std::vector<UIEditorTreeViewItem> BuildTreeItems() {
return {
{ "scene", "Scene", 0u, false, 0.0f },
{ "camera", "Camera", 1u, true, 0.0f },
{ "lights", "Lights", 1u, false, 0.0f },
{ "directional-light", "Directional Light", 2u, true, 0.0f },
{ "fill-light", "Fill Light", 2u, true, 0.0f },
{ "characters", "Characters", 0u, false, 0.0f },
{ "hero", "Hero", 1u, false, 0.0f },
{ "hero-mesh", "HeroMesh", 2u, true, 0.0f },
{ "hero-rig", "HeroRig", 2u, true, 0.0f }
};
}
std::string DescribeHitTarget(const UIEditorTreeViewHitTarget& hitTarget, const std::vector<UIEditorTreeViewItem>& items) {
if (hitTarget.kind == UIEditorTreeViewHitTargetKind::None || hitTarget.itemIndex >= items.size()) {
return "(none)";
}
return items[hitTarget.itemIndex].itemId;
}
UIInputEvent MakePointerEvent(UIInputEventType type, const UIPoint& position, UIPointerButton button) {
UIInputEvent event = {};
event.type = type;
event.position = position;
event.pointerButton = button;
return event;
}
UIInputEvent MakeKeyEvent(std::int32_t keyCode) {
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = keyCode;
return event;
}
UIInputEvent MakeCharacterEvent(wchar_t character) {
UIInputEvent event = {};
event.type = UIInputEventType::Character;
event.character = static_cast<std::uint32_t>(character);
return event;
}
int ScenarioApp::Run(HINSTANCE hInstance, int nCmdShow) {
if (!Initialize(hInstance, nCmdShow)) {
Shutdown();
return 1;
}
MSG message = {};
while (message.message != WM_QUIT) {
if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) {
TranslateMessage(&message);
DispatchMessageW(&message);
continue;
}
RenderFrame();
Sleep(8);
}
Shutdown();
return static_cast<int>(message.wParam);
}
LRESULT CALLBACK ScenarioApp::WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
if (message == WM_NCCREATE) {
const auto* createStruct = reinterpret_cast<CREATESTRUCTW*>(lParam);
auto* app = reinterpret_cast<ScenarioApp*>(createStruct->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(app));
return TRUE;
}
auto* app = reinterpret_cast<ScenarioApp*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
switch (message) {
case WM_SIZE: if (app != nullptr && wParam != SIZE_MINIMIZED) { app->OnResize(static_cast<UINT>(LOWORD(lParam)), static_cast<UINT>(HIWORD(lParam))); } return 0;
case WM_MOUSEMOVE: if (app != nullptr) { app->HandleMouseMove(static_cast<float>(GET_X_LPARAM(lParam)), static_cast<float>(GET_Y_LPARAM(lParam))); return 0; } break;
case WM_MOUSELEAVE: if (app != nullptr) { app->HandleMouseLeave(); return 0; } break;
case WM_LBUTTONDOWN: if (app != nullptr) { app->HandleLeftButtonDown(static_cast<float>(GET_X_LPARAM(lParam)), static_cast<float>(GET_Y_LPARAM(lParam))); return 0; } break;
case WM_LBUTTONUP: if (app != nullptr) { app->HandleLeftButtonUp(static_cast<float>(GET_X_LPARAM(lParam)), static_cast<float>(GET_Y_LPARAM(lParam))); return 0; } break;
case WM_KEYDOWN:
case WM_SYSKEYDOWN: if (app != nullptr) { app->HandleKeyDown(static_cast<UINT>(wParam)); return 0; } break;
case WM_CHAR: if (app != nullptr) { app->HandleCharacter(static_cast<wchar_t>(wParam)); return 0; } break;
case WM_PAINT:
if (app != nullptr) {
PAINTSTRUCT paintStruct = {};
BeginPaint(hwnd, &paintStruct);
app->RenderFrame();
EndPaint(hwnd, &paintStruct);
return 0;
}
break;
case WM_ERASEBKGND: return 1;
case WM_DESTROY: if (app != nullptr) { app->m_hwnd = nullptr; } PostQuitMessage(0); return 0;
default: break;
}
return DefWindowProcW(hwnd, message, wParam, lParam);
}
bool ScenarioApp::Initialize(HINSTANCE hInstance, int nCmdShow) {
WNDCLASSEXW windowClass = {};
windowClass.cbSize = sizeof(windowClass);
windowClass.style = CS_HREDRAW | CS_VREDRAW;
windowClass.lpfnWndProc = &ScenarioApp::WndProc;
windowClass.hInstance = hInstance;
windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
windowClass.lpszClassName = kWindowClassName;
m_windowClassAtom = RegisterClassExW(&windowClass);
if (m_windowClassAtom == 0) {
return false;
}
m_hwnd = CreateWindowExW(0, kWindowClassName, kWindowTitle, WS_OVERLAPPEDWINDOW | WS_VISIBLE, CW_USEDEFAULT, CW_USEDEFAULT, 1540, 940, nullptr, nullptr, hInstance, this);
if (m_hwnd == nullptr) {
return false;
}
ShowWindow(m_hwnd, nCmdShow);
UpdateWindow(m_hwnd);
if (!m_renderer.Initialize(m_hwnd)) {
return false;
}
m_captureRoot = ResolveRepoRootPath() / "tests/UI/Editor/integration/shell/tree_view_inline_rename/captures";
m_autoScreenshot.Initialize(m_captureRoot);
const auto themeLoad = XCEngine::Tests::EditorUI::LoadEditorValidationTheme(ResolveValidationThemePath());
if (themeLoad.succeeded) {
m_theme = themeLoad.theme;
m_themeStatus = "loaded";
} else {
m_themeStatus = themeLoad.error.empty() ? "fallback" : themeLoad.error;
}
ResetScenario();
return true;
}
void ScenarioApp::Shutdown() {
m_autoScreenshot.Shutdown();
m_renderer.Shutdown();
if (m_hwnd != nullptr && IsWindow(m_hwnd)) {
DestroyWindow(m_hwnd);
}
m_hwnd = nullptr;
if (m_windowClassAtom != 0) {
UnregisterClassW(kWindowClassName, GetModuleHandleW(nullptr));
m_windowClassAtom = 0;
}
}
ScenarioLayout ScenarioApp::GetLayout() const {
RECT clientRect = {};
GetClientRect(m_hwnd, &clientRect);
const float width = static_cast<float>((std::max)(1L, clientRect.right - clientRect.left));
const float height = static_cast<float>((std::max)(1L, clientRect.bottom - clientRect.top));
return BuildScenarioLayout(width, height, XCEngine::Tests::EditorUI::ResolveEditorValidationShellMetrics(m_theme));
}
void ScenarioApp::ResetScenario() {
m_items = BuildTreeItems();
m_selectionModel = {};
m_selectionModel.SetSelection("hero-mesh");
m_expansionModel = {};
m_expansionModel.Expand("scene");
m_expansionModel.Expand("lights");
m_expansionModel.Expand("characters");
m_expansionModel.Expand("hero");
m_treeInteractionState = {};
m_treeInteractionState.treeViewState.focused = true;
m_renameState = {};
m_renameFrame = {};
m_mousePosition = UIPoint(-1000.0f, -1000.0f);
m_hoveredAction = ActionId::Reset;
m_hasHoveredAction = false;
m_lastCommittedItemId.clear();
m_lastCommittedValue.clear();
m_lastResult = "已重置到默认状态,准备进入 hero-mesh 的 inline rename。";
RefreshTreeFrame();
BeginRename("hero-mesh");
}
void ScenarioApp::RefreshTreeFrame() {
if (m_hwnd == nullptr) {
return;
}
const ScenarioLayout layout = GetLayout();
m_treeFrame = UpdateUIEditorTreeViewInteraction(m_treeInteractionState, m_selectionModel, m_expansionModel, layout.treeRect, m_items, {});
if (m_renameState.active) {
const UIEditorInlineRenameSessionRequest request = BuildRenameRequest(false);
m_renameFrame = UpdateUIEditorInlineRenameSession(m_renameState, request, {}, ResolveInlineRenameMetrics(request.bounds));
ApplyRenameFrame(m_renameFrame);
}
}
void ScenarioApp::OnResize(UINT width, UINT height) {
if (width == 0u || height == 0u) {
return;
}
m_renderer.Resize(width, height);
RefreshTreeFrame();
}
void ScenarioApp::HandleMouseMove(float x, float y) {
m_mousePosition = UIPoint(x, y);
const ScenarioLayout layout = GetLayout();
UpdateHoveredAction(layout, x, y);
TRACKMOUSEEVENT trackEvent = {};
trackEvent.cbSize = sizeof(trackEvent);
trackEvent.dwFlags = TME_LEAVE;
trackEvent.hwndTrack = m_hwnd;
TrackMouseEvent(&trackEvent);
const UIInputEvent pointerEvent =
MakePointerEvent(UIInputEventType::PointerMove, m_mousePosition);
if (m_renameState.active) {
PumpRenameEvents({ pointerEvent });
} else {
PumpTreeEvents({ pointerEvent });
}
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void ScenarioApp::HandleMouseLeave() {
m_mousePosition = UIPoint(-1000.0f, -1000.0f);
m_hasHoveredAction = false;
const UIInputEvent leaveEvent =
MakePointerEvent(UIInputEventType::PointerLeave, m_mousePosition);
if (m_renameState.active) {
PumpRenameEvents({ leaveEvent });
} else {
PumpTreeEvents({ leaveEvent });
}
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void ScenarioApp::HandleLeftButtonDown(float x, float y) {
m_mousePosition = UIPoint(x, y);
const ScenarioLayout layout = GetLayout();
if (HitTestAction(layout, x, y) != nullptr) {
UpdateHoveredAction(layout, x, y);
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
const UIInputEvent pointerEvent =
MakePointerEvent(UIInputEventType::PointerButtonDown, m_mousePosition, UIPointerButton::Left);
if (m_renameState.active) {
const UIRect renameBounds = BuildRenameBoundsForActiveItem();
const bool insideRename = ContainsPoint(renameBounds, x, y);
PumpRenameEvents({ pointerEvent });
if (!insideRename && !m_renameState.active) {
PumpTreeEvents({ pointerEvent });
}
} else {
PumpTreeEvents({ pointerEvent });
}
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void ScenarioApp::HandleLeftButtonUp(float x, float y) {
m_mousePosition = UIPoint(x, y);
const ScenarioLayout layout = GetLayout();
const ButtonLayout* button = HitTestAction(layout, x, y);
if (button != nullptr) {
ExecuteAction(button->action);
UpdateHoveredAction(layout, x, y);
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
const UIInputEvent pointerEvent =
MakePointerEvent(UIInputEventType::PointerButtonUp, m_mousePosition, UIPointerButton::Left);
if (m_renameState.active) {
const UIRect renameBounds = BuildRenameBoundsForActiveItem();
const bool insideRename = ContainsPoint(renameBounds, x, y);
PumpRenameEvents({ pointerEvent });
if (!insideRename && !m_renameState.active) {
const UIEditorTreeViewInteractionResult result = PumpTreeEvents({ pointerEvent });
UpdateTreeResultText(result);
}
} else {
const UIEditorTreeViewInteractionResult result = PumpTreeEvents({ pointerEvent });
UpdateTreeResultText(result);
}
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void ScenarioApp::HandleKeyDown(UINT virtualKey) {
if (virtualKey == VK_F12) {
m_autoScreenshot.RequestCapture("manual_f12");
m_lastResult = "已请求截图,输出到 captures/latest.png";
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
const std::int32_t keyCode = MapTreeKey(virtualKey);
if (keyCode == static_cast<std::int32_t>(KeyCode::None)) {
return;
}
if (m_renameState.active) {
PumpRenameEvents({ MakeKeyEvent(keyCode) });
} else {
const UIEditorTreeViewInteractionResult result =
PumpTreeEvents({ MakeKeyEvent(keyCode) });
if (result.renameRequested) {
BeginRename(result.renameItemId);
} else {
UpdateTreeResultText(result);
}
}
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void ScenarioApp::HandleCharacter(wchar_t character) {
if (character < 32 || !m_renameState.active) {
return;
}
PumpRenameEvents({ MakeCharacterEvent(character) });
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void ScenarioApp::UpdateHoveredAction(const ScenarioLayout& layout, float x, float y) {
const ButtonLayout* button = HitTestAction(layout, x, y);
if (button == nullptr) {
m_hasHoveredAction = false;
return;
}
m_hoveredAction = button->action;
m_hasHoveredAction = true;
}
const ButtonLayout* ScenarioApp::HitTestAction(const ScenarioLayout& layout, float x, float y) const {
for (const ButtonLayout& button : layout.buttons) {
if (ContainsPoint(button.rect, x, y)) {
return &button;
}
}
return nullptr;
}
UIEditorTreeViewInteractionResult ScenarioApp::PumpTreeEvents(std::vector<UIInputEvent> events) {
const ScenarioLayout layout = GetLayout();
m_treeFrame = UpdateUIEditorTreeViewInteraction(
m_treeInteractionState,
m_selectionModel,
m_expansionModel,
layout.treeRect,
m_items,
std::move(events));
return m_treeFrame.result;
}
void ScenarioApp::BeginRename(const std::string& itemId) {
if (itemId.empty()) {
return;
}
const std::size_t itemIndex = FindUIEditorTreeViewItemIndex(m_items, itemId);
if (itemIndex == UIEditorTreeViewInvalidIndex) {
return;
}
UIEditorInlineRenameSessionRequest request = {};
request.beginSession = true;
request.itemId = itemId;
request.initialText = m_items[itemIndex].label;
request.bounds = BuildRenameBounds(itemIndex);
m_renameFrame = UpdateUIEditorInlineRenameSession(
m_renameState,
request,
{},
ResolveInlineRenameMetrics(request.bounds));
ApplyRenameFrame(m_renameFrame);
}
void ScenarioApp::PumpRenameEvents(std::vector<UIInputEvent> events) {
if (!m_renameState.active) {
return;
}
const UIEditorInlineRenameSessionRequest request = BuildRenameRequest(false);
m_renameFrame = UpdateUIEditorInlineRenameSession(
m_renameState,
request,
std::move(events),
ResolveInlineRenameMetrics(request.bounds));
ApplyRenameFrame(m_renameFrame);
}
void ScenarioApp::ApplyRenameFrame(const UIEditorInlineRenameSessionFrame& frame) {
const auto& result = frame.result;
if (result.sessionStarted) {
m_lastResult = "已进入 inline rename: " + result.itemId;
return;
}
if (result.sessionCommitted) {
const std::size_t itemIndex = FindUIEditorTreeViewItemIndex(m_items, result.itemId);
if (itemIndex != UIEditorTreeViewInvalidIndex) {
m_items[itemIndex].label = result.valueAfter;
m_lastCommittedItemId = result.itemId;
m_lastCommittedValue = result.valueAfter;
RefreshTreeFrame();
}
m_lastResult = "已提交 rename: " + result.itemId + " -> " + result.valueAfter;
return;
}
if (result.sessionCanceled) {
m_lastResult = "已取消 rename: " + result.itemId;
return;
}
if (m_renameState.active && result.textFieldResult.consumed) {
m_lastResult =
"编辑中: " + m_renameState.itemId + " -> " +
m_renameState.textFieldInteraction.textFieldState.displayText;
}
}
void ScenarioApp::UpdateTreeResultText(const UIEditorTreeViewInteractionResult& result) {
if (result.renameRequested) {
m_lastResult = "收到 rename 请求: " + result.renameItemId;
return;
}
if (result.keyboardNavigated && !result.selectedItemId.empty()) {
m_lastResult = "键盘导航到: " + result.selectedItemId;
return;
}
if (result.expansionChanged && !result.toggledItemId.empty()) {
m_lastResult = "切换展开: " + result.toggledItemId;
return;
}
if (result.selectionChanged && !result.selectedItemId.empty()) {
m_lastResult = "选中行: " + result.selectedItemId;
return;
}
if (result.consumed && result.hitTarget.kind == UIEditorTreeViewHitTargetKind::Row) {
m_lastResult = "点击行: " + DescribeHitTarget(result.hitTarget, m_items);
return;
}
if (result.consumed && result.hitTarget.kind == UIEditorTreeViewHitTargetKind::Disclosure) {
m_lastResult = "点击 disclosure: " + DescribeHitTarget(result.hitTarget, m_items);
return;
}
m_lastResult = "等待交互";
}
UIRect ScenarioApp::BuildRenameBoundsForActiveItem() const {
if (!m_renameState.active) {
return {};
}
const std::size_t itemIndex = FindUIEditorTreeViewItemIndex(m_items, m_renameState.itemId);
return BuildRenameBounds(itemIndex);
}
UIRect ScenarioApp::BuildRenameBounds(std::size_t itemIndex) const {
if (itemIndex == UIEditorTreeViewInvalidIndex) {
return {};
}
const auto& layout = m_treeFrame.layout;
std::size_t visibleIndex = UIEditorTreeViewInvalidIndex;
for (std::size_t index = 0u; index < layout.visibleItemIndices.size(); ++index) {
if (layout.visibleItemIndices[index] == itemIndex) {
visibleIndex = index;
break;
}
}
if (visibleIndex == UIEditorTreeViewInvalidIndex ||
visibleIndex >= layout.labelRects.size() ||
visibleIndex >= layout.rowRects.size()) {
return {};
}
const UIEditorTextFieldMetrics hostedMetrics = ResolveHostedTextFieldMetrics();
const UIRect& rowRect = layout.rowRects[visibleIndex];
const UIRect& labelRect = layout.labelRects[visibleIndex];
const float x = (std::max)(rowRect.x, labelRect.x - hostedMetrics.valueTextInsetX);
const float right = rowRect.x + rowRect.width - 8.0f;
const float width = (std::max)(120.0f, right - x);
return UIRect(x, rowRect.y, width, rowRect.height);
}
UIEditorInlineRenameSessionRequest ScenarioApp::BuildRenameRequest(bool beginSession) const {
UIEditorInlineRenameSessionRequest request = {};
request.beginSession = beginSession;
request.itemId = m_renameState.itemId;
request.initialText = m_renameState.textFieldSpec.value;
request.bounds = BuildRenameBoundsForActiveItem();
return request;
}
UIEditorTextFieldMetrics ScenarioApp::ResolveHostedTextFieldMetrics() const {
const auto propertyMetrics = ResolveUIEditorPropertyGridMetrics(m_theme);
const auto textMetrics = ResolveUIEditorTextFieldMetrics(m_theme);
return BuildUIEditorHostedTextFieldMetrics(propertyMetrics, textMetrics);
}
UIEditorTextFieldMetrics ScenarioApp::ResolveInlineRenameMetrics(const UIRect& bounds) const {
return BuildUIEditorInlineRenameTextFieldMetrics(bounds, ResolveHostedTextFieldMetrics());
}
UIEditorTextFieldPalette ScenarioApp::ResolveInlineRenamePalette() const {
const auto propertyPalette = ResolveUIEditorPropertyGridPalette(m_theme);
const auto textPalette = ResolveUIEditorTextFieldPalette(m_theme);
return BuildUIEditorHostedTextFieldPalette(propertyPalette, textPalette);
}
void ScenarioApp::ExecuteAction(ActionId action) {
switch (action) {
case ActionId::Reset:
ResetScenario();
break;
case ActionId::Capture:
m_autoScreenshot.RequestCapture("manual_button");
m_lastResult = "已请求截图,输出到 captures/latest.png";
break;
}
}
void ScenarioApp::RenderFrame() {
if (m_hwnd == nullptr) {
return;
}
RECT clientRect = {};
GetClientRect(m_hwnd, &clientRect);
const float width = static_cast<float>((std::max)(1L, clientRect.right - clientRect.left));
const float height = static_cast<float>((std::max)(1L, clientRect.bottom - clientRect.top));
const auto shellMetrics =
XCEngine::Tests::EditorUI::ResolveEditorValidationShellMetrics(m_theme);
const auto shellPalette =
XCEngine::Tests::EditorUI::ResolveEditorValidationShellPalette(m_theme);
const ScenarioLayout layout = BuildScenarioLayout(width, height, shellMetrics);
RefreshTreeFrame();
const UIEditorTreeViewHitTarget currentHit =
HitTestUIEditorTreeView(m_treeFrame.layout, m_mousePosition);
std::vector<UIEditorTreeViewItem> renderItems = m_items;
if (m_renameState.active) {
const std::size_t activeIndex = FindUIEditorTreeViewItemIndex(m_items, m_renameState.itemId);
if (activeIndex != UIEditorTreeViewInvalidIndex && activeIndex < renderItems.size()) {
renderItems[activeIndex].label.clear();
}
}
UIDrawData drawData = {};
UIDrawList& drawList = drawData.EmplaceDrawList("EditorTreeViewInlineRename");
drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), shellPalette.windowBackground);
DrawCard(
drawList,
layout.introRect,
shellPalette,
shellMetrics,
"这个测试在验证什么功能?",
"只验证 Editor TreeView 的 inline rename 契约默认进入编辑、字符编辑、Enter 提交、Esc 取消、点击外部提交。");
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 72.0f),
"1. 启动后默认进入 hero-mesh rename输入框只能覆盖 label 区。",
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 94.0f),
"2. 输入字符后Draft 必须实时变化,原标签不能和 overlay 叠字。",
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 116.0f),
"3. 按 Enter名称写回 TreeViewItem.label并退出 rename。",
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 138.0f),
"4. 按 Esc取消编辑保留原标签。",
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 160.0f),
"5. rename 中点击输入框外部:提交当前草稿并退出编辑态。",
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 182.0f),
"6. 需要时先按 Esc 退出,再单击树节点后按 F2F12 可截图。",
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
DrawCard(drawList, layout.controlRect, shellPalette, shellMetrics, "操作");
for (const ButtonLayout& button : layout.buttons) {
DrawButton(
drawList,
button,
shellPalette,
shellMetrics,
m_hasHoveredAction && m_hoveredAction == button.action);
}
DrawCard(
drawList,
layout.stateRect,
shellPalette,
shellMetrics,
"状态摘要",
"重点检查 TreeView 选择状态、可见索引和 rename 生命周期是否同步。");
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 70.0f),
"Hover: " + DescribeHitTarget(currentHit, m_items),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 94.0f),
std::string("Tree Focused: ") + (m_treeInteractionState.treeViewState.focused ? "on" : "off"),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 118.0f),
"Selected Item: " +
(m_selectionModel.HasSelection() ? m_selectionModel.GetSelectedId() : std::string("(none)")),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 142.0f),
std::string("Rename Active: ") + (m_renameState.active ? "yes" : "no"),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 166.0f),
"Rename Item: " + (m_renameState.active ? m_renameState.itemId : std::string("(none)")),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 190.0f),
"Draft: " + (m_renameState.active
? m_renameState.textFieldInteraction.textFieldState.displayText
: std::string("(none)")),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 214.0f),
"Committed: " + (m_lastCommittedValue.empty()
? std::string("(none)")
: (m_lastCommittedItemId + " -> " + m_lastCommittedValue)),
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 238.0f),
std::string("Current Index: ") +
(m_treeInteractionState.keyboardNavigation.HasCurrentIndex()
? std::to_string(m_treeInteractionState.keyboardNavigation.GetCurrentIndex())
: std::string("(none)")),
shellPalette.textMuted,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 262.0f),
"Visible Count: " + std::to_string(m_treeFrame.layout.visibleItemIndices.size()),
shellPalette.textMuted,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 286.0f),
"Result: " + m_lastResult,
shellPalette.textPrimary,
shellMetrics.bodyFontSize);
const std::string captureSummary =
m_autoScreenshot.HasPendingCapture()
? "截图排队中..."
: (m_autoScreenshot.GetLastCaptureSummary().empty()
? std::string("F12 -> tests/UI/Editor/integration/shell/tree_view_inline_rename/captures/")
: m_autoScreenshot.GetLastCaptureSummary());
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 310.0f),
captureSummary,
shellPalette.textWeak,
shellMetrics.bodyFontSize);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 334.0f),
"Theme: " + m_themeStatus,
shellPalette.textWeak,
shellMetrics.bodyFontSize);
DrawCard(
drawList,
layout.previewRect,
shellPalette,
shellMetrics,
"TreeView 预览",
"这里只放一个 TreeView启动时默认直接进入 hero-mesh 的 rename便于截图和自检。");
AppendUIEditorTreeViewBackground(
drawList,
m_treeFrame.layout,
renderItems,
m_selectionModel,
m_treeInteractionState.treeViewState);
AppendUIEditorTreeViewForeground(drawList, m_treeFrame.layout, renderItems);
if (m_renameState.active) {
const UIEditorTextFieldPalette palette = ResolveInlineRenamePalette();
const UIEditorTextFieldMetrics metrics =
ResolveInlineRenameMetrics(BuildRenameBoundsForActiveItem());
AppendUIEditorTextFieldBackground(
drawList,
m_renameFrame.layout,
m_renameState.textFieldSpec,
m_renameState.textFieldInteraction.textFieldState,
palette,
metrics);
AppendUIEditorTextFieldForeground(
drawList,
m_renameFrame.layout,
m_renameState.textFieldSpec,
m_renameState.textFieldInteraction.textFieldState,
palette,
metrics);
}
const bool framePresented = m_renderer.Render(drawData);
m_autoScreenshot.CaptureIfRequested(
m_renderer,
drawData,
static_cast<unsigned int>(width),
static_cast<unsigned int>(height),
framePresented);
}
} // namespace
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return ScenarioApp().Run(hInstance, nCmdShow);
}

View File

@@ -0,0 +1,30 @@
add_executable(editor_ui_tree_view_multiselect_validation WIN32
main.cpp
)
target_include_directories(editor_ui_tree_view_multiselect_validation PRIVATE
${CMAKE_SOURCE_DIR}/new_editor/include
${CMAKE_SOURCE_DIR}/new_editor/app
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_tree_view_multiselect_validation PRIVATE
UNICODE
_UNICODE
XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}"
)
if(MSVC)
target_compile_options(editor_ui_tree_view_multiselect_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_tree_view_multiselect_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_tree_view_multiselect_validation PRIVATE
XCUIEditorLib
XCUIEditorHost
)
set_target_properties(editor_ui_tree_view_multiselect_validation PROPERTIES
OUTPUT_NAME "XCUIEditorTreeViewMultiSelectValidation"
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -0,0 +1,971 @@
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <XCEditor/Collections/UIEditorTreeView.h>
#include <XCEditor/Collections/UIEditorTreeViewInteraction.h>
#include "Host/AutoScreenshot.h"
#include "Host/NativeRenderer.h"
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/DrawData.h>
#include <XCEngine/UI/Widgets/UIExpansionModel.h>
#include <XCEngine/UI/Widgets/UISelectionModel.h>
#include <windows.h>
#include <windowsx.h>
#include <algorithm>
#include <filesystem>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT
#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "."
#endif
namespace {
using XCEngine::Input::KeyCode;
using XCEngine::UI::UIColor;
using XCEngine::UI::UIDrawData;
using XCEngine::UI::UIDrawList;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIInputModifiers;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIPointerButton;
using XCEngine::UI::UIRect;
using XCEngine::UI::Widgets::UIExpansionModel;
using XCEngine::UI::Widgets::UISelectionModel;
using XCEngine::UI::Editor::Host::AutoScreenshotController;
using XCEngine::UI::Editor::Host::NativeRenderer;
using XCEngine::UI::Editor::UIEditorTreeViewInteractionFrame;
using XCEngine::UI::Editor::UIEditorTreeViewInteractionResult;
using XCEngine::UI::Editor::UIEditorTreeViewInteractionState;
using XCEngine::UI::Editor::UpdateUIEditorTreeViewInteraction;
using XCEngine::UI::Editor::Widgets::AppendUIEditorTreeViewBackground;
using XCEngine::UI::Editor::Widgets::AppendUIEditorTreeViewForeground;
using XCEngine::UI::Editor::Widgets::HitTestUIEditorTreeView;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewHitTarget;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewHitTargetKind;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewInvalidIndex;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewItem;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewLayout;
constexpr const wchar_t* kWindowClassName = L"XCUIEditorTreeViewMultiSelectValidation";
constexpr const wchar_t* kWindowTitle = L"XCUI Editor | TreeView MultiSelect";
constexpr UIColor kWindowBg(0.13f, 0.13f, 0.13f, 1.0f);
constexpr UIColor kCardBg(0.18f, 0.18f, 0.18f, 1.0f);
constexpr UIColor kCardBorder(0.29f, 0.29f, 0.29f, 1.0f);
constexpr UIColor kTextPrimary(0.94f, 0.94f, 0.94f, 1.0f);
constexpr UIColor kTextMuted(0.72f, 0.72f, 0.72f, 1.0f);
constexpr UIColor kTextWeak(0.56f, 0.56f, 0.56f, 1.0f);
constexpr UIColor kTextSuccess(0.63f, 0.76f, 0.63f, 1.0f);
constexpr UIColor kButtonBg(0.25f, 0.25f, 0.25f, 1.0f);
constexpr UIColor kButtonHoverBg(0.32f, 0.32f, 0.32f, 1.0f);
enum class ActionId : unsigned char {
Reset = 0,
Capture
};
struct ButtonLayout {
ActionId action = ActionId::Reset;
const char* label = "";
UIRect rect = {};
};
struct ScenarioLayout {
UIRect introRect = {};
UIRect controlRect = {};
UIRect stateRect = {};
UIRect previewRect = {};
UIRect treeRect = {};
std::vector<ButtonLayout> buttons = {};
};
std::filesystem::path ResolveRepoRootPath() {
std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT;
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
root = root.substr(1u, root.size() - 2u);
}
return std::filesystem::path(root).lexically_normal();
}
bool ContainsPoint(const UIRect& rect, float x, float y) {
return x >= rect.x &&
x <= rect.x + rect.width &&
y >= rect.y &&
y <= rect.y + rect.height;
}
const char* BoolText(bool value) {
return value ? "true" : "false";
}
UIInputModifiers QueryKeyboardModifiers() {
UIInputModifiers modifiers = {};
modifiers.shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;
modifiers.control = (GetKeyState(VK_CONTROL) & 0x8000) != 0;
modifiers.alt = (GetKeyState(VK_MENU) & 0x8000) != 0;
modifiers.super = (GetKeyState(VK_LWIN) & 0x8000) != 0 || (GetKeyState(VK_RWIN) & 0x8000) != 0;
return modifiers;
}
UIInputModifiers QueryPointerModifiers(WPARAM wParam) {
UIInputModifiers modifiers = QueryKeyboardModifiers();
modifiers.shift = modifiers.shift || (wParam & MK_SHIFT) != 0;
modifiers.control = modifiers.control || (wParam & MK_CONTROL) != 0;
return modifiers;
}
std::int32_t MapTreeNavigationKey(UINT keyCode) {
switch (keyCode) {
case VK_UP:
return static_cast<std::int32_t>(KeyCode::Up);
case VK_DOWN:
return static_cast<std::int32_t>(KeyCode::Down);
case VK_LEFT:
return static_cast<std::int32_t>(KeyCode::Left);
case VK_RIGHT:
return static_cast<std::int32_t>(KeyCode::Right);
case VK_HOME:
return static_cast<std::int32_t>(KeyCode::Home);
case VK_END:
return static_cast<std::int32_t>(KeyCode::End);
default:
return static_cast<std::int32_t>(KeyCode::None);
}
}
ScenarioLayout BuildScenarioLayout(float width, float height) {
constexpr float margin = 20.0f;
constexpr float leftWidth = 456.0f;
constexpr float gap = 16.0f;
ScenarioLayout layout = {};
layout.introRect = UIRect(margin, margin, leftWidth, 272.0f);
layout.controlRect = UIRect(margin, layout.introRect.y + layout.introRect.height + gap, leftWidth, 92.0f);
layout.stateRect = UIRect(
margin,
layout.controlRect.y + layout.controlRect.height + gap,
leftWidth,
(std::max)(240.0f, height - (layout.controlRect.y + layout.controlRect.height + gap) - margin));
layout.previewRect = UIRect(
leftWidth + margin * 2.0f,
margin,
(std::max)(420.0f, width - leftWidth - margin * 3.0f),
height - margin * 2.0f);
layout.treeRect = UIRect(
layout.previewRect.x + 18.0f,
layout.previewRect.y + 64.0f,
layout.previewRect.width - 36.0f,
layout.previewRect.height - 84.0f);
const float buttonWidth = (layout.controlRect.width - 44.0f) * 0.5f;
const float buttonY = layout.controlRect.y + 40.0f;
layout.buttons = {
{ ActionId::Reset, "重置", UIRect(layout.controlRect.x + 14.0f, buttonY, buttonWidth, 36.0f) },
{ ActionId::Capture, "截图(F12)", UIRect(layout.controlRect.x + 26.0f + buttonWidth, buttonY, buttonWidth, 36.0f) }
};
return layout;
}
void DrawCard(
UIDrawList& drawList,
const UIRect& rect,
std::string_view title,
std::string_view subtitle = {}) {
drawList.AddFilledRect(rect, kCardBg, 10.0f);
drawList.AddRectOutline(rect, kCardBorder, 1.0f, 10.0f);
drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 14.0f), std::string(title), kTextPrimary, 17.0f);
if (!subtitle.empty()) {
drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 40.0f), std::string(subtitle), kTextMuted, 12.0f);
}
}
void DrawButton(UIDrawList& drawList, const ButtonLayout& button, bool hovered) {
drawList.AddFilledRect(button.rect, hovered ? kButtonHoverBg : kButtonBg, 8.0f);
drawList.AddRectOutline(button.rect, kCardBorder, 1.0f, 8.0f);
drawList.AddText(UIPoint(button.rect.x + 16.0f, button.rect.y + 10.0f), button.label, kTextPrimary, 12.0f);
}
std::vector<UIEditorTreeViewItem> BuildTreeItems() {
return {
{ "scene", "Scene", 0u, false, 0.0f },
{ "camera", "Camera", 1u, true, 0.0f },
{ "lights", "Lights", 1u, false, 0.0f },
{ "directional-light", "Directional Light", 2u, true, 0.0f },
{ "fill-light", "Fill Light", 2u, true, 0.0f },
{ "characters", "Characters", 0u, false, 0.0f },
{ "hero", "Hero", 1u, false, 0.0f },
{ "hero-mesh", "HeroMesh", 2u, true, 0.0f },
{ "hero-rig", "HeroRig", 2u, true, 0.0f },
{ "ui-root", "UI Root", 0u, false, 0.0f },
{ "canvas", "Canvas", 1u, false, 0.0f },
{ "button", "Button", 2u, true, 0.0f },
{ "event-system", "EventSystem", 1u, true, 0.0f }
};
}
std::string JoinSelectedIds(const UISelectionModel& selectionModel) {
if (selectionModel.GetSelectedIds().empty()) {
return "(none)";
}
std::ostringstream stream = {};
for (std::size_t index = 0u; index < selectionModel.GetSelectedIds().size(); ++index) {
if (index > 0u) {
stream << " | ";
}
stream << selectionModel.GetSelectedIds()[index];
}
return stream.str();
}
std::string JoinExpandedIds(
const std::vector<UIEditorTreeViewItem>& items,
const UIExpansionModel& expansionModel) {
std::ostringstream stream = {};
bool first = true;
for (const UIEditorTreeViewItem& item : items) {
if (!expansionModel.IsExpanded(item.itemId)) {
continue;
}
if (!first) {
stream << " | ";
}
first = false;
stream << item.itemId;
}
return first ? "(none)" : stream.str();
}
std::string JoinVisibleIds(
const std::vector<UIEditorTreeViewItem>& items,
const UIEditorTreeViewLayout& layout) {
if (layout.visibleItemIndices.empty()) {
return "(none)";
}
std::ostringstream stream = {};
for (std::size_t visibleIndex = 0u; visibleIndex < layout.visibleItemIndices.size(); ++visibleIndex) {
if (visibleIndex > 0u) {
stream << " | ";
}
const std::size_t itemIndex = layout.visibleItemIndices[visibleIndex];
if (itemIndex < items.size()) {
stream << items[itemIndex].itemId;
}
}
return stream.str();
}
std::string DescribeHitTarget(
const UIEditorTreeViewHitTarget& hitTarget,
const std::vector<UIEditorTreeViewItem>& items) {
if (hitTarget.itemIndex >= items.size()) {
return "(none)";
}
const std::string& itemId = items[hitTarget.itemIndex].itemId;
switch (hitTarget.kind) {
case UIEditorTreeViewHitTargetKind::Disclosure:
return "disclosure: " + itemId;
case UIEditorTreeViewHitTargetKind::Row:
return "row: " + itemId;
case UIEditorTreeViewHitTargetKind::None:
default:
return "(none)";
}
}
UIInputEvent MakePointerEvent(
UIInputEventType type,
const UIPoint& position,
const UIInputModifiers& modifiers,
UIPointerButton button = UIPointerButton::None) {
UIInputEvent event = {};
event.type = type;
event.position = position;
event.modifiers = modifiers;
event.pointerButton = button;
return event;
}
UIInputEvent MakeKeyEvent(std::int32_t keyCode, const UIInputModifiers& modifiers) {
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = keyCode;
event.modifiers = modifiers;
return event;
}
class ScenarioApp {
public:
int Run(HINSTANCE hInstance, int nCmdShow) {
if (!Initialize(hInstance, nCmdShow)) {
Shutdown();
return 1;
}
MSG message = {};
while (message.message != WM_QUIT) {
if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) {
TranslateMessage(&message);
DispatchMessageW(&message);
continue;
}
RenderFrame();
Sleep(8);
}
Shutdown();
return static_cast<int>(message.wParam);
}
private:
static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
if (message == WM_NCCREATE) {
const auto* createStruct = reinterpret_cast<CREATESTRUCTW*>(lParam);
auto* app = reinterpret_cast<ScenarioApp*>(createStruct->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(app));
return TRUE;
}
auto* app = reinterpret_cast<ScenarioApp*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
switch (message) {
case WM_SIZE:
if (app != nullptr && wParam != SIZE_MINIMIZED) {
app->OnResize(static_cast<UINT>(LOWORD(lParam)), static_cast<UINT>(HIWORD(lParam)));
}
return 0;
case WM_MOUSEMOVE:
if (app != nullptr) {
app->HandleMouseMove(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)),
wParam);
return 0;
}
break;
case WM_MOUSELEAVE:
if (app != nullptr) {
app->HandleMouseLeave();
return 0;
}
break;
case WM_LBUTTONDOWN:
if (app != nullptr) {
app->HandleLeftButtonDown(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)),
wParam);
return 0;
}
break;
case WM_LBUTTONUP:
if (app != nullptr) {
app->HandleLeftButtonUp(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)),
wParam);
return 0;
}
break;
case WM_RBUTTONDOWN:
if (app != nullptr) {
app->HandleRightButtonDown(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)),
wParam);
return 0;
}
break;
case WM_RBUTTONUP:
if (app != nullptr) {
app->HandleRightButtonUp(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)),
wParam);
return 0;
}
break;
case WM_KEYDOWN:
case WM_SYSKEYDOWN:
if (app != nullptr) {
if (wParam == VK_F12) {
app->m_autoScreenshot.RequestCapture("manual_f12");
app->m_lastResult = "已请求截图,输出到 captures/latest.png";
app->m_resultFlags =
"selectionChanged=false, expansionChanged=false, keyboardNavigated=false, secondaryClicked=false, consumed=true";
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
const std::int32_t keyCode = MapTreeNavigationKey(static_cast<UINT>(wParam));
if (keyCode != static_cast<std::int32_t>(KeyCode::None)) {
app->HandleNavigationKey(keyCode, QueryKeyboardModifiers());
return 0;
}
}
break;
case WM_PAINT:
if (app != nullptr) {
PAINTSTRUCT paintStruct = {};
BeginPaint(hwnd, &paintStruct);
app->RenderFrame();
EndPaint(hwnd, &paintStruct);
return 0;
}
break;
case WM_ERASEBKGND:
return 1;
case WM_DESTROY:
if (app != nullptr) {
app->m_hwnd = nullptr;
}
PostQuitMessage(0);
return 0;
default:
break;
}
return DefWindowProcW(hwnd, message, wParam, lParam);
}
bool Initialize(HINSTANCE hInstance, int nCmdShow) {
WNDCLASSEXW windowClass = {};
windowClass.cbSize = sizeof(windowClass);
windowClass.style = CS_HREDRAW | CS_VREDRAW;
windowClass.lpfnWndProc = &ScenarioApp::WndProc;
windowClass.hInstance = hInstance;
windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
windowClass.lpszClassName = kWindowClassName;
m_windowClassAtom = RegisterClassExW(&windowClass);
if (m_windowClassAtom == 0) {
return false;
}
m_hwnd = CreateWindowExW(
0,
kWindowClassName,
kWindowTitle,
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
CW_USEDEFAULT,
CW_USEDEFAULT,
1480,
920,
nullptr,
nullptr,
hInstance,
this);
if (m_hwnd == nullptr) {
return false;
}
ShowWindow(m_hwnd, nCmdShow);
UpdateWindow(m_hwnd);
if (!m_renderer.Initialize(m_hwnd)) {
return false;
}
m_captureRoot =
ResolveRepoRootPath() / "tests/UI/Editor/integration/shell/tree_view_multiselect/captures";
m_autoScreenshot.Initialize(m_captureRoot);
ResetScenario();
return true;
}
void Shutdown() {
m_autoScreenshot.Shutdown();
m_renderer.Shutdown();
if (m_hwnd != nullptr && IsWindow(m_hwnd)) {
DestroyWindow(m_hwnd);
}
m_hwnd = nullptr;
if (m_windowClassAtom != 0) {
UnregisterClassW(kWindowClassName, GetModuleHandleW(nullptr));
m_windowClassAtom = 0;
}
}
ScenarioLayout GetLayout() const {
RECT clientRect = {};
GetClientRect(m_hwnd, &clientRect);
const float width = static_cast<float>((std::max)(1L, clientRect.right - clientRect.left));
const float height = static_cast<float>((std::max)(1L, clientRect.bottom - clientRect.top));
return BuildScenarioLayout(width, height);
}
void ResetScenario() {
m_items = BuildTreeItems();
m_selectionModel = {};
m_selectionModel.SetSelections({ "camera", "lights", "ui-root" }, "lights");
m_expansionModel = {};
m_expansionModel.Expand("scene");
m_expansionModel.Expand("lights");
m_expansionModel.Expand("characters");
m_expansionModel.Expand("hero");
m_expansionModel.Expand("ui-root");
m_expansionModel.Expand("canvas");
m_interactionState = {};
m_interactionState.treeViewState.focused = true;
m_interactionState.selectionAnchorId = "lights";
m_mousePosition = UIPoint(-1000.0f, -1000.0f);
m_hoveredAction = ActionId::Reset;
m_hasHoveredAction = false;
m_lastResult = "已重置到默认多选状态camera | lights | ui-root";
m_resultFlags =
"selectionChanged=false, expansionChanged=false, keyboardNavigated=false, secondaryClicked=false, consumed=false";
RefreshTreeFrame();
}
void RefreshTreeFrame() {
if (m_hwnd == nullptr) {
return;
}
const ScenarioLayout layout = GetLayout();
m_treeFrame = UpdateUIEditorTreeViewInteraction(
m_interactionState,
m_selectionModel,
m_expansionModel,
layout.treeRect,
m_items,
{});
}
void OnResize(UINT width, UINT height) {
if (width == 0u || height == 0u) {
return;
}
m_renderer.Resize(width, height);
RefreshTreeFrame();
}
void HandleMouseMove(float x, float y, WPARAM wParam) {
m_mousePosition = UIPoint(x, y);
const ScenarioLayout layout = GetLayout();
UpdateHoveredAction(layout, x, y);
TRACKMOUSEEVENT trackEvent = {};
trackEvent.cbSize = sizeof(trackEvent);
trackEvent.dwFlags = TME_LEAVE;
trackEvent.hwndTrack = m_hwnd;
TrackMouseEvent(&trackEvent);
PumpTreeEvents({ MakePointerEvent(UIInputEventType::PointerMove, m_mousePosition, QueryPointerModifiers(wParam)) });
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleMouseLeave() {
m_mousePosition = UIPoint(-1000.0f, -1000.0f);
m_hasHoveredAction = false;
PumpTreeEvents({ MakePointerEvent(UIInputEventType::PointerLeave, m_mousePosition, {}) });
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleLeftButtonDown(float x, float y, WPARAM wParam) {
m_mousePosition = UIPoint(x, y);
const ScenarioLayout layout = GetLayout();
if (HitTestAction(layout, x, y) != nullptr) {
UpdateHoveredAction(layout, x, y);
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
PumpTreeEvents({
MakePointerEvent(
UIInputEventType::PointerButtonDown,
m_mousePosition,
QueryPointerModifiers(wParam),
UIPointerButton::Left)
});
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleLeftButtonUp(float x, float y, WPARAM wParam) {
m_mousePosition = UIPoint(x, y);
const ScenarioLayout layout = GetLayout();
const ButtonLayout* button = HitTestAction(layout, x, y);
if (button != nullptr) {
ExecuteAction(button->action);
UpdateHoveredAction(layout, x, y);
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
const UIInputModifiers modifiers = QueryPointerModifiers(wParam);
const bool insideTree = ContainsPoint(layout.treeRect, x, y);
const UIEditorTreeViewInteractionResult result =
PumpTreeEvents({
MakePointerEvent(
UIInputEventType::PointerButtonUp,
m_mousePosition,
modifiers,
UIPointerButton::Left)
});
std::string actionLabel = "点击树外空白focus 清除";
if (result.hitTarget.kind == UIEditorTreeViewHitTargetKind::Disclosure) {
actionLabel = "点击 disclosure 切换展开,不改 selection";
} else if (result.hitTarget.kind == UIEditorTreeViewHitTargetKind::Row) {
if (modifiers.shift) {
actionLabel = "Shift+单击范围多选";
} else if (modifiers.control) {
actionLabel = "Ctrl+单击切换多选";
} else {
actionLabel = "单击单选";
}
} else if (insideTree) {
actionLabel = "点击树内空白,只更新 focus / hover";
}
UpdateResultSummary(result, actionLabel);
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleRightButtonDown(float x, float y, WPARAM wParam) {
m_mousePosition = UIPoint(x, y);
PumpTreeEvents({
MakePointerEvent(
UIInputEventType::PointerButtonDown,
m_mousePosition,
QueryPointerModifiers(wParam),
UIPointerButton::Right)
});
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleRightButtonUp(float x, float y, WPARAM wParam) {
m_mousePosition = UIPoint(x, y);
const UIEditorTreeViewHitTarget hitBefore = HitTestUIEditorTreeView(m_treeFrame.layout, m_mousePosition);
const std::size_t selectedCountBefore = m_selectionModel.GetSelectionCount();
bool targetAlreadySelected = false;
if (hitBefore.itemIndex < m_items.size()) {
targetAlreadySelected = m_selectionModel.IsSelected(m_items[hitBefore.itemIndex].itemId);
}
const UIEditorTreeViewInteractionResult result =
PumpTreeEvents({
MakePointerEvent(
UIInputEventType::PointerButtonUp,
m_mousePosition,
QueryPointerModifiers(wParam),
UIPointerButton::Right)
});
std::string actionLabel = "右键空白区域";
if (result.hitTarget.kind == UIEditorTreeViewHitTargetKind::Row ||
result.hitTarget.kind == UIEditorTreeViewHitTargetKind::Disclosure) {
if (targetAlreadySelected && selectedCountBefore > 1u) {
actionLabel = "右键命中已选集合,不打散 selection仅切换 primary";
} else if (targetAlreadySelected) {
actionLabel = "右键命中已选项,仅切换 primary";
} else {
actionLabel = "右键命中未选项,改为单选并触发 secondary click";
}
}
UpdateResultSummary(result, actionLabel);
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleNavigationKey(std::int32_t keyCode, const UIInputModifiers& modifiers) {
const UIEditorTreeViewInteractionResult result =
PumpTreeEvents({ MakeKeyEvent(keyCode, modifiers) });
std::string actionLabel = modifiers.shift
? "Shift+键盘范围扩选"
: "键盘导航";
if (keyCode == static_cast<std::int32_t>(KeyCode::Left) ||
keyCode == static_cast<std::int32_t>(KeyCode::Right)) {
actionLabel = "Left/Right 层级导航或展开切换";
}
UpdateResultSummary(result, actionLabel);
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void UpdateHoveredAction(const ScenarioLayout& layout, float x, float y) {
const ButtonLayout* button = HitTestAction(layout, x, y);
if (button == nullptr) {
m_hasHoveredAction = false;
return;
}
m_hoveredAction = button->action;
m_hasHoveredAction = true;
}
const ButtonLayout* HitTestAction(const ScenarioLayout& layout, float x, float y) const {
for (const ButtonLayout& button : layout.buttons) {
if (ContainsPoint(button.rect, x, y)) {
return &button;
}
}
return nullptr;
}
UIEditorTreeViewInteractionResult PumpTreeEvents(std::vector<UIInputEvent> events) {
const ScenarioLayout layout = GetLayout();
m_treeFrame = UpdateUIEditorTreeViewInteraction(
m_interactionState,
m_selectionModel,
m_expansionModel,
layout.treeRect,
m_items,
std::move(events));
return m_treeFrame.result;
}
void UpdateResultSummary(
const UIEditorTreeViewInteractionResult& result,
std::string_view actionLabel) {
std::ostringstream summary = {};
summary << actionLabel;
if (!result.selectedItemId.empty()) {
summary << " -> selected " << result.selectedItemId;
}
if (!result.toggledItemId.empty()) {
summary << " -> toggled " << result.toggledItemId;
}
if (result.selectedVisibleIndex != UIEditorTreeViewInvalidIndex) {
summary << " (visible " << result.selectedVisibleIndex << ")";
}
m_lastResult = summary.str();
std::ostringstream flags = {};
flags << "selectionChanged=" << BoolText(result.selectionChanged)
<< ", expansionChanged=" << BoolText(result.expansionChanged)
<< ", keyboardNavigated=" << BoolText(result.keyboardNavigated)
<< ", secondaryClicked=" << BoolText(result.secondaryClicked)
<< ", consumed=" << BoolText(result.consumed);
m_resultFlags = flags.str();
}
void ExecuteAction(ActionId action) {
switch (action) {
case ActionId::Reset:
ResetScenario();
break;
case ActionId::Capture:
m_autoScreenshot.RequestCapture("manual_button");
m_lastResult = "已请求截图,输出到 captures/latest.png";
m_resultFlags =
"selectionChanged=false, expansionChanged=false, keyboardNavigated=false, secondaryClicked=false, consumed=true";
break;
}
}
void RenderFrame() {
if (m_hwnd == nullptr) {
return;
}
RECT clientRect = {};
GetClientRect(m_hwnd, &clientRect);
const float width = static_cast<float>((std::max)(1L, clientRect.right - clientRect.left));
const float height = static_cast<float>((std::max)(1L, clientRect.bottom - clientRect.top));
const ScenarioLayout layout = BuildScenarioLayout(width, height);
RefreshTreeFrame();
const UIEditorTreeViewHitTarget currentHit = HitTestUIEditorTreeView(m_treeFrame.layout, m_mousePosition);
UIDrawData drawData = {};
UIDrawList& drawList = drawData.EmplaceDrawList("EditorTreeViewMultiSelect");
drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg);
DrawCard(
drawList,
layout.introRect,
"这个测试在验证什么功能?",
"只验证 Editor TreeView 的多选契约,不混入 Hierarchy / Inspector 业务面板。");
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 72.0f),
"1. 单击 row应切回单选primary / anchor / current 同步到该行。",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 94.0f),
"2. Ctrl+单击 / Shift+单击:应只验证 visible tree 范围内的多选切换与连续扩选。",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 116.0f),
"3. 点击 disclosure应只切换 expanded不应无故打散 selection。",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 138.0f),
"4. Left / Right应验证展开、折叠以及父子层级之间的 current / selection 跳转。",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 160.0f),
"5. 右键命中已选集合中的一项:不得打散 selection只允许切换 primary。",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 182.0f),
"6. 重点检查左侧状态Primary / Count / Ids / Anchor / Current / Expanded / Visible / Result。",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.introRect.x + 16.0f, layout.introRect.y + 204.0f),
"7. 按 F12 手动截图;设置 XCUI_AUTO_CAPTURE_ON_STARTUP=1 可做启动自动截图。",
kTextPrimary,
12.0f);
DrawCard(drawList, layout.controlRect, "操作");
for (const ButtonLayout& button : layout.buttons) {
DrawButton(drawList, button, m_hasHoveredAction && m_hoveredAction == button.action);
}
DrawCard(drawList, layout.stateRect, "状态摘要", "重点检查 multi-select 与 expanded / visible contract 是否稳定。");
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 70.0f),
"Hit: " + DescribeHitTarget(currentHit, m_items),
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 94.0f),
std::string("Focused: ") + BoolText(m_interactionState.treeViewState.focused),
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 118.0f),
"Primary: " +
(m_selectionModel.HasSelection() ? m_selectionModel.GetSelectedId() : std::string("(none)")),
kTextSuccess,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 142.0f),
"Selected Count: " + std::to_string(m_selectionModel.GetSelectionCount()),
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 166.0f),
"Selected Ids: " + JoinSelectedIds(m_selectionModel),
kTextMuted,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 190.0f),
"Anchor: " +
(m_interactionState.selectionAnchorId.empty()
? std::string("(none)")
: m_interactionState.selectionAnchorId),
kTextMuted,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 214.0f),
std::string("Current: ") +
(m_interactionState.keyboardNavigation.HasCurrentIndex()
? std::to_string(m_interactionState.keyboardNavigation.GetCurrentIndex())
: std::string("(none)")),
kTextMuted,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 238.0f),
"Expanded: " + JoinExpandedIds(m_items, m_expansionModel),
kTextMuted,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 262.0f),
"Visible: " + JoinVisibleIds(m_items, m_treeFrame.layout),
kTextMuted,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 286.0f),
"Result: " + m_lastResult,
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 310.0f),
"Flags: " + m_resultFlags,
kTextWeak,
12.0f);
const std::string captureSummary =
m_autoScreenshot.HasPendingCapture()
? "截图排队中..."
: (m_autoScreenshot.GetLastCaptureSummary().empty()
? std::string("F12 -> tests/UI/Editor/integration/shell/tree_view_multiselect/captures/")
: m_autoScreenshot.GetLastCaptureSummary());
drawList.AddText(
UIPoint(layout.stateRect.x + 16.0f, layout.stateRect.y + 334.0f),
captureSummary,
kTextWeak,
12.0f);
DrawCard(drawList, layout.previewRect, "TreeView 多选预览", "这里只放一个 TreeView用来验证多选与层级展开状态机。");
AppendUIEditorTreeViewBackground(
drawList,
m_treeFrame.layout,
m_items,
m_selectionModel,
m_interactionState.treeViewState);
AppendUIEditorTreeViewForeground(drawList, m_treeFrame.layout, m_items);
const bool framePresented = m_renderer.Render(drawData);
m_autoScreenshot.CaptureIfRequested(
m_renderer,
drawData,
static_cast<unsigned int>(width),
static_cast<unsigned int>(height),
framePresented);
}
HWND m_hwnd = nullptr;
ATOM m_windowClassAtom = 0;
NativeRenderer m_renderer = {};
AutoScreenshotController m_autoScreenshot = {};
std::filesystem::path m_captureRoot = {};
std::vector<UIEditorTreeViewItem> m_items = {};
UISelectionModel m_selectionModel = {};
UIExpansionModel m_expansionModel = {};
UIEditorTreeViewInteractionState m_interactionState = {};
UIEditorTreeViewInteractionFrame m_treeFrame = {};
UIPoint m_mousePosition = UIPoint(-1000.0f, -1000.0f);
ActionId m_hoveredAction = ActionId::Reset;
bool m_hasHoveredAction = false;
std::string m_lastResult = {};
std::string m_resultFlags = {};
};
} // namespace
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return ScenarioApp().Run(hInstance, nCmdShow);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -2,6 +2,7 @@
#define NOMINMAX
#endif
#include <XCEditor/Core/UIEditorDockHostInteraction.h>
#include <XCEditor/Core/UIEditorPanelRegistry.h>
#include <XCEditor/Core/UIEditorWorkspaceController.h>
#include <XCEditor/Core/UIEditorWorkspaceModel.h>
@@ -10,7 +11,6 @@
#include "Host/NativeRenderer.h"
#include <XCEngine/UI/DrawData.h>
#include <XCEngine/UI/Widgets/UISplitterInteraction.h>
#include <windows.h>
#include <windowsx.h>
@@ -31,12 +31,11 @@ namespace {
using XCEngine::UI::UIColor;
using XCEngine::UI::UIDrawData;
using XCEngine::UI::UIDrawList;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIPointerButton;
using XCEngine::UI::UIRect;
using XCEngine::UI::Widgets::BeginUISplitterDrag;
using XCEngine::UI::Widgets::EndUISplitterDrag;
using XCEngine::UI::Widgets::UISplitterDragState;
using XCEngine::UI::Widgets::UpdateUISplitterDrag;
using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController;
using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel;
using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit;
@@ -46,26 +45,22 @@ using XCEngine::UI::Editor::GetUIEditorWorkspaceCommandStatusName;
using XCEngine::UI::Editor::GetUIEditorWorkspaceLayoutOperationStatusName;
using XCEngine::UI::Editor::Host::AutoScreenshotController;
using XCEngine::UI::Editor::Host::NativeRenderer;
using XCEngine::UI::Editor::TrySetUIEditorWorkspaceSplitRatio;
using XCEngine::UI::Editor::UIEditorDockHostInteractionFrame;
using XCEngine::UI::Editor::UIEditorDockHostInteractionResult;
using XCEngine::UI::Editor::UIEditorDockHostInteractionState;
using XCEngine::UI::Editor::UIEditorPanelRegistry;
using XCEngine::UI::Editor::UIEditorWorkspaceCommand;
using XCEngine::UI::Editor::UIEditorWorkspaceCommandKind;
using XCEngine::UI::Editor::UIEditorWorkspaceCommandResult;
using XCEngine::UI::Editor::UIEditorWorkspaceCommandStatus;
using XCEngine::UI::Editor::UIEditorWorkspaceController;
using XCEngine::UI::Editor::UIEditorWorkspaceLayoutOperationStatus;
using XCEngine::UI::Editor::UIEditorWorkspaceModel;
using XCEngine::UI::Editor::UIEditorWorkspaceNode;
using XCEngine::UI::Editor::UIEditorWorkspaceNodeKind;
using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis;
using XCEngine::UI::Editor::UpdateUIEditorDockHostInteraction;
using XCEngine::UI::Editor::Widgets::AppendUIEditorDockHostBackground;
using XCEngine::UI::Editor::Widgets::AppendUIEditorDockHostForeground;
using XCEngine::UI::Editor::Widgets::BuildUIEditorDockHostLayout;
using XCEngine::UI::Editor::Widgets::FindUIEditorDockHostSplitterLayout;
using XCEngine::UI::Editor::Widgets::HitTestUIEditorDockHost;
using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTarget;
using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTargetKind;
using XCEngine::UI::Editor::Widgets::UIEditorDockHostLayout;
using XCEngine::UI::Editor::Widgets::UIEditorDockHostState;
using XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex;
constexpr const wchar_t* kWindowClassName = L"XCUIEditorWorkspaceShellComposeValidation";
constexpr const wchar_t* kWindowTitle = L"XCUI Editor | Workspace Shell Compose";
@@ -77,6 +72,7 @@ constexpr UIColor kTextPrimary(0.94f, 0.94f, 0.94f, 1.0f);
constexpr UIColor kTextMuted(0.73f, 0.73f, 0.73f, 1.0f);
constexpr UIColor kTextWeak(0.58f, 0.58f, 0.58f, 1.0f);
constexpr UIColor kSuccess(0.63f, 0.76f, 0.63f, 1.0f);
constexpr UIColor kWarning(0.82f, 0.67f, 0.35f, 1.0f);
constexpr UIColor kDanger(0.82f, 0.50f, 0.50f, 1.0f);
constexpr UIColor kButtonBg(0.26f, 0.26f, 0.26f, 1.0f);
constexpr UIColor kButtonHoveredBg(0.34f, 0.34f, 0.34f, 1.0f);
@@ -97,15 +93,6 @@ bool ContainsPoint(const UIRect& rect, float x, float y) {
y <= rect.y + rect.height;
}
bool AreTargetsEqual(
const UIEditorDockHostHitTarget& lhs,
const UIEditorDockHostHitTarget& rhs) {
return lhs.kind == rhs.kind &&
lhs.nodeId == rhs.nodeId &&
lhs.panelId == rhs.panelId &&
lhs.index == rhs.index;
}
void DrawCard(
UIDrawList& drawList,
const UIRect& rect,
@@ -285,6 +272,7 @@ private:
break;
case WM_LBUTTONDOWN:
if (app != nullptr) {
SetFocus(hwnd);
app->HandleLeftButtonDown(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)));
@@ -299,6 +287,32 @@ private:
return 0;
}
break;
case WM_SETFOCUS:
if (app != nullptr) {
UIInputEvent eventInput = {};
eventInput.type = UIInputEventType::FocusGained;
app->m_pendingInputEvents.push_back(eventInput);
return 0;
}
break;
case WM_KILLFOCUS:
if (app != nullptr) {
UIInputEvent eventInput = {};
eventInput.type = UIInputEventType::FocusLost;
app->m_pendingInputEvents.push_back(eventInput);
return 0;
}
break;
case WM_CAPTURECHANGED:
if (app != nullptr &&
app->m_interactionState.splitterDragState.active &&
reinterpret_cast<HWND>(lParam) != hwnd) {
UIInputEvent eventInput = {};
eventInput.type = UIInputEventType::FocusLost;
app->m_pendingInputEvents.push_back(eventInput);
return 0;
}
break;
case WM_KEYDOWN:
case WM_SYSKEYDOWN:
if (app != nullptr && wParam == VK_F12) {
@@ -342,7 +356,6 @@ private:
windowClass.hInstance = hInstance;
windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
windowClass.lpszClassName = kWindowClassName;
m_windowClassAtom = RegisterClassExW(&windowClass);
if (m_windowClassAtom == 0) {
return false;
@@ -377,6 +390,9 @@ private:
}
void Shutdown() {
if (GetCapture() == m_hwnd) {
ReleaseCapture();
}
m_autoScreenshot.Shutdown();
m_renderer.Shutdown();
@@ -392,18 +408,19 @@ private:
}
void ResetScenario() {
if (GetCapture() == m_hwnd) {
ReleaseCapture();
}
m_controller =
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());
m_dockState = {};
m_layout = {};
m_dragState = {};
m_dragSplitterNodeId.clear();
m_interactionState = {};
m_cachedFrame = {};
m_pendingInputEvents.clear();
m_mousePosition = UIPoint(-1000.0f, -1000.0f);
m_resetHovered = false;
m_resetPressed = false;
m_lastResult = "等待操作";
UpdateSceneRects();
RefreshLayout();
UpdateHoverTarget();
}
void OnResize(UINT width, UINT height) {
@@ -423,8 +440,8 @@ private:
constexpr float outerPadding = 20.0f;
constexpr float leftColumnWidth = 380.0f;
m_introRect = UIRect(outerPadding, outerPadding, leftColumnWidth, 192.0f);
m_stateRect = UIRect(outerPadding, 228.0f, leftColumnWidth, height - 248.0f);
m_introRect = UIRect(outerPadding, outerPadding, leftColumnWidth, 212.0f);
m_stateRect = UIRect(outerPadding, 248.0f, leftColumnWidth, height - 268.0f);
m_previewCardRect = UIRect(
leftColumnWidth + outerPadding * 2.0f,
outerPadding,
@@ -442,23 +459,6 @@ private:
38.0f);
}
void RefreshLayout() {
m_layout = BuildUIEditorDockHostLayout(
m_previewRect,
m_controller.GetPanelRegistry(),
m_controller.GetWorkspace(),
m_controller.GetSession(),
m_dockState);
}
void UpdateHoverTarget() {
UIEditorDockHostHitTarget hoveredTarget = HitTestUIEditorDockHost(m_layout, m_mousePosition);
if (!AreTargetsEqual(hoveredTarget, m_dockState.hoveredTarget)) {
m_dockState.hoveredTarget = std::move(hoveredTarget);
RefreshLayout();
}
}
void HandleMouseMove(float x, float y) {
m_mousePosition = UIPoint(x, y);
TRACKMOUSEEVENT event = {};
@@ -470,79 +470,38 @@ private:
UpdateSceneRects();
m_resetHovered = ContainsPoint(m_resetButtonRect, x, y);
if (m_dragState.active) {
XCEngine::UI::Layout::UISplitterLayoutResult draggedLayout = {};
if (UpdateUISplitterDrag(m_dragState, m_mousePosition, draggedLayout)) {
ApplyDraggedSplitterRatio(draggedLayout.splitRatio);
} else {
RefreshLayout();
}
m_dockState.hoveredTarget = {
UIEditorDockHostHitTargetKind::SplitterHandle,
m_dragSplitterNodeId,
{},
UIEditorTabStripInvalidIndex
};
m_dockState.activeSplitterNodeId = m_dragSplitterNodeId;
RefreshLayout();
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
RefreshLayout();
UpdateHoverTarget();
UIInputEvent eventInput = {};
eventInput.type = UIInputEventType::PointerMove;
eventInput.position = m_mousePosition;
m_pendingInputEvents.push_back(eventInput);
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleMouseLeave() {
if (m_dragState.active) {
return;
}
m_mousePosition = UIPoint(-1000.0f, -1000.0f);
m_resetHovered = false;
m_dockState.hoveredTarget = {};
RefreshLayout();
UIInputEvent eventInput = {};
eventInput.type = UIInputEventType::PointerLeave;
eventInput.position = m_mousePosition;
m_pendingInputEvents.push_back(eventInput);
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void HandleLeftButtonDown(float x, float y) {
UpdateSceneRects();
m_mousePosition = UIPoint(x, y);
m_dockState.focused = true;
RefreshLayout();
UpdateHoverTarget();
const UIEditorDockHostHitTarget hit = m_dockState.hoveredTarget;
if (hit.kind != UIEditorDockHostHitTargetKind::SplitterHandle) {
m_resetPressed = ContainsPoint(m_resetButtonRect, x, y);
if (m_resetPressed) {
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
const auto* splitter = FindUIEditorDockHostSplitterLayout(m_layout, hit.nodeId);
if (splitter == nullptr) {
return;
}
if (!BeginUISplitterDrag(
1u,
splitter->axis == UIEditorWorkspaceSplitAxis::Horizontal
? XCEngine::UI::Layout::UILayoutAxis::Horizontal
: XCEngine::UI::Layout::UILayoutAxis::Vertical,
splitter->bounds,
splitter->splitterLayout,
splitter->constraints,
splitter->metrics,
m_mousePosition,
m_dragState)) {
return;
}
m_dragSplitterNodeId = splitter->nodeId;
m_dockState.activeSplitterNodeId = splitter->nodeId;
SetCapture(m_hwnd);
m_lastResult = "开始拖拽 splitter: " + splitter->nodeId;
RefreshLayout();
UIInputEvent eventInput = {};
eventInput.type = UIInputEventType::PointerButtonDown;
eventInput.position = m_mousePosition;
eventInput.pointerButton = UIPointerButton::Left;
m_pendingInputEvents.push_back(eventInput);
InvalidateRect(m_hwnd, nullptr, FALSE);
}
@@ -550,115 +509,81 @@ private:
UpdateSceneRects();
m_mousePosition = UIPoint(x, y);
if (m_dragState.active) {
XCEngine::UI::Layout::UISplitterLayoutResult draggedLayout = {};
if (UpdateUISplitterDrag(m_dragState, m_mousePosition, draggedLayout)) {
ApplyDraggedSplitterRatio(draggedLayout.splitRatio);
}
EndUISplitterDrag(m_dragState);
m_dockState.activeSplitterNodeId.clear();
if (GetCapture() == m_hwnd) {
ReleaseCapture();
}
m_lastResult = "结束拖拽 splitter: " + m_dragSplitterNodeId;
m_dragSplitterNodeId.clear();
RefreshLayout();
UpdateHoverTarget();
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
if (ContainsPoint(m_resetButtonRect, x, y)) {
const bool resetTriggered = m_resetPressed && ContainsPoint(m_resetButtonRect, x, y);
m_resetPressed = false;
if (resetTriggered) {
ResetScenario();
m_lastResult = "Reset";
InvalidateRect(m_hwnd, nullptr, FALSE);
return;
}
RefreshLayout();
UpdateHoverTarget();
ExecuteClick(m_dockState.hoveredTarget);
RefreshLayout();
UpdateHoverTarget();
UIInputEvent eventInput = {};
eventInput.type = UIInputEventType::PointerButtonUp;
eventInput.position = m_mousePosition;
eventInput.pointerButton = UIPointerButton::Left;
m_pendingInputEvents.push_back(eventInput);
InvalidateRect(m_hwnd, nullptr, FALSE);
}
void ExecuteClick(const UIEditorDockHostHitTarget& hit) {
switch (hit.kind) {
case UIEditorDockHostHitTargetKind::Tab:
DispatchWorkspaceCommand(
UIEditorWorkspaceCommandKind::ActivatePanel,
hit.panelId,
"Activate Tab");
return;
case UIEditorDockHostHitTargetKind::TabCloseButton:
DispatchWorkspaceCommand(
UIEditorWorkspaceCommandKind::ClosePanel,
hit.panelId,
"Close Tab");
return;
case UIEditorDockHostHitTargetKind::PanelHeader:
case UIEditorDockHostHitTargetKind::PanelBody:
case UIEditorDockHostHitTargetKind::PanelFooter:
DispatchWorkspaceCommand(
UIEditorWorkspaceCommandKind::ActivatePanel,
hit.panelId,
"Activate Panel");
return;
case UIEditorDockHostHitTargetKind::PanelCloseButton:
DispatchWorkspaceCommand(
UIEditorWorkspaceCommandKind::ClosePanel,
hit.panelId,
"Close Panel");
return;
case UIEditorDockHostHitTargetKind::TabStripBackground:
m_lastResult = "DockHost focus = On";
return;
case UIEditorDockHostHitTargetKind::None:
default:
m_dockState.focused = false;
m_lastResult = "DockHost focus = Off";
return;
void ApplyHostCaptureRequests(const UIEditorDockHostInteractionResult& result) {
if (result.requestPointerCapture && GetCapture() != m_hwnd) {
SetCapture(m_hwnd);
}
if (result.releasePointerCapture && GetCapture() == m_hwnd) {
ReleaseCapture();
}
}
void DispatchWorkspaceCommand(
UIEditorWorkspaceCommandKind kind,
std::string_view panelId,
std::string_view label) {
UIEditorWorkspaceCommand command = {};
command.kind = kind;
command.panelId = std::string(panelId);
const UIEditorWorkspaceCommandResult result = m_controller.Dispatch(command);
m_lastResult =
std::string(label) + " -> " +
std::string(GetUIEditorWorkspaceCommandStatusName(result.status)) +
" | " +
result.message;
}
void ApplyDraggedSplitterRatio(float splitRatio) {
auto snapshot = m_controller.CaptureLayoutSnapshot();
if (!TrySetUIEditorWorkspaceSplitRatio(snapshot.workspace, m_dragSplitterNodeId, splitRatio)) {
void SetInteractionResult(const UIEditorDockHostInteractionResult& result) {
if (result.layoutResult.status != UIEditorWorkspaceLayoutOperationStatus::Rejected) {
m_lastResult =
std::string("Layout: ") +
std::string(GetUIEditorWorkspaceLayoutOperationStatusName(result.layoutResult.status)) +
" | " +
(result.layoutResult.message.empty() ? "layout updated" : result.layoutResult.message);
return;
}
const auto result = m_controller.RestoreLayoutSnapshot(snapshot);
std::ostringstream stream;
stream.setf(std::ios::fixed, std::ios::floatfield);
stream.precision(2);
stream << "Drag " << m_dragSplitterNodeId << " -> "
<< GetUIEditorWorkspaceLayoutOperationStatusName(result.status)
<< " | ratio=" << splitRatio;
m_lastResult = stream.str();
RefreshLayout();
if (result.commandResult.status != UIEditorWorkspaceCommandStatus::Rejected) {
m_lastResult =
std::string("Command: ") +
std::string(GetUIEditorWorkspaceCommandStatusName(result.commandResult.status)) +
" | " +
(result.commandResult.message.empty() ? "command handled" : result.commandResult.message);
return;
}
if (result.requestPointerCapture) {
m_lastResult = "Capture: begin splitter drag";
return;
}
if (result.releasePointerCapture) {
m_lastResult = "Capture: end splitter drag";
return;
}
if (result.hitTarget.kind != UIEditorDockHostHitTargetKind::None) {
m_lastResult = "Hover: " + DescribeHitTarget(result.hitTarget);
return;
}
if (result.consumed) {
m_lastResult = "Consumed: input handled by DockHostInteraction";
}
}
void RenderFrame() {
UpdateSceneRects();
RefreshLayout();
UpdateHoverTarget();
m_cachedFrame = UpdateUIEditorDockHostInteraction(
m_interactionState,
m_controller,
m_previewRect,
m_pendingInputEvents);
m_pendingInputEvents.clear();
ApplyHostCaptureRequests(m_cachedFrame.result);
SetInteractionResult(m_cachedFrame.result);
RECT clientRect = {};
GetClientRect(m_hwnd, &clientRect);
@@ -672,54 +597,67 @@ private:
DrawCard(
drawList,
m_introRect,
"测试功能DockHost / Workspace Compose 基础层",
"验证 DockHost 真实 compose、splitter drag、tab host、panel frame 联动,不包含业务面板");
"这个测试在验证什么功能?",
"验证 Workspace + DockHost 的组合场景是否完全收口到统一交互层");
drawList.AddText(
UIPoint(m_introRect.x + 16.0f, m_introRect.y + 68.0f),
"重点检查:三条 splitter 的 resize 是否稳定;关闭面板后 branch 是否正确 collapse点击 tab / panel 是否同步 active",
"1. splitter drag 只能通过 UIEditorDockHostInteraction + WorkspaceController 生效",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(m_introRect.x + 16.0f, m_introRect.y + 92.0f),
"操作:拖拽 Hierarchy / Documents / Inspector / Console 之间的 splitter点击 Document A/B/C点 X 关闭 tab 或 side panelReset 恢复F12 截图",
kTextMuted,
"2. tab 激活/关闭、panel 激活/关闭都必须统一回到 controller",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(m_introRect.x + 16.0f, m_introRect.y + 116.0f),
"预期splitter 会被最小尺寸 clampDocument C 没有 XInspector/Console 关闭后对应分支会收拢,不应留下歪掉的空洞",
kTextWeak,
"3. active panel、visible panels、split ratio 在组合场景里必须同步更新",
kTextPrimary,
12.0f);
drawList.AddText(
UIPoint(m_introRect.x + 16.0f, m_introRect.y + 140.0f),
"4. 操作建议:拖动三个 splitter点击 Document A/B/C关闭 Inspector 或 Console按 F12 截图。",
kTextWeak,
11.0f);
DrawCard(
drawList,
m_stateRect,
"状态回显",
"这里直接回显 hover / focus / dragging / active panel / split ratio,方便人工检查");
"这里直接展示 hover / focus / dragging / active panel / split ratio。");
DrawButton(drawList, m_resetButtonRect, "Reset", m_resetHovered);
const auto validation = m_controller.ValidateState();
drawList.AddText(
UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 70.0f),
"Hover: " + DescribeHitTarget(m_dockState.hoveredTarget),
"Hover: " + DescribeHitTarget(m_interactionState.dockHostState.hoveredTarget),
kTextPrimary,
13.0f);
drawList.AddText(
UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 96.0f),
std::string("Focused: ") + (m_dockState.focused ? "On" : "Off"),
kTextPrimary,
std::string("Focused: ") + (m_cachedFrame.focused ? "On" : "Off"),
m_cachedFrame.focused ? kSuccess : kTextMuted,
13.0f);
drawList.AddText(
UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 122.0f),
"Dragging: " + (m_dragSplitterNodeId.empty() ? std::string("(none)") : m_dragSplitterNodeId),
kTextPrimary,
"Capture: " + std::string(GetCapture() == m_hwnd ? "On" : "Off"),
GetCapture() == m_hwnd ? kSuccess : kTextMuted,
13.0f);
drawList.AddText(
UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 148.0f),
"Active Panel: " + m_controller.GetWorkspace().activePanelId,
"Dragging: " +
(m_interactionState.dockHostState.activeSplitterNodeId.empty()
? std::string("(none)")
: m_interactionState.dockHostState.activeSplitterNodeId),
kTextPrimary,
13.0f);
drawList.AddText(
UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 174.0f),
"Active Panel: " + m_controller.GetWorkspace().activePanelId,
kTextPrimary,
13.0f);
drawList.AddText(
UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 198.0f),
"Visible Panels: " + JoinVisiblePanelIds(m_controller),
kTextMuted,
12.0f);
@@ -741,7 +679,7 @@ private:
drawList.AddText(
UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 356.0f),
"Result: " + m_lastResult,
kTextMuted,
kWarning,
12.0f);
drawList.AddText(
UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 382.0f),
@@ -765,10 +703,10 @@ private:
drawList,
m_previewCardRect,
"预览区",
"这里只保留一个 DockHost 试验场,不混入业务 UI。");
"这里只保留一个 DockHost 组合试验场,不混入业务 UI。");
AppendUIEditorDockHostBackground(drawList, m_layout);
AppendUIEditorDockHostForeground(drawList, m_layout);
AppendUIEditorDockHostBackground(drawList, m_cachedFrame.layout);
AppendUIEditorDockHostForeground(drawList, m_cachedFrame.layout);
const bool framePresented = m_renderer.Render(drawData);
m_autoScreenshot.CaptureIfRequested(
@@ -785,10 +723,9 @@ private:
AutoScreenshotController m_autoScreenshot = {};
std::filesystem::path m_captureRoot = {};
UIEditorWorkspaceController m_controller = {};
UIEditorDockHostState m_dockState = {};
UIEditorDockHostLayout m_layout = {};
UISplitterDragState m_dragState = {};
std::string m_dragSplitterNodeId = {};
UIEditorDockHostInteractionState m_interactionState = {};
UIEditorDockHostInteractionFrame m_cachedFrame = {};
std::vector<UIInputEvent> m_pendingInputEvents = {};
UIPoint m_mousePosition = UIPoint(-1000.0f, -1000.0f);
UIRect m_introRect = {};
UIRect m_stateRect = {};
@@ -796,6 +733,7 @@ private:
UIRect m_previewRect = {};
UIRect m_resetButtonRect = {};
bool m_resetHovered = false;
bool m_resetPressed = false;
std::string m_lastResult = {};
};

View File

@@ -24,6 +24,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES
test_ui_editor_color_field.cpp
test_ui_editor_color_field_interaction.cpp
test_ui_editor_dock_host.cpp
test_ui_editor_inline_rename_session.cpp
test_ui_editor_list_view.cpp
test_ui_editor_list_view_interaction.cpp
test_ui_editor_panel_chrome.cpp
@@ -44,6 +45,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES
test_ui_editor_scroll_view_interaction.cpp
test_ui_editor_status_bar.cpp
test_ui_editor_tab_strip.cpp
test_ui_editor_tab_strip_interaction.cpp
test_ui_editor_tree_view.cpp
test_ui_editor_tree_view_interaction.cpp
test_ui_editor_viewport_input_bridge.cpp

View File

@@ -1,6 +1,6 @@
#include <gtest/gtest.h>
#include <XCEditor/Core/UIEditorCommandDispatcher.h>
#include <XCEditor/Foundation/UIEditorCommandDispatcher.h>
namespace {

View File

@@ -1,6 +1,6 @@
#include <gtest/gtest.h>
#include <XCEditor/Core/UIEditorCommandRegistry.h>
#include <XCEditor/Foundation/UIEditorCommandRegistry.h>
namespace {

View File

@@ -1,5 +1,6 @@
#include <gtest/gtest.h>
#include <XCEngine/Input/InputTypes.h>
#include <XCEditor/Core/UIEditorDockHostInteraction.h>
#include <XCEditor/Core/UIEditorWorkspaceController.h>
#include <XCEditor/Core/UIEditorWorkspaceModel.h>
@@ -11,6 +12,7 @@ using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIRect;
using XCEngine::UI::UIPointerButton;
using XCEngine::Input::KeyCode;
using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController;
using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel;
using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit;
@@ -86,6 +88,13 @@ UIInputEvent MakeFocusLost() {
return event;
}
UIInputEvent MakeKeyDown(KeyCode keyCode) {
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = static_cast<std::int32_t>(keyCode);
return event;
}
UIPoint RectCenter(const UIRect& rect) {
return UIPoint(rect.x + rect.width * 0.5f, rect.y + rect.height * 0.5f);
}
@@ -184,6 +193,14 @@ TEST(UIEditorDockHostInteractionTest, ClickingTabActivatesTargetPanel) {
EXPECT_EQ(frame.result.hitTarget.kind, UIEditorDockHostHitTargetKind::Tab);
EXPECT_EQ(frame.result.hitTarget.panelId, "doc-a");
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{ MakePointerDown(RectCenter(docARect).x, RectCenter(docARect).y) });
EXPECT_TRUE(frame.result.consumed);
EXPECT_FALSE(frame.result.commandExecuted);
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
@@ -218,6 +235,14 @@ TEST(UIEditorDockHostInteractionTest, ClickingTabCloseClosesPanelThroughControll
EXPECT_EQ(frame.result.hitTarget.kind, UIEditorDockHostHitTargetKind::TabCloseButton);
EXPECT_EQ(frame.result.hitTarget.panelId, "doc-b");
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{ MakePointerDown(closeCenter.x, closeCenter.y) });
EXPECT_TRUE(frame.result.consumed);
EXPECT_FALSE(frame.result.commandExecuted);
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
@@ -233,6 +258,84 @@ TEST(UIEditorDockHostInteractionTest, ClickingTabCloseClosesPanelThroughControll
EXPECT_EQ(controller.GetWorkspace().activePanelId, "doc-a");
}
TEST(UIEditorDockHostInteractionTest, FocusedTabStripHandlesKeyboardNavigationThroughTabStripInteraction) {
auto controller =
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());
UIEditorDockHostInteractionState state = {};
auto frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{});
ASSERT_EQ(frame.layout.tabStacks.size(), 1u);
const UIRect docARect = frame.layout.tabStacks.front().tabStripLayout.tabHeaderRects[0];
const UIPoint docACenter = RectCenter(docARect);
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{ MakePointerMove(docACenter.x, docACenter.y) });
EXPECT_EQ(frame.result.hitTarget.kind, UIEditorDockHostHitTargetKind::Tab);
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{ MakePointerDown(docACenter.x, docACenter.y) });
EXPECT_TRUE(frame.result.consumed);
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{ MakePointerUp(docACenter.x, docACenter.y) });
EXPECT_TRUE(frame.result.commandExecuted);
EXPECT_EQ(controller.GetWorkspace().activePanelId, "doc-a");
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{ MakeKeyDown(KeyCode::Right) });
EXPECT_TRUE(frame.result.consumed);
EXPECT_TRUE(frame.result.commandExecuted);
EXPECT_EQ(controller.GetWorkspace().activePanelId, "doc-b");
ASSERT_EQ(frame.layout.tabStacks.size(), 1u);
EXPECT_EQ(frame.layout.tabStacks.front().selectedPanelId, "doc-b");
}
TEST(UIEditorDockHostInteractionTest, BatchedPointerMoveDownUpActivatesTabInSameUpdateCall) {
auto controller =
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());
UIEditorDockHostInteractionState state = {};
auto frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{});
ASSERT_EQ(frame.layout.tabStacks.size(), 1u);
const UIPoint docACenter =
RectCenter(frame.layout.tabStacks.front().tabStripLayout.tabHeaderRects[0]);
frame = UpdateUIEditorDockHostInteraction(
state,
controller,
UIRect(0.0f, 0.0f, 800.0f, 600.0f),
{
MakePointerMove(docACenter.x, docACenter.y),
MakePointerDown(docACenter.x, docACenter.y),
MakePointerUp(docACenter.x, docACenter.y)
});
EXPECT_TRUE(frame.result.consumed);
EXPECT_TRUE(frame.result.commandExecuted);
EXPECT_EQ(controller.GetWorkspace().activePanelId, "doc-a");
ASSERT_EQ(frame.layout.tabStacks.size(), 1u);
EXPECT_EQ(frame.layout.tabStacks.front().selectedPanelId, "doc-a");
}
TEST(UIEditorDockHostInteractionTest, ClickingStandalonePanelBodyActivatesTargetPanel) {
auto controller =
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());

View File

@@ -0,0 +1,124 @@
#include <gtest/gtest.h>
#include <XCEditor/Collections/UIEditorInlineRenameSession.h>
#include <XCEngine/Input/InputTypes.h>
namespace {
using XCEngine::Input::KeyCode;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIRect;
using XCEngine::UI::Editor::UIEditorInlineRenameSessionRequest;
using XCEngine::UI::Editor::UIEditorInlineRenameSessionState;
using XCEngine::UI::Editor::UpdateUIEditorInlineRenameSession;
UIInputEvent MakeKeyDown(KeyCode keyCode) {
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = static_cast<std::int32_t>(keyCode);
return event;
}
UIInputEvent MakeCharacter(char character) {
UIInputEvent event = {};
event.type = UIInputEventType::Character;
event.character = static_cast<std::uint32_t>(character);
return event;
}
UIInputEvent MakeFocusLost() {
UIInputEvent event = {};
event.type = UIInputEventType::FocusLost;
return event;
}
UIEditorInlineRenameSessionRequest MakeRequest(bool beginSession = false) {
UIEditorInlineRenameSessionRequest request = {};
request.beginSession = beginSession;
request.itemId = "camera";
request.initialText = "Camera";
request.bounds = UIRect(10.0f, 20.0f, 180.0f, 24.0f);
return request;
}
} // namespace
TEST(UIEditorInlineRenameSessionTest, BeginRequestStartsActiveEditingSession) {
UIEditorInlineRenameSessionState state = {};
const auto frame = UpdateUIEditorInlineRenameSession(
state,
MakeRequest(true),
{});
EXPECT_TRUE(frame.result.sessionStarted);
EXPECT_TRUE(frame.result.active);
EXPECT_EQ(frame.result.itemId, "camera");
EXPECT_TRUE(state.active);
EXPECT_EQ(state.itemId, "camera");
EXPECT_TRUE(state.textFieldInteraction.textFieldState.focused);
EXPECT_TRUE(state.textFieldInteraction.textFieldState.editing);
EXPECT_EQ(state.textFieldInteraction.textFieldState.displayText, "Camera");
}
TEST(UIEditorInlineRenameSessionTest, EnterCommitsEditedValueAndClosesSession) {
UIEditorInlineRenameSessionState state = {};
UpdateUIEditorInlineRenameSession(state, MakeRequest(true), {});
UpdateUIEditorInlineRenameSession(
state,
MakeRequest(false),
{ MakeCharacter('2') });
const auto frame = UpdateUIEditorInlineRenameSession(
state,
MakeRequest(false),
{ MakeKeyDown(KeyCode::Enter) });
EXPECT_TRUE(frame.result.sessionCommitted);
EXPECT_TRUE(frame.result.valueChanged);
EXPECT_EQ(frame.result.valueBefore, "Camera");
EXPECT_EQ(frame.result.valueAfter, "Camera2");
EXPECT_FALSE(state.active);
}
TEST(UIEditorInlineRenameSessionTest, EscapeCancelsEditedValueAndClosesSession) {
UIEditorInlineRenameSessionState state = {};
UpdateUIEditorInlineRenameSession(state, MakeRequest(true), {});
UpdateUIEditorInlineRenameSession(
state,
MakeRequest(false),
{ MakeCharacter('X') });
const auto frame = UpdateUIEditorInlineRenameSession(
state,
MakeRequest(false),
{ MakeKeyDown(KeyCode::Escape) });
EXPECT_TRUE(frame.result.sessionCanceled);
EXPECT_EQ(frame.result.valueBefore, "Camera");
EXPECT_EQ(frame.result.valueAfter, "Camera");
EXPECT_FALSE(state.active);
}
TEST(UIEditorInlineRenameSessionTest, FocusLostCommitsEditedValue) {
UIEditorInlineRenameSessionState state = {};
UpdateUIEditorInlineRenameSession(state, MakeRequest(true), {});
UpdateUIEditorInlineRenameSession(
state,
MakeRequest(false),
{ MakeCharacter('X') });
const auto frame = UpdateUIEditorInlineRenameSession(
state,
MakeRequest(false),
{ MakeFocusLost() });
EXPECT_TRUE(frame.result.sessionCommitted);
EXPECT_EQ(frame.result.valueAfter, "CameraX");
EXPECT_FALSE(state.active);
}

View File

@@ -1,6 +1,6 @@
#include <gtest/gtest.h>
#include <XCEditor/Widgets/UIEditorListView.h>
#include <XCEditor/Collections/UIEditorListView.h>
namespace {

View File

@@ -1,6 +1,6 @@
#include <gtest/gtest.h>
#include <XCEditor/Core/UIEditorListViewInteraction.h>
#include <XCEditor/Collections/UIEditorListViewInteraction.h>
#include <XCEngine/Input/InputTypes.h>
@@ -9,6 +9,7 @@ namespace {
using XCEngine::Input::KeyCode;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIInputModifiers;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIPointerButton;
using XCEngine::UI::UIRect;
@@ -27,6 +28,18 @@ std::vector<UIEditorListViewItem> BuildListItems() {
};
}
UIInputModifiers MakeShiftModifiers() {
UIInputModifiers modifiers = {};
modifiers.shift = true;
return modifiers;
}
UIInputModifiers MakeControlModifiers() {
UIInputModifiers modifiers = {};
modifiers.control = true;
return modifiers;
}
UIInputEvent MakePointerMove(float x, float y) {
UIInputEvent event = {};
event.type = UIInputEventType::PointerMove;
@@ -34,26 +47,37 @@ UIInputEvent MakePointerMove(float x, float y) {
return event;
}
UIInputEvent MakePointerDown(float x, float y, UIPointerButton button = UIPointerButton::Left) {
UIInputEvent MakePointerDown(
float x,
float y,
UIPointerButton button = UIPointerButton::Left,
UIInputModifiers modifiers = {}) {
UIInputEvent event = {};
event.type = UIInputEventType::PointerButtonDown;
event.position = UIPoint(x, y);
event.pointerButton = button;
event.modifiers = modifiers;
return event;
}
UIInputEvent MakePointerUp(float x, float y, UIPointerButton button = UIPointerButton::Left) {
UIInputEvent MakePointerUp(
float x,
float y,
UIPointerButton button = UIPointerButton::Left,
UIInputModifiers modifiers = {}) {
UIInputEvent event = {};
event.type = UIInputEventType::PointerButtonUp;
event.position = UIPoint(x, y);
event.pointerButton = button;
event.modifiers = modifiers;
return event;
}
UIInputEvent MakeKeyDown(KeyCode keyCode) {
UIInputEvent MakeKeyDown(KeyCode keyCode, UIInputModifiers modifiers = {}) {
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = static_cast<std::int32_t>(keyCode);
event.modifiers = modifiers;
return event;
}
@@ -163,6 +187,124 @@ TEST(UIEditorListViewInteractionTest, RightClickRowSelectsItemAndMarksSecondaryC
EXPECT_TRUE(state.listViewState.focused);
}
TEST(UIEditorListViewInteractionTest, ControlClickRowTogglesMembershipWithoutDroppingExistingSelection) {
const auto items = BuildListItems();
UISelectionModel selectionModel = {};
selectionModel.SetSelections({ "material", "script" }, "script");
UIEditorListViewInteractionState state = {};
state.listViewState.focused = true;
state.selectionAnchorId = "script";
const auto initialFrame = UpdateUIEditorListViewInteraction(
state,
selectionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{});
const UIPoint sceneCenter = RectCenter(initialFrame.layout.rowRects[0]);
auto frame = UpdateUIEditorListViewInteraction(
state,
selectionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{
MakePointerDown(sceneCenter.x, sceneCenter.y, UIPointerButton::Left, MakeControlModifiers()),
MakePointerUp(sceneCenter.x, sceneCenter.y, UIPointerButton::Left, MakeControlModifiers())
});
EXPECT_TRUE(frame.result.selectionChanged);
EXPECT_TRUE(selectionModel.IsSelected("scene"));
EXPECT_TRUE(selectionModel.IsSelected("material"));
EXPECT_TRUE(selectionModel.IsSelected("script"));
EXPECT_EQ(selectionModel.GetSelectedId(), "scene");
const UIPoint scriptCenter = RectCenter(initialFrame.layout.rowRects[2]);
frame = UpdateUIEditorListViewInteraction(
state,
selectionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{
MakePointerDown(scriptCenter.x, scriptCenter.y, UIPointerButton::Left, MakeControlModifiers()),
MakePointerUp(scriptCenter.x, scriptCenter.y, UIPointerButton::Left, MakeControlModifiers())
});
EXPECT_TRUE(frame.result.selectionChanged);
EXPECT_TRUE(selectionModel.IsSelected("scene"));
EXPECT_TRUE(selectionModel.IsSelected("material"));
EXPECT_FALSE(selectionModel.IsSelected("script"));
}
TEST(UIEditorListViewInteractionTest, ShiftClickRowSelectsRangeFromAnchor) {
const auto items = BuildListItems();
UISelectionModel selectionModel = {};
selectionModel.SetSelection("material");
UIEditorListViewInteractionState state = {};
state.listViewState.focused = true;
state.selectionAnchorId = "material";
const auto initialFrame = UpdateUIEditorListViewInteraction(
state,
selectionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{});
const UIPoint textureCenter = RectCenter(initialFrame.layout.rowRects[3]);
const auto frame = UpdateUIEditorListViewInteraction(
state,
selectionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{
MakePointerDown(textureCenter.x, textureCenter.y, UIPointerButton::Left, MakeShiftModifiers()),
MakePointerUp(textureCenter.x, textureCenter.y, UIPointerButton::Left, MakeShiftModifiers())
});
EXPECT_TRUE(frame.result.selectionChanged);
EXPECT_EQ(frame.result.selectedItemId, "texture");
EXPECT_TRUE(selectionModel.IsSelected("material"));
EXPECT_TRUE(selectionModel.IsSelected("script"));
EXPECT_TRUE(selectionModel.IsSelected("texture"));
EXPECT_EQ(selectionModel.GetSelectionCount(), 3u);
EXPECT_EQ(state.selectionAnchorId, "material");
}
TEST(UIEditorListViewInteractionTest, RightClickSelectedRowKeepsExistingMultiSelection) {
const auto items = BuildListItems();
UISelectionModel selectionModel = {};
selectionModel.SetSelections({ "material", "script", "texture" }, "material");
UIEditorListViewInteractionState state = {};
state.listViewState.focused = true;
state.selectionAnchorId = "material";
const auto initialFrame = UpdateUIEditorListViewInteraction(
state,
selectionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{});
const UIPoint scriptCenter = RectCenter(initialFrame.layout.rowRects[2]);
const auto frame = UpdateUIEditorListViewInteraction(
state,
selectionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{
MakePointerDown(scriptCenter.x, scriptCenter.y, UIPointerButton::Right),
MakePointerUp(scriptCenter.x, scriptCenter.y, UIPointerButton::Right)
});
EXPECT_TRUE(frame.result.secondaryClicked);
EXPECT_FALSE(frame.result.selectionChanged);
EXPECT_TRUE(selectionModel.IsSelected("material"));
EXPECT_TRUE(selectionModel.IsSelected("script"));
EXPECT_TRUE(selectionModel.IsSelected("texture"));
EXPECT_EQ(selectionModel.GetSelectionCount(), 3u);
EXPECT_EQ(selectionModel.GetSelectedId(), "script");
}
TEST(UIEditorListViewInteractionTest, ArrowAndHomeEndKeysDriveSelectionWhenFocused) {
const auto items = BuildListItems();
UISelectionModel selectionModel = {};
@@ -201,6 +343,50 @@ TEST(UIEditorListViewInteractionTest, ArrowAndHomeEndKeysDriveSelectionWhenFocus
EXPECT_TRUE(selectionModel.IsSelected("texture"));
}
TEST(UIEditorListViewInteractionTest, ShiftArrowExtendsVisibleRangeFromAnchor) {
const auto items = BuildListItems();
UISelectionModel selectionModel = {};
selectionModel.SetSelection("material");
UIEditorListViewInteractionState state = {};
state.listViewState.focused = true;
state.selectionAnchorId = "material";
const auto frame = UpdateUIEditorListViewInteraction(
state,
selectionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{ MakeKeyDown(KeyCode::Down, MakeShiftModifiers()) });
EXPECT_TRUE(frame.result.keyboardNavigated);
EXPECT_TRUE(selectionModel.IsSelected("material"));
EXPECT_TRUE(selectionModel.IsSelected("script"));
EXPECT_EQ(selectionModel.GetSelectionCount(), 2u);
EXPECT_EQ(frame.result.selectedItemId, "script");
}
TEST(UIEditorListViewInteractionTest, F2RequestsRenameForPrimarySelectionWhenFocused) {
const auto items = BuildListItems();
UISelectionModel selectionModel = {};
selectionModel.SetSelection("script");
UIEditorListViewInteractionState state = {};
state.listViewState.focused = true;
const auto frame = UpdateUIEditorListViewInteraction(
state,
selectionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{ MakeKeyDown(KeyCode::F2) });
EXPECT_TRUE(frame.result.renameRequested);
EXPECT_TRUE(frame.result.consumed);
EXPECT_EQ(frame.result.renameItemId, "script");
EXPECT_EQ(frame.result.selectedItemId, "script");
EXPECT_EQ(frame.result.selectedIndex, 2u);
EXPECT_FALSE(frame.result.selectionChanged);
}
TEST(UIEditorListViewInteractionTest, OutsideClickAndFocusLostClearFocusAndHoverButKeepSelection) {
const auto items = BuildListItems();
UISelectionModel selectionModel = {};

View File

@@ -94,6 +94,20 @@ UIEditorPropertyGridField MakeEnumField(
return field;
}
UIEditorPropertyGridField MakeColorField(
std::string id,
std::string label,
XCEngine::UI::UIColor value,
bool showAlpha = true) {
UIEditorPropertyGridField field = {};
field.fieldId = std::move(id);
field.label = std::move(label);
field.kind = UIEditorPropertyGridFieldKind::Color;
field.colorValue.value = value;
field.colorValue.showAlpha = showAlpha;
return field;
}
UIEditorPropertyGridField MakeVector4Field(
std::string id,
std::string label,
@@ -337,3 +351,68 @@ TEST(UIEditorPropertyGridTest, Vector4FieldUsesHostedLayoutAndForegroundText) {
EXPECT_TRUE(ContainsTextCommand(drawData, "4"));
EXPECT_EQ(ResolveUIEditorPropertyGridFieldValueText(sections[0].fields[0]), "1, 2, 3, 4");
}
TEST(UIEditorPropertyGridTest, ColorFieldUsesHostedLayoutAndPopupAwareForeground) {
std::vector<UIEditorPropertyGridSection> sections = {
{
"material",
"Material",
{
MakeColorField("tint", "Tint", XCEngine::UI::UIColor(0.8f, 0.4f, 0.2f, 0.5f))
},
0.0f
}
};
UISelectionModel selectionModel = {};
selectionModel.SetSelection("tint");
UIExpansionModel expansionModel = {};
expansionModel.Expand("material");
UIPropertyEditModel propertyEditModel = {};
UIEditorPropertyGridState state = {};
state.focused = true;
state.hoveredFieldId = "tint";
state.hoveredHitTarget = UIEditorPropertyGridHitTargetKind::ValueBox;
state.colorFieldStates = {
{
"tint",
{
XCEngine::UI::Editor::Widgets::UIEditorColorFieldHitTargetKind::Swatch,
XCEngine::UI::Editor::Widgets::UIEditorColorFieldHitTargetKind::None,
true,
true,
0.0f,
false
}
}
};
const auto layout = BuildUIEditorPropertyGridLayout(
UIRect(0.0f, 0.0f, 520.0f, 320.0f),
sections,
expansionModel);
ASSERT_EQ(layout.visibleFieldIndices.size(), 1u);
EXPECT_GT(layout.fieldValueRects[0].x, layout.fieldLabelRects[0].x + layout.fieldLabelRects[0].width);
EXPECT_GT(layout.fieldValueRects[0].width, 200.0f);
XCEngine::UI::UIDrawData drawData = {};
auto& drawList = drawData.EmplaceDrawList("PropertyGridColor");
AppendUIEditorPropertyGridBackground(
drawList,
layout,
sections,
selectionModel,
propertyEditModel,
state);
AppendUIEditorPropertyGridForeground(
drawList,
layout,
sections,
state,
propertyEditModel);
EXPECT_TRUE(ContainsTextCommand(drawData, "Tint"));
EXPECT_TRUE(ContainsTextCommand(drawData, "Color"));
EXPECT_TRUE(ContainsTextCommand(drawData, "Hexadecimal"));
EXPECT_EQ(ResolveUIEditorPropertyGridFieldValueText(sections[0].fields[0]), "#CC663380");
}

View File

@@ -1,6 +1,8 @@
#include <gtest/gtest.h>
#include <XCEditor/Core/UIEditorTheme.h>
#include <XCEditor/Core/UIEditorPropertyGridInteraction.h>
#include <XCEditor/Widgets/UIEditorColorField.h>
#include <XCEngine/Input/InputTypes.h>
@@ -15,8 +17,10 @@ using XCEngine::UI::UIRect;
using XCEngine::UI::Widgets::UIExpansionModel;
using XCEngine::UI::Widgets::UIPropertyEditModel;
using XCEngine::UI::Widgets::UISelectionModel;
using XCEngine::UI::Editor::BuildUIEditorHostedColorFieldMetrics;
using XCEngine::UI::Editor::UIEditorPropertyGridInteractionState;
using XCEngine::UI::Editor::UpdateUIEditorPropertyGridInteraction;
using XCEngine::UI::Editor::Widgets::BuildUIEditorColorFieldLayout;
using XCEngine::UI::Editor::Widgets::UIEditorPropertyGridField;
using XCEngine::UI::Editor::Widgets::UIEditorPropertyGridFieldKind;
using XCEngine::UI::Editor::Widgets::UIEditorPropertyGridSection;
@@ -76,6 +80,20 @@ UIEditorPropertyGridField MakeEnumField(
return field;
}
UIEditorPropertyGridField MakeColorField(
std::string id,
std::string label,
XCEngine::UI::UIColor value,
bool showAlpha = true) {
UIEditorPropertyGridField field = {};
field.fieldId = std::move(id);
field.label = std::move(label);
field.kind = UIEditorPropertyGridFieldKind::Color;
field.colorValue.value = value;
field.colorValue.showAlpha = showAlpha;
return field;
}
std::vector<UIEditorPropertyGridSection> BuildSections() {
return {
{
@@ -489,3 +507,101 @@ TEST(UIEditorPropertyGridInteractionTest, ArrowAndHomeEndKeysNavigateVisibleFiel
EXPECT_EQ(frame.result.selectedFieldId, "guid");
EXPECT_TRUE(selectionModel.IsSelected("guid"));
}
TEST(UIEditorPropertyGridInteractionTest, ColorFieldPopupCanOpenAndDragAlphaThroughHostedInteraction) {
std::vector<UIEditorPropertyGridSection> sections = {
{
"material",
"Material",
{
MakeColorField("tint", "Tint", XCEngine::UI::UIColor(0.8f, 0.4f, 0.2f, 1.0f))
},
0.0f
}
};
UISelectionModel selectionModel = {};
UIExpansionModel expansionModel = {};
expansionModel.Expand("material");
UIPropertyEditModel propertyEditModel = {};
UIEditorPropertyGridInteractionState state = {};
auto frame = UpdateUIEditorPropertyGridInteraction(
state,
selectionModel,
expansionModel,
propertyEditModel,
UIRect(0.0f, 0.0f, 520.0f, 360.0f),
sections,
{});
XCEngine::UI::Editor::Widgets::UIEditorColorFieldSpec initialColorSpec = {};
initialColorSpec.fieldId = "tint";
initialColorSpec.label = "Tint";
initialColorSpec.value = sections[0].fields[0].colorValue.value;
initialColorSpec.showAlpha = sections[0].fields[0].colorValue.showAlpha;
const auto initialColorLayout = BuildUIEditorColorFieldLayout(
frame.layout.fieldRowRects[0],
initialColorSpec,
BuildUIEditorHostedColorFieldMetrics({}),
UIRect(-4096.0f, -4096.0f, 8192.0f, 8192.0f));
const UIPoint swatchCenter = RectCenter(initialColorLayout.swatchRect);
frame = UpdateUIEditorPropertyGridInteraction(
state,
selectionModel,
expansionModel,
propertyEditModel,
UIRect(0.0f, 0.0f, 520.0f, 360.0f),
sections,
{
MakePointerDown(swatchCenter.x, swatchCenter.y),
MakePointerUp(swatchCenter.x, swatchCenter.y)
});
ASSERT_TRUE(frame.result.popupOpened);
ASSERT_EQ(state.propertyGridState.colorFieldStates.size(), 1u);
EXPECT_TRUE(state.propertyGridState.colorFieldStates[0].state.popupOpen);
EXPECT_TRUE(selectionModel.IsSelected("tint"));
const auto popupFrame = UpdateUIEditorPropertyGridInteraction(
state,
selectionModel,
expansionModel,
propertyEditModel,
UIRect(0.0f, 0.0f, 520.0f, 360.0f),
sections,
{});
const auto& colorState = state.propertyGridState.colorFieldStates[0].state;
EXPECT_TRUE(colorState.popupOpen);
XCEngine::UI::Editor::Widgets::UIEditorColorFieldSpec colorSpec = {};
colorSpec.fieldId = "tint";
colorSpec.label = "Tint";
colorSpec.value = sections[0].fields[0].colorValue.value;
colorSpec.showAlpha = sections[0].fields[0].colorValue.showAlpha;
const auto colorLayout = BuildUIEditorColorFieldLayout(
popupFrame.layout.fieldRowRects[0],
colorSpec,
BuildUIEditorHostedColorFieldMetrics({}),
UIRect(-4096.0f, -4096.0f, 8192.0f, 8192.0f));
const float alphaX =
colorLayout.alphaSliderRect.x + colorLayout.alphaSliderRect.width * 0.25f;
const float alphaY =
colorLayout.alphaSliderRect.y + colorLayout.alphaSliderRect.height * 0.5f;
frame = UpdateUIEditorPropertyGridInteraction(
state,
selectionModel,
expansionModel,
propertyEditModel,
UIRect(0.0f, 0.0f, 520.0f, 360.0f),
sections,
{
MakePointerDown(alphaX, alphaY),
MakePointerMove(alphaX, alphaY),
MakePointerUp(alphaX, alphaY)
});
EXPECT_TRUE(frame.result.fieldValueChanged);
EXPECT_EQ(frame.result.changedFieldId, "tint");
EXPECT_LT(sections[0].fields[0].colorValue.value.a, 0.5f);
}

View File

@@ -1,6 +1,6 @@
#include <gtest/gtest.h>
#include <XCEditor/Core/UIEditorShortcutManager.h>
#include <XCEditor/Foundation/UIEditorShortcutManager.h>
#include <XCEngine/Input/InputTypes.h>

Some files were not shown because too many files have changed in this diff Show More