Содержание

WebGL2Fundamentals.org

Fix, Fork, Contribute

WebGL2 Проблемы точности

Эта статья о различных проблемах точности в WebGL2

lowp, mediump, highp

В первой статье на этом сайте мы создали вершинный шейдер и фрагментный шейдер. Когда мы создавали фрагментный шейдер, было упомянуто почти вскользь, что фрагментный шейдер не имеет точности по умолчанию, и поэтому нам нужно было установить одну, добавив строку

precision highp float;

Что это вообще было?

lowp, mediump и highp - это настройки точности. Точность в данном случае эффективно означает, сколько битов используется для хранения значения. Число в JavaScript использует 64 бита. Большинство чисел в WebGL только 32 бита. Меньше битов = быстрее, больше битов = более точно и/или больший диапазон.

Я не знаю, смогу ли я объяснить это хорошо. Вы можете поискать double vs float для других примеров проблем точности, но один способ объяснить это как разницу между байтом и шортом или в JavaScript Uint8Array против Uint16Array.

  • Uint8Array - это массив беззнаковых 8-битных целых чисел. 8 бит могут содержать 28 значений от 0 до 255.
  • Uint16Array - это массив беззнаковых 16-битных целых чисел. 16 бит могут содержать 216 значений от 0 до 65535.
  • Uint32Array - это массив беззнаковых 32-битных целых чисел. 32 бита могут содержать 232 значений от 0 до 4294967295.

lowp, mediump и highp похожи.

  • lowp - это как минимум 9-битное значение. Для значений с плавающей точкой они могут варьироваться от: -2 до +2, для целых значений они похожи на Uint8Array или Int8Array

  • mediump - это как минимум 16-битное значение. Для значений с плавающей точкой они могут варьироваться от: -214 до +214, для целых значений они похожи на Uint16Array или Int16Array

  • highp - это как минимум 32-битное значение. Для значений с плавающей точкой они могут варьироваться от: -262 до +262, для целых значений они похожи на Uint32Array или Int32Array

Важно отметить, что не каждое значение внутри диапазона может быть представлено. Самый простой для понимания, вероятно, lowp. Есть только 9 бит, и поэтому только 512 уникальных значений могут быть представлены. Выше говорится, что диапазон от -2 до +2, но есть бесконечное количество значений между -2 и +2. Например, 1.9999999 и 1.999998 - это 2 значения между -2 и +2. С только 9 битами lowp не может представить эти 2 значения. Так, например, если вы хотите сделать какую-то математику с цветом и вы использовали lowp, вы можете увидеть некоторую полосатость. Не вдаваясь в то, какие фактические значения могут быть представлены, мы знаем, что цвета идут от 0 до 1. Если lowp идет от -2 до +2 и может представлять только 512 уникальных значений, то кажется вероятным, что только 128 из этих значений помещаются между 0 и 1. Это также предполагает, что если у вас есть значение, которое составляет 4/128, и я пытаюсь добавить к нему 1/512, ничего не произойдет, потому что 1/512 не может быть представлено lowp, поэтому это эффективно 0.

Мы могли бы просто использовать highp везде и полностью игнорировать эту проблему, но на устройствах, которые действительно используют 9 бит для lowp и/или 16 бит для mediump, они обычно быстрее, чем highp. Часто значительно быстрее.

К последнему пункту, в отличие от значений в Uint8Array или Uint16Array, значение lowp или mediump, или, если на то пошло, даже значение highp, может использовать более высокую точность (больше бит). Так, например, на настольном GPU, если вы поставите mediump в ваш шейдер, он все равно, скорее всего, будет использовать 32 бита внутренне. Это имеет проблему, что трудно тестировать ваши шейдеры, если вы используете lowp или mediump. Чтобы увидеть, действительно ли ваши шейдеры работают правильно с lowp или mediump, вы должны тестировать на устройстве, которое действительно использует 8 бит для lowp и 16 бит для highp.

Если вы действительно хотите попытаться использовать mediump для скорости, вот некоторые из проблем, которые возникают.

Хороший пример, вероятно, пример точечных источников света, в частности, вычисление зеркального блика, передает значения в мировом или видовом пространстве в фрагментный шейдер, эти значения могут легко выйти за пределы диапазона для значения mediump. Так что, может быть, на устройстве mediump вы могли бы просто убрать зеркальные блики. Например, вот шейдер точечного света из статьи о точечных источниках света, измененный для mediump.

#version 300 es

-precision highp float;
+precision mediump float;

// Переданный и измененный из вершинного шейдера.
in vec3 v_normal;
in vec3 v_surfaceToLight;
in vec3 v_surfaceToView;

uniform vec4 u_color;
uniform float u_shininess;

// нам нужно объявить выход для фрагментного шейдера
out vec4 outColor;

void main() {
  // потому что v_normal - это varying, он интерполируется
  // поэтому он не будет единичным вектором. Нормализация его
  // сделает его снова единичным вектором
  vec3 normal = normalize(v_normal);

  vec3 surfaceToLightDirection = normalize(v_surfaceToLight);
-  vec3 surfaceToViewDirection = normalize(v_surfaceToView);
-  vec3 halfVector = normalize(surfaceToLightDirection + surfaceToViewDirection);

  // вычисляем свет, взяв скалярное произведение
  // нормали к обратному направлению света
  float light = dot(normal, surfaceToLightDirection);
-  float specular = 0.0;
-  if (light > 0.0) {
-    specular = pow(dot(normal, halfVector), u_shininess);
-  }

  outColor = u_color;

  // Давайте умножим только цветовую часть (не альфа)
  // на свет
  outColor.rgb *= light;

-  // Просто добавляем блик
-  outColor.rgb += specular;
}

Примечание: Даже этого на самом деле недостаточно. В вершинном шейдере у нас есть

  // вычисляем вектор поверхности к свету
  // и передаем его в фрагментный шейдер
  v_surfaceToLight = u_lightWorldPosition - surfaceWorldPosition;

Так что скажем, свет находится на расстоянии 1000 единиц от поверхности. Затем мы попадаем в фрагментный шейдер, и эта строка

  vec3 surfaceToLightDirection = normalize(v_surfaceToLight);

выглядит достаточно невинно. За исключением того, что нормальный способ нормализовать вектор

  • это разделить на его длину, а нормальный способ вычислить длину
  float length = sqrt(v.x * v.x + v.y * v.y * v.z * v.z);

Если один из этих x, y или z равен 1000, то 1000*1000 = 1000000. 1000000 выходит за пределы диапазона для mediump.

Одно решение здесь - нормализовать в вершинном шейдере.

  // вычисляем вектор поверхности к свету
  // и передаем его в фрагментный шейдер
-  v_surfaceToLight = u_lightWorldPosition - surfaceWorldPosition;
+  v_surfaceToLight = normalize(u_lightWorldPosition - surfaceWorldPosition);

Теперь значения, присвоенные v_surfaceToLight, находятся между -1 и +1, что в пределах диапазона для mediump.

Обратите внимание, что нормализация в вершинном шейдере на самом деле не даст те же результаты, но они могут быть достаточно близкими, что никто не заметит, если не сравнивать бок о бок.

Функции, такие как normalize, length, distance, dot, все имеют эту проблему, что если значения слишком большие, они выйдут за пределы диапазона для mediump.

Но вы действительно должны тестировать на устройстве, для которого mediump составляет 16 бит. На настольном компьютере mediump составляет 32 бита, то же самое, что и highp, и поэтому любые проблемы не будут видны.

Обнаружение поддержки 16-битного mediump

Вы вызываете gl.getShaderPrecisionFormat, вы передаете тип шейдера, VERTEX_SHADER или FRAGMENT_SHADER, и вы передаете один из LOW_FLOAT, MEDIUM_FLOAT, HIGH_FLOAT, LOW_INT, MEDIUM_INT, HIGH_INT, и он [возвращает информацию о точности].

gl.getShaderPrecisionFormat возвращает объект с тремя значениями: precision, rangeMin и rangeMax.

Для LOW_FLOAT и MEDIUM_FLOAT precision будет 23, если они действительно просто highp. Иначе они, вероятно, будут 8 и 15 соответственно, или по крайней мере они будут меньше 23. Для LOW_INT и MEDIUM_INT если они такие же, как highp, то rangeMin будет 31. Если они меньше 31, то mediump int на самом деле более эффективен, чем highp int, например.

Мой Pixel 2 XL использует 16 бит для mediump, он также использует 16 бит для lowp. Я не уверен, что когда-либо использовал устройство, которое использует 9 бит для lowp, поэтому я не уверен, какие проблемы обычно возникают, если они есть.

На протяжении этих статей мы указывали точность по умолчанию в фрагментном шейдере. Мы также можем указать точность любой отдельной переменной. Например

uniform mediump vec4 color;  // uniform
in lowp vec4 normal;         // атрибут или varying вход
out lowp vec4 texcoord;      // выход фрагментного шейдера или varying выход
lowp float foo;              // переменная

Форматы текстур

Текстуры - это еще одно место, где спецификация говорит, что фактическая точность, используемая, может быть больше, чем запрошенная точность.

В качестве примера вы можете запросить 16-битную, 4-битную на канал текстуру, как это

gl.texImage2D(
  gl.TEXTURE_2D,               // target
  0,                           // mip level
  gl.RGBA4,                    // internal format
  width,                       // width
  height,                      // height
  0,                           // border
  gl.RGBA,                     // format
  gl.UNSIGNED_SHORT_4_4_4_4,   // type
  null,
);

Но реализация может на самом деле использовать формат более высокого разрешения внутренне. Я считаю, что большинство настольных компьютеров делают это, а большинство мобильных GPU - нет.

Мы можем протестировать. Сначала мы запросим 4-битную на канал текстуру, как выше. Затем мы отрендерим в нее, рендеря некоторый градиент от 0 до 1.

Затем мы отрендерим эту текстуру на холст. Если текстура действительно 4 бита на канал внутренне, будет только 16 уровней цвета из градиента, который мы нарисовали. Если текстура действительно 8 бит на канал, мы увидим 256 уровней цветов.

Запуская это на моем смартфоне, я вижу, что текстура использует 4 бита на канал (или по крайней мере 4 бита в красном, поскольку я не тестировал другие каналы).

Тогда как на моем настольном компьютере я могу видеть, что текстура на самом деле использует 8 бит на канал, даже though я только запросил 4.

Одна вещь, которую стоит отметить, это то, что по умолчанию WebGL может дизерить свои результаты, чтобы сделать такие градации более гладкими. Вы можете отключить дизеринг с помощью

gl.disable(gl.DITHER);

Если я не отключаю дизеринг, то мой смартфон производит это.

Сходу единственное место, где это действительно возникло бы, это если бы вы использовали какую-то текстуру с более низким битовым разрешением как цель рендеринга и не тестировали на устройстве, где эта текстура действительно имеет это более низкое разрешение. Если вы тестировали только на настольном компьютере, любые проблемы, которые это вызывает, могут быть не очевидны.

Есть предложения или замечания? Создайте issue на GitHub.
comments powered by Disqus