The Book of Shaders 附录导读:写给 JavaScript 开发者的 GLSL 入门指南
The Book of Shaders 附录导读:写给 JavaScript 开发者的 GLSL 入门指南
导读
本文基于 appendix/04 章节内容,为从 JavaScript 转向着色器编程的开发者撰写一份 GLSL 入门指南。全文围绕"强类型系统、向量与 swizzle、运算符/构造函数重载、数组与 struct、控制流、限定符、坐标系"七个核心主题展开,并结合本仓库 02、03、05、09 等章节的真实 .frag 示例源码逐点印证。读完本文,你将能够理解并读懂本书后续章节的所有着色器示例,并具备从零编写第一个片段着色器的语法基础。
一、为什么 JS 开发者读这本书会感到困惑?
如果你是一名 JavaScript 开发者,翻开本书时很可能会一脸茫然:你习惯了在浏览器里随手扔出一堆无类型变量、动态增删对象成员、刷新页面看效果的高效原型开发节奏;而着色器编程完全是另一套世界。两者虽然都运行在浏览器中、都用于在屏幕上绘制图像,但底层模型截然不同。
1. 解释型语言 vs 编译型语言
两者的根本差异在于:JavaScript 是解释型(interpreted)语言,GLSL 是编译型(compiled)语言。
- 解释型程序:需要依赖虚拟机(VM)执行,例如浏览器中的 JavaScript 引擎。由于引擎对每个变量的类型、每个函数的作用"一无所知"(唯一的例外是 TypedArrays 类型化数组),它无法提前做任何优化,只能在运行时不断"推断"(infer)变量与方法类型,再把部分代码转为执行更快的汇编代码。这是一个缓慢、痛苦且极其复杂的过程,更糟的是浏览器优化 JS 的方式各不相同,且整个过程对程序员隐藏——你无力干预。
- 编译型程序:不需要逐行解释,由操作系统直接运行,前提是程序本身必须正确。比如你在行尾漏写了一个分号,代码就是非法的,整个程序根本不会编译通过,更不会变成可执行程序。
这听起来很冷酷,但**着色器(shader)**正是这样一种东西:一段在 GPU 上执行的编译型程序。不过别担心——编译器(负责检查你代码合法性的程序)会成为你的好朋友。本书的配套在线编辑器会精确指出你的程序在哪一行、因为什么原因编译失败;一旦修复完毕、着色器通过编译,运行结果会立刻显示出来。这种"所见即所得"的学习方式非常直观,而且你几乎不可能搞坏任何东西。
2. 一个着色器 = 顶点着色器 + 片段着色器
着色器由两个程序组成:顶点着色器(vertex shader)与片段着色器(fragment shader):
- 顶点着色器(第一个程序):接收几何体作为输入,将其转换为一系列像素(或片段),交给第二个程序;
- 片段着色器(第二个程序):决定每个像素该涂上什么颜色。
本书几乎完全聚焦于片段着色器。在所有示例中,几何体都是一个覆盖整个屏幕的简单四边形。例如仓库中最基础的示例 02/hello_world.frag 只做一件事——把整个屏幕涂成品红色:
#ifdef GL_ES
precision mediump float;
#endif
uniform float u_time;
void main() {
gl_FragColor = vec4(1.0,0.0,1.0,1.0);
}
3. 运行环境速览
如果你想在浏览器之外的环境快速验证本文学到的语法,可以参考 04/README.md 中介绍的工具链:网页端用 glslCanvas(一个 canvas 元素加上 class="glslCanvas" 和 data-fragment-url 即可加载着色器),命令行可用 glslViewer,在线编写调试则用 glslEditor。它们共享同一套 uniform 约定(u_resolution、u_time、u_mouse),与本文下文介绍的限定符一一对应。
插图来自原文档:从 JS 的无类型世界进入 GLSL 的强类型世界,是第一个也是最难的思维转变。
二、强类型(Strong types):var 不存在了
从 JS 或任何无类型语言过来的人,给变量标注类型是个完全陌生的概念,而这是迈向 GLSL 最艰难的一步。
类型化(typing)意味着你要给每个变量(以及函数)都声明一个类型。这基本上宣告了关键字 var 的死刑——GLSL 的"思想警察"已经把它从通用语中抹去了。你只能 显式声明每个变量的类型,这样编译器看到的就都是它知道如何高效处理的对象与基本类型。
这看起来吓人,但细想其实和你写 JS 时的直觉差别不大:布尔变量只会存 true/false;var uid = XXX 大概率存整数;var y = YYY 可能引用浮点数。有了强类型,你再也不用纠结 X == Y 到底是"值相等"还是"类型相等"——你知道它是什么,即使你不知道,编译器也确切知道。
1. GLSL 标量类型与声明对照
**标量(scalar)**描述的是"数量"。GLSL 中的标量类型有:
bool:布尔类型;int:整数类型;float:浮点数类型。
与 JS 的声明方式对比如下:
// 布尔值
JS: var b = true; GLSL: bool b = true;
// 整数值
JS: var i = 1; GLSL: int i = 1;
// 浮点数值
JS: var f = 3.14159; GLSL: float f = 3.14159;
不难吧?正如上文所说,这种做法反而让编码更省心,因为你不用花时间追踪某个变量的类型。记住,这一切都是为了让你写的程序比 JS 快上几个数量级。
2. void:近似于 null
GLSL 存在 void 类型,大致对应 null。它只用作"不返回任何值"的方法的返回类型,不能声明成变量。本书几乎所有示例的入口函数都是 void main()(见上文 02/hello_world.frag)。
3. boolean 与条件分支的"陷阱"
布尔值主要用于条件测试:if( myBoolean == true ){}else{}。条件分支在 CPU 上是完全可行的选项,但 GLSL 的并行本质让这条结论打了折扣——GPU 上成千上万的线程同时执行,分支会让它们分道扬镳、严重拖慢整体性能。因此,多数情况下不推荐使用条件分支,本书后续章节介绍了几种绕过这一限制的替代技法(例如用 step()、smoothstep() 这类内置函数做"数学化的分支",见 05/step.frag)。
4. 类型转换(type casting):不能随便把猫和长颈鹿杂交
和 JavaScript 不同,GLSL 不允许在不同类型的变量之间直接做运算。例如下面这段代码就无法编译:
int i = 2;
float f = 3.14159;
// 尝试把整数和浮点数相乘
float r = i * f;
因为你试图把一只猫和一只长颈鹿杂交。解决办法是类型转换(type casting)——它让编译器相信 i 是 float 类型,但并不会真正改变 i 的类型:
// 把整数变量 'i' 转换为 float 类型
float r = float( i ) * f;
这就像给猫披上长颈鹿的外衣,结果符合预期:r 中保存了 i 与 f 的乘积。
上述标量类型之间可以任意互相转换。需要注意的是转换规则:
float→int:行为类似Math.floor(),直接去掉小数点右侧的数字;float或int→bool:变量不等于零时返回true。
5. 构造函数(constructor):类型即类
变量类型同时也是它们自身的类构造函数。事实上,可以把 float 变量想象成 Float 类的一个实例。以下声明完全等价:
int i = 1;
int i = int( 1 );
int i = int( 1.9995 );
int i = int( true );
对标量类型来说,这看起来和类型转换没什么区别、略显琐碎,但当你读到后文"重载"一节时,它的威力就会显现。
三、向量(Vectors):着色器的肌肉
就像 JS 需要更高级的数据组织方式一样,GLSL 内置了**向量(vector)**结构。假如你在 JS 中写过 Point 类来同时保存 x 和 y:
// '类'定义:
var Point = function( x, y ){
this.x = x || 0;
this.y = y || 0;
}
// 实例化:
var p = new Point( 100,100 );
正如我们刚才所见,这段代码在太多层面上都错得离谱:那个 var 关键字、可怕的 this、以及无类型的 x/y……这些在着色器世界里统统行不通。
1. GLSL 内置向量类型
GLSL 为每个基本类型都提供了对应的向量版本:
bvec2:2D 布尔向量,bvec3:3D 布尔向量,bvec4:4D 布尔向量;ivec2:2D 整数向量,ivec3:3D 整数向量,ivec4:4D 整数向量;vec2:2D 浮点向量,vec3:3D 浮点向量,vec4:4D 浮点向量。
由此可以推断:bvec2 存两个 bool,vec4 存四个 float。
2. 维度 = 组件数量,不是"几维图形"
向量引入了一个叫维度的概念。但这不意味着画 2D 图形用 2D 向量、画 3D 图形用 3D 向量——那 4D 向量又该代表什么?(好吧,数学上它叫"超立方体"。)维度指的是向量中存储的组件(component)/变量数量:
// 声明一个二维布尔向量
bvec2 b2 = bvec2 ( true, false );
// 声明一个三维整数向量
ivec3 i3 = ivec3( 0,0,1 );
// 声明一个四维浮点向量
vec4 v4 = vec4( 0.0, 1.0, 2.0, 1. );
b2 存两个布尔值,i3 存三个整数,v4 存四个浮点数。标量取值很直白(float f = 1.2; 中 f 就是 1.2),向量的取值方式则别有一番美感。
3. 访问器(accessors):四种取法
声明一个四维浮点向量后,有四种等价方式取出它的值:
// 声明四维浮点向量
vec4 v4 = vec4( 0.0, 1.0, 2.0, 3.0 );
float x = v4.x; // x = 0.0
float y = v4.y; // y = 1.0
float z = v4.z; // z = 2.0
float w = v4.w; // w = 3.0
下面这些写法完全等价:
float x = v4.x = v4.r = v4.s = v4[0]; // x = 0.0
float y = v4.y = v4.g = v4.t = v4[1]; // y = 1.0
float z = v4.z = v4.b = v4.p = v4[2]; // z = 2.0
float w = v4.w = v4.a = v4.q = v4[3]; // w = 3.0
细心的读者会发现三个事实:
X、Y、Z、W:常用于 3D 程序中表示空间向量;R、G、B、A:用于编码颜色与透明度;[0]、[1]、[2]、[3]:说明向量本质是支持随机访问的数组。
所以你可以根据场景选择最合适的类型与维度:二维/三维坐标通常存为 vec2/vec3/vec4,颜色存为 vec3/vec4。当然这并无硬性限制——比如把单个布尔值塞进 bvec4 也完全合法,只是白白浪费内存。
重要注意事项:在着色器中,颜色值(R、G、B、A)是归一化的,范围是 0 到 1 而不是 0 到 0xFF(255),所以存颜色请用浮点 vec4 而不是整数 ivec4。
4. 混合(swizzle):优雅的组件重组
一次可以取出向量的多个值。假设你需要一个 vec4 的 X 和 Y 分量,JS 写法大概是这样的:
var needles = [0, 1]; // 在数据结构中标记 'x' 和 'y' 的位置
var a = [ 0,1,2,3 ]; // 我们的 'vec4' 数据结构
var b = a.filter( function( val, i, array ) {
return needles.indexOf( array.indexOf( val ) ) != -1;
});
// b = [ 0, 1 ]
丑陋至极。GLSL 中一行搞定:
// 创建四维浮点向量
vec4 v4 = vec4( 0.0, 1.0, 2.0, 3.0 );
// 只取出 X 和 Y 分量
vec2 xy = v4.xy; // xy = vec2( 0.0, 1.0 );
发生了什么?当你串联访问器时,GLSL 会优雅地以最合适的向量类型返回你要的子集。这之所以可行,是因为向量本身就是随机访问数据结构——就像 JS 里的数组。因此你不仅能取子集,还能指定顺序,例如把向量的分量顺序颠倒:
// 创建四分量向量:R,G,B,A
vec4 color = vec4( 0.2, 0.8, 0.0, 1.0 );
// 按 A,B,G,R 的顺序取出颜色分量
vec4 backwards = color.abgr; // backwards = vec4( 1.0, 0.0, 0.8, 0.2 );
甚至可以重复取同一个分量:
// 创建四分量向量:R,G,B,A
vec4 color = vec4( 0.2, 0.8, 0.0, 1.0 );
// 基于原颜色的 G 和 A 通道取出一个 GAG 形式的 vec3
vec3 GAG = color.gag; // GAG = vec3( 0.8, 1.0, 0.8 );
swizzle 在仓库示例中随处可见:例如 00/cmyk-halftone.frag 用 c.rgb/k 取出颜色三分量做 CMYK 分色;06/gradient.frag 用 st.x 驱动 mix(colorA, colorB, pct) 实现渐变;09/truchet.frag 则用 gl_FragCoord.xy/u_resolution.xy 把像素坐标归一化到 0~1 区间——这是本书最频繁出现的向量操作模式。
插图来自原文档:向量把多个值打包在一起,并通过 swizzle 自由重组。
5. 重载(overload)一切!
在类型一节提到的构造函数引出了 GLSL 又一大特性——重载(overloading)。重载运算符或函数,指的是根据操作数/参数的不同改变该运算符或函数的行为。JS 不允许重载,所以一开始你会觉得怪异;但用顺手之后,你甚至会疑惑为什么 JS 不实现它(简短回答:因为类型化)。
最基础的运算符重载:
vec2 a = vec2( 1.0, 1.0 );
vec2 b = vec2( 1.0, 1.0 );
// 重载的加法
vec2 c = a + b; // c = vec2( 2.0, 2.0 );
什么?非数字的东西也能相加?!没错。当然,这也适用于所有运算符(+、-、*、/),而且这只是开始。再比如构造函数重载:
vec2 a = vec2( 0.0, 0.0 );
vec2 b = vec2( 1.0, 1.0 );
// 重载的构造函数
vec4 c = vec4( a , b ); // c = vec4( 0.0, 0.0, 1.0, 1.0 );
我们用两个 vec2 拼出了一个 vec4:新向量 c 把 a.x、a.y 作为自己的 X、Y 分量,把 b.x、b.y 作为 Z、W 分量。
这就是函数针对不同参数集被重载的情形——本例中是 vec4 构造函数。它意味着同一方法的不同"版本"可以共存于一个程序中,例如下面这些声明全部合法:
vec4 a = vec4(1.0, 1.0, 1.0, 1.0);
vec4 a = vec4(1.0); // x, y, z, w 全部等于 1.0
vec4 a = vec4( v2, float, v4 ); // vec4( v2.x, v2.y, float, v4.x );
vec4 a = vec4( v3, float ); // vec4( v3.x, v3.y, v3.z, float );
// 等等
你只需要保证提供的参数数量足够填满这个向量。最后,你甚至可以为内置函数重载出它们原本没设计的参数类型(不过这种事情还是少做为妙)。
6. 需要更多类型:数组与 struct
向量很酷,它们是着色器的肌肉。此外还有矩阵(matrix)和纹理采样器(texture sampler)等类型,将在本书后续章节(如 08 的旋转矩阵示例)详细展开。
数组。GLSL 当然也有数组,但它们是类型化的,且与 JS 数组有几处差异:
- 大小固定;
- 不能用
push()、pop()、splice(),也没有length属性; - 声明时不能立即用值初始化;
- 必须逐个赋值。
这样是不行的:
int values[3] = [0,0,0];
这样才行:
int values[3];
values[0] = 0;
values[1] = 0;
values[2] = 0;
当你数据已知或数组较小时这够用了。如果想要更具表达力的声明方式,还有 struct 类型——它像没有方法的对象,可以在同一个对象里存储并访问多个变量:
struct ColorStruct {
vec3 color0;
vec3 color1;
vec3 color2;
}
随后你可以这样初始化并读取:
// 用一些值初始化结构体
ColorStruct sandy = ColorStruct( vec3(0.92,0.83,0.60),
vec3(1.,0.94,0.69),
vec3(0.95,0.86,0.69) );
// 从结构体取值
sandy.color0 // vec3(0.92,0.83,0.60)
这是语法糖,但能帮你写出更干净、至少是更熟悉的代码。
四、语句与条件:语法与 JS 惊人地相似
数据结构固然好用,但早晚你可能需要迭代数组或做条件判断。幸运的是,这里的语法和 JavaScript 非常接近。
条件判断:
if( condition ){
//true
}else{
//false
}
for 循环的常规写法:
const int count = 10;
for( int i = 0; i <= count; i++){
//do something
}
或使用浮点迭代器:
const float count = 10.;
for( float i = 0.0; i <= count; i+= 1.0 ){
//do something
}
注意:count 必须声明为常量——即在类型前面加上 const 限定符,这在下文"限定符"一节会讲到。
我们还拥有 break 和 continue 语句:
const float count = 10.;
for( float i = 0.0; i <= count; i+= 1.0 ){
if( i < 5. )continue;
if( i >= 8. )break;
}
注意:在部分硬件上,break 的行为可能不符合预期,循环并不会提前跳出。总体原则是:尽量把迭代次数压到最低,并且尽可能回避循环与分支——这是 GPU 并行架构下的性能铁律。仓库示例对此贯彻得十分彻底:例如 09/truchet.frag 虽然用 if/else if 判断格子索引来旋转图案,但依然把每个单元的迭代控制在小规模内;11/1d-noise.frag 则完全用 floor、fract、mix 等纯函数替代循环,实现了 1D 值噪声。
五、限定符(qualifiers):告诉编译器变量的使命
除变量类型外,GLSL 还使用限定符(qualifiers)。简而言之,限定符帮助编译器弄清楚"哪个变量是干什么用的"。
1. attribute、uniform 与 varying
某些数据只能由 CPU 提供给 GPU,它们被称为 attributes(属性) 和 uniforms(统一变量):
- attributes 只出现在顶点着色器中;
- uniforms 在顶点着色器和片段着色器中都能用;
varying限定符用于在顶点着色器与片段着色器之间传递变量。
本文聚焦片段着色器,不过多展开,但你会在本书中经常看到类似这样的代码:
uniform vec2 u_resolution;
这里发生了什么?我们在变量类型前面加了 uniform 限定符,表示"当前画布的分辨率由 CPU 传给着色器"——2D 向量的 x 分量存宽度,y 分量存高度。当编译器看到带此限定符的变量时,它会确保你无法在运行时改写这些值。
仓库中几乎所有示例都遵循这套 uniform 约定。例如 03/space.frag 一口气声明了三个:
uniform vec2 u_resolution;
uniform vec2 u_mouse;
uniform float u_time;
void main() {
vec2 st = gl_FragCoord.xy/u_resolution;
gl_FragColor = vec4(st.x,st.y,0.0,1.0);
}
注意 st 的构造方式:gl_FragCoord.xy(像素坐标,vec2)除以 u_resolution(画布分辨率,vec2)——这就是向量除法重载与分量访问的完美合体,把像素坐标归一化到 0~1 区间。
2. const:不可变常量
上文循环中的 count 变量同样适用:当我们使用 const 限定符时,编译器会确保变量只被赋值一次,否则它就不是常量了。
const float count = 10.;
for( ... )
3. 函数参数限定符:in、out、inout
还有三个用于函数签名的限定符:in、out 和 inout。在 JavaScript 中,传给函数的参数只读——函数内部改动它,函数外部不受影响:
function banana( a ){
a += 1;
}
var value = 0;
banana( value );
console.log( value );// > 0 ; 函数外部的值没有改变
而借助参数限定符,你可以指定参数的行为:
in:只读(默认行为);out:只写——不能读取该参数的值,但可以给它赋值;inout:读写——既能取值也能赋值。
把上面的 banana 方法改写成 GLSL:
void banana( inout float a ){
a += 1.;
}
float A = 0.;
banana( A ); // 现在 A = 1.;
这与 JS 的行为截然不同,而且相当强大。当然,你并非必须显式写出参数限定符——默认就是只读的。这一约定在仓库示例的函数签名中大量出现,例如 07/rect.frag 的 float rect(in vec2 st, in vec2 size) 明确标注了 in。
六、空间与坐标:Y 轴方向变了
最后一点提醒:在 DOM 和 Canvas 2D 中,我们习惯了 Y 轴朝下——这符合网页的排版方式(导航栏在上、内容向下滚动)。但在 WebGL 画布中,Y 轴被翻转了:Y 朝上。
这意味着:
- 原点
(0,0)位于 WebGL 上下文的左下角,而不是 2D Canvas 的左上角; - 纹理坐标遵循同样规则,这初看颇为反直觉。
理解这一点对后续章节的坐标运算至关重要——几乎所有示例的第一步 vec2 st = gl_FragCoord.xy/u_resolution; 都是在做"坐标系归一化",把像素坐标从"左下角原点、Y 向上"的 WebGL 空间转换到便于数学处理的 0~1 区间。
七、结语:消化它,然后启程
当然,各种概念都还可以深挖下去,但正如开篇所说,这篇文章的初衷是给新手一个大大的拥抱。这里的内容已经足够你消化一阵子了——但只要有耐心并坚持练习,这门语言会变得越来越自然。
希望这份指南对你有用。现在,是时候正式踏上 The Book of Shaders 的旅程了——从 第 02 章 的 Hello World 开始,你会在 03、05、06、09 等章节的源码中反复遇见本文讲到的每一个语法点。祝你着色愉快!

