第 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);
});
🔍 原理对比分析
- 几何体封装:原生 WebGL 需要手动书写坐标为
[-1, 1]的 Buffer 并直接映射到裁剪空间(不需要 MVP 矩阵);Three.js 则提供了OrthographicCamera和PlaneGeometry(2, 2)。 - 多滤镜堆叠:此节我们介绍了单一滤镜操作,如果需要同时叠加多个滤镜(例如:先做模糊,再做边缘检测),将需要在两张纹理之间来回绘制。这依赖于帧缓冲 (Framebuffer, FBO) 的相关知识,原生 WebGL 对应多次
bindFramebuffer,而 Three.js 则对应EffectComposer的多次Pass。我们将在后续章节详细讨论。
📝 编程作业
基础作业(⭐)
任务:实现动态图像滤镜切换 要求:
- 加载一张图片并绘制到铺满画布的矩形上
- 使用 HTML
<select>下拉菜单提供不同的滤镜选项(正常、反相、边缘检测、高斯模糊) - 在片段着色器中实现 3x3 卷积核运算
- 根据用户的选择,通过 JavaScript 更新 uniform 中的卷积核数组以实时切换效果
进阶作业(⭐⭐)
任务:实现双色调滤镜 (Duotone Filter)
要求:不使用卷积核,而是根据图像像素的亮度值,将其映射到两种预设颜色之间(例如 Spotify 风格的双色海报)。提示:可以使用 dot(color.rgb, vec3(0.299, 0.587, 0.114)) 获取亮度,然后用 mix() 函数混合颜色。
🎓 本节总结
关键概念
- 图像处理:在着色器中读取纹理并在输出前进行运算的过程。
- 像素跨度:利用
1.0 / 纹理尺寸求出对应真实一个像素宽度的 UV 偏移。 - 卷积:通过矩阵核加权周围像素的值来产生平滑、锐化或边缘提取等功能。
下一步
完成作业后,进入下一节:第 8 节:光照模型