抖动与复古着色的 Web 之术
摘要
Maxime Heckel 深入讲解如何在 Web 上实现抖动(dithering)、颜色量化与 CRT 像素化等复古着色后处理效果,结合 React Three Fiber/Three.js 展示自定义 shader 技术与艺术化应用。
暂无内容
查看缓存全文
缓存时间: 2026/10/13 09:54
# 抖动与复古着色的艺术——Maxime Heckel 的博客
来源:https://blog.maximeheckel.com/posts/the-art-of-dithering-and-retro-shading-web/
在过去几个月里,我从零开始构建了我的个人网站(https://maximeheckel.com/),终于有时间融入一些 3D 作品来展示我的着色器和 WebGL 技能。在这个过程中,我真正理解了**后处理**在让一个场景真正*好看*方面所起的关键作用,这也让我对过去 React Three Fiber 和着色器项目中的一些长期困扰有了答案——无论我投入多少精力和心血,我的视觉构想总是无法实现。
花时间构建、组合和实验自定义后处理效果,为我打开了一扇额外的创意之门。在我迭代过的众多效果类型中,我一直对几个"复古"效果情有独钟。通过一些微妙的细节,比如**抖动**、**颜色量化**或**像素化/CRT RGB 网格**,它们在现代网络世界与我们这些 90 后/00 后孩子有时怀念的逝去技术时代之间营造出了一种令人愉悦的对比。
鉴于我花在深入剖析这些效果的每个方面的大量时间,我想在这篇文章中与你深入探讨支撑这些效果的概念,以及我在这一过程中学到的着色器技术。
在本文中,我希望让你信服后处理效果的威力,以及没有什么比将优雅的复古风格应用到网站上更好的了 👌✨。我们还将看看一些来自才华横溢的创作者的抖动和像素艺术示例,他们使用的过程与我稍后将介绍的相同,以及我在学习所有这些内容时创作的一些作品。
## 抖动技术
抖动(Dithering)最早是一种早期图形技术,通过有意在图像或渲染上引入噪声,来*欺骗*观众的大脑,使其在渐变/阴影中看到更多的颜色或更平滑的效果,而当时的机器输出能力十分有限。那时的调色板非常有限,因此游戏设计师依靠这类技术来实现他们的构想至关重要。由此,那个计算机开始普及但先进图形能力尚未到来的时代,其游戏和媒体作品获得了独特的视觉风格和质感。
如今,抖动更多是一种艺术选择而非变通手段。许多艺术家或游戏设计师将此技术作为创意表达方式,为作品赋予独特的*复古氛围*,致敬早期游戏时代,或在自我设定的颜色限制范围内进行创作。一些优秀的抖动使用示例包括:
- Basement Studio 的 Basement Chronicle(https://chronicles.bestment.studio/)游戏:一款出色的*点击解谜*游戏,让我想起自己早期的游戏经历。
- @loackme(https://twitter.com/loackme_)的艺术作品,我是他的忠实粉丝。
- @aweusmeuh(https://twitter.com/aweusmeuh)使用原始 Game Boy 相机进行实验摄影,其作品呈现出极佳的抖动效果。
从左到右分别是 Basement Studio、@aweusmeuh 和 @loackme_ 制作的精美抖动艺术示例
我们将在本文中采用的正是最后一种方式:为我们的 React Three Fiber/Three.js 项目赋予独特的风格!
在第一部分中,我们将探索抖动技术的工作原理,将其实现为着色器,并构建一个自定义抖动后处理效果的第一版迭代,使其可以应用于任何 3D 场景之上。
### 在 React Three Fiber 中初步实现抖动
对于这个项目,我们将创建一个**自定义后处理效果**。正如我们之前在 Moebius 风格着色器(https://blog.maximeheckel.com/posts/moebius-style-post-processing/)中所做的那样,依赖后处理可以让我们对着已渲染的场景应用着色器并改变其风格,就像给照片添加"图像滤镜"一样。
要创建自定义效果,我们将:
1. 声明一个继承自 `Effect` 的类
2. 定义我们的片元着色器,并在父类构造函数中使用 `super` 关键字调用它
3. 定义效果所需的 uniforms 集合
4. 从 `@react-three/post-processing` 调用 `wrapEffect` 函数,并将我们的效果类作为参数传入。这样我们就可以在 `EffectComposer` 中将效果用作 JSX 组件。
```glsl
import { OrbitControls, OrthographicCamera, useFBO } from '@react-three/drei';
import { Canvas } from '@react-three/fiber';
import { wrapEffect, EffectComposer } from '@react-three/postprocessing';
import { Effect } from 'postprocessing';
import { Suspense, useRef, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import fragmentShader from './fragmentShader.glsl';
class RetroEffectImpl extends Effect {
super('RetroEffect', fragmentShader, {
uniforms: new Map([]),
});
}
const RetroEffect = wrapEffect(RetroEffectImpl);
const mesh = useRef();
```
作为构建效果的第一步,我们从简单的**基于亮度的白噪声抖动**开始。其思路是:
- 查看每个像素的*亮度*
- 将其与随机数进行比较(这就是"白噪声"名称的由来)
- 根据亮度是否高于或低于该随机数,输出白色或黑色像素
```glsl
float random(vec2 c) {
return fract(sin(dot(c.xy, vec2(12.9898, 78.233))) * 43758.5453);
}
vec3 whiteNoiseDither(vec2 uv, float lum) {
vec3 color = vec3(0.0);
if (lum < random(uv)) {
color = vec3(1.0);
}
return color;
}
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec4 color = texture2D(inputBuffer, uv);
float lum = dot(vec3(0.2126, 0.7152, 0.0722), color.rgb);
color.rgb = whiteNoiseDither(uv, lum);
outputColor = color;
}
```
你可以在下面的组件中观察这段代码产生的效果,它重新实现了类似的过程:
这样做会得到一个灰度版本的场景,其中任何非纯黑或纯白的像素都会被*抖动*处理,欺骗我们的大脑看到更多灰度层次。下面的演示展示了该效果应用于一个简单 React Three Fiber 场景的效果:
### 有序抖动与 Bayer 矩阵
我们刚构建的效果可行,但它依赖白噪声作为抖动阈值,导致结果比较混乱。我们可以通过一种通常被称为**有序抖动**的技术来为这一切带来秩序(🥁),因为它在应用时会产生*有序*的图案。该技术依赖于通过 **Bayer 矩阵**定义的阈值映射,其中包含的值用于确定是否应将给定像素的颜色调整为黑色或白色。
展示将 4x4 Bayer 矩阵应用于场景输入缓冲区并基于匹配每个像素的阈值获取抖动图案过程的图表
为了演示这种抖动类型的工作方式,我构建了下面的组件,你可以看到这个矩阵一旦应用到像素网格上,如何改变其输出结果:
通过上面展示的例子可以看出,我们得到了一些非常独特的抖动图案,这取决于:
- 底层像素网格中使用的灰度色调
- 用于获取阈值值的 Bayer 矩阵大小
**之前** → **之后**:应用于简单灰度渐变的有序抖动
要在 GLSL 中实现这一点,我们需要获取每个像素的亮度,并将其值与 Bayer 矩阵中该像素对应的阈值进行比较:
- 如果这些值之间的差值为正,像素为白色
- 否则,像素为黑色
```glsl
const mat4x4 bayerMatrix4x4 = mat4x4(
0.0, 8.0, 2.0, 10.0,
12.0, 4.0, 14.0, 6.0,
3.0, 11.0, 1.0, 9.0,
15.0, 7.0, 13.0, 5.0
) / 16.0;
vec3 orderedDither(vec2 uv, float lum) {
vec3 color = vec3(0.0);
float threshold = 0.0;
int x = int(uv.x * resolution.x) % 4;
int y = int(uv.y * resolution.y) % 4;
threshold = bayerMatrix4x4[y][x];
if (lum < threshold + bias) {
color = vec3(1.0);
}
return color;
}
```
用我们刚才介绍的代码修改上一部分实现的效果代码,就能得到底层场景的有序抖动效果:
### 蓝噪声抖动
我们得到了一个令人满意的有序抖动效果!虽然这是最流行的抖动技术,也是我们在本文中主要使用的技术,但我还想介绍另一种你或许还记得在 Volume Raymarching 文章(https://blog.maximeheckel.com/posts/real-time-cloudscapes-with-volumetric-raymarching/)中见过的抖动方式:**蓝噪声抖动**。我在光线步进的云场景中使用了这项技术来*"消除由于\[光线步进\]循环精度不足而产生的条纹或分层效果"*,有趣的是,这正是我们在 Retro 后处理效果中需要抖动的相同场景。
与之前的技术不同,这种方式依赖于一张纹理,我们将通过 uniform 传递给自定义后处理效果的着色器,然后按照如下方式采样:
```glsl
vec4 noise = texture2D(uNoise, gl_FragCoord.xy / 128.0);
float threshold = noise.r;
```
其中 `128.0` 是该纹理的宽/高。我们将阈值定义为采样获得的噪声颜色的红色通道值,由于我们使用的是灰度纹理,因此选择哪个通道并不太重要。
下面是使用蓝噪声纹理获取抖动阈值时的输出结果:
如你所见,它感觉比有序抖动更少重复和结构化,同时又不像白噪声抖动那样*随机*;是一个不错的折中方案。
## 颜色量化
到目前为止,我们所有的抖动示例还将底层场景转换为了黑白,因此丢失了大量信息和颜色。这是因为:
- 我们基于像素亮度计算抖动阈值,因此依赖场景的灰度版本
- 我们根据阈值相对于亮度的比较手动返回黑色或白色像素
这种技术通常被称为**基于亮度的抖动**,这里使用的颜色转换将调色板压缩到 2-bit:应用我们后处理效果后的场景中,每个像素要么是黑色要么是白色,任何中间色调都通过抖动呈现给我们。这种*颜色压缩*被称为**颜色量化**,它不仅支持黑白像素,在本节中我们将看到更多。
### 灰度与颜色
手动设置抖动图案的颜色很快就会变得不可控,尤其是面对大型调色板时。为了获得黑白以外的像素并利用灰度层次,我们将使用一个公式来根据我们希望在效果中输出的总颜色数找到给定像素颜色的最邻近颜色:`floor(color * (n - 1) + 0.5) / (n - 1)`,其中 `n` 是颜色总数。
例如,如果我们最终的调色板只需要两种颜色,我们会得到以下值:
- 颜色 `vec3(0.3)` → `vec3(0.0)`,即黑色
- 颜色 `vec3(0.6)` → `vec3(1.0)`,即白色
如果我们增加颜色数量,在灰度场景中就能得到更多灰度层次。
```glsl
vec3 dither(vec2 uv, float lum) {
vec3 color = vec3(lum);
int x = int(uv.x * resolution.x) % 8;
int y = int(uv.y * resolution.y) % 8;
float threshold = bayerMatrix8x8[y * 8 + x];
color.rgb += threshold;
color.r = floor(color.r * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
color.g = floor(color.g * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
color.b = floor(color.b * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
return color;
}
```
**之前** → **之后**:2 色 vs 4 色量化有序抖动。注意 4 色版本呈现出更好的渐变效果。
这个公式不仅适用于灰度,我们可以直接将其应用于原始像素颜色来计算其最邻近颜色:
- 对于双色调色板,每个颜色通道会有 2 种可能值,因此是 2³ = 8 种颜色
- 对于四色调色板,就是 4³ = 64 种颜色
```glsl
vec3 dither(vec2 uv, vec3 color) {
int x = int(uv.x * resolution.x) % 8;
int y = int(uv.y * resolution.y) % 8;
float threshold = bayerMatrix8x8[y * 8 + x];
color.rgb += threshold;
color.r = floor(color.r * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
color.g = floor(color.g * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
color.b = floor(color.b * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
return color;
}
```
这种量化技术让我们可以近似旧时代图形的视觉效果。我们可以获得与当年计算机和游戏主机所能输出的调色板更接近的效果。
### 自定义调色板
我们现在知道了一种将最终渲染中的颜色数量减少到任意数量的技术,但如果要减少到一组任意颜色呢?在 @Acerola_t(https://twitter.com/Acerola_t)关于量化和抖动的视频(https://www.youtube.com/watch?v=8wOUe32Pt-E&ab_channel=Acerola)中,他介绍了一种通过使用灰度调色板的值来采样定义自定义调色板的纹理,从而实现这一目标的技术。
例如,如果我们之前的灰度示例场景将颜色数设为四,我们将得到以下灰度值:
展示如何使用灰度值来采样包含相同数量颜色的纹理的图表
这些值对应于我们调色板纹理 UV 坐标的水平方向值,从而让我们使用这些值来采样纹理并从中获取自定义颜色:
```glsl
vec3 dither(vec2 uv, float lum) {
vec3 color = vec3(lum);
int x = int(uv.x * resolution.x) % 8;
int y = int(uv.y * resolution.y) % 8;
float threshold = bayerMatrix8x8[y * 8 + x];
color.rgb += threshold * 0.2;
color.r = floor(color.r * (4.0 - 1.0) + 0.5) / (4.0 - 1.0);
color.g = floor(color.g * (4.0 - 1.0) + 0.5) / (4.0 - 1.0);
color.b = floor(color.b * (4.0 - 1.0) + 0.5) / (4.0 - 1.0);
vec3 paletteColor = texture2D(palette, vec2(color.r)).rgb;
return paletteColor;
}
```
如果我们把这项技术应用到之前的场景上,最终输出将是这样的:
我建议你 fork 这个演示并尝试:
- 不同的纹理
- 不同的颜色数量
唯一需要注意的是保持调色板纹理中的颜色块数量与你在自定义效果中设置的颜色数量一致。
### 基于色相-明度的颜色量化
在 Alex Charlton 的文章 Dithering on the GPU(http://alex-charlton.
相似文章
使用CSS进行抖动处理
本文演示如何使用CSS滤镜和SVG feTurbulence对图像应用抖动效果,以保持一致的视觉风格。
@VincentLogic: 以前做前端,为了个加载动画,要么到处找 GIF,要么自己写 CSS 写到头秃。 最近看到这个 math-curve-loaders 开源库,完全是用数学公式生成的动效。 我看了一下,玫瑰曲线、李萨如曲线这些数学图形,动效极其优雅。纯 HT…
介绍了一个名为 math-curve-loaders 的开源前端库,利用数学公式(如玫瑰曲线、李萨如曲线)生成优雅的加载动画,纯 HTML+CSS 实现,零依赖,并带有可视化调试面板,可以实时调整参数并一键复制代码。
着色运动
本文介绍将运动作为着色器的新型创作媒介,使用WebGPU计算着色器通过帧差异提取运动,并创建如拖尾和运动模糊等视觉效果。
@10xmylife: 这个网站很棒,各种动画效果都有了,还是开源的
介绍 Amicro,一个开源的 React 微交互和过渡组件库,可通过 CLI 添加漂亮的动画效果。
@Jolyne_AI: Ambient CSS 很惊艳,我几乎没见过类似的库:把物理光照模型搬进 CSS,让 React 组件也能有真实的高光、阴影和表面材质。作者用同一套几何与灯光在 Blender 里先渲染一遍,再在浏览器里用 box-shadow 和 CS…
Ambient CSS 是一个将物理光照模型引入 CSS 的库,允许 React 组件实现真实的高光、阴影和表面材质效果。作者通过 Blender 渲染和 CSS 变量来复刻光照,提供丰富的组件如按钮、旋钮等。