做渲染这些年,我越来越觉得画面“电影感”的秘诀往往不在模型和贴图,而在镜头本身怎么模拟。Bokeh散景效果Shader就是一个非常典型的例子:它能让高光点离焦之后变成圆形、六边形甚至八边形光斑,画面一下子就有了层次和氛围。游戏过场、UI特效、短视频包装、产品展示,凡是需要“镜头语言”的地方,这个效果都是高频需求。
这篇文章我会从镜头光学的基本原理讲起,一路拆到一个可以直接跑起来的GLSL实现。适合正在做后期特效、移动端渲染,或者只是想在项目里快速加上这个效果的朋友。看完你至少能明白两件事:散景光斑的形状到底是怎么来的;以及怎么在自己的Shader里可控地做出这种形状,而不是简单套一个高斯模糊了事。
1. 一个Bokeh效果到底在解决什么问题
1.1 为什么不能直接糊一层高斯模糊
很多朋友第一次做散景,会想当然地“那还不简单,模糊一下不就完事了”。实际做出来之后就会发现,高斯模糊出来的东西更像照片蒙了一层雾,完全没有光斑的感觉。
原因在高斯模糊的本质:它对整幅图像做低通滤波,用圆形对称、各向同性的权重把所有像素都“揉”在一起。像素本身的亮度差异被削弱了,高光区和暗部一起被抹平,结果是画面变柔,但高光不会“亮起来”,更不会形成有边缘形状的光斑。换句话说,高斯模糊没有区分“我要保留的高光细节”和“我要压平的暗部信息”。
真正的Bokeh效果有三个关键视觉特征,高斯模糊一个都做不到:
- 高光优先:只有亮度超过阈值的区域才会扩展成光斑,暗部尽量保持原有对比度;
- 形状感:光斑不是圆,而是带棱角的近似多边形,这是光圈叶片切出来的;
- 亮度堆积:多个高光光斑重叠的区域,会比单个光斑更亮,照片里的“过曝感”就这么来的。
所以我们不能把散景当成一种普通模糊,它本质上是“高光提取 + 形状化卷积 + 选择性合成”的组合技。
1.2 这个Shader能做什么,不能做什么
先划定边界。屏幕空间的Bokeh Shader能做的,是对已经渲染好的画面进行后期处理:把画面里超过一定亮度的像素提取出来,让它们按照设定的光圈形状向外扩散,再把结果叠加回原图。它非常适合处理2D插画、UI特效,以及3D场景里“背景高光点”的增强。
但它不是物理相机模拟,不会根据真实深度判断光斑应该出现在前景还是后景,也不会自动处理遮挡关系。如果需要严格的景深表现,还是要结合深度缓冲,或者用CoC(弥散圆)权重去控制每个像素的模糊半径。这点在工程集成的时候一定要心里有数,否则很容易做着做着就当成景深滤镜来用,最后发现遮挡关系全乱了。
2. 光斑形状从哪来:镜头光学与点扩散函数
2.1 从点光源到弥散圆
想做好一个效果,最好先搞清楚它背后的物理过程。Bokeh这个词来自日语“ボケ”,原意是“模糊、朦胧”,在摄影里专门指焦外高光的虚化方式。
想象一个点光源,比如夜晚远处的路灯。当它正好在合焦平面上时,光线经过镜头后会聚成一个点,传感器上记录下来的就是一个锐利的亮点。但一旦这个点光源离开合焦平面,光线就无法汇聚成一个点,而会在传感器上扩散成一个圆盘。这个圆盘叫弥散圆(Circle of Confusion,简称CoC)。
弥散圆的直径大致和光圈开口成正比,和离焦距离也成正比。你可以把镜头想象成一把漏水的花洒:花洒孔径越大,水流落下来扩散的范围就越大;离得越远,水点也越散。孔径就是光圈大小,距离差就是离焦程度。这也是为什么大光圈镜头散景会更明显、更梦幻。
2.2 光圈叶片如何决定多边形形状
细心的朋友会注意到,现实中镜头的光斑很少是完美的圆,更多是五边形、六边形、八边形。这是因为光圈不是一个圆孔,而是由多片光圈叶片围成的多边形开口。
常见镜头的光圈叶片数量在5到9片之间,叶片数量直接决定了光斑的边数。六边形光斑最常见,因为很多镜头的光圈是六片叶片。这几片叶片围出的开口,决定了光线通过时的边界形状,也就等于给每个弥散圆“刻”上了多边形轮廓。
除了形状本身,光斑内部的亮度分布也很有意思。理想情况下弥散圆应当是均匀亮度,但真实镜头会因为像差和渐变因素出现两类变化:一类是中心亮边缘逐渐变暗,比较柔和;另一类是边缘被压暗、接近外圈反而亮,形成类似“甜甜圈”的效果,像折返镜头那样。这些细节都可以在Shader里通过调整采样权重来模拟。
2.3 Shader里怎么近似这个物理过程
在屏幕空间里,我们不会真的去追踪光线穿过光圈叶片的路径。业界通用的近似思路是:
把图像里的每个高像素当成一个点光源,然后把“模糊”看成一次卷积。普通高斯模糊的卷积核是圆形且各向同性的,而Bokeh要用的卷积核必须满足两个额外条件:一是只在高亮区域产生反应;二是卷积核的形状必须由光圈多边形决定。
听起来抽象,但落到具体实现上其实很清楚:先用亮度检测找出一堆“虚拟点光源”,再对它们做一次“带形状的模糊扩散”,最后合成回原图。这就是后处理Bokeh的基本框架。
3. 方案选型:四类Bokeh实现怎么取舍
3.1 粒子/精灵散景方案
一种思路是让引擎帮忙:在场景中检测高亮像素后,把这些点变成屏幕空间的Billboard精灵,每个精灵使用一张预制的散景贴图,再根据深度信息控制缩放和透明度。优点是可控制性极强,可以手动摆放光斑位置,形状、颜色、大小都能单独调。缺点是工程量大,高光提取一旦做得不准,粒子的数量和质量都崩,而且很多实时渲染场景里很难稳定获取需要的高光点数据。
这个方案更适合做“导演级”的过场动画,而不是通用后处理。
3.2 后处理多Pass卷积方案
这是我认为最通用的方案,也是这篇文章要展开实现的方案。
核心思路分三步:
- 高光提取Pass:把原图里亮度高于阈值的像素保留下来,其余压暗;
- 形状化模糊Pass:对提取出来的高亮纹理做带光圈形状的卷积扩散;
- 合成Pass:把扩散结果按照亮度遮罩混合回原图。
这种方案的优势在于纯屏幕空间、和场景几何无关,引擎里任何一个全屏Quad都能跑。效果调节非常直观,改几个参数就能适配不同镜头风格。手机端也能跑,采样数量控制好就行了。
3.3 基于Bloom框架的迭代近似
有一定渲染经验的朋友应该看出来了,散景本质和高光扩散很像Bloom。所以有人直接用Bloom的迭代模糊框架,在每级降采样纹理上做模糊并累加。这种方式性能极好,多级迭代天然会产生一层层的光斑堆叠效果。
但缺点也很明显:Bloom的每一次模糊通常是标准高斯,对形状的支持很弱;如果想要六边形或多边形光斑,还是得在模糊阶段做定制。所以这个方案更适合移动端“能出效果就行”的场景,PC端想要高质量光斑,还是得走后处理卷积。
3.4 物理级镜头仿真方案
再往上是离线渲染领域的玩法,直接模拟光圈叶片的几何形状、光线走线,甚至用光线追踪计算PSF。精度当然最高,但计算成本不是实时渲染能承受的,一般只用于产品渲染和影视级离线流程。做游戏和交互渲染的朋友了解一下就行。
四种方案对比如下:
| 方案类型 | 实现难度 | 光斑真实感 | 实时性能 | 适用场景 |
|---|---|---|---|---|
| 粒子/精灵 | 中 | 高(需手工控制) | 中 | 过场、UI特写 |
| 后处理多Pass | 中 | 高 | 中高 | 通用屏幕空间效果 |
| Bloom迭代 | 低 | 中 | 高 | 移动端、低成本项目 |
| 物理仿真 | 高 | 极高 | 低 | 离线渲染 |
这次我选择方案二:能在长篇文章里把每一步讲清楚,性能也足够实用。
4. 核心Shader实现:从高光提取到多边形光斑生成
下面开始写核心实现。我用简单的全屏Quad作为载体,三个Pass解决。
先说清楚代码的位置:下面所有代码都是片段着色器的核心片段,顶点着色器只需要传UV坐标即可。
4.1 Pass 1:高光提取与柔化阈值
第一阶段的任务是找出“哪些像素值得变成光斑”。直接用硬阈值会得到苹果切出来的边缘,必须用软阈值过渡,常用的做法是smoothstep。
// 高光提取 Pass 片段核心 uniform sampler2D inputTex; uniform float threshold; // 建议 0.7 - 0.9 uniform float softKnee; // 建议 0.05 - 0.15 const vec3 lumaWeights = vec3(0.2126, 0.7152, 0.0722); void main() { vec2 uv = gl_FragCoord.xy / uResolution.xy; vec4 color = texture(inputTex, uv); float luma = dot(color.rgb, lumaWeights); float bright = smoothstep( threshold - softKnee, threshold + softKnee, luma ); // 保留原色,但按亮度遮罩压低暗部 outputColor = vec4(color.rgb * bright, bright); }这一步有几个细节:
softKnee不能让零,否则高光和暗部之间会出现生硬的切割感;- 这里用的是gamma空间的luma权重,如果你的管线是线性HDR,建议在线性空间计算luma,效果更准确;
- 阈值不能设太低。很多人第一次用散景会把阈值调到0.5以下,结果人物皮肤的亮部也变成了光斑,画面跟镜头脏了一样。
4.2 多边形光斑的形状函数
接下来是重头戏:怎么在Shader里生成六边形或任意多边形的光斑形状。
我采用的方法是先定义“某个角度方向上,多边形边界能到达的最大半径”。对于正N边形,用极坐标能推导出非常简洁的公式:
#define TWO_PI 6.28318530718 // 给定角度 theta 和边数 sides,返回该方向上多边形边界的半径缩放系数 float polygonRadius(float theta, float sides) { float sector = TWO_PI / sides; float phi = mod(theta + 0.5 * sector, sector) - 0.5 * sector; return cos(0.5 * sector) / max(cos(phi), 0.0001); }解释一下这个函数的行为:当theta正好对准多边形的顶点时,phi = ±sector / 2,分母cos(phi)得到最小值,所以返回值为1,表示这个方向上光斑能撑到最大半径。当theta对准平边方向时,phi = 0,分母为1,返回值变成cos(sector / 2),比如六边形就是0.866,光斑在这一方向被压缩,正好形成六条边。
实际使用时,我们不需要严格计算每个采样点的最大半径,而是用采样点的距离与这个方向允许的最大半径做比较,再乘以一个平滑权重,就能得到柔化边缘的多边形。
// 计算采样点相对中心的方向和距离 float theta = atan(offset.y, offset.x); float r = length(offset); // 该方向允许的最大半径(R是整体光斑半径) float maxR = R * polygonRadius(theta, sides); // 超出边界时平滑衰减 float shapeWeight = 1.0 - smoothstep(maxR * 0.85, maxR, r);这段代码里的0.85起到“让光斑边缘不要突然消失”的作用。数值越小光斑边缘越利落,越接近1越柔和。
4.3 Pass 2:低差异采样与亮度加权合成
形状函数有了,现在要解决采样分布问题。
我常用的是黄金角螺旋加平方根半径分布。这个方案的思路是让采样点均匀覆盖以当前像素为圆心的圆盘,避免出现条纹状伪影。黄金角大约是137.5度,也就是2.39996323弧度,用这个角度的增量去旋转,再配合sqrt(t)控制半径,采样点在视觉上非常均匀。
uniform sampler2D highLightTex; // Pass 1 输出的高光纹理 uniform vec2 texelSize; uniform float radius; // 光斑半径,相对屏幕高度 uniform float sides; // 多边形边数,6 最常见 uniform float rotationAngle; // 光圈旋转角度 uniform float boost; // 高光亮度权重提升 const float GOLDEN_ANGLE = 2.39996323; const int MAX_SAMPLES = 64; void main() { vec2 uv = gl_FragCoord.xy / uResolution.xy; vec3 acc = vec3(0.0); float totalWeight = 0.0; // 中心样本保留,权重1 vec4 center = texture(highLightTex, uv); acc += center.rgb; totalWeight += 1.0; for (int i = 1; i < MAX_SAMPLES; i++) { float t = (float(i) + 0.5) / float(MAX_SAMPLES); float theta = rotationAngle + float(i) * GOLDEN_ANGLE; float r = radius * sqrt(t); vec2 offset = vec2(cos(theta), sin(theta)) * r * texelSize * vec2(uAspect, 1.0); vec4 sampleColor = texture(highLightTex, uv + offset); // 当前采样点的亮度作为权重:高亮贡献大,暗部贡献小 float sampleLuma = dot(sampleColor.rgb, vec3(0.2126, 0.7152, 0.0722)); float weight = pow(sampleLuma, boost); // 用多边形形状权重把超出边界的采样点压掉 float maxR = radius * polygonRadius(theta, sides); float shapeWeight = 1.0 - smoothstep(maxR * 0.85, maxR, r); acc += sampleColor.rgb * weight * shapeWeight; totalWeight += weight * shapeWeight; } outputColor = vec4(acc / max(totalWeight, 0.001), 1.0); }这段代码有几个值得注意的地方:
texelSize乘以vec2(uAspect, 1.0)是必要的,否则采样圆盘在非正方形屏幕上会被拉成椭圆;- 亮度权重
pow(sampleLuma, boost)的boost建议在1.0到3.0之间。大于1会让高亮像素更强势,小于1会让较低亮度的像素也参与扩散; - MAX_SAMPLES建议PC端64,移动端32;如果光斑半径特别大,64个样本依然不够密,这时候优先考虑降分辨率而不是一味加采样。
4.4 Pass 3:混合回原图
最后的合成Pass看起来简单,但决定了最终观感。
uniform sampler2D originalTex; uniform sampler2D bokehTex; uniform float intensity; // 建议 0.3 - 1.0 const vec3 lumaWeights = vec3(0.2126, 0.7152, 0.0722); void main() { vec2 uv = gl_FragCoord.xy / uResolution.xy; vec4 original = texture(originalTex, uv); vec4 bokeh = texture(bokehTex, uv); float luma = dot(original.rgb, lumaWeights); float brightMask = smoothstep(0.65, 0.95, luma); // 只在高亮区域加入光斑,暗部保持原样 vec3 result = mix(original.rgb, max(original.rgb, bokeh.rgb), brightMask * intensity); outputColor = vec4(result, 1.0); }这里的max(original.rgb, bokeh.rgb)是很有用的技巧:散景纹理只增亮不压暗,避免光斑扩散区域出现黑边。如果直接用bokeh.rgb替换,原本有一点亮度的区域可能会因为卷积权重分配不均而变暗。
合成强度intensity我建议从0.5起步。很多第一次做散景的朋友一上来就开到1.5,结果画面全是光斑,细节全被埋掉。散景应该是“配菜”,不是“主菜”。
4.5 可选扩展:色差与甜甜圈光斑
要让效果更“镜头感”,还可以做两个小扩展。
色差很简单:把Pass 2的采样半径对RGB三个通道分别乘0.98、1.0、1.02,黄色和蓝色边缘自然就出来了。注意不要调太大,否则会变成明显的彩虹。
甜甜圈光斑的实现思路是在合成前对散景纹理做径向模糊权重修正:中心区域权重压低,外圈权重保持。视觉上就是光斑中心变暗、边缘高亮。做法是在Pass 2的循环里,把shapeWeight额外乘以一个随r递增的系数。具体效果可以参考折返镜头拍摄的感觉。
5. 参数调优与性能优化
5.1 核心参数参考表
给一套我实测比较稳的初始值,后面具体项目还要按分辨率调整:
| 参数 | 建议值 | 说明 |
|---|---|---|
| threshold | 0.7 - 0.9 | 高光阈值,越低光斑越多 |
| softKnee | 0.05 - 0.15 | 阈值过渡带宽度 |
| radius | 0.01 - 0.05 | 相对屏幕高度,越大光斑越大 |
| sides | 6 | 边形数,5、6、8最常见 |
| rotationAngle | 任意 | 光圈旋转角度 |
| boost | 1.0 - 3.0 | 高亮权重提升 |
| intensity | 0.3 - 1.0 | 最终混合强度 |
| MAX_SAMPLES | 32 - 64 | 采样数量 |
5.2 大半径与降分辨率配合
散景效果最让人头疼的问题就是采样数量。半径越大,需要覆盖的像素范围越大,同样数量采样点之间的间距就越大,画面就越容易出现放射状条纹。
我自己的经验是:当光斑半径超过画面高度的3%时,一定要在降分辨率纹理上做散景。方法很简单:Pass 1先降采样到1/4分辨率,Pass 2在低分辨率上跑散景,Pass 3再上采样回原分辨率并混合。这样80个样本的效果顶得上全分辨率下的200个样本,移动端甚至可以用1/8或1/16分辨率,伤不到太多画质。
5.3 闪烁问题与TAA适配
采样数量不足时,画面会出现“闪斑”:某个像素因为采样点位置变化,一下子亮一下暗。很多项目会引入时间抖动随机偏移采样点位置,这样帧间闪烁会被随机化,再靠TAA抗锯齿来抹平。
但要注意,TAA本身对高频细节有抹除作用。如果你的管线没有TAA,建议关闭时间抖动,改用固定的低差异采样,否则闪斑反而会更明显。简单说:采样品质够,就不要抖;采样品质不够,抖动后必须有TAA兜底。
5.4 移动端优化要点
移动端跑这个效果,有几个硬指标要守住:
- 采样数量尽量不超过32,最好在16到24之间;
- Pass 1和Pass 2用半浮点纹理,避免带宽浪费;
- 不要在循环里做动态分支,把MAX_SAMPLES定死;
- 降分辨率用双线性插值,但要注意UV坐标换算别出错。
如果项目对性能有硬性要求,更推荐在Bloom框架里自定义形状核,把散景和Bloom合并成一个后处理链,性能能再上一个台阶。
6. 踩坑记录:散景效果常见问题与排查思路
6.1 光斑边缘锯齿
现象:多边形光斑边缘能明显看到一根根采样条纹,像梳子一样。
原因基本两类:一个是shapeWeight的smoothstep范围太窄,边缘过渡太硬;一个是采样数量太少,圆盘覆盖不够均匀。
优先加采样数量,尤其把MAX_SAMPLES从32提到64,基本能消掉一半条纹。再加宽smoothstep的过渡起点,比如把0.85改成0.7,边缘会柔和很多。
6.2 高光区域糊成一片死白
现象:原本还能看出纹理的高光区域,叠完散景后变成纯白块。
原因在于加法混合带来的HDR溢出。多个高光光斑叠加后,亮度远远超过1.0,显示时被截断,就成了一片死白。
解决办法有几个方向:
- 降低
intensity; - 在合成结果上做一次色调映射,比如简单的
result / (result + 1.0); - 或者限制叠加次数,让总权重不要无限累加。
我在某项目里遇到过最极端的情况:夜景霓虹灯牌,开了散景后整条街道都亮了。最后把boost降到1.2、intensity压到0.3才恢复正常。
6.3 暗部噪点被放大
现象:画面暗部的噪点颗粒被放大成小光斑,整体看着脏。
这是阈值太低导致的结果。smoothstep的阈值如果低于0.5,暗部噪点的亮度也会被提取出来参与扩散。建议阈值不低于0.65。如果噪点本身很强,先跑一遍降噪再提取高光。
6.4 光斑变成奇怪的椭圆
现象:明明设置了六边形,光斑却被压成了扁椭圆。
这是宽高比校正没做对。UV空间是0到1的,但屏幕不是正方形,直接在UV空间做圆形采样,拉伸后必然是椭圆。我在上面对Pass 2代码加过一个vec2(uAspect, 1.0),记住这个校正只用于偏移方向,不能用于采样坐标本身,否则UV会错位。
6.5 前景高光也被散景化
现象:前景主体上的细节高光也变成了光斑,画面像隔着一层脏玻璃。
这就是我前面说的“屏幕空间方案没有深度信息”的局限。解决方式是在Pass 2之前用深度缓冲生成一个遮罩:把近景区域的值压到0,远景区域保持1。这样前景高光就不会参与扩散,散景只会出现在背景。
某项目里我加了一个最常用的简化版本:直接用场景深度做step,设定一个分界距离,近处完全不做散景,远处按距离递增采样权重。效果比完整方案差一点,但胜在简单。
最后再分享一个小技巧
做了一段时间散景效果后,我个人的体会是:这个效果的成败常常不取决于某个算法的参数调得多精细,而是在于“用得克制”。
阈值保持在0.7以上,半径控制在屏幕高度的5%以内,强度控制在0.5上下,画面已经能有明显的镜头感。如果你想让光斑更耐看,可以在合成之前对散景纹理做一次轻微的径向模糊,让光斑中心稍微“陷”下去,那种带点甜甜圈味的光班会很有高级感。但别拿这个技巧套所有镜头,夜景、逆光、霓虹场景用它都合适,日常打光均匀的室内场景强行加就会显得假。
希望这篇关于Bokeh散景效果Shader的拆解能帮到你。建议先拿一张夜景照片跑跑看,把六边形光斑调出来之后,你才算真正摸到了这个效果的命门。