Skip to content

🖼️ 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)

⚠️ 常见陷阱:

  1. 坐标系差异: WebGL 的 UV 原点在左下角,但 Canvas 2D/图像文件通常是左上角为原点
  2. 超出范围: UV 可以超出 [0, 1],行为取决于纹理的 Wrap 模式(重复/钳制/镜像)
  3. 宽高比: 屏幕坐标通常需要宽高比校正,但 UV 坐标已经归一化,不需要!

2. 🔧 纹理采样基础 ​

2.1 📐 最简单的纹理采样 ​

glsl
#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) 这一行代码背后发生了什么?

  1. 定位: GPU 根据 UV 坐标计算出对应的纹理像素位置

    • UV(0.5, 0.5) + 1024×1024纹理 → Texel[512, 512]
  2. 读取: 从 GPU 纹理内存中读取该位置的 RGBA 值

    • 这个过程极快,因为纹理缓存专门为此优化
  3. 过滤: 如果 UV 坐标落在两个 texel 之间,会进行插值

    • 类似"四舍五入" vs "线性混合"
  4. 返回: 返回一个 vec4(r, g, b, a),每个分量都在 [0.0, 1.0]

类比:就像用放大镜(UV坐标)查看一张海报(纹理),你指向的位置(坐标)决定了你看到的颜色。

JavaScript 端加载纹理 (Three.js 示例):

javascript
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: 翻转纹理 ​

glsl
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: 重复平铺 ​

glsl
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: 偏移和缩放 ​

glsl
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: 极坐标映射 ​

glsl
#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
// 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 🎨 视觉对比实验 ​

glsl
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

关键点:

  1. 线性操作: 卷积是线性变换,可以叠加(先模糊再锐化 ≈ 直接应用组合核)
  2. 局部性: 只考虑邻域像素,适合并行计算(GPU 的强项)
  3. 平移不变性: 同一个核在图像任何位置效果一致

实际应用中的变体:

  • 权重归一化: 确保核的所有值加起来等于 1(保持亮度)
  • 边界处理: 边缘像素怎么办?(Clamp/Wrap/Mirror)
  • 分离卷积: 将 2D 卷积拆成两个 1D(性能优化)

⚠️ 常见误区

  1. 卷积 ≠ 相关 (Correlation): 严格数学定义中,卷积需要翻转核,但在图像处理中通常直接使用(称为互相关)
  2. 核大小的影响: 3×3 够用吗?更大的核(5×5, 7×7)能捕捉更远的信息,但计算量呈平方增长
  3. 边缘伪影: 卷积后边缘一圈会有奇怪的效果,通常需要特殊处理

4.3 🔧 通用卷积核实现 ​

glsl
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;
}

💡 代码解析:关键步骤

  1. texelSize 的计算:

    • 屏幕坐标 → UV坐标的转换因子
    • 800×600 图像:texelSize = (1/800, 1/600) ≈ (0.00125, 0.00167)
    • 这样 offset = vec2(1, 0) * texelSize 就是"向右一个像素"
  2. 双重循环:

    • 外层 y:从上到下遍历
    • 内层 x:从左到右遍历
    • 顺序:左上 → 中上 → 右上 → 左中 → 中中 → 右中 → 左下 → 中下 → 右下
  3. kernel[index] 的含义:

    • kernel[4] = 中心像素权重(当前像素)
    • kernel[1] = 正上方像素权重
    • kernel[3] = 正左侧像素权重
  4. 为什么用 sum += 而不是 =:

    • 卷积是"加权求和",需要累加所有 9 个采样值

5. 🎨 常见图像滤镜实现 ​

5.1 滤镜 1: 模糊 (Blur) ​

原理: 每个像素取周围像素的平均值

💡 为什么模糊能消除细节?

想象你在看一张报纸照片:

  • 清晰图像: 每个像素都独立,黑白交界处非常锐利
  • 模糊后: 黑白像素互相"渗透",交界变成灰色过渡带

数学上,模糊是低通滤波器:

  • 保留低频信息(大块颜色、轮廓)
  • 去除高频信息(细节、纹理、噪点)

应用:降噪、柔化皮肤、景深效果、毛玻璃。

5.1.1 Box Blur (方框模糊) ​

glsl
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 (高斯模糊) ​

glsl
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σ²))
        ↑             ↑
    归一化因子    距离越远,权重指数下降

为什么用高斯?

  1. 符合视觉直觉: 离中心越近的信息越重要
  2. 数学优雅: 可分离(能拆成两个1D卷积优化性能)
  3. 中心极限定理: 多次卷积趋向高斯(无论初始核是什么)
  4. 各向同性: 四面八方模糊程度一致,无方向偏好

权重分布模式:

1  2  1    ← 权重递减规律
2  4  2    ← 对称性
1  2  1    ← 1:2:4 = 二项式系数(帕斯卡三角形)

优势: 比 Box Blur 更自然,是行业标准(Photoshop 的"高斯模糊"就是这个)

5.1.3 优化: 可分离高斯模糊 (Separable Gaussian) ​

glsl
// ⚡ 性能优化: 将 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 示例):

javascript
// 第一次渲染:水平模糊
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) ​

原理: 增强边缘,突出细节

💡 锐化与模糊是相反的操作

  • 模糊: 混合周围像素 → 减少差异 → 低通滤波器
  • 锐化: 放大差异 → 增强边缘 → 高通滤波器

类比: 拍照时的"清晰度"滑块

  • 向左:柔化(模糊)
  • 向右:锐化(强调细节、纹理)

应用场景: 恢复失焦照片、强调文字可读性、艺术效果

glsl
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)
glsl
// 可调强度的锐化
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 算子 (经典) ​

glsl
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]?

  1. 中心差分: 用"右边 - 左边"估计中心的导数

    导数 ≈ (f(x+1) - f(x-1)) / 2
  2. 权重加倍: 中间行权重 2,是为了更重视正中心的变化

    Sobel X:       简单差分:
    -1  0  1       -1  0  1
    -2  0  2  vs   -1  0  1
    -1  0  1       -1  0  1
  3. 隐含的平滑: Sobel = 差分 + 高斯平滑的组合

    • X方向差分 [-1, 0, 1]
    • Y方向平滑 [1, 2, 1] (减少噪声影响)

梯度向量的含义:

(gx, gy) = 梯度向量
|G| = sqrt(gx² + gy²) = 变化强度(边缘强度)
θ = atan(gy/gx) = 变化方向(边缘法线方向)

效果: 白色线条表示边缘,黑色表示平坦区域

优化技巧:

glsl
// 使用 length() 代替 sqrt() 可能更快(取决于GPU)
float edge = length(vec2(gx, gy));

// 曼哈顿距离(更快但不精确)
// float edge = abs(gx) + abs(gy);

5.3.2 Laplacian 算子 (更简单) ​

glsl
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) ​

模拟凹凸效果

glsl
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) ​

glsl
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) ​

glsl
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) ​

glsl
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 结果转换成屏幕能显示的范围
glsl
// 📸 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;
}

🧠 两种算法对比

特性ReinhardACES
公式x/(x+1)有理函数(复杂)
计算量⚡ 极快(2条指令)⚡⚡ 较快(约10条指令)
视觉效果高光容易"灰"✅ 高光更自然
色彩偏移⚠️ 可能偏色✅ 色彩准确
应用场景实时游戏、快速预览电影、高质量渲染

何时使用?

  • 如果纹理已经是 LDR(普通照片):不需要色调映射
  • 如果纹理来自 HDR 渲染(光照计算):必须色调映射
  • 移动端/性能敏感:Reinhard
  • 追求质量:ACES

6.3 效果 3: 颜色分级 (Color Grading) ​

glsl
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) ​

边缘变暗效果,常用于摄影

glsl
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) ​

模拟镜头色散

💡 什么是色差?

物理原理:

  • 光通过镜头时,不同波长(颜色)的折射率不同
  • 红光(长波)折射少 → 落在更远的位置
  • 蓝光(短波)折射多 → 落在更近的位置
  • 结果:边缘出现红蓝分离的彩色条纹

摄影中的表现:

  • 廉价镜头:明显色差(瑕疵)
  • 高端镜头:特殊镜片矫正色差
  • 艺术效果:故意添加色差营造复古/梦幻感

类比: 三棱镜分解白光成彩虹,镜头色差就是"不完美的三棱镜效应"。

glsl
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) ​

glsl
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 显示器效果

glsl
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: 波纹扭曲 ​

glsl
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: 玻璃折射 ​

glsl
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 渲染,用于模拟凹凸(而不真正改变几何体)

在折射中的应用:

  1. 平坦表面: 法线 = (0, 0, 1) → RGB(0.5, 0.5, 1.0) → 蓝紫色

    • XY = (0, 0) → 无偏移 → 不扭曲
  2. 凸起表面: 法线 = (0.5, 0, 1) → RGB(0.75, 0.5, 1.0)

    • XY = (0.5, 0) → 向右偏移 → 向右扭曲
  3. 凹陷表面: 法线 = (-0.5, 0, 1) → RGB(0.25, 0.5, 1.0)

    • XY = (-0.5, 0) → 向左偏移 → 向左扭曲

物理类比:

  • 想象透过有纹理的磨砂玻璃看东西
  • 玻璃表面的凹凸导致光线弯曲
  • 法线贴图就是这个"凹凸地图"

应用场景:

  • 水面波动(用噪声生成法线贴图)
  • 热空气扭曲
  • 玻璃/冰块效果
  • 透明材质扰动

7.3 扭曲 3: 鱼眼镜头 ​

glsl
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 畸变校正的逆向(预畸变)

⚠️ 常见问题:

  1. 不校正宽高比: 会产生椭圆形鱼眼(应该是圆形)
  2. 强度过大: 边缘会被压缩到同一点(奇点)
  3. 采样超界: distortedUV 可能超出 [0, 1],需要处理边界

8. 🎓 本章总结 ​

8.1 📚 推荐练习(从易到难) ​

⭐ 入门级

  1. 单色滤镜:将图像转为指定色调(例如全蓝色、棕褐色)
  2. 镜像翻转:实现上下/左右/对角线翻转
  3. 亮度分级:根据亮度将图像分为 5 个档次(posterize 效果)

⭐⭐ 进阶级 4. Instagram 滤镜:

  • 组合饱和度 + 对比度 + 晕影 + 色温
  • 添加 UI 滑块控制每个参数
  • 实现复古胶片、冷色调、暖色调三种预设
  1. 实时边缘卡通化:

    • Sobel 边缘检测 → 二值化(黑白线条)
    • 原图色彩分级(减少颜色数量)
    • 叠加边缘和分级色彩(类似手绘)
  2. 动态水波:

    • 用 sin(dist - time) 生成波纹
    • 点击屏幕产生涟漪动画
    • 多个涟漪的叠加(干涉效果)

⭐⭐⭐ 挑战级 7. 多级模糊景深:

  • 生成深度图(近处清晰,远处模糊)
  • 根据深度控制高斯模糊强度
  • 优化性能(降采样 + 可分离卷积)
  1. 完整的 CRT 显示器模拟:

    • 扫描线 + RGB 像素网格
    • 曲面屏幕变形(桶形畸变)
    • 色彩溢出(Bloom)
    • 抖动/噪点动画
  2. 艺术风格滤镜:

    • 油画效果(Kuwahara 滤镜)
    • 素描效果(Sobel + 反色 + 纸张纹理)
    • 水彩效果(边缘保留模糊 + 色彩扩散)

8.2 🔗 学习资源 ​

必读文档:

深入研究:

工具推荐: