Я получаю то, что выглядит как проблема с точностью в 16 бит для текстуры uv. Это может быть общеизвестно, и texture
приведение к нормализованному целому числу, но я не смог найти много¹ об этом, и это не имело бы для меня особого смысла (зачем ухудшать координаты на последнем шаге?).
В реальном коде (который слишком длинный, чтобы его показывать) это приводит к растеканию текстуры при слишком большом увеличении, задолго до ожидаемых проблем для float32. Следующая демонстрация содержит много шаблонов, поэтому вот части, которые я считаю важными:
const offset = -1 / (1 << bitsRequired);
немного смещает эти UV по оси Y.const zoom = 2 ** (bitsRequired - 14);
увеличивает край.По конструкции смещение uv затем устанавливается на шаг, который требует более 16 бит. Масштаб выбирается таким образом, чтобы отображался ровно один зеленый пиксель без проблем с точностью. Однако при требуемой точности ровно 17 бит пиксель исчезает (по крайней мере, для меня, но это может зависеть от оборудования/драйвера/чего-то еще - если кто-то не может воспроизвести и об этом еще не упоминалось, пожалуйста, сделайте).
Сначала я подумал, что совершил ошибку. Однако добавление округления вручную перед вызовом texture
и раскомментирование следующего приводит к тому, что зеленая линия пикселей снова появляется:
vec2 texCoordRounded = vec2(
(floor(vTextureCoord.x * 512.) + 0.5) / 512.,
(floor(vTextureCoord.y * 512.) + 0.5) / 512.
);
Теперь я в замешательстве. Я что-то пропустил или ошибся? Приводит ли texture
к некоторому нормализованному целому числу? Почему это выглядит так, как будто моя точность иссякает на 16 бит?
Ниже приведен пример копирования и вставки того же кода с измененными параметрами (слишком громоздко для параметризации этого демонстрационного кода):
При смещении, требующем менее 16 бит, появляется зеленая линия пикселей:
const assert = (condition, message) => {
if (!condition) throw new Error(message);
};
const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl2');
assert(gl !== null, `WebGL2 was unexpectedly not supported.`);
const testImage = new Uint8Array(Array.from(
{ length: 512 * 512 },
(_, i) => (i % 16 > 7) !== (Math.floor(i / 512) % 16 > 7)
? [0, 0xff, 0, 0xff]
: [0, 0, 0xff, 0xff],
).flat());
const bitsRequired = 16;
const offset = -1 / (1 << bitsRequired);
const vData = new Float32Array([
-1, 0, 0, 0.5 + offset, 1, 0, 0.015625, 0.5 + offset,
1, 2, 0.015625, 0.515625 + offset, -1, 2, 0, 0.515625 + offset,
]);
const zoom = 2 ** (bitsRequired - 14);
const projection = new Float32Array([
zoom, 0, 0, 0,
0, zoom, 0, 0,
0, 0, -zoom, 0,
0, 0, 0, 1,
]);
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, 512, 512, 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;
uniform mat4 projection;
layout(location = 0) in vec2 aPosition;
layout(location = 1) in vec2 aTextureCoord;
out vec2 vTextureCoord;
void main(void) {
gl_Position = projection * vec4(aPosition, 0.0, 1.0);
vTextureCoord = aTextureCoord;
}`;
const fragmentShaderSrc = `#version 300 es
precision highp float;
uniform sampler2D sampler;
in vec2 vTextureCoord;
out vec4 fColor;
void main(void){
// vec2 texCoordRounded = vec2(
// (floor(vTextureCoord.x * 512.) + 0.5) / 512.,
// (floor(vTextureCoord.y * 512.) + 0.5) / 512.
// );
// vec4 color = texture(sampler, texCoordRounded);
vec4 color = texture(sampler, vTextureCoord);
fColor = color;
}`;
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);
const uniformLocationProjection = gl.getUniformLocation(program, 'projection');
gl.uniformMatrix4fv(uniformLocationProjection, false, projection);
gl.clearColor(0, 0, 0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
<canvas id='canvas' width='256' height='256'></canvas>
Изменение bitsRequired
на 17
(только изменение) вызывает проблему, исчезает зеленая линия пикселей:
const assert = (condition, message) => {
if (!condition) throw new Error(message);
};
const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl2');
assert(gl !== null, `WebGL2 was unexpectedly not supported.`);
const testImage = new Uint8Array(Array.from(
{ length: 512 * 512 },
(_, i) => (i % 16 > 7) !== (Math.floor(i / 512) % 16 > 7)
? [0, 0xff, 0, 0xff]
: [0, 0, 0xff, 0xff],
).flat());
const bitsRequired = 17;
const offset = -1 / (1 << bitsRequired);
const vData = new Float32Array([
-1, 0, 0, 0.5 + offset, 1, 0, 0.015625, 0.5 + offset,
1, 2, 0.015625, 0.515625 + offset, -1, 2, 0, 0.515625 + offset,
]);
const zoom = 2 ** (bitsRequired - 14);
const projection = new Float32Array([
zoom, 0, 0, 0,
0, zoom, 0, 0,
0, 0, -zoom, 0,
0, 0, 0, 1,
]);
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, 512, 512, 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;
uniform mat4 projection;
layout(location = 0) in vec2 aPosition;
layout(location = 1) in vec2 aTextureCoord;
out vec2 vTextureCoord;
void main(void) {
gl_Position = projection * vec4(aPosition, 0.0, 1.0);
vTextureCoord = aTextureCoord;
}`;
const fragmentShaderSrc = `#version 300 es
precision highp float;
uniform sampler2D sampler;
in vec2 vTextureCoord;
out vec4 fColor;
void main(void){
// vec2 texCoordRounded = vec2(
// (floor(vTextureCoord.x * 512.) + 0.5) / 512.,
// (floor(vTextureCoord.y * 512.) + 0.5) / 512.
// );
// vec4 color = texture(sampler, texCoordRounded);
vec4 color = texture(sampler, vTextureCoord);
fColor = color;
}`;
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);
const uniformLocationProjection = gl.getUniformLocation(program, 'projection');
gl.uniformMatrix4fv(uniformLocationProjection, false, projection);
gl.clearColor(0, 0, 0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
<canvas id='canvas' width='256' height='256'></canvas>
Активация ранее прокомментированного ручного округления перед вызовом texture
(только изменение) для того, что все еще является float32, вызывает повторное появление зеленой линии пикселей, устраняя проблему, но почему?
const assert = (condition, message) => {
if (!condition) throw new Error(message);
};
const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl2');
assert(gl !== null, `WebGL2 was unexpectedly not supported.`);
const testImage = new Uint8Array(Array.from(
{ length: 512 * 512 },
(_, i) => (i % 16 > 7) !== (Math.floor(i / 512) % 16 > 7)
? [0, 0xff, 0, 0xff]
: [0, 0, 0xff, 0xff],
).flat());
const bitsRequired = 17;
const offset = -1 / (1 << bitsRequired);
const vData = new Float32Array([
-1, 0, 0, 0.5 + offset, 1, 0, 0.015625, 0.5 + offset,
1, 2, 0.015625, 0.515625 + offset, -1, 2, 0, 0.515625 + offset,
]);
const zoom = 2 ** (bitsRequired - 14);
const projection = new Float32Array([
zoom, 0, 0, 0,
0, zoom, 0, 0,
0, 0, -zoom, 0,
0, 0, 0, 1,
]);
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, 512, 512, 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;
uniform mat4 projection;
layout(location = 0) in vec2 aPosition;
layout(location = 1) in vec2 aTextureCoord;
out vec2 vTextureCoord;
void main(void) {
gl_Position = projection * vec4(aPosition, 0.0, 1.0);
vTextureCoord = aTextureCoord;
}`;
const fragmentShaderSrc = `#version 300 es
precision highp float;
uniform sampler2D sampler;
in vec2 vTextureCoord;
out vec4 fColor;
void main(void){
vec2 texCoordRounded = vec2(
(floor(vTextureCoord.x * 512.) + 0.5) / 512.,
(floor(vTextureCoord.y * 512.) + 0.5) / 512.
);
vec4 color = texture(sampler, texCoordRounded);
// vec4 color = texture(sampler, vTextureCoord);
fColor = color;
}`;
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);
const uniformLocationProjection = gl.getUniformLocation(program, 'projection');
gl.uniformMatrix4fv(uniformLocationProjection, false, projection);
gl.clearColor(0, 0, 0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
<canvas id='canvas' width='256' height='256'></canvas>
[1]: Обновлено: я нашел комментарий в другом несвязанном вопросе, который поддерживает мое предположение о выборке с использованием нормализованных целых чисел, но до сих пор нет официальной документации:
Многие современные графические процессоры используют некоторое представление с фиксированной точкой для texcoords, поэтому существует только ограниченный набор позиций, которые могут быть сэмплированы между двумя текселями (обычно 256).
Обратите внимание, что комментарий относится к 2014 году (9 лет), и я предполагаю, что по умолчанию используется 16-битное нормализованное целое число вместо 8-битного.
Edit2: теперь я также нашел это в спецификации directx d3d (спасибо сообщению в блоге)
Координаты текстуры для операций выборки привязываются к фиксированной точке (после масштабирования по размеру текстуры), чтобы равномерно распределить точность в пространстве текстуры при выборе местоположения/веса касания фильтра. Значения веса преобразуются обратно в числа с плавающей запятой перед выполнением фактической арифметической фильтрации.
Я до сих пор не могу найти авторитетную документацию для opengl/webgl. Становится все яснее, что происходящее — это именно мое предположение, но где документация, и достаточно ли «равномерного распределения» для того, чтобы отсечь 8-битную точность?
зачем ухудшать координаты на последнем шаге?
... потому что графические процессоры могут выполнять несколько миллиардов операций фильтрации текстур в секунду, и вы действительно не хотите тратить энергию и площадь кремния на вычисления с точностью fp32, если во всех практических случаях использования требуется 8-битная фиксированная точка.
Обратите внимание, что это 8-битная точность субтекселей (т. е. степень детализации для фильтрации GL_LINEAR между двумя соседними текселями). Выбор текселей выполняется с любой необходимой точностью (большинство современных графических процессоров могут однозначно адресовать 16-килобайтные текстуры с 8-битной точностью субтекселей).
Зачем -> Посмотрите, сколько математики вам нужно сделать с образцами компонентов для трилинейного фильтра =)
В вопросе есть короткая демонстрация. Насколько я понял, это проблема округления до ближайшего четного при достаточном увеличении (например, один тексель составляет 256 пикселей на экране). Интерполированный uv фрагментов с плавающей запятой по-прежнему сможет приспособиться к этому, но 16,8 с фиксированной точкой не будет. Он будет округлен до границы текселя, и тогда одна из двух сторон будет неправильной. Возможно, такое масштабирование недостаточно распространено, чтобы было решение по умолчанию.
Да, извините - пропустил код шейдера в исходном посте.
Я случайно связал полную запись в блоге во время поиска части спецификации openGL, но только сейчас прочитал ее (после того, как понял, что это не специфично для openGL). Также я вижу смысл с трилинейной фильтрацией — я играю в пиксели, с GL_NEAREST
, но есть смысл, что железо не будет внезапно переключаться только на один случай.
Да, как только вы получите 256-кратный зум, вы приблизитесь к пределу того, что может дать точность субтекселей. Как упоминалось в вашем другом посте, вы действительно находитесь в сфере поведения, определяемого реализацией - даже плавание графического процессора в шейдерах не является строгим IEEE, поэтому реализации могут свободно выбирать, например. режимы округления, переассоциировать некоторые виды операций и т.д.
Угадайте какое-то округление/фиксацию/что угодно в фрагментном шейдере, до середины текселя, прежде чем передавать данные для выборки. Я могу перейти к precision highp float
, и в этом случае у меня есть гарантированный float32+ (webgl2) и пока нет проблем с точностью при 256-кратном увеличении. Что хорошо в пиксельных играх, так это то, что GPU вряд ли когда-либо является пределом производительности, но мне нужно избегать размытия текстур :)
Спасибо! Я до сих пор не совсем понимаю «почему» (нужно также конвертировать из числа с плавающей запятой один раз на эти миллиарды операций), но вижу, что вполне вероятно, что есть веские причины, которых на данный момент достаточно. Чтобы решить насущную проблему, является ли какая-либо операция (например, округление) во фрагментном шейдере нормальной при увеличении до 256x или более (где видно смещение 1/512-го текселя в пространстве текстуры) или есть лучший трюк?