PHASE 0 · 心智模型

0.2第一个 Fragment Shader

gl_FragCoord、归一化与居中坐标

0.1 建立了心智模型:Fragment Shader 是「输入坐标、输出颜色」的纯函数。这一章把它落成真实代码——只有五行,但每一行都值得停下来问一句「为什么必须这样写」。然后解决所有 Shader 的第一件事:把坐标整理成好用的形状

1五行代码,逐行拆解

这是一个合法、完整、能跑的 Fragment Shader(OpenGL ES 3.0 / GLSL 300 es,也就是 Android 上你会用的版本):

逐行拆解,每行都有一个「为什么」:

#version 300 es — 我说的是哪种方言

GLSL 有很多版本,语法差异不小。300 es 对应 OpenGL ES 3.0,是 Android(2013 年后几乎所有设备)与 WebGL2 通用的版本,本书 Demo 与 Android 代码完全同源。这行必须是整个文件的第一行,前面连空行、注释都不能有——驱动就是这么较真。

precision mediump float — 浮点数用多少位

移动 GPU 为了省电,提供三档浮点精度:lowpmediumphighp。Fragment Shader 里 float 没有默认精度,必须声明,否则编译直接报错(这是从桌面 OpenGL 转来的人必踩的坑,桌面版有默认值)。日常特效 mediump 够用;涉及大坐标、长时间累积(比如 uTime 跑了几分钟)时用 highp,否则低端机上会出现肉眼可见的抖动和条带。

out vec4 fragColor — 我的交卷通道

out 表示这个变量是本阶段的输出。Fragment Shader 的使命就是产出一个颜色,类型是 vec4:四个分量分别是红、绿、蓝、不透明度(RGBA),每个分量的范围约定为 0.0 到 1.0(不是 0~255!)。变量名随意,叫 fragColor 只是习惯。旧版 GLSL(100 es)用内置的 gl_FragColor,看到老代码别慌,是一回事。

void main() — 每个像素的入口

和 C 一样的入口函数。区别在于:CPU 程序整个进程只跑一次 main,而这里每个像素各跑一次。上面那块 140px 高的画布如果宽 700px,这个 main 每帧执行约 10 万次(还要乘上设备像素比)。

新手第一坑:1 和 1.0 不是一回事

GLSL 的类型检查极其严格:float x = 1; 在很多驱动上直接编译失败,因为 1 是 int。所有浮点字面量必须带小数点。以后看到报错 cannot convert from 'int' to 'float',先检查是不是哪个数字裸奔了。

2gl_FragCoord:我在哪

纯色太无聊了。工厂比喻里说过,每个工人知道自己的工位——这个信息就是内置变量 gl_FragCoord,它的 .xy 是当前像素的坐标,单位是像素,原点在左下角,x 向右、y 向上。

y 轴方向:GL 与 Android 相反

OpenGL 的 y 轴向上,原点在左下;Android 的 View/触摸坐标 y 轴向下,原点在左上。把触摸位置传进 Shader 时要做一次 glY = height - touchY 翻转(3.3 章),纹理上传时也有一次类似的翻转问题(俗称「图是倒的」)。现在先记住:凡是画面上下颠倒,先查 y 轴方向

3归一化:摆脱分辨率

直接用像素坐标写效果,换台设备就错位——1080p 手机上居中的东西,到 1440p 上就偏了。所以几乎所有 Shader 的第一行都是同一个动作:把坐标除以分辨率,归一化到 0~1。分辨率从哪来?它不是内置变量,而是 CPU 端传入的 uniform(0.3 章细讲,先会用):

这个「把坐标画成颜色」的技巧值得单独点名:它是 Shader 调试的第一工具。任何时候你对「此刻坐标被我折腾成了什么样」没把握,就直接 fragColor = vec4(uv, 0.0, 1.0); 输出看看——红绿渐变的形状会告诉你一切(第 5 节还会用到)。

4居中坐标:为图形做准备

0~1 的 UV 适合处理图像(纹理坐标就是这个范围),但画图形时有两个别扭:原点在角落(图形通常以中心对称),而且 x、y 分别被拉伸到 0~1,在非正方形画布上比例是歪的——你画的「圆」会变成椭圆。标准解法一行搞定:

GLSL · 居中且保持比例的坐标
// 中心为 (0,0),y 方向范围 -0.5 ~ +0.5,x 按宽高比自然延伸
vec2 p = (gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y;

拆开看:减去 0.5 * uResolution 把原点挪到画布中心;统一除以高度 y(而不是各除各的),保证 x、y 的单位长度一致——圆是圆的。代价是 x 的范围不再固定(宽屏上可能是 ±0.9),但比例正确远比范围整齐重要。这是 Shadertoy 社区的通用惯例,后面所有章节的图形代码都以这行开头:

两套坐标,各司其职

从现在起你手里有两套坐标,选哪套取决于任务:处理图像用 uv(0~1,匹配纹理采样,Phase 2 主用);画图形用 p(居中保比例,Phase 1 主用)。很多效果两套同时用,别混。

5调试:没有 Log 怎么办

Shader 里没有 println,没有断点,百万个实例同时跑也没法「单步」。听起来绝望,但 Shader 调试有自己的三板斧,而且非常趁手:

  • 把数值画成颜色。想看变量 d 的值?fragColor = vec4(vec3(d), 1.0);——黑色是 0,白色是 1,负数会被钳到黑。想看正负,用 vec3(max(d,0.0), max(-d,0.0), 0.0):正值发红,负值发绿。
  • 分段验收。效果 = 一串坐标变换 + 一次上色。从第一步开始,每加一步就输出一次中间结果,确认无误再叠下一步。比写完 30 行再瞪眼强得多。
  • 让参数动起来。拿不准某个常数该多大,就先写成 0.5 + 0.5 * sin(uTime) 让它自己扫一遍范围,看到最顺眼的画面再定格——比反复改数字重编译快十倍。
Android 视角 工作流

正因为 Shader 调试靠「看」,永远不要在 Android Studio 里直接迭代 Shader 代码——改一行等一次构建,一晚上磨不出一个效果。正确工作流:在本书 Demo 或 Shadertoy 里把效果调到满意,再整段搬进 App(3.5 章演示完整移植)。GLSL 300 es 保证两边行为一致。

本章小结

  • 最小 Shader 五要素:#version 300 es(必须第一行)、精度声明(FS 必须)、out vec4 输出、main()、颜色分量 0.0~1.0。
  • 浮点字面量必须带小数点:1.0 不是 1
  • gl_FragCoord.xy 是像素坐标,原点左下、y 向上——与 Android 的 y 向下相反,颠倒先查它。
  • 两套标准坐标:图像用归一化 uv = gl_FragCoord.xy / uResolution;图形用居中保比例 p = (gl_FragCoord.xy - 0.5*uResolution) / uResolution.y
  • 调试三板斧:数值画成颜色、分段验收、用 uTime 扫参数。

动手练习

  1. 在「归一化 UV」Demo 里,把输出改成 vec4(uv.y, uv.y, uv.y, 1.0),做出一张从下到上的黑白渐变;再试试 1.0 - uv.y 翻转它。
  2. 在画圆的 Demo 里,把圆心挪到画面右上方(提示:对 p 减去一个 vec2),并把呼吸速度放慢一半。
  3. 用本章知识画一面「四色旗」:画布均分为左上、右上、左下、右下四块,各涂一色(提示:两个 step 加乘法就够了,不许用 if)。