Автор: Сергей Резник «Sergio»

Опубликовано: 13.06.2010

Изменено: 13.06.2010

Постоянная ссылка

Комментарии [21]

Моделирование стеклянных поверхностей


Пошагово описывается процесс моделирования и рендеринга гладких полированных стеклянных предметов на базе OpenGL.



Страницы: 1 2

Содержание

Введение
1. Стекло. Что же мы будем моделировать?
2. Прозрачность стекла
3. Модель освещения
4. Моделирование каустики
5. Фильтрация текстуры с каустикой
6. Два премломления при отрисовке стекла
7. Дальнейшее развитие техники
8. Рабочая программа и исходники
Полезные ссылки

Введение

С помощью современных графических адаптеров можно получать все более сложные динамические сцены. Это означает что высококачественный (фотореалистичный) рендеринг переноситься из offline в реальное время. В частности, одной из насущных задач компьютерной графики является расчет и рендеринг глобального освещения и сложных материалов. В этой статье я хотел бы рассказать о моделировании стеклянных предметов. Стекло является довольно сложным для моделирования материалом, если поставлена задача получить максимально реалистичное изображение. В данной статье мы вместе с вами разберем пошагово процесс моделирования и рендеринга стеклянных предметов. Надеюсь, прочитав данную статью, читатель сможет разобраться в технологии моделирования стекла и получить у себя что-то вроде этого:

Моделирование стеклянных поверхностей

1. Стекло. Что же мы будем моделировать?

Итак, что же нам предстоит сделать для того, чтобы получить реалистичное стекло?

Основное, что делает стекло стеклом – это его прозрачность. То есть стекло преломляет и пропускает лучи света (но при этом, конечно же, не стоит забывать и то, что стекло также и отражает свет), позволяя видеть (хотя и в искаженном виде за счет преломления) предметы, находящиеся за стеклянным предметом, по отношению к наблюдателю.

Также следует с осторожностью подойти к выбору модели освещения стекла. От правильно выбранной модели освещения зависит очень многое. Так, например, если выбрать модель освещения Кука-Торренса – мы получим гладкое полированное стекло, а если выбрать модель Фонга - то стекло уже не будет казаться полированным. Но в статье я остановлю свой выбор именно на модели освещения Кука-Торренса.

Еще одна важная часть моделирования стекла – это каустика. Те самые светлые пятна, которые образуются при концентрации лучей света на небольшой площади вследствие их преломления (отклонения от начальной траектории) на границе материала. Да, кстати, каустика относится уже к моделированию глобального освещения.

Итак, четыре компонента, которые делают стекло стеклом в компьютерной графике:
1. Прозрачность
2. Отражения
3. Освещение
4. Каустика

Ну что же, начнем моделирование нашего стекла. Да, кстати я забыл сказать, что для моделирования я выбрал OpenGL 3.2. По ходу дела я конечно же буду объяснять, как сделать тот или иной эффект с помощью этого графического API и где это потребуется, будет приведен исходный код непосредственно на С++, а так же все необходимые шейдеры. Также, я полагаю, что читатель знаком с основами OpenGL и сможет самостоятельно провести некоторую подготовку (сделать простенькую сцену с одним точечным источником свет) а так же знаком с технологией использования Framebuffer Object.

Итак, мы начинаем!

2. Прозрачность стекла

Собственно прозрачность объекта можно смоделировать относительно легко. Достаточно нарисовать из центра моделируемого объекта кубическую карту, а затем делать выборки из неё в правильных направлениях. До относительно недавнего времени, получить динамическую кубическую карту в OpenGL можно было шесть раз отрисовав сцену в каждую из граней кубической текстуры. Но с появлением в ядре OpenGL 3.2 геометрических шейдеров все стало намного проще. Достаточно один раз нарисовать сцену, все за нас остальное сделает геометрический шейдер. Но давайте все же действовать по порядку. Итак, представьте себе, что у нас есть сцена, а лучше давайте сразу договоримся о том, что она унас есть. У меня в примере – это комната, с расставленными в ней случайным образом ящиками. Рисуется это все очень просто, но постепенно мы будем добавлять в отрисовку сцены новые пункты.

Теперь самое время перейти к созданию кубической карты для нашего стекла. Рассмотрим два подхода – первый будет с шестью отрисовками в грани кубической карты, а второй – с геометрическим шейдером. Далее и везде я буду использовать Framebuffer Object для отрисовки в любую текстуру. Надеюсь читатель уже знаком с этой технологией и мне придется только лишь добавить некоторые моменты, относительно рендеринга в кубическую карту.

Итак, для того чтобы получить кубическую карту с использованием шести проходов отрисовки нам будет необходимо: создать саму кубическую текстуру, создать Framebuffer Object, который будет отвечать за отрисовку в созданную кубическую карту и выполнить отрисовку сцены в неё. Кубическая карта создается следующим образом:

void Ce2Render::buildCubeTexture(Ce2TextureObject* texture)
{
  texture->target = GL_TEXTURE_CUBE_MAP;
  glGenTextures(1, &(texture->glID)); // создаем новую текстуру
  bindTexture(0, texture, GL_TEXTURE_CUBE_MAP); // биндим её как кубическую карту

  glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
  glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
  glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
  glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
  glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); 

  // генерируем все шесть граней
  for (int face = 0; face < 6; face++)
  {
    TexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, 0, 
    texture->internalFormat, texture->width, texture->height, texture->format, texture->type, 0, texture->name); 
  }
}

Код, для создания объекта Framebuffer Object используем стандартный (помните, я рассчитываю на то, что вы уже знакомы с ним, во всяком случае, описывать создание FBO – это не та задача, которая поставлена в данной статье).

И еще один важный момент – настроить матрицы преобразования так, чтобы камера охватывала все окружение (360°). Для этого нам нужно настроить матрицу проекции (projection matrix) так, чтобы FOV равнялся 90°. Приведу фрагмент исходного кода, который создает матрицу проекции:

mat4 Ce2Camera::perspectiveProjection(float fov, float aspect, float zNear, float zFar)
{
  mat4 result = IDENTITY_MATRIX;

  float fHalfFOV = 0.5f * fov;
  float cotan = cos(fHalfFOV) / sin(fHalfFOV);
  float dz = zFar - zNear;

  result[0][0] = cotan / aspect;
  result[1][1] = cotan;
  result[2][2] = -(zFar + zNear) / dz;
  result[3][3] =  0.0f;
  result[2][3] = -1.0f;
  result[3][2] = -2.0f * zNear * zFar / dz;

  return result;
}
...
mat4 _cubemapProjectionMatrix = Ce2Camera::perspectiveProjection(HALF_PI, 1.0, 1.0, 2048.0);

Теперь у нас есть матрица проекции, но нам нужны еще шесть видовых матриц, с помощью которых наша камера "смотрела" бы в разные стороны (±X, ±Y, ±Z). Получить эти матрицы можно простым умножением проективной матрицы на матрицу поворота на соответствующие углы, а можно просто (что при этом даст тот же результат) переставить местами (конечно, не случайным образом) строки проективной матицы. Я однажды делал это, а теперь поделюсь с вами :)

Следующий код генерирует 6 матриц для отрисовки в кубическую карту из заданной позиции:

CubemapMatrixArray Ce2BasicHelper::cubemapMatrix(const mat4& projectionMatrix, const vec3& pointOfView)
{
  CubemapMatrixArray result; // просто массив из 6-ти матриц

  mat4 translation = translationMatrix(-pointOfView);

  // строки проективной матрицы
  const vec4& rX = projectionMatrix[0]; 
  const vec4& rY = projectionMatrix[1];
  const vec4& rZ = projectionMatrix[2];
  const vec4& rW = projectionMatrix[3];

  // переставляем хитрым образом и домножаем на матрицу перемещения в заданную точку
  result[0] = translation * mat4( -rZ, -rY, -rX, rW );
  result[1] = translation * mat4(  rZ, -rY,  rX, rW );
  result[2] = translation * mat4(  rX, -rZ,  rY, rW );
  result[3] = translation * mat4(  rX,  rZ, -rY, rW );
  result[4] = translation * mat4(  rX, -rY, -rZ, rW );
  result[5] = translation * mat4( -rX, -rY,  rZ, rW );

  return result;
}

Ну вот, теперь у нас есть все, чтобы нарисовать в кубическую карту вашу сцену. Делается это примерно следующим образом:

 // _reflectionRefractionBuffer – созданный раннее Framebuffer Object
 // _reflectionRefractionTexture – созданная ранее кубическая карта
 // _cubemapMatrices – массив из шести предрасчитанных матриц

 render()->bindFramebuffer(_reflectionRefractionBuffer);
 for (int i = 0; i < 6; ++i)
 {
   _reflectionRefractionBuffer->setCurrentRenderTarget(_reflectionRefractionTexture, GL_TEXTURE_CUBE_MAP_POSITIVE_X + i);
   glClear(GL_DEPTH_BUFFER_BIT);
   // метод, который просто рисует сцену (параметры – положение камеры и матрицы)
   renderEnvironment(_modelCenter, _cubemapMatrices[i]);
 }

С первым методом кажется все ясно. А как же быть с отрисовкой в кубическую карту, с использованием геометрического шейдера? Давайте разбираться с этим. При таком подходе нам понадобится Framebuffer Object, к которому присоединена не обычная 2D текстура, а кубическая карта. Так, если для присоединения 2D текстуры мы использовали метод glFramebufferTexture2D, то теперь нам необходимо использовать метод glFramebufferTexture, который позволяет присоединить к объекту Framebuffer Object любую текстуру – будь то 1D, 2D, 3D текстура или же кубическая карта.

Теперь самое интересное – геометрический шейдер. Суть его в том, что он будет создавать новые треугольники и проецировать их на каждую из шести граней кубической карты. Для этого будем использовать в геометрическом шейдере встроенную переменную gl_Layer, которая позволяет выбрать слой (грань) многослоевой текстуры (кубической карты), в который мы будем выводить примитивы. Для того чтобы треугольники корректно проецировались на грани кубической карты, нам нужно будет передать в геометрический шейдер шесть матриц, которые были успешно рассчитаны ранее.

Теперь рассмотрим шейдеры, которые используются для отрисовки окружения по порядку:

Вершинный шейдер. Я использовал препроцессор, для того чтобы не размножать шейдеры, а скомпоновать все в один, с четко определенным функционалом. Директива «WITH_GS» используется для того, чтобы определить – используются ли геометрический шейдер или нет. Если не используется (а значит следом за вершинным сразу идет фрагментный шейдер) значит, мы как обычно проецируем нашу вершину и передаем требуемые значения (нормаль, текстурные координаты и т.д.) сразу во фрагментный шейдер. Если же используется геометрический шейдер, то нам необходимо передать в него исходную вершину и требуемые значения, а он уже в свою очередь сделает все необходимое и передаст управление фрагментному шейдеру. Итак, вот он наш вершинный шейдер:

#ifndef WITH_GS 
// если мы используем геометрический шейдер, то эта матрица нам не нужна
uniform mat4 mModelViewProjection; 
#endif

uniform vec3 vCamera;
uniform vec3 vPrimaryLight;
uniform mat4 mLightProjectionMatrix;
uniform mat4 mTransform; // матрица преобразования модели

in vec4 Vertex;
in vec3 Normal;
in vec2 TexCoord0;

#ifdef WITH_GS
 // данные для передачи в геометрический шейдер
 out vec3 gs_vLightWS;
 out vec3 gs_vViewWS;
 out vec3 gs_vNormalWS;
 out vec2 gs_TexCoord;
 out vec4 gs_LightProjectedVertex;
#else
 // данные для передачи во фрагментный шейдер
 out vec3 vLightWS;
 out vec3 vViewWS;
 out vec3 vNormalWS;
 out vec2 TexCoord;
 out vec4 LightProjectedVertex;
#endif

void main()
{
  vec4 vTransformedVertex = mTransform * Vertex;

  // здесь я приведу лишь основную (ключевую) часть шейдера
  // полный код см. в прилагаемых исходниках

  #ifdef WITH_GS
  ...
  gl_Position = vTransformedVertex; // передаем исходную вершину в геометрический шейдер
  #else
  ...
  gl_Position = mModelViewProjection * vTransformedVertex; // просто проецируем вершину, как обычно
  #endif
}

Теперь давайте рассмотрим геометрический шейдер.

layout(triangles) in;
layout(triangle_strip, max_vertices = 18) out;
// на выходе имеем 18 вершин – по одному треугольнику на каждую из шести граней

uniform mat4 mModelViewProjection[6]; // матрицы проецирования на каждую из граней кубической карты

// принимаемые значения из вершинного шейдер
in vec3 gs_vLightWS[];
in vec3 gs_vViewWS[];
in vec3 gs_vNormalWS[];
in vec2 gs_TexCoord[];
in vec4 gs_LightProjectedVertex[];

// значения, передаваемые во фрагментный шейдер
out vec3 vLightWS;
out vec3 vViewWS;
out vec3 vNormalWS;
out vec2 TexCoord;
out vec4 LightProjectedVertex;

void main()
{
  // проходим циклом по всем шести граням кубической карты
  for (int layer = 0; layer < 6; layer++)
  {
    gl_Layer = layer; // указываем, в какую грань сейчас должен быть направлен вывод

    // и далее проецируем каждую из граней треугольника
    // все это можно собрать в цикл (for i = 0; i < 3; ++i), но зачем нам еще один цикл в шейдере? 
    // здесь я также приведу лишь основной функционал, полный код шейдера смотрите в исходниках

    ...
    gl_Position = mModelViewProjection[layer] * gl_in[0].gl_Position;
    EmitVertex();

   ...
    gl_Position = mModelViewProjection[layer] * gl_in[1].gl_Position;
    EmitVertex();
    ...
    gl_Position = mModelViewProjection[layer] * gl_in[2].gl_Position;
    EmitVertex();

    EndPrimitive();
  }
}

Фрагментный шейдер мы пока оставляем без изменений. А вот отрисовка в данном случае будет следующей:

 
  render()->bindFramebuffer(_reflectionRefractionCubemapBuffer);
  glClear(GL_DEPTH_BUFFER_BIT);
  renderEnvironmentToCubeMap(_modelCenter); // полный код данной ф-ции см. в исходниках
   

Как видите – нам нужна только одна отрисовка вместо шести.

Конечно, возникает вопрос "А что быстрее?. Рисовать 6 раз в каждую из граней, либо один раз, но с увеличением числа треугольников в геометрическом шейдере?". На самом деле скорость работы практически одинакова, но вариант с геометрическим шейдером мне нравится больше из-за меньшего размера кода и его "красоты".

Кубическая карта у нас готова (первым или вторым способом). Теперь можно посмотреть наложить её на объект и посмотреть, что же из этого выйдет. Предлагаю начать с простого шейдера и постепенно наращивать функционал, увеличивая качество изображения. Для начала надо сказать, что при рендеринге стекла нам нужно забыть об освещении Ламберта (это то, которое dot(light, nomal)). Так как в этом разделе речь пойдет только о правильном наложении кубической карты, то я пока опущу и бликовое освещение и перейдем непосредственно к делу. Итак, у нас есть кубическая карта, отрендеренная из центра объекта. В шейдерах выборка из такой текстуры происходит следующим образом:

vec4 color = texture(cubemap, direction);

где direction – трехкомпонентный вектор, по которому определяется грань кубической карты и текстурные координаты на этой грани.

Предлагаю рассмотреть наложение кубической карты на сферу, так как стеклянный шарик есть практически у каждого, и вы сами сможете использовать его как некий эталон. Давайте для начала в качестве направления выборки (вектора direction) используем нормаль на сфере – таким образом можно проверить – правильно ли мы отрисовали нашу кубическую карту. При таком наложении мы увидим объекты так, как бы мы их видели из центра шара в направлении нормали:

Моделирование стеклянных поверхностей

Надеюсь, у вас сразу получилось правильно отрисовать кубическую карту и теперь можно перейти к более сложному алгоритму. Давайте еще раз вспомним – что мы собственно хотели от кубической карты? Это - отражения и преломления. Давайте попробуем наложить кубическую карту так, чтобы сфера отражала лучи света. Хорошо, что в GLSL есть встроенная функция reflect, которая принимает два параметра – направление луча и нормаль к плоскости, от которой луч света отражается. Разработчики GLSL избавили нас от необходимости самостоятельного вычисления отраженного вектора по формуле:

R = I – 2·dot(N, I)·N

где I (incidence) – падающий луч, N (normal) – нормаль.

Итак, рассчитаем отраженный вектор и сделаем выборку из кубической карты.

 
  vec3 vReflected = reflect(-vViewNormal, vNormalWS);
  FragColor = texture(environment_map, vReflected);
   

Следует заметить, что вектор взгляда должен быть нормированным и вычисляться как vertex – camera, а не наоборот, либо (как в приведенном коде) использовать знак "–" перед вектором взгляда. Думаю это следует пояснить небольшим рисунком:

Моделирование стеклянных поверхностей

Итак, сделаем выборку в направлении отраженного вектора и увидим вот такую картинку:

Моделирование стеклянных поверхностей

Теперь, думаю, самое время исследовать преломление лучей света. Как известно из курса физики (если не ошибаюсь, это есть даже в школьной программе) луч света преломляется на границе двух сред по закону Снелла:

n1 sinϑ1 = n2 sinϑ2

где n1,2 - абсолютные показатели преломления материала (отношение скорости света в среде к скорости света в вакууме);
     ϑ1,2 - углы между нормалью и соответственно падающим лучом и преломленным.

Мы рассматриваем случай, когда луч света переходит из воздуха (n1≈1.0) в стекло (n2≈1.4). Относительный показатель преломления η будет равен n1/n2≈0.7. Вот мы и добрались до относительного показателя преломления. Именно он используется во встроенной ф-ции GLSL refract, которая принимает три параметра – падающий луч, нормаль и показатель преломления.

 
  vec3 vRefracted = refract(-vViewNormal, vNormalWS, indexOfRefraction);
  FragColor = texture(environment_map, vRefracted);
   

Результатом будет вот такая картинка:

Моделирование стеклянных поверхностей

Итак, отражения и преломления у нас есть, но по отдельности. Как же их совместить? Очень просто. Еще в 1823 году Огюст Френель вывел формулы, с помощью которых можно рассчитать долю отраженной энергии в зависимости от показателя преломления и угла падения луча:

Моделирование стеклянных поверхностей

Сейчас существует множество аппроксимаций для данной формулы, но я же предлагаю использовать оригинальную формулу, только мы её немножко сейчас преобразуем (вы же не думаете, что я предлагаю вам вычислять синусы и косинусы?).

Итак – что же такое cos(Θ)? Это не что иное, как скалярное произведение нормированного вектора взгляда на нормаль в данной точке. Единственное, что хотелось бы сказать – вектор взгляда в данном случае надо вычислять как camera – vertex. С косинусом разобрались, а синус есть не что иное, как единица минус косинус в квадрате. Если умножить и разделить все выражение (под степенью) на его числитель, то получим сверху квадрат числителя, а снизу 1 - η2. Теперь формула выглядит значительно проще (кому интересно – могут проделать эти преобразования вручную или в любом математическом пакете). Таким образом, на самом деле нет ничего страшного в этой формуле в вычислительном плане и мы можем вынести вычисление коэффициента Френеля в отдельную функцию вида:

float fresnel(float VdotN, float eta)
{
 float sqr_eta = eta * eta; // квадрат показателя преломления
 float etaCos = eta * VdotN; // η·cos(Θ)
 float sqr_etaCos = etaCos*etaCos; // в квадрате
 float one_minSqrEta = 1.0 - sqr_eta; // 1 – η2
 float value = etaCos - sqrt(one_minSqrEta + sqr_etaCos);
 value *= value / one_minSqrEta; // возводим в квадрат и делим на 1 – η2
 return min(1.0, value * value); // финальное возведение в квадрат
}

Если вывести коэффициент Френеля в качестве цвета:

 
  float fFresnel = fresnel(dot(vViewNormal, vNormalWS), indexOfRefraction);
  FragColor = vec4(fFresnel);
   

то мы увидим следующее:

Моделирование стеклянных поверхностей
Моделирование стеклянных поверхностей

Белые области соответствуют большему отражению света, черные – преломленным лучам.

В общем-то, у нас есть всё, чтобы сделать самое примитивное стекло. Давайте просто смешаем полученные ранее цвета (для отражения и преломленного света) с учетом коэффициента Френеля:

FragColor = mix(cRefraction, cReflection, fFresnel);

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

Моделирование стеклянных поверхностей
Моделирование стеклянных поверхностей
 
Страницы: 1 2