
做渲染这些年我越来越觉得画面“电影感”的秘诀往往不在模型和贴图而在镜头本身怎么模拟。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 核心参数参考表给一套我实测比较稳的初始值后面具体项目还要按分辨率调整参数建议值说明threshold0.7 - 0.9高光阈值越低光斑越多softKnee0.05 - 0.15阈值过渡带宽度radius0.01 - 0.05相对屏幕高度越大光斑越大sides6边形数5、6、8最常见rotationAngle任意光圈旋转角度boost1.0 - 3.0高亮权重提升intensity0.3 - 1.0最终混合强度MAX_SAMPLES32 - 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的拆解能帮到你。建议先拿一张夜景照片跑跑看把六边形光斑调出来之后你才算真正摸到了这个效果的命门。