Skip to main content

第 7 节:图像处理基础与卷积

🎯 学习目标

  • 理解 WebGL 图像处理的核心思想
  • 掌握在片段着色器中获取相邻像素的方法
  • 理解卷积(Convolution)运算原理
  • 实现多种图像滤镜(模糊、锐化、边缘检测)

📖 理论:用 WebGL 处理图像

在 WebGL 中,处理图像的核心思想是将图像作为纹理贴到一个覆盖全画布的 2D 矩形上,然后在片段着色器中对纹理上的像素进行数学运算。这也是许多专业图像编辑软件底层的滤镜实现原理。

1. 像素的 UV 跨度计算

由于 WebGL 的 UV 坐标范围始终是 [0.0, 1.0],无论纹理的实际物理尺寸是多少。因此,要获取相邻的一个像素,我们需要计算单个像素在 UV 坐标轴上所占的比例:

// u_textureSize 是图像的真实宽高,例如 (800.0, 600.0)
vec2 onePixel = vec2(1.0, 1.0) / u_textureSize;

有了 onePixel,我们就可以通过 v_texCoord + vec2(onePixel.x, 0.0) 采样到右侧的像素,通过 v_texCoord + vec2(0.0, onePixel.y) 采样到上方的像素。

2. 卷积运算(Convolution)

很多高级图像滤镜(如高斯模糊、锐化、边缘检测等)都可以通过卷积核(Kernel)来实现。 卷积核通常是一个 3x3 的矩阵,它表示当前处理的像素及其周围 8 个像素对最终颜色的影响权重

  • 边缘检测核 (Edge Detect)[-1, -1, -1, -1, 8, -1, -1, -1, -1]
  • 锐化核 (Sharpen)[0, -1, 0, -1, 5, -1, 0, -1, 0]
  • 高斯模糊核 (Gaussian Blur)[1, 2, 1, 2, 4, 2, 1, 2, 1]

在运算后,为了保持图像整体的亮度不变,我们通常会将计算出来的颜色值之和除以权重和(如果权重和小于或等于0,则除以 1.0)。


💻 原生 WebGL 实现

下面我们实现一个可以通过传入不同卷积核来实现多种图像滤镜的 WebGL 程序。

<!DOCTYPE html>
<html>
<head>
<title>图像处理与卷积 - 原生 WebGL</title>
<style>
body { margin: 0; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="glcanvas"></canvas>

<script>
const canvas = document.getElementById("glcanvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const gl = canvas.getContext("webgl");

// 1. 顶点着色器:渲染一个覆盖整个画布的矩形
const vertexShaderSource = `
attribute vec2 aPosition;
attribute vec2 aTexCoord;
varying vec2 vTexCoord;
void main() {
// 直接映射到裁剪空间,不需要 MVP 矩阵
gl_Position = vec4(aPosition, 0.0, 1.0);
vTexCoord = aTexCoord;
}
`;

// 2. 片段着色器:3x3 卷积运算
const fragmentShaderSource = `
precision mediump float;

uniform sampler2D uImage;
uniform vec2 uTextureSize;
uniform float uKernel[9];
uniform float uKernelWeight;

varying vec2 vTexCoord;

void main() {
// 计算 1 个像素对应的 UV 偏移量
vec2 onePixel = vec2(1.0, 1.0) / uTextureSize;

// 采集周围 3x3 共 9 个像素,并与对应的卷积核权重相乘
vec4 colorSum =
texture2D(uImage, vTexCoord + onePixel * vec2(-1, -1)) * uKernel[0] +
texture2D(uImage, vTexCoord + onePixel * vec2( 0, -1)) * uKernel[1] +
texture2D(uImage, vTexCoord + onePixel * vec2( 1, -1)) * uKernel[2] +
texture2D(uImage, vTexCoord + onePixel * vec2(-1, 0)) * uKernel[3] +
texture2D(uImage, vTexCoord + onePixel * vec2( 0, 0)) * uKernel[4] +
texture2D(uImage, vTexCoord + onePixel * vec2( 1, 0)) * uKernel[5] +
texture2D(uImage, vTexCoord + onePixel * vec2(-1, 1)) * uKernel[6] +
texture2D(uImage, vTexCoord + onePixel * vec2( 0, 1)) * uKernel[7] +
texture2D(uImage, vTexCoord + onePixel * vec2( 1, 1)) * uKernel[8];

// 将最终 rgb 值除以权重和,保持亮度平衡
gl_FragColor = vec4((colorSum / uKernelWeight).rgb, 1.0);
}
`;

// ... 编译 Shader 的通用函数 (略)
function createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
return shader;
}
function createProgram(gl, vs, fs) {
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
return program;
}
const program = createProgram(gl, createShader(gl, gl.VERTEX_SHADER, vertexShaderSource), createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource));
gl.useProgram(program);

// 全屏矩形数据 (位置+UV)
const bufferData = new Float32Array([
-1.0, -1.0, 0.0, 0.0,
1.0, -1.0, 1.0, 0.0,
-1.0, 1.0, 0.0, 1.0,
-1.0, 1.0, 0.0, 1.0,
1.0, -1.0, 1.0, 0.0,
1.0, 1.0, 1.0, 1.0
]);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, bufferData, gl.STATIC_DRAW);

const aPosition = gl.getAttribLocation(program, "aPosition");
const aTexCoord = gl.getAttribLocation(program, "aTexCoord");
gl.enableVertexAttribArray(aPosition);
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 4 * 4, 0);
gl.enableVertexAttribArray(aTexCoord);
gl.vertexAttribPointer(aTexCoord, 2, gl.FLOAT, false, 4 * 4, 2 * 4);

// 加载图像并进行处理
const image = new Image();
image.src = "your_image_url.jpg"; // 替换为同源图像地址
image.onload = () => {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

// 设置 Uniforms
const uTextureSize = gl.getUniformLocation(program, "uTextureSize");
const uKernel = gl.getUniformLocation(program, "uKernel[0]");
const uKernelWeight = gl.getUniformLocation(program, "uKernelWeight");

gl.uniform2f(uTextureSize, image.width, image.height);

// 选用一个边缘检测卷积核
const edgeDetectKernel = [
-1, -1, -1,
-1, 8, -1,
-1, -1, -1
];

// 计算权重和
const weight = edgeDetectKernel.reduce((a, b) => a + b) || 1.0;

gl.uniform1fv(uKernel, edgeDetectKernel);
gl.uniform1f(uKernelWeight, weight);

// 绘制图像
gl.viewport(0, 0, canvas.width, canvas.height);
gl.drawArrays(gl.TRIANGLES, 0, 6);
};
</script>
</body>
</html>

🎨 Three.js 对比实现

在 Three.js 中,要实现屏幕级别的图像处理和滤镜,我们通常不会通过直接向 Shader 传 3x3 数组来手动编写卷积,而是使用 EffectComposer(后处理系统),借助 ShaderPass 来进行全屏渲染。不过,如果是直接渲染在一张面上,可以使用 ShaderMaterial 达到类似的原生效果。

import * as THREE from 'three';

// 场景与相机(使用正交相机直接渲染平面)
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 加载纹理
const textureLoader = new THREE.TextureLoader();
textureLoader.load('your_image_url.jpg', (texture) => {

// 自定义着色器材质
const material = new THREE.ShaderMaterial({
uniforms: {
uImage: { value: texture },
uTextureSize: { value: new THREE.Vector2(texture.image.width, texture.image.height) },
uKernel: { value: [-1, -1, -1, -1, 8, -1, -1, -1, -1] },
uKernelWeight: { value: 1.0 }
},
vertexShader: `
varying vec2 vTexCoord;
void main() {
vTexCoord = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D uImage;
uniform vec2 uTextureSize;
uniform float uKernel[9];
uniform float uKernelWeight;
varying vec2 vTexCoord;

void main() {
vec2 onePixel = 1.0 / uTextureSize;
vec4 colorSum =
texture2D(uImage, vTexCoord + onePixel * vec2(-1, -1)) * uKernel[0] +
texture2D(uImage, vTexCoord + onePixel * vec2( 0, -1)) * uKernel[1] +
texture2D(uImage, vTexCoord + onePixel * vec2( 1, -1)) * uKernel[2] +
texture2D(uImage, vTexCoord + onePixel * vec2(-1, 0)) * uKernel[3] +
texture2D(uImage, vTexCoord + onePixel * vec2( 0, 0)) * uKernel[4] +
texture2D(uImage, vTexCoord + onePixel * vec2( 1, 0)) * uKernel[5] +
texture2D(uImage, vTexCoord + onePixel * vec2(-1, 1)) * uKernel[6] +
texture2D(uImage, vTexCoord + onePixel * vec2( 0, 1)) * uKernel[7] +
texture2D(uImage, vTexCoord + onePixel * vec2( 1, 1)) * uKernel[8];

gl_FragColor = vec4((colorSum / uKernelWeight).rgb, 1.0);
}
`
});

const geometry = new THREE.PlaneGeometry(2, 2);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

renderer.render(scene, camera);
});

🔍 原理对比分析

  1. 几何体封装:原生 WebGL 需要手动书写坐标为 [-1, 1] 的 Buffer 并直接映射到裁剪空间(不需要 MVP 矩阵);Three.js 则提供了 OrthographicCameraPlaneGeometry(2, 2)
  2. 多滤镜堆叠:此节我们介绍了单一滤镜操作,如果需要同时叠加多个滤镜(例如:先做模糊,再做边缘检测),将需要在两张纹理之间来回绘制。这依赖于帧缓冲 (Framebuffer, FBO) 的相关知识,原生 WebGL 对应多次 bindFramebuffer,而 Three.js 则对应 EffectComposer 的多次 Pass。我们将在后续章节详细讨论。

📝 编程作业

基础作业(⭐)

任务:实现动态图像滤镜切换 要求

  1. 加载一张图片并绘制到铺满画布的矩形上
  2. 使用 HTML <select> 下拉菜单提供不同的滤镜选项(正常、反相、边缘检测、高斯模糊)
  3. 在片段着色器中实现 3x3 卷积核运算
  4. 根据用户的选择,通过 JavaScript 更新 uniform 中的卷积核数组以实时切换效果

进阶作业(⭐⭐)

任务:实现双色调滤镜 (Duotone Filter) 要求:不使用卷积核,而是根据图像像素的亮度值,将其映射到两种预设颜色之间(例如 Spotify 风格的双色海报)。提示:可以使用 dot(color.rgb, vec3(0.299, 0.587, 0.114)) 获取亮度,然后用 mix() 函数混合颜色。


🎓 本节总结

关键概念

  1. 图像处理:在着色器中读取纹理并在输出前进行运算的过程。
  2. 像素跨度:利用 1.0 / 纹理尺寸 求出对应真实一个像素宽度的 UV 偏移。
  3. 卷积:通过矩阵核加权周围像素的值来产生平滑、锐化或边缘提取等功能。

下一步

完成作业后,进入下一节:第 8 节:光照模型


📚 参考资源