第21章:像素处理与后处理(Pixel Processing & Post-Processing)
写给初级程序员的图形学笔记
本章导读
想象一下,你刚拍了一张照片,打开美图软件,点一下"黑白滤镜"——照片瞬间变成了老电影的感觉。再点一下"模糊"——背景柔化了,人物更突出了。这些操作,在图形学里统称为像素处理(Pixel Processing)或后处理(Post-Processing)。
本章我们要做的事情,就是亲手用代码实现这些"滤镜"。我们会从 CPU 逐像素处理开始,理解每一个像素的来龙去脉;然后过渡到 GPU 着色器,体验什么叫"并行计算的暴力美学"。
本章所有示例均来自 akira-graphics/pixels/ 和 akira-graphics/pixels-shader/ 中的 demo,建议边读边打开对应文件对照学习。
21.1 CPU 像素操作:getImageData / putImageData
生活类比:用放大镜观察报纸上的网点
小时候你可能用放大镜看过报纸——原本平滑的图片,放大后变成了密密麻麻的小黑点。每个小黑点就是一个"像素"。getImageData 就像是你用放大镜把报纸上的每个网点都抄录到笔记本上;putImageData 则是你把修改后的笔记重新印回报纸。
本质是什么
Canvas 2D 上下文提供了两个核心 API:
getImageData(x, y, width, height):从画布上"抓取"一块矩形区域的像素数据putImageData(imageData, x, y):把修改后的像素数据"放回去"
抓取回来的数据是一个 ImageData 对象,它有一个 data 属性——这是一个一维的 Uint8ClampedArray,每 4 个元素代表一个像素:
[data[0], data[1], data[2], data[3]] -> 第1个像素的 [R, G, B, A]
[data[4], data[5], data[6], data[7]] -> 第2个像素的 [R, G, B, A]
...以此类推
每个通道的值范围是 0 ~ 255。Uint8ClampedArray 的意思是:如果你不小心写入了 300,它会被"钳制"(clamp)到 255,不会溢出。
代码示例:基础像素遍历
<!-- 对应 demo: akira-graphics/pixels/index1.html -->
<!DOCTYPE html>
<html>
<body>
<canvas id="paper" width="0" height="0"></canvas>
<script>
function loadImage(src) {
const img = new Image();
img.crossOrigin = 'anonymous'; // 允许跨域读取像素
return new Promise((resolve) => {
img.onload = () => resolve(img);
img.src = src;
});
}
const canvas = document.getElementById('paper');
const context = canvas.getContext('2d');
(async function () {
const img = await loadImage('assets/girl1.jpg');
const { width, height } = img;
// 1. 把图片画到画布上
canvas.width = width;
canvas.height = height;
context.drawImage(img, 0, 0);
// 2. 抓取像素数据
const imgData = context.getImageData(0, 0, width, height);
const data = imgData.data; // 一维数组,长度 = width * height * 4
// 3. 遍历每个像素(步长为4,因为每个像素占4个通道)
for (let i = 0; i < width * height * 4; i += 4) {
const r = data[i]; // 红色通道
const g = data[i + 1]; // 绿色通道
const b = data[i + 2]; // 蓝色通道
const a = data[i + 3]; // Alpha通道
// 这里可以对 r, g, b, a 做任何处理...
// 比如把红色通道翻倍:
// data[i] = Math.min(255, r * 2);
}
// 4. 把修改后的数据写回画布
context.putImageData(imgData, 0, 0);
}());
</script>
</body>
</html>
常见误区
误区1:"我以为 getImageData 返回的是二维数组"
不是。data 是一维数组。像素 (x, y) 对应的索引是:
index = (y * width + x) * 4
r = data[index]
g = data[index + 1]
b = data[index + 2]
a = data[index + 3]
误区2:"修改 data 后画布自动更新"
不会。data 是 ImageData 对象的一个属性,它是数组的引用。你修改数组内容后,必须调用 putImageData 才会真正更新到画布上。
误区3:"跨域图片可以直接读取像素"
必须设置 img.crossOrigin = 'anonymous',且图片服务器要允许 CORS。否则会抛出安全错误。
动手试一试
打开 akira-graphics/pixels/index1.html,尝试把循环中的处理改成:
// 只保留红色通道(赛博朋克风格)
data[i + 1] = 0; // 去掉绿色
data[i + 2] = 0; // 去掉蓝色
看看效果是什么?再试试交换红色和蓝色通道。
21.2 灰度转换:Luminance 公式
生活类比:把彩色照片洗成黑白胶卷
彩色照片变成黑白,不是简单地把 R、G、B 取平均。人眼对不同颜色的敏感度不同:我们对绿色最敏感,对蓝色最不敏感。所以黑白胶卷的化学配方会"偏重"绿色。Luminance(亮度)公式就是模拟人眼这种感知的数学表达。
本质是什么
灰度化的本质是:把三维的 RGB 颜色映射到一维的亮度值。关键是这个映射要符合人眼的感知特性。
实验心理学研究表明,人眼的亮度感知权重大约是:
- 红色(R):约 21.2%
- 绿色(G):约 71.4% (人眼最敏感!)
- 蓝色(B):约 7.4% (人眼最不敏感)
公式推导
第一步:为什么不能简单取平均?
假设像素是 [255, 0, 0](纯红)和 [0, 255, 0](纯绿)。简单平均都是 85,但人眼看纯绿明显比纯红"亮"。所以平均法是错的。
第二步:加权平均
Y = 0.212 * R + 0.714 * G + 0.074 * B
这里的系数加起来等于 1:
0.212 + 0.714 + 0.074 = 1.0
这样当 R=G=B=255 时,Y=255,是纯白;当 R=G=B=0 时,Y=0,是纯黑。
第三步:应用到像素
const v = 0.212 * r + 0.714 * g + 0.074 * b;
data[i] = v; // R = 亮度
data[i + 1] = v; // G = 亮度
data[i + 2] = v; // B = 亮度
// Alpha 不变
data[i + 3] = a;
注意:不同标准(ITU-R BT.601、BT.709)给出的系数略有差异。BT.709(高清电视标准)使用
0.2126, 0.7152, 0.0722,和 demo 中的0.212, 0.714, 0.074非常接近。
代码示例
<!-- 对应 demo: akira-graphics/pixels/index1.html -->
<script>
// ... 前面的加载和 getImageData 代码相同 ...
for (let i = 0; i < width * height * 4; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
// 计算亮度(luminance)
const v = 0.212 * r + 0.714 * g + 0.074 * b;
// 三个通道都赋值为亮度,得到灰度
data[i] = v;
data[i + 1] = v;
data[i + 2] = v;
data[i + 3] = a; // 保持透明度不变
}
context.putImageData(imgData, 0, 0);
</script>
常见误区
误区:"灰度 = (R + G + B) / 3"
虽然数学上没错,但视觉上完全不对。纯蓝色 (0, 0, 255) 按平均法得到 85,看起来"挺亮";但按 luminance 公式得到只有约 19,看起来"很暗"——这才是人眼的真实感受。夜晚看蓝色就是觉得暗。
动手试一试
修改 index1.html,对比两种灰度化效果:
// 方法A:错误平均法
const v1 = (r + g + b) / 3;
// 方法B:正确 luminance 法
const v2 = 0.212 * r + 0.714 * g + 0.074 * b;
把画布分成左右两半,左边用方法A,右边用方法B。观察蓝天、绿叶、红花的区域,你会发现明显的亮度差异。
21.3 颜色矩阵变换(Color Matrix)
生活类比:调色师的工作台
电影后期有一个职位叫"调色师"(Colorist),他们坐在一大堆旋钮前:一个旋钮控制亮度,一个控制对比度,一个控制饱和度……每个旋钮都在独立调整颜色的某个维度。颜色矩阵就是把这些"旋钮"数学化的工具。
本质是什么
颜色矩阵是一个 4x5 的矩阵,它把输入的 [R, G, B, A] 变换成输出的 [R', G', B', A']:
| R' | | m0 m1 m2 m3 m4 | | R |
| G' | = | m5 m6 m7 m8 m9 | * | G |
| B' | | m10 m11 m12 m13 m14| | B |
| A' | | m15 m16 m17 m18 m19| | A |
| 1 |
注意最后一列 m4, m9, m14, m19 是偏移量(bias),对应齐次坐标中的常数项。
展开写就是:
R' = m0*R + m1*G + m2*B + m3*A + m4
G' = m5*R + m6*G + m7*B + m8*A + m9
B' = m10*R + m11*G + m12*B + m13*A + m14
A' = m15*R + m16*G + m17*B + m18*A + m19
公式推导:亮度矩阵
目标:让所有颜色变亮 20%(乘以 1.2)。
分析:每个输出通道只依赖自己的输入通道,且比例相同:
R' = 1.2 * R + 0 * G + 0 * B + 0 * A + 0
G' = 0 * R + 1.2 * G + 0 * B + 0 * A + 0
B' = 0 * R + 0 * G + 1.2 * B + 0 * A + 0
A' = 0 * R + 0 * G + 0 * B + 1 * A + 0
写成矩阵(按行优先展开为一维数组):
function brightness(p) {
return [
p, 0, 0, 0, 0, // R' = p*R
0, p, 0, 0, 0, // G' = p*G
0, 0, p, 0, 0, // B' = p*B
0, 0, 0, 1, 0, // A' = 1*A
];
}
公式推导:饱和度矩阵
目标:调整饱和度。饱和度 = 0 时完全灰度,饱和度 = 1 时保持原样,饱和度 > 1 时更鲜艳。
分析:饱和度的本质是"颜色偏离灰度的程度"。灰度值就是 luminance Y = 0.212*R + 0.714*G + 0.074*B。
设原颜色为 C,灰度值为 Y,饱和度系数为 p。那么:
C' = Y + p * (C - Y)
= p*C + (1-p)*Y
= p*C + (1-p)*(0.212*R + 0.714*G + 0.074*B)
对红色通道展开:
R' = p*R + (1-p)*0.212*R + (1-p)*0.714*G + (1-p)*0.074*B
= (p + (1-p)*0.212)*R + (1-p)*0.714*G + (1-p)*0.074*B
令 r = 0.212*(1-p), g = 0.714*(1-p), b = 0.074*(1-p),则:
function saturate(p) {
const r = 0.212 * (1 - p);
const g = 0.714 * (1 - p);
const b = 0.074 * (1 - p);
return [
r + p, g, b, 0, 0,
r, g + p, b, 0, 0,
r, g, b + p, 0, 0,
0, 0, 0, 1, 0,
];
}
公式推导:灰度化矩阵(带过渡)
目标:实现从彩色到灰度的渐变过渡,参数 p=0 时原样,p=1 时完全灰度。
分析:和饱和度类似,但反过来。最终颜色是原色和灰度的插值:
C' = (1-p)*C + p*Y
展开红色通道:
R' = (1-p)*R + p*(0.212*R + 0.714*G + 0.074*B)
= (1-p + p*0.212)*R + p*0.714*G + p*0.074*B
function grayscale(p) {
const r = 0.212 * p;
const g = 0.714 * p;
const b = 0.074 * p;
return [
r + 1 - p, g, b, 0, 0,
r, g + 1 - p, b, 0, 0,
r, g, b + 1 - p, 0, 0,
0, 0, 0, 1, 0,
];
}
矩阵的复合:链式调用
多个效果可以矩阵相乘合并成一个矩阵,然后只处理一次像素:
// 对应 demo: akira-graphics/pixels/index2-2.html
import { transformColor } from './lib/color-matrix.js';
traverse(imageData, ({ r, g, b, a }) => {
return transformColor(
[r, g, b, a],
channel({ r: 1.2 }), // 红色增强
brightness(1.2), // 整体提亮
saturate(1.2), // 增加饱和度
);
});
transformColor 内部会把多个矩阵先乘起来,再应用到颜色上。这比逐个应用效率高得多。
常见误区
误区:"矩阵变换只能做线性变换"
因为有第 5 列偏移量(m4, m9, m14, m19),所以实际上可以表示仿射变换(线性变换 + 平移)。比如对比度调整:
function contrast(p) {
const d = 0.5 * (1 - p); // 偏移量
return [
p, 0, 0, 0, d,
0, p, 0, 0, d,
0, 0, p, 0, d,
0, 0, 0, 1, 0,
];
}
对比度降低时(p < 1),所有颜色向中间灰度 0.5 靠拢,这就是偏移量的作用。
动手试一试
打开 index2-2.html,尝试调整参数:
// 把 saturate(1.2) 改成 saturate(0) 看看完全灰度的效果
// 把 brightness(1.2) 改成 brightness(0.5) 看看变暗的效果
// 把 channel({r: 1.2}) 改成 channel({g: 2.0}) 看看绿色通道过曝的效果
21.4 CPU 高斯模糊(Gaussian Blur)
生活类比:近视眼中的世界
近视的人不戴眼镜时,世界是模糊的。这种模糊不是简单的"糊成一团",而是每个点都变成了周围点的"加权平均"——离你越近的点权重越高,越远的点权重越低。高斯模糊就是模拟这种自然的模糊方式。
本质是什么
高斯模糊的核心是高斯函数(正态分布曲线):
G(x) = (1 / (sigma * sqrt(2*pi))) * exp(-x^2 / (2*sigma^2))
其中 sigma 控制模糊程度。sigma 越大,曲线越"胖",模糊越强烈。
公式推导
第一步:一维高斯核
直接对二维图像做二维高斯卷积,计算量是 O(width * height * radius^2)。但高斯函数有个美妙的性质:可分离性。二维高斯核可以拆成两个一维高斯核的乘积:
G(x, y) = G(x) * G(y)
所以我们可以先做水平方向的一维模糊,再做垂直方向的一维模糊。计算量降为 O(width * height * radius * 2),大幅提升效率。
第二步:生成高斯权重矩阵
function gaussianMatrix(radius, sigma = radius / 3) {
const a = 1 / (Math.sqrt(2 * Math.PI) * sigma);
const b = -1 / (2 * sigma ** 2);
let sum = 0;
const matrix = [];
for (let x = -radius; x <= radius; x++) {
const g = a * Math.exp(b * x ** 2);
matrix.push(g);
sum += g;
}
// 归一化:所有权重的和为1,保证亮度不变
for (let i = 0; i < matrix.length; i++) {
matrix[i] /= sum;
}
return { matrix, sum };
}
第三步:水平方向卷积
对于每个像素 (x, y),取它左右 radius 范围内的像素,按高斯权重加权求和:
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0;
for (let j = -radius; j <= radius; j++) {
const k = x + j; // 邻域像素的x坐标
if (k >= 0 && k < width) {
const i = (y * width + k) * 4; // 邻域像素在数组中的索引
r += pixels[i] * matrix[j + radius];
g += pixels[i + 1] * matrix[j + radius];
b += pixels[i + 2] * matrix[j + radius];
}
}
const i = (y * width + x) * 4;
pixels[i] = r / sum;
pixels[i + 1] = g / sum;
pixels[i + 2] = b / sum;
}
}
第四步:垂直方向卷积
和水平方向类似,只是遍历方向换成了从上到下:
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
let r = 0, g = 0, b = 0;
for (let j = -radius; j <= radius; j++) {
const k = y + j; // 邻域像素的y坐标
if (k >= 0 && k < height) {
const i = (k * width + x) * 4;
r += pixels[i] * matrix[j + radius];
// ...
}
}
// 写回...
}
}
注意:第二次卷积时,读取的
pixels已经是第一次水平模糊后的结果了。
常见误区
误区1:"高斯模糊就是取周围像素的平均值"
平均模糊(box blur)确实简单,但边缘会有明显的"方块感"。高斯模糊的权重按钟形曲线分布,边缘过渡自然,更符合人眼对"失焦"的感知。
误区2:"sigma 越大越好"
sigma 通常取 radius / 3。如果 sigma 太大,权重分布太分散,中心像素的权重过低,会导致图像变暗;如果 sigma 太小,权重集中在中心,模糊效果不明显。
误区3:"可以直接在原数组上读写"
水平模糊可以原地进行(因为每个输出只依赖同一行的输入,而同一行是从左到右处理的,但注意:水平模糊时,当前像素左边的像素已经被修改过了!)。
仔细看 demo 中的实现,它确实是原地修改的。这是因为水平模糊时,虽然左边的像素被改了,但当前像素计算时读的是已经水平模糊过的值——这其实是一种近似,严格来说应该用一个临时数组保存第一次的结果。不过 demo 中的实现在视觉上是可以接受的。
动手试一试
打开 index2-3.html,修改 gaussianBlur 的 radius 参数:
gaussianBlur(data, width, height, 10); // 大半径,强模糊
gaussianBlur(data, width, height, 1); // 小半径,轻微柔化
观察处理时间和视觉效果的平衡。再试试把 radius 设为 20,感受 CPU 处理的性能瓶颈。
21.5 纹理混合与合成(Texture Blending & Compositing)
生活类比:给照片叠加光效素材
摄影师常用的一种技巧是"叠加光效":拍一张正常照片,再拍一张阳光透过树叶的光斑,然后在 Photoshop 里把两张图叠在一起,上层选择"滤色"或"叠加"混合模式。纹理混合做的就是这件事。
本质是什么
纹理混合是指用**第二张图片(纹理)的信息来影响第一张图片(主图)**的像素处理。纹理可以提供:
- 局部亮度变化(如光斑、阴影)
- 局部颜色变化(如色调映射)
- 遮罩信息(如哪里该处理、哪里不该处理)
代码示例:阳光光效叠加
<!-- 对应 demo: akira-graphics/pixels/index5.html -->
<script type="module">
import { loadImage, getImageData, traverse, getPixel } from './lib/util.js';
import { transformColor, brightness, saturate } from './lib/color-matrix.js';
const canvas = document.getElementById('paper');
const context = canvas.getContext('2d');
(async function () {
const img = await loadImage('assets/girl1.jpg');
const sunlight = await loadImage('assets/sunlight.png');
const imageData = getImageData(img); // 主图
const texture = getImageData(sunlight); // 光效纹理
traverse(imageData, ({ r, g, b, a, index }) => {
// 获取同一位置的纹理像素(已归一化到 0~1)
const texColor = getPixel(texture, index);
// texColor[3] 是纹理的 alpha 通道
// 纹理越不透明的地方,亮度提升越多(最多提升 0.7)
// 纹理越不透明的地方,饱和度降低(从 2 降到 1)
return transformColor(
[r, g, b, a],
brightness(1 + 0.7 * texColor[3]),
saturate(2 - texColor[3])
);
});
context.putImageData(imageData, 0, 0);
}());
</script>
代码示例:爆炸纹理置换
<!-- 对应 demo: akira-graphics/pixels/index6.html -->
<script type="module">
import { loadImage, getImageData, traverse, getPixelXY } from './lib/util.js';
import { transformColor, brightness, saturate } from './lib/color-matrix.js';
(async function () {
const img = await loadImage('assets/girl1.jpg');
const textureImage = await loadImage('assets/explode.jpg');
const imageData = getImageData(img);
const texture = getImageData(textureImage);
traverse(imageData, ({ r, g, b, a, x, y }) => {
// x, y 已经被归一化到 0~1 范围
// 用归一化坐标去纹理中采样
const texColor = getPixelXY(texture, x, y);
// 把纹理转成灰度,作为影响强度
const e = 0.212 * texColor[0] + 0.714 * texColor[1] + 0.074 * texColor[2];
// 纹理越亮的地方,主图越暗(brightness 0.3 + e)
// 同时提高饱和度
return transformColor([r, g, b, a], brightness(0.3 + e), saturate(1.5));
});
context.putImageData(imageData, 0, 0);
}());
</script>
关键工具函数解析
// getPixel: 按数组索引读取像素,返回归一化的 [r, g, b, a]
export function getPixel(imageData, index) {
const { data } = imageData;
return [
data[index] / 255,
data[index + 1] / 255,
data[index + 2] / 255,
data[index + 3] / 255,
];
}
// getPixelXY: 按归一化坐标 (0~1) 读取像素
export function getPixelXY(imgData, x, y) {
const { width, height } = imgData;
if (x < 0 || y < 0 || x >= 1 || y >= 1) return null;
x = Math.floor(width * x);
y = Math.floor(height * y);
const idx = 4 * (y * width + x);
return getPixel(imgData, idx);
}
常见误区
误区:"纹理必须和主图一样大"
不一定。getPixelXY 会把归一化坐标映射到纹理的像素坐标。如果纹理比主图小,它会取整采样(最近邻采样),可能产生锯齿。更高级的做法是用双线性插值(bilinear interpolation)平滑采样。
动手试一试
打开 index5.html,把 sunlight.png 换成 explode.jpg,观察效果变化。再尝试修改混合公式:
// 试试乘法混合(正片叠底)
return [r * texColor[0], g * texColor[1], b * texColor[2], a];
// 试试屏幕混合(滤色)
return [1 - (1-r)*(1-texColor[0]), ...];
21.6 暗角效果(Vignette Effect)
生活类比:老相机的边缘失光
老式的胶片相机和镜头,由于光学设计限制,照片的四角往往比中心暗。这种"暗角"(Vignette)后来被摄影师和设计师反过来当作一种艺术效果——它能把观众的视线自然地引向画面中心。
本质是什么
暗角的本质是根据像素到中心的距离,逐渐降低其亮度或透明度。距离中心越远,暗化程度越高。
公式推导
第一步:计算归一化距离
假设画布中心是 (0.5, 0.5),像素位置 (x, y) 已经归一化到 0~1:
dx = x - 0.5
dy = y - 0.5
d = sqrt(dx^2 + dy^2) // 欧几里得距离
第二步:距离到暗化系数的映射
最简单的线性映射:
factor = 1.0 - 2 * d
当 d = 0(中心)时,factor = 1,完全不暗;
当 d = 0.5(角落)时,factor = 0,完全变暗。
第三步:应用到 Alpha 通道
traverse(imageData, ({ r, g, b, a, x, y }) => {
const d = Math.hypot((x - 0.5), (y - 0.5)); // 到中心的距离
a *= 1.0 - 2 * d; // 距离越远,alpha越小
return [r, g, b, a];
});
注意:这里修改的是
a(alpha),所以暗角是通过让边缘变透明实现的。如果画布下面有黑色背景,看起来就是边缘变暗。
更平滑的暗角曲线
线性暗角在边界处可能有明显的"硬边"。可以用平方或更高次幂让过渡更平滑:
// 平方衰减,更柔和
const factor = 1.0 - (2 * d) ** 2;
a *= Math.max(0, factor);
常见误区
误区:"暗角就是画一个黑色边框"
画黑色边框是"遮罩",暗角是"渐变"。真正的暗角是从中心到边缘平滑过渡的,而且通常不是纯黑,而是保持原图色相只降低亮度。
动手试一试
打开 index7.html,尝试:
// 1. 把暗角应用到亮度而不是透明度
const factor = Math.max(0, 1.0 - 2 * d);
return [r * factor, g * factor, b * factor, a];
// 2. 做一个"亮角"效果(反过来的暗角)
const factor = Math.min(1, 2 * d);
return [r * factor, g * factor, b * factor, a];
// 3. 用椭圆距离代替圆形距离
const d = Math.hypot((x - 0.5) * 1.5, (y - 0.5)); // x方向拉伸
21.7 GPU 像素处理:为什么它更快
生活类比:手工刺绣 vs 印刷机
CPU 逐像素处理,就像一位绣娘一针一线地绣图案——精细,但慢。GPU 并行处理,就像一台印刷机,一次印出成千上万个墨点——每个墨点由独立的"印刷头"同时工作。
本质是什么
CPU 是串行处理器,它有少数几个非常强大的核心,适合复杂的逻辑判断和顺序执行。GPU 是并行处理器,它有成千上万个简单的小核心,适合同时做大量相同的计算。
一张 1920x1080 的图片有大约 200 万个像素。CPU 处理时,是一个 for 循环串行执行 200 万次。GPU 处理时,是把 200 万个像素分配给 200 万个"线程"同时计算。
性能对比
| 操作 | CPU (JS) | GPU (Shader) | 加速比 |
|---|---|---|---|
| 灰度化 1920x1080 | ~50ms | ~0.5ms | ~100x |
| 高斯模糊 radius=10 | ~500ms | ~1ms | ~500x |
| 实时动画 (60fps) | 难以实现 | 轻松实现 | - |
GPU 像素处理的基本架构
顶点着色器(Vertex Shader) 片元着色器(Fragment Shader)
| |
处理顶点位置 处理每个像素颜色
(4个角就够了) (并行执行数百万次)
| |
+-------------> 光栅化 ---------------->
对于全屏后处理,我们通常只画一个覆盖整个屏幕的矩形(两个三角形),所有的"魔法"都在片元着色器里完成。
从 CPU 到 GPU 的思维转换
CPU 思维:
for (let i = 0; i < width * height; i++) {
// 处理第 i 个像素
}
GPU 思维:
// 这个函数会同时被调用数百万次,每次处理一个像素
void main() {
vec2 uv = vUv; // 当前像素的归一化坐标 (0~1)
// 直接计算这个像素的颜色,不需要循环!
}
常见误区
误区1:"GPU 做所有事情都比 CPU 快"
不是。GPU 擅长"大量相同计算的并行执行"。如果你的逻辑有很多分支判断(if/else),或者需要读取邻域像素(如复杂模糊),GPU 的优势会减小。另外,把数据从 CPU 传到 GPU 也有开销,小图片可能反而是 CPU 更快。
误区2:"Shader 很难学"
GLSL(OpenGL Shading Language)的语法和 C 非常像。核心的概念只有几个:uniform(全局变量)、varying(插值变量)、texture2D(纹理采样)。掌握了这些,你就能写出大部分后处理效果。
动手试一试
对比 pixels/index1.html(CPU 灰度)和 pixels-shader/texture.html(GPU 灰度)。前者处理大图片时会有明显卡顿,后者即使窗口很大也丝般顺滑。
21.8 着色器中的颜色矩阵
生活类比:把调色台搬进显卡
我们在 CPU 里用颜色矩阵调整图片,本质上是做矩阵乘法。GPU 最擅长的就是矩阵运算!所以把颜色矩阵搬到着色器里,是天作之合。
本质是什么
在片元着色器中,我们用一个 mat4(4x4 矩阵)来变换颜色。注意 GLSL 中的 mat4 是 4x4 的,所以我们把 RGB 变换放在 3x3 区域,Alpha 单独处理。
代码示例:GPU 灰度化
<!-- 对应 demo: akira-graphics/pixels-shader/texture.html -->
<script>
const vertex = `
attribute vec2 a_vertexPosition;
attribute vec2 uv;
varying vec2 vUv;
void main() {
gl_PointSize = 1.0;
vUv = uv;
gl_Position = vec4(a_vertexPosition, 1, 1);
}
`;
const fragment = `
#ifdef GL_ES
precision highp float;
#endif
uniform sampler2D tMap; // 输入纹理
uniform mat4 colorMatrix; // 4x4 颜色矩阵
varying vec2 vUv;
void main() {
vec4 color = texture2D(tMap, vUv); // 采样当前像素
gl_FragColor = colorMatrix * vec4(color.rgb, 1.0); // 矩阵变换
gl_FragColor.a = color.a; // 保持原 Alpha
}
`;
// ... 创建 renderer、编译 shader ...
// 设置灰度矩阵(和 CPU 版本的系数一致)
const r = 0.2126, g = 0.7152, b = 0.0722;
renderer.uniforms.colorMatrix = [
r, r, r, 0, // 第一行:R' = r*R + r*G + r*B
g, g, g, 0, // 第二行:G' = g*R + g*G + g*B
b, b, b, 0, // 第三行:B' = b*R + b*G + b*B
0, 0, 0, 1, // 第四行:A' = A
];
</script>
矩阵解析
CPU 版本的颜色矩阵是 4x5(包含偏移量列),GLSL 的 mat4 是 4x4。这里我们把偏移量暂时忽略了(或者可以通过其他 uniform 传入)。
灰度矩阵的含义:
| R' | | 0.2126 0.2126 0.2126 0 | | R |
| G' | = | 0.7152 0.7152 0.7152 0 | * | G |
| B' | | 0.0722 0.0722 0.0722 0 | | B |
| A' | | 0 0 0 1 | | 1 |
每一行都是相同的 luminance 系数,这意味着变换后 R' = G' = B' = Y,即灰度。
常见误区
误区:"GLSL 矩阵和 JavaScript 数组的存储顺序一样"
GLSL 的 mat4 是列优先(column-major)存储的。当你传入一个一维数组 [r, r, r, 0, g, g, g, 0, b, b, b, 0, 0, 0, 0, 1] 时,GLSL 会把它理解为:
第1列: r, g, b, 0
第2列: r, g, b, 0
第3列: r, g, b, 0
第4列: 0, 0, 0, 1
但在 gl-renderer.js 这个库内部,它可能做了转置处理。如果你直接用原生 WebGL,一定要注意 gl.uniformMatrix4fv 的 transpose 参数。
动手试一试
修改 texture.html 中的 colorMatrix,实现:
// 反色效果
renderer.uniforms.colorMatrix = [
-1, 0, 0, 0,
0, -1, 0, 0,
0, 0, -1, 0,
0, 0, 0, 1,
];
// 还需要加一个偏移量 uniform 把负值拉回 0~1 范围
// 或者尝试 Sepia(老照片)效果
renderer.uniforms.colorMatrix = [
0.393, 0.769, 0.189, 0,
0.349, 0.686, 0.168, 0,
0.272, 0.534, 0.131, 0,
0, 0, 0, 1,
];
21.9 多纹理合成(Multi-Texture Compositing)
生活类比:电影绿幕抠像
拍电影时,演员站在绿色背景前表演,后期把绿色抠掉,换成太空、城堡或外星风景。多纹理合成就是在着色器里同时读取多张图片,按一定规则混合。
本质是什么
GPU 可以同时绑定多个纹理单元(Texture Unit)。在片元着色器中,我们用不同的 sampler2D 变量来访问它们,然后按像素进行混合运算。
代码示例:猫咪遮罩合成
<!-- 对应 demo: akira-graphics/pixels-shader/combine.html -->
<script>
const fragment = `
#ifdef GL_ES
precision highp float;
#endif
uniform sampler2D tMap; // 主图(风景照)
uniform sampler2D tCat; // 猫咪图(作为遮罩/内容)
varying vec2 vUv;
void main() {
vec4 color = texture2D(tMap, vUv); // 采样主图
// 把 UV 坐标缩放并偏移,让猫咪显示在特定区域
vec2 st = vUv * 3.0 - vec2(1.2, 0.5);
vec4 cat = texture2D(tCat, st);
// 默认显示猫咪
gl_FragColor.rgb = cat.rgb;
// 如果猫咪像素偏红(r < 0.5)且偏绿(g > 0.6)
// 说明这可能是绿色背景,换成主图
if (cat.r < 0.5 && cat.g > 0.6) {
gl_FragColor.rgb = color.rgb;
}
gl_FragColor.a = color.a;
}
`;
// 加载两张纹理
const [picture, cat] = await Promise.all([
renderer.loadTexture('https://.../picture.jpg'),
renderer.loadTexture('https://.../cat.jpg'),
]);
renderer.uniforms.tMap = picture;
renderer.uniforms.tCat = cat;
</script>
混合模式解析
上面的 demo 用的是条件替换(chroma key,色键抠像)。更常见的混合模式有:
正常混合(Normal):
vec4 result = mix(bottom, top, top.a);
正片叠底(Multiply):
vec4 result = bottom * top;
屏幕混合(Screen):
vec4 result = 1.0 - (1.0 - bottom) * (1.0 - top);
叠加混合(Overlay):
vec4 result;
if (bottom.r < 0.5) result.r = 2.0 * bottom.r * top.r;
else result.r = 1.0 - 2.0 * (1.0 - bottom.r) * (1.0 - top.r);
// 同理处理 g, b
常见误区
误区:"纹理坐标超出 0~1 会自动报错"
默认情况下,WebGL 的纹理环绕模式(wrap mode)是 CLAMP_TO_EDGE,超出范围会重复边缘像素。如果设置为 REPEAT,则会平铺纹理。所以 st = vUv * 3.0 会把纹理重复显示 3 次。
动手试一试
修改 combine.html,实现真正的"绿幕抠像":
// 计算当前像素与纯绿色的距离
vec3 green = vec3(0.0, 1.0, 0.0);
float dist = distance(cat.rgb, green);
// 距离绿色越近,越显示主图
float alpha = smoothstep(0.3, 0.6, dist);
gl_FragColor.rgb = mix(color.rgb, cat.rgb, alpha);
21.10 像素位移与消散效果(Pixel Displacement / Dissipation)
生活类比:沙子从指缝流走
想象你握着一把细沙,慢慢松开手指,沙子不是瞬间消失,而是从指缝间一点点"流散",每粒沙子的运动轨迹都有些随机。像素消散效果模拟的就是这种"有序的混乱"。
本质是什么
像素位移效果的核心是:不直接采样当前坐标的颜色,而是根据某种规则偏移采样坐标。这个"规则"可以是:
- 时间(动画)
- 噪声函数(随机性)
- 另一张纹理(置换贴图)
代码示例:随机消散效果
<!-- 对应 demo: akira-graphics/pixels-shader/particle.html -->
<script>
const fragment = `
#ifdef GL_ES
precision highp float;
#endif
uniform sampler2D tMap;
uniform float uTime; // 时间,从 JS 传入
varying vec2 vUv;
// 伪随机数生成器
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
void main() {
// 把 UV 空间划分成网格(100 x 55.4)
vec2 st = vUv * vec2(100, 55.4);
// 对每个网格,生成一个随机偏移方向
// floor(st) 把坐标取整到网格中心
// random(floor(st)) 给每个网格一个 0~1 的随机数
// 映射到 1.0 - 2.0 * random,即 -1 ~ 1 的偏移
vec2 uv = vUv + 1.0 - 2.0 * random(floor(st));
// mix(uv, vUv, min(uTime, 1.0)):
// uTime=0 时,用随机偏移的 uv,画面是乱的
// uTime=1 时,用原始 vUv,画面恢复正常
// 中间状态是两者的线性插值
vec4 color = texture2D(tMap, mix(uv, vUv, min(uTime, 1.0)));
gl_FragColor.rgb = color.rgb;
gl_FragColor.a = color.a * uTime; // 同时从透明渐入
}
`;
// JS 端:每帧更新时间
function update(t) {
renderer.uniforms.uTime = t / 2000; // 2秒内完成动画
requestAnimationFrame(update);
}
update(0);
</script>
效果解析
这个 shader 实现了一个从混乱到清晰的入场动画:
初始状态(uTime = 0):
mix(uv, vUv, 0) = uv,即完全使用随机偏移的坐标- 每个网格(100x55.4 个网格)采样一个随机位置的颜色
- 画面看起来像打乱的拼图
alpha = 0,完全透明
过渡状态(0 < uTime < 1):
mix在随机坐标和正确坐标之间插值- 画面逐渐"归位"
- alpha 从 0 增加到 1
最终状态(uTime >= 1):
mix(uv, vUv, 1) = vUv,正常采样- 画面完全清晰
- alpha = 1
随机函数解析
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
这是 GLSL 中经典的伪随机数技巧:
dot(st.xy, vec2(12.9898, 78.233)):把二维坐标投影到一个方向上sin(...):把线性值变成波动的正弦值* 43758.5453123:放大频率,让小数部分剧烈变化fract(...):取小数部分,得到0~1的"随机"值
注意:这不是真正的随机,而是确定性的伪随机——相同的输入永远得到相同的输出。这在 GPU 中非常重要,因为 GPU 无法做真正的随机采样。
常见误区
误区:"GPU 可以直接生成真随机数"
GPU 的执行是确定性的。所有"随机"效果都是通过伪随机函数或预计算的噪声纹理实现的。random() 函数虽然看起来随机,但对同一个 st 永远返回同一个值。
误区:"位移效果只能做入场动画"
恰恰相反。把 mix(uv, vUv, min(uTime, 1.0)) 反过来:
// 从清晰到消散
vec4 color = texture2D(tMap, mix(vUv, uv, min(uTime, 1.0)));
这就是常见的"像素化消散"退场效果。
动手试一试
修改 particle.html,尝试:
// 1. 让网格更小,效果更细碎
vec2 st = vUv * vec2(200, 110);
// 2. 用正弦波代替随机,做"波浪归位"效果
vec2 offset = vec2(sin(floor(st.y) * 0.5), cos(floor(st.x) * 0.5));
vec2 uv = vUv + offset;
// 3. 做径向消散:从中心向外扩散
float dist = distance(vUv, vec2(0.5));
vec2 uv = vUv + normalize(vUv - 0.5) * random(floor(st)) * (1.0 - uTime);
21.11 综合案例:CPU 后处理管线
让我们把前面学到的 CPU 技术组合起来,实现一个完整的后处理流程。参考 akira-graphics/pixels/index4.html:
<script type="module">
import { loadImage, getImageData, traverse, gaussianBlur } from './lib/util.js';
import { transformColor, grayscale, brightness, contrast, saturate } from './lib/color-matrix.js';
const canvas = document.getElementById('paper');
const context = canvas.getContext('2d');
(async function () {
const img = await loadImage('assets/girl2.jpg');
const imageData = getImageData(img);
// 步骤1:高斯模糊(柔化背景感)
gaussianBlur(imageData.data, imageData.width, imageData.height, 4);
// 步骤2:颜色矩阵调整
traverse(imageData, ({ r, g, b, a }) => {
return transformColor(
[r, g, b, a],
grayscale(0.5), // 半灰度
saturate(1.2), // 稍微增加饱和度
contrast(1.1), // 轻微增加对比度
brightness(1.2), // 整体提亮
);
});
canvas.width = imageData.width;
canvas.height = imageData.height;
context.putImageData(imageData, 0, 0);
}());
</script>
管线设计要点
顺序很重要:先模糊后调色,和先调色后模糊,效果完全不同。模糊会混合颜色,所以通常先做几何/模糊类操作,再做颜色类操作。
矩阵可以合并:
transformColor内部会把多个矩阵相乘,所以虽然传了 4 个矩阵,实际只做了 1 次矩阵乘法 + 1 次向量乘法。性能权衡:CPU 高斯模糊是性能瓶颈。如果要做实时效果,考虑:
- 缩小图片处理后再放大
- 用近似算法(如 box blur 多次迭代逼近高斯)
- 迁移到 GPU
Q&A 常见问题
Q1:为什么 getImageData 抓到的数据是 Uint8ClampedArray 而不是普通数组?
A:因为像素值必须在 0~255 范围内。Clamped 意味着任何超出范围的值都会被自动截断到边界。如果你用普通数组,写入 300 会溢出成 44(300 % 256),导致颜色错误;而 ClampedArray 会把 300 变成 255,更安全。
Q2:颜色矩阵的系数 0.212, 0.714, 0.074 和 0.2126, 0.7152, 0.0722 有什么区别?
A:前者来自早期的 ITU-R BT.601 标准(标清电视),后者来自 BT.709 标准(高清电视)。两者差异极小,肉眼几乎无法区分。现代图形学更常用 BT.709 的系数。
Q3:高斯模糊的 radius 和 sigma 到底怎么选?
A:经验法则是 sigma = radius / 3。这是因为高斯分布中,3 * sigma 已经覆盖了 99.7% 的能量。如果 sigma 相对于 radius 太大,边缘的像素权重不足,会导致图像变暗;如果太小,模糊效果不够。
Q4:GPU 着色器里可以做 if/else 吗?性能如何?
A:可以做,但要谨慎。GPU 是 SIMD(单指令多数据)架构,同一个 warp/wavefront 里的线程必须执行相同的指令。如果有分支,GPU 会执行所有分支路径,然后用掩码屏蔽不需要的结果。简单的条件赋值(如 mix)通常比 if/else 更高效。
Q5:为什么 pixels-shader 里的 demo 用 gl-renderer.js 而不是原生 WebGL?
A:原生 WebGL API 非常冗长:创建 context、编译 shader、链接 program、创建 buffer、绑定 attribute、设置 uniform、处理纹理……gl-renderer.js 把这些样板代码封装起来,让我们专注于 shader 逻辑本身。学习时建议先理解封装后的用法,再逐步深入原生 API。
Q6:CPU 处理后的图片怎么保存?
A:用 Canvas 的 toDataURL 或 toBlob 方法:
// 转成 base64 PNG 图片
const dataURL = canvas.toDataURL('image/png');
// 或者下载
const link = document.createElement('a');
link.download = 'filtered.png';
link.href = canvas.toDataURL('image/png');
link.click();
Q7:后处理效果可以叠加多少个?
A:理论上无限,但每增加一个效果就多一次像素遍历的开销。CPU 上建议控制在 3~5 个以内。GPU 上可以通过多 pass 渲染(一次渲染的结果作为下一次的输入纹理)叠加更多效果,现代游戏通常有 10+ 个后处理 pass。
本章总结
| 概念 | 核心要点 | 对应 Demo |
|---|---|---|
getImageData/putImageData |
像素数据的读写入口 | index1.html |
| 灰度化 | 用 luminance 加权,非简单平均 | index1.html |
| 颜色矩阵 | 4x5 仿射变换,可链式复合 | index2-2.html |
| 高斯模糊 | 可分离的二维卷积,先横后竖 | index2-3.html |
| 纹理混合 | 用第二张图影响主图处理 | index5.html, index6.html |
| 暗角效果 | 按到中心距离衰减 | index7.html |
| GPU 处理 | 并行计算,适合像素级任务 | texture.html |
| Shader 颜色矩阵 | mat4 变换,列优先存储 |
texture.html |
| 多纹理合成 | 多个 sampler2D 混合 |
combine.html |
| 像素位移 | 偏移 UV 坐标实现动画 | particle.html |
课后练习
复古相机:结合暗角 + 灰度 + 轻微模糊,实现一个"老照片"效果。
实时调色板:用
requestAnimationFrame循环更新颜色矩阵参数,实现图片从灰度到彩色、从暗到亮的动态过渡。GPU 迁移:把
index4.html的 CPU 后处理管线,用 GLSL shader 重新实现一遍。对比性能差异。自定义混合模式:在
combine.html的基础上,实现 Photoshop 中的"叠加"(Overlay)和"柔光"(Soft Light)混合模式。鼠标交互暗角:让暗角的中心跟随鼠标移动,而不是固定在画面中心。提示:把鼠标坐标归一化后传给 shader 或遍历函数。
像素处理是图形学中最直观、最有成就感的领域之一。因为你写的每一行代码,都能在屏幕上看到立竿见影的效果。希望你享受这个过程,下一章见!