Three.js案例
使用 GLSL 实现爆炸粒子特效

使用 GLSL 实现爆炸粒子特效

一、功能概述

这段着色器代码旨在实现类似烟花或粒子“爆炸”效果的动态画面。它通过一系列随机噪声函数来确定粒子的位置和颜色,再结合时间参数 iTime 不断更新,从而让画面中出现带有多重颜色变化的粒子爆炸。在 mainImage 函数里,代码会把屏幕上的每个像素(fragCoord)转换到一个 0~1 的 UV 坐标系,并根据随机种子与时间,渲染出多组爆炸。最后还会叠加一个 Rainbow 函数来对整体色彩做进一步的动态处理,使画面更具炫酷的视觉效果。

二、宏定义与基本常量

在这一段代码中,我们首先看到了一些宏定义,包括 PITWOPI 等数学常量,以及 SB 这样的简写函数。

  1. 这些宏能使代码更紧凑,像 PITWOPI 可以用来处理角度、正弦和余弦周期等相关计算。
  2. S(x, y, z) = smoothstep(x, y, z) 是对 GLSL 内置 smoothstep 函数的重命名,方便在后续做平滑过渡。
  3. B(x, y, z, w) 则是把多次 smoothstep 嵌套组合,用来实现更复杂的平滑插值判断,常常用于判定某个数值是否处于某个范围内,并做平滑衰减。
  4. NUM_EXPLOSIONSNUM_PARTICLES 分别表示要渲染的爆炸组数以及每次爆炸中的粒子数量,这种显式常量让我们能灵活地控制整体规模和开销。
  5. 值得注意的是,GLSL 的预处理器宏只是在编译时做文本替换,不像函数那样有调用开销,所以对性能相对有利,也让代码可读性更好。
#define PI 3.141592653589793238
#define TWOPI 6.283185307179586
#define S(x,y,z) smoothstep(x,y,z)
#define B(x,y,z,w) S(x-z, x+z, w)*S(y+z, y-z, w)
 
#define NUM_EXPLOSIONS 8.
#define NUM_PARTICLES 70.

这些宏会在后面的诸多地方被用到,例如 S(0., .1, pt) 就是基于 smoothstep 的一种平滑插值,用来让粒子大小或亮度等随时间平滑过渡。由于粒子爆炸很强调渐变感,不希望突然出现或消失,所以使用平滑插值函数是一个非常合理的选择。

三、随机噪声函数

在爆炸或粒子特效中,随机数的获取非常关键。下面这一段中定义了 MOD3hash31hash12,它们的作用是给定一个输入,再返回看似随机的浮点数或向量,这能让我们在 GPU 端不依赖外部随机源而直接生成随机分布。

在展示代码之前,需要先了解以下几点:

  1. MOD3 是一个预先选好的质数向量 (0.1031, 0.11369, 0.13787)。这些值用于让哈希计算尽可能分散,不会出现明显的周期或重复。
  2. hash31(float p) 会把一个浮点数 p 转换成 3D 随机向量,其核心思想是不断对 p 进行“分形化” (fract) 操作,并与 p3.yzx + 19.19 做点积,使结果在 0~1 范围内有良好的离散性。
  3. hash12(vec2 p) 则接收一个二维向量 p,最后返回一个浮点随机数。它的过程与 hash31 相似,都是通过乘以 MOD3 并结合 fract 和加法,来尽量打散原始输入的相关性。
  4. fract(x) 函数在 GLSL 中会返回 x 的小数部分,从而确保结果被限制在 0~1 之间,这对制作随机效果非常有用。
  5. 这些自定义哈希函数都属于 GPU 中常见的“固定随机”生成方式,每次同样的输入都会得到同样的输出,以实现可重复的伪随机分布。
// Noise functions by Dave Hoskins 
#define MOD3 vec3(.1031,.11369,.13787)
 
vec3 hash31(float p) {
   vec3 p3 = fract(vec3(p) * MOD3); 
   p3 += dot(p3, p3.yzx + 19.19);
   return fract(vec3(
       (p3.x + p3.y)*p3.z, 
       (p3.x + p3.z)*p3.y, 
       (p3.y + p3.z)*p3.x
   ));
}
 
float hash12(vec2 p){
	vec3 p3  = fract(vec3(p.xyx) * MOD3);
    p3 += dot(p3, p3.yzx + 19.19);
    return fract((p3.x + p3.y) * p3.z);
}

之所以使用这些定制的哈希函数而不是直接用 GPU 提供的噪声函数,原因在于我们希望对随机分布有更细微的控制,并且这些函数在不同平台上都有一致、可重复的表现。对于需要大规模并行生成随机数的场景,这类哈希函数非常合适,也能保证爆炸效果在每帧或每次运行时都能保持“视觉一致”的随机性。

四、辅助函数:circlight

接下来这两个小函数分别用于计算圆形边界的平滑渐变和粒子光强衰减。其中 circ 常用于生成一个圆形的渐变边缘,而 light 则可被看作是粒子的“强度”分布,让越靠近中心的位置颜色越亮。

在讨论这块代码前,先说明:

  1. circ(vec2 uv, vec2 pos, float size):先让 uv 减去 pos,表示将坐标系移动到圆心,再用 dot(uv, uv) 计算该点到圆心的平方距离。如果这个平方距离比 size 更小,就会得到一个从 0 过渡到 1 的平滑范围。
  2. smoothstep 是一个最常见的 GLSL 过渡函数,smoothstep(edge0, edge1, x) 会在 x < edge0 时输出 0,x > edge1 时输出 1,在中间则平滑过渡。所以 S(size*1.1, size, dot(uv, uv)) 代表在距离略大于 size*1.1 时完全衰减,距离小于 size 时保持最强。
  3. light(vec2 uv, vec2 pos, float size) 采用了 size/dot(uv, uv) 的形式,这意味着离圆心越近,值就越大。此公式很像点光源在 2D 平面的光强分布,也带有一定的高亮效应。
  4. 这些函数的主要目的,是给后续的粒子或特效提供一个带有中心亮度的分布,模拟出能量核或发光核心的感觉。
  5. 通过对比 circlight 的返回值,可以看到它们一个倾向于“是否在圆内”的平滑判断,另一个则倾向于“离圆心越近越明亮”的强度计算,在爆炸粒子中常常会混合使用这两种效果。
float circ(vec2 uv, vec2 pos, float size) {
	uv -= pos;
    size *= size;
    return S(size*1.1, size, dot(uv, uv));
}
 
float light(vec2 uv, vec2 pos, float size) {
	uv -= pos;
    size *= size;
    return size/dot(uv, uv);
}

这里我们最终会将 circlight 结合在一起,用于计算每个粒子在屏幕上的形状和亮度。也就是说,既需要判断这个像素点是否落在圆形范围之内,也需要知道离圆心有多近,从而叠加出一个绚丽的粒子发光效果。

五、explosion 函数:爆炸粒子实现核心

接下来是本着色器最为重要的一段:explosion。它会基于传入的 UV 坐标、爆炸中心点、随机种子和当前时间,计算粒子们在屏幕上的位置和最终颜色。

在展示代码前,先做几点详细说明:

  1. 函数签名:vec3 explosion(vec2 uv, vec2 p, float seed, float t) 中,uv 是当前像素坐标(0~1 范围),p 是爆炸的中心点位置,seed 用于与哈希函数结合制造随机分布,t 是时间参数控制爆炸动画的进行。
  2. vec3 en = hash31(seed) 这一步用哈希函数把一个 seed 转化为三维随机向量 en,其中包含了随机的 RGB 值,也就是爆炸的基础颜色。
  3. for(float i=0.; i<NUM_PARTICLES; i++) { ... } 这段循环相当于一次爆炸中的所有粒子,每个粒子的方向、大小、闪烁等都由内置的随机过程决定。
  4. 动画进度 pt 的计算方式是 1. - pow(t-1., 2.),这里用了一个抛物线形式,让粒子先在爆炸初期快速膨胀,随后又慢慢收缩或变小,形成一次性的爆发感觉。
  5. 对粒子进行叠加时,light(uv, pos, size) 会根据粒子当前所在的位置与屏幕坐标比较,然后输出一个衰减值,再乘以 baseCol 逐帧累加到最终颜色,形成粒子的“发光”叠加效果。
vec3 explosion(vec2 uv, vec2 p, float seed, float t) {
    vec3 col = vec3(0.);    // 初始化颜色为黑色
    
    vec3 en = hash31(seed); // 使用种子生成随机向量
    vec3 baseCol = en;      // 设置基础颜色
    
    // 循环创建粒子
    for(float i=0.; i<NUM_PARTICLES; i++) {
        vec3 n = hash31(i)-.5;  // 生成-0.5到0.5的随机向量
        
        // 计算粒子起始位置,加入重力影响
        vec2 startP = p-vec2(0., t*t*.1);        
        // 计算粒子结束位置,使用随机方向
        vec2 endP = startP+normalize(n.xy)*n.z;
        
        // 计算粒子当前进度(0到1)
        float pt = 1.-pow(t-1., 2.);
        // 计算粒子当前位置
        vec2 pos = mix(p, endP, pt);    
        
        // 计算粒子大小,随时间变化
        float size = mix(.01, .005, S(0., .1, pt));
        size *= S(1., .1, pt);
        
        // 添加闪烁效果
        float sparkle = (sin((pt+n.z)*100.)*.5+.5);
        sparkle = pow(sparkle, pow(en.x, 3.)*50.)*mix(0.01, .01, en.y*n.y);
        size += sparkle*B(en.x, en.y, en.z, t);
        
        // 累加粒子发光效果到最终颜色
        col += baseCol*light(uv, pos, size);
    }
    
    return col; // 返回最终颜色
}

在这里,我们要重点讨论一下“粒子运动轨迹”与“闪烁”部分:

  • 运动轨迹 主要由 startPendPmix 构成,startP 是粒子起始位置(考虑到了简单重力,让它在 y 方向上下落一点),endP 则是随机方向发散后的位置,再用 pt 进行线性插值。
  • 闪烁 则利用了 sparkle 变量,它与 sin((pt+n.z)*100.) 相关,通过不同的频率让不同粒子在爆炸过程中发生不规则的小范围“闪动”。这能让场景更加生动,而不是所有粒子都一样地扩散。
  • 最终,所有粒子颜色会累加到 col 上,因此一处像素可能会受到多个粒子的叠加影响,看起来更亮、更饱满。

六、色彩变幻函数:Rainbow

这段 Rainbow 函数会对已经算好的颜色做一个“彩虹”效果的再处理。它先计算颜色的平均值,然后再叠加 sin 或者一些相位变化,从而让整幅画面拥有周期性的色彩波动。

在展示代码时需要注意:

  1. float avg = (c.r + c.g + c.b)/3.; 获取一个像素的平均亮度,为后续计算做基准。
  2. c = avg + (c - avg) * sin(vec3(0., .333, .666) + t); 通过在 RGB 三通道各自加不同相位 (0., 0.333, 0.666),让三原色的变化不同步,出现明显的渐变。
  3. 后面还用到了 c += sin(...) * vec3(.4, .1, .5); 来再度修饰波形,形成更丰富的色彩层次。
  4. iTime 在 Shadertoy 环境或一些 GLSL 环境中通常代表当前的运行时间,使用它可以让颜色随时间变化。
  5. 这个函数在最后叠加到所有爆炸的结果上,因此会给所有粒子带来“彩虹般”的动态色彩,非常华丽。
vec3 Rainbow(vec3 c) {
    float t = iTime;
    
    float avg = (c.r+c.g+c.b)/3.;
    c = avg + (c-avg)*sin(vec3(0., .333, .666)+t);
    
    c += sin(vec3(.4, .3, .3)*t + vec3(1.1244,3.43215,6.435))*vec3(.4, .1, .5);
    
    return c;
}

之所以选择这种做法,是因为通常在粒子爆炸场景中,单一的颜色容易显得单调;利用 sin 波叠加可以让画面呈现出持续的色彩波动,这种效果在视觉表现上非常吸引眼球,给人更炫酷的感觉。同时,这种动态效果又不需要太多额外的运算量,非常高效。

七、主渲染函数:mainImage

最后我们来看最关键的 mainImage 函数。在 GLSL(尤其是 Shadertoy 或同类环境)中,mainImage 通常是所有像素渲染的入口,每个屏幕像素对应一次调用。

在这之前,我们需要知道:

  1. fragCoord 是当前像素在屏幕坐标系下的位置,iResolution.xy 则是屏幕的宽高。通过 fragCoord.xy / iResolution.xy 把它变成了 0~1 的 UV 坐标,方便进行统一计算。
  2. 这里对 uv.x -= .5; 以及 uv.x *= iResolution.x/iResolution.y; 表示我们把坐标原点移动到屏幕中央,并根据屏幕宽高做拉伸,保证纵横比的正确。
  3. t = iTime*.5; 让我们可以控制整个动画的播放速度,如果想更快或更慢,只需调整这里的乘数即可。
  4. NUM_EXPLOSIONS 次循环里,每一次都先用 hash31(id).xy 生成了爆炸中心 p,并传入 explosion(uv, p, id, et) 做颜色叠加。
  5. 最终再经过 Rainbow(c) 的处理,把彩虹渐变效果加到所有累加后的结果里,再输出到 fragColor
  6. fragColor = vec4(c, 1.) 表示输出一个 RGBA 值,这里不做透明度叠加,所以 alpha 直接给 1.0。
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
	vec2 uv = fragCoord.xy / iResolution.xy;
	uv.x -= .5;
    uv.x *= iResolution.x/iResolution.y;
    
    float n = hash12(uv+10.); // 随机噪声
    float t = iTime*.5;
  
    vec3 c = vec3(0.);
  
    for(float i=0.; i<NUM_EXPLOSIONS; i++) {
        float et = t + i*1234.45235;
        float id = floor(et);
        et -= id;
        
        vec2 p = hash31(id).xy;
        p.x -= .5;
        p.x *= 1.6;
        
        c += explosion(uv, p, id, et);
    }
    c = Rainbow(c);
  
    fragColor = vec4(c, 1.);
}

通过这段主函数,我们就能看到屏幕上出现多个爆炸,每个爆炸都随着时间变化而展现不同的粒子运动轨迹、闪烁和色彩变化。由于把所有逻辑都集中到 GPU 片段着色器中计算,整个特效在现代显卡上可以实时运行,且能处理大量粒子而保持流畅。

八、总结

  1. 噪声与随机性
    代码中采用了自定义的哈希函数 hash31hash12,有效地生成分布均匀、可重复的随机数,为粒子爆炸提供随机初始位置、颜色和运动方向。

  2. 爆炸粒子多重叠加
    每次爆炸在 explosion 函数里会包含 NUM_PARTICLES 个粒子,通过位置插值、闪烁大小和颜色叠加,形成丰富的视觉表现。循环叠加后,就得到一个完整的爆炸效果。

  3. 渐变、平滑和闪烁
    平滑插值函数(smoothstep)以及正弦波(sin)的巧妙运用,让粒子的大小、亮度、消失都带有平滑的过渡或周期性的闪烁,避免突兀的变化。

  4. 色彩控制
    最后用 Rainbow 函数为整体再做一次色彩波动处理,这样同样的粒子爆炸过程,就能在色相上呈现多层变化,让画面更具冲击力。

  5. 可扩展性
    如果想在项目中扩展这种特效,可以调大或调小 NUM_EXPLOSIONSNUM_PARTICLES,或者更改 explosion 函数中的运动曲线、初始颜色分布等,甚至结合更多的噪声函数与物理计算,实现更复杂的烟花、火焰或其他粒子效果。

希望这篇文章能够帮助你更好地理解该爆炸粒子特效的具体实现原理,以及为什么要在这些地方使用哈希噪声、平滑插值、正弦波和随机闪烁等技术手段。如果你在自己的项目中引用了这份代码,不妨尝试修改随机分布、色彩波动的方式,相信会获得许多有趣的新效果。祝你创作愉快、不断探索更多 GLSL 的无限可能。


完整 GLSL 代码

https://www.shadertoy.com/view/lscGRl (opens in a new tab)

/**
fract: 返回浮点数的小数部分,使数字保持在0-1之间
*/
 
#define PI 3.141592653589793238
#define TWOPI 6.283185307179586
#define S(x,y,z) smoothstep(x,y,z)
#define B(x,y,z,w) S(x-z, x+z, w)*S(y+z, y-z, w)
 
#define NUM_EXPLOSIONS 8.
#define NUM_PARTICLES 70.
 
// Noise functions by Dave Hoskins 
#define MOD3 vec3(.1031,.11369,.13787)
 
vec3 hash31(float p) {
   vec3 p3 = fract(vec3(p) * MOD3);
   p3 += dot(p3, p3.yzx + 19.19);
   return fract(vec3(
       (p3.x + p3.y)*p3.z, 
       (p3.x + p3.z)*p3.y, 
       (p3.y + p3.z)*p3.x
   ));
}
 
float hash12(vec2 p){
	vec3 p3  = fract(vec3(p.xyx) * MOD3);
    p3 += dot(p3, p3.yzx + 19.19);
    return fract((p3.x + p3.y) * p3.z);
}
 
float circ(vec2 uv, vec2 pos, float size) {
	uv -= pos;
    size *= size;
    return S(size*1.1, size, dot(uv, uv));
}
 
float light(vec2 uv, vec2 pos, float size) {
	uv -= pos;
    size *= size;
    return size/dot(uv, uv);
}
 
vec3 explosion(vec2 uv, vec2 p, float seed, float t) {
    vec3 col = vec3(0.);
    
    vec3 en = hash31(seed); 
    vec3 baseCol = en;
    
    for(float i=0.; i<NUM_PARTICLES; i++) {
        vec3 n = hash31(i)-.5;   
        
        vec2 startP = p-vec2(0., t*t*.1);
        vec2 endP   = startP+normalize(n.xy)*n.z;
        
        float pt = 1.-pow(t-1., 2.);
        vec2 pos = mix(p, endP, pt);
        
        float size = mix(.01, .005, S(0., .1, pt));
        size *= S(1., .1, pt);
        
        float sparkle = (sin((pt+n.z)*100.)*.5+.5);
        sparkle = pow(sparkle, pow(en.x, 3.)*50.)*mix(0.01, .01, en.y*n.y);
        size += sparkle*B(en.x, en.y, en.z, t);
        
        col += baseCol*light(uv, pos, size);
    }
    
    return col;
}
 
vec3 Rainbow(vec3 c) {
    float t = iTime;
    
    float avg = (c.r+c.g+c.b)/3.;
    c = avg + (c-avg)*sin(vec3(0., .333, .666)+t);
    
    c += sin(vec3(.4, .3, .3)*t + vec3(1.1244,3.43215,6.435))*vec3(.4, .1, .5);
    
    return c;
}
 
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
	vec2 uv = fragCoord.xy / iResolution.xy;
	uv.x -= .5;
    uv.x *= iResolution.x/iResolution.y;
    
    float n = hash12(uv+10.);
    float t = iTime*.5;
  
    vec3 c = vec3(0.);
  
    for(float i=0.; i<NUM_EXPLOSIONS; i++) {
        float et = t + i*1234.45235;
        float id = floor(et);
        et -= id;
        
        vec2 p = hash31(id).xy;
        p.x -= .5;
        p.x *= 1.6;
        
        c += explosion(uv, p, id, et);
    }
    c = Rainbow(c);
  
    fragColor = vec4(c, 1.);
}

通过以上解析与完整代码,希望你能对 GLSL 中如何通过噪声、随机分布、插值、以及正余弦函数来实现绚丽的粒子爆炸效果有更深入的认识。你可以在自己的项目或 ShaderToy 环境中尝试修改相关参数,例如提高粒子数量、改变 smoothstep 区间,或更换随机哈希函数的写法,都能得到不同的视觉效果。祝你学习和创作愉快!