采样(Sampling)是从数据集中提取数据点的过程。在 WebGL 图形渲染中,纹理采样(Texture Sampling)是指根据纹理坐标从纹理图像的离散像素数据中读取颜色值的技术。本文将深入探讨纹理采样的工作原理、UV 坐标到像素的映射、采样策略、以及 Mipmap 优化技术。
纹理采样的工作原理
纹理采样通过纹理坐标从纹理图像中读取颜色值。这个过程涉及三个关键概念:从哪个纹理采样(纹理单元)、在纹理中的什么位置采样(纹理坐标)、以及如何将坐标映射到具体像素(采样器)。
纹理单元 - 从哪个纹理采样
纹理单元是 GPU 中用于管理纹理的硬件资源。WebGL 支持多个纹理单元,允许在同一个渲染过程中使用多张纹理。每个纹理单元通过 gl.TEXTURE0、gl.TEXTURE1 等常量标识。
// 激活纹理单元 0
gl.activeTexture(gl.TEXTURE0);
// 将纹理绑定到当前激活的纹理单元
gl.bindTexture(gl.TEXTURE_2D, texture);
纹理坐标 - UV 值
纹理坐标指定在纹理图像中的采样位置。在 WebGL 中,纹理坐标用 (u, v) 表示,取值范围是 [0.0, 1.0]。(0.0, 0.0) 代表纹理的左下角,(1.0, 1.0) 代表右上角。
纹理坐标在 Vertex Shader 中定义,并通过 varying 变量传递给 Fragment Shader:
// Vertex Shader
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main() {
v_texCoord = a_texCoord;
gl_Position = /* ... */;
}
// Fragment Shader
varying vec2 v_texCoord;
void main() {
// 使用 v_texCoord 进行纹理采样
}
纹理环绕模式
当纹理坐标超出 [0.0, 1.0] 范围时,环绕模式决定如何处理:
// REPEAT - 重复平铺
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
// CLAMP_TO_EDGE - 使用边缘像素
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
// MIRRORED_REPEAT - 镜像重复
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
| 环绕模式 | 行为 | 适用场景 |
|---|---|---|
REPEAT | 纹理重复平铺 | 地板、墙面等平铺纹理 |
CLAMP_TO_EDGE | 使用边缘像素颜色 | UI 元素、独立纹理 |
MIRRORED_REPEAT | 镜像重复平铺 | 需要无缝衔接的纹理 |
采样器 - UV 到具体像素
采样器是 GLSL 中的特殊变量类型,负责将连续的 UV 坐标映射到纹理的离散像素。这是纹理采样的核心问题:纹理是离散的像素数组,UV 是连续的 [0.0, 1.0] 值,如何对应?
像素坐标系统
假设纹理宽度为 width 个像素,像素索引从 0 到 width - 1。关键点:像素坐标位于像素中心。
每个像素占据 1/width 的 UV 范围,第 i 个像素的中心坐标为:
u_center = (i + 0.5) / width
示例:宽度为 4 的纹理
像素索引: 0 1 2 3
像素中心: 0.125 0.375 0.625 0.875
| | | |
UV: 0.0 0.25 0.5 0.75 1.0
UV 坐标可能落在像素中心,也可能落在两个像素之间。不同的处理方式构成了不同的采样策略。
使用采样器
在 Fragment Shader 中,采样器通过 texture2D() 函数根据 UV 坐标读取颜色:
uniform sampler2D u_texture;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
gl_FragColor = color;
}
在 JavaScript 中关联采样器到纹理单元:
const textureLocation = gl.getUniformLocation(program, "u_texture");
gl.uniform1i(textureLocation, 0); // 关联到 gl.TEXTURE0
采样策略
当 UV 坐标落在像素中心时,直接返回该像素的颜色。但当 UV 坐标落在两个像素之间时,如何计算颜色值?这就是采样策略要解决的问题。不同的策略在性能和视觉质量上有不同的权衡。
最近邻采样
最近邻采样(Nearest Neighbor Sampling)直接选择距离采样点最近的像素,不进行插值计算。
算法步骤
假设纹理坐标为 (u, v),纹理尺寸为 width × height:
- 计算像素坐标:
x = u × width
y = v × height
- 四舍五入得到最近的像素索引:
i = round(x)
j = round(y)
- 返回该像素的颜色:
color = texture[j × width + i]
WebGL 配置
在 WebGL 中,通过设置纹理参数来使用最近邻采样:
// 创建纹理对象
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
// 设置放大和缩小时的采样方式为最近邻
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
特点
- 优点: 计算简单,性能最高
- 缺点: 放大时会出现明显的块状效应(Pixelation),缩小时容易产生锯齿
- 适用场景: 像素风格游戏、需要保持像素清晰度的场景
双线性采样
双线性采样(Bilinear Filtering)对采样点周围的四个像素进行插值计算,生成平滑的结果。
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
线性插值
线性插值(Lerp)在两个值之间计算中间值:
lerp(a, b, t) = a + (b - a) × t // t ∈ [0, 1]
双线性插值过程
假设采样点 (x, y) 周围的四个像素为 P11(x1,y1)、P21(x2,y1)、P12(x1,y2)、P22(x2,y2),值分别为 p11、p21、p12、p22。
插值分两步:
- 在 x 方向插值:
tx = (x - x1) / (x2 - x1)
top = lerp(p11, p21, tx)
bottom = lerp(p12, p22, tx)
- 在 y 方向插值:
ty = (y - y1) / (y2 - y1)
result = lerp(top, bottom, ty)
算法实现
/**
* 双线性采样
* @param {Array} texture - 纹理数据
* @param {number} width - 纹理宽度
* @param {number} height - 纹理高度
* @param {number} u - U 坐标 (0-1)
* @param {number} v - V 坐标 (0-1)
* @returns {number} 插值后的颜色值
*/
function bilinearSample(texture, width, height, u, v) {
// 确保坐标在 [0,1] 范围内
u = Math.max(0, Math.min(1, u));
v = Math.max(0, Math.min(1, v));
// 计算像素尺寸
const pixelWidth = 1 / width;
const pixelHeight = 1 / height;
// 获取周围四个像素的中心坐标
const { low: u1, high: u2 } = calculatePixelBoundaries(u, pixelWidth);
const { low: v1, high: v2 } = calculatePixelBoundaries(v, pixelHeight);
// 计算插值权重
const tx = calculateWeight(u1, u2, u);
const ty = calculateWeight(v1, v2, v);
// 获取四个角点的像素值
const p11 = getPixel(texture, u1, v1, width, height);
const p21 = getPixel(texture, u2, v1, width, height);
const p12 = getPixel(texture, u1, v2, width, height);
const p22 = getPixel(texture, u2, v2, width, height);
// 双线性插值
const top = lerp(p11, p21, tx);
const bottom = lerp(p12, p22, tx);
return lerp(top, bottom, ty);
}
// 线性插值
function lerp(a, b, t) {
return a + (b - a) * t;
}
// 计算插值权重
function calculateWeight(low, high, v) {
if (low === high) return 0;
return (v - low) / (high - low);
}
// 获取像素边界坐标
function calculatePixelBoundaries(coord, pixelSize) {
if (pixelSize === 1) {
return { low: 0.5, high: 0.5 };
}
const low = Math.floor(coord / pixelSize) * pixelSize + pixelSize / 2;
const high = Math.min(low + pixelSize, 1 - pixelSize / 2);
return { low, high };
}
// 获取像素值
function getPixel(texture, u, v, width, height) {
const x = Math.floor(u * width);
const y = Math.floor(v * height);
return texture[y * width + x];
}
特点
- 优点: 生成平滑图像,避免块状效应
- 缺点: 需要读取 4 个像素并进行 3 次插值,计算量较大
- 适用场景: 大多数 3D 图形渲染,需要平滑过渡的纹理
Mipmap
Mipmap 是一种纹理优化技术,通过预先生成多个不同分辨率的纹理版本来提升渲染性能和视觉质量。当物体距离相机较远时,使用低分辨率版本可以减少纹理采样的计算量并避免摩尔纹(Moiré Pattern)。
Mipmap 层级
Mipmap 由一系列纹理图像组成,每一级的尺寸是上一级的一半。假设原始纹理尺寸为 512×512:
- Level 0:
512×512(原始纹理) - Level 1:
256×256 - Level 2:
128×128 - Level 3:
64×64 - ...
- Level 9:
1×1
WebGL 中生成 Mipmap
WebGL 提供自动生成 Mipmap 的方法:
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
// 自动生成 Mipmap
gl.generateMipmap(gl.TEXTURE_2D);
// 设置使用 Mipmap 的采样方式
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
Mipmap 采样模式
WebGL 提供多种 Mipmap 采样模式,用于 TEXTURE_MIN_FILTER 参数:
| 模式 | 说明 |
|---|---|
NEAREST_MIPMAP_NEAREST | 选择最近的 Mipmap 层级,使用最近邻采样 |
LINEAR_MIPMAP_NEAREST | 选择最近的 Mipmap 层级,使用双线性采样 |
NEAREST_MIPMAP_LINEAR | 在两个 Mipmap 层级之间插值,每层使用最近邻采样 |
LINEAR_MIPMAP_LINEAR | 在两个 Mipmap 层级之间插值,每层使用双线性采样(三线性采样) |
三线性采样
LINEAR_MIPMAP_LINEAR 模式也称为三线性采样(Trilinear Filtering)。它在双线性采样的基础上,再对相邻两个 Mipmap 层级的采样结果进行线性插值,消除不同 Mipmap 层级之间的切换边界,获得最平滑的视觉效果。
注意事项
使用 Mipmap 需要注意:
- 纹理尺寸应该是 2 的幂次(如
256×256、512×512),否则某些设备可能无法生成 Mipmap - Mipmap 会额外占用约 33% 的显存空间
TEXTURE_MAG_FILTER只能使用NEAREST或LINEAR,不能使用 Mipmap 模式