光线追踪透明阴影:shadow ray透射率累乘原理与DXR实现
2026/9/9 11:49:54 网站建设 项目流程

如果你把一个彩色玻璃花瓶放进场景里,开了 Ray Tracing,结果地面上的影子还是死黑一片,顶多边缘柔和了一点——恭喜你踩到了“Shadow transparency”最典型的理解误区。很多人以为光线追踪阴影天然就能表现半透明物体的色彩透过,其实 Ray Tracing 只负责“光线有没有被挡住”,至于挡住之后的光线衰减了多少、透过来的是什么颜色,完全要你自己写。这个项目标题看起来只有三个词,但它背后实际上是一整套阴影衰减逻辑:shadow ray 的连续求交、透射率的累乘、材质参数的读取、性能的取舍。本文就围绕这套逻辑,把透明阴影从原理到落地完整讲透,适合刚把手伸进 DXR/Vulkan RT 的引擎开发者,也适合给想搞懂 RT 阴影系统拆解路径的 TA 当作参考。

1. 先搞清楚:透明阴影为什么难画,难在“信息丢了”

1.1 传统阴影贴图是怎么丢信息的

在进入 Ray Tracing 之前,我们得先承认一个事实:透明阴影在光栅化管线里不是做不出来,而是做得非常憋屈。传统 Shadow Map 的做法是先从光源视角渲染一张深度图,然后在主 Pass 里把片元变换到光源空间,拿深度去比较,遮挡就是阴影,没遮挡就是亮部。这个模型天生是二值的:一个点要么被挡住,要么没被挡住,没有任何中间档位。

对不透明物体来说这没什么问题,但对半透明物体就是灾难。玻璃、彩色塑料、薄纱窗帘,这些物体在深度图里留下的只是一个深度值,它“挡住了”光,至于它挡了多少、把光染成了什么颜色,深度图里根本没有存储这些信息的通道。所以常见的折中方案是:要么把半透明物体塞进 Shadow Map 的渲染队列里,靠 Alpha Blend 让深度值变得不三不四,影子出来一团脏;要么干脆给阴影贴图加一张颜色附件,做一张“彩色阴影图”,但这也只解决单层平面的问题,多层玻璃叠在一起,光影的叠加顺序又没法处理。

这就是我标题里说的“信息丢了”的意思。光栅化阴影的本质是把三维遮挡压缩成一个深度值,压缩过程中,光线和透明物体之间的交互信息全被丢弃了。这也是为什么 Ray Tracing 的 shadow ray 一出来,大家第一反应是“透明阴影有救了”——因为它根本不做压缩,它沿着光源方向真实地打出一条光线,沿线遇到什么都能知道。

1.2 光线追踪是怎么把信息找回来的

Ray Tracing 的阴影查询,本质上是向光源方向发射一条射线,检查这条射线是否与场景相交。如果只用“有没有相交”这个布尔值,那它跟 Shadow Map 没有任何区别,最多只是更准一点。透明阴影的关键在于:你不把这条射线当成一次性查询,而是当成一条完整的光路,沿途每一个半透明交点都要记录“被过滤了多少光”。

这个思路说出来很简单,实现上有两个变化是很多人忽略的。第一,shadow ray 不能再用“最近命中”的 Closest Hit 逻辑了,因为最近的那个命中如果是透明物体,光线还会继续往光源方向走,可能还会碰到后面的东西;你需要的是“沿途所有命中”。第二,每个命中点的材质信息必须能被阴影着色器读取到,这意味着透明物体的阴影渲染不能光看一个深度值,而是要把材质的不透明度、透射颜色、体积吸收系数这些参数都暴露给 shadow ray 的命中处理逻辑。

把这两件事想明白之后,你再去读各种引擎源码里的 Ray Traced Shadow 实现,思路就通了很多。比如说你在虚幻引擎里打开 Ray Traced Shadow,会发现它对透明物体的处理有个专门的通道,透射颜色会通过按比例混合的方式作用在阴影上——那句话背后就是我们接下来要讲的 transmittance 累乘。可以说,搞懂 shadow ray 的透过率累乘,透明阴影基本上就完成了一大半。

2. 核心原理:shadow ray 沿路径累乘透过率

2.1 从光的传输公式说起,T(x, ω) 到底在乘什么

我们做实时渲染的人可能平时不会把渲染方程挂在嘴边,但透明阴影这件事绕不开一个小小的积分概念。一个表面点 x 收到的来自方向 ω_i 的光照,等于光源出射的辐射度乘以一个“路径衰减系数”T(x, ω_i)。对于真空中的不透明遮挡,这个衰减系数只有两个值:完全通过是 1,完全挡住是 0。对于半透明物体,它就是一个 0 到 1 之间的分数,而且这个分数来自光路上所有材质属性的连乘。

在图形学里,这个衰减被称为 transmittance,用公式写就是:

T = exp(-∫ σ_e(s) ds)

这是一个对光路长度 s 的积分,σ_e 是材质的衰减系数。这个概念其实大家在高中学比尔-朗伯定律时就见过——光穿过溶液,吸收越多,透出越少,而且是按指数衰减的。在实时渲染的简化处理中,我们通常不会对每条 shadow ray 做真正的体积积分,而是把它离散化:光线穿过一层半透明表面时乘一个系数,穿过两层就再乘一个系数,一层一层叠下去。

这里有个非常容易出错的地方:transmittance 是乘法,不是加法。很多人写透明阴影的时候,下意识会觉得“两层 50% 透明的玻璃叠在一起,阴影应该是 50% 或者 100% 黑”,其实都不对。两层 50% 透明叠在一起,光透过第一层剩 50%,再透过第二层,剩 50% 的 50%,也就是 25%。所以最终阴影的亮度是 25%,这是指数衰减的直觉,跟“叠纸”的直觉完全不一样。理解这一点,后面写代码的时候就不会加错。

2.2 三种透明度模型怎么算 transmittance

理想化的 transmittance 计算有三种层次,从简单到复杂对应不同效果需求。我们在实时引擎里最常用的是第一种,第二种主要在体积渲染里,第三种则是路径追踪的标准做法。

第一种是“表面对表面”的离散衰减。就是你有一堆半透明三角形,shadow ray 命中了它们,每个命中点会返回一个透射系数。这个系数可以来自贴图的 alpha 通道,可以是材质常数,也可以是更复杂的 per-pixel 着色结果。实现时每次命中都执行一次 transmittance = transmittance * materialTransmittance。这个方案适合玻璃窗、彩色塑料、挡风玻璃这类封闭或半封闭表面。注意这里的 materialTransmittance 本身是一个 RGB 三元组,所以 R、G、B 三个通道会按不同比例衰减,最终造成彩色阴影。

第二种是体积吸收。把透明物体当成参与介质,比如有色烟雾、水池。transmittance = exp(-extinctionCoefficient * distance),distance 是光线在物体内部穿过的距离。这个方案能表现出“物体越厚,影子越深”的物理直觉,比如一个装满浑水的大缸,缸壁边缘影子浅,中心影子深。实现它需要知道 shadow ray 在物体内的入射点和出射点之间的距离,所以你得对同一个物体做两次命中,或者用背面命中距离减去正面命中距离。

第三种是全光谱路径衰减。路径追踪器里用带颜色的吸收系数,把每个波长的 transmittance 分开算,最后得到的阴影本身就是彩色的。这个方法最精确,但实时渲染中基本不会这样做,因为成本太高。不过它的思想可以指导我们做近似:不要在阴影里只算一个标量灰度,而是用 RGB 透射颜色去乘光源颜色,这样才能得到真正的彩色透明阴影。

2.3 阴影结果的组合:不是简单的 lerp

当 transmittance 累乘完,怎么把它变成最终输出的阴影颜色,这里也有讲究。最常见的做法是:

shadowColor = lightColor * transmittance

然后把这个 shadowColor 作为光照衰减系数,乘到被着色点的直接光照项上。注意这意味着 transmittance 如果为 0,shadowColor 就是黑色,完全不透明物体的阴影退化成纯黑,跟经典 shadow ray 行为一致。

有些引擎会做一步额外的处理:把 transmittance 和 shadow 的百分比 visibility 分开存储,先判断这条 shadow ray 是否被完全不透明物体挡住,如果是,直接丢弃后面的累乘逻辑;如果途中只遇到半透明物体,再累乘 transmittance。这个分离有什么用呢?它可以把“不透明阴影”和“透明阴影”当成两个可用插值混合的项,方便美术在同一个光源上控制两类阴影的强度,也方便处理一些材质自阴影的边界情况。

另外再提一个细节:光源有多个时,每个光源要分别发射 shadow ray,每个光源的 transmittance 独立计算。很多人在单光源 demo 里跑通了,一接多光源就出问题——把 transmittance 存进了全局变量里跨光源复用,结果光从 A 灯射过来,却带着 B 灯的衰减信息。每个光源的日志其实都应该重置路径衰减为 1。

3. 在 DXR/HLSL 里把透明阴影跑起来:一份最小实现

3.1 关键前置:材质数据的暴露与分类

光说不练没有用,我们直接看实现。下面的代码基于 DXR 的 DXIL 着色器模型 6.5 编写,但整体逻辑迁移到 Vulkan RT 射线遍历里也完全成立。我先说清楚前提:场景中透明物体要能和普通不透明物体区分开。最简单的方式是给材质增加一个标志位 isTransmissive,同时准备几个材质参数:基础透射颜色 transmissionColor(RGB),不透明度 opacity(float),体积吸收系数 extinction(float)。

在写 shadow ray 之前,先做一个决定:用 TraceRay 的标准 Closest Hit 还是用 Inline Ray Query?这里我强烈建议透明阴影用 RayQuery。为什么?因为标准的 TraceRay 在遇到透明物体时会走 Any Hit 或者 Closest Hit 着色器,哪怕你对所有透明物体做了分类,Shader Binding Table 的路由处理也会让你在多次命中时频繁跳转着色器,开销非常大。RayQuery 允许你在一条循环里逐个检查命中点,命中了几十次也只是一个着色器实例里的事,灵活度完全不同。

那这个材质参数怎么传递到用于 TraceRay 的加速结构场景里?在 DXR 里一般通过绑定场景的 per-instance 或 per-geometry 数据,比如一个 StretchedBuffer 存材质索引,命中后用 GeometryIndex 或 InstanceID 去读取。这里不展开讲整个资源绑定,只说一个关键点:transmissionColor 必须在阴影通道里可见,不要把它放在一个只有 Final Gather Pass 才绑定的 CBV 里。这个坑我踩过不止一次,在 FullScreen Pass 里测试主光线着色没问题,一接 shadow ray 就全黑,查半天发现是 SRV 没有绑定到 Ray Generation 着色器。

3.2 shadow ray 主循环:沿途遍历所有命中

接下来是核心函数。它做的事情是:从着色点向光源方向发射一条 ray,不断取出最近的命中点,如果是不透明物体直接判定遮挡,如果是透明物体则累乘透射率,然后继续前进,直到光线到达光源位置或者 transmittance 小于某个阈值。

先给出用于判断光源类型、光照颜色的示例接口,假设光源是点光源,lightDistance 是着色点到灯的距离。在 HLSL 里大致长这样:

float3 traceTransparentShadow(RayDesc ray, float lightDistance, float3 lightColor) { float3 transmittance = float3(1, 1, 1); float tCurrent = 0.001; // 起始偏移,避免自交 RayQuery<RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH> q; RayDesc localRay = ray; localRay.TMin = tCurrent; localRay.TMax = lightDistance; while (transmittance.r > 0.003 || transmittance.g > 0.003 || transmittance.b > 0.003) { // 初始化 RayQuery q.TraceRayInline( sceneTLAS, RAY_FLAG_NONE, 0xFF, localRay); // 遍历所有候选命中 bool distributionDone = false; float hitT = lightDistance; bool isTransmissiveHit = false; while (!distributionDone) { CandidateProbe cand = q.Probe(); switch (cand.kind) { case CANDIDATE_NON_OPAQUE: { // 对候选做 alpha test 或者材质判断 Material mat = fetchMaterial(cand.instanceID, cand.geometryIndex, cand.barycentrics); if (mat.opacity < 0.95 && mat.isTransmissive) { q.CommitProbe(); } break; } case CANDIDATE_OPAQUE: case CANDIDATE_COMMITTED: { // 命中不透明体,直接全遮挡 return 0.0; } case CANDIDATE_MISS: { distributionDone = true; break; } } } // 此时要么无命中,要么命中了透明物体 if (hitT >= lightDistance) break; // 累乘透射率 Material mat = fetchCommittedMaterial(); float thickness = (hitT - tCurrent); float3 attenuation = exp(-mat.extinction * thickness); transmittance *= mat.transmissionColor * attenuation * (1.0 - mat.opacity); // 继续从命中点往后追踪 tCurrent = hitT + 0.002; localRay.TMin = tCurrent; } return lightColor * transmittance; }

这里我把代码做了大量简化,但逻辑骨架就是那个 while 循环:不透明物体直接干,透明物体乘衰减,衰减到阈值以下就不用再追了。还有一个细节值得注意:我用的衰减公式里同时出现了 transmissionColor 和 exp(-extinction * thickness),这是一种混合模型,transmissionColor 负责贴图信息,extinction 负责厚度信息。如果场景里的透明物体没有体积概念,直接把 extinction 设成 0,表达式就退化成 transmittance *= transmissionColor * (1.0 - opacity),也完全可用。

3.3 把阴影结果接进光照模型:别忘了累积区分的坑

上面函数返回的是 lightColor * transmittance,在主着色器里把它当作直接光衰减因子用就行。代码看起来是:

float3 directDiffuse = albedo * lightColor * brdf(N, L) * cosTheta; float3 shadowedDiffuse = directDiffuse * traceTransparentShadow(ray, dist, lightColor) / max(lightColor, 1e-5);

第二步除回去是为了避免光源颜色被乘两遍。有些人会直接拿 transmittance 作为因子乘到 directDiffuse 上,这要求 traceTransparentShadow 只返回 transmittance 而不是 lightColor * transmittance。通常为了减少后面主着色器里重复除法的麻烦,我会让阴影追踪函数统一返回纯的透射率因子,不混合 lightColor,这样多次光源采样时也方便做归一化。我上面写的函数返回了 lightColor * transmittance 是为了展示阴影颜色带光源色后的效果,实际工程里你最好把它拆成两步:先得到 transmittance,再做 lightColor * transmittance。

有了阴影因子,最后一步是决定阴影通道的分辨率和时间累积策略。我的经验是,把这个 traceTransparentShadow 放在一个半分辨率(全屏的 1/2 或 1/4)的阴影专用 pass 做,输出一份 transmittance 图,再做一次空间重建上采样到全屏。这样性能开销能压得很低,同时阴影质量基本不损失,尤其是透明阴影这种低频信息占主导的东西,半分辨率重建的效果会比不透明阴影好很多。

4. 工程化避坑:自交、性能、混合管线避不开的三个硬骨头

4.1 bias 与自交噪声的博弈

头号敌人永远是自交(self-intersection)。shader ray 起点如果有任何数值误差,光线会立即和出发面本身相交,造成阴影闪烁。不透明阴影通常用一个很小的 bias 沿法线方向偏移起点就能解决,但透明阴影问题更麻烦:当一个透明物体是曲面时,光从偏角方向射入,起点偏移小了,光线会在同一层三角片上反复命中,累乘出一串错误的衰减;偏移大了,又会漏掉薄物体的边缘部分,造成漏光。

我最终试下来比较有效的方案是层识别。给场景里的透明实例加一个归属标记,在阴影 RayQuery 的循环里,如果下一次命中点和当前出发点属于同一个实例 ID 和同一个几何 ID,且命中距离小于一个很小的阈值(比如 0.01 世界单位),直接跳过这次命中,不参与任何 transmittance 累乘。这个方案比单纯加大 bias 稳健得多,因为它是基于几何身份的过滤,不会对物体厚度产生误判。

另外,如果你的透明物体有双面材质(BackFace 会被看到),比如玻璃瓶,shadow ray 在入口面和出口面都会有命中。这时候每个表面贡献的衰减应该是入口面乘一次、出口面乘一次,结果是 exp(-extinction * totalThickness),物理上是对的。怕就怕图形 API 给你把背面剔除了,只留下一个面,那厚度就从入口面到光源剩下所有距离都算了,阴影会离谱地深。建几何体时不要让透明物体开背面剔除,否则 transparent shadow 的功能会直接废掉,而且排查起来非常隐蔽。

4.2 性能优化三板斧:提前终止、降解析、随机抖动

透明阴影的常规性能优化手段里,提前终止和降分辨率是见效最快的。提前终止的条件就是 while 循环里 transmittance 的阈值,我建议写成三个通道都必须小于 0.003 才 break,因为 0.003 量级的透射率贡献几乎肉眼不可见了,再往下追只是在浪费 ALU。这个 0.003 不是随便定的,它对应的亮度大约是 8bit 显示下的 0.765,你就算让它继续累到 0.0001,最终显示上也不会有一丁点差别。

降分辨率这块,上面提到半分辨率方案是标配,但要注意透明材质的高频细节。如果场景里有一片带条纹纹理的半透明塑料板,降分辨率会直接把条纹阴影细节洗掉。这种场景我建议不要整张半分辨率,而是把 shadow pass 分成两个区域:包含高细节半透明物体的区域用全分辨率,其余区域用半分辨率。DXR 的 TraceRayInline 本身就支持这种分块处理,一个 dispatch 里通过 screen UV 判断使用哪套参数即可。

随机抖动是一个进阶技巧。严格说透明阴影不需要随机性,因为它是完全确定性的路径。但很多引擎为了让最终聚合效果更柔和,会故意给 shadow ray 加一个微小扰动来模拟软阴影。针对透明阴影,扰动有一个额外的坑:扰动方向如果跟表面切线方向不完全匹配,会造成透射率和遮挡状态的不连续,出现毛毛糙糙的噪点。我的建议是:软阴影的扰动用面光源的 G-buffer 法线做半球采样;透明阴影的扰动用垂直于光源方向的双向圆盘采样,别用半球,因为半球采样会让 shadow ray 的透射路径出现弯曲的假象,表现成扭曲的阴影。

4.3 与级联阴影贴图混合:为什么全 RT 不现实,混合才是答案

透明阴影性能再好,也不建议全职做全场景的 ray traced shadow。大面积静态不透明场景用 shadow map 已经非常成熟,RT 暴露出来的收益并不明显。所以工程上更常见的做法是:远处的近景级联继续走 shadow map,只有近景并且包含重要半透明物体的区域才开启透明阴影射线。这个混合方案里有一个隐藏问题:RT 阴影和 shadow map 阴影的覆盖区域边缘怎么衔接。如果 RT 覆盖范围内外都在渲染同一束光,边界的连续性会很难看。

我的处理思路是给 RT 阴影区域做一个软过渡带。在过渡带内,最终阴影 = lerp(shadowMapShadow, rtShadow, fadeWeight)。fadeWeight 根据像素到 RT 区域边界的距离计算,用 smoothstep 做平滑。这个方案会让半透明物体的阴影在视野边缘处跟 shadow map 的硬阴影融为一体,观感上基本无感。另外,如果 shadow map 管线的部分透明物体不参与渲染,那么在过渡带内,半透明物体的阴影会突然变成没有,这个比颜色不一致还严重。如果你必须在同一个场景里兼顾两种阴影方案,最省事的办法是:让所有半透明物体都强制参与 RT 阴影,而不透明物体按距离走 shadow map,两类物体能分开处理,过渡问题就局限在不透明物体的边界上了。

5. 再进一步:体积阴影、焦散近似和路径追踪的做法

5.1 体积参与介质:从“表面透明”升级到“空间透明”

前面讲的是基于表面对表面的透明阴影。如果你想要烟、雾、水下尘埃这种空间中的半透明阴影,那条 while 循环里的逻辑就完全不够用了。对体积参与介质,shadow ray 在传播过程中每时每刻都在被吸收和散射,不能用离散命中点,要用体素步进(Ray Marching)。

不过,你完全可以复用同一套 transmittance 累乘框架:把 shadow ray 从起点到光源切成若干段,每段查询体积纹素的 extinction 值,乘上步长距离,再累乘到 transmittance 上。在只求阴影衰减、不求体积光照的情况下,这样做成本还是可控的,因为阴影射线不需要存储散射方向信息,只需要最终透射率。

体积阴影对半透明物体还有一个额外好处:它能自动处理多层表面物体内部厚度的问题。以前面那种双面玻璃瓶为例,如果有一个密度场正好填在玻璃瓶内,那么 shadow ray 在瓶体内部那一段会乘上瓶壁的透射率,再乘上瓶内体积的衰减,两者天然叠加,不需要额外写碰撞逻辑。

5.2 折射弯曲的焦散近似:一条直线做不到的事

到这里我必须给很多想直接上手的人泼一盆冷水:完全基于直线 shadow ray 的透明阴影,只能模拟光的吸收和透过,不能模拟光的折射弯曲。这意味着真正的焦散效果——光透过玻璃凸面汇聚成一个明亮的光斑——没法用透明阴影的常规框架做出来。因为光在进入玻璃后路径会弯曲,直线 shadow ray 根本不会命中那个“正确”的落点。

想做近似焦散,有一个投机取巧的方法:在透明阴影的 shadow ray 主循环里,当命中一个强折射材质时,不把 transmittance 直接乘进去,而是把光源方向做一次 Snell 折射(拿 ray 方向跟表面法线算折射),折射后的方向生成一条新的 shadow ray 继续向前追。这已经是一个小型递归射线追踪了,性能会急剧上升,而且多次折射后误差会累积。我做过一次实验,单层薄玻璃还可以,两层玻璃叠起来结果就完全不可控了,一般不推荐在实际产品里这么干。

如果你的项目里焦散是刚需,老老实实走光子映射或者路径追踪的焦散管线,那是另一套体系。实时的近似方案 PBRT 里说的 filtered photon maps 思路也值得借鉴——只不过那是离线玩法,游戏引擎里要实时做就得上体积光子树一类的加速结构,复杂度会高出一个档次。

5.3 离线渲染里的透明阴影有更简单的答案

最后说一句,如果你不是在做实时渲染,而是在写自己的离线渲染器,那透明阴影反而是最简单的部分。路径追踪的天然特性就是光线会真实地穿过半透明介质,吸收随路径累积,最后落在相机上的像素本身就是带着正确答案的,没有什么阴影贴图、射线取舍、材质分类的问题。

但路径追踪里透明阴影的难点在于噪点。透射路径要在一堆随机采样里抽中玻璃内部那一段,采样效率很低,尤其是场景里半透明物体很小、很薄时,绝大部分路径都是撞在周围的遮挡物上,最终生成的阴影区域噪点极其严重。解决思路是给半透明物体建一个重要性采样包围盒,专门用来多采透过包围盒的路径。这个做法在 Mitsuba 里很常见,你自己实现时也完全可以借鉴。

回到我们整个讨论的起点:透明阴影这件事,从“渲染出透明物体的彩色影子”这个表面需求出发,最终会引导你一步步深入到 light transport 的最底层——衰减、厚度、折射、体积、采样式本。这也是我认为它值得专门写一篇文章的原因:它不是一个独立的“开关”,而是让你被迫理解光在介质中如何传播的一个入口。

再补充一个我自己印象很深的案例。之前有个项目需要在近景玻璃幕墙前放一群半透明的人物雕塑,开启透明阴影前,地面上所有影子都是统一的死黑色,雕塑的通透材质完全无法体现;开启后,因为我用的 transmissionColor 是直接从材质贴图采样来的,雕塑表面花纹的颜色被完整地投到地面上,整个场景的层次感一下子就出来了。我当时用的技巧其实只有一个:让 shadow ray 在命中半透明物体时,不仅读取 alpha,还采样了物体的 baseColor 贴图和自发光贴图,这样阴影颜色就能携带物体的纹理细节。这个思路你可能在看任何文档时都不会看到,但实际特效里非常管用。拿这个技巧,配合前面说的半分辨率重建和体积衰减模型,你完全可以在一个普通的游戏场景里做出让美术惊喜的透明阴影效果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询