想象你走进一家周末爆满的餐厅后厨:几十位厨师在同一排灶台上各炒各的菜,动作一致、互不等待。你递给每位厨师一张菜谱卡,上面写着“这口锅放多少盐、翻几下、什么时候出锅”。Shader,就是这张菜谱卡;GPU,就是那一排需要同时出菜的厨师们。我们要做的三道菜,是游戏里最常见的三道特效:一道波光粼粼的水面,一道翻腾跳跃的火焰,一道漫天飞舞的粒子。水面要用到 Gerstner 波浪来模拟真实起伏,用 Fresnel 规律决定哪里该反光、哪里该透出水下;火焰和粒子要用噪声来打乱千篇一律的规则感,用 UV 动画让火焰升腾、水流向前;最后还要想办法让整间“后厨”出菜更快。读完这篇,你会从菜谱的角度重新理解图形渲染:每一行 Shader 代码,都是在给成千上万的像素同时下指令。
在游戏引擎与 AI 仿真场景中,水面、火焰与粒子特效是构建高质量视觉环境的核心能力。掌握这些 Shader 技巧,能让你的渲染管线更快、效果更真实,直接服务于游戏开发与 AI 训练环境的落地项目。
目录
可视化演示
展示水面/火焰/粒子三大Shader核心:水面Shader(3层Gerstner波浪W1/W2/W3叠加+Fresnel反射折射效应视角相关)、火焰Shader(5层噪声NSE叠加+UV动画向上滚动+颜色渐变红→橙→粉温度映射)、GPU粒子系统(3阶段EMT发射→UPD更新位置速度生命→RND渲染20粒子并行),三模式切换理解Shader编程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
'+C1+'
Shader 基础认知:后厨里的菜谱卡
周末晚上,餐厅后厨进入最忙的时刻:一排灶台前,几十位厨师同时开火,每人的动作几乎一致——放油、下菜、撒盐、颠勺。如果让主厨一个人一道菜一道菜地炒,客人得等到天亮;真正的秘诀是“一人一锅、同时开炒”。在 GPU 里,这个后厨就是显卡,而 Shader 就是递给每位厨师的菜谱卡:你写的每一行指令,都会被复制给成千上万个并行执行的小单元,每个小单元只负责屏幕上的一个像素或一个顶点。
理解这个前提,整个 Shader 编程就有了根基。为什么水面特效不用 CPU 一张一张画?因为 GPU 天生适合“同一套操作、海量数据”的任务——就像后厨里所有厨师同时读同一张菜谱。渲染管线就是后厨的动线:顶点着色器负责把食材端到该放的位置,片元着色器负责最后调味上色。
顶点着色器(Vertex Shader)逐顶点执行,把模型坐标变换到屏幕坐标;片元着色器(Fragment Shader)逐像素执行,决定每个像素最终颜色。顶点变换的核心是一个矩阵乘法链:从模型本地坐标,经过模型矩阵、视图矩阵、投影矩阵,最终变成屏幕上的一小块。
回到后厨:菜谱卡写的不是“做一道菜”,而是“每个灶台照做同一套动作”。理解这点,后面所有水面、火焰、粒子的技巧,都是在菜谱卡上添一行字而已。
- P_object顶点在模型自身的本地坐标,相当于食材还放在自家砧板上,还没进后厨。
- M_model模型矩阵,把每条鱼每棵菜从砧板搬到厨房中央大台面——世界空间的确定位置。
- M_view视图矩阵,把所有东西搬进摄像机的取景范围,像把镜头对准那盘菜。
- M_projection投影矩阵,把三维场景压成二维屏幕坐标,类似相机按下快门,光影投到底片上。
- 矩阵连乘从右往左依次作用,先本地、再世界、再视图、再投影,一步到位算出屏幕上该画哪儿。
噪声混合技法:香料罐的秘密
一口汤只放盐,喝三口就腻了。有经验的厨师撒料是有层次的:先放底料提鲜,再放香料增香,最后撒一点香菜点缀。Shader 里的噪声,就是这个“香料罐”。水面的波纹为什么不是完美的正弦曲线?火焰的边缘为什么忽明忽暗?粒子的轨迹为什么弯弯曲曲?答案都是同一味香料——噪声。
噪声(Noise)不是随机数。随机数像打翻的胡椒罐,每粒落地毫无关联;噪声是“连续的随机”,相邻位置的值平滑过渡,像撒糖霜时手腕轻轻抖动留下的细密纹理。最常用的是梯度噪声,它能产生自然的不规则起伏。
单层噪声只有一种粗细。要在水面做出大浪套小浪、在火焰做出大团翻腾带小碎火苗,需要把多层不同频率的噪声按振幅递减叠起来,这就是 fBm(分形布朗运动)。还可以“用噪声去扰动坐标”,再拿扰动后的坐标去采样另一份噪声,得到扭曲缠绕的效果,火焰的翻卷就靠它。
所以别把噪声当“乱画”,它是一套可控的调味体系:频率定粗细、振幅定浓淡、层数定丰富度。水面、火焰、粒子,三盘菜共用同一罐香料。
- noise(p × freq^i)第 i 层噪声,freq 越大纹理越密——香料颗粒越细。
- amp^i 振幅每层权重按指数衰减,高频细节只当点缀,不能喧宾夺主。
- 求和符号把各层噪声加权相加,从低频轮廓到高频细节逐层叠好。
- p 是坐标把二维坐标丢进噪声函数,输出一个连续变化的值——一撮香料在锅里均匀化开。
Gerstner 波浪:揉面推出的浪尖
揉面是体力活也是技术活:手掌往面团里推一下,面团表面会鼓起一道波浪,再一拉,波浪塌下去。经验丰富的厨师知道,真正海浪的波形不是对称的正弦曲线,而是“前面陡、后面缓”的尖形波——这正是 Gerstner 波想要捕捉的形状。
Gerstner 波模型把水面当成无数个质点的集合,每个质点不仅上下振动,还会在水平方向前后晃动。于是波形呈现出更尖锐的浪尖,顶点会“聚集”在波峰处,看起来就像真的海浪在推挤。
现实海面从来不是一道波:远处涌来的大浪、近处揉起的碎浪、风推起的小波纹,同时叠加。把多个 Gerstner 波相加,就得到复杂而可信的海面。得到表面高度后,用高度场求偏导得到法线,光照、反射、折射全都要依赖它。
于是海面不再是贴图上的假动效:每一帧,顶点都被菜谱推着走,浪尖聚集、浪谷拉平,配合法线计算,阳光在波峰上的跳动闪烁就有了依据。
- 振幅 A_i第 i 个波的浪高,相当于推面团的力气。
- 波数 k_i决定一个波的疏密,k 越大波长越短,波纹越细密。
- 角频率 ω_i波峰向前推进的速度指标,反映浪涌的快慢。
- 相位 φ_i每道波的起始位置错开,让所有波不必同时起浪。
- 整体求和大浪叠小浪,合成海面的最终高度场。
Fresnel 光学:越斜越亮的水面
站在岸边低头看水,你能看见水底的石头;蹲下身侧着看同一片水面,水面却亮得像一面镜子。同一片水,为什么变了个样?这就是 Fresnel(菲涅尔)效应在捣鬼:光打到水面,一部分反射进眼睛,一部分折射进水底,而两者的比例取决于你看水面的角度。
当视线垂直于水面(俯视),大部分光折射进水里,你看到的是水下世界;当视线几乎平行于水面(掠射),大部分光被反射,你看到的是倒影。这个“越斜越反光”的规律,是真实渲染中判断水面、玻璃、油漆、潮湿路面反光强度的通用法则。
精确计算 Fresnel 需要解复杂的电磁学方程,游戏里常用 Schlick 近似:只需要一个垂直入射时的反射率 F0,再按照视线与法线的夹角做五次方衰减,就能得到足够真实的反射比例。边缘光(Rim Light)也是它的应用之一。
给水面 Shader 用上 Fresnel,船头方向的波光、地平线附近的白亮,就不是“加一层白色描边”的偷懒,而是物理规律在菜谱里的正确呈现。
- F0光线垂直射向水面时的反射率,水面约 0.02,玻璃约 0.04。
- θ视线方向与表面法线的夹角,俯视为 0°,掠射接近 90°。
- cos θ把夹角映射到 0~1,垂直时等于 1。
- (1−cos θ)^5越倾斜越接近 1,反射率被快速拉高——这五次方就是“越斜越亮”的曲线。
- F(θ)最终反射比例,用它混合反射颜色与折射颜色。
UV 动画:转动烤盘的艺术
烤蛋糕时,主厨会每隔几分钟转动一次烤盘,让火候均匀,表面上色一致。“转烤盘”这个动作,在 Shader 里的名字就叫 UV 动画:把纹理坐标当成烤盘上的位置,随时间轻轻转动,画面就流动起来了。
UV 坐标是纹理贴图的“地址”:每个像素从贴图的哪个位置取色,由它的 UV 决定。UV 是 0 到 1 的小数,超过 1 会自然循环回 0,因此贴图可以无缝重复——这给了流动效果极大的便利。
实现流动非常直观:每一帧给 UV 加上一个时间相关的偏移量。水流沿着某个方向推进,就给 U 或 V 加上固定流速;火焰向上升腾,就持续减去一个 V 偏移。复杂的漩涡、扭曲,则是叠加噪声之后对 UV 做更高级的扰动。
做水面时,把法线贴图沿两个方向交叉流动,波光就活了;做火焰时,让噪波贴图持续上移,火舌就舔上去。“转烤盘”这一招,几乎承包了所有 2D 流动观感。
- uv0原始纹理坐标,桌布上每个点原本的位置。
- 方向 d流动方向,单位向量,水流往东还是火焰朝上,由它决定。
- speed 流速每秒钟偏移多少 UV,速度越快流动越急。
- t 累计时间从效果开始到现在的总时间,持续增长的推动力。
- uv'(t)新的采样坐标,随时间线性增长,配合取小数循环实现无限流动。
GPU 粒子系统:上百位撒糖霜的帮手
甜品师要给一百块蛋糕撒糖霜。如果派一个人一块一块地撒,得忙到打烊;但如果请一百位帮工,每人负责一块,几秒钟就完工。GPU 粒子系统正是这个思路:把成千上万颗粒子交给 GPU 上成千上万个并行线程,每颗粒子有自己的生命周期,从出生、运动到消亡,全部在显卡内部完成。
粒子系统的传统做法是 CPU 逐颗更新位置,再上传顶点数据——粒子一多,CPU 就成了瓶颈。GPU 粒子把位置、速度、出生时间都存放在显存里,顶点着色器每帧读取并更新位置,数据根本不需要离开显卡,带宽和延迟都大幅下降。
粒子的运动是简单的物理积分:位置等于初始位置加上初速度乘时间,再加上加速度造成的抛物项。重力、风力都是加速度。粒子还可以随生命阶段改变大小和透明度:出生时小、中段膨胀、末段淡出。最后,软粒子(Soft Particle)让粒子在靠近场景物体时自动柔化边缘,避免硬生生的接缝。
从零星的火星到铺天盖地的爆炸碎片,GPU 粒子让“人多力量大”变成了图形特效的日常。而软粒子、生命阶段这些细节,则是让上万个粒子看起来像是一个整体在呼吸。
- P0粒子出生时的位置,一颗糖霜离开指尖的起点。
- V0初速度,决定粒子飞出去的方向和冲劲。
- V0×t匀速运动项,没有外力时粒子一直向前飞。
- 加速度 a重力、风力等外力,让轨迹弯曲。
- ½at²抛物线项,时间越久偏离直线越远,形成火焰的弧线、爆炸的抛洒。
优化技巧:高峰时段的后厨心法
饭点一到,后厨拼的不是谁家菜多好吃,而是谁出锅快。聪明的厨师会提前备菜、同一种酱汁一锅出、离灶台远的菜最后才下单。Shader 优化也是同样的道理:在正确的地方省时间,而不是等卡了才来救火。
一个像素执行的指令越多,整个屏幕的负担越大——屏幕上可是有几百万个像素。所以优化思路有两条主线:减少每个像素的指令数,以及减少需要重度计算的像素数量。前者靠精简代码与低精度运算,后者靠 LOD 分级:远处的波浪少算两层噪声,远处的火焰换成更简单的粒子。
具体手段包括:用 half 精度代替 float(移动端尤其明显);合并纹理减少采样次数;把条件分支提前到 uniform 层面,避免像素间的分支发散;粒子系统里用实例化批处理,将上千颗粒子压缩进一次 draw call;软粒子虽然好看,也要控制像素覆盖面积。
水面、火焰、粒子三盘菜,端上桌之前都要在后厨过一遍效率关。菜谱写得好不好看,最终要看高峰期能不能准时出菜。
- N_pixels着色器覆盖的像素总数,上菜的人数。
- C_instructions每个像素执行的指令数,每位客人点的菜数。
- ∝ 正比总成本与两者的乘积成正比,谁翻倍成本就翻倍。
- 优化方向减半指令或减半像素,都能直接砍掉一半成本——精简菜单与 LOD 是同一件事。
AI 应用场景
场景 1:航海游戏里的动态海面
阶段:游戏引擎实时渲染 / 环境模拟
解决的问题:传统做法贴一张静态法线贴图伪装水面,船桨划过没有浪、也没有倒影,浸没感差;用 Gerstner 多波叠加驱动水面网格顶点位移,Fresnel 控制反射折射混合,UV 动画让法线流动,海面就有了真实的起伏与反光。
具体案例:在一款帆船航海游戏中,水面 Shader 叠加 4 层 Gerstner 波,波峰处法线偏转明显;视线掠射角增大时,Fresnel 把反射比例拉高,夕阳下海面一片金红。船体与水面的交互用高度场采样响应,玩家能明显感到浪的推挤。
场景 2:角色技能的火爆粒子特效
阶段:实时特效渲染
解决的问题:技能释放瞬间需要上千颗火焰、火星、烟雾粒子,CPU 逐颗更新会导致帧率骤降;GPU 粒子把位置更新放进顶点着色器,配合噪声扰动与软粒子,大火球在 60 帧下也能流畅炸开。
具体案例:MOBA 游戏中火法师的大招,由 3000 颗 GPU 粒子组成,噪声驱动粒子速度方向抖动,UV 动画让火焰贴图沿粒子轴向流动,软粒子柔化粒子与地面的交界,画面既有冲击力又不穿帮。
场景 3:AI 训练环境的程序化视觉素材
阶段:仿真数据生成
解决的问题:强化学习训练无人艇避障,需要大量不同天气、不同浪高的水面图像;用 Shader 程序化生成水面(Gerstner + 噪声 + Fresnel),可以无限变化参数,批量产出带标注的训练样本。
具体案例:无人艇视觉导航项目中,用 Unity 的 Shader 参数接口快速切换浪高、风向、光照,连续渲染一万张不同状态的水面图像作为训练集,训练出的模型在真实水域表现出更强的泛化能力。
Python 演示
import math def gerstner(x, y, t, amp, kx, ky, omega, phase): return amp * math.sin(kx * x + ky * y - omega * t + phase) def noise01(x, y): n = math.sin(x * 12.9898 + y * 78.233) * 43758.5453 return n - math.floor(n) def frag_color(x, y, t): h = 0.0 h += gerstner(x, y, t, 0.8, 0.10, 0.04, 0.9, 0.0) h += gerstner(x, y, t, 0.4, 0.06, -0.08, 1.3, 1.5) h += gerstner(x, y, t, 0.2, 0.15, 0.02, 1.7, 2.8) h += (noise01(x * 0.8, y * 0.8) - 0.5) * 0.7 if h > 0.7: return 'W' if h > 0.1: return '~' return ' ' def main(): rows = 18 cols = 72 t = 2.5 for j in range(rows): line = '' for i in range(cols): x = i * 0.3 y = j * 0.3 line += frag_color(x, y, t) print(line) if __name__ == '__main__': main()
return amp * math.sin(kx * x + ky * y - omega * t + phase)单条 Gerstner 波的高度公式,kx、ky 控制波的传播方向。h += gerstner(x, y, t, 0.8, 0.10, 0.04, 0.9, 0.0)第一层大浪,振幅 0.8,波长较长,决定海面轮廓。h += gerstner(x, y, t, 0.4, 0.06, -0.08, 1.3, 1.5)第二层波浪方向斜向,产生交叉的碎浪纹理。h += gerstner(x, y, t, 0.2, 0.15, 0.02, 1.7, 2.8)第三层高频小波,为海面补充细密波纹。h += (noise01(x * 0.8, y * 0.8) - 0.5) * 0.7用连续噪声扰动高度,打乱正弦波的规则感,结果更接近自然水面。if h > 0.7: return 'W'超过泡沫阈值的位置标记为浪花,对应 Shader 里“波峰变沫”的判定。if h > 0.1: return '~'中等高度显示为波面,低于阈值则打印空格表示深水区。if __name__ == '__main__':程序入口,保证模块可被导入而不直接执行。
常见误区
误区我写的 Shader 里有 for 循环,GPU 就会一个像素一个像素排队执行,代码写在后面的行一定会等前面的行跑完,就像 CPU 上的普通程序。
正解GPU 是 SIMT 并行架构,成千上万个线程同时执行同一份 Shader,每个线程负责一个像素或顶点。Shader 里的“循环”只是在单个像素内部展开的有限次数计算,像素与像素之间没有执行顺序依赖。写 Shader 时要把思维从“一个点跑完整段代码”切换成“无数个点同时跑同一段代码”。
为何常见:因为 CPU 编程是顺序思维,写惯了 for + return 的人很容易把 GPU 想象成一个大号的 CPU。
误区所谓 Fresnel 就是描边效果,把物体边缘涂亮一点就行了,乘一个“边缘系数”等于 1 减视角余弦,做完收工。
正解Fresnel 是物理上反射/折射比例随入射角变化的规律,边缘亮只是这个规律在视觉效果上的体现。正确的做法是用它混合反射颜色与折射颜色:俯视时折射为主看清水下,掠射时反射为主看见倒影。如果把它当成常量描边,正面也亮、斜看却暗,完全违背直觉。
为何常见:很多教程把 Fresnel 直接用于边缘光效果,看得多了,就误以为它只是一种描边技巧。
误区粒子放到 GPU 里运算很快,所以粒子数量可以随便加,十万颗不够就上一百万颗,反正显卡算得过来。
正解GPU 粒子的每次更新都要读写显存并执行顶点/片元着色,粒子数受显存带宽、填充率和 draw call 共同限制。真正靠谱的优化组合是:合并粒子网格、用低精度运算、按距离做 LOD、按屏幕面积裁剪,而不是无脑加粒子。
为何常见:因为看到“GPU 加速”就觉得免费,忽略了带宽和填充率这座隐形天花板。
误区噪声就是用 random() 生成一堆随机数贴上去,只要看着乱、看不出规律,效果就算成功了。
正解程序化噪声需要可控的频域特性:低频决定轮廓,高频决定细节,fBm 用固定频率倍增和振幅衰减,让结果既有结构又自然。真正的随机数是逐点独立、不连续的,直接贴上去会闪成雪花噪点,根本不像水面或火焰。
为何常见:“随机”和“噪声”在中文里听起来很像,初学者容易把两者混为一谈。
面试题
Q1. Shader 中每个像素的颜色由谁计算?
- A.CPU 顺序遍历每个像素
- B.GPU 并行线程同时执行← 正确答案
- C.游戏主线程逐帧处理
- D.驱动一次性预计算
答案:B。GPU 采用并行架构,同一份 Shader 同时被成百上千个线程执行,每个线程算一个像素。A 错在以为是 CPU 顺序算;C 错在 Shader 不归主线程管;D 错在驱动只负责编译和调度,不计算颜色。
Q2. Gerstner 波浪模型中,为什么要叠加多个正弦波?
- A.让波形更像真实海浪的起伏← 正确答案
- B.让顶点数量变得更多更密
- C.降低水面网格的计算负担
- D.让水面完全没有反射光
答案:A。多个不同振幅、波长、相位的正弦波叠加,才能生成大浪套小浪的真实海面。B 把波形叠加误以为增加顶点数;C 方向反了,多波叠加计算量更大;D 与反射无关。
Q3. Fresnel 效应描述的是哪种物理现象?
- A.水面吸收全部入射光线
- B.掠射角越大反射越强← 正确答案
- C.物体越远颜色越深
- D.灯光随距离逐渐衰减
答案:B。Fresnel 描述入射角越大反射占比越高的现象:俯视水面以透射为主,掠射以反射为主。A 是吸收,C 是大气透视,D 是光衰减,都不是 Fresnel。
Q4. 让水面纹理产生流动感,最常用的 Shader 做法是?
- A.每帧修改网格顶点坐标
- B.给 UV 坐标加上时间偏移← 正确答案
- C.更换一张更大分辨率贴图
- D.增大纹理过滤的采样半径
答案:B。UV 动画的核心就是给 UV 加线性时间偏移,采样位置随时间移动,纹理自然流动。A 改网格成本高且不是纹理流动;C 换贴图没有流动;D 是过滤参数,与流动无关。
Q5. GPU 粒子系统相对 CPU 粒子系统的优势有哪些?
- A.位置更新在显存中并行完成← 正确答案
- B.可以支持非常庞大的粒子数量← 正确答案
- C.完全不需要任何顶点缓冲
- D.所有碰撞检测自动完成
答案:AB。GPU 粒子把更新放在显存内并行完成,能轻松支撑数万粒子;而 CPU 粒子需要上传数据、受 CPU 核心数限制。C 错,粒子仍需要顶点缓冲;D 错,碰撞仍需额外处理。
Q6. 多层噪声叠加 fBm 的主要目的是?
- A.让噪声频率单一且稳定
- B.增加高频细节让效果更自然← 正确答案
- C.让噪声结果完全可预测
- D.减少采样纹理的次数
答案:B。fBm 通过多频噪声叠加引入高频细节,让噪声既有整体轮廓又有丰富质感。A 说反了,它让频谱更宽;C 完全可预测是错误目标;D 与纹理采样次数无关。
Q7. 下列哪些属于 Shader 优化手段?
- A.减少分支指令和冗余计算← 正确答案
- B.用低精度运算替代高精度← 正确答案
- C.远处使用低精度 LOD 网格← 正确答案
- D.每次采样都加一层循环
答案:ABC。减少分支避免并行发散、用 half 精度提升吞吐、远处用 LOD 减少像素数量都是标准优化。D 加循环只会增加指令数,是反优化。
Q8. 顶点着色器的主要任务是什么?
- A.计算每个像素的最终颜色
- B.把顶点从模型空间变换到屏幕← 正确答案
- C.管理纹理的压缩格式
- D.保存粒子系统的生命周期
答案:B。顶点着色器负责顶点变换,即模型→世界→相机→裁剪空间的矩阵变换。A 是片元着色器的活;C 与顶点着色器无关;D 粒子生命周期在逻辑层管理。
Q9. 软粒子(Soft Particle)主要解决什么问题?
- A.粒子与场景交界处的硬边问题← 正确答案
- B.粒子数量过大的显存溢出问题
- C.纹理采样速度过慢的性能问题
- D.波浪法线计算错误的显示问题
答案:A。粒子与场景深度交界处会出现硬边接缝,软粒子根据深度差值调制透明度,让交界柔和。B 是显存问题不是软粒子的目标;C 与采样速度无关;D 与法线无关。
Q10. 噪声在粒子/火焰 Shader 中的作用有哪些?
- A.让粒子运动不再是直线轨迹← 正确答案
- B.为火焰边缘增加不规则抖动← 正确答案
- C.让同一材质产生多样观感← 正确答案
- D.把粒子数量自动减少一半
答案:ABC。噪声让粒子轨迹抖动、火焰边缘不规则、同一材质在不同位置呈现多样观感,这些都是噪声的典型应用。D 是性能优化手段(数量裁剪),不是噪声的职责。