如何解决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像素图形场景的合理优化方向:
- 放大四边形:确保所有需要的片元都被光栅化,避免因冲突规则遗漏片元;
- 手动UV舍入到纹理单元中心:彻底规避硬件采样的舍入不确定性,无论UV空间与屏幕空间是否对齐、是否旋转,都能精准选中目标纹理单元;
- 丢弃超出范围的片元:防止放大后的四边形采样到纹理图集的其他区域,保证渲染结果的正确性。
这种方案的优势在于适配旋转、缩放等所有变换场景,且不会破坏纹理图集的布局或尺寸要求。
行业通用解决方案
行业内针对这类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

