ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

光线追踪透明阴影:shadow ray透射率累乘原理与DXR实现

2026/9/9 11:49:57 拓冰建站 浏览量
光线追踪透明阴影:shadow ray透射率累乘原理与DXR实现 如果你把一个彩色玻璃花瓶放进场景里开了 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 如果为 0shadowColor 就是黑色完全不透明物体的阴影退化成纯黑跟经典 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同时准备几个材质参数基础透射颜色 transmissionColorRGB不透明度 opacityfloat体积吸收系数 extinctionfloat。在写 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; // 起始偏移避免自交 RayQueryRAY_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 贴图和自发光贴图这样阴影颜色就能携带物体的纹理细节。这个思路你可能在看任何文档时都不会看到但实际特效里非常管用。拿这个技巧配合前面说的半分辨率重建和体积衰减模型你完全可以在一个普通的游戏场景里做出让美术惊喜的透明阴影效果。