片元着色器:让每个像素同时开工
Fragment Shaders & GLSL Basics
换一种画画的方式
平时用程序画画,像手抄一本书:先画一个圆,再画一个矩形,再画一条线,一步接一步。
片元着色器不是这样工作的。它更像一台印刷机:同一段代码在屏幕的每个像素上同时运行。你写的不是「先画什么、后画什么」,而是一个函数——输入「我在屏幕上的哪个位置」,输出「我该是什么颜色」。《着色器之书》(The Book of Shaders) 把它比作图形领域的古登堡印刷术:从手机滤镜到 3D 游戏,处处都有它。
为什么非得这么干:数一数像素
一帧画面要把每一个像素都算一遍,而且一秒要算几十帧。书里算过一笔账:
| 分辨率 | 帧率 | 每秒要算的像素 |
|---|---|---|
| 800 × 600 | 30 fps | 约 1440 万 |
| 2880 × 1800(视网膜屏) | 60 fps | 约 3.11 亿 |
CPU 像几根很粗的管道,任务得排着队一个个过;普通电脑上这样的「线程」往往只有几个。GPU 则是成千上万根细管子,每个像素一个小任务,一起往下流。三角函数、矩阵运算这些常用数学,GPU 还有硬件加速,不必全靠软件一步步算。
第一个着色器:只做一件事
最小的片元着色器只有一个 main 函数,最后把颜色交给保留变量 gl_FragColor。颜色是 vec4,四个分量对应 RGBA,每个分量都归一化在 0 到 1 之间。
也可以直接改右边的代码:停手约 0.3 秒会自动重新运行;改坏了点「还原代码」。
这几行里藏着几个之后天天要用的东西:
- 类型:
float、int、bool,以及vec2、vec3、vec4这些向量。 - 预处理:
#ifdef GL_ES只在手机和浏览器这类 OpenGL ES 环境里成立,这时要声明浮点精度:precision mediump float;。可选lowp、mediump、highp,精度越低通常越快,画质也越差。 - 构造:向量可以拆开拼,比如
vec4(vec3(1.0, 0.0, 1.0), 1.0)。也可以写一个返回vec4的辅助函数,再在main里调用。
gl_FragColor 是旧式 GLSL 的写法。现代的核心配置 (core profile) 改成自己声明一个 out vec4 变量来输出颜色,道理一样。
uniform:CPU 发给所有像素的同一份广播
成千上万个线程都需要从 CPU 拿一些相同的输入,比如现在几点了、画布有多大。这类输入叫 uniform:对所有线程都一样,而且只读。它可以是 float、vec2~vec4、mat2~mat4,也可以是纹理 sampler2D、samplerCube。
书里约定 uniform 名字前加 u_,最常用的是这三个。ShaderToy 上同样的东西换了名字,类型也不一样,移植代码时要留意:
| 含义 | 本书写法 | ShaderToy 写法 |
|---|---|---|
| 画布宽高(像素) | vec2 u_resolution | vec3 iResolution |
| 鼠标位置(像素) | vec2 u_mouse | vec4 iMouse |
| 启动以来的秒数 | float u_time | float iTime |
本站的编辑器两种写法都认。先让画面随时间动起来:
也可以直接改右边的代码:停手约 0.3 秒会自动重新运行;改坏了点「还原代码」。
gl_FragCoord:每个像素唯一不同的输入
uniform 人人一样,那像素之间靠什么区分?靠 gl_FragCoord:当前这个线程负责的像素,在屏幕上的坐标。它每个线程都不一样,所以不是 uniform;书里把它叫作 varying。
它的单位是像素,画布换个尺寸数值就全变了。通常先除以画布尺寸,把坐标归一化到 0~1:
vec2 st = gl_FragCoord.xy / u_resolution.xy;
书里接着出了一道题:(0,0)、(1,0)、(0,1)、(0.5,0.5)、(1,1) 分别在画布的哪里?把 st.x 放进红色通道、st.y 放进绿色通道,答案就直接画出来了:
下面就是这段代码。另外,u_mouse 也是像素单位,同样除以 u_resolution 就能和 st 放在一起比较。把鼠标移进画布,白点会跟着你走:
也可以直接改右边的代码:停手约 0.3 秒会自动重新运行;改坏了点「还原代码」。
没有 print,就用颜色调试
着色器几乎没有调试手段:没有断点,也打印不出变量。最常用的办法是把想检查的值涂成显眼的颜色,看画面去猜它是多少。编译失败时,很多编辑器只会显示一片白屏。本站的编辑器会把出错的那一行标出来,方便不少。
下面这个小工具把一个值分成三种情况来显示:落在 0~1 之间显示成灰度,超过 1 显示成粉色,跌破 0 显示成蓝色。
也可以直接改右边的代码:停手约 0.3 秒会自动重新运行;改坏了点「还原代码」。
再往前一步:插值从哪来
前面一直在画全屏的片元,每个像素只有一个位置可用。在完整的光栅化管线里,片元着色器还有别的输入:顶点着色器用 out 输出值,片元着色器用同名、同类型的 in 接收,程序链接时这两端会被接上。
三角形只有三个顶点,覆盖到的片元却多得多。中间那些片元的输入从哪来?光栅器按每个片元在三角形里的位置,把三个顶点的值插值出来。LearnOpenGL 里有个经典例子:三个顶点分别是红、绿、蓝,插出来就是整面的渐变。下面用一个片元着色器模拟这件事:
也可以直接改右边的代码:停手约 0.3 秒会自动重新运行;改坏了点「还原代码」。
在游戏里怎么用
取舍与常见的坑
小结与练习
- 片元着色器是一个「位置 → 颜色」的函数,在每个像素上并行执行;线程彼此看不见,也不记得上一帧。
- 输出写进
gl_FragColor,所有像素共享的输入用 uniform,像素自己的位置用gl_FragCoord。 - 先把坐标除以
u_resolution归一化到 0~1,后面所有课程都从这一步开始。
练习:在「归一化坐标」那段代码里,用 u_time 让背景的蓝色通道随时间缓慢变化;再试着让白点的大小也随着 sin(u_time) 一呼一吸。做完就可以进入下一课塑形函数了。
片元着色器(fragment shader)是在每个像素(片元)上并行执行的小程序:输入位置等信息,输出颜色。GLSL 是 OpenGL / OpenGL ES / WebGL 共用的着色语言;本书用全屏 billboard 上的像素着色练习,建立程序化图形的基础心智模型。
串行绘画 vs 并行压印
传统用计算机画画像手写书:先画圆、再画矩形、再画线,一步接一步。片元着色器像印刷机:同一套指令在屏幕上每个像素同时执行。代码必须按「像素在屏幕上的位置」表现不同结果——函数接收位置、返回颜色,编译后极快。
《着色器之书》(The Book of Shaders) 把片元着色器比作图形领域的「古登堡印刷术」:对屏幕像素的完全控制 + 极高速度,从手机滤镜到 3D 游戏都在用。
为何快:并行与硬件数学
CPU 像少量粗管道,任务必须排队串行;现代消费级机器常只有少数几个这样的「线程」。图形要把每个像素都算一遍:旧分辨率 800×600、30 fps 约 1440 万次/秒;视网膜级 2880×1800、60 fps 约 3.11 亿次/秒,串行 CPU 吃不消。
GPU 用大量微小处理器并行处理「每个像素一小任务」。另外,三角函数与矩阵等运算有硬件加速,不必全走软件。
与本库 GPU 并行架构 的关系:BoS 用「管道桌 + 乒乓球」比喻讲清「大量简单像素任务」;RTR4/GEA 侧更细的是 warp、占用率、分歧等。
GLSL 与约束(盲、无记忆)
GLSL(OpenGL Shading Language)由 Khronos 规范。本书聚焦 GLSL 像素/片元着色器,不是完整的 OpenGL/WebGL 教程,也不系统讲线性代数。
并行带来硬约束:
- 线程彼此不可见:不能读别的线程结果、不能改输入、不能把一线程输出喂给另一线程(否则破坏数据完整性)。
- 无跨帧记忆:线程刚空闲就接下一批工作,可能上一刻画 UI、下一刻画游戏天空;不能依赖「上一时刻自己在干什么」。
因此入门难:既要写出「随位置变化」的通式,又要接受盲与无记忆。管线里跨 pass 用纹理/缓冲通信是后续图像处理与仿真章节的出路(见 图像处理与后处理)。
Hello World:结构、精度、禁止隐式转型
最小程序只有一个 main,最后把颜色赋给保留变量 gl_FragColor(旧式 GLSL;现代核心配置文件改用 out 变量,概念相同)。
要点(均来自原文练习与说明):
- 类型:
vec4/vec3/vec2、float、int、bool。vec4四分量对应 RGBA,值 归一化 到[0,1]。 - 预处理器:
#define、#ifdef/#endif。#ifdef GL_ES时常见于移动端与浏览器,再写precision mediump float;(也可lowp/highp)。精度越低通常越快、质量越差。 - 规范不保证自动类型转换:
vec4期望浮点字面量;写vec4(1,0,0,1)可能在部分卡上失败。习惯给浮点加小数点:1.0。 - 可把构造拆开:
vec4(vec3(1.0,0.0,1.0), 1.0);也可写返回vec4的辅助函数再在main里调用。
调试资源极少:常靠给变量赋醒目颜色「猜」含义;编译失败时常表现为白屏。
Uniform:CPU→GPU 的只读广播
大量并行线程需要同一份来自 CPU 的输入。这类输入叫 uniform:对所有线程相同、只读。类型涵盖 float、vec*、mat*、sampler2D、samplerCube 等,写在精度声明之后。
本书约定(名字前加 u_):
u_resolution:画布宽高(像素)u_mouse:鼠标在画布内的像素坐标u_time:自着色器启动起的秒数
ShaderToy 同类量常叫 iResolution(vec3)、iMouse(vec4)、iTime(float)——类型与 BoS 的 vec2/float 约定不同。可用 u_time 与 sin 等做通道动画。
GPU 还提供硬件加速的数学函数,例如:sin/cos/tan 及反函数、pow/exp/log/sqrt、abs/sign、floor/ceil/fract、mod、min/max/clamp 等(后续塑形函数章节大量用到)。
gl_FragCoord:每线程不同的 varying
默认输出是 gl_FragColor;默认输入是 gl_FragCoord(当前线程负责的片元屏幕坐标)。它不是 uniform——每线程不同——书中称之为 varying(与顶点→片元插值属性的历史用语一致;在纯全屏片元练习里它就是「像素位置」)。
常用写法:vec2 st = gl_FragCoord.xy / u_resolution.xy; 把坐标归一化到 [0,1],再映到颜色通道。练习题要求弄清 (0,0)、(1,0)、(0,1)、(0.5,0.5)、(1,1) 各在画布何处,以及如何把像素单位的 u_mouse 用起来。
运行与集成环境(工具链摘要)
作者配套工具(跨 Linux/macOS/Windows/树莓派与浏览器,尽量不改着色器源码):
- 显示:glslCanvas(
<canvas class="glslCanvas" data-fragment-url=...>) - 命令行:glslViewer(可进 bash 管道,树莓派 / openFrame.io 亦用)
- 编辑:glslEditor(书内嵌与 editor.thebookofshaders.com)
- 策展:glslGallery
在常用框架里挂同一套 u_resolution / u_time / u_mouse:
- Three.js:全屏
PlaneBufferGeometry(2,2)+ShaderMaterial,用Clock累加u_time,resize 时写u_resolution。 - Processing:
loadShader+ 每帧shader.set(...),再rect铺满;旧于 2.1 需#define PROCESSING_COLOR_SHADER。 - openFrameworks:
ofShader::load/setUniform*;也可用ofxShader对齐 glslViewer/glslCanvas 的 uniform 约定与多缓冲等。
在游戏开发中的意义
- 全屏片元 pass 是后处理、UI 特效、2D 程序化背景的共同骨架。
- uniform 命名与「分辨率归一化 + 时间」是跨引擎可移植的最小契约。
- 「线程盲、无记忆」解释了为何单 pass 内不能直接读邻居像素,必须 ping-pong 纹理或算子分离——见后处理与仿真。
与本库已有页面的分工
- 管线阶段与 API 演进:可编程着色器与 GPU 管线
- 光栅化后的片元阶段、MRT、2×2 quad:像素着色器
- 并行硬件细节:GPU 并行架构
- 本页负责 BoS 的「从零写 GLSL 片元」路径:语言表象、uniform/fragCoord、运行环境。
UE5 路径(HLSL,不是 GLSL)
本页练的是 GLSL 片元心智模型。在虚幻里美术主路径是材质节点图(幕后译成 HLSL,见 材质图到 HLSL 的翻译);要手写着色代码时用 Custom 材质表达式(HLSL、禁止常量折叠)。总览见 材质系统。
LearnOpenGL 的视角:OpenGL 核心配置里的 GLSL 接口(Getting Started / Shaders)
来源:Joey de Vries《LearnOpenGL》Getting Started — Shaders;摘录 Getting Started — Shaders。与上文 BoS 全屏片元练习互补:BoS 讲「每个像素跑同一套代码」;本节讲 顶点→片元管线 里着色器如何声明输入输出、如何从 CPU 喂数据。
着色器是隔离的输入→输出程序
着色器在 GPU 上按管线阶段运行,彼此不能直接通信,只能靠同名同类型的 in/out 在链接程序时对接。典型结构:#version → in/out/uniform → main。
类型、向量与 swizzle
基础类型含 int/float/double/uint/bool;容器以 vecn/bvecn/ivecn/uvecn/dvecn 为主。分量可用 .xyzw、.rgba、.stpq 访问,并支持 swizzle(如 someVec.xyxx)。构造时可把较短向量拆进较长向量:vec4(vect, 0.0, 0.0)。
与 着色器中的颜色(BoS)重叠处:向量即颜色、swizzle 习惯——不重复展开,此处只强调它们同样用在顶点属性与光照中间量上。
in/out、layout location 与片元插值
- 顶点着色器的输入叫顶点属性;OpenGL 保证至少 16 个四分量属性。可用
layout (location = 0)固定槽位,也可事后glGetAttribLocation。 - 片元着色器通常要输出
vec4颜色;未写则颜色缓冲未定义。 - 发送端
out与接收端in同名同类型时,链接程序会把它们接上。光栅化产生的片元远多于顶点,光栅器按片元在三角形上的位置对所有片元着色器输入做插值(书中 RGB 三角例:三顶点色插出整面渐变)。
Uniform:全局、跨阶段、保持到被改写
uniform 对整个着色器程序全局可见,值保持到重置或更新。glGetUniformLocation 查位置;更新前必须 glUseProgram。OpenGL 按类型后缀提供 glUniform*(f/i/3f/fv 等)。未使用的 uniform 可能被编译器静默剔除,导致 location 为 -1。
与 BoS 的 u_time/u_resolution 同一机制;LearnOpenGL 示例用 glfwGetTime + sin 驱动 ourColor。
多属性交错与 Shader 类
把位置与颜色交错进 VBO 后,用 stride/offset 配置 glVertexAttribPointer。书末把读文件、编译、链接着色器、查错、use/setFloat 等封装成 Shader 头文件类——对应引擎里材质/着色器对象的最小形态,详见 可编程着色器与 GPU 管线。