从 JavaScript 到 GLSL:写给前端开发者的着色器语言快速入门(The Book of Shaders 附录指南)
从 JavaScript 到 GLSL:写给前端开发者的着色器语言快速入门(The Book of Shaders 附录指南)
本文是 The Book of Shaders 附录中的一篇专题指南(由 Nicolas Barradeau 撰写),专门面向已有 JavaScript 经验的开发者,帮助他们跨越从"动态、弱类型"的 JS 到"编译型、强类型"的 GLSL 之间的思维鸿沟。读完本文,你将掌握 GLSL 的标量类型、向量与 swizzle、类型转换、struct、控制流与参数限定符(uniform / in / out / inout)等核心语法,并能读懂本书后续章节中的 fragment shader 示例代码,开始编写自己的着色器。
心态转变:从解释型 JS 到编译型 GLSL
JavaScript 开发者刚翻开这本书时通常会感到困惑:JS 是一种**解释型(interpreted)语言,而 GLSL 是一种编译型(compiled)**语言,两者在运行模型上截然不同。
- 解释型程序需要虚拟机(VM)执行。浏览器里的 JavaScript VM 在执行代码时并不知道每个变量的含义,因此无法预先优化,只能在运行时推断变量类型,并尽可能将部分代码转换为更快的汇编指令。整个过程缓慢、复杂,而且每个浏览器优化 JS 的方式各不相同,这个过程对开发者完全不可见。
- 编译型程序由操作系统直接执行,是底层程序,通常非常快。如果代码有语法错误(例如漏掉一个分号),程序根本不会编译成功,代码也就不会变成可运行的程序。
这正是 shader 的本质:一个编译后交给 GPU 执行的小程序。不过不用害怕,编译器会成为你最可靠的战友——本书中的示例和配套在线编辑器会明确指出你的代码为什么编译失败、应该改哪里,一旦编译通过,结果会立刻显示出来。这是一种非常直观的学习方式,而且不会破坏任何东西。
此外需要建立一个基本认知:一个 shader 程序由两个子程序组成——vertex shader(顶点着色器)和 fragment shader(片元着色器)。顶点着色器接收几何体作为输入,将其转换为一系列像素(pixel,即 fragment),再交给片元着色器决定每个像素的颜色。本书几乎只关注后者,在所有示例中,使用的几何体都是一个覆盖整个屏幕的四边形。
仓库佐证:本书第 1 章 01/README.md 详细解释了为什么 shader 快——GPU 用大量小型处理器并行执行任务,且每个线程之间"盲目"(blind)且"无记忆"(memoryless),这正是后面理解 GLSL 语法约束(例如尽量少用分支)的基础。
强类型:告别 var 关键字
对习惯 JS(或其他弱类型语言)的开发者来说,为每个变量显式声明类型(typing) 是最陌生、也是迈入 GLSL 的最大障碍。这意味着神奇的关键字 var 不复存在,你必须显式指定每个变量的类型,编译器才能高效地处理它们。
其实这并不难:如果变量是 boolean,它只存储 true 或 false;如果变量名是 var uid = XXX,它多半存整数;var y = YYY 可能指向浮点数。有了强类型,你不再需要花时间纠结 X == Y 还是 typeof X == typeof Y,你直接知道答案;就算你不知道,编译器也知道。
GLSL 中有三种标量类型(scalar types):
// 布尔值
JS: var b = true; GLSL: bool b = true;
// 整数
JS: var i = 1; GLSL: int i = 1;
// 浮点数(Number)
JS: var f = 3.14159; GLSL: float f = 3.14159;
void 与 boolean
void大致对应null,用于声明不返回任何值的函数,但不能用它声明变量。boolean主要用于条件判断,如if( myBoolean == true ){}else{}。需要注意的是,条件分支在 CPU 上是常规操作,但在 GPU 的并行架构 下却受到限制,多数情况下使用条件语句是不被鼓励的,本书后续章节会介绍一些替代技术。
类型转换(type casting)
与 JavaScript 不同,GLSL 不允许对不同类型的操作数直接做运算。例如下面这段代码不会得到好结果,因为你在试图"杂交猫和长颈鹿":
int i = 2;
float f = 3.14159;
// 试图用整数乘以浮点数
float r = i * f;
解决方案是类型转换——它让编译器相信 i 是 float 类型,而 i 本身的类型并不改变:
// 将整数变量 'i' 转换为 float
float r = float( i ) * f;
注意两个转换细节:把 float 转成 int 会丢弃小数部分(行为类似 Math.floor());把 float 或 int 转成 bool 时,只要数值不为零就得到 true。
构造函数:类型即构造器
GLSL 中变量的类型同时也是它对应的"类构造函数"——一个 float 变量可以看作 Float 类的一个实例。以下声明全部合法且结果相同:
int i = 1;
int i = int( 1 );
int i = int( 1.9995 );
int i = int( true );
对标量来说这看起来和类型转换差不多,但当你读到后面的 overload(重载) 部分时就会明白它的真正威力。
向量:GLSL 的主力数据结构
像 JavaScript 一样,GLSL 也需要更精巧的方式处理数据,这时 vectors(向量) 就派上用场了。假设你在 JS 中写过 Point 类来同时保存 x 和 y:
// 'class' 定义:
var Point = function( x, y ){
this.x = x || 0;
this.y = y || 0;
}
// 实例化:
var p = new Point( 100,100 );
在 shader 世界里这套写法完全行不通——var 关键字、莫名其妙的 this、未类型化的 x/y……GLSL 直接内置了把数据封装在一起的结构,针对每种标量类型都有对应向量:
bvec2/bvec3/bvec4:2/3/4 维布尔向量ivec2/ivec3/ivec4:2/3/4 维整数向量vec2/vec3/vec4:2/3/4 维浮点向量
维度(dimension) 并不表示"2D 图形用二维向量、3D 图形用三维向量"——它只表示向量里存储的值的个数:
// 创建一个 2 维布尔向量
bvec2 b2 = bvec2 ( true, false );
// 创建一个 3 维整数向量
ivec3 i3 = ivec3( 0,0,1 );
// 创建一个 4 维浮点向量
vec4 v4 = vec4( 0.0, 1.0, 2.0, 1. );
访问器:四种取数方式
向量内部的值有多种访问方式。以 vec4 v4 = vec4( 0.0, 1.0, 2.0, 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用于编码颜色和 alpha 通道;[0]、[1]、[2]、[3]表明向量是一个可随机访问的值数组。
因此,你可以根据场景自由选择:2D/3D 坐标通常存为 vec2/vec3/vec4,颜色(需要透明度时)存为 vec3/vec4,但这并非强制——甚至用 bvec4 只存一个布尔值也合法,只是浪费内存。
重要提示:在 shader 中,颜色通道(R、G、B、A)的值是归一化的,范围在 0 到 1 之间(而不是 0x00 到 0xFF),所以存储颜色最好使用浮点 vec4 而不是整数 ivec4。
仓库中的示例大量使用了这些访问器,例如 00/cmyk-halftone.frag 中的 c.rgb(只取颜色的 RGB 三通道)、u_resolution.xy(取分辨率向量的 x、y 分量),以及 03/space.frag 中的 gl_FragCoord.xy/u_resolution——用坐标访问器 .xy 归一化片元坐标。
swizzle:随心组合与重排
swizzle 允许你一次性取出多个分量。假设你只需要一个 vec4 的 X 和 Y,在 JS 里要写繁琐的过滤代码;而 GLSL 只需:
// 创建 4 维浮点向量
vec4 v4 = vec4( 0.0, 1.0, 2.0, 3.0 );
// 只取出 X 和 Y 分量
vec2 xy = v4.xy; // xy = vec2( 0.0, 1.0 );
当你串联访问器(concatenate accessors) 时,GLSL 会把你要的值以最合适的向量形式返回。向量本质上是一种随机访问数据结构,因此你不仅能取子集,还能指定元素的顺序,甚至可以重复取同一个分量:
// 创建一个 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 );
// 重复取分量:只取 G(两次)和 A
vec3 GAG = color.gag; // GAG = vec3( 0.8, 1.0, 0.8 );
这在拼接向量、从 RGBA 颜色中提取 RGB 通道等场景中极为实用——这正是 00/cmyk-halftone.frag 中 c.rgb*k 这类写法的原理。
overload:运算符与构造函数的"智能适配"
重载(overloading) 指运算符或函数会根据操作数/参数的类型自动改变行为。JavaScript 不允许重载(根本原因是弱类型),但 GLSL 可以。最基本的例子:
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 直接构造 vec4:
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 );
这里 vec4 构造函数被重载为接受两个 vec2:把 a.x、a.y 作为新向量的 X、Y 分量,把 b.x、b.y 作为 Z、W 分量。这意味着同一函数的不同签名版本可以共存于一个程序中,以下声明全部合法:
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 );
唯一要确保的是:为构造函数提供足够的数据来填满向量。此外,你也完全可以重载内置函数以适应新参数(但不要滥用)。
更多类型:数组与 struct
向量之外,矩阵(Matrices)和纹理采样器(sampler)等类型会在本书后面章节介绍。数组也是可用的,但必须声明类型且有几点限制:
- 数组大小固定;
- 不能使用
push()、pop()、splice()等,也没有length属性; - 不能在初始化时批量赋值,只能逐个元素赋值。
下面这样写是错的:
int values[3] = [0,0,0];
必须这样写:
int values[3];
values[0] = 0;
values[1] = 0;
values[2] = 0;
当你需要更富表达力的类型时,还有 struct。它就像没有方法的 object,只负责把多个变量封装在同一个结构里:
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)
这是一种语法糖,能帮助你写出更清晰、更易读的代码。
控制流:语法相近,但要用得克制
好消息是,GLSL 的条件与循环语法和 JavaScript 非常接近:
if( condition ){
//true
}else{
//false
}
常见的 for 循环:
const int count = 10;
for( int i = 0; i <= count; i++){
// 做点什么
}
或者使用浮点迭代器:
const float count = 10.;
for( float i = 0.0; i <= count; i+= 1.0 ){
// 做点什么
}
注意 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 的表现并不一致,循环可能不会提前退出。总体原则是——尽可能减少循环次数,尽量少用循环和条件分支。这是由本书第 1 章 01/README.md 讲过的 GPU 并行架构(线程彼此盲目、无记忆)决定的。
限定符(qualifiers):告诉编译器变量的"身份"
除了类型,GLSL 还用限定符帮助编译器识别每个变量的用途。例如,有些数据只能由 CPU 传给 GPU,称为 attribute 和 uniform:attribute 只用于 vertex shader,uniform 则可用于 vertex 和 fragment shader;varying 用于在顶点着色器与片元着色器之间传递变量。
本书主要关注 fragment shader,因此你会经常见到类似这样的代码:
uniform vec2 u_resolution;
这里我们在变量类型前加了 uniform 限定符,表示 canvas 的分辨率由 CPU 传给 shader:canvas 的宽度存入二维向量的 x 分量,高度存入 y 分量。编译器看到带此限定符的变量,会确保你不能在 shader 内运行时修改它的值。
仓库印证:本书第 3 章 03/README.md 系统介绍了 uniform,本书示例统一使用 u_time(shader 启动以来的秒数)、u_resolution(绘制区域的尺寸)和 u_mouse(鼠标在区域内的像素坐标),而 ShaderToy 使用 iResolution、iMouse、iTime 等不同命名。例如 03/time.frag 中 uniform float u_time; 配合 abs(sin(u_time)) 实现红色通道的动画,03/space.frag 中则用 uniform vec2 u_resolution; 配合 gl_FragCoord.xy/u_resolution 归一化坐标。
const 限定符则保证变量的值只能被初始化一次——就像 for 循环上限那个 count 变量一样,否则它就不配叫常量。
函数签名中还有 3 个常用参数限定符:
in:只读,不改变外部变量的值(所有参数的默认行为);out:只写,你无法读取该参数的值,但可以给它赋新值,且改动在函数外保持;inout:可读可写。
对比 JavaScript:向函数传入标量时,函数内的修改不会影响外部变量:
function banana( a ){
a += 1;
}
var value = 0;
banana( value );
console.log( value );// > 0 ; 函数外的 value 保持不变
而 GLSL 用 inout 就能让修改"穿透"到函数外部:
void banana( inout float a ){
a += 1.;
}
float A = 0.;
banana( A ); // 此时 A = 1.;
这跟 JavaScript 完全不同,但非常强大(当然,默认的只读签名限定符不需要显式写出)。
坐标系:Y 轴方向是反的
最后一个需要牢记的差异:在 DOM 和 Canvas 2D 中,Y 轴向下,这符合网页从上到下展开的直觉;但在 WebGL 的 canvas 中,Y 轴被翻转,向上。
这意味着 WebGL 上下文的原点 (0,0) 位于画布左下角,而不是 2D Canvas 的左上角。纹理坐标也遵循这一规则,初次接触时可能有些反直觉。实际效果可以对照本书示例:在 03/space.frag 中,归一化后的 st.x 映射到红色、st.y 映射到绿色,画出的渐变方向就是基于"Y 向上"的坐标系。
结语:这只是旅程的开始
当然,上面的每个概念都可以深入探讨很多,但这篇附录的定位是给初来乍到的 JavaScript 开发者一个"温暖的拥抱"。信息量不小,但只要耐心练习,一切都会越来越自然。
现在你已经掌握了 GLSL 的类型系统、向量与 swizzle、控制流和限定符,可以回到 README.md 开始正篇学习,或参考 04/README.md 了解如何把 shader 跑在浏览器(glslCanvas)、命令行(glslViewer)、在线编辑器(glslEditor)以及 Three.js、Processing、openFrameworks、SFML 等框架上。附录目录 appendix/README.md 还列出了向量入门(appendix/05)与插值入门(appendix/06)等后续专题,供你继续探索。

