☰
屏幕空间反射(SSR)实战:原理、GLSL实现与调优技巧
2026/10/5 13:58:28 网站建设 项目流程

屏幕空间反射(SSR,Screen Space Reflections)是我做实时渲染时最喜欢的反射方案。它不需要预烘焙、不依赖场景复杂度,只从当前帧画面里反复采样,就能给水面、金属表面、湿地板这些材质做出足够可信的反射效果。对于做游戏画面、三维可视化、工业预览的朋友来说,SSR是一个投入产出比很高的技术——美术想要的效果大部分它都能顶住,引擎内置实现能直接用,想改造也留有很多余地。这篇文章不推复杂公式,只讲我落地SSR时踩过的坑、试出来的参数习惯,以及可以直接抄走的GLSL实现逻辑。

1. SSR的思路与选型逻辑

1.1 先聊聊反射这件事为什么难做

实时渲染里的反射是个老难题。真实世界里光打到物体表面,再进入摄像机,中间经历的是一次完整的“光线弹射”。实时渲染要模拟这种弹射,计算量经常指数级上升。之前做项目时,美术想要光滑大理石地面倒映出天花板灯带,我第一反应不是上SSR,而是先算算反射探针够不够用。

反射探针(Reflection Probe)是早期标准方案,本质是在场景某个点捕获周围环境到立方体贴图,然后当作反射源。优点是速度快、可控性强,缺点也很明显——它只能反射出“探针捕捉到的东西”,对于动态物体、近距离物体,画面很容易对不上。比如一个士兵走过大理石地面,探针如果放在房间中央,地面反射里的士兵位置和真实位置经常差半米,穿帮严重。平面反射(Planar Reflection)能做得很精准,但它会把场景再画一遍,开销大,而且只适合平面表面,遇到曲面就抓瞎。

SSR走的是另一条路:不额外重构场景,而是利用当前帧屏幕里已经渲染好的深度、法线、颜色信息,把这些信息当成一个“场景切片”,然后从靠近相机的那层像素出发,半条反射轨迹在屏幕空间里进行射线步进。说白了,它是在2D屏幕上做“伪光线追踪”。因为用的就是当前帧画面,所以动态物体、粒子、角色位置可以天然和反射对齐,不存在“探针数据过期”的问题。

1.2 为什么SSR值得优先考虑

使用SSR最大的好处是廉价。它不需要重新绘制几何体,也不维护额外的场景加速结构,核心计算就是一堆纹理采样和比较运算。在延迟渲染里,深度和法线本来就有现成的G-Buffer,SSR直接捡起来用,管线集成非常顺。

另一个优势是效果真实。由于它采样的是当前帧的最终画面,反射里能带上其他后处理效果,比如雾、激光、粒子爆闪,这在美术调画面时很有价值。传统探针反射经常“缺少灵魂”,因为和最终画面中间隔着各种光照和特效堆叠,反射颜色经常偏平。SSR则基本零学习成本,所见即所得。

但SSR也有硬伤,它只能反射屏幕内“能看见”的内容。如果一个物体在画面外、被墙挡住、或者屏幕边缘外,那它不可能出现在反射里。所以实际项目中几乎没有纯SSR的方案,大家都是“SSR打底,探针补漏”,最终反射结果用探针颜色做远端插值。这也是我后来做品质优化时最常用的一招。

很多学图形学的朋友容易陷入“我要把SSR做到完美”的误区,最后发现屏幕空间本身的限制是解决不了的。我的态度是:把SSR当成“锦上添花”,而不是“唯一真理”。只要它能大幅度提升主要视觉区域,边缘那些反射丢失问题,就用混合去掩盖,这才是工程心态。

2. 核心细节与实现要点

2.1 从深度纹理重建像素位置

SSR第一步是知道当前像素在三维空间里在哪,以及视线朝哪个方向。通常延迟渲染里都有深度纹理,里面存的是NDC(标准化设备坐标)深度值,需要解算出世界空间或视空间坐标。

我习惯在视空间里做步进,因为相机在原点,反射向量算起来直观,步进方向也好控制。重建视空间坐标有标准做法:拿到屏幕坐标uv和深度值d,先转换成NDC坐标(xyz范围在[-1, 1]),然后利用投影矩阵的逆矩阵算出视空间坐标。代码看起来是这样:

vec3 reconstructViewPos(vec2 uv, float depth) { // uv: [0,1] 屏幕坐标, depth: 线性或非线性深度 vec4 ndc = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); vec4 viewPos = inverseProjectionMatrix * ndc; return viewPos.xyz / viewPos.w; }

很多引擎为了省性能,不会真用逆矩阵乘,而是用一个近似的“在近裁剪面插值”方法。因为视空间里,射线在屏幕上投影是线性的,所以可以用屏幕空间四个角的方向向量做插值再乘以深度。这个优化在小规模场景里效果不明显,但VR和移动端很吃性能,值得做。

需要注意的是深度纹理的存储格式。很多引擎把深度变成非线性的,存到16位上下。如果直接在步进时拿这种深度去比较,会出现近处精度高、远处精度低的偏差,导致反射命中位置漂移。我一般会先做一步“线性化”处理,把depth从[0,1]变成视空间里的实际z值,方便后面做距离判断。

线性化公式网上很多,本质是用透视投影的近远裁剪面还原。给一个常见版本:

float linearDepth(float depth, float near, float far) { float z = depth * 2.0 - 1.0; return 2.0 * near * far / (far + near - z * (far - near)); }

这套手续费不了多少性能,但对后续步进命中判定的稳定性帮助很大。

2.2 步进距离和方向怎么定

拿到像素的世界/视空间位置后,就要算出反射方向。如果法线纹理里存的是世界空间法线,直接用reflect(viewDir, normal)就行;如果法线在切线空间,需要先变换。在视空间里,视线方向是normalize(-viewPos),然后反射向量就是用标准reflect公式。注意这里法线必须和视线在同一空间,否则反射方向会有肉眼可见的偏差。

确定方向后,SSR最常见的实现是“沿着反射向量往前迈步,每次迈步后把三维坐标投影到屏幕uv,然后采样深度纹理,比较当前深度和步进点深度”。如果步进点深度更靠近相机且和实际深度足够接近,就认为射线命中了几何表面,然后取该uv处的颜色作反射色。

伪代码如下:

vec3 stepDir = normalize(reflectDir); vec3 curPos = startPos + stepDir * minDist; float stepLen = 0.05; // 步长需要自适应 for (int i = 0; i < maxSteps; i++) { vec4 clipPos = projectionMatrix * vec4(curPos, 1.0); vec2 uv = clipPos.xy / clipPos.w * 0.5 + 0.5; // 超出屏幕范围就放弃 if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) break; float sceneDepth = texture(depthTexture, uv).r; float sampledZ = -curPos.z; // 看坐标系约定 float delta = sampledZ - linearDepth(sceneDepth); if (abs(delta) < hitThreshold) { // 命中 return texture(colorTexture, uv).rgb; } curPos += stepDir * stepLen; }

这段代码能跑,但实际项目里不能用固定步长。因为屏幕空间里近处物体变化快,远处物体变化慢,固定步长会导致近处漏检、远处过采样。更合理的做法是根据视空间深度动态调整步长:离相机越远,步长越大;离相机越近,步长越小。也可以直接使用“线性视空间步长”,然后投影到屏幕空间后检查进度。

另一个容易被忽略的点是起始位置。如果射线从像素位置本身出发,第一个步进点可能和几何体重合,造成自遮挡空洞。建议给起始位置加一个很小的偏移量,比如沿法线方向偏移一点点。这个偏移量不能太大,否则细缝里的反射会消失;太小又会有自交噪点。通常取0.001 * viewDepth左右比较稳妥。

2.3 HZB加速和二分命中优化

纯线性步进效率很低。maxSteps设少了,反射全是断断续续的“斑马纹”;设多了,性能又扛不住。我推荐的加速方案是层级Z缓冲(HZB,Hierarchical Z-Buffer),原理就是把深度纹理做成一整套mipmap链,每个层级记录该区域内离相机最近或最远的深度。步进时先从粗层级开始,如果步进点深度比该层级的深度记录还远,说明还没碰到任何场景,直接跳一大步;如果碰到,就降一层继续精细判断,直到命中。

这个思路和“先看地图找大方向,再缩小范围找具体街道”一样。在2K分辨率下,用HZB可以把平均步进次数从100+降到20~30,画面质量反而更稳定,因为算法能自适应地形复杂度。

二分命中优化是配合HZB使用的。当你在某一层发现当前深度已经越过场景深度,就“回退”到上一层,缩小步长继续迭代。通过反复“前进过头再回退一半”,可以逐步逼近真实交点。这比纯向前步进能更好处理薄片物体,比如水面上的落叶、栅栏。

实际编码时,HZB的命中判断不能只看单个采样点,否则物体边缘会有很重的锯齿。可以用一个类似“最近深度”的mip:步进点前后各取若干深度范围比较,允许一定余量。这个余量会直接影响视觉观感,调小了反射出现虫洞,调大了反射泛白模糊。

我个人喜欢把HZB计算放在预处理阶段,用一次计算着色器生成层级深度。然后在SSR的步进循环里,通过textureLod(depthTexture, uv, mipLevel)采到指定层级的深度,用这个和步进点的深度做比较。mipLevel并不是固定的,而是根据当前uv区域和步进过程动态调整。

3. 实操:一版可用的SSR实现

3.1 输入输出和渲染流程

在谈代码前,我们先对齐接口。SSR作为后处理或G-Buffer之后的pass,一般需要这些输入:

  • 颜色纹理(最终光照后的帧缓冲颜色)
  • 深度纹理(场景深度,带mipmap)
  • 法线纹理(世界空间或视空间)
  • 相机投影矩阵、逆投影矩阵
  • 从颜色纹理分辨率映射到SSR渲染分辨率

输出是一张“反射颜色图”(可带透明度和置信度),后面再接一个反射混合pass,和屏幕反射探针结果做插值。这样逻辑解耦,出了问题方便排查。

在延迟渲染里,法线纹理可以复用G-Buffer中那张normals,省一次采样。如果做前向渲染,则需要额外生成法线图和深度图,成本稍微高一点。我的建议是,SSR尽量配合延迟渲染使用,不然投入的成本会让美术皱眉头。

3.2 关键代码与逐段说明

这里给一段精简但能工作的GLSL片段,基于视空间实现。为了阅读方便,我拆成几个子函数。

uniform sampler2D colorTexture; uniform sampler2D depthTexture; uniform sampler2D normalTexture; uniform mat4 projectionMatrix; uniform mat4 inverseProjectionMatrix; // 由uv和深度重建视空间坐标 vec3 viewPosFromUV(vec2 uv, float depth) { vec4 ndc = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); vec4 viewPos = inverseProjectionMatrix * ndc; return viewPos.xyz / viewPos.w; } // 视空间坐标投影到屏幕uv vec2 uvFromViewPos(vec3 viewPos) { vec4 clipPos = projectionMatrix * vec4(viewPos, 1.0); return (clipPos.xy / clipPos.w) * 0.5 + 0.5; } vec3 ssr(vec2 uv, float depth) { vec3 viewPos = viewPosFromUV(uv, depth); vec3 normal = texture(normalTexture, uv).xyz; // 约定:视空间摄像方向朝-z,viewDir是normalize(-viewPos) vec3 viewDir = normalize(-viewPos); vec3 reflectDir = reflect(-viewDir, normal); // 起始偏移一点,防止和自身相交 vec3 curPos = viewPos + normal * 0.002 * abs(viewPos.z); vec3 stepVec = reflectDir * 0.02 * abs(viewPos.z); for (int i = 0; i < 64; i++) { curPos += stepVec; vec2 sampleUV = uvFromViewPos(curPos); if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0) { break; } float sceneDepth = linearizeDepth(texture(depthTexture, sampleUV).r); float sampleDepth = -curPos.z; // 视空间z为负 float depthDelta = sampleDepth - sceneDepth; if (depthDelta < 0.01 && depthDelta > -0.05) { // 命中 return texture(colorTexture, sampleUV).rgb; } // 如果步进点已经在场景深处,说明越过表面,需要回退修正 if (depthDelta < -0.05) { curPos -= stepVec * 0.5; stepVec *= 0.5; } } return vec3(0.0); }

这个版本每一步都做一次投影变换,性能不是最优,但胜在逻辑清楚。实际项目可以改成一次“屏幕空间射线步进”算法,直接在屏幕空间推UV,不反复做向量与矩阵运算,性能能提升一个量级。

depthDelta的上下阈值很关键。我这里的0.01表示步进点深度略大于场景深度,也就是步进点刚好在场景表面的后方一点点,这个通常被认为是命中;-0.05表示步进点比场景深度更靠近相机,如果大得比较明显,说明射线已经穿透表面,需要回退。具体数值因场景尺寸而异,建议做成材质参数,方便不同关卡调整。

3.3 参数调优与质量档位

我做过一组参数组合,用来匹配不同目标帧率下的画质。不需要每个项目都一样,但可以作为起跳点:

档位最大步进次数初始步长系数深度命中阈值HZB层级目标平台
低160.030.02无HZB,固定深度偏移手机低端
中320.020.01使用2级mip手机中高端 / 主机
高640.010.005使用4级mip桌面
超高1280.0050.003全mip + 二分细化高端PC

步长系数配合视空间深度会变化,所以不同的视角和场景规模对参数很敏感。我的经验是,先固定步进次数,把步长调大直到出现明显断层,再往回降低三档;然后调阈值,到反射边缘出现黑色裂缝前,往回放松一点点。整个过程在动态场景里反复晃相机,千万别只盯静止画面看。

SSR边缘的柔和度也值得调。通常反射命中后,不能直接当作最终颜色,要根据命中点uv离屏幕边缘的距离做fade,否则屏幕边缘会突然“断反射”。另外命中点离起点越远,信息越不可信,也要做距离衰减。这两个衰减曲线直接影响观感,我做项目时常把edgeFade和distanceFade单独放开给美术调。

4. 常见问题与排查技巧

4.1 反射边缘断裂和噪点

反射边缘疯狂闪、断成一条条噪点带,是我见过最多的问题,尤其是在阳光下的金属表面。原因通常是步进次数不够、步长固定,导致步进点跨越了表面却没能采样到。关键在于让步进自适应:用HZB或者动态步长,让近距离小步走,远距离大步跳。

加了HZB之后依旧有少量噪点,可以在命中处做“邻域遮罩检测”——检查命中点uv和初始uv之间是否被凸起物体遮挡。具体做法是:在命中点往回反弹半步,再投影采样一次深度,看是否一致。如果落差大,就把该采样标记为无效,用探针颜色兜底。这个技巧在曲面高光反射里很有用。

另一个造成噪点的情况是法线贴图高频细节太强。法线波动导致反射方向剧烈变化,屏幕空间里步进路径会弯曲,即使每个点的深度都对,反射方向却对不齐。这种情况我一般会把法线贴图的mip level调高一点,或者对法线做一次3x3模糊。效果立竿见影,代价是反射细节损失一点。

4.2 屏幕边缘的反射消失和物体后方的漏光

SSR的信息来源是当前帧画面,所以屏幕外物体反射不出来是正常现象。但屏幕上方的动态物体、角色背后的路灯,如果反射消失了,会特别突兀。我的处理是给SSR增加一个“回退颜色”的混合逻辑:在距离衰减和边缘衰减为0的地方,不要直接取黑色,而是取反射探针颜色做混合。这样就算反射没命中,颜色也不会发黑。

物体后方漏光指的是:反射命中点其实在另一个物体的背面,但由于屏幕空间信息没有几何连续性,射线穿过去了,拿到一个不该看到的颜色。这个问题在薄墙、栏杆、树叶场景尤其严重。解决办法是限制最大反射距离,并加“背面剔除”检查:在命中点处再采样法线,判断反射射线与表面法线方向是否大致相反,如果方向不合理就放弃命中。

我做过对比,只加这一道检查,能把“物体后方漏光”的伪影减少90%以上,代价是多了两次纹理采样。但要注意,有些材质(比如玻璃)希望在光线穿透后还反射后面的颜色,所以这道逻辑需要做成可选。

4.3 性能瓶颈与移动端适配

SSR的性能大头在步进循环里的纹理采样和矩阵运算。在PC上,64步加上HZB完全没压力,但到了移动端,尤其是OpenGL ES 3.0的设备,每一步都可能是压死帧率的最后一根稻草。

移动端常用这几种省钱方式:

  • 降低SSR缓冲区的分辨率,比如按半分辨率甚至四分之一分辨率计算,再做上采样。反射是一种低频信息,用低分辨率计算视觉差异不大。
  • 限制最大步进次数。移动端给24次以内比较稳,超过32次在部分芯片上可能掉帧。
  • 放弃HZB回退,改用固定步长加深阈值。移动端GPU纹理采样带宽有限,HZB需要来回读不同层的mip,反而可能更慢。
  • 用“双边滤波”代替“大范围高斯滤波”做反射颜色平滑。双边滤波能在保持边缘的同时消除大部分步进噪声。

我之前在骁龙8系上跑过中档效果,320x180的SSR缓冲,24步,开启双边滤波,整体开销在1.2ms左右,放在后处理管线里完全能接受。如果无脑上2K全分辨率和64步,开销直接飙到4ms以上,帧率立刻破相。

4.4 屏幕空间深度压缩带来的近裁剪面失真

最后一个我特别想提醒的坑:靠近近裁剪面的物体会产生巨大深度差,SSR在屏幕边缘容易出现严重失真。这是因为透视投影把大量精度给了近处,深度值变化率极大,固定步长根本追不上。如果你发现靠近画面边缘的反射像被拉伸成一坨糊状,大概率是近裁剪面太近导致的。

解决办法有两个:一是调大近裁剪面,从0.01调成0.1,很多漂浮感会消失;二是把深度线性化后用视空间距离比较,而不直接用原始深度。我在做第一人称项目时,把相机近裁剪面从0.05改成0.5后,SSR整体稳定度提升不少,代价是贴近物体的细节会有裁剪,需要在地形碰撞上做补偿。

另外要留意屏幕尺寸和FOV变化。不同宽高比下,屏幕边缘衰减的系数要重新计算。用UV坐标算边缘距离时,最好乘上一个宽高比修正系数,否则超宽屏上左右两侧的反射范围会偏窄。

总之,SSR是个越用越有心得的技术。有人在网上问“为什么我的SSR反射会有黑条”,多半是阈值和步长不匹配;有人问“为什么SSR画面很花”,通常需要滤波和降分辨率。最终效果好坏,其实不是靠某个大招,而是把深度线性化、HZB、命中衰减、边缘融合这一串细节都做对了,反射自然就扎实了。自己从零实现一遍之后,再去理解商业引擎里的SSR参数,会觉得整个选项都变得亲切起来了。

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

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

立即咨询