![]() |
Главная
![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() |
Давайте вспомним для начала как в OpenGL происходит преобразования координат при рендеринге. После выполнения перспективного деления мы получаем так называемые NDC-координаты (Normalized Device Coordinates), видимая область в них образует куб \( [-1,1]^3 \) в трехмерном пространстве.
При рендеринге координаты вершин обычно проходят целую цепочку преобразований, изображенную на рис. 1.
Рис. 1. Цепочка преобразования координат в OpenGL
Изначально координаты задаются в пространстве модели (model space), потом при помощи модельной матрицы (model matrix) они переводятся в единую мировую систему координат (world space). У нас может несколько копий одного и того же объекта с одними и теми же модельными координатами, но у каждой копии своя модельная матрица и при переводе в мировые координаты мы получаем разные координаты. Фактически мировые координаты - это единые координаты всей сцены со всеми объектами в ней. Эти координаты не зависят от положения и ориентации камеры.
Далее мы преобразовываем мировые координаты в систему координат наблюдателя/камеры (view space) при помощи видовой матрицы. После этого мы применяем матрицу проектирования, переводящую координаты камеры в пространство отсечения (clip space). Видимая область сцены в этих координатах задается набором следующих неравенств:
\[ \begin{matrix} \begin{aligned} & -w_{clip} \leq x_{clip} \leq w_{clip} \\ & -w_{clip} \leq y_{clip} \leq w_{clip} \\ & -w_{clip} \leq z_{clip} \leq w_{clip} \\ \end{aligned} \end{matrix} \]
После этого применяется перспективное деление, т.е. координаты делятся на \( w_{clip} \). В результате мы получаем так называемые нормализованные координаты устройства (NDC, Normalized Device Coordinates). В OpenGL они изменяются в \( [-1,1]^3 \).
Стандартная матрица перспективного проектирования в OpenGL имеет следующий вид:
\[ Р = \begin{pmatrix} a & 0 & 0 & 0 \\ 0 & b & 0 & 0 \\ 0 & 0 & c & d \\ 0 & 0 & -1 & 0 \end{pmatrix} \]
Коэффициенты a и b определяются ближней и дальней плоскостями отсечения \( z_{near} \) и \( z_{far} \). Ниже приводятся формулы для коэффициентов этой матрицы
\[ \begin{matrix} \begin{aligned} & a = \frac{ 1 }{ aspect \cdot \tan \frac{fov}{2} } \\ & b = \frac{ 1 }{ \tan \frac{fov}{2} } \\ & c = -\frac{ z_{far} + z_{near} }{ z_{far} - z_{near} } \\ & d = -\frac{ 2 z_{near} z_{far} }{ z_{far} - z_{nar} } \\ & aspect = \frac{ width }{ height } \end{aligned} \end{matrix} \]
В результате мы получаем
\[ z_{NDC} = -c - \frac {d}{z_{clip} } \]
И в буфер глубины будет на замом деле записано именно \( z_{NDC} \) перемасштабированное в \( [0,1] \). Т.е. на самом деле в буфер глубины записывается не z-координата в системе координат камеры, а функция от \( \frac{1}{z} \).
То, что мы записываем в буфер глубины, это не z, а функция \( \frac{1}{z} \) связано с тем, что именно \( \frac{1}{z} \) линейно изменяется в координатах экрана (пикселах). Это позволяет использовать для получения записываемого в буфер глубины значения обычную билинейную интерполяцию.
Как работает такой буфер глубины понятно, но возникает важный (и обычно не рассматриваемый) вопрос - а что у нас происходит с точностью ? В буфере глубины обычно хранятся значения, представленные 16, 24 или 32 битами и вопрос в том, как именно распределяются эти биты по всему диапазону от \( z_{near} \) до \( z_{far} \).
Давайте начнем с самого простого случая - у нас приведенная глубина изменяется от 0 до 1 (как в Direct3D и Vulkan) и лога хранится в виде числа с фиксированной точкой. Тогда если разбить весь диапазон изменения глубины \( [z_{near}, z_{far}] \) на равные части и отобразить точки разбиения на графике мы получим следующую картинку:
Рис 2. Распределение точности для глубины из \( [0,1] \) Видно, что получающееся распределение точности очень плохое - наибольшая точность у нас получается в окрестности \( z = z_{near} \), а наименьшая - в окрестности \( z = z_{far} \) (рис 2). Понятно, что это крайне нежелательное (и весьма неожиданное) поведение. P> Что происходит в классическом OpenGL и что будет если мы для повышения точности перейдем к хранению глубины в виде значений с плавающей точкой. В этом случае картинка слегка меняется - мы получаем следующий график. Видно что вся точность сосредоточена где-то в середине и что у ближней плоскости отсечения, что у дальней точность очень низкая.
Рис 3. Точность для 1/z (с сайта nvidia)
В OpenGL 4.5 вошло расширение ARB_clip_control, которое позволяет при отсечении использовать не \(z=-1 \), а \( z = 0 \).
При помощи функции glClipControl
(вводимой этим расширением), мы можем управлять отсечением невидимой геометрии.
Если параметр depth
равен GL_NEGATIVE_ONE_TO_ONE
, то для отсечения по z
будет использоваться уравнение \( -w_c \leq z_c \).
В случае, когда depth
равен GL_ZERO_TO_ONE
, то для отсечения по z будет использоваться неравенство \( z_c >= 0 \).
void glClipControl ( GLenum origin, GLenum depth );
При помощи параметра origin
мы можем управлять направлением оси Oy - доступны два значения -
GL_LOWER_LEFT
и GL_UPPER_LEFT
.
Обратите внимание, что при определении того, является ли треугольник лицевым или нет используется
площадь треугольника со знаком и она зависит от направления оси Oy.
По умолчанию используются значения (GL_LOWER_LEFT, GL_NEGATIVE_ONE_TO_ONE)
.
Рис 4. Что будет если использовать для z диапазон [0,1]
Видно, что если мы просто перейдем в OpenGL от диапазона [-1,1] для z к [0,1], то сильно лучше не стало - точность в окрестности \( z = z_{far} \) по прежнему крайне низкая. Это значит что вдали от камеры мы можем столкнуться с так называемым z-fighting, когда нам не хватает точности буфера глубины и возникает хорошо заметные визуальные артефакты.
Что произойдет, если мы "перевернем" z - так что, записываемое в буфер значение \( d = 0 \) будет
соответствовать дальней плоскости отсечения, а \( d = 1 \) - ближней ?
С точки зрения работы буфера глубины не изменится ничего, надо только изменить правило сравнения в тесте глубины
с GL_LESS
на GL_GREATER
и изменить значение, которым мы будем инициализировать буфер глубины.
А вот с точностью произойдут огромные изменения - мы получим в результате почти равномерное распределение точности по всему диапазону \( [z_{near}, z_{far}] \). И при этом резкого падения точности при \( z = z_{far} \) уже не будет, как не будет его и при \( z = z_{near} \).
Рис 5. Распределение точности при reversed-z
Вот такой подход и получил название reversed-z и смысл его применения заключается в том, чтобы получить более равномерное распределение точности буфера глубины по всей сцене.
Для перехода к reversed-z в OpenGL нам нужно выполнить несколько шагов.
Во-первых при помощи команды glClipControl
нам нужно перейти к диапазону \([0,1]\)
при отсечении геометрии.
glClipControl ( GL_LOWER_LEFT, GL_ZERO_TO_ONE );
Во-вторых, для использования reversed-z нужно использовать формат буфера глубины с плавающей точкой,
т.е. GL_DEPTH_COMPONENT32F
.
// we need floating point z-buffer
fb.create ( getWidth (), getHeight (), 0 );
fb.bind ();
fb.attachColorTexture ( fb.createColorTexture () );
fb.attachDepthTexture ( fb.createDepthTexture ( GL_DEPTH_COMPONENT, GL_DEPTH_COMPONENT32F,
GL_CLAMP_TO_BORDER, FrameBuffer::filterNearest ) );
if ( !fb.isOk () )
exit ( "Error with fb\n" );
fb.unbind ();
Поскольку теперь дальней плоскости соответствует значение 0, то нам нужно изменить значение, которым мы будем очищать буфер глубины.
glClearDepth ( 0.0f );
Также нам нужно изменить операцию сравнения, используемую в тесте глубины на GL_GREATER
.
glDepthFunc ( GL_GREATER );
Также нам нужно изменить нашу матрицу проектирования. Фактически нам нужно выполнить два дополнительных преобразования над z - сперва из \( [-1,1] \) в \( [0,1] \) и потом перевернуть его, заменив z на 1-z. Пусть наша матрица проектирования имеет вид
\[ Р = \begin{pmatrix} a & 0 & 0 & 0 \\ 0 & b & 0 & 0 \\ 0 & 0 & c & d \\ 0 & 0 & -1 & 0 \end{pmatrix} \]
Здесь использованы следующие обозначения \[ \begin{matrix} \begin{aligned} & a = \frac{f}{a} \\ & b = f \\ & c = \frac{ z_{near} + z_{far} }{ z_{near} - z_{far} } \\ & d = \frac{ 2 z_{near} z_{far} }{ z_{near} - z_{far} } \end{aligned} \end{matrix} \]
Тогда нам нужно умножить эту матрицу на произведение следующих матриц
\[ \begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & -1 & 1 \\ 0 & 0 & 0 & 1 \end{pmatrix} \cdot \begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1/2 & 1/2 \\ 0 & 0 & 0 & 1 \end{pmatrix} \]
В результате этого умножения мы получаем матрицу того же вида, но два элемента изменились:
\[ \begin{matrix} \begin{aligned} & c' = -\frac{ z_{near} }{ z_{near} - z_{far} } \\ & d' = -\frac{ z_{near} z_{far} }{ z_{near} - z_{far} } \end{aligned} \end{matrix} \]
На практике обычно рассматривают случай бесконечно удаленно дальней плоскости. Для этого достаточно в приведенных выше формулах выполнить предельный переход при \( z_{far} \rightarrow \infty \). В результате мы получаем следующую матрицу:
\begin{pmatrix} a & 0 & 0 & 0 \\ 0 & b & 0 & 0 \\ 0 & 0 & 0 & z_{near} \\ 0 & 0 & -1 & 0 \end{pmatrix}
Ниже приводятся функции, строящие соответствующие матрицы проектирования с использованием библиотеки GLM.
// create reversed-z projection
glm::mat4 perspectiveReversed ( float fovy, float n, float f )
{
float aspect = getAspect ();
float b = 1.0f / tan ( fovy );
float a = b / aspect;
float c = n / (f - n);
float d = (n * f) / (f - n);
return glm:: mat4 (
a, 0.0f, 0.0f, 0.0f,
0.0f, b, 0.0f, 0.0f,
0.0f, 0.0f, c, -1.0f,
0.0f, 0.0f, d, 0.0f );
}
// create reversed-z projection with infinite zFar
glm::mat4 perspectiveReversedInf ( float fovy, float n )
{
float aspect = getAspect ();
float b = 1.0f / tan ( fovy );
float a = b / aspect;
float c = 0.0f; // n / (f - n) -> 0
float d = n; // (n * f) / (f - n) -> n
return glm:: mat4 (
a, 0.0f, 0.0f, 0.0f,
0.0f, b, 0.0f, 0.0f,
0.0f, 0.0f, c, -1.0f,
0.0f, 0.0f, d, 0.0f );
}
Если собрать все это вместе, то можно получить следующий код на С++ для осуществления рендеринга на OpenGL с использованием reversed-z.
#include "GlutRotateWindow.h"
#include "Program.h"
#include "BasicMesh.h"
#include "Texture.h"
#include "Framebuffer.h"
class TestWindow : public GlutRotateWindow
{
Program program;
Texture tex;
FrameBuffer fb;
glm::vec3 eye;
glm::vec3 light;
float fovY = 60.0f;
float zNear = 0.1f;
BasicMesh * mesh = nullptr;
public:
TestWindow () : GlutRotateWindow ( 200, 100, 900, 900, "Reversed-Z" )
{
// we need floating point z-buffer
fb.create ( getWidth (), getHeight (), 0 );
fb.bind ();
fb.attachColorTexture ( fb.createColorTexture () );
fb.attachDepthTexture ( fb.createDepthTexture ( GL_DEPTH_COMPONENT, GL_DEPTH_COMPONENT32F, GL_CLAMP_TO_BORDER, FrameBuffer::filterNearest ) );
if ( !fb.isOk () )
exit ( "Error with fb\n" );
fb.unbind ();
if ( !tex.load2D ( "../../Textures/oak.jpg" ) )
exit ( "Error loading decal texture\n" );
if ( !program.loadProgram ( "lighting.glsl" ) )
exit ( "Error building program: %s\n", program.getLog ().c_str () );
mesh = loadMesh ( "../../Models/teapot.3ds", 0.1f );
if ( mesh == nullptr )
exit ( "Error loading mesh" );
eye = glm::vec3 ( 4, 4, 4 );
light = glm::vec3 ( 7, 7, 7 );
program.bind ();
program.setTexture ( "decalMap", 0 );
tex.bind ( 0 );
setupReversedZ ();
}
void redisplay () override
{
fb.bind ();
glm::mat4 mv = getRotation ();
glm::mat3 nm = normalMatrix ( mv );
glClear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );
program.setUniformMatrix ( "mv", mv );
program.setUniformMatrix ( "nm", nm );
mesh -> render ();
fb.unbind ();
// copy color buffer from fb
glBlitNamedFramebuffer ( fb.getId (), 0,
0, 0, getWidth () - 1, getHeight () - 1,
0, 0, getWidth () - 1, getHeight () - 1,
GL_COLOR_BUFFER_BIT, GL_NEAREST );
}
void reshape ( int w, int h ) override
{
GlutRotateWindow::reshape ( w, h );
glm::mat4 proj = perspectiveReversedInf ( fovY, zNear );
program.setUniformMatrix ( "proj", proj );
program.setUniformVector ( "eye", eye );
program.setUniformVector ( "light", light );
}
void idle () override
{
float angle = 4 * getTime ();
light.x = 8*cos ( angle );
light.y = 8*sin ( 1.4 * angle );
light.z = 8 + 0.5 * sin ( angle / 3 );
program.setUniformVector ( "eye", eye );
program.setUniformVector ( "light", light );
glutPostRedisplay ();
}
void setupReversedZ ()
{
glClipControl ( GL_LOWER_LEFT, GL_ZERO_TO_ONE );
glClearDepth ( 0.0f );
glDepthFunc ( GL_GREATER );
}
// create reversed-z projection
glm::mat4 perspectiveReversed ( float fovy, float n, float f )
{
float aspect = getAspect ();
float b = 1.0f / tan ( fovy );
float a = b / aspect;
float c = n / (f - n);
float d = (n * f) / (f - n);
return glm:: mat4 (
a, 0.0f, 0.0f, 0.0f,
0.0f, b, 0.0f, 0.0f,
0.0f, 0.0f, c, -1.0f,
0.0f, 0.0f, d, 0.0f );
}
// create reversed-z projection with infinite zFar
glm::mat4 perspectiveReversedInf ( float fovy, float n )
{
float aspect = getAspect ();
float b = 1.0f / tan ( fovy );
float a = b / aspect;
float c = 0.0f; // n / (f - n) -> 0
float d = n; // (n * f) / (f - n) -> n
return glm:: mat4 (
a, 0.0f, 0.0f, 0.0f,
0.0f, b, 0.0f, 0.0f,
0.0f, 0.0f, c, -1.0f,
0.0f, 0.0f, d, 0.0f );
}
};
int main ( int argc, char * argv [] )
{
GlutWindow::init ( argc, argv );
TestWindow win;
return GlutWindow::run ();
}
Давайте теперь рассмотрим как сделать то же самое в Vulkan. В Vulkan z изменяется на \( [0,1] \), поэтому ряд шагов, которые мы делали ранее, уже не нужны. Но матрицу проектирования нам все-таки придется изменить - для этого ее достаточно домножить на матрицу, "переворачивающую" z, т.е. на
\[ \begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & -1 & 1 \\ 0 & 0 & 0 & 1 \end{pmatrix} \]
Классическая матрица перспективного проектирования в Vulkan выглядит следующим образом:
\[ Р_V = \begin{pmatrix} a & 0 & 0 & 0 \\ 0 & b & 0 & 0 \\ 0 & 0 & c & d \\ 0 & 0 & -1 & 0 \end{pmatrix} \]
Здесь использованы следующие обозначения \[ \begin{matrix} \begin{aligned} & a = \frac{f}{a} \\ & b = f \\ & c = \frac{ z_{far} }{ z_{far} - z_{near} } \\ & d = -\frac{ z_{near} \cdot z_{far} }{ z_{far} - z_{near} } \end{aligned} \end{matrix} \]
В результате умножения мы получим следующую матрицу
\[ Р_V = \begin{pmatrix} a & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1-c & -d \\ 0 & 0 & -1 & 0 \end{pmatrix} \]
Для получения бесконечной дальней плоскости отсечения, делаем предельный переход \( z_{far} \rightarrow \infty \). В результате получаем
\[ \begin{matrix} \begin{aligned} & c \rightarrow 1 - 1 = 0 \\ & d \rightarrow -z_{near} \end{aligned} \end{matrix} \]
Ниже приводится функция на С++, создающая такую матрицу
glm::mat4 perspectiveProjection ( float fovY, float aspectWbyH, float zNear )
{
float f = 1.0f / tanf ( fovY / 2.0f );
return glm::mat4 (
f / aspectWbyH, 0.0f, 0.0f, 0.0f,
0.0f, f, 0.0f, 0.0f,
0.0f, 0.0f, 0.0f, 1.0f,
0.0f, 0.0f, zNear, 0.0f );
}
Исходный код для примера на OpenGL можно скачать здесь. Пример на Vulkan доступен в репозитории на github - https://github.com/steps3d/vulkan-with-classes.git.
Есть еще одно изменение в коде, которое нужно будет внести, если вы используете reversed-z. Если потом в шейдере читается значение из буфера глубины и по нему восстанавливаются трехмерные координаты, то при reversed-z нужно внести изменения в код.
Давайте рассмотрим reverse-z в OpenGL. Координаты в системе координат камеры преобразуются в координаты в пространстве отсечение следующим образом (нас интересуют только z и w, поэтому рассмотрим нижнюю правую подматрицу).
\[ \begin{pmatrix} z_{clip} \\ w_{clip} \end{pmatrix} = \begin{pmatrix} c & d \\ -1 & 0 \end{pmatrix} \cdot \begin{pmatrix} z_{eye} \\ w_{eye} \end{pmatrix} \]
После этого выполняется перспективное деление и мы получаем нормализованные координаты устройства.
\[ z_{NDC} = \frac {z_{eye}}{w_{eye}} = \frac {c \cdot z_{eye} + d}{ -z_{eye} } = -c - \frac {d}{z_{eye}} \]
В классическом OpenGL мы получаем значение от -1 до 1 и для записи в буфер глубины его надо привести в \( [0,1] \).
\[ D = \frac {1 + z_{NDC}}{2} = -\frac{1}{2} \left ( c + \frac {d}{z_{eye}} \right ) \]
У нас z изменяется на \( [0,1] \) поэтому это преобразование не нужно и \( D = z_{NDC} \). В случае бесконечно удаленной дальней плоскости мы получаем
\[ D = z_{NDC} = -\frac {z_{near}}{z_{eye}} \]
Отсюда мы получаем формулу для восстановления \( z_{eye} \) по значению \( D \) из буфера глубины:
\[ z_{eye} = - \frac {z_{near}}{D} \]
Расчет остальных координат ( x и y ) все остается также как и всегда.
Reverse Z (and why it's so awesome)
Reverse Depth Buffer in OpenGL
Reversed-Z Rendering in OpenGL
https://github.com/Zswop/ReversedZ