You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何解决2D像素图形中的纹理舍入误差问题?

问题描述

在采用GL_NEAREST采样的2D像素图形中,平移半个屏幕像素时,舍入行为会导致片元选择错误的纹理单元,具体表现为:

  • 左侧片元的UV坐标恰好处于纹理单元之间;
  • 光栅化器因三角形无缝连接的冲突规则,会遗漏部分片元;
  • 默认舍入仅在UV空间与屏幕空间方向一致时正确,旋转后失效。

例如Y轴翻转纹理时,选中的纹理单元会偏移一行,钳位处理会导致重复使用一行且丢失一行。旋转精灵时,这类误差会出现在特定行列。

已知两种常用方案均存在缺陷:

  • 将顶点舍入到像素角:无法适配可旋转的精灵;
  • 纹理图集添加透明边框:属于临时补丁,会破坏2的幂纹理尺寸,且边框大小依赖mipmap层级。

针对2D像素图形多为带纹理四边形的特点,自研思路为:将每个四边形渲染得稍大一些(确保问题场景下的两侧片元都被包含),在片元着色器中手动将UV舍入到纹理单元中心,并丢弃超出目标纹理范围的片元。

请问该思路是否可行?行业内的通用解决方案是什么?


验证代码(WebGL2实现Y轴翻转UV及片元钳位)

JavaScript

const assert = (condition, message) => {
  if (!condition) throw new Error(message);
};

const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl2', { antialias: false });
assert(gl !== null, `WebGL2 was unexpectedly not supported.`);

const distribution = (x) => Math.E ** (-((1.6 * x) ** 2));
const rollover = 11;
const getColors = (i) => [0, 1, 2].map(e => Math.round(0xff * distribution((((e + 0.5) + (i % rollover) / rollover * 3) % 3) - 1.5)));

const textureSize = 8;
const testImage = new Uint8Array(Array.from(
  { length: textureSize * textureSize },
  (_, i) => [...getColors(i), 0xff],
).flat());
const positionPerPixel = 2 / 8;
const selectionInPixels = 5;
const offsetY = -0.5;
const vertices = [
  [0,                 0 + offsetY],
  [selectionInPixels, 0 + offsetY],
  [selectionInPixels, selectionInPixels + offsetY],
  [0,                 selectionInPixels + offsetY],
].map(coordPair => coordPair.map(coord => (coord - 2) * positionPerPixel));
const pixelOffset = 2;
const normalizedCoordPerPixel = 1 / textureSize;
const textureCoords = [
  [0,                 selectionInPixels],
  [selectionInPixels, selectionInPixels],
  [selectionInPixels, 0],
  [0,                 0],
].map(coordPair => coordPair.map(coord => (coord + pixelOffset) * normalizedCoordPerPixel));
const vData = new Float32Array(vertices.map((e, i) => [...e, ...textureCoords[i]]).flat());

const indices = new Uint16Array([0, 1, 2, 0, 2, 3]);
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 8, 8, 0, gl.RGBA, gl.UNSIGNED_BYTE, testImage);

const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
gl.enableVertexAttribArray(0);
gl.enableVertexAttribArray(1);

const vBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vBuffer);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 16, 0);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 16, 8);
gl.bufferData(gl.ARRAY_BUFFER, vData, gl.STATIC_DRAW);

const iBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, iBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

const vertexShaderSrc = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aPosition;
layout(location = 1) in vec2 aTextureCoord;
out vec2 vTextureCoord;
void main(void) {
  gl_Position = vec4(aPosition, 0.0, 1.0);
  vTextureCoord = aTextureCoord;
}`;

const maxTextureCoord = Math.max(...textureCoords.flat());
const minTextureCoord = Math.min(...textureCoords.flat());
const fragmentShaderSrc = `#version 300 es
precision highp float;
uniform sampler2D sampler;
in vec2 vTextureCoord;
out vec4 fColor;

void main(void){
  vec2 clamped = vec2(
    clamp(vTextureCoord.x, ${minTextureCoord} + 0.01, ${maxTextureCoord} - 0.01),
    clamp(vTextureCoord.y, ${minTextureCoord} + 0.01, ${maxTextureCoord} - 0.01)
  );
  fColor = texture(sampler, clamped);
}`;

const program = gl.createProgram();
assert(program !== null, `Program was unexpectedly \`null\`.`);

const vertexShader = gl.createShader(gl.VERTEX_SHADER);
assert(vertexShader !== null, `Vertex-shader was unexpectedly \`null\`.`);
gl.shaderSource(vertexShader, vertexShaderSrc);
gl.compileShader(vertexShader);
assert(gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS), `Vertex-shader failed to compile:\n${gl.getShaderInfoLog(vertexShader)}`);

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
assert(fragmentShader !== null, `Vertex-shader was unexpectedly \`null\`.`);
gl.shaderSource(fragmentShader, fragmentShaderSrc);
gl.compileShader(fragmentShader);
assert(gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS), `Fragment-shader failed to compile:\n${gl.getShaderInfoLog(fragmentShader)}`);

gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
assert(gl.getProgramParameter(program, gl.LINK_STATUS), `Program linking failed:\n${gl.getProgramInfoLog(program)}`);

gl.useProgram(program);

const uniformLocationSampler = gl.getUniformLocation(program, 'sampler');
gl.uniform1i(uniformLocationSampler, 0);

gl.clearColor(0, 0, 0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);

CSS

#canvas {
  width: 256px;
  height: 256px;
  image-rendering: pixelated;
}

HTML

<canvas id='canvas' width='8' height='8'></canvas>

注:代码基于Stack Overflow相关问题修改,用于展示UV精度不足导致的纹理选择误差。


回答

自研思路可行性

你的自研思路完全可行,且是针对2D像素图形场景的合理优化方向:

  1. 放大四边形:确保所有需要的片元都被光栅化,避免因冲突规则遗漏片元;
  2. 手动UV舍入到纹理单元中心:彻底规避硬件采样的舍入不确定性,无论UV空间与屏幕空间是否对齐、是否旋转,都能精准选中目标纹理单元;
  3. 丢弃超出范围的片元:防止放大后的四边形采样到纹理图集的其他区域,保证渲染结果的正确性。

这种方案的优势在于适配旋转、缩放等所有变换场景,且不会破坏纹理图集的布局或尺寸要求。

行业通用解决方案

行业内针对这类GL_NEAREST采样的精度问题,主流方案分为三类:

1. 片元着色器手动控制采样(与你的思路同源)

这是最通用的解决方案,核心就是在片元着色器中直接计算目标纹理单元的UV坐标,不依赖硬件自动采样:

  • 计算逻辑:先通过vec2 texSize = textureSize(sampler, 0);获取纹理尺寸,将UV转换为像素索引vec2 pixelIndex = floor(vTextureCoord * texSize + 0.5);,再转回中心UVvec2 centeredUV = (pixelIndex + 0.5) / texSize;,最后用texture(sampler, centeredUV)采样;
  • 纹理图集适配:提前传入精灵的纹理区域偏移和尺寸,先将UV转换到精灵局部像素空间,再做舍入转换,最后判断是否超出范围并丢弃片元。

2. UV坐标偏移调整

针对无需旋转的场景,在顶点着色器中给UV坐标添加微小偏移(如1.0/(2.0*texSize)),让UV始终落在纹理单元中心区域,避免处于边界位置。但该方案仅适用于UV与屏幕空间对齐的场景,旋转后失效。

3. 整数纹理坐标采样(WebGL2/OpenGL 3.0+)

利用ivec2类型的纹理坐标,通过texelFetch函数直接采样指定索引的纹理单元,完全跳过UV归一化和硬件舍入步骤。这种方式精度最高,适合像素风格游戏,缺点是需要管理整数形式的纹理坐标,纹理图集适配需额外计算局部索引。


内容的提问来源于stack exchange,提问作者Doofus

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.01 12:45:35