NeHe Tutorials Народный учебник по OpenGL
Урок 37. OpenGL

Cel-Shading

Наблюдая, как люди всё ещё пишут мне, спрашивая об исходном коде для статьи, которую я недавно написал на 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)

PMG  19 декабря 2003 (c)  Верисокин Владимир