🖼️ Shader 进阶 - 纹理与图像处理的艺术
1. 🌟 什么是纹理 (Texture)?
在前面的文章中,我们学会了用代码生成图案(SDF、噪声、分形)。但真实世界的纹理往往更复杂 - 照片、手绘图、扫描的材质...这时候就需要纹理贴图 (Texture Mapping)!
1.1 📚 纹理的本质
纹理 = 图像数据 + 采样方式
GPU内存中的图像 3D模型表面
┌─────────────┐ ╱╲
│░▒▓█▓▒░▒▓█▓░│ ╱ ╲
│▓█░▒░▓█░▒▓░▓│ ╱ ╲
│░▒▓█▓░▒▓█▓▒░│ ╱──────╲
└─────────────┘ 纹理映射
像素数据 (Texels) 到三角形表面核心概念:
- Texel (纹理像素): 纹理中的最小单元,类似像素但存储在 GPU 专用内存中
- UV 坐标: 纹理空间的坐标系统 (U = 水平, V = 垂直)
- 采样 (Sampling): 根据 UV 坐标从纹理中读取颜色
💡 生活类比:纹理就像墙纸
想象你要给一个立方体房间贴墙纸:
- 纹理图像 = 你手里的墙纸卷
- UV坐标 = 墙纸上的网格参考线(告诉你哪部分应该贴到墙的哪里)
- 采样 = 用手指指向墙纸某个位置,查看那里的颜色/图案
- 纹理映射 = 把墙纸贴到墙上的整个过程
关键区别是:墙纸只能贴一次,但纹理可以通过不同的 UV 映射方式贴无数次!
1.2 🎯 UV 坐标系统
UV 坐标是归一化的纹理坐标:
V (垂直)
↑
1.0 ┌─────────────┐
│ 纹理图像 │
0.5 │ ● │ ← (0.5, 0.5) = 纹理中心
│ │
0.0 └─────────────┘→ U (水平)
0.0 0.5 1.0关键特性:
- ✅ 范围: U, V ∈ [0, 1]
- ✅ 与图像分辨率无关 (512×512 和 2048×2048 用相同的 UV)
- ✅ 左下角 (0,0), 右上角 (1,1)
💡 为什么要归一化到 [0, 1]?
想象你有两张照片:一张是 100×100 像素,另一张是 4000×4000 像素。
- 如果用像素坐标:你需要写两套代码,分别处理 (0-100) 和 (0-4000)
- 如果用归一化坐标:两张图都用 (0.0-1.0),代码通用!
这就像用"百分比"代替"绝对值":
- "图像 50% 的位置" = UV(0.5, 0.5)
- 无论图像多大,中心永远是 (0.5, 0.5)
⚠️ 常见陷阱:
- 坐标系差异: WebGL 的 UV 原点在左下角,但 Canvas 2D/图像文件通常是左上角为原点
- 超出范围: UV 可以超出 [0, 1],行为取决于纹理的 Wrap 模式(重复/钳制/镜像)
- 宽高比: 屏幕坐标通常需要宽高比校正,但 UV 坐标已经归一化,不需要!
2. 🔧 纹理采样基础
2.1 📐 最简单的纹理采样
#ifdef GL_ES
precision mediump float; // 指定浮点精度(移动端必需)
#endif
uniform vec2 u_resolution; // 屏幕分辨率(像素)
uniform sampler2D u_texture; // 🖼️ 纹理对象(一种特殊的数据类型)
void main() {
// 1️⃣ 生成 UV 坐标 (0~1 范围)
// gl_FragCoord.xy = 当前像素的屏幕坐标(例如 (320, 240))
// u_resolution.xy = 整个画布大小(例如 (800, 600))
// 相除后得到归一化坐标:(320/800, 240/600) = (0.4, 0.4)
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 2️⃣ 采样纹理(核心步骤)
vec4 color = texture2D(u_texture, uv);
// ────┬──── ─┬─ ─┬─
// │ │ └─ UV坐标(从哪里读)
// │ └─ 采样器(读哪张图)
// └─ 采样函数(返回 RGBA 四个通道)
// 3️⃣ 输出颜色
gl_FragColor = color; // 将采样到的颜色写入当前像素
}💡 采样的底层原理
texture2D(u_texture, uv)这一行代码背后发生了什么?
定位: GPU 根据 UV 坐标计算出对应的纹理像素位置
- UV(0.5, 0.5) + 1024×1024纹理 → Texel[512, 512]
读取: 从 GPU 纹理内存中读取该位置的 RGBA 值
- 这个过程极快,因为纹理缓存专门为此优化
过滤: 如果 UV 坐标落在两个 texel 之间,会进行插值
- 类似"四舍五入" vs "线性混合"
返回: 返回一个
vec4(r, g, b, a),每个分量都在 [0.0, 1.0]类比:就像用放大镜(UV坐标)查看一张海报(纹理),你指向的位置(坐标)决定了你看到的颜色。
JavaScript 端加载纹理 (Three.js 示例):
const textureLoader = new THREE.TextureLoader();
const texture = textureLoader.load('path/to/image.jpg');
const material = new THREE.ShaderMaterial({
uniforms: {
u_texture: { value: texture },
u_resolution: { value: new THREE.Vector2(800, 600) }
},
fragmentShader: shaderCode
});2.2 🎨 UV 操作技巧
2.2.1 技巧 1: 翻转纹理
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 🔄 水平翻转
uv.x = 1.0 - uv.x;
// 🔄 垂直翻转
// uv.y = 1.0 - uv.y;
// 🔄 180°旋转 (两个都翻转)
// uv = 1.0 - uv;
gl_FragColor = texture2D(u_texture, uv);
}2.2.2 技巧 2: 重复平铺
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 🔁 重复 3x3 次
uv *= 3.0;
uv = fract(uv); // 保留小数部分,自动循环
gl_FragColor = texture2D(u_texture, uv);
}效果: 纹理会重复平铺,像墙纸一样
2.2.3 技巧 3: 偏移和缩放
uniform float u_time;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 📏 缩放 (中心化后缩放)
uv -= 0.5; // 移到中心
uv *= 2.0; // 放大 2 倍
uv += 0.5; // 移回原点
// 🎬 动画平移
uv.x += u_time * 0.1; // 向右滚动
// 🔁 循环重复
uv = fract(uv);
gl_FragColor = texture2D(u_texture, uv);
}2.2.4 技巧 4: 极坐标映射
#define PI 3.14159265359
#define TWO_PI 6.28318530718
void main() {
vec2 st = gl_FragCoord.xy / u_resolution.xy;
st -= 0.5; // 中心化
st.x *= u_resolution.x / u_resolution.y; // 宽高比校正
// 🧭 转换到极坐标
float radius = length(st) * 2.0;
float angle = atan(st.y, st.x);
// 📐 映射到 UV
vec2 uv = vec2(
angle / TWO_PI + 0.5, // 角度 → U (0~1)
radius // 半径 → V
);
gl_FragColor = texture2D(u_texture, uv);
}效果: 将矩形图像扭曲成圆形,常用于全景图、星球表面
3. 🔬 纹理过滤 (Texture Filtering)
3.1 ❓ 为什么需要过滤?
当纹理分辨率和屏幕分辨率不匹配时会出现问题:
问题1: 放大 (Magnification) 问题2: 缩小 (Minification)
屏幕像素 > 纹理像素 屏幕像素 < 纹理像素
纹理 (小) → 屏幕 (大) 纹理 (大) → 屏幕 (小)
┌──┐ ┌────┐ ┌────────┐ ┌──┐
│██│ → │████│ │████████│ → │▓▓│
└──┘ └────┘ └────────┘ └──┘
如何"填充"空白? 如何"合并"多个像素?💡 生活中的类比
放大问题:想象你有一张 10×10 厘米的照片,要放大到 100×100 厘米的海报。
- 原照片的每个"点"现在要覆盖海报上的 10×10=100 个"点"
- 这 100 个新点应该是什么颜色?直接复制(锯齿)还是渐变过渡(模糊)?
缩小问题:把一张 4000×4000 的高清图缩小到 40×40 的缩略图。
- 原图 100×100=10000 个像素要"挤"成缩略图的 1 个像素
- 该取哪些像素?平均?最近的?随机?
这就是过滤要解决的核心问题:采样率不匹配时如何优雅地处理。
3.2 🧠 底层原理:采样理论
在数字信号处理中,这是经典的重采样 (Resampling) 问题:
- 上采样 (Upsampling): 纹理放大 → 需要插值 (Interpolation)
- 下采样 (Downsampling): 纹理缩小 → 需要抗锯齿 (Anti-aliasing)
如果处理不当:
- 放大:出现马赛克/像素块
- 缩小:出现摩尔纹/闪烁(高频信息丢失导致)
3.3 📊 过滤模式对比
| 过滤模式 | 原理 | 视觉效果 | 性能 | 应用场景 |
|---|---|---|---|---|
| NEAREST | 最近邻,取最近的texel | ❌ 像素化,锯齿明显 | ⚡⚡⚡ 最快 | 像素艺术,复古游戏 |
| LINEAR | 双线性插值,混合周围4个texel | ✅ 平滑,但稍模糊 | ⚡⚡ 较快 | 大部分纹理,UI |
| MIPMAP | 多层级纹理,自动选择合适层级 | ✅ 最佳质量 | ⚡ 稍慢 | 3D场景,远近景 |
3.4 🔧 在 WebGL 中设置过滤模式
// JavaScript 端设置
const texture = textureLoader.load('image.jpg');
// 🎯 放大时使用线性插值
texture.magFilter = THREE.LinearFilter;
// 🎯 缩小时使用 Mipmap
texture.minFilter = THREE.LinearMipmapLinearFilter;
// 其他选项:
// THREE.NearestFilter - 最近邻
// THREE.LinearFilter - 线性
// THREE.NearestMipmapNearestFilter
// THREE.LinearMipmapNearestFilter
// THREE.NearestMipmapLinearFilter
// THREE.LinearMipmapLinearFilter - 三线性(最高质量)3.5 🎨 视觉对比实验
uniform sampler2D u_textureNearest; // 使用 NEAREST
uniform sampler2D u_textureLinear; // 使用 LINEAR
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 放大纹理 10 倍
uv *= 10.0;
uv = fract(uv);
vec4 colorNearest = texture2D(u_textureNearest, uv);
vec4 colorLinear = texture2D(u_textureLinear, uv);
// 左边显示 NEAREST, 右边显示 LINEAR
vec4 color = (gl_FragCoord.x < u_resolution.x * 0.5)
? colorNearest
: colorLinear;
gl_FragColor = color;
}4. 🎭 图像处理基础 - 卷积核 (Convolution Kernel)
4.1 🧮 什么是卷积核?
卷积 (Convolution) 是图像处理的核心技术,通过一个小矩阵(核/kernel)对图像进行"滑窗"运算。
原理: 当前像素的新值 = 周围像素的加权和
图像区域 卷积核 结果
┌───┬───┬───┐ ┌───┬───┬───┐
│ 1 │ 2 │ 3 │ │ a │ b │ c │ 新像素值 =
├───┼───┼───┤ × ├───┼───┼───┤ 1a + 2b + 3c +
│ 4 │ 5 │ 6 │ │ d │ e │ f │ 4d + 5e + 6f +
├───┼───┼───┤ ├───┼───┼───┤ 7g + 8h + 9i
│ 7 │ 8 │ 9 │ │ g │ h │ i │
└───┴───┴───┘ └───┴───┴───┘💡 生活类比:加权投票
想象你要决定一个社区的"平均收入",但不同家庭的权重不同:
简单平均(Box Blur 核):
- 所有家庭权重相同:(家庭1 + 家庭2 + ... + 家庭9) ÷ 9
加权平均(高斯核):
- 中心家庭权重更大:中心×4 + 邻居×2 + 对角×1,再除以总权重
边缘检测(Sobel 核):
- 不关心绝对值,只关心"差异":左侧 - 右侧
- 如果左右收入差距大 → 边缘明显
卷积核的本质:定义"如何综合周围信息来得出新结论"的权重分配策略。
4.2 🧠 底层数学原理
卷积的数学定义:
┌ 1 1 1 ┐
(I * K)(x,y) = Σ Σ I(x+i, y+j) · K(i,j)
i j关键点:
- 线性操作: 卷积是线性变换,可以叠加(先模糊再锐化 ≈ 直接应用组合核)
- 局部性: 只考虑邻域像素,适合并行计算(GPU 的强项)
- 平移不变性: 同一个核在图像任何位置效果一致
实际应用中的变体:
- 权重归一化: 确保核的所有值加起来等于 1(保持亮度)
- 边界处理: 边缘像素怎么办?(Clamp/Wrap/Mirror)
- 分离卷积: 将 2D 卷积拆成两个 1D(性能优化)
⚠️ 常见误区
- 卷积 ≠ 相关 (Correlation): 严格数学定义中,卷积需要翻转核,但在图像处理中通常直接使用(称为互相关)
- 核大小的影响: 3×3 够用吗?更大的核(5×5, 7×7)能捕捉更远的信息,但计算量呈平方增长
- 边缘伪影: 卷积后边缘一圈会有奇怪的效果,通常需要特殊处理
4.3 🔧 通用卷积核实现
uniform sampler2D u_texture; // 输入纹理
uniform vec2 u_resolution; // 分辨率(例如 800×600)
// 🧮 3x3 卷积核函数
// 参数:tex = 纹理采样器, uv = 当前像素UV坐标, kernel = 9个权重值
vec4 applyKernel(sampler2D tex, vec2 uv, float kernel[9]) {
// 📏 计算一个像素在UV空间中的大小
// 例如:800×600 的图像,一个像素 = (1/800, 1/600) 的UV单位
vec2 texelSize = 1.0 / u_resolution;
vec4 sum = vec4(0.0); // 累加器,存储加权和
int index = 0; // 当前核索引
// 🔄 遍历 3x3 邻域(9个像素)
// y 从 -1 到 1 (上中下三行)
// x 从 -1 到 1 (左中右三列)
for(int y = -1; y <= 1; y++) {
for(int x = -1; x <= 1; x++) {
// 计算邻居像素的UV偏移量
vec2 offset = vec2(float(x), float(y)) * texelSize;
// 采样邻居像素并乘以对应权重
vec4 sampleColor = texture2D(tex, uv + offset);
sum += sampleColor * kernel[index];
index++; // 移动到下一个核权重
}
}
return sum; // 返回加权和
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 📐 定义卷积核 (按行优先存储)
// 排列方式:
// kernel[0] kernel[1] kernel[2] ← 顶部行
// kernel[3] kernel[4] kernel[5] ← 中间行
// kernel[6] kernel[7] kernel[8] ← 底部行
float kernel[9];
// ... (在后面的例子中填充具体值)
vec4 color = applyKernel(u_texture, uv, kernel);
gl_FragColor = color;
}💡 代码解析:关键步骤
texelSize的计算:
- 屏幕坐标 → UV坐标的转换因子
- 800×600 图像:
texelSize = (1/800, 1/600) ≈ (0.00125, 0.00167)- 这样
offset = vec2(1, 0) * texelSize就是"向右一个像素"双重循环:
- 外层
y:从上到下遍历- 内层
x:从左到右遍历- 顺序:左上 → 中上 → 右上 → 左中 → 中中 → 右中 → 左下 → 中下 → 右下
kernel[index]的含义:
kernel[4]= 中心像素权重(当前像素)kernel[1]= 正上方像素权重kernel[3]= 正左侧像素权重为什么用
sum +=而不是=:
- 卷积是"加权求和",需要累加所有 9 个采样值
5. 🎨 常见图像滤镜实现
5.1 滤镜 1: 模糊 (Blur)
原理: 每个像素取周围像素的平均值
💡 为什么模糊能消除细节?
想象你在看一张报纸照片:
- 清晰图像: 每个像素都独立,黑白交界处非常锐利
- 模糊后: 黑白像素互相"渗透",交界变成灰色过渡带
数学上,模糊是低通滤波器:
- 保留低频信息(大块颜色、轮廓)
- 去除高频信息(细节、纹理、噪点)
应用:降噪、柔化皮肤、景深效果、毛玻璃。
5.1.1 Box Blur (方框模糊)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 📦 3x3 均值核(所有邻居一视同仁) 注意GLSL2.0 和3.0的语法差异
float kernel[9] = float[](
1.0/9.0, 1.0/9.0, 1.0/9.0, // 上排:每个权重 1/9
1.0/9.0, 1.0/9.0, 1.0/9.0, // 中排:每个权重 1/9
1.0/9.0, 1.0/9.0, 1.0/9.0 // 下排:每个权重 1/9
);
// ✅ 权重总和 = 9 × (1/9) = 1(保持亮度不变)
vec4 color = applyKernel(u_texture, uv, kernel);
gl_FragColor = color;
}特点:
- ✅ 计算简单,所有权重相同
- ❌ 模糊效果较"方",不够自然
- ⚠️ 方向性伪影(因为是矩形窗口)
5.1.2 Gaussian Blur (高斯模糊)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 🔔 高斯核(中心权重最大,距离越远权重越小)
float kernel[9] = float[](
1.0/16.0, 2.0/16.0, 1.0/16.0, // 顶部行:角落1,边2,角落1
2.0/16.0, 4.0/16.0, 2.0/16.0, // 中间行:边2,中心4,边2
1.0/16.0, 2.0/16.0, 1.0/16.0 // 底部行:角落1,边2,角落1
);
// 🧮 权重分析:
// - 中心像素: 4/16 = 25%(最重要)
// - 上下左右: 4 × 2/16 = 50%(次重要)
// - 四个角: 4 × 1/16 = 25%(最不重要)
// - 总和: 16/16 = 1 ✅
vec4 color = applyKernel(u_texture, uv, kernel);
gl_FragColor = color;
}🧠 高斯分布的魔力
数学背景: 高斯函数(正态分布)
G(x, y) = (1 / 2πσ²) · e^(-(x²+y²) / (2σ²)) ↑ ↑ 归一化因子 距离越远,权重指数下降为什么用高斯?
- 符合视觉直觉: 离中心越近的信息越重要
- 数学优雅: 可分离(能拆成两个1D卷积优化性能)
- 中心极限定理: 多次卷积趋向高斯(无论初始核是什么)
- 各向同性: 四面八方模糊程度一致,无方向偏好
权重分布模式:
1 2 1 ← 权重递减规律 2 4 2 ← 对称性 1 2 1 ← 1:2:4 = 二项式系数(帕斯卡三角形)
优势: 比 Box Blur 更自然,是行业标准(Photoshop 的"高斯模糊"就是这个)
5.1.3 优化: 可分离高斯模糊 (Separable Gaussian)
// ⚡ 性能优化: 将 2D 卷积分解为两次 1D 卷积
// 复杂度: O(9) → O(3+3) = O(6),节省 33% 计算量
uniform bool u_horizontal; // true = 水平pass, false = 垂直pass
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec2 texelSize = 1.0 / u_resolution;
vec4 color = vec4(0.0);
if(u_horizontal) {
// 🔹 水平 pass(只在 X 方向模糊)
color += texture2D(u_texture, uv + vec2(-1.0, 0.0) * texelSize) * 1.0/4.0; // 左邻居
color += texture2D(u_texture, uv) * 2.0/4.0; // 当前
color += texture2D(u_texture, uv + vec2(1.0, 0.0) * texelSize) * 1.0/4.0; // 右邻居
// 权重 [1, 2, 1] 归一化为 [1/4, 2/4, 1/4]
} else {
// 🔸 垂直 pass(只在 Y 方向模糊)
color += texture2D(u_texture, uv + vec2(0.0, -1.0) * texelSize) * 1.0/4.0; // 上邻居
color += texture2D(u_texture, uv) * 2.0/4.0; // 当前
color += texture2D(u_texture, uv + vec2(0.0, 1.0) * texelSize) * 1.0/4.0; // 下邻居
}
gl_FragColor = color;
}💡 可分离卷积的原理
数学证明:高斯核可以分解成两个向量的外积:
高斯 2D 核: 分解为: ┌ 1 2 1 ┐ ┌ 1 ┐ ┌ 1 2 1 ┐ │ 2 4 2 │ = 1/16 × │ 2 │ × └ 1 2 1 ┘ └ 1 ┘ 即:先用 [1, 2, 1] 在水平方向卷积 再用 [1, 2, 1] 在垂直方向卷积性能提升:
- 传统 3×3: 每个像素 9 次采样 + 9 次乘法 = 18 次操作
- 可分离: 水平 3 次 + 垂直 3 次 = 6 次采样 + 6 次乘法 = 12 次操作
- 更大核性能更明显: 7×7 核从 49 次降到 14 次(节省 71%)
⚠️ 注意: 需要渲染到中间纹理(Framebuffer),不能在一个 Pass 完成。
使用方法 (Three.js 示例):
// 第一次渲染:水平模糊
shader.uniforms.u_horizontal.value = true;
renderer.setRenderTarget(tempRenderTarget); // 渲染到临时纹理
renderer.render(scene, camera);
// 第二次渲染:垂直模糊
shader.uniforms.u_horizontal.value = false;
shader.uniforms.u_texture.value = tempRenderTarget.texture; // 使用上一步结果
renderer.setRenderTarget(null); // 渲染到屏幕
renderer.render(scene, camera);5.2 滤镜 2: 锐化 (Sharpen)
原理: 增强边缘,突出细节
💡 锐化与模糊是相反的操作
- 模糊: 混合周围像素 → 减少差异 → 低通滤波器
- 锐化: 放大差异 → 增强边缘 → 高通滤波器
类比: 拍照时的"清晰度"滑块
- 向左:柔化(模糊)
- 向右:锐化(强调细节、纹理)
应用场景: 恢复失焦照片、强调文字可读性、艺术效果
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// ⚔️ 锐化核(标准 Laplacian 锐化)
float kernel[9] = float[](
0.0, -1.0, 0.0, // 上排:只抑制正上方
-1.0, 5.0, -1.0, // 中排:强化中心,抑制左右
0.0, -1.0, 0.0 // 下排:只抑制正下方
);
// ⚖️ 权重总和 = 5 - 4 = 1(保持整体亮度)
vec4 color = applyKernel(u_texture, uv, kernel);
gl_FragColor = color;
}🧠 锐化的数学原理
推导过程:
新值 = 中心 × 5 + 上下左右 × (-1) = 中心 × 5 - (上 + 下 + 左 + 右) = 中心 × 1 + 中心 × 4 - 周围之和 = 原值 + 4×(中心 - 周围平均) └──────────┬──────────┘ 边缘增强项关键洞察:
- 如果中心像素 = 周围平均 → 边缘增强项 = 0 → 保持原样(平坦区域不变)
- 如果中心像素 > 周围平均 → 边缘增强项 > 0 → 变更亮(亮边缘更亮)
- 如果中心像素 < 周围平均 → 边缘增强项 < 0 → 变更暗(暗边缘更暗)
本质: 锐化 = 原图 + 高频成分(边缘/细节)
⚠️ 过度锐化的问题:
- 光晕 (Halo): 边缘周围出现白边或暗边
- 噪点放大: 噪点也是"高频",会被增强
- 颜色溢出: 可能产生超出 [0, 1] 的值(需要 clamp)
// 可调强度的锐化
uniform float u_sharpness; // 0.0 ~ 2.0
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec4 original = texture2D(u_texture, uv);
// 锐化核
float kernel[9] = float[](
0.0, -1.0, 0.0,
-1.0, 5.0, -1.0,
0.0, -1.0, 0.0
);
vec4 sharpened = applyKernel(u_texture, uv, kernel);
// 在原图和锐化之间插值(避免过度锐化)
vec4 color = mix(original, sharpened, u_sharpness);
gl_FragColor = clamp(color, 0.0, 1.0); // 防止颜色溢出
}5.3 滤镜 3: 边缘检测 (Edge Detection)
检测图像中亮度剧烈变化的地方
💡 什么是边缘?
在图像中,边缘 = 像素值突变的地方:
- 物体轮廓(背景和前景的分界)
- 纹理变化(木纹、布料褶皱)
- 阴影边界
数学上: 边缘 = 图像的梯度 (Gradient) 大的地方
- 梯度 = 变化率(像高度图的"陡峭程度")
- 平坦区域梯度 ≈ 0,边缘梯度大
应用: 素描风格、AR 追踪、机器视觉、医学影像分析
5.3.1 Sobel 算子 (经典)
uniform sampler2D u_texture;
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec2 texelSize = 1.0 / u_resolution;
// 📐 Sobel X 核(检测垂直边缘,即水平方向的变化)
float sobelX[9] = float[](
-1.0, 0.0, 1.0, // 左列 -1, 右列 +1
-2.0, 0.0, 2.0, // 中间行权重加倍
-1.0, 0.0, 1.0
);
// 逻辑:右侧 - 左侧 = 水平变化量
// 📐 Sobel Y 核(检测水平边缘,即垂直方向的变化)
float sobelY[9] = float[](
-1.0, -2.0, -1.0, // 上排 -1, 下排 +1
0.0, 0.0, 0.0,
1.0, 2.0, 1.0
);
// 逻辑:下方 - 上方 = 垂直变化量
// 🔍 计算梯度
float gx = 0.0, gy = 0.0;
int index = 0;
for(int y = -1; y <= 1; y++) {
for(int x = -1; x <= 1; x++) {
vec2 offset = vec2(float(x), float(y)) * texelSize;
// 只取红色通道作为亮度(或使用 dot(rgb, vec3(0.299, 0.587, 0.114)))
float intensity = texture2D(u_texture, uv + offset).r;
gx += intensity * sobelX[index]; // X方向梯度累加
gy += intensity * sobelY[index]; // Y方向梯度累加
index++;
}
}
// 📊 梯度幅值(勾股定理)
float edge = sqrt(gx * gx + gy * gy);
// 可选:计算梯度方向
// float angle = atan(gy, gx);
vec3 color = vec3(edge);
gl_FragColor = vec4(color, 1.0);
}🧠 Sobel 算子的设计原理
为什么是 [-1, 0, 1] 和 [-2, 0, 2]?
中心差分: 用"右边 - 左边"估计中心的导数
导数 ≈ (f(x+1) - f(x-1)) / 2权重加倍: 中间行权重 2,是为了更重视正中心的变化
Sobel X: 简单差分: -1 0 1 -1 0 1 -2 0 2 vs -1 0 1 -1 0 1 -1 0 1隐含的平滑: Sobel = 差分 + 高斯平滑的组合
- X方向差分 [-1, 0, 1]
- Y方向平滑 [1, 2, 1] (减少噪声影响)
梯度向量的含义:
(gx, gy) = 梯度向量 |G| = sqrt(gx² + gy²) = 变化强度(边缘强度) θ = atan(gy/gx) = 变化方向(边缘法线方向)
效果: 白色线条表示边缘,黑色表示平坦区域
优化技巧:
// 使用 length() 代替 sqrt() 可能更快(取决于GPU)
float edge = length(vec2(gx, gy));
// 曼哈顿距离(更快但不精确)
// float edge = abs(gx) + abs(gy);5.3.2 Laplacian 算子 (更简单)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 🎯 Laplacian 核 (二阶导数)
float kernel[9] = float[](
0.0, 1.0, 0.0,
1.0, -4.0, 1.0,
0.0, 1.0, 0.0
);
vec4 color = applyKernel(u_texture, uv, kernel);
// ⚠️ Laplacian 可能产生负值,需要处理
color = abs(color); // 取绝对值
// 或者: color = color * 0.5 + 0.5; // 映射到 [0,1]
gl_FragColor = color;
}5.4 滤镜 4: 浮雕 (Emboss)
模拟凹凸效果
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 🗿 浮雕核
float kernel[9] = float[](
-2.0, -1.0, 0.0,
-1.0, 1.0, 1.0,
0.0, 1.0, 2.0
);
vec4 color = applyKernel(u_texture, uv, kernel);
// 🎨 通常转为灰度 + 偏移到中灰
float gray = (color.r + color.g + color.b) / 3.0;
gray = gray * 0.5 + 0.5; // 映射到 [0,1]
gl_FragColor = vec4(vec3(gray), 1.0);
}6. 🎨 高级图像效果
6.1 效果 1: 色彩调整
💡 色彩调整的核心概念
图像的"外观"由三大要素控制:
- 亮度 (Brightness): 整体明暗程度
- 对比度 (Contrast): 最亮和最暗之间的差距
- 饱和度 (Saturation): 颜色的鲜艳程度
这三者是后期处理的基础,几乎所有调色都从这里开始。
6.1.1 亮度 (Brightness)
uniform float u_brightness; // -1.0 ~ 1.0(负值变暗,正值变亮)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec4 color = texture2D(u_texture, uv);
// ☀️ 线性加法(所有像素统一增加/减少亮度)
color.rgb += u_brightness;
// 例如: (0.3, 0.5, 0.2) + 0.2 = (0.5, 0.7, 0.4)
gl_FragColor = color;
}🧠 亮度调整的本质
- 线性偏移: 所有像素值加/减相同的量
- 不改变对比度: 亮暗差距保持不变
- 可能溢出: 需要
clamp(color, 0.0, 1.0)防止超出范围类比: 给所有学生的成绩都加 10 分,排名不变。
6.1.2 对比度 (Contrast)
uniform float u_contrast; // 0.0 ~ 2.0(1.0 = 原图,<1 降低,>1 增强)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec4 color = texture2D(u_texture, uv);
// 📊 以 0.5(中灰)为中心缩放
// 原理:(值 - 中心) × 系数 + 中心
color.rgb = (color.rgb - 0.5) * u_contrast + 0.5;
// 展开理解:
// 假设原值 = 0.8, 对比度 = 2.0
// (0.8 - 0.5) × 2.0 + 0.5 = 0.3 × 2.0 + 0.5 = 1.1(变更亮)
//
// 假设原值 = 0.3, 对比度 = 2.0
// (0.3 - 0.5) × 2.0 + 0.5 = -0.2 × 2.0 + 0.5 = 0.1(变更暗)
gl_FragColor = color;
}🧠 对比度调整的本质
- 围绕中心点缩放: 比 0.5 亮的更亮,比 0.5 暗的更暗
- 拉伸动态范围: 增加亮暗差距
- 中灰不变: 0.5 始终保持 0.5
类比: 拉伸橡皮筋,中心点固定,两端向外拉(增强)或向内收(降低)。
数学形式: 线性变换
y = a(x - c) + c,其中 c = 0.5(支点)
6.1.3 饱和度 (Saturation)
uniform float u_saturation; // 0.0 ~ 2.0(0 = 黑白,1 = 原图,>1 = 过饱和)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec4 color = texture2D(u_texture, uv);
// 🎨 计算感知灰度值(Luminance)
// 权重来自 ITU-R BT.601 标准(模拟人眼对不同颜色的敏感度)
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
// ↑ ↑ ↑
// 红29% 绿59% 蓝11%(人眼对绿色最敏感)
// 🌈 在灰度和原色之间线性插值
color.rgb = mix(vec3(gray), color.rgb, u_saturation);
// mix(A, B, t) = A × (1-t) + B × t
//
// u_saturation = 0.0: 完全灰度(黑白照片)
// u_saturation = 0.5: 50% 灰度 + 50% 原色(复古胶片感)
// u_saturation = 1.0: 100% 原色(保持原样)
// u_saturation = 2.0: 200% 原色 - 100% 灰度(过度鲜艳)
gl_FragColor = color;
}🧠 饱和度调整的本质
什么是饱和度?
- 高饱和度: 颜色"纯"(红就是红,不掺杂其他颜色)
- 低饱和度: 颜色"灰"(接近灰色,但略带色调)
- 零饱和度: 完全灰色(无色彩信息)
数学原理:
饱和度调整 = 灰度 + 饱和度 × (原色 - 灰度) └───────┬───────┘ 色彩偏离量为什么用这些灰度权重?
- 人眼对绿光最敏感(59%),其次是红光(29%),蓝光最不敏感(11%)
- 纯粹的 (R+G+B)/3 会让绿色看起来过暗
- 这些权重来自人类视觉感知的科学测量
类比: 给彩色照片加"灰色滤镜",然后用滑块控制混合比例。
6.2 效果 2: 色调映射 (Tone Mapping)
将 HDR (高动态范围) 映射到 LDR (低动态范围)
💡 为什么需要色调映射?
问题背景:
- 真实世界: 亮度范围极大(太阳 vs 阴影 可以相差 100000:1)
- 显示器: 只能显示有限范围(约 1000:1,[0.0, 1.0] 映射到黑白)
- 渲染引擎: 可以计算超出 [0, 1] 的 HDR 值(例如火焰 = 10.0)
色调映射的作用: 把"无限"范围压缩到 [0, 1],同时保留视觉细节
类比: 用相机拍日落,天空很亮、地面很暗:
- 直接拍 → 天空过曝(全白)或地面欠曝(全黑)
- HDR 合成 → 同时保留天空细节和地面细节
- 色调映射 → 把 HDR 结果转换成屏幕能显示的范围
// 📸 Reinhard Tone Mapping(最简单,类似人眼响应)
vec3 reinhardToneMapping(vec3 color) {
// 公式:输出 = 输入 / (输入 + 1)
return color / (color + vec3(1.0));
// 特性分析:
// 输入 → 输出
// 0.0 → 0.0 (黑色保持黑色)
// 0.5 → 0.33 (中间调压缩)
// 1.0 → 0.5 (白色变灰)
// 5.0 → 0.83 (超亮被压缩但不丢失)
// ∞ → 1.0 (无限亮也只是白色)
}
// 🎬 ACES 电影级 Tone Mapping(行业标准,更自然)
vec3 acesToneMapping(vec3 color) {
// Academy Color Encoding System (奥斯卡科学技术奖)
// 用于好莱坞电影后期的标准色调映射
// 魔法数字来自曲线拟合(模拟胶片响应)
float a = 2.51;
float b = 0.03;
float c = 2.43;
float d = 0.59;
float e = 0.14;
// 有理函数形式:(ax²+bx) / (cx²+dx+e)
return clamp(
(color * (a * color + b)) / (color * (c * color + d) + e),
0.0,
1.0
);
// 特点:
// - S型曲线(Sigmoid),更符合视觉感知
// - 高光rolloff平滑(不会突然截断)
// - 色彩保护更好(不容易偏色)
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec4 color = texture2D(u_texture, uv);
// 应用色调映射
color.rgb = acesToneMapping(color.rgb);
gl_FragColor = color;
}🧠 两种算法对比
特性 Reinhard ACES 公式 x/(x+1) 有理函数(复杂) 计算量 ⚡ 极快(2条指令) ⚡⚡ 较快(约10条指令) 视觉效果 高光容易"灰" ✅ 高光更自然 色彩偏移 ⚠️ 可能偏色 ✅ 色彩准确 应用场景 实时游戏、快速预览 电影、高质量渲染 何时使用?
- 如果纹理已经是 LDR(普通照片):不需要色调映射
- 如果纹理来自 HDR 渲染(光照计算):必须色调映射
- 移动端/性能敏感:Reinhard
- 追求质量:ACES
6.3 效果 3: 颜色分级 (Color Grading)
uniform float u_temperature; // -1.0 ~ 1.0 (冷暖调)
uniform float u_tint; // -1.0 ~ 1.0 (品红/绿)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec4 color = texture2D(u_texture, uv);
// 🌡️ 色温调整
color.r += u_temperature * 0.1;
color.b -= u_temperature * 0.1;
// 🎨 色调调整
color.r += u_tint * 0.05;
color.g -= abs(u_tint) * 0.05;
gl_FragColor = color;
}6.4 效果 4: 晕影 (Vignette)
边缘变暗效果,常用于摄影
uniform float u_vignetteStrength; // 0.0 ~ 1.0
uniform float u_vignetteSoftness; // 0.0 ~ 1.0
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec4 color = texture2D(u_texture, uv);
// 📐 计算到中心的距离
vec2 center = uv - 0.5;
center.x *= u_resolution.x / u_resolution.y; // 宽高比校正
float dist = length(center);
// 🌑 晕影遮罩
float vignette = smoothstep(
u_vignetteStrength,
u_vignetteStrength - u_vignetteSoftness,
dist
);
color.rgb *= vignette;
gl_FragColor = color;
}6.5 效果 5: 色差 (Chromatic Aberration)
模拟镜头色散
💡 什么是色差?
物理原理:
- 光通过镜头时,不同波长(颜色)的折射率不同
- 红光(长波)折射少 → 落在更远的位置
- 蓝光(短波)折射多 → 落在更近的位置
- 结果:边缘出现红蓝分离的彩色条纹
摄影中的表现:
- 廉价镜头:明显色差(瑕疵)
- 高端镜头:特殊镜片矫正色差
- 艺术效果:故意添加色差营造复古/梦幻感
类比: 三棱镜分解白光成彩虹,镜头色差就是"不完美的三棱镜效应"。
uniform float u_aberrationStrength; // 0.0 ~ 0.05(建议 0.002 ~ 0.01)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 📐 计算从中心到当前像素的向量
vec2 toCenter = uv - 0.5; // 中心点为 (0.5, 0.5)
// 边缘的 toCenter 更大 → 边缘色差更明显(符合物理规律)
// 🔴 红色通道向外偏移(波长最长,折射最少)
vec2 uvR = uv + toCenter * u_aberrationStrength;
// 🟢 绿色通道不变(作为参考,也可以略微偏移)
vec2 uvG = uv;
// 🔵 蓝色通道向内偏移(波长最短,折射最多)
vec2 uvB = uv - toCenter * u_aberrationStrength;
// 🎨 分别采样三个通道并重新组合
float r = texture2D(u_texture, uvR).r; // 从稍外侧位置取红色
float g = texture2D(u_texture, uvG).g; // 从原位置取绿色
float b = texture2D(u_texture, uvB).b; // 从稍内侧位置取蓝色
gl_FragColor = vec4(r, g, b, 1.0);
}🧠 技术细节
为什么要"分别采样"?
- 传统采样:
texture2D(u_texture, uv)返回 (R, G, B, A) 来自同一位置- 色差采样:R、G、B 来自不同位置,模拟不同波长聚焦到不同位置
距离中心越远,色差越明显:
- 中心 (0.5, 0.5):
toCenter = (0, 0)→ 无偏移 → 无色差- 角落 (1.0, 1.0):
toCenter = (0.5, 0.5)→ 最大偏移 → 明显色差高级变体:
glsl// 径向色差(基于距离而非方向) float dist = length(toCenter); vec2 direction = normalize(toCenter); vec2 uvR = uv + direction * dist * u_aberrationStrength; vec2 uvB = uv - direction * dist * u_aberrationStrength;
效果: 边缘出现彩色条纹,营造复古胶片感或镜头失真效果
⚠️ 注意事项:
- 强度不要过大(0.01 已经很明显),否则会晕眩
- 适合暗色调、复古风格、赛博朋克美学
- 不适合需要清晰度的 UI、文字
6.6 效果 6: 像素化 (Pixelation)
uniform float u_pixelSize; // 像素块大小(例如 4.0 = 4×4 像素块)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 🎮 降低分辨率的核心算法
// 步骤1: 将UV转回像素坐标
vec2 pixelCoord = uv * u_resolution.xy;
// 步骤2: 除以块大小再向下取整(量化)
vec2 pixelated = floor(pixelCoord / u_pixelSize) * u_pixelSize;
// 例如: 块大小=4, 坐标15 → 15/4=3.75 → floor=3 → 3×4=12
// 坐标16、17、18、19 都会变成 12(形成一个4×4的块)
// 步骤3: 转回UV坐标
pixelated /= u_resolution.xy;
// 步骤4: 用量化后的UV采样(同一块内所有像素采样同一点)
vec4 color = texture2D(u_texture, pixelated);
gl_FragColor = color;
}🧠 像素化的原理
本质: 强制降低采样频率,多个屏幕像素共享同一个纹理采样值
数学过程:
原始分辨率: 800×600 块大小: 4×4 实际渲染分辨率: (800/4) × (600/4) = 200×150 降低到原来的 1/16!类比: 把高清照片强制降低成 8-bit 游戏画质
- 原图:每个像素独立
- 像素化:4×4=16 个像素共享同一颜色(形成可见的"马赛克")
应用场景:
- 复古游戏风格(FC、GB 模拟)
- 隐私保护(模糊敏感区域)
- 艺术效果(像素艺术)
- 性能优化(降低渲染负担)
6.7 效果 7: 扫描线 (Scanlines)
CRT 显示器效果
uniform float u_scanlineIntensity; // 0.0 ~ 1.0(扫描线明显程度)
uniform float u_scanlineCount; // 扫描线数量(通常 = 屏幕高度)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec4 color = texture2D(u_texture, uv);
// 📺 生成扫描线图案(正弦波)
// sin() 产生 [-1, 1] 的波动,映射到 [0, 1]
float scanline = sin(uv.y * u_scanlineCount * 3.14159) * 0.5 + 0.5;
// ↑ ↑ ↑ ↑
// Y坐标 频率(线条数量) π值 归一化到[0,1]
// 控制扫描线强度(在"完全不变"和"扫描线图案"之间插值)
scanline = mix(1.0, scanline, u_scanlineIntensity);
// intensity=0: scanline=1.0(无效果)
// intensity=1: scanline=正弦值(全强度)
// 用扫描线图案调制颜色(暗线位置变暗)
color.rgb *= scanline;
gl_FragColor = color;
}💡 CRT 显示器的扫描线
历史背景:
- 老式CRT显示器用电子束逐行扫描屏幕
- 相邻扫描行之间有物理间隙(黑色区域)
- 形成了特征性的"横向暗线"图案
为什么要模拟?
- 复古怀旧(街机、老游戏机)
- 增加视觉"质感"(不是所有人都喜欢完美平滑)
- 隐藏低分辨率的锯齿(反直觉,但确实有效)
完整 CRT 效果通常包含:
glsl// 1. 扫描线 float scanline = sin(uv.y * 600.0 * 3.14159) * 0.5 + 0.5; // 2. 曲率变形(模拟球面屏) uv = uv * 2.0 - 1.0; uv *= 1.0 + 0.1 * dot(uv, uv); // 径向畸变 uv = uv * 0.5 + 0.5; // 3. 色彩溢出(电子束晕染) vec3 bloom = texture2D(u_texture, uv).rgb * 0.3; // 4. RGB 分离(模拟三色荧光粉) // ...
7. 🌀 纹理扭曲效果
💡 什么是纹理扭曲?
之前的效果都是直接修改颜色(模糊、锐化、调色等),现在我们要修改采样位置。
核心思想:
- 不改变纹理本身
- 改变每个像素"从哪里采样"
- 结果:图像看起来被"扭曲"或"变形"
类比: 透过水波、放大镜、哈哈镜看照片,照片本身没变,但视线被"弯曲"了。
7.1 扭曲 1: 波纹扭曲
uniform float u_time; // 时间(用于动画)
uniform vec2 u_center; // 扭曲中心(例如 (0.5, 0.5))
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 🌊 计算到中心的距离和方向
vec2 toCenter = uv - u_center;
float dist = length(toCenter); // 欧几里得距离
// 📐 波纹公式(随距离和时间变化的正弦波)
// dist * 30.0: 控制波的密度(数字越大,波纹越密集)
// u_time * 5.0: 控制波的传播速度
// * 0.01: 控制扭曲强度(偏移量)
float wave = sin(dist * 30.0 - u_time * 5.0) * 0.01;
// 🌀 沿径向方向扭曲(从中心向外)
vec2 direction = normalize(toCenter); // 单位方向向量
vec2 distortedUV = uv + direction * wave;
// └──────┬──────┘
// 偏移量 = 方向 × 强度
// 从扭曲后的位置采样
vec4 color = texture2D(u_texture, distortedUV);
gl_FragColor = color;
}🧠 波纹公式解析
完整公式:
wave = sin(dist × 频率 - time × 速度) × 振幅参数作用:
dist × 30.0: 空间频率
- 距离中心越远,相位变化越大 → 形成同心圆波纹
- 30.0 可以改成 10.0(宽波纹)或 50.0(窄波纹)
time × 5.0: 时间项(负号表示向外传播)
- 时间增加 → 相位减少 → 波向外"推进"
- 5.0 控制速度,越大越快
× 0.01: 振幅(扭曲强度)
- 决定偏移的最大距离
- 0.01 = UV空间的 1%
效果变体:
glsl// 多重波纹(干涉图案) float wave1 = sin(dist * 20.0 - u_time * 3.0) * 0.01; float wave2 = sin(dist * 35.0 + u_time * 2.0) * 0.005; float wave = wave1 + wave2; // 衰减波纹(远处减弱) float wave = sin(dist * 30.0 - u_time * 5.0) * 0.01 / (dist + 1.0);
7.2 扭曲 2: 玻璃折射
uniform sampler2D u_normalMap; // 法线贴图(模拟玻璃表面凹凸)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 📐 从法线贴图读取扭曲方向
// 法线贴图通常是 RGB 格式,存储 3D 向量 (x, y, z)
vec3 normal = texture2D(u_normalMap, uv).rgb;
// 🔄 从 [0, 1] 映射到 [-1, 1](法线贴图的标准编码)
// 原因:RGB 不能直接存储负数,所以用 0.5 表示 0
// 例如: RGB(0.5, 0.5, 1.0) → 法线(0, 0, 1) → 指向正上方
normal = normal * 2.0 - 1.0;
// 🌀 用法线的 XY 分量扭曲 UV
// XY 表示表面倾斜方向,模拟光线折射的方向
float refractionStrength = 0.05; // 折射强度(类似玻璃厚度)
vec2 distortedUV = uv + normal.xy * refractionStrength;
// 从扭曲后的位置采样(透过凹凸玻璃看背景)
vec4 color = texture2D(u_texture, distortedUV);
gl_FragColor = color;
}🧠 法线贴图折射的原理
什么是法线贴图?
- 一张特殊的图片,每个像素存储一个法线向量(表面朝向)
- 常见于 3D 渲染,用于模拟凹凸(而不真正改变几何体)
在折射中的应用:
平坦表面: 法线 = (0, 0, 1) → RGB(0.5, 0.5, 1.0) → 蓝紫色
- XY = (0, 0) → 无偏移 → 不扭曲
凸起表面: 法线 = (0.5, 0, 1) → RGB(0.75, 0.5, 1.0)
- XY = (0.5, 0) → 向右偏移 → 向右扭曲
凹陷表面: 法线 = (-0.5, 0, 1) → RGB(0.25, 0.5, 1.0)
- XY = (-0.5, 0) → 向左偏移 → 向左扭曲
物理类比:
- 想象透过有纹理的磨砂玻璃看东西
- 玻璃表面的凹凸导致光线弯曲
- 法线贴图就是这个"凹凸地图"
应用场景:
- 水面波动(用噪声生成法线贴图)
- 热空气扭曲
- 玻璃/冰块效果
- 透明材质扰动
7.3 扭曲 3: 鱼眼镜头
uniform float u_fisheyeStrength; // 0.0 ~ 1.0(推荐 0.3 ~ 0.6)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// 步骤1: 中心化到 [-1, 1](原点在中心)
uv = uv * 2.0 - 1.0;
// 步骤2: 宽高比校正(防止椭圆变形)
float aspectRatio = u_resolution.x / u_resolution.y;
uv.x *= aspectRatio; // 宽屏会拉伸X,这里补偿回来
// 🐟 鱼眼畸变公式(径向畸变)
float dist = length(uv); // 到中心的距离
// 畸变因子:距离越远,畸变越大
float distortion = 1.0 + dist * dist * u_fisheyeStrength;
// └───┬───┘
// 平方项 → 边缘畸变剧烈
// 应用畸变(除法 = 向内"挤压")
vec2 distortedUV = uv / distortion;
// dist=0(中心): distortion=1.0 → 不变
// dist=1(边缘): distortion=1.0+strength → 大幅压缩
// 步骤3: 还原宽高比和归一化
distortedUV.x /= aspectRatio; // 撤销宽高比校正
distortedUV = distortedUV * 0.5 + 0.5; // 从 [-1,1] 回到 [0,1]
vec4 color = texture2D(u_texture, distortedUV);
// 🌑 边缘晕影(模拟镜头暗角)
float vignette = smoothstep(1.0, 0.5, dist);
// dist=0.5: vignette=1.0(完全可见)
// dist=1.0: vignette=0.0(完全黑)
color.rgb *= vignette;
gl_FragColor = color;
}🧠 鱼眼镜头的原理
什么是鱼眼效果?
- 超广角镜头(> 180°)产生的球面透视畸变
- 直线变弯曲(除了通过中心的线)
- 边缘被"压缩",中心相对"放大"
数学模型 - 径向畸变:
原始位置: (x, y) 距离: r = sqrt(x² + y²) 畸变后距离: r' = r / (1 + k·r²) └─┬─┘ 桶形畸变系数 新位置: (x', y') = (x, y) · (r' / r)为什么除法?
- 乘法: 向外拉伸(枕形畸变,远处更远)
- 除法: 向内压缩(桶形畸变,远处被"吸"向中心)
- 鱼眼 = 桶形畸变 → 用除法
视觉效果:
- 中心:几乎无畸变
- 中间:轻度弯曲
- 边缘:强烈压缩,180° 视野都塞进画面
应用场景:
- 全景查看器
- 极限运动视频(GoPro 效果)
- 艺术创作(超现实感)
- VR 畸变校正的逆向(预畸变)
⚠️ 常见问题:
- 不校正宽高比: 会产生椭圆形鱼眼(应该是圆形)
- 强度过大: 边缘会被压缩到同一点(奇点)
- 采样超界:
distortedUV可能超出 [0, 1],需要处理边界
8. 🎓 本章总结
8.1 📚 推荐练习(从易到难)
⭐ 入门级
- 单色滤镜:将图像转为指定色调(例如全蓝色、棕褐色)
- 镜像翻转:实现上下/左右/对角线翻转
- 亮度分级:根据亮度将图像分为 5 个档次(posterize 效果)
⭐⭐ 进阶级 4. Instagram 滤镜:
- 组合饱和度 + 对比度 + 晕影 + 色温
- 添加 UI 滑块控制每个参数
- 实现复古胶片、冷色调、暖色调三种预设
实时边缘卡通化:
- Sobel 边缘检测 → 二值化(黑白线条)
- 原图色彩分级(减少颜色数量)
- 叠加边缘和分级色彩(类似手绘)
动态水波:
- 用
sin(dist - time)生成波纹 - 点击屏幕产生涟漪动画
- 多个涟漪的叠加(干涉效果)
- 用
⭐⭐⭐ 挑战级 7. 多级模糊景深:
- 生成深度图(近处清晰,远处模糊)
- 根据深度控制高斯模糊强度
- 优化性能(降采样 + 可分离卷积)
完整的 CRT 显示器模拟:
- 扫描线 + RGB 像素网格
- 曲面屏幕变形(桶形畸变)
- 色彩溢出(Bloom)
- 抖动/噪点动画
艺术风格滤镜:
- 油画效果(Kuwahara 滤镜)
- 素描效果(Sobel + 反色 + 纸张纹理)
- 水彩效果(边缘保留模糊 + 色彩扩散)
8.2 🔗 学习资源
必读文档:
深入研究:
- GPU Gems - Image Processing
- Shadertoy - 海量实例学习
- Three.js Examples - 查看后期处理实现
工具推荐:
- ShaderToy - 在线调试
- GLSL Sandbox - 简化环境
- Spector.js - WebGL 调试