Основы 3D с Kha

от
GameDev    3d, haxe, kha, glsl

Введение
В данной статье мы рассмотрим 3d основы на примере api абстракции над OpenGL, WebGL и DirectX, реализованной в фреймворке Kha. Весь код будет представлен на языке Haxe, но вы не потеряетесь в нем, если знакомы с java/js-подобным синтаксисом.

Итак, из чего состоит 3d модель? В первую очередь это меш (т.е. полигональная сетка, от англ. polygon mesh), что означает набор вершин (vertices), ребер (edges) и граней (faces).

mesh.png

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

По идее это должны быть массивы вершин, ребер и граней? На самом деле нет, достаточно иметь массив вершин и массив индексов вершин, которые будут соединены в треугольники. Почему только треугольники? Дело в том, что аппаратный рендеринг оптимизирован только для отрисовки треугольников, так что даже если мы хотим отрисовать квадрат, придется разбить его на два треугольника. Вот пример наших массивов, которых формально достаточно, чтобы отрисовать простейший плоский треугольник в 3d пространстве:

  1. var vertices:Array<Float> = [ //вершины (3 шт)
  2.     //x, y, z cords
  3.     -1.0, -1.0, 0.0, //низ слева
  4.     1.0, -1.0, 0.0, //низ справа
  5.     0.0, 1.0, 0.0 //верх
  6. ];
  7. var indices:Array<Int> = [ //набор индексов вершин для треугольников (1 шт)
  8.     0, 1, 2
  9. ];

Кстати, подавляющее большинство стандартов представляют x и z как горизонтальную плоскость, а y – как вертикальную. Возможно, вам придется привыкнуть, что z имеет значение "вглубь", а не "вверх".

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

  - Задать структуру вершин (кроме позиции каждой вершины, в массиве vertices также можно передавать любые дополнительные данные, например цвет каждой вершины)
  - Передать наши массивы в соответствующие им vertex/index буферы (так буферы смогут использоваться в GPU (графическом процессоре устройства))
  - Задать параметры рендеринга, а именно – структуру вершин и файлы шейдеров.
  - Создать шейдеры, где задать как минимум цвет нашего треугольника
  - Наконец вызвать метод отрисовки!

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



О шейдерах
Шейдер (shader) – кусок программного кода, выполняемого на GPU, и имеющий вводные и выходные данные. Для языка шейдеров в Kha используется синтаксис GLSL, стандарт OpenGL. Для поддержки определенных платформ Kha использует внешнюю утилиту, при необходимости выполняющую кросскомпиляцию GLSL шейдеров в Direct3D / Metal / Vulkan / etc, так что нам не придется переписывать шейдеры на другой язык, чтобы собрать приложение, например, для Windows или консолей.

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

Однако, мы обойдем стороной майнинг, и рассмотрим, как используются шейдеры в рендеринге 3d графики. В этой сфере есть два типа шейдеров – вершинный и фрагментный (или пиксельный). Их смысл крайне прост – код первого выполняется для каждой вершины нашей меши, код второго – для каждого фрагмента (пикселя). Рассмотрим код, который мы будем использовать для рендеринга нашего треугольника:

Шейдер вершин (vertex shader):

  1. //переданная нами информация о вершине
  2. //(отличается каждое выполнение кода, т.е. для каждой вершины)
  3. in vec3 pos;
  4.  
  5. //основная точка входа
  6. //можно объявлять дополнительные переменные/функции
  7. void main() {
  8.     //просто задаем позицию вершины
  9.     gl_Position = vec4(pos, 1.0);
  10.     //pos является типом vec3, мы преобразуем его в vec4, добавляя четвертую координату 1.0
  11. }

Если интересно, необходимость типа vec4 (X, Y, Z, W) у gl_Position заключается в необходимости возможности трансформаций, подробнее:
https://ru.wikipedia.org/wiki/Однородная_система_координат

Шейдер фрагментов (fragment shader), также известный как pixel shader:

  1. //выходное значение для каждого пикселя
  2. out vec4 fragColor;
  3.  
  4. void main() {
  5.     //просто возвращаем красный цвет (RGBA)
  6.     fragColor = vec4(1.0, 0.0, 0.0, 1.0);
  7. }

Вот и весь код шейдеров. Мы просто используем переданные координаты вершин и заливаем треугольники красным цветом. Сначала выполняется вершинный шейдер, а затем фрагментный, так что мы можем передавать данные из первого во второй (задать дополнительную out переменную в первом и in во втором). Также мы можем передавать константы из обычного (CPU) кода, тогда переменная будет объявлена типа uniform и станет доступна в обоих шейдерах.

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

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



Практика

Установка Kha
Для Kha нам потребуется установленные git и Node.js. После установки, откройте терминал и выполните данную команду:

  1. git clone --recursive https://github.com/Kha-Samples/Empty.git

Это все, что необходимо. Если же вас интересует использование IDE, то можете установить Kode Studio, которая является форком VSCode и идет полностью подготовленной для работы с Kha. Также можно просто поставить расширение для VSCode.

После того как вы склонировали репозиторий, или создали новый kha-проект в VSCode, перейдите в папку /Sources и откройте файл Empty.hx. Добавим в данный класс функцию рендеринга, которая просто очищает экран. Код будет выглядеть так:

  1. package;
  2.  
  3. import kha.Framebuffer;
  4. import kha.Color;
  5.  
  6. class Empty {
  7.     public function new() {}
  8.  
  9.     public function render(frame:Framebuffer) {
  10.         //графический объект, позволяющий нам выполнить 3d операции
  11.         var g = frame.g4;
  12.  
  13.         //начало отрисовки
  14.         g.begin();
  15.  
  16.         //заливаем экран черным цветом
  17.         g.clear(Color.Black);
  18.  
  19.         //конец отрисовки
  20.         g.end();
  21.     }
  22. }

Для запуска в IDE достаточно нажать кнопку Запуск (F5).
Для сборки в терминале перейдите в папку проекта и выполните:

  1. node Kha/make html5
  2. node Kha/make --server

И откройте http://localhost:8080 в браузере. Также можно задать любой другой порт через флаг --port 1234 на второй строке.
Если проблем не возникло, вы увидите окно с черной областью!

Также можете насладиться данной областью в онлайн-IDE:
http://kodegarden.org/#478ad1c23118d8db84e0d08d1403cc99c5072fe5


Треугольник
Итак, настает самый захватывающий момент в вашей жизни – отрисовка треугольника!
Однако, сначала нужно указать, что теперь мы будем использовать файлы шейдеров в коде проекта. Создадим в папке Sources папку Shaders и сохраним в ней код шейдеров, описанных выше, в файлы simple.vert.glsl и simple.frag.glsl соответственно. После этого, перейдем в основную директорию самого проекта, откроем khafile.js и добавим туда строчку project.addShaders('Sources/Shaders/**'); перед строкой resolve(project);. Теперь при компиляции файлы шейдеров будут подхватываться, и станут доступны из кода проекта.

Продолжим править наш Empty.hx. Для начала, возьмем наши массивы данных и создадим переменные буферов и параметры рендеринга (PipelineState).

  1. import kha.Framebuffer;
  2. import kha.Color;
  3. //импортируем все необходимое
  4. import kha.Shaders;
  5. import kha.graphics4.PipelineState;
  6. import kha.graphics4.VertexStructure;
  7. import kha.graphics4.VertexBuffer;
  8. import kha.graphics4.IndexBuffer;
  9. import kha.graphics4.FragmentShader;
  10. import kha.graphics4.VertexShader;
  11. import kha.graphics4.VertexData;
  12. import kha.graphics4.Usage;
  13.  
  14. class Empty {
  15.  
  16.     //сделаем поля массивов статичными
  17.     static var vertices:Array<Float> = [
  18.         -1.0, -1.0, 0.0, //низ слева
  19.         1.0, -1.0, 0.0, //низ справа
  20.         0.0, 1.0, 0.0 //верх
  21.     ];
  22.     static var indices:Array<Int> = [
  23.         0, 1, 2
  24.     ];
  25.  
  26.     var vertexBuffer:VertexBuffer;
  27.     var indexBuffer:IndexBuffer;
  28.     var pipeline:PipelineState;

Теперь изменим код внутри конструктора (в функции new). Сначала зададим в pipeline структуру и шейдеры:

  1. //объявляем структуру вершин
  2. var structure = new VertexStructure();
  3. structure.add("pos", VertexData.Float3);
  4. //сохраняем длину данных каждой вершины - сейчас мы храним там только позицию
  5. //когда-нибудь в каждой вершине будут координаты текстур, нормали и тд
  6. var structureLength = 3;
  7.  
  8. //компилируем pipeline state, т.е. шейдеры
  9. pipeline = new PipelineState();
  10. pipeline.inputLayout = [structure];
  11. //шейдеры находятся в папке 'Sources/Shaders'
  12. //и Kha включает их автоматически
  13. pipeline.fragmentShader = Shaders.simple_frag;
  14. pipeline.vertexShader = Shaders.simple_vert;
  15. pipeline.compile();

Теперь создаем буферы вершин и индексов из наших массивов данных.

  1. //создаем vertex buffer
  2. vertexBuffer = new VertexBuffer(
  3.     //число вершин, делим длину массива на длину каждой вершины
  4.     Std.int(vertices.length / structureLength),
  5.     structure, //структура вершины
  6.     Usage.StaticUsage //данные не будут меняться динамически
  7. );
  8.  
  9. //копируем вершины из нашего массива в буфер
  10. var vbData = vertexBuffer.lock();
  11. for (i in 0...vbData.length) {
  12.     vbData.set(i, vertices[i]);
  13. }
  14. vertexBuffer.unlock();
  15.  
  16. //создаем index buffer
  17. indexBuffer = new IndexBuffer(
  18.     indices.length, //3 индекса на треугольник
  19.     Usage.StaticUsage //данные не будут меняться динамически
  20. );
  21.  
  22. //копируем индексы из нашего массива в буфер
  23. var iData = indexBuffer.lock();
  24. for (i in 0...iData.length) {
  25.     iData[i] = indices[i];
  26. }
  27. indexBuffer.unlock();

С конструктором и приготовлениями закончено! Изменим код внутри функции render:

  1. //графический объект, позволяющий нам выполнить 3d операции
  2. var g = frame.g4;
  3.  
  4. //начало отрисовки
  5. g.begin();
  6.  
  7. //заливаем экран черным цветом
  8. g.clear(Color.Black);
  9.  
  10. //задаем pipeline state с которым мы хотим рисовать
  11. g.setPipeline(pipeline);
  12.  
  13. //задаем буферы, которые мы хотим отрисовать
  14. g.setVertexBuffer(vertexBuffer);
  15. g.setIndexBuffer(indexBuffer);
  16.  
  17. //отрисовка!
  18. g.drawIndexedVertices();
  19.  
  20. //конец отрисовки
  21. g.end();

Как он прекрасен!

triangle.png

Демонстрация в онлайн-IDE:
http://kodegarden.org/#e477e6b27943e1a6294f997456f20ca27ff67499

Как можно заметить, такая простейшая операция заняла значительное количество кода. Но на самом деле, это все является необходимыми приготовлениями, и отличия в размере кода от загрузки более полноценной сцены с текстурами и навигацией по ней будут довольно незначительными. Как пример, данное демо (управление на стрелки/мышь):
http://kodegarden.org/#6c140796749f51a59553daf98fe895e952f41f6e

Я постараюсь разобрать работу с пользовательским вводом, трансформации, карты нормалей и что-нибудь еще в следующий раз, спасибо за внимание.



Полезные материалы
Сборник 3d уроков на Kha, повторяющих OpenGL Tutorials
(Данная статья частично является рекапом двух первых уроков оттуда. Отличный источник всего самого необходимого, если хочется начинать уже сейчас)
[видео] GPU-программирование с Kha и ее автором
  • +7
  • views 3963