PHASE 0 · 心智模型

0.1GPU 的思维方式

从画家到百万工人:渲染管线与并行心智模型

你抄过的每一段 Shader 代码,都默认你已经懂一件事:GPU 处理问题的方式和 CPU 完全相反。没建立这个心智模型,GLSL 的每一行都会显得莫名其妙——为什么没有循环遍历像素?为什么不能「先画个圆再挪一下」?为什么到处是 smoothstep?这一章不写代码,先把脑子换过来。

1CPU 是画家,GPU 是工厂

假设要把一张 1080 × 2400 的手机屏幕涂满渐变色,一共约 260 万个像素。用你熟悉的 CPU 思路写,大概是这样:

Kotlin · 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):

顶点数据
Vertex Shader
光栅化
Fragment Shader
混合 / 帧缓冲

五个环节各干一件事:

📦

顶点数据

CPU 端(你的 Kotlin 代码)提供的原材料:一堆顶点坐标。画全屏特效只需要 4 个顶点围成的矩形。

📐

Vertex Shader

每个顶点执行一次,决定它最终落在屏幕的哪个位置。3D 游戏在这里做投影变换;2D 特效基本原样输出。

🔲

光栅化

GPU 固定功能,不可编程。把三角形「压」到屏幕上,找出它覆盖了哪些像素,并对顶点携带的数据做插值。

🎨

Fragment Shader

对光栅化产出的每个像素候选(fragment)执行一次,输出颜色。本书 90% 的篇幅都在写它。

🖼️

混合 / 帧缓冲

把颜色写进最终的画面缓冲区,可能与已有内容做透明度混合。写完一整帧,屏幕刷新显示。

Fragment 和像素是一回事吗?

基本可以这么理解。严格说 fragment 是「候选像素」——它可能被深度测试淘汰、可能和别的 fragment 混合。写 2D 特效时两者一一对应,本书行文中不做区分。

「光栅化会对顶点数据做插值」这件事值得多说一句,因为它解释了一个之后常用的机制:如果三角形的三个顶点各带一个颜色,那么三角形内部每个像素收到的颜色,是按它到三个顶点的距离加权混合出来的。纹理坐标、法线,任何从 Vertex Shader 传出的变量都会被这样平滑插值——这是 GPU 送你的免费渐变器。

3Fragment Shader 的世界规则

回到那个工厂。每个工人(每次 Fragment Shader 执行)都活在三条铁律之下,这三条就是 Shader 编程和普通编程的全部区别:

规则含义对你写代码的影响
① 只知道自己你知道自己的坐标、时间等全局量,仅此而已一切图形都得表达成「坐标的函数」
② 不能串门读不到「隔壁像素这一帧算出了什么颜色」模糊等效果要靠采样上一帧/纹理实现(2.1 章)
③ 人人一样所有像素跑同一份代码,只有输入不同「对一半像素这样、另一半那样」要用数学分支,而不是流程分支

用一个比喻把它钉进脑子:

工厂比喻(全书反复引用)

你是几百万工人中的一个,坐在固定工位上(像素坐标),头顶广播播着全厂通知(uniform 变量:时间、分辨率、触摸位置)。你不能和邻座说话,只能根据「我的位置 + 广播内容」决定给自己的格子涂什么颜色。所有人同时开工,同时交卷。

规则 ③ 有一个重要推论:GPU 硬件层面,相邻的几十个像素是锁步(lockstep)执行的——同一组工人必须同一时刻执行同一条指令。如果代码里写 if/else,而相邻像素走了不同分支,硬件只能把两个分支都执行一遍,各自丢弃不要的结果。这就是「Shader 里分支很贵」的真正原因,也是为什么老手更爱用 stepmix 这类「数学开关」——1.1 章会专门训练这个思维。

Android 视角 贯穿全书

你在 Android 上其实早就在用管线了:View 的每一帧,最终都由 Skia/HWUI 转成 GPU 命令走这条管线;Compose 的 graphicsLayerRenderEffect、乃至 Modifier.blur() 底层全是 Fragment Shader。本书教你的,就是亲手写这一层,不再隔着封装猜。

4你能改写管线的哪些环节

五个环节里,只有两个是可编程的:Vertex Shader 和 Fragment Shader。其余是 GPU 固定功能,你只能配置参数,不能改逻辑。这两个可编程环节的分工决定了本书的结构:

Vertex ShaderFragment 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 这类数学开关。

动手练习

  1. 打开本章 Demo 的「编辑代码」,把 uv.x 改成 uv.y,再改成 uv.x + uv.y,运行前先预测画面会怎么变。预测对了才算懂。
  2. sin(...) 整体替换成 uv.x,观察没有时间参与时画面是否还会动,想想为什么。
  3. 思考题:如果想做「高斯模糊」,每个像素需要知道周围像素的颜色,但规则②说读不到邻居这一帧的结果——那模糊效果是怎么实现的?带着猜想去 2.1 章找答案。