Майнкрафт-подобный рендер на OpenGL4

29.08.2012

Статья является переводом замечательного материала от Флориана Боша http://codeflow.org/entries/2010/dec/09/minecraft-like-rendering-experiments-in-opengl-4/. Все спасибо отправляются автору. Переведено по лицензии Creative Commons (http://creativecommons.org/licenses/by-sa/3.0/deed.en_US)

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

Я уже писал статью про тесселированый шейдинг OpenGL, в которой я научился многим современным методикам. Поэтому в качестве платформы я выбрал OpenGL4, так как я подумал, что в ней всё сделать будет попроще. Но вообще, все это можно сделать и с помощью более ранних версий OpenGL.

Финальный результат

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

(Многие интересовались, что за фрагмент музыки я использовал: это «IntimateMoment» от Люка Ричардса размещенный на аудиопортале YouTube).

Парящая скала:

Вход в пещеру:

Внутренность пещеры, содержащая лаву:

Приближенный фрагмент каменистой стены со вкраплением золота:

Содержание

  • Финальный результат
  • Содержание
  • Как читать эту статью
  • Установки
  • Объемный массив
  • Каркас объекта
  • Нормали
  • Рассеянное освещение с использованием сферических гармоник
  • Несложный алгоритм для шума
  • Внутреннее освещение
  • Текстуры
  • Источника света типа фонаря
  • Текстуры выпуклостей
  • Текстуры для эффекта блеска
  • Захват лучей для лавы
  • Атмосферные эффекты
  • Заключение

Как читать эту статью

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

Установки

Приложение использует таки технологии, как шейдеры GLSL, VertexBufferObjects, и ArrayTextures. Рендер уровня делается одним вызовом glDrawArrays. При этом предварительно установлены шейдеры, вершинные и индексные буферы а также текстуры.

В качестве вспомогательных инструментов используется Pyglet а также gletools. Некоторые функции написаны на C, — там, где требуется производительность, остальное же написано на питоне.

Вы можете скачать исходный текст и поэкспериментировать с ним. Часть кода, содержащая C, компилируется скриптом make.sh.  Если вы не на линуксе, вы должны портировать код в ваш компилятор.

Объемный массив

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

Существуют две процедуры – макросы для доступа к массиву:

#define get(x, y, z)\
data[\
    clamp(x,size)+\
    clamp(y, size)*size+\
    clamp(z, size)*size*size\
]
 
#define put(x, y, z, value)\
data[\
    (x)+\
    (y)*size+\
    (z)*size*size\
] = value

Также в подобных массивах содержаться данные для расчета источников света типа occlusionи gathered.

Каркас объекта

Для того, чтобы преобразовать данные из трехмерного массива, описанного в предыдущем разделе в полигоны OpenGL для каркаса объекта, необходимо произвести его тесселяцию. Для каждого пикселя из объема, протестируем два параметра: то, что пиксель пустой и то, что он содержит заполненные соседние пиксели. Если эти два условия совпадают, то на границе между пикселями мы генерируем квадратный полигон. При этом плоскость квадрата будет смещена относительно центра на 0.5 в ту или иную сторону.

Заполним объемный массив случайными данными, а также установим glClearColor

glClearColor(0.8, 0.8, 0.8, 1.0);

И используем несложный шейдер который просто выдает темно серый цвет:

glsl
vertex:
    in vec4 position;
    uniform mat4 mvp;
 
    void main(){
        gl_Position = mvp * position;
    }
fragment:
    out vec3 fragment;
    void main(){
        fragment = vec3(0.2);
    }

Это будет выглядеть так:

Не слишком веселая картинка, так что нужно добавить немного освещения. Для этого пригодятся нормали.

Нормали

Вектора нормалей указывают в направлении от плоскостей стенок к центрам пустых ячеек. Что соответствует противоположному направлению смещения плоскостей от центров ячеек.

Для того, чтобы увидеть, что нормали отображены правильно, мы используем геометрический шейдер (см. shaders/normals.shader). Он довольно полезен так как входящие данные для шейдера – значения нормалей а выходящие – линия от каждого полигона.

Рассеянное освещение с использованием сферических гармоник

В статье OpenGLShadingLanguage есть неплохой раздел 12 посвященный сферическим гармоникам и использованию их для освещения. На этом сайте также есть демо с исходным кодом (см. glsldemo-src/shaders/OrangeBook/CH-12-shLight).

Я написал функцию, которая позволяет мне установить рассеянное освещение (см. shaders/spherical_harmonics.shader):

fragment = sh_light(data.normal, beach) * 0.5;

Применяя эту функцию к заполненному случайными значениями кубу мы получим:

Несложный алгоритм для шума

Настало время заполнить трехмерный куб более интересными данными. Чтобы это сделать, воспользуемся методом KenPerlinsSimplexNoise. Я переписал код функции Noiseдля трехмерного случая (см. ext/simplex.c):

Каждый пиксель будет устанавливаться в 1, если 

Рассеянное освещение

Освещение с использованием сферических гармоник работает хорошо. Однако необходимо добавить немного глубины эффекту. Для этого подойдет метод рассеянного освещения (AmbientOcclusion). Главная идея этого метода это исследовать количество лучей, способных вырваться из определенной точки без столкновений.

Мы могли бы для каждой ячейки использовать одно вычисленное значение Occlusion, но мы выберем еще лучший способ. Для каждой ячейки мы создадим массив для хранения значений Occlusion для каждой из 6 прилегающих к ячейке полигонов. Согласно правилу LambertСosineLaw только часть лучей эффективно отразиться, так что мы должны учесть и это. Вообщем, создадим массив для хранения шести прилегающих значений Occlusion для близлежащих с ячейкой полигонов:

typedef struct{
        float left, right, top, bottom, front, back;
} Cell;

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

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

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

typedef struct{
        float depth;
            int x, y, z;
} Offset;
 
typedef struct{
        float left, right, top, bottom, front, back;
        Offset points[point_count];
} Ray;

Структура Sampleх ранит все лучи, которые мы проверяем для каждой ячейки объема. Она также хранит сумму значений Occlusionвсех лучей для каждого отдельного полигона:

typedef struct{
        float left, right, top, bottom, front, back;
            Ray rays[ray_count];
} Sample;

Теперь остается только проверить, как работает целочисленное смещение лучей. Для этого мы заполним наш трехмерный массив не случайными данными, а собственно значениями целочисленных смещений лучей. В этом блоге имеется неплохое описание метода GoldenSpiral алгоритма SpherePicking, благодаря которму мы можем выбрать N-ое количество точек из поверхности сферы. Генерируя лучи в соответствии с алгоритмом, и заполняя ими объемный массив, мы получаем следующее:

Если теперь произвести вычисление значений Occlusion для каждой ячейки, т.е. проверить, насколько хорошо работает алгоритм для рассеянного освещения, и просуммировать все исходящие лучи, заполнив ими значение Occlusion для каждого полигона модели, то мы получим следующий результат:

Гамма коррекция

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

Проблема в том, что большинство значений цветов в текстурах уже гамма откорректированы. Чтобы учесть значения гамма, мы должны произвести операцию degammaдля цветов пикселей текстур:   и перед выводом цвета на экран соответственно выполнить:

vec3 gamma(vec3 color){
    return pow(color, vec3(1.0/2.0));
}
main(){
    fragment = gamma(color);
}

Гамма значения обычно лежат в диапазоне от 1.8 до 2.2. (В зависимости от монитора, но так как у нас нет способа получить правильное значение для каждого монитора, вы возможно оставите выбор значения гамма как настройку пользователя, или выберите промежуточное значение).

В статье GPUGems 3 Chapter 24 «TheImportanceofBeingLinear« произведен хороший обзор по значениям гамма. Игнорирование гамма значений это вопрос вашего усмотрения, просто со значения гамма изображение выглядит немного иначе.

Более эффектный алгоритм шума

Если мы установим размер объемного массива, равный 128, то результат все равно еще не похож на парящую скалу:

Чтобы сделать шум для заполнения массива более интересным, мы изменим алгорим, внеся множественные октавы в алгоритм simplex_noiseи просуммируем все данные:

float simplex_noise(int octaves, float x, float y, float z){
    float value = 0.0;
    int i;
    for(i=0; i<octaves; i++){
        value += noise(
            x*pow(2, i),
            y*pow(2, i),
            z*pow(2, i)
        );
    }
    return value;
}

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

Комбинируя все вместе, а также немного вытянув алгоритм шума в направлении Y и сжав соответственно по X и Z, мы уже получим нечто похожее на парящую скалу:

Для достижения еще большего сходства со скалой, мы произведем операцию рытья пещер внутри её поверхности при помощи еще одного экземпляра объекта шума:

Теперь окончательно имеем полный код генерации фрагмента скалы:

void floating_rock(int size, byte* data){
    float caves, center_falloff, plateau_falloff, density;
    foreach_xyz(1, size-1)
        if(yf <= 0.8){
            plateau_falloff = 1.0;
        }
        else if(0.8 < yf && yf < 0.9){
            plateau_falloff = 1.0-(yf-0.8)*10.0;
        }
        else{
            plateau_falloff = 0.0;
        }
 
        center_falloff = 0.1/(
            pow((xf-0.5)*1.5, 2) +
            pow((yf-1.0)*0.8, 2) +
            pow((zf-0.5)*1.5, 2)
        );
        caves = pow(simplex_noise(1, xf*5, yf*5, zf*5), 3);
        density = (
            simplex_noise(5, xf, yf*0.5, zf) *
            center_falloff *
            plateau_falloff
        );
        density *= pow(
            noise((xf+1)*3.0, (yf+1)*3.0, (zf+1)*3.0)+0.4, 1.8
        );
        if(caves<0.5){
            density = 0;
        }
        put(x, y, z, density>3.1 ? ROCK : 0);
    endfor
}

Внутреннее освещение

Если мы проникнем в пещеры скалы, то увидим, что внутренность пещеры довольно темная, хотя вообщем, это и не удивительно, ведь внутри пещер темно; однако мы не видим ничего интересного в темных местах пещеры:

Во первых, добавим немного рассеянного освещения, используя сферические гармоники, чтобы хоть что-то различить в темных областях. Для этого установим несколько констант для сферических гармоник с различными цветами. Затем мы смешаем значения рассеянного освещения (inside), со значениями Occlusion (outside).

vec3 outside = sh_light(surface_normal, beach);
vec3 inside = sh_light(surface_normal, groove)*0.04;
vec3 ambient = mix(outside, inside, data.occlusion);
fragment = gamma(ambient);

Проблема решена, теперь в темных местах кое-что видно:

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

Текстуры

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

Так как мы будем использовать несколько материалов, каждая текстурная координата будет трехкомпонентным вектором. Две первые компоненты это координаты текстуры, а третья это номер текстуры.

OpenGL имеет специальное средство для комбинирования нескольких текстур. Оно называется ArrayTextures. При помощи этого несколько текстур могут загружаться в одну трехмерную текстуру графического адаптера. Это средство работает наподобие 3d текстур, однако слои текстур не смешиваются и третья компонента Z целочисленная.

Я создал несколько текстур, используя пакет Lithospere, и поместил их в ArrayTexture:

В качестве дополнения я присвоил каждой константе для текстуры имена, обозначая текстуры как воздух, скала, вкрапления, грунт, трава и лава:

const int AIR = 0;
const int ROCK = 1;
const int GEMS = 2;
const int DIRT = 3;
const int GRASS = 4;
const int LAVA = 5;

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

vertex:
    in float occlusion;
    in vec3 normal, texcoord;
    in vec4 position;
    uniform mat4 mvp;
 
    out Data{
        vec3 texcoord, normal;
        float occlusion;
    } data;
 
    void main(void){
        data.occlusion = occlusion;
        data.texcoord = texcoord;
        data.normal = normal;
        gl_Position = mvp * position;
    }
 
fragment:
    import: spherical_harmonics
    import: util
 
    uniform sampler2DArray material;
 
    in Data{
        vec3 texcoord, normal;
        float occlusion;
    } data;
 
    out vec3 fragment;
 
    void main(){
        vec3 material_color = texture(
            material, data.texcoord
        ).rgb;
 
        vec3 outside = sh_light(data.normal, beach);
        vec3 inside = sh_light(data.normal, groove)*0.004;
        vec3 ambient = mix(outside, inside, data.occlusion);
 
        vec3 color = material_color*ambient;
        fragment = gamma(color);
    }

Теперь поверхность скалы выглядит неплохо, и осталось создать несколько простых правил для назначения той или иной текстуры:

  • Если ячейка имеет много пустого пространства сверху, то это грунт (cake_dirtв ext/ext.c).
  • Верхняя плоскость ячейки грунта это трава (tesselateext/ext.c).
  • Если ячейка скала и значение шума 3.6, то это вкрапления (add_gemsв ext/ext.c).
  • Если расстояние от центра скалы ближе чем 12 ячеек, то есть если это центр скалы, то если значение шума 3.2, то добавим лаву (add_lavaв ext/ext.c).
  • Если какие-то ячейки оказались одиночными, то добавим значение воздух. (delete_solitary в ext.c)

Источник света типа фонаря

Так как внутри пещеры темно, то обычно, те, кто погружается в пещеры, имеют фонарь. Почему бы не смоделировать его для освещения таких участков:

Для того, чтобы создать источник света типа фонаря нужны данные:

  • Расстояния (глубины) на которую луч от фонаря распространяется
  • Нормали полигона скалы
  • Нормали наблюдателя

Имея эти данные мы можем вычислить значение cosinelaw для нормали полигона и поделить это значение на квадратный корень от расстояния (lightfalloff).

Значение глубины:Depth

Чтобы получить расстояние, необходимо поместить матрицу modelview (однако, не умноженную на матрицу проекций) в шейдер, умножить её на position и определить значение длины получившегося вектора:

in vec4 position;
uniform mat4 modelview;
out Data{
    float depth;
} data;
 
void main(){
    data.depth = length((modelview * position).xyz);
}

Значения нормалей: FaceNormal

Для того, чтобы получить значения нормалей, нам необходима матрица normalmatrix. Эта трехрядная 3×3 матрица образуется из матрицы modelview. Так как я не применял масштабирования к modelview, то я просто могу скопировать эти три столбца и строки:

mat3 normalmatrix = mat3(modelview);

Однако вы должны передать матрицу modelviewкак константу шейдера.

Теперь преобразуем матрицу в пространство EyeSpace:

vec3 eye_face_normal = normalmatrix * data.normal;

Значения Eye нормалей: EyeNormal

Чтобы получить вектор eyenormalиз gl_FragCoord(который в пространстве экрана), вам необходимо:

  1. Преобразовать эту координату в пространство устройства (разделить координаты на количество точек по горизонтали, затем вычесть 0.5 и умножить на два).
  2. Умножить её на инверсную матрицу проекции.

Формулы для преобразований матриц проекций и инверсной матрицы проекций вы можете найти в Redbook(OpenGLProgrammingGuide), AppendixF. Я включил данные функции в утилиты, там эта функция называется get_eye_normal:

vec3 get_eye_normal(vec2 viewport, mat4 inverse_projection){
    vec4 device_normal = vec4(
        ((gl_FragCoord.xy/viewport)-0.5)*2.0, 0.0, 1.0
    );
    return normalize(
        (inverse_projection * device_normal).xyz
    );
}

В качестве цвета для фонаря мы выберем теплый желтый цвет(как от небольшой лампочки). В данном коде производится установка нашего источника света:

vec3 eye_face_normal = normalmatrix * data.normal;
vec3 eye_normal = get_eye_normal(
    viewport, inverse_projection
);
vec3 torch_color = vec3(1.0, 0.83, 0.42);
float intensity = 2.0/pow(data.depth, 2);
float lambert_term = abs(
    min(0, dot(eye_face_normal, eye_normal))
);
vec3 torch = lambert_term * intensity * torch_color;
 
vec3 color = material_color*ambient + material_color*torch;

Вся подготовительная часть сделалана, теперь можно посмотреть на результат установки источника освещения типа фонаря:

Текстуры выпуклостей

Плоская текстура выглядит несколько скучноватой, и один из методов оживить её – normalimapping. Идея текстуры выпуклостей в том, чтобы брать нормали непосредственно из текстуры. Во первых, для этого, понадобиться собственно текстура. Я использовал пакет Lithospere для создания текстур типа normalmap:

Так как зеленый цвет нормалей обозначает направление вверх, то я не могу просто наложить нормали сразу на полигон. Сначала нужно проецировать нормаль из текстуры normalmapв пространство facenormal.

Для этого нам понадобиться два дополнительных вектора. Они лежат в плоскости полигона, один по направлению x, другой y. Обозначим эти вектора s и t.

Эти перпендикулярные вектора мы будем передавать как дополнительные атрибуты к каждой вершине(также как и нормаль). Чтобы преобразовать вектор из normalmap в пространство facenormal, мы затем сделаем:

Мы получим данную матрицу в шейдер в качестве атрибутов вершин. Затем мы её пердадим в пиксельныйшейдер:

in normal, s, t;
out Data{
    mat3 matfn;
} data;
 
void main(){
    data.matfn = transpose(mat3(s, normal, t));
}

В пиксельномшейдере мы будем испльзовать эту матрицу для создания векторов frag_normal и eye_frag_normal:

vec3 map_normal = normalize(
    texture(normalmap, data.texcoord).rgb
);
vec3 frag_normal = normalize(
    map_normal * data.matfn
);
vec3 eye_frag_normal = normalize(
    normalmatrix * frag_normal;
);

Теперь осталось просто заменить каждый экземпляр normal.data и eye_face_normal данными frag_normal и eye_frag_normal, в результате чего получиться неплохая текстура выпуклостей:

Текстуры для эффекта блеска

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

Чтобы придать более реалистичный вид текстуре вкраплений, нужно использовать текстуры типа Specular. Для этого создадим маски для текстур:

Данная текстура будет использоваться для маски Specular, чтобы преобразования яркости цвета:

float frag_specular = texture(specularmap, data.texcoord).r; 
vec3 torch_color = vec3(1.0, 0.83, 0.42);
float intensity = 2.0/pow(data.depth, 2);
float lambert = abs(min(0, dot(eye_frag_normal, eye_normal)));
float specular = pow(lambert, 1+frag_specular*8);
vec3 torch = specular * intensity * torch_color; 
float highlight = pow(specular, 4) * intensity * frag_specular;
 
vec3 color = (
    material_color*ambient +
    material_color*torch +
    highlight*torch_color
);

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

Захват лучей для лавы

Я поместил в центр скалы отдельные участки лавы. Однако, проблема в том, что лава обычно излучает свет, что пока не наблюдается в сцене:

Решение проблемы аналогично методике AmbientOcclusion. Идея в том, чтобы захватить все лучи, которые попадают на поверхность лавы:

Каждая ячейка тестируется тем же методм, что и AmbientOcclusion, и подсчитывается количество лучей(см. функцию в ext/ext.c).

При этом осуществляется проход луча по объемному массиву, содержащему теперь, конечно информацию о положении ячеек с лавой. Вершинныйшейдер передает переменную light, содержащую полученные данные в пиксельные шейдер:

data.light = light;

Пиксельный шейдер умножает цвет материала на цвет источника света:

vec3 color = (
    material_color * ambient +
    material_color * torch +
    highlight * torch_color +
    material_color * data.light
);

Таким образом, в результате получается следующее:

Атмосферные эффекты

Каждый луч света, отражаясь от поверхности скалы, должен пройти через слой атмосферы, прежде чем попадет к наблюдателю. Атмосфера блокирует некоторое количество света, при этом добавляя свой цвет. Пока это не видно:

Простой способ образования эффекта тумана представлен в этой статье.

Для расчета необходимо взять  где eэто константа Ейлера, dэто глубина и Dэто плотность тумана. Затем параметр fбудет использоваться для смешивания цветов объекта и атмосферы.

vec3 fog(vec3 color, vec3 fcolor, float depth, float density){
    const float e = 2.71828182845904523536028747135266249;
    float f = pow(e, -pow(depth*density, 2));
    return mix(fcolor, color, f);
}       
 
// and in the fragment shader do
vec3 at_observer = fog(color, vec3(0.8), data.depth, 0.005);
fragment = gamma(at_observer);

В результате получается следующее:

Заключение

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

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

Об авторе: Florian Boesch. I like writing software, toying with 3d things and reading science fiction. Living in Basel, Switzerland

Заполнен: OpenGL4Туториалы
Присвоен тэг:

avatar

Об Авторе ()

Планета назначения, JO - 8703 - IV, обозначается светлой точкой на объемном экране корабля. К тому времени, как Робоид нанялся на юпитерианский круговой рейс, ему следовало бы стать главным инженером, но после Суховодного пионерского его вышибли, внеся в черный список, и высадили в Луна-Сити за то, что вместо слежения за приборами он провел время, программируя синтезатор пищи корабля строжайше запрещенной корабельными инструкциями последовательностью синтеза пива.

Комментарии ()

Обратная URL | RSS фид комментариев

  1. avatar madeinsoviets:

    Довольно сложная статья — рекомендую читать с блокнотом и карандашом!

  2. avatar Dmitry:

    Никогда не думал что такие ландшафты можно генерировать кодом!

  3. avatar madeinsoviets:

    Мы еще со временем переведем статью о тесселяции — вот там вообще диво дивное..
    http://codeflow.org/entries/2010/nov/07/opengl-4-tessellation/

Наверх