在屏幕空间用 OpenGL Shader 做闪电动画是我这几年调试实时特效时觉得性价比最高的一类东西它不像粒子系统那样要管理几千个物体也不像物理模拟那样要解一堆约束只要你把柏林噪声、折线采样、距离场这三件事串起来一个像模像样的闪电就能实时跑起来。前阵子我在一个模拟项目X里又把这套流程完整走了一遍索性把源码和踩坑记录都整理出来。这篇博文会从柏林噪声的 GLSL 实现讲起到闪电路径生成、光晕合成、OpenGL 集成最后是调试时最容易踩的几种坑适合有基础 GLSL 概念、想做全屏特效的读者直接照着抄。1. 先想清楚闪电的本质是一根被噪声反复折断的线段1.1 直接画线段为什么不像闪电如果你第一次做闪电最直接的想法多半是用 Bresenham 或者 OpenGL 的GL_LINES画一条从起点到终点的直线然后给线段加个宽度和蓝色。这么做出来的东西最多算“一根描边线条”不是闪电。真实闪电的视觉特征非常鲜明主干由大量方向突变的短折线段构成转角幅度很大而且同一时刻会有若干条细小的分支从主干上甩出去周围还裹着一层从亮白到淡蓝的辉光。换句话说闪电不是一个连续平滑的曲线而是一个“被反复折断”的折线集合。你在 Shader 里要做的就是尽可能模拟这种折断感而不是画一条优雅的贝塞尔曲线。1.2 柏林噪声在整个方案里的角色柏林噪声在这里的作用是给这条折线的每个节点提供“看似随机、但有连续性”的偏移量。如果直接用纯随机数闪电在相邻两帧之间会跳变得像雪花屏如果用正弦波叠加拐弯又太规则没有自然感。柏林噪声的特性正好卡在两者之间它的数值在空间上是连续变化的所以相邻节点的偏移量不会突然跳开这让折线整体看起来是“一条连续的物体”同时它又不是某个简单函数所以拐弯的角度和幅度都足够杂乱。真正用在闪电路径上的通常是多层柏林噪声叠加出来的分形噪声也就是 FBM低频层决定整条闪电的甩尾方向高频层负责把局部边缘变成锯齿。1.3 两个关键技术选型的理由我做这个效果的时候有两个选型是提前定死的后面才发现这俩决策省了非常多事。第一选全屏片元着色器方案而不是逐顶点构造闪电网格。网上很多教程是 CPU 端生成折线点再用GL_LINE_STRIP或三角带画出来。这种方案优点是直观但缺点是分支一多CPU 和 GPU 之间的顶点数据每帧都要更新而且光晕效果还得靠额外贴图或后处理。全屏着色器方案里每个像素直接根据 UV 坐标去计算自己到闪电路径的距离路径本身完全在 GPU 里生成CPU 只提供一个时间 uniform干净利落。第二路径采样用“离散节点 折线连接”而不是逐点连续曲线。柏林噪声给出来的是连续值如果用它直接驱动一个连续的 x/y 坐标得到的是一条舒缓的波浪线一点闪电的锐利感都没有。所以我在路径参数 t 上均匀取十几个节点节点之间的偏移由噪声决定然后节点和节点之间直接用直线连线。这样天然就是折线那感觉一下就对了。2. 柏林噪声的 GLSL 实现与数学细节2.1 哈希函数给每个格点一个“随机梯度”柏林噪声的第一步是把二维空间划分成整数格子每个格点需要分配一个确定但看起来随机的梯度方向。GLSL 里没有random()所以通常用哈希函数从坐标推导出伪随机值。我用的哈希函数是很多 Shader 里经典的 sin 版vec2 hash2(vec2 p) { p vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); return -1.0 2.0 * fract(sin(p) * 43758.5453123); }这个函数输入任意二维坐标输出一个范围在 [-1, 1] 的二维向量。原理是先和两个不同的素数点积把 p 搅散再用正弦函数放大其小数部分的不规则性。注意它并不是最科学的哈希方案sin 在部分移动 GPU 上开销偏高但写起来简单桌面机器上完全没问题。有了 hash2就能给当前 UV 所在格子的四个角分配梯度向量。所谓的“梯度”并不需要真的去求导在 Perlin 噪声的语境里它就是四个角各自带的一个方向向量用来和“点到该角的向量”做点乘产生影响值。2.2 平滑插值5 次函数而不是 3 次Perlin 噪声最核心的步骤是把四个角的影响值插值到一起。这里有个很容易被忽略的细节插值权重不能用mix(u, v, t)默认的线性函数因为线性插值在格子边界处会有明显的不平滑感视觉上表现为噪声的“棱”。标准做法是使用五次多项式vec2 u f * f * f * (f * (f * 6.0 - 15.0) 10.0);这个公式对应6t^5 - 15t^4 10t^3。它能保证在 t0 和 t1 处一阶导数和二阶导数都为零通俗讲就是过渡比 smoothstep 还要圆润连接处看不出接缝。虽然 smoothstep 也够用但既然我们要让闪电路径的抖动足够自然这里我不会省。2.3 FBM 叠层让噪声从“柔”变“烈”单个 Perlin 噪声输出的是平滑变化的云状图案用它的直接结果驱动闪电只会得到轻微的蛇形摇摆。分形布朗运动FBM的做法是把同一个噪声在不同频率、不同振幅下叠加起来。float fbm(vec2 p) { float v 0.0; float a 0.5; mat2 rot mat2(0.8, -0.6, 0.6, 0.8); for (int i 0; i 5; i) { v a * perlin(p); p rot * p * 2.03 vec2(1.7, 9.2); a * 0.5; } return v; }每一层频率提升约一倍、振幅减半这样低频层的能量最大决定了全局走势高频层负责在局部产生细小波动。我习惯在每次迭代时把坐标乘上一个旋转矩阵rot再略微偏移这样可以避免多次采样同一方向上产生明显的“网格对齐感”。3. 闪电路径从噪声到折线的完整设计3.1 主干路径的采样与锐化闪电路径我定义成一个函数samplePath(t, time)t 是 0 到 1 的路径进度把整个路径分成固定数量的等距节点。节点坐标由三部分决定低频 FBM 层控制整体甩动幅度通常给 0.30 左右的振幅中频 FBM 层往主干上叠一些稍小的弯折振幅 0.08 左右高频层用pow(abs(perlin(...)), 4.0)制造罕见的“脉冲式”大折角。这里有个关键技术点第三层如果把普通 Perlin 值直接加进去只会给路径增加柔和抖动但一旦用pow(abs(x), 4.0)大部分区域的数值会被压到接近 0只有偶尔出现的波峰才能突出来于是路径在某几个节点处突然拐一个大弯这就成了真正的“闪电折断感”。下面是我实际用的主干采样函数vec2 samplePath(float t, float time) { float x 0.0; x fbm(vec2(t * 2.5, time * 0.18)) * 0.30; x fbm(vec2(t * 6.0, time * 0.55)) * 0.08; x pow(abs(perlin(vec2(t * 13.0, time * 0.9))), 4.0) * 0.03; return vec2(0.5 x, mix(0.06, 0.94, t)); }这里的路径主轴是竖直方向y 从 0.06 到 0.94x 在 0.5 附近水平漂移。t 采样个数我是用宏定义的#define SEGMENTS 1414 段折线是比较平衡的效果再多会削弱“锐利感”再少又显得太僵硬。3.2 分支系统的加入闪电的分支是灵魂。全屏着色器里不可能动态创建顶点所以分支必须靠“在每个节点附近追加一小段折线”来实现。我的做法是遍历主干节点时用一个哈希值决定当前节点是否长分支float h hash2(pts[i] * 17.0 vec2(3.1, 7.7)).x; if (h 0.35) continue;这个条件的意思是大约 65% 的节点会生成一条分支。分支的方向由主干在该点的法线方向决定长度由一个随机因子控制中间再加一个扰动点让分支也带一点弯折而不是直愣愣的刺。哈希值用节点坐标而不是时间好处是分支位置在动画过程中基本稳定不会每帧都在全身乱长。3.3 时间参数与频闪节奏闪电动画有两种常见路线一种是持续扭动的“能量流”另一种是模拟真实放电的“瞬亮瞬灭”。全屏 Shader 里两者都能实现。持续扭动就是把time混合进噪声坐标的低频层让路径整体慢慢变化。真实感不够但演示和 Decoration 效果足够。我更喜欢的是把两者结合路径用time * 0.18之类的慢速系数扭动同时给最终颜色叠加一个高频闪烁因子float flicker 0.7 0.3 * sin(time * 80.0) * sin(time * 37.0 1.0); color * 0.75 0.25 * flicker;两个不同周期的正弦相乘会产生类似“忽强忽弱又不规则”的闪烁节奏。这一步是纯经验技巧只用一个 sin 的闪烁太机械用两个倍数不同的 sin 相乘立刻显得自然。4. 距离场渲染把折线变成带电光柱4.1 点到线段距离的公式推导路径生成之后下一步是让每个像素知道距离闪电路径有多远。如果路径只是一条直线可以直接用点到直线距离。但闪电是折线所以每个像素要依次求它到每一折线段的距离取最小值。点到线段距离的 GLSL 实现float segDist(vec2 uv, vec2 a, vec2 b) { vec2 ab b - a; vec2 ap uv - a; float h clamp(dot(ap, ab) / dot(ab, ab), 0.0, 1.0); return length(ap - h * ab); }这里的 h 可以理解为“投影点在线段上的位置比例”。如果投影落在线段外面就 clamp 到最近的端点如果落在里面就用点到投影点的距离。这个函数就是整个闪电光柱的核心所有光效都围绕它展开。4.2 内核、辉光、弥散三层合成得到最小距离 d 之后渲染就很直接了。我把光柱分成三层内核最亮的部分宽度很窄用 smoothstep 产生硬边float core smoothstep(0.006, 0.0, d);辉光比内核宽得多用指数衰减模拟光线在空气中散射float glow exp(-d * 260.0) * 0.8;弥散层更宽的淡蓝色氛围光float halo exp(-d * 90.0) * 0.25;为什么要分三层因为真实闪电的亮度从中心到边缘是快速衰减但又不是截断的单靠一个 smoothstep 会得到一个边界生硬的圆管而“窄核 指数辉光 宽弥散”的组合能形成中心刺眼、四周逐渐隐没的体积感。4.3 色调与空间坐标处理颜色我用了三个基色夜空底色、闪电蓝色、核心白色。最终合成vec3 base vec3(0.02, 0.08, 0.18); vec3 bolt vec3(0.35, 0.55, 1.0); vec3 hot vec3(0.95, 0.98, 1.0); vec3 color base; color bolt * (core * 0.6 glow); color hot * pow(core, 3.0) * 1.5; color bolt * halo;要注意pow(core, 3.0)这个操作它会把只有中心极小区域的 core 保留下来其余全部压低这样最中央会亮成接近白色而不是整条光柱都泛白。还有一个坐标处理容易踩坑如果窗口宽高比变化闪电的粗细会被拉伸。我习惯在片元着色器入口做一次坐标修正float aspect u_resolution.x / u_resolution.y; uv.x (uv.x - 0.5) * aspect 0.5;这样在宽屏上闪电路径的横向比例与纵向保持一致粗细看起来比较均匀。5. 完整源码与 OpenGL 集成5.1 片元着色器完整代码下面是我验证过的一份可运行片元着色器整合了上面所有逻辑。直接复制到一个空文件里命名成lightning.frag就能用#version 330 core out vec4 fragColor; in vec2 vUv; uniform float u_time; uniform vec2 u_resolution; #define SEGMENTS 14 #define BRANCH_SEGMENTS 2 vec2 hash2(vec2 p) { p vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); return -1.0 2.0 * fract(sin(p) * 43758.5453123); } float perlin(vec2 p) { vec2 i floor(p); vec2 f fract(p); vec2 u f * f * f * (f * (f * 6.0 - 15.0) 10.0); vec2 ga hash2(i vec2(0.0, 0.0)); vec2 gb hash2(i vec2(1.0, 0.0)); vec2 gc hash2(i vec2(0.0, 1.0)); vec2 gd hash2(i vec2(1.0, 1.0)); float va dot(ga, f - vec2(0.0, 0.0)); float vb dot(gb, f - vec2(1.0, 0.0)); float vc dot(gc, f - vec2(0.0, 1.0)); float vd dot(gd, f - vec2(1.0, 1.0)); return mix(mix(va, vb, u.x), mix(vc, vd, u.x), u.y); } float fbm(vec2 p) { float v 0.0; float a 0.5; mat2 rot mat2(0.8, -0.6, 0.6, 0.8); for (int i 0; i 5; i) { v a * perlin(p); p rot * p * 2.03 vec2(1.7, 9.2); a * 0.5; } return v; } vec2 samplePath(float t, float time) { float x 0.0; x fbm(vec2(t * 2.5, time * 0.18)) * 0.30; x fbm(vec2(t * 6.0, time * 0.55)) * 0.08; x pow(abs(perlin(vec2(t * 13.0, time * 0.9))), 4.0) * 0.03; return vec2(0.5 x, mix(0.06, 0.94, t)); } float segDist(vec2 uv, vec2 a, vec2 b) { vec2 ab b - a; vec2 ap uv - a; float h clamp(dot(ap, ab) / dot(ab, ab), 0.0, 1.0); return length(ap - h * ab); } float distToLightning(vec2 uv, float time) { vec2 pts[SEGMENTS 1]; for (int i 0; i SEGMENTS; i) { float t float(i) / float(SEGMENTS); pts[i] samplePath(t, time); } float d 1e5; for (int i 0; i SEGMENTS; i) { d min(d, segDist(uv, pts[i], pts[i 1])); } for (int i 2; i SEGMENTS - 2; i) { float h hash2(pts[i] * 17.0 vec2(3.1, 7.7)).x; if (h 0.35) continue; vec2 dir normalize(pts[i 1] - pts[i - 1]); vec2 nrm vec2(-dir.y, dir.x); vec2 start pts[i] nrm * 0.002; float len 0.04 0.16 * hash2(pts[i] vec2(91.7, 13.37)).y; vec2 end pts[i] nrm * len; end dir * (perlin(pts[i] * 3.0 vec2(time * 0.6, 0.0)) - 0.5) * 0.06; vec2 mid mix(start, end, 0.5) dir * (hash2(pts[i] vec2(12.9, 4.7)).x - 0.5) * 0.05; d min(d, segDist(uv, start, mid)); d min(d, segDist(uv, mid, end)); } return d; } void main() { vec2 uv vUv; float aspect u_resolution.x / u_resolution.y; uv.x (uv.x - 0.5) * aspect 0.5; float d distToLightning(uv, u_time); float core smoothstep(0.006, 0.0, d); float glow exp(-d * 260.0) * 0.8; float halo exp(-d * 90.0) * 0.25; vec3 base vec3(0.02, 0.08, 0.18); vec3 bolt vec3(0.35, 0.55, 1.0); vec3 hot vec3(0.95, 0.98, 1.0); vec3 color base; color bolt * (core * 0.6 glow); color hot * pow(core, 3.0) * 1.5; color bolt * halo; float flicker 0.7 0.3 * sin(u_time * 80.0) * sin(u_time * 37.0 1.0); flicker clamp(flicker, 0.0, 1.0); color * 0.75 0.25 * flicker; fragColor vec4(color, 1.0); }顶点着色器就更简单了只负责把一个覆盖视口的全屏三角形的位置传给光栅器顺便输出 UV#version 330 core layout(location 0) in vec2 aPos; out vec2 vUv; void main() { vUv aPos * 0.5 0.5; gl_Position vec4(aPos, 0.0, 1.0); }5.2 OpenGL 端初始化与绘制循环OpenGL 端只需要创建一个覆盖屏幕的三角形 VBO绑定后每帧调用glDrawArrays(GL_TRIANGLES, 0, 3)。这里有一个很常用的技巧顶点坐标写(-1, -1)、(3, -1)、(-1, 3)而不是传统的两个三角形 6 个顶点。这样一个三角形就能覆盖整个裁剪空间避免对角线方向的接缝问题。float verts[] { -1.0f, -1.0f, 3.0f, -1.0f, -1.0f, 3.0f }; GLuint vao, vbo; glGenVertexArrays(1, vao); glBindVertexArray(vao); glGenBuffers(1, vbo); glBindBuffer(GL_ARRAY_BUFFER, vbo); glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW); glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, (void*)0); glEnableVertexAttribArray(0);主循环里就三件事设置 uniform、清屏、画三角形。GLint uTime glGetUniformLocation(program, u_time); GLint uRes glGetUniformLocation(program, u_resolution); while (!glfwWindowShouldClose(window)) { glViewport(0, 0, width, height); glClearColor(0.02f, 0.08f, 0.18f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); glUseProgram(program); glUniform1f(uTime, (float)glfwGetTime()); glUniform2f(uRes, (float)width, (float)height); glBindVertexArray(vao); glDrawArrays(GL_TRIANGLES, 0, 3); glfwSwapBuffers(window); }5.3 Uniform 参数速查表调效果时我经常改的参数都列在这里。核心原则是改振幅比改频率更直观改衰减系数比改颜色更有效。参数 / 宏作用建议范围SEGMENTS主干折线段数10 - 18samplePath中第一层振幅 0.30低频大甩尾强度0.18 - 0.40samplePath中第二层振幅 0.08中频细节抖动0.04 - 0.12pow(perlin, 4.0)前的系数 0.03高频锐化脉冲强度0.015 - 0.06smoothstep(0.006, 0.0, d)中的 0.006内核半宽0.003 - 0.010exp(-d * 260.0)辉光衰减速度150 - 400exp(-d * 90.0)弥散层衰减速度50 - 150分支触发阈值 0.35分支密度越小越密0.2 - 0.5闪烁频率 80 / 37闪烁节奏40 - 1206. 常见问题与调试实录6.1 出现黑线缝隙怎么办这是最典型的问题闪电光柱上会出现一条条的黑色细线仔细看像是折线转折处断开了。原因通常是smoothstep的宽度太窄加上路径的某些转折角很尖锐导致相邻两段光柱之间的覆盖区域出现裂缝。解决办法有两个方向第一把内核半宽从 0.006 调到 0.008 以上。第二对 d 做一次轻微的 smooth 处理让整个距离场的过渡更平滑d sqrt(d * d 0.0004);这本质上是个近似平滑距离场的 trick能让转折处的低谷被填平。实测下来只要把半宽调大一点黑线基本都会消失。6.2 闪烁太密集或太安静如果你觉得闪电像接触不良一样疯狂闪问题出在flicker那两个频率太高了。80 和 37 这两个数在 60fps 下每帧都会变化多次视觉上就是不停拌动。反过来如果想要“每隔几秒猛地亮一下”就把时间切分成周期在一个周期内让亮度从 0 到 1 再到 0float cycle fract(u_time / 3.0); float flash exp(-cycle * 8.0); color * flash;这里cycle在 0 到 1 之间循环exp(-cycle * 8.0)会让亮度在 cycle 接近 0 时突亮、之后迅速衰减模拟一次放电。6.3 分支不明显分支不明显的常见原因是分支长度太短或者触发条件太苛刻。我的默认参数里分支长度是0.04 0.16 * hash(...)如果窗口分辨率很高这个长度在屏幕上可能只有几个像素看起来就像没长。建议把长度下限提高到 0.08上限提高到 0.25。同时注意分支端点的扰动不要太大否则分支会拧成一团看不出“甩出去”的方向感。6.4 不同显卡表现差异分支和主干的距离计算都是在片元着色器里用 for 循环完成的所以 Shader 的负载是固定的跟场景复杂度无关。但不同的显卡在 sin 哈希上的差距很明显。桌面独显跑起来很轻松核显上可能会有明显的帧率下降。如果要在核显上跑优先把 FBM 的循环从 5 层降到 4 层再把SEGMENTS从 14 降到 10。视觉损失不大帧率能回来不少。还有一个和驱动相关的坑有些老驱动在循环里读取vec2 pts[SEGMENTS 1]这种本地数组时会遇到索引必须是常量表达式的限制。我这份代码里所有数组索引都是循环变量在 GLSL 330 下没问题但如果你把SEGMENTS改成从 uniform 传入就会编译报错。记住这种固定长度的循环和数组就是要硬编码成常量。7. 往后的扩展思路这套“噪声路径 距离场 辉光合成”的框架改一改就能做很多别的东西。把主干路径从单条改成多条每条的噪声相位不同就能得到多道闪电同时劈下的效果。做法是把采样函数加一个seed参数在samplePath的噪声坐标上混入不同的偏移。我试过同时跑三条闪电把每条的振幅和半宽都稍微调开层次立刻出来了。把距离场的衰减函数从指数改成高斯函数exp(-d * d * k)闪电就变成了悬浮在空中的光带可以做极光或者能量流。再往颜色里加一点时间驱动的色相偏移就是法术特效里常见的“魔力涌动”效果。把这条路径采样函数和粒子的发射位置对接还能做出闪电击中地面后反弹粒子的复合效果。因为 CPU 端不需要知道 GPU 里路径的具体坐标只需要在 CPU 里复算一遍同一套采样公式拿到某个t值对应的坐标来发射粒子就行两边用完全相同的噪声参数就能对上。最后再分享一个我个人调试时的小习惯写这种全屏特效最容易看不清的问题就是距离场本身。我会在调效果的第一时间把fragColor临时输出成vec4(vec3(d * 20.0), 1.0)这样整个屏幕上会直接显示出闪电路径的灰度距离图哪段的距离异常、哪里出现缝隙一眼就能看出来。等距离场正常了再把颜色合成加回来。这套流程我每次做全屏特效都会用省下的时间远比写那两行临时代码要多。