Наблюдая, как люди всё ещё пишут мне, спрашивая об исходном коде для статьи, которую я недавно написал на GameDev.net и, понимая, что второй вариант этой статьи (с исходными кодами для каждого API) далёк от того, чтобы быть законченным даже наполовину, я сварганил этот урок для NeHe (который фактически должен бы был лечь в основу статьи), что бы вы все, гуру OpenGL, могли поэкспериментировать с ним. Извините за выбор модели, но недавно я много играл в Quake II…
Примечание: оригинал статьи для этого кода можно найти на:
http://www.gamedev.net/reference/programming/features/celshading
В этом уроке на самом деле нет объяснения теории, только код. Ответ на вопрос, ПОЧЕМУ это работает, можно найти по ссылке, указанной выше. Теперь, чёрт возьми, ПРЕКРАТИТЕ ПИСАТЬ И ПРОСИТЬ ИСХОДНЫЙ КОД!!!!
Наслаждайтесь!
Прежде всего, нам необходимо подключить несколько дополнительных заголовочных файлов. Первый из них (math.h) для использования функции sqrtf (квадратный корень), а второй (stdio.h) для обеспечения доступа к файлам.
#include <math.h> // Заголовочный файл математической библиотеки
#include <stdio.h> // Заголовочный файл для стандартной библиотеки ввода\вывода
Теперь мы собираемся определить несколько структур для хранения данных (для сохранения сотен массивов вещественных чисел). Первая – структура tagMATRIX. Если вы внимательно посмотрите, то обнаружите, что мы сохраняем матрицу как одномерный массив из 16 вещественных чисел, а не двумерный размерностью 4x4. Это соответствует тому, как в OpenGL хранятся матрицы. Если бы мы использовали массив 4x4, то значения располагались бы в неправильном порядке.
typedef struct tagMATRIX // Структура для хранения матрицы в формате OpenGL
{
float Data[16]; // Мы используем размерность 16 из-за формата матрицы OpenGL
} MATRIX;
Вторая структура – векторный класс. Она просто хранит значения для X, Y и Z.
typedef struct tagVECTOR // Структура для хранения вектора
{
float X, Y, Z; // Компоненты вектора
} VECTOR;
Третья – структура вершины. Для каждой вершины необходима только её нормаль и позиция (без координат текстуры). Они ДОЛЖНЫ храниться только в этом порядке, иначе процесс загрузки данных из файла пройдет не корректно (я затратил много сил, чтобы понять это, возможно, этот опыт научит меня разбивать код на части).
typedef struct tagVERTEX // Структура для хранения одной вершины
{
VECTOR Nor; // Нормаль вершины
VECTOR Pos; // Позиция вершины
} VERTEX;
Наконец, структура многоугольника. Я знаю, что так глупо хранить вершины, но это просто и работает. Вообще, я использовал бы массив вершин, массив многоугольников и помещал бы номера индексов трех вершин в структуру многоугольника, но так проще показать вам, что происходит.
typedef struct tagPOLYGON // Структура для хранения многоугольника
{
VERTEX Verts[3]; // Массив из 3-х структур VERTEX
} POLYGON;
Далее тоже довольно простой материал. Смотрите комментарии, объясняющие назначение каждой переменной.
bool outlineDraw = true; // Флаг рисования контура
bool outlineSmooth = false; // Флаг сглаживания линий
float outlineColor[3] = { 0.0f, 0.0f, 0.0f }; // Цвет линий
float outlineWidth = 3.0f; // Ширина линий
VECTOR lightAngle; // Направление света
bool lightRotate = false; // Флаг поворота источника света
float modelAngle = 0.0f; // Угол наклона модели по оси Y
bool modelRotate = false; // Флаг поворота модели
POLYGON *polyData = NULL; // Информация о многоугольнике
int polyNum = 0; // Количество многоугольников
GLuint shaderTexture[1]; // Хранилище для одной текстуры
Далее идет функция чтения модели. Формат хранения модели крайне прост. В первых нескольких байтах хранится число многоугольников в сцене, а остальная часть файла – массив структур tagPOLYGON. Благодаря этому, при считывании данных нет необходимости сортировать их в какой-либо особой последовательности.
BOOL ReadMesh () // Чтение содержимого файла «model.txt»
{
FILE *In = fopen ("Data\\model.txt", "rb"); // Открытие файла
if (!In)
return FALSE; // FALSE, если файл не открыт
fread (&polyNum, sizeof (int), 1, In); // Считывание заголовка (т.е. кол-во многоугольников)
polyData = new POLYGON [polyNum]; // Резервирование памяти
fread (&polyData[0], sizeof(POLYGON)*polyNum, 1, In); // Чтение данных о всех многоугольниках
fclose (In); // Закрытие файла
return TRUE; // Файл отработан
}
Теперь несколько основных математических функций. Функция DotProduct рассчитывает угол между двумя векторами или плоскостями, функция Magnitude рассчитывает длину вектора, а функция Normalize уменьшает вектор до единичной длины.
inline float DotProduct (VECTOR &V1, VECTOR &V2) // Вычисление угла между двумя векторами
{
return V1.X * V2.X + V1.Y * V2.Y + V1.Z * V2.Z; // Возвращает угол
}
inline float Magnitude (VECTOR &V) // Вычисление длины вектора
{
return sqrtf (V.X * V.X + V.Y * V.Y + V.Z * V.Z); // Возвращает длину вектора
}
void Normalize (VECTOR &V) // Создаёт вектор единичной длины
{
float M = Magnitude (V); // Вычисление длины вектора
if (M != 0.0f) // Удостовериться, что нет деления на 0
{
V.X /= M; // Нормализация трёх составляющих
V.Y /= M;
V.Z /= M;
}
}
Эта функция производит вращение вектора, используя предоставляемую ей матрицу. Пожалуйста, обратите внимание, что она ТОЛЬКО вращает вектор – что не имеет никакого отношения к расположению вектора. Это используется при вращении нормалей, когда необходимо удостовериться, что они указывают в правом направлении при расчете освещения.
void RotateVector (MATRIX &M, VECTOR &V, VECTOR &D) // Вращение вектора с использованием матрицы
{
D.X =(M.Data[0] * V.X) + (M.Data[4] * V.Y) + (M.Data[8] * V.Z); // Поворот вокруг оси X
D.Y =(M.Data[1] * V.X) + (M.Data[5] * V.Y) + (M.Data[9] * V.Z); // Поворот вокруг оси Y
D.Z =(M.Data[2] * V.X) + (M.Data[6] * V.Y) + (M.Data[10] * V.Z); // Поворот вокруг оси Z
}
Первая главная функция движка… Initialise, выполняет то, что означает. Я вырезал несколько строк кода, т.к. объяснять их нет необходимости.
//Здесь располагается любой код инициализации графической библиотеки и пользователя
BOOL Initialize (GL_Window* window, Keys* keys)
{
Следующие три переменные используются при загрузки файла с данными о закрашивании (shader-файл). Line используется для чтения строки из текстового файла, в то время как shaderData хранит действительные параметры закрашивания. Вам может быть интересно, почему у нас 96 значений вместо 32. Ну, необходимо конвертировать значения шкалы оттенков серого цвета (greyscale) в RGB, чтобы OpenGL мог их использовать. Можно по-прежнему хранить значения как оттенки серого цвета, но дальше мы будем использовать значения для компонентов RGB при загрузке текстуры.
char Line[255]; // Хранилище для 255 символов
float shaderData[32][3]; // Хранилище для 96 значений тени
FILE *In = NULL; // Указатель на файл
При прорисовке линий, мы хотим удостовериться, что они точные и гладкие. Первоначально этот режим отключён, но, нажимая клавишу «2», можно переключаться между режимами вкл\выкл.
glShadeModel (GL_SMOOTH); // Включение плавного цветового закрашивания
glDisable (GL_LINE_SMOOTH); // Первоначальное отключение сглаживания линий
glEnable (GL_CULL_FACE); // Разрешить удаление задних граней
Мы отключаем освещение OpenGL, потому что мы делаем все вычисления освещения самостоятельно.
glDisable (GL_LIGHTING); // Отключение освещения OpenGL
Здесь мы загружаем файл закрашивания. Это просто 32 числа с плавающей запятой, сохранённые в кодировке ASCII (для простой модификации), каждое на отдельной строке.
In = fopen ("Data\\shader.txt", "r"); // Открытие файла закрашивания
if (In) // Проверка на открытие файла
{
for (i = 0; i < 32; i++) // Цикл по 32 значениям шкалы оттенков серого цвета
{
if (feof (In)) // Проверка на конец файла
break;
fgets (Line, 255, In); // Текущая строка
Здесь мы конвертируем значения серого цвета в RGB, как описано выше.
// Копирование значений
shaderData[i][0] = shaderData[i][1] = shaderData[i][2] = atof (Line);
}
fclose (In); // Закрытие файла
}
else
return FALSE; // Ужасно-ужасно неверно
Теперь мы загружаем текстуру. Как ясно указывается, не используйте никакой фильтрации на текстуре, иначе она будет выглядеть, мягко говоря, странно. Используется GL_TEXTURE_1D, потому что это одномерный массив значений.
glGenTextures (1, &shaderTexture[0]); // Получение свободного ID текстуры
// Связывание с текстурой. В дальнейшем это будет ID текстуры.
glBindTexture (GL_TEXTURE_1D, shaderTexture[0]);
// Чёрт возьми, не позволяйте OpenGL использовать двух\трёхлинейную фильтрацию!
glTexParameteri (GL_TEXTURE_1D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri (GL_TEXTURE_1D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
// Подгрузка
glTexImage1D (GL_TEXTURE_1D, 0, GL_RGB, 32, 0, GL_RGB , GL_FLOAT, shaderData);
Теперь установите направление освещения. Я установил его вдоль оси Z, что значит – свет будет падать на лицевую сторону модели.
lightAngle.X = 0.0f; // Направление вдоль оси X
lightAngle.Y = 0.0f; // Направление вдоль оси Y
lightAngle.Z = 1.0f; // Направление вдоль оси Z
Normalize (lightAngle); // Нормализация направления света
Загрузите набор граней из файла (описано выше).
return ReadMesh (); // Возвращает значение функции ReadMesh()
}
Функция, обратная описанной выше… Deinitialize, удаляет текстуру и данные многоугольника, созданные функциями Initalize и ReadMesh.
void Deinitialize (void) // Здесь может располагаться любой код деинициализации пользователя
{
glDeleteTextures (1, &shaderTexture[0]); // Удаление текстуры
delete [] polyData; // Удаление данных многоугольника
}
Главный демонстрационный цикл. Все что он делает – это обрабатывает ввод и модифицирует угол. Средство управления следующие:
<SPACE> = Переключатель вращения
1 = Переключатель прорисовки контура
2 = Переключатель сглаживания контура
<UP> = Увеличение ширины линии
<DOWN> = Уменьшение ширины линии
void Update (DWORD milliseconds) // Здесь производится модернизация движения
{
if (g_keys->keyDown [' '] == TRUE) // Нажата клавиша <SPACE>?
{
modelRotate = !modelRotate; // Переключение вращения модели вкл\выкл
g_keys->keyDown [' '] = FALSE;
}
if (g_keys->keyDown ['1'] == TRUE) // Нажата клавиша «1»?
{
outlineDraw = !outlineDraw; // Переключение прорисовки контура вкл\выкл
g_keys->keyDown ['1'] = FALSE;
}
if (g_keys->keyDown ['2'] == TRUE) // Нажата клавиша «2»?
{
outlineSmooth = !outlineSmooth; // Переключение сглаживания контура вкл\выкл
g_keys->keyDown ['2'] = FALSE;
}
if (g_keys->keyDown [VK_UP] == TRUE) // Нажата стрелка «Вверх»?
{
outlineWidth++; // Увеличение ширины линии
g_keys->keyDown [VK_UP] = FALSE;
}
if (g_keys->keyDown [VK_DOWN] == TRUE) // Нажата стрелка «Вниз»?
{
outlineWidth--; // Уменьшение ширины линии
g_keys->keyDown [VK_DOWN] = FALSE;
}
if (modelRotate) // Проверка включен или нет режим вращения
modelAngle += (float) (milliseconds) / 10.0f; // Модификация угла основанного на таймере
}
Функция, которую вы все ждали. Функция Draw делает всё – вычисляет значения закрашивания, отображает набор граней, отображает контуры - и это действительно так.
void Draw (void)
{
TmpShade используется для хранения значений закрашивания для текущей вершины. Вся информация о вершине рассчитывается в одно и то же время, в том смысле, что мы многократно используем одну и ту же переменную.
Структуры TmpMatrix, TmpVector и TmpNormal так же используются для расчета информации о вершине. Значение TmpMatrix устанавливается однократно при запуске функции и не меняется до следующего вызова функции Draw. С другой стороны, TmpVector и TmpNormal изменяются при расчете каждой последующей вершины.
float TmpShade; // Временное значение закрашивания
MATRIX TmpMatrix; // Временная структура MATRIX
VECTOR TmpVector, TmpNormal; // Временная структура VECTOR
Давайте очистим буферы и матричные данные.
glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // Очистка буферов
glLoadIdentity (); // Перезагрузка матрицы
Первая проверка – хотим ли мы иметь гладкий контур. Если так, то включаем сглаживание. Если нет – отключаем. Просто!
if (outlineSmooth) // Проверка – хотим ли гладкий контур
{
glHint (GL_LINE_SMOOTH_HINT, GL_NICEST); //Использовать качественные вычисления
glEnable (GL_LINE_SMOOTH); // Включить сглаживание
}
else // Мы не хотим гладкий контур
glDisable (GL_LINE_SMOOTH); // Отключить сглаживание
Затем мы устанавливаем область просмотра. Отодвигаем камеру на две единицы назад, а затем поворачиваем модель заданный угол. Обратите внимание: модель будет вращаться на месте, т.к. мы в начале переместили камеру. Если бы мы делали наоборот, модель вращалась бы вокруг камеры.
Затем мы берём вновь созданную матрицу у OpenGL и сохраняем её в TmpMatrix.
glTranslatef (0.0f, 0.0f, -2.0f); // Отодвигаемся на две единицы от экрана
glRotatef (modelAngle, 0.0f, 1.0f, 0.0f); // Вращаем модель вдоль оси Y
glGetFloatv (GL_MODELVIEW_MATRIX, TmpMatrix.Data); // Берём сгенерированную матрицу
Начинаются чудеса. Вначале включаем одномерное текстурирование, а затем разрешаем использование текстуры с оттенками серого цвета для закрашивания. OpenGL пользуется ей в качестве таблицей соответствия. Затем мы устанавливаем цвет модели (белый). Я выбрал белый, т.к. он даёт возможность отображать подсветки и затенения намного лучше, чем другие цвета. Предлагаю вам не использовать чёрный…
// Код эффекта мультипликационного закрашивания
glEnable (GL_TEXTURE_1D); // Включить одномерное текстурирование
glBindTexture (GL_TEXTURE_1D, shaderTexture[0]); // «Захват» нашей текстуры
glColor3f (1.0f, 1.0f, 1.0f); // Установка цвета модели
Теперь начинаем прорисовку треугольников. Мы просматриваем каждый многоугольник в массиве, а затем по очереди все его вершины. На первом шаге информация о нормали копируется во временную структуру. Таким образом, мы можем вращать нормали, но сохранить их первоначальные значения (без снижения точности).
glBegin (GL_TRIANGLES); // «Сказать» OpenGL, что мы рисуем треугольники
for (i = 0; i < polyNum; i++) // Цикл по каждому многоугольнику
{
for (j = 0; j < 3; j++) // Цикл по каждой вершине
{
TmpNormal.X = polyData[i].Verts[j].Nor.X; // Заполнение структуры TmpNormal
TmpNormal.Y = polyData[i].Verts[j].Nor.Y; // значениями нормали текущей вершины
TmpNormal.Z = polyData[i].Verts[j].Nor.Z;
Во-вторых, мы вращаем нормаль в соответствии с матрицей ранее взятой у OpenGL. Затем мы нормализуем её, что бы она ни вела себя странно.
// Вращение в соответствии с матрицей
RotateVector (TmpMatrix, TmpNormal, TmpVector);
Normalize (TmpVector); // Нормализация новой нормали
В-третьих, мы получаем скалярное произведение векторов вращаемой нормали и направления света lightAngel. Затем мы урезаем значение до диапазона 0-1 (с диапазона от -1 до +1).
// Вычисление значения закрашивания
TmpShade = DotProduct (TmpVector, lightAngle);
if (TmpShade < 0.0f)
TmpShade = 0.0f; // Установка значения в 0, если TmpShade отрицательно
В-четвёртых, мы передаём это значение OpenGL как координаты текстуры. Текстура закрашивания ведёт себя как таблица соответствия (значение закрашивания – это индекс), которая (как я думаю) и является главной причиной того, почему была инвертирована одномерная текстура. Затем мы передаём позицию вершины OpenGL, и повторяем цикл. Повторяем. Повторяем. Думаю, вы ухватили идею.
glTexCoord1f (TmpShade); // Установка координат текстуры значением закрашивания
// Отправка вершин
glVertex3fv (&polyData[i].Verts[j].Pos.X);
}
}
glEnd (); // Завершение рисования
glDisable (GL_TEXTURE_1D); // Отключение одномерных текстур
Теперь переходим к контурам. Контур может быть определён как «край соприкосновения двух многоугольников, один из которых обращён к наблюдателю, а другой наоборот». В OpenGL это соответствует тому, когда тест глубины установлен в значение меньшее или равное текущему значению (GL_LEQUAL). Мы также смешиваем линии для лучшего отображения.
Теперь разрешаем смешивание и устанавливаем режим. Говорим OpenGL, отобразить задние многоугольники как линии, и устанавливаем ширину этих линий. Запрещаем прорисовку передних многоугольников и устанавливаем тест глубины меньшим или равным текущему значению Z. После того, как установлен цвет линий, перебираем в цикле все многоугольники, отображая их вершины. Необходимо передавать только позицию вершины, а не нормаль или значение закрашивания, т.к. всё, что нам нужно - это контур.
// Код прорисовки контура
if (outlineDraw) // Проверка на необходимость прорисовки контура
{
glEnable (GL_BLEND); // Разрешить смешивание
// Установить режим смешивания
glBlendFunc (GL_SRC_ALPHA ,GL_ONE_MINUS_SRC_ALPHA);
glPolygonMode (GL_BACK, GL_LINE); // Прорисовка задних многоугольников в виде линий
glLineWidth (outlineWidth); // Установка ширины линии
glCullFace (GL_FRONT); // Запрет на прорисовку видимых многоугольников
glDepthFunc (GL_LEQUAL); // Изменение режима глубины
glColor3fv (&outlineColor[0]); // Установка цвета контура
glBegin (GL_TRIANGLES); // Сообщение того, что хотим нарисовать
for (i = 0; i < polyNum; i++) // Цикл по каждому многоугольнику
{
for (j = 0; j < 3; j++) // Цикл по каждой вершине
{
// Передача положения вершины
glVertex3fv (&polyData[i].Verts[j].Pos.X);
}
}
glEnd (); // Сообщение о завершении прорисовки
После этого, возвращаем все назад, как было до этого, и выходим.
glDepthFunc (GL_LESS); // Возврат в исходное положение режима теста глубины
glCullFace (GL_BACK); // Возвращение режима прорисовка граней
glPolygonMode (GL_BACK, GL_FILL); // Возвращение режима прорисовки задних граней
glDisable (GL_BLEND); // Отключение смешивания
}
}
Теперь вы видите, что мультипликационное закрашивание - это не так трудно, как кажется. Безусловно, методику можно сильно улучшить. Хороший пример – игра XIII (http://www.nvidia.com/object/game_xiii.html), которая заставляет вас думать, что вы в мультяшном мире. Если вы хотите глубже понять технику отображения мультипликационных объектов, то можете посмотреть главу "Non-Photorealistic Rendering" в книге «Real-time Rendering» (Muller, Hains). Если вам нравится читать статьи в сети, обширный список ссылок можно найти здесь: http://www.red3d.com/cwr/npr/ .
© Sami Hamlaoui (MENTAL)
© Jeff Molofee (NeHe)