WebGL实战:打造发光雪花粒子系统与辉光后处理

发布时间:2026/10/17 18:04:20
WebGL实战:打造发光雪花粒子系统与辉光后处理
早几年我站在路灯下看雪心里琢磨过一个很奇怪的画面雪花穿过光柱的瞬间会突然变成一小颗一小颗发亮的星子近处的雪是凉的远处却泛着柔和的金色。我一直想把这种“发光的雪”搬进浏览器让网页动效不只是贴图在飘于是就有了 Luminous Snow 这个练手项目。Luminous Snow 不是简单地把雪花图片铺满屏幕那只能叫下雪特效。这个项目的目标是在 WebGL 里实时生成上万片形态各异的六角雪花模拟它们下落、旋转、被气流吹散再用一套后处理渲染管线把“路灯下雪片发光”的氛围真正做出来。它适合想入门图形学的前端开发者、做数字艺术或品牌页动效的创意工作者也适合想了解粒子系统、辉光后处理和性能优化的人阅读参考。1. 先弄清楚Luminous Snow 要解决的到底是什么问题1.1 从“贴图下雪”到“氛围下雪”最常见的网页下雪实现是 PNG 雪花图片加上随机位移动画或者用 Canvas 2D 每帧画一堆白色圆点。这种方式在普通展示页里够用但离“发光的雪”差得很远一是雪花永远只有一个或几个固定轮廓重复感很强二是没有光雪片只是白色形状不会被灯光点亮、不会在背景上留下柔和光晕三是缺少空间层次所有雪都飘在同一个平面。Luminous Snow 在动手前先把需求拆成了四类需求分类具体指标说明视觉表现至少 12 种雪花轮廓、冷暖光色渐变、辉光叠加雪形要能看出六角分支不能只是一坨圆点运动表现下落、水平风场、自旋摆动、鼠标扰动雪要有“被环境影响”的感觉而不是匀速直线运动性能表现桌面端 60 FPS、粒子数 8000 以上交互时不能掉帧移动端可降级到 3000 粒子工程表现参数化配置、渲染与逻辑分层方便换色、换风场、换背景面向复用拆完需求才发现真正的难点不在“让雪飘”而在“让雪发光”和“让雪长得像真正的雪花”。这两个问题占了整个项目大约七成的开发时间。1.2 为什么“发光”是渲染里最难的部分肉眼看到路灯下的雪发亮本质不是雪自身发光而是散射。光打到冰晶表面和内部一部分进入眼睛短波长蓝光更容易被散射所以雪在冷色灯光下偏蓝在暖色路灯下又带金色边缘。也就是说“发光”是一种综合的光学效果真正要模拟它需要在渲染层面做两件事第一让雪花有一圈从亮到暗的柔和边缘模拟散射的衰减第二让整片亮部向外晕开模拟灯光照亮空气里浮尘和雪晶的氛围感。屏幕上的辉光通常不靠物理模拟而是靠后处理“骗”出来先把画面里足够亮的像素提取出来在低分辨率下反复模糊最后叠加回原图。这一套流程听起来简单实际写的时候全是细节后面我在第 3 节和第 6 节专门展开。2. 雪花不是贴图用递归分支生成六角轮廓2.1 为什么自然雪花大都是六角形水分子里氢氧根的夹角接近 104.5°冰晶生长时氢键网络会倾向于形成六重对称的晶格结构所以在绝大多数温度和湿度条件下落到地面附近的雪花都是六角骨架。这给了我们一个非常友好的建模起点只要生成一个六角对称的轮廓就能让观众一眼认出“这是雪花”而不是随便一块噪点。真实的雪花形成过程很复杂受温度、湿度、过冷水滴含量影响柱状、片状、树枝状都有。但 Luminous Snow 需要的不是气象级精度而是轮廓上的“真实感”。所以我在实现时做了一个简化模型用递归分支模拟冰晶从中心向外生长主枝干每隔一段距离分叉分叉方向左右各偏约 60 度层级越深分支越密长度逐渐缩短。2.2 分支生长的代码简化生成单条雪花分支的思路类似分形树从中心点出发朝向正上方画一条线段到末端后旋转出左右两个子分支子分支再继续递归直到深度达到上限。为了让每片雪花不一样长度衰减系数、分支角度和是否继续分裂都要加随机扰动。// 递归生成单支雪花从中心向上生长 function growBranch(len, angle, depth, points, rand) { const lastX points[points.length - 2]; const lastY points[points.length - 1]; const endX lastX len * Math.cos(angle); const endY lastY len * Math.sin(angle); points.push(endX, endY); if (depth 0) { const childLen len * (0.6 rand() * 0.12); const spread (0.5 rand() * 0.35) * (Math.PI / 3); growBranch(childLen, angle spread, depth - 1, points, rand); growBranch(childLen, angle - spread, depth - 1, points, rand); } }这段代码里depth初始值我建议取 4 到 5。取太小分支稀疏看起来像光秃秃的树枝取太大会瞬间产生海量顶点3 个分支点经过 5 层递归数量会膨胀到几百条线段单片雪花还好上万片实例化时顶点数会失控。生成单支后需要用旋转拼出完整雪花。因为雪花是六重对称把单支绕中心点旋转 60 度六次即可function buildSnowflakePoints(seed) { const rand mulberry32(seed); const base [0, 0]; growBranch(1.0, 0.0, 4, base, rand); const all []; for (let k 0; k 6; k) { const rot k * Math.PI / 3; for (let i 0; i base.length; i 2) { const x base[i]; const y base[i 1]; all.push( x * Math.cos(rot) - y * Math.sin(rot), x * Math.sin(rot) y * Math.cos(rot) ); } } return all; }这里用固定种子伪随机函数而不是直接Math.random()是为了让雪花形状可复现。调试的时候特别有用同一个种子跑出来的形状完全一致能稳定复现渲染问题不用每次刷新都碰运气。2.3 从顶点到可渲染图形线条、面片还是着色器距离场雪花分支生成出来是一堆线段顶点但 WebGL 里画细线很不靠谱因为gl.LINE_WIDTH在很多环境里被限制为最小值线条宽度没法控制。我试过三种画法第一种是把雪花当纯线条用LINES绘制简单但太细发光之后的边缘也糊成一片没有分支的锐利感。第二种是对三角形做三角剖分把分支路径扩张成有宽度的多边形。剖分效果扎实但实现量偏大每片雪花都要在 CPU 侧生成几百个三角形上传数据压力大。第三种是现在推荐的思路先用线段点把轮廓画到一张小纹理上或者直接用着色器里的距离判断来生成形状。实际工程里我会混合使用小尺寸雪花用点渲染需要大尺寸特写时再对单独一片雪花做面片渲染。这样兼顾了性能和视觉细节。我给你的结论是这一环节最忌一步到位追求完美先用LINES跑通全流程再根据需求补面片或距离场。3. 发光的渲染管线加法混合、阈值提取与低分辨率辉光3.1 粒子不是圆点用实例化绘制承载雪花形状Luminous Snow 的每个雪花实例都需要在 GPU 上完成位置、旋转、缩放、形状组合的计算。经典做法是把所有雪花的形状态点放进一个 VBO再用实例化绘制drawArraysInstanced一次性画出来。每个实例的附加属性包括形状索引、初始位置偏移、缩放系数、转速、相位移、颜色偏移。顶点着色器里的大致逻辑是attribute vec2 aShapeRect; // 雪花矩形区域顶点 attribute float aShapeIndex; // 形状索引 attribute float aSeed; // 随机种子 attribute float aScale; // 缩放 uniform float uTime; uniform vec2 uCenterOffset; // 所有粒子的整体偏移 void main() { vec2 snowPos computeSnowBasePosition(aSeed, uTime); snowPos windOffset(aSeed, uTime); vec2 vertex snowPos rotateRect(aShapeRect, aSeed, uTime) * aScale; gl_Position projectionMatrix * vec4(vertex, 0.0, 1.0); }这样做的好处是不在 CPU 上更新每个粒子的坐标所有运动全部在着色器里用uTime推算。每一帧都不需要把粒子位置回读到 CPU也就不会产生 GPU-CPU 同步等待这是保持 60 帧的前提。3.2 基础发光柔和的圆形光晕单个雪花的发光最直接的方法是让片元不是纯白硬边而是带距离衰减的圆。在片元着色器里拿到gl_PointCoord或片元在矩形面片里的相对坐标计算到中心的距离再用指数或二次函数得到衰减float dist length(uv - 0.5); float glow pow(1.0 - clamp(dist * 2.0, 0.0, 1.0), 2.0); vec3 snowColor mix(coolColor, warmColor, temperatureNoise); vec4 outputColor vec4(snowColor * glow * alphaMultiplier, glow * alphaMultiplier);这段代码里最关键的是最后一项alphaMultiplier。因为后面要做加法混合如果这里的 alpha 或亮度过高几百片雪花叠加后全屏会变成白茫茫一片。我实测下来普通画布上雪花片元亮度控制在 0.15 到 0.35 是一个比较舒服的范围。3.3 真正的辉光两次渲染加低分辨率模糊单片光晕只能让雪花本身变亮不够让画面产生“灯照亮周围空气”的感觉。要得到真正的辉光需要走完整后处理把场景渲染到离屏纹理sceneFBO。做一个亮度阈值着色器只保留画面里亮度超过uThreshold的像素输出到新的纹理。将高光纹理反复降采样降到原始尺寸的 1/4 或 1/8。在低分辨率上做多级高斯模糊模糊半径从小到大循环迭代。把模糊结果和原始场景用加法合并得到最终画面。参数推荐取值说明亮度阈值0.72 到 0.85阈值太低画面整体发雾太高则小雪花不发光降采样倍数原始尺寸的 1/41/8 更快但光晕颗粒感明显高斯模糊迭代次数4 次少于 3 次光晕有锯齿多于 6 次性能下降明显辉光强度0.3 到 0.5和亮度阈值联动调整合并方式加法混合叠加时直接相加 RGB我在第 6 节要讲的黑边、过曝问题几乎都出在这张参数表的两三个点上调试时优先改阈值和强度不要乱动模糊核。4. 从静止到飘动时间驱动、风场与鼠标气流扰动4.1 每一帧都不用“更新位置”时间才是唯一变量把粒子运动放进着色器后雪花坐标全部由uTime、粒子自身的种子和初始偏移决定。飘落最基础的部分是匀速下落加上水平风场。竖直方向比较好写直接y - speed * uTime水平方向如果只是线性位移看起来像在传送带上下雪很不自然。我最后用了一个叠加了正弦函数的风场模型基础偏移加一段周期性摆动再叠一个全局低频扰动噪声让雪流时而飘向左、时而飘向右像真的有风穿过屏幕。float windX windBase * uTime windAmplitude * sin(uTime * windFreq phase seed * 6.28); float swayY swayAmplitude * sin(uTime * swayFreq seed * 12.56);seed在这里非常重要它让每片雪花有不同的相位没有它所有雪花会像一个整体一样整齐左右晃画面会非常机械。4.2 雪花要有“转身”旋转与透视缩放真实雪花下落时会翻滚但从正面看它始终是一个六角平面在旋转。实现上不需要真的计算三维旋转矩阵直接把雪花矩形顶点绕着中心做二维旋转就行。为了让旋转不是匀速我会在角度里混入一个带噪声的速度float spinSpeed 0.4 1.2 * noise(seed); float angle spinSpeed * uTime aSeed * 6.2831;另外近大远小的透视也需要模拟。最简单的做法是给每个粒子一个深度值z根据z修正缩放和透明度远处略小、更暗更柔和近处大而清晰。伪透视会让画面立刻丰富起来效果好于强行做真实 3D 投影。4.3 鼠标扰动让观众真的能“拨开”雪鼠标和触摸交互是 Luminous Snow 最吸引人的部分之一。实现思路是把鼠标的归一化坐标和按下状态传到 uniform比如uMouse和uMouseStrength在顶点着色器里计算每个雪花到鼠标的距离用反比形式施加排斥力float distSq dot(particlePos - uMouse, particlePos - uMouse); float force uMouseStrength / (distSq 0.5); particlePos normalize(particlePos - uMouse) * force;这里要注意数值稳定性distSq接近 0 时力的大小会趋于无穷大所以分母要加一个常数做下限保护。实际调试时把uMouseStrength做成可在调试面板里拖动调节的 uniform会省掉很多改代码重编译的时间。鼠标事件的平滑也很关键。直接在mousemove里更新 uniform数值跳变会很大视觉上像雪被一瞬间吹飞。正确的做法是做一个脚本侧的 lerp 缓动每帧把当前鼠标值向目标值靠近大概 10% 到 20%让力的变化有惯性。5. 性能调优把一万片雪花压进 60 帧5.1 数据上传策略把 1 万片雪花压缩成 64 种如果给每一片雪花单独上传一组几十个顶点的形状数据一万片就是几十万顶点内存带宽立即告急。更好的方案是预生成 64 种不同的雪花轮廓每种轮廓只在初始化时上传一次到 VBO所有粒子通过aShapeIndex引用其中的一种。一个粒子只需要 4 个 float 的属性和 1 个 shapeIndex相比拷贝大量顶点数据量下降了两个数量级。这是我强烈建议的第一层优化因为它不只是快了一点是从根本上改变了数据规模。5.2 绘制调用控制在 5 次以内很多刚接触 WebGL 的朋友容易把雪景做成“每片雪花一个 draw call”一万片就是一万次绘制调用浏览器根本顶不住。Luminous Snow 的全部渲染结构控制在 4 到 5 次 draw call主场景粒子一次drawArraysInstanced。高光提取一次。模糊两到三次。最终合并一次。加法混合让雪花不需要像普通透明物体那样做深度排序这是能用一次实例化绘制的关键。如果换作普通 alpha 混合问题会大得多。5.3 移动端降级参数矩阵而不是砍功能移动端 GPU 性能和桌面端差得远最避免踩坑的做法不是到了移动端再去删代码而是提前设计好一套“质量档位”根据设备参数自动选择。质量档位粒子数辉光降采样是否启用鼠标扰动适用设备高8000 到 120001/4是桌面独立显卡中4000 到 60001/4是降低力度近两年旗舰手机低2000 到 30001/8否老款手机、低端电脑移动端还要注意devicePixelRatio。很多手机上 dpr 是 3渲染分辨率等于 3 倍屏宽高粒子数和后处理成本会按面积放大 9 倍。我买过一个教训在 dpr 为 3 的设备上直接跑高配档GPU 直接拉满帧率掉到 20。后来把渲染缓冲的 dpr 上限锁在 2视觉差别很小帧率却稳定回了 60。5.4 用帧率工具验证而不是靠感觉优化有没有效果不能靠眼睛猜要用帧率统计数据说话。翻页、滚动、鼠标移动这些事件都要纳入测试因为很多掉帧只在交互瞬间出现。我自己会同时观察两个指标平均帧率和帧率抖动。前者代表整体流畅度后者代表交互时会不会卡顿。如果平均帧率是 60但鼠标移动时突然掉到 30 又弹回来大概率是开关交互时发生了多余的重绘需要查看是否在鼠标事件里触发了不必要的 uniform 更新或缓冲重传。6. 实测里踩过的坑黑边、纸片感和全屏过曝6.1 辉光黑边阈值提取得太“生硬”第一次做完整辉光时雪花旁边出现了一条明显的深色窄边亮部像被什么东西割了一刀。定位后发现是亮度阈值那一步出了问题我对 RGB 直接做if (color.r 0.8 || color.g 0.8 || color.b 0.8)这种线性判断导致提取出来的高光区域边缘存在大量半亮不亮的像素后续模糊合并时边缘处的半透明像素和原始场景产生了较暗的混合效果。解决办法是不要用简单的分量阈值而是用亮度加权公式计算灰度再对灰度做平滑过渡float luma dot(color.rgb, vec3(0.299, 0.587, 0.114)); float highlight smoothstep(uThreshold, uThreshold 0.1, luma); vec3 bright color.rgb * highlight;smoothstep的两端宽度要足够宽0.08 到 0.12 这个范围比较稳太窄还是会出现硬边。黑边问题本质上是“提取边缘突然截断”造成的任何能让过渡变柔和的做法都有帮助。6.2 雪花像一张纸片透视错误和边缘透明度前期版本最被人吐槽的是雪花“太像贴纸了”。原因有两个。第一个是投影矩阵没有考虑画布纵横比雪花在宽屏被横向压扁原本的六角轮廓变得歪歪扭扭一眼假。解决办法是在投影矩阵里乘一个aspect修正或者把顶点在 shader 里手动除以画布纵横比让菱形恢复成标准六角。第二个原因是纯白线条没有边缘衰减。真实雪花在光照下边缘和内部透光率不一样边缘更透、更亮中心稍暗。我在片元着色器里用一个简单的遮罩函数让边缘 alpha 落在 0.4 左右中心约 0.8 到 0.9配合前面说过的光晕衰减纸片感立刻消失。6.3 全屏过曝加法混合的“白色洪流”发光效果做完后刚开始全屏白得像得了白内障。这几乎一定会发生因为加法混合会把所有亮光直接相加。我一度以为自己强度参数写错了反复检查后发现是alphaMultiplier太高再加上辉光阈值太低两片高亮区域叠加后轻而易举到达白色。最终稳定的参数是雪花片元亮度 0.25 到 0.3辉光强度 0.4阈值 0.8。这三个参数必须联动单独调任何一个都会回归过曝或“发不出光”。我把它们做进一个参数面板后专门花了半天时间在暗色、浅色、渐变背景下分别校验因为同一组参数在不同背景下的视觉差异非常大。6.4 移动端出现莫名的色带和绿色噪点有些手机 GPU 对半浮点精度支持不完整颜色值在片元着色器里传了几轮之后会出现肉眼可见的色阶断层和绿色噪点。一开始以为是我把颜色字节打包成 float 造成的精度问题后来发现是片元着色器里没有显式声明highp float。WebGL 在某些移动端驱动里默认中精度运算一轮就把精度损耗放大了。逐字面加上precision highp float;后问题消失。这是我的建议凡是写了复杂后处理的着色器一律从一开始就声明 highp 精度不要指望驱动会做正确选择。7. 项目还能怎么长场景移植与交互扩展7.1 从网页动效走向可落地的应用场景Luminous Snow 完成基础版后可以直接用在很多地方品牌数字产品页的冬日氛围背景音乐会或直播间的虚拟背景心理疗愈类应用的低对比度专注模式。核心渲染模块和三个质量档位基本不需要改只需要把事件监听从鼠标换成 HTTP 数据、传感器数据或音频数据。直播间场景还可以更进一步把雪花渲染到透明 Canvas再通过浏览器自带的媒体流接口合成到摄像头画面里。这个方案不需要额外装采集卡纯前端就能做出接近电视包装的效果。7.2 下一步让雪“停留”在地面上当前版本粒子是穿过整个画面后消失的雪花不会在地面堆积。要达到“地上慢慢积起一层雪”的效果就不能再用纯粒子系统了需要维护一张高度场或者粒子网格。大致思路是当雪花降到最低边界时将它登记到附近网格单元格里更新地面高度随后地面部分用另一个网格 Mesh 渲染。这会带来额外的存储和绘制成本通常只在中高配档打开。7.3 音乐驱动与多尺度粒子系统把音频 FFT 能量映射成风强度、粒子数量和辉光强度是一个低成本但效果非常显著的升级。音乐进入低频鼓点时风雪加强、辉光变亮进入安静段落时雪化作风中细屑亮度和速度同时回落。另一个值得尝试的方向是多尺度粒子系统大雪花负责视觉主体小雪花负责氛围密度超小的冰晶颗粒负责光照散射感三种粒子叠加起来才有真实雪夜那种糅合不清的层次感。这些扩展方向我都只做到了原型验证真正稳定的是从零到一的核心链路递归生成形状、实例化粒子、加法混合、低分辨率辉光、性能适配。做这个项目最大的收获不是最终画面有多惊艳而是我彻底理解了“氛围感”可以被拆解成一个个可调参数每一项参数都有它对应的视觉贡献和性能成本。后面你如果自己做类似项目记住一句话先把发光流程跑通再去纠结雪花形状顺序错了会反复推倒重来。