04 体素光照注入
04 体素光照注入
体素化结束后,规则网格里已经有了表面属性,但这些属性还不是光。当前实现把 AlbedoOpacity、Normal 和独立的 Emissive 作为材质 GBuffer,把方向光可见性和光照结果写入 DirectRadiance;启用第二次反弹时,再从它得到 FinalRadiance。因此本篇的核心不是“把颜色乘上一个亮度”,而是建立材质数据、阴影空间和辐射资源之间的边界。
一、辐射体积
体素光照的输入来自上一阶段的 Resolve 结果。AlbedoOpacity 的 RGB 是表面反照率,A 是占用/遮挡值;Normal 的 RGB 是编码到 0–1 的世界法线,A 复用同一占用值;Emissive 的 RGB 是独立的 HDR 自发光,A 仍是占用值。它们描述“这里有什么表面”,而不是“这里已经收到了多少光”。
VoxelGICameraContext 为每个相机持有这些持久 3D 纹理。AlbedoOpacity、Normal 和 Emissive 不需要辐射查询的 Mip;DirectRadiance 与 FinalRadiance 则使用同样的 R16G16B16A16_SFloat 格式创建为带 Mip 的 3D 纹理。前者保持材质语义,后两者保存光照阶段的结果,这个区分也避免重光照时把上一轮的方向光重复加回去。
每个光照线程以 float3(id) + 0.5 得到体素中心,再通过 VoxelToWorld 回到世界空间。未占用体素直接把 DirectRadiance 写为零;占用体素才继续进行法线、阴影与光源计算。于是一个体素的辐射写入可以概括为:
其中 代表 Albedo, 代表 Normal, 代表 Emissive, 代表 DirectRadiance, 代表可选的 FinalRadiance。A 通道不是相机颜色的透明度;它是后续体素查询判断占用和遮挡的约定。
二、方向光阴影空间
方向光没有像点光源那样由位置决定阴影体积,项目只使用 Light.transform.forward 表示光照方向。VoxelGIFrameMatrices.Create 围绕体素 Bounds 构造一台虚拟光源相机:令 range = bounds.extents.magnitude,把相机放在 bounds.center - forward * range,再用方向光的朝向构造 ShadowView。正交投影覆盖 [-range, range],近裁剪面为 0.01,远裁剪面为 range * 2 + 0.01,确保整个体素立方体落在深度范围内。
Unity 的观察空间和 Transform 的局部 +Z 约定不同,创建 View 后会翻转第三行以匹配渲染观察方向。ShadowProjection 使用正交矩阵;启用 Reversed-Z 的平台还会翻转投影的深度相关项。最后用一个 scale-bias 矩阵把齐次 NDC 的 XY 和 Z 从 映射到 ,组合成 WorldToShadow:
这里 是 ShadowDepth 的采样坐标, 是与深度纹理比较的值。它与体素空间的 WorldToVoxel 是两套不同的坐标变换:前者服务方向光投影,后者服务 3D 网格定位。
RecordShadow 把跨帧的 ShadowDepth 导入 Render Graph,ExecuteShadow 清空深度、设置阴影视口和 ShadowView/ShadowProjection,然后恢复相机 View/Projection。绘制的是体素光照注册表中允许投射 Voxel Shadow 的 Renderer;这张纹理只为本次体素光照服务,不是 URP 相机的普通阴影贴图。
三、ShadowCaster 与 Blocker
阴影绘制优先查找材质的 ShadowCaster Pass。因而标准 Universal Render Pipeline/Lit 材质的阴影裁剪和相关属性可以直接复用,不需要额外写一份“体素阴影材质”。透明 Render Queue 不进入这条遍历,Alpha Clip 仍可在 URP 的 ShadowCaster 中保留正确的裁剪行为。
当材质没有 ShadowCaster 时,运行时会尝试 VoxelGIShadow。VXGIBlocker.shader 只有这个 Pass:它使用当前注入的光源 View/Projection,把几何写入 Shadow Depth;ColorMask 0 表明它不贡献颜色,ZWrite On 才是它唯一需要的光栅副作用。它没有 UniversalForward,所以它的语义是 Blocker-only,而不是一个可以被正常相机渲染的表面材质。
Blocker-only 还要通过 Compute 体素化写入占用:_VoxelGIOpacityOnly 分支只对 OpacityAccumulation 执行 InterlockedMax(..., 65535),不写 Albedo、Normal 或 Emissive。这样同一几何体同时满足两种约束:在方向光 Shadow Depth 中挡住太阳光,在体素辐射查询中挡住自发光和后续 Cone 的空间传播;它不会凭空制造表面反射或发光。
因此,ShadowCaster 与 VoxelGIShadow 的职责可以分开看:前者是“按材质已有规则投射阴影”,后者是“没有可见表面、但需要成为 GI 遮挡体”。两者最终都服务 VoxelGI_SampleShadow 的可见性判断,但只有普通表面 GBuffer 才提供 Albedo、Normal 和 Emissive。
四、直接光照
VoxelDirectLighting 只处理 A 通道大于零的体素。它从 Normal 读取 编码并还原到 ,从体素中心求世界位置,再使用方向光方向计算 Lambert 余弦项:
阴影函数先做两种以体素尺寸缩放的偏移:沿法线加 ShadowNormalBias,沿光照方向的反向项减去 ShadowSunBias。偏移后的世界点经 WorldToShadow 得到 ,若坐标超出阴影投影范围则返回完全可见;否则从 ShadowDepth 取存储深度,并按 _VoxelGIReversedZ 选择比较方向。比较结果是 。
当前方向光直接项对应:
其中 是 Albedo, 是方向光颜色, 是 Light.intensity, 是 lightIntensity。项目当前只取第一个活动 Directional Light,不注入点光源或聚光灯。没有方向光时,直接项为零,但 Emissive 仍然可以进入 DirectRadiance。
五、HDR Emissive
Emissive 不从 AlbedoOpacity 推导,也不与方向光阴影相乘。体素化阶段读取 _EmissionMap 和 _EmissionColor,只有 _EMISSION 等价开关开启时才写入;Resolve 后成为独立的 Emissive 3D 纹理。累积时 RGB 以 1024 固定点编码并限制单次值不超过 64,Resolve 再除以量化倍率和样本数,得到每体素的平均 HDR 自发光。
光照阶段使用 emissive = Emissive.rgb * emissiveIntensity,与方向光直接项相加。于是:
其中 是独立 Emissive 纹理读取值。DirectRadiance 每次重写时都重新读取纯 Emissive,而不是读取上一轮 DirectRadiance;方向光、阴影或强度变化因此不会让自发光逐帧叠加。Emissive 的 A 通道仍复制占用值,保持辐射纹理和空间遮挡的通道契约一致。
六、DirectRadiance
DirectRadiance 的 RGB 是“自发光 + 当前方向光直射”的线性辐射近似,A 是体素占用/遮挡值;它不是材质 GBuffer,也不是屏幕颜色。VoxelGIRenderPass.Lighting.cs 在 VoxelDirectLighting 后立即为它生成完整 Mip 链,供更大空间足迹的辐射查询使用。FinalRadiance 使用同样的格式和 A 语义,但只有启用 SecondBounce 时才由 VoxelIndirectLighting 写入:
这里的 来自对 DirectRadiance 的一次半球辐射查询;本式只用于明确资源边界。关闭第二次反弹时,管线保留可用的 DirectRadiance,不会把尚未计算的间接结果冒充为最终辐射。
Direct 与 Final 都是 VoxelGICameraContext 的跨帧持久资源,而 Mip Scratch 是当前 Lighting Pass 创建的临时 3D 纹理。每一级 Mip 都由上一层的 2×2×2 体素平均得到,再复制回目标纹理对应层级;这使辐射数据在写入、重光照和空间查询之间保持清楚的生产者/消费者关系。
