第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 ~ 255Uint8ClampedArray 的意思是:如果你不小心写入了 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 后画布自动更新"

不会。dataImageData 对象的一个属性,它是数组的引用。你修改数组内容后,必须调用 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,修改 gaussianBlurradius 参数:

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 的 mat44x4。这里我们把偏移量暂时忽略了(或者可以通过其他 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.uniformMatrix4fvtranspose 参数。

动手试一试

修改 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 实现了一个从混乱到清晰的入场动画:

  1. 初始状态(uTime = 0)

    • mix(uv, vUv, 0) = uv,即完全使用随机偏移的坐标
    • 每个网格(100x55.4 个网格)采样一个随机位置的颜色
    • 画面看起来像打乱的拼图
    • alpha = 0,完全透明
  2. 过渡状态(0 < uTime < 1)

    • mix 在随机坐标和正确坐标之间插值
    • 画面逐渐"归位"
    • alpha 从 0 增加到 1
  3. 最终状态(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 中经典的伪随机数技巧:

  1. dot(st.xy, vec2(12.9898, 78.233)):把二维坐标投影到一个方向上
  2. sin(...):把线性值变成波动的正弦值
  3. * 43758.5453123:放大频率,让小数部分剧烈变化
  4. 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>

管线设计要点

  1. 顺序很重要:先模糊后调色,和先调色后模糊,效果完全不同。模糊会混合颜色,所以通常先做几何/模糊类操作,再做颜色类操作。

  2. 矩阵可以合并transformColor 内部会把多个矩阵相乘,所以虽然传了 4 个矩阵,实际只做了 1 次矩阵乘法 + 1 次向量乘法。

  3. 性能权衡: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.0740.2126, 0.7152, 0.0722 有什么区别?

A:前者来自早期的 ITU-R BT.601 标准(标清电视),后者来自 BT.709 标准(高清电视)。两者差异极小,肉眼几乎无法区分。现代图形学更常用 BT.709 的系数。

Q3:高斯模糊的 radiussigma 到底怎么选?

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 的 toDataURLtoBlob 方法:

// 转成 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

课后练习

  1. 复古相机:结合暗角 + 灰度 + 轻微模糊,实现一个"老照片"效果。

  2. 实时调色板:用 requestAnimationFrame 循环更新颜色矩阵参数,实现图片从灰度到彩色、从暗到亮的动态过渡。

  3. GPU 迁移:把 index4.html 的 CPU 后处理管线,用 GLSL shader 重新实现一遍。对比性能差异。

  4. 自定义混合模式:在 combine.html 的基础上,实现 Photoshop 中的"叠加"(Overlay)和"柔光"(Soft Light)混合模式。

  5. 鼠标交互暗角:让暗角的中心跟随鼠标移动,而不是固定在画面中心。提示:把鼠标坐标归一化后传给 shader 或遍历函数。


像素处理是图形学中最直观、最有成就感的领域之一。因为你写的每一行代码,都能在屏幕上看到立竿见影的效果。希望你享受这个过程,下一章见!