[Из песочницы] Введение в использование OpenGL 4.0 в .NET
Введение
На хабрахабре (и на русском языке вообще) мало информации по использованию современного OpenGL (тем более, на достаточно популярной сегодня платформе .NET) так что для восполнения данного пробела я решил попробовать написать эту статью.
Данная статья является введением в использование OpenGL 4.0 на языке C# с помощью OpenTK, однако весь приведённый здесь код, прямо относящийся к OpenGL может быть легко перенесён на любой другой язык.
OpenTK — фреймворк для .NET, который включает в себя врапперы над OpenGL и OpenAL, вспомогательные математические функции, функции для кроссплатформенного создания окна и работы с ним.
Создание и настройка проекта
Чтобы начать работать с OpenGL в C# с OpenTK, необходимо:
- Создать новый пустой проект приложения на C#;
- Подключить к нему сборки System и System.Drawing;
- Через NuGet или вручную с сайта www.opentk.com скачать и подключить библиотеку OpenTK.
Создание окна OpenTK
В OpenTK окно приложения представлено классом GameWindow. Можно создать экземпляр этого класса и подписаться на его события, либо создать наследующий его класс и переопределить в нём соответствующие методы. Я буду использовать второй способ.
В конструктор этого класса передаются свойства окна и графического контекста, требуемая версия OpenGL.
Создание окна приложения производится вызовом метода Run объекта данного класса. Возврат из этого метода произойдёт после закрытия окна.
Минимальное работоспособное приложение, создающее окно OpenTK, имеет следующий вид:
using OpenTK;
using OpenTK.Graphics;
namespace OpenGLTutorial
{
public sealed class Window : GameWindow
{
// Размер окна 800x600, заголовок "OpenGL Tutorial"
// Версия OpenGL 4.0
// Без поддержки deprecated-функциональности
// Остальное оставим по умолчанию
public Window() : base(800, 600, GraphicsMode.Default, "OpenGL Tutorial", GameWindowFlags.Default, DisplayDevice.Default, 4, 0, GraphicsContextFlags.ForwardCompatible) { }
public static void Main()
{
// Создаём и запускаем окно
using (var window = new Window())
window.Run(60);
}
}
}
Если сейчас скомпилировать и запустить проект, то появится окно с заданными размерами и заголовком. Содержимым окна может быть то, что отображалось на экране в момент его создания или просто какой-нибудь графический мусор.
Чтобы это исправить, требуется выполнить ещё несколько действий:
- Назначить цвет очистки окна;
- Задать область окна, в которой будет происходить отрисовка;
- В методе отрисовки очищать окно и переключать буферы.
Добавим следующий код в класс окна:
// Вызывается при первоначальной загрузке
protected override void OnLoad(EventArgs e)
{
GL.ClearColor(Color4.Black); // Зададим цвет очистки окна
}
// Вызывается при изменение размеров окна
protected override void OnResize(EventArgs e)
{
GL.Viewport(0, 0, Width, Height); // Зададим область перерисовки размером со всё окно
}
// Вызывается при отрисовке очередного кадра
protected override void OnRenderFrame(FrameEventArgs e)
{
GL.Clear(ClearBufferMask.ColorBufferBit); // Очищаем буфер цвета
// Тут будет распологаться основной код отрисовки
SwapBuffers(); // Переключаем задний и передний буферы
}
Сейчас окно приложения должно принять такой вид:
Вывод полигона на экран
Среди функций API OpenGL можно выделить:
- создающие различные объекты OpenGL (текстуры, шейдеры, буферы...) и возвращающие их идентификатор;
- удаляющие объекты по их идентификатору и освобождающие занятые ими ресурсы;
- изменяющие параметры объектов по идентификатору;
- делающие некоторый объект текущим и изменяющие параметры текущего объекта некоторого вида.
Сами объекты OpenGL находятся в неуправляемой памяти, а потому они являются неуправляемыми ресурсами, так что хорошей идеей будет создать для них классы, реализующие интерфейс IDisposable.
Чтобы отобразить на экране что-нибудь, необходимы следующие объекты OpenGL:
- Vertex Buffer Object (VBO) – хранит данные о вершинах;
- Vertex Array Object (VAO) – хранит данные о формате вершин, к нему присоединяет один или несколько VBO;
- Shader – представляет собой подпрограмму на языке GLSL, выполняемую на GPU;
- Shader Program – объединяет несколько шейдеров в шейдерную программу.
Vertex Buffer Object
Для работы с VBO существуют методы:
int GL.GenBuffer() // Создаёт новый VBO и возвращает его идентификатор.
void GL.DeleteBuffer(int handle) // Удаляет VBO по идентификатору.
void GL.BindBuffer(BufferTarget bufferTarget, int handle) // Делает указанный VBO текущим.
// Это означает, что все идущие далее функции, предназначенные для работы с VBO и не принимающие явно идентификатор – будут относиться к данному VBO.
// Параметр bufferTarget задаёт тип VBO, сейчас нам будет нужен только BufferTarget.ArrayBuffer.
void GL.BufferData<T>(BufferTarget bufferTarget, IntPtr length, T[] data, BufferUsageHint hint) // Заполняет данными текущий VBO указанного типа.
// Параметр length – длина данных в байтах;
// hint – подсказывает OpenGL, каким образом расположить данные в памяти.
// Массив data не должен быть пуст, иначе произойдёт ошибка.
Класс VBO может быть описан следующим образом:
public sealed class VBO : IDisposable
{
private const int InvalidHandle = -1;
public int Handle { get; private set; } // Идентификатор VBO
public BufferTarget Type { get; private set; } // Тип VBO
public VBO(BufferTarget type = BufferTarget.ArrayBuffer)
{
Type = type;
AcquireHandle();
}
// Создаёт новый VBO и сохраняет его идентификатор в свойство Handle
private void AcquireHandle()
{
Handle = GL.GenBuffer();
}
// Делает данный VBO текущим
public void Use()
{
GL.BindBuffer(Type, Handle);
}
// Заполняет VBO массивом data
public void SetData<T>(T[] data) where T : struct
{
if (data.Length == 0)
throw new ArgumentException("Массив должен содержать хотя бы один элемент", "data");
Use();
GL.BufferData(Type, (IntPtr)(data.Length * Marshal.SizeOf(typeof(T))), data, BufferUsageHint.StaticDraw);
}
// Освобождает занятые данным VBO ресурсы
private void ReleaseHandle()
{
if (Handle == InvalidHandle)
return;
GL.DeleteBuffer(Handle);
Handle = InvalidHandle;
}
public void Dispose()
{
ReleaseHandle();
GC.SuppressFinalize(this);
}
~VBO()
{
// При вызове финализатора контекст OpenGL может уже не существовать и попытка выполнить GL.DeleteBuffer приведёт к ошибке
if (GraphicsContext.CurrentContext != null && !GraphicsContext.CurrentContext.IsDisposed)
ReleaseHandle();
}
}
Чтобы отобразить на экране разноцветный полигон, как на первой картинке, необходимо для каждой его вершины записать в VBO её позицию и цвет.
Добавим в класс окна поле meshVbo типа VBO и инициализируем его в OnLoad описанием позиции и цвета трёх вершин:
meshVbo = new VBO();
meshVbo.SetData(new[] {
0.0f, 0.5f, 1.0f, 0.0f, 0.0f,
-0.5f, -0.5f, 0.0f, 1.0f, 0.0f,
0.5f, -0.5f, 0.0f, 0.0f, 1.0f
});
Также можно создать для описания вершины структуру вида:
[StructLayout(LayoutKind.Sequential)]
public struct Vertex
{
public float X, Y;
public float R, G, B;
public Vertex(float x, float y, Color4 color)
{
X = x; Y = y;
R = color.R; G = color.G; B = color.B;
}
}
И изменить код заполнения VBO на более наглядный:
meshVbo.SetData(new[] {
new Vertex( 0.0f, 0.5f, Color4.Red),
new Vertex(-0.5f, -0.5f, Color4.Green),
new Vertex( 0.5f, -0.5f, Color4.Blue)
});
Стоит переопределить метод OnClosing окна и добавить в него вызов метода meshVbo.Dispose().Vertex Array Object
В VAO сохраняется описание формата вершин.
Каждая вершина характеризуется одним или несколькими атрибутами, такими как положение в пространстве, нормаль, текстурные координаты, цвет и т.п. Каждый атрибут описывается своим номером, числом и типом компонентов, а также тем, из какого VBO соответствующие ему данные берутся и как они там расположены.
В соответствии с тем, как мы заполнили VBO, будем использовать следующий формат вершин:
Для работы с VAO используются методы:
int GL.GenVertexArray() // Создаёт VAO.
void GL.DeleteVertexArray(int handle) // Удаляет VAO.
void GL.BindVertexArray(int handle) // Делает VAO текущим.
void GL.DrawArrays(PrimitiveType type, int start, int count) // Производит отрисовку геометрии из текущего VAO.
// Параметр type задаёт, какими примитивами производится отрисовка, как правило, это PrimitiveType.Triangles.
// Параметры start и count задают диапазон отображаемых вершин – это позволяет хранить несколько мешей в одном VAO.
К VAO могут быть присоединены один или несколько VBO, каждый из которых может хранить один или несколько вершинных атрибутов. Для этого существуют методы:
void GL.EnableVertexAttribArray(int index) // Включает использование вершинного атрибута под номером index.
void GL.VertexAttribPointer(int index, int elementsPerVertex, VertexAttribPointerType pointerType, bool normalized, int stride, int offset) // Прикрепляет текущий VBO к текущему VAO и задаёт параметры вершинного атрибута index.
// Параметр elementsPerVertex задаёт, какое количество элементов данного атрибута приходится на одну вершину;
// pointerType – тип элементов данного атрибута, обычно это VertexAttribPointerType.Float;
// stride – размер описания одной вершины в данном VBO в байтах, а offset – смещение данного атрибута в описание вершины в данном VBO в байтах.
Класс VAO может быть описан аналогично VBO:
public sealed class VAO : IDisposable
{
private const int InvalidHandle = -1;
public int Handle { get; private set; }
public int VertexCount { get; private set; } // Число вершин для отрисовки
public VAO(int vertexCount)
{
VertexCount = vertexCount;
AcquireHandle();
}
private void AcquireHandle()
{
Handle = GL.GenVertexArray();
}
public void Use()
{
GL.BindVertexArray(Handle);
}
public void AttachVBO(int index, VBO vbo, int elementsPerVertex, VertexAttribPointerType pointerType, int stride, int offset)
{
Use();
vbo.Use();
GL.EnableVertexAttribArray(index);
GL.VertexAttribPointer(index, elementsPerVertex, pointerType, false, stride, offset);
}
public void Draw()
{
Use();
GL.DrawArrays(PrimitiveType.Triangles, 0, VertexCount);
}
private void ReleaseHandle()
{
if (Handle == InvalidHandle)
return;
GL.DeleteVertexArray(Handle);
Handle = InvalidHandle;
}
public void Dispose()
{
ReleaseHandle();
GC.SuppressFinalize(this);
}
~VAO()
{
if (GraphicsContext.CurrentContext != null && !GraphicsContext.CurrentContext.IsDisposed)
ReleaseHandle();
}
}
Добавим в класс окна поле meshVao типа VAO и в OnLoad инициализируем его следующим образом:
meshVao = new VAO(3); // 3 вершины
meshVao.AttachVBO(0, meshVbo, 2, VertexAttribPointerType.Float, 5 * sizeof(float), 0); // Нулевой атрибут вершины – позиция, у неё 2 компонента типа float
meshVao.AttachVBO(1, meshVbo, 3, VertexAttribPointerType.Float, 5 * sizeof(float), 2 * sizeof(float)); // Первый атрибут вершины – цвет, у него 3 компонента типа float
В OnRenderFrame до SwapBuffers() необходимо добавить вызов meshVao.Draw().
В OnClosing стоит добавить вызов meshVao.Dispose().Shader Program
В современном OpenGL некоторые части процесса отрисовки должны быть запрограммированы с помощью шейдеров.
Шейдер — специальная подпрограмма, выполняемая на GPU. Шейдеры для OpenGL пишутся на специализированном C-подобном языке — GLSL. Они компилируются самим OpenGL перед использованием.
Шейдерная программа объединяет набор шейдеров. В простейшем случае шейдерная программа состоит из двух шейдеров: вершинного и фрагментного.
Вершинный шейдер вызывается для каждой вершины. На его вход поступают данные из VAO/VBO. Его выходные данные интерполируются и поступают на вход фрагментного шейдера. Обычно, работа вершинного шейдера состоит в том, чтобы перевести координаты вершин из пространства сцены в пространство экрана и выполнить вспомогательные расчёты для фрагментного шейдера.
Фрагментный шейдер вызывается для каждого графического фрагмента (грубо говоря, пикселя растеризованной геометрии, попадающего на экран). Выходом фрагментного шейдера, как правило, является цвет фрагмента, идущий в буфер цвета. На фрагментный шейдер обычно ложится основная часть расчёта освещения.
Для работы с шейдерными программами есть методы:
int GL.CreateProgram() // Создаёт объект шейдерной программы.
void GL.UseProgram(int handle) // Использует данную шейдерную программу для отрисовки.
void GL.DeleteProgram(int handle) // Удаляет шейдерную программу.
С самими шейдерами используются методы:
int GL.CreateShader(ShaderType type) // Создаёт шейдер указанного типа, типом может быть, к примеру, ShaderType.VertexShader или ShaderType.FragmentShader.
void GL.DeleteShader(int handle) // Удаляет шейдер.
void GL.ShaderSource(int handle, string source) // Назначает исходный код шейдеру.
void GL.CompileShader(int handle) // Компилирует шейдер.
void GL.GetShader(int handle, ShaderParameter.CompileStatus, out compileStatus) // Позволяет получить в переменную compileStatus статус компиляции шейдера. Если он равен нулю, значит что-то не так.
string GL.GetShaderInfoLog(int handle) // Возвращает строку, содержащую конкретную информацию об ошибке компиляции.
Шейдеры присоединяются к шейдерной программе, после чего она должна быть слинкована, для этого существуют методы:
void GL.AttachShader(int handle, int shaderHandle) // Прикрепляет шейдер shaderHandle к шейдерной программе handle.
void GL.LinkProgram(int handle) // Линкует шейдерную программу.
void GL.GetProgram(int handle, GetProgramParameterName.LinkStatus, out int linkStatus) // По аналогии с GL.GetShader позволяет получить в переменную linkStatus статус линковки шейдерной программы.
string GL.GetProgramInfoLog(int handle) // Возвращает информацию об ошибке.
Сами объекты шейдеров после линковки шейдерной программы могут быть удалены. Класс шейдера:
public sealed class Shader : IDisposable
{
private const int InvalidHandle = -1;
public int Handle { get; private set; }
public ShaderType Type { get; private set; }
public Shader(ShaderType type)
{
Type = type;
AcquireHandle();
}
private void AcquireHandle()
{
Handle = GL.CreateShader(Type);
}
public void Compile(string source)
{
GL.ShaderSource(Handle, source);
GL.CompileShader(Handle);
int compileStatus;
GL.GetShader(Handle, ShaderParameter.CompileStatus, out compileStatus);
// Если произошла ошибка, выведем сообщение
if (compileStatus == 0)
Console.WriteLine(GL.GetShaderInfoLog(Handle));
}
private void ReleaseHandle()
{
if (Handle == InvalidHandle)
return;
GL.DeleteShader(Handle);
Handle = InvalidHandle;
}
public void Dispose()
{
ReleaseHandle();
GC.SuppressFinalize(this);
}
~Shader()
{
if (GraphicsContext.CurrentContext != null && !GraphicsContext.CurrentContext.IsDisposed)
ReleaseHandle();
}
}
Класс шейдерной программы:
public sealed class ShaderProgram : IDisposable
{
private const int InvalidHandle = -1;
public int Handle { get; private set; }
public ShaderProgram()
{
AcquireHandle();
}
private void AcquireHandle()
{
Handle = GL.CreateProgram();
}
public void AttachShader(Shader shader)
{
GL.AttachShader(Handle, shader.Handle);
}
public void Link()
{
GL.LinkProgram(Handle);
int linkStatus;
GL.GetProgram(Handle, GetProgramParameterName.LinkStatus, out linkStatus);
if (linkStatus == 0)
Console.WriteLine(GL.GetProgramInfoLog(Handle));
}
public void Use()
{
GL.UseProgram(Handle);
}
private void ReleaseHandle()
{
if (Handle == InvalidHandle)
return;
GL.DeleteProgram(Handle);
Handle = InvalidHandle;
}
public void Dispose()
{
ReleaseHandle();
GC.SuppressFinalize(this);
}
~ShaderProgram()
{
if (GraphicsContext.CurrentContext != null && !GraphicsContext.CurrentContext.IsDisposed)
ReleaseHandle();
}
}
Добавим в класс окна поле shaderProgram типа ShaderProgram, и в OnLoad код загрузки простейшего шейдера:
shaderProgram = new ShaderProgram();
using (var vertexShader = new Shader(ShaderType.VertexShader))
using (var fragmentShader = new Shader(ShaderType.FragmentShader))
{
vertexShader.Compile(@"
#version 400 // Версия GLSL
layout(location = 0) in vec2 Position; // Нулевой входной вершинный атрибут из VBO/VAO
layout(location = 1) in vec3 Color; // Первый входной вершинный атрибут из VBO/VAO
out vec3 fragColor; // Выход вершинного шейдера, передаваемый в фрагментный шейдер
void main()
{
gl_Position = vec4(Position, 0.0, 1.0); // Позицию вершины дополняем до четырёхмерного вектора нулём по глубине и единицей в четвёртой компоненте, и записываем в специальную встроенную переменную gl_Position
fragColor = Color; // Цвет передаём в фрагментный шейдер как есть. Указанные для вершин чистые цвета будут интерполированы по всему полигону, образуя плавные переходы
}
");
fragmentShader.Compile(@"
#version 400
in vec3 fragColor; // Вход из вершинного шейдера
layout(location = 0) out vec4 outColor; // Нулевой выход фрагментного шейдера – в буфер цвета
void main()
{
outColor = vec4(fragColor, 1.0); // Дополняем цвет до четырёхмерного вектора единицей. Тут четвёртый компонент – прозрачность
}
");
shaderProgram.AttachShader(vertexShader);
shaderProgram.AttachShader(fragmentShader);
shaderProgram.Link();
}
И в OnRenderFrame перед meshVao.Draw() добавим вызов shaderProgram.Use(). А в OnClosing вызовем shaderProgram.Dispose().
Заключение
Если всё сделано правильно, при запуске приложения получится следующее изображение:
Полный код итогового проекта можно взять на GitHub: github.com/NyanOmich/OpenGL-Tutorial/tree/master/Project1.
При написание статьи использовались материалы:
Если данная тема окажется кому-нибудь интересна, я постараюсь написать также про матрицы, uniform-переменные, загрузку моделей и текстур, модели освещения.