你抄过的每一段 Shader 代码,都默认你已经懂一件事:GPU 处理问题的方式和 CPU 完全相反。没建立这个心智模型,GLSL 的每一行都会显得莫名其妙——为什么没有循环遍历像素?为什么不能「先画个圆再挪一下」?为什么到处是 smoothstep?这一章不写代码,先把脑子换过来。
1CPU 是画家,GPU 是工厂
假设要把一张 1080 × 2400 的手机屏幕涂满渐变色,一共约 260 万个像素。用你熟悉的 CPU 思路写,大概是这样:
// 一个画家,拿着一支笔,从左上角开始一个点一个点涂
for (y in 0 until height) {
for (x in 0 until width) {
bitmap.setPixel(x, y, computeColor(x, y)) // 执行 260 万次
}
}
这是串行思路:一个很聪明的画家(CPU 核心),按顺序处理每个点。哪怕每个点只花 100 纳秒,涂一整屏也要约 260 毫秒——而流畅动画要求 16 毫秒内画完一帧。差了一个数量级不止。
GPU 的解法完全不同:它不雇一个聪明的画家,而是雇几千个只会算术的工人(着色器核心),把 260 万个像素分给他们同时开工。每个工人的任务简单到极点:
- 告诉你一个坐标 (x, y);
- 你算出这个坐标该是什么颜色;
- 交卷,下一批。
而你将要学的 Fragment Shader,就是发给每个工人的那张任务说明书——一个「输入坐标、输出颜色」的纯函数。你只写一份,GPU 把它复制给所有工人同时执行。
Shader(着色器):一段运行在 GPU 上的小程序,对海量数据(顶点或像素)中的每一个独立执行一次。GLSL(OpenGL Shading Language)是编写它的语言,C 风格语法。
下面是本书第一个实时 Demo。此刻你屏幕上这块画布里的每一个像素,都正在独立执行同一段代码——没有循环遍历像素,没有谁先谁后:
2渲染管线:一个三角形的旅程
GPU 不是直接「画像素」的,它画的是三角形。任何画面——游戏里的角色、App 的转场、甚至一块全屏特效——底层都是三角形被逐步加工成像素的过程。这条加工流水线叫渲染管线(Rendering Pipeline):
五个环节各干一件事:
顶点数据
CPU 端(你的 Kotlin 代码)提供的原材料:一堆顶点坐标。画全屏特效只需要 4 个顶点围成的矩形。
Vertex Shader
对每个顶点执行一次,决定它最终落在屏幕的哪个位置。3D 游戏在这里做投影变换;2D 特效基本原样输出。
光栅化
GPU 固定功能,不可编程。把三角形「压」到屏幕上,找出它覆盖了哪些像素,并对顶点携带的数据做插值。
Fragment Shader
对光栅化产出的每个像素候选(fragment)执行一次,输出颜色。本书 90% 的篇幅都在写它。
混合 / 帧缓冲
把颜色写进最终的画面缓冲区,可能与已有内容做透明度混合。写完一整帧,屏幕刷新显示。
基本可以这么理解。严格说 fragment 是「候选像素」——它可能被深度测试淘汰、可能和别的 fragment 混合。写 2D 特效时两者一一对应,本书行文中不做区分。
「光栅化会对顶点数据做插值」这件事值得多说一句,因为它解释了一个之后常用的机制:如果三角形的三个顶点各带一个颜色,那么三角形内部每个像素收到的颜色,是按它到三个顶点的距离加权混合出来的。纹理坐标、法线,任何从 Vertex Shader 传出的变量都会被这样平滑插值——这是 GPU 送你的免费渐变器。
3Fragment Shader 的世界规则
回到那个工厂。每个工人(每次 Fragment Shader 执行)都活在三条铁律之下,这三条就是 Shader 编程和普通编程的全部区别:
| 规则 | 含义 | 对你写代码的影响 |
|---|---|---|
| ① 只知道自己 | 你知道自己的坐标、时间等全局量,仅此而已 | 一切图形都得表达成「坐标的函数」 |
| ② 不能串门 | 读不到「隔壁像素这一帧算出了什么颜色」 | 模糊等效果要靠采样上一帧/纹理实现(2.1 章) |
| ③ 人人一样 | 所有像素跑同一份代码,只有输入不同 | 「对一半像素这样、另一半那样」要用数学分支,而不是流程分支 |
用一个比喻把它钉进脑子:
你是几百万工人中的一个,坐在固定工位上(像素坐标),头顶广播播着全厂通知(uniform 变量:时间、分辨率、触摸位置)。你不能和邻座说话,只能根据「我的位置 + 广播内容」决定给自己的格子涂什么颜色。所有人同时开工,同时交卷。
规则 ③ 有一个重要推论:GPU 硬件层面,相邻的几十个像素是锁步(lockstep)执行的——同一组工人必须同一时刻执行同一条指令。如果代码里写 if/else,而相邻像素走了不同分支,硬件只能把两个分支都执行一遍,各自丢弃不要的结果。这就是「Shader 里分支很贵」的真正原因,也是为什么老手更爱用 step、mix 这类「数学开关」——1.1 章会专门训练这个思维。
你在 Android 上其实早就在用管线了:View 的每一帧,最终都由 Skia/HWUI 转成 GPU 命令走这条管线;Compose 的 graphicsLayer、RenderEffect、乃至 Modifier.blur() 底层全是 Fragment Shader。本书教你的,就是亲手写这一层,不再隔着封装猜。
4你能改写管线的哪些环节
五个环节里,只有两个是可编程的:Vertex Shader 和 Fragment Shader。其余是 GPU 固定功能,你只能配置参数,不能改逻辑。这两个可编程环节的分工决定了本书的结构:
| Vertex Shader | Fragment Shader | |
|---|---|---|
| 执行次数 | 每个顶点一次(全屏特效只有 4 次) | 每个像素一次(动辄百万次) |
| 核心职责 | 顶点最终在屏幕哪里 | 这个像素是什么颜色 |
| 2D 特效中 | 固定套路,写一次抄到老(3.2 章) | 全部创造力所在 |
| 游戏 / 3D 中 | 投影、骨骼动画、草地摆动(4.1 章) | 光照、材质、后处理 |
所以本书的路线是:Phase 0~2 集中火力练 Fragment Shader(在浏览器里,零环境配置),Phase 3 补上 Vertex Shader 和 Android 管线装配,Phase 4 再回头看游戏引擎如何把两者玩出花。
「学 Shader = 学 OpenGL API」——不对。OpenGL(以及 Vulkan、Metal)只是把 Shader 装进管线的胶水,概念枯燥但套路固定;Shader 本身才是创造画面的语言,而且跨引擎通用:本书的 GLSL 思路,换到 Unity 的 HLSL、Godot 的 gdshader 几乎原样成立(4.1 章有对照表)。先学语言,再学胶水,顺序别反。
本章小结
- CPU 串行地「一支笔画所有点」,GPU 并行地「百万工人各画自己的点」;Fragment Shader 就是发给每个工人的任务说明书。
- 渲染管线五环节:顶点数据 → Vertex Shader → 光栅化 → Fragment Shader → 混合输出;可编程的只有两个 Shader。
- Fragment Shader 三条铁律:只知道自己的坐标、读不到邻居的结果、所有像素执行同一份代码。
- 光栅化会自动对顶点输出做插值——免费的渐变机制,后面经常白嫖。
- 相邻像素锁步执行,分支两边都会被算一遍,所以偏爱
step / mix这类数学开关。
动手练习
- 打开本章 Demo 的「编辑代码」,把
uv.x改成uv.y,再改成uv.x + uv.y,运行前先预测画面会怎么变。预测对了才算懂。 - 把
sin(...)整体替换成uv.x,观察没有时间参与时画面是否还会动,想想为什么。 - 思考题:如果想做「高斯模糊」,每个像素需要知道周围像素的颜色,但规则②说读不到邻居这一帧的结果——那模糊效果是怎么实现的?带着猜想去 2.1 章找答案。