Все статьи / Смешиваем цвета

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


Физика полупрозрачности

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

Полная эмуляция полупрозрачности на компьютере выполнима (например, путём трассировки лучей). Однако, такие задачи не принято выполнять в реальном времени. Вместо физически реалистичной эмуляции OpenGL предлагает модель смешивания цветов, позволяющую эмулировать полупрозрачную поверхность путём смешения цвета фона, лежащего за поверхностью, с цветом самой поверхности. Смешение можно выполнять с весовыми коэффициентами, которые зависят от alpha-компоненты RGBA-цветов материала и фона, а также от выбранной формулы смешивания (выбор формул смешивания достаточно большой, но ограниченный).

Режим смешивания

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

Результат этой ошибки можно увидеть на скриншоте — грани тетраэдра выброшены полупрозрачным кубом в ходе теста глубины:

Скриншот

Формулы смешивания

OpenGL позволяет задавать способ смешения с помощью установки весовых коэффициентов функции смешивания. Для изменения функции смешивания служит функция-команда glBlendFunc, которая принимает константы перечислимого типа, задающие способ выбора коэффициентов смешивания. Первый параметр задаёт способ выбора весового коэффициента для фонового фрагмента, второй — для фрагмента полупрозрачной поверхности, рисуемой поверх фона со смешиванием. Несколько примеров:

  • glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) задаёт формулу “непрозрачность * цвет_поверхности + (1 - непрозрачность) * цвет_фона”, где под “непрозрачностью” подразумевается alpha-канал рисуемой поверхности. В результате цвет полупрозрачной поверхности накладывается на фон привычным для человека образом.
  • glBlendFunc(GL_ONE, GL_ZERO) задаёт формулу, эквивалентную отсутствию смешивания: цвет фрагментов новой поверхности замещает собой цвет фоновых фрагментов.
  • glBlendFunc(GL_CONSTANT_ALPHA, GL_ONE_MINUS_CONSTANT_ALPHA) применяет уже показанную ранее формулу “непрозрачность * цвет_поверхности + (1 - непрозрачность) * цвет_фона”, но в качестве “непрозрачности” берёт константу, установленную вызовом glBlendColor.
  • glBlendFunc(GL_SRC_ALPHA, GL_ONE) устанавливает аддитивную формулу “непрозрачность_поверхности * цвет_поверхности + цвет фона”, которая при большом числе смешиваний или высокой непрозрачности поверхности может дать очень яркий, возможно, даже белый цвет. Такой метод может пригодиться при рисовании некоторых систем частиц — например, языков пламени.
// включает смешивание цветов
// перед выводом полупрозрачных тел
void enableBlending()
{
    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}

// отключает смешивание цветов
// перед выводом непрозрачных тел
void disableBlending()
{
    glDisable(GL_BLEND);
}

Алгоритм вывода полупрозрачных тел

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

Изменим вспомогательные функции:

// включает смешивание цветов
// перед выводом полупрозрачных тел
void enableBlending()
{
    glDepthMask(GL_FALSE);
    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}

// отключает смешивание цветов
// перед выводом непрозрачных тел
void disableBlending()
{
    glDepthMask(GL_TRUE);
    glDisable(GL_BLEND);
}

В ситуациях, когда полупрозрачные тела или их отдельные грани накладываются друг на друга, следует отсортировать тела либо отдельные грани по глубине (удалённости от камеры) и выводить так, чтобы для каждой пары из двух визуально пересекающихся тел (граней) первым рисовалось более далёкое от камеры тело (грань), а затем — более близкое. Только тогда смешивание будет работать правильно.

Общий алгоритм действий:

  • включить возможность записи в буфер глубины вызовом glDepthMask(GL_TRUE) (или disableBlending())
  • нарисовать все непрозрачные объекты сцены
  • выключить возможность записи в буфер глубины, включить смешивание
  • отсортировать все полупрозрачные объекты (или непосредственно грани) в порядке от дальних к ближним (по отношению к камере)
  • нарисовать все полупрозрачные объекты (грани)

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

Иллюстрация

Выделение интерфейса IBody

Чтобы упростить дальнейшее расширение кода, введём интерфейс IBody в файле “IBody.h”:

#pragma once
#include <memory>

class IBody
{
public:
    virtual ~IBody() = default;
    virtual void Update(float deltaTime) = 0;
    virtual void Draw()const = 0;
};

using IBodyUniquePtr = std::unique_ptr<IBody>;

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

// фрагмент объявления CWindow
private:
    std::vector<IBodyUniquePtr> m_opaqueBodies;
    std::vector<IBodyUniquePtr> m_transparentBodies;

// изменения в обновлении состояния сцены
void CWindow::OnUpdateWindow(float deltaSeconds)
{
    m_camera.Update(deltaSeconds);
    for (const IBodyUniquePtr &pBody : m_opaqueBodies)
    {
        pBody->Update(deltaSeconds);
    }
    for (const IBodyUniquePtr &pBody : m_transparentBodies)
    {
        pBody->Update(deltaSeconds);
    }
}

// изменения в рисовании кадра сцены
void CWindow::OnDrawWindow(const glm::ivec2 &size)
{
    SetupView(size);
    m_sunlight.Setup();
    for (const IBodyUniquePtr &pBody : m_opaqueBodies)
    {
        pBody->Draw();
    }
    enableBlending();
    for (const IBodyUniquePtr &pBody : m_transparentBodies)
    {
        pBody->Draw();
    }
    disableBlending();
}

Вывод задних граней

В полупрозрачном теле видны не только задние, но и передние грани. Чтобы их нарисовать, можно воспользоваться трюком: нарисовать трёхмерное тело дважды, изменив способ определения передних граней при первом рисовании. Для изменения способа определения передних граней достаточно вызвать функцию glFrontFace(GL_CW), т.к. по умолчанию OpenGL считает передними гранями только грани, вершины которых перечислены против часовой стрелки (режим GL_CCW). Всё это приводит нас к простой модификации метода CIdentityCube::Draw:

void CIdentityCube::Draw() const
{
    if (m_alpha < 0.99f)
    {
        glFrontFace(GL_CW);
        OutputFaces();
        glFrontFace(GL_CCW);
    }
    OutputFaces();
}

void CIdentityCube::OutputFaces() const
{
    // выводит треугольники, составляющие грани куба,
    // вместе с цветами и нормалями вершин.
}

Платоновы тела

Существует ровно пять платоновых тел тетраэдр, октаэдр, икосаэдр, куб, додекаэдр.

Иллюстрация

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

Более подробно о триангуляции платоновых тел рассказывается в книге Френсиса Хилла, “OpenGL. Программирование компьютерной графики.” (ISBN 5-318-00219-6), раздел 6.3 “Многогранники”. Схожая информация есть и в других источниках в литературе и в сети Интернет.

Правильный тетраэдр — это правильный многогранник, состоящий из четырёх граней, каждая из которых является правильным треугольником (с равными сторонами и равными углами по 60°). Как и другие платоновы тела, тетраэдр является выпуклым и обладает высокой степенью симметрии. Сделав простое построение, можно аналитически рассчитать соотношения между его сторонами и особыми внутренними линиями, такими, ка высота тетраэдра (перпендикуляр из вершины к противоположной грани). Вычислим эти отношения:

Иллюстрация

Вершины и грани тетраэдра

После построения несложно составить массив вершин и массив индексов граней: достаточно смотреть на построение и записывать. Если для удобства взять за длину стороны базового тетраэдра число √3, получатся такие массивы:

// Сторона тетраэдра равна √3,
// расстояние от центра грани до вершины равно 1.
const Vertex TETRAHEDRON_VERTICES[] = {
    {0.f, 0.f, -1.0f},
    {sqrtf(1.5f), 0.f, 0.5f},
    {-sqrtf(1.5f), 0.f, 0.5f},
    {0.f, sqrtf(2.f), 0.f},
};

const STriangleFace TETRAHEDRON_FACES[] = {
    {0, 1, 2, 0},
    {0, 3, 1, 0},
    {2, 1, 3, 0},
    {0, 2, 3, 0},
};

Класс CIdentityTetrahedron

Теперь объявим класс базового тетраэдра, у которого будет только одно свойство — единый цвет поверхности.

class CIdentityTetrahedron final : public IBody
{
public:
    void Update(float deltaTime) final;
    void Draw()const final;

    void SetColor(const glm::vec4 &color);

private:
    void OutputFaces()const;

    glm::vec4 m_color;
};

Для реализации рисования воспользуемся ранее увиденным трюком с вызовом glFrontFace:

void CIdentityTetrahedron::Update(float deltaTime)
{
    (void)deltaTime;
}

void CIdentityTetrahedron::Draw() const
{
    if (m_color.a < 0.99f)
    {
        glFrontFace(GL_CW);
        OutputFaces();
        glFrontFace(GL_CCW);
    }
    OutputFaces();
}

void CIdentityTetrahedron::SetColor(const glm::vec4 &color)
{
    m_color = color;
}

void CIdentityTetrahedron::OutputFaces() const
{
    // менее оптимальный способ рисования: прямая отправка данных
    // могла бы работать быстрее, чем множество вызовов glColor/glVertex.
    glBegin(GL_TRIANGLES);

    for (const STriangleFace &face : TETRAHEDRON_FACES)
    {
        const Vertex &v1 = TETRAHEDRON_VERTICES[face.vertexIndex1];
        const Vertex &v2 = TETRAHEDRON_VERTICES[face.vertexIndex2];
        const Vertex &v3 = TETRAHEDRON_VERTICES[face.vertexIndex3];
        glm::vec3 normal = glm::normalize(glm::cross(v2 - v1, v3 - v1));

        glColor4fv(glm::value_ptr(m_color));
        glNormal3fv(glm::value_ptr(normal));
        glVertex3fv(glm::value_ptr(v1));
        glVertex3fv(glm::value_ptr(v2));
        glVertex3fv(glm::value_ptr(v3));
    }
    glEnd();
}

Введение объектов-декораторов

В связи с добавлением тетраэдра перемещение и анимирование куба было переделано с применением шаблона проектирования “Декоратор”. Декоратор — класс, который оборачивает реальное трёхмерное тело и изменяет способ его рисования. Для удобства выделен класс абстрактного декоратора, который реализует интерфейс IBody и имеет методы для установки и получения единственного дочернего IBody:

class CAbstractDecorator : public IBody
{
public:
    void SetChild(IBodyUniquePtr && pChild);

protected:
    void UpdateChild(float deltaTime);
    void DrawChild()const;

private:
    IBodyUniquePtr m_pChild;
};

void CAbstractDecorator::SetChild(IBodyUniquePtr &&pChild)
{
    m_pChild = std::move(pChild);
}

void CAbstractDecorator::UpdateChild(float deltaTime)
{
    assert(m_pChild.get());
    m_pChild->Update(deltaTime);
}

void CAbstractDecorator::DrawChild() const
{
    assert(m_pChild.get());
    m_pChild->Draw();
}

Перемещение двух кубов в разные позиции теперь реализуется с помощью CTransformDecorator:

class CTransformDecorator : public CAbstractDecorator
{
public:
    void Update(float deltaTime);
    void Draw()const;

    void SetTransform(const glm::mat4 &transform);

private:
    glm::mat4 m_transform;
};

void CTransformDecorator::Draw() const
{
    glPushMatrix();
    glMultMatrixf(glm::value_ptr(m_transform));
    DrawChild();
    glPopMatrix();
}

Анимирование куба реализуется в классе CAnimatedDecorator:

class CAnimatedDecorator : public CAbstractDecorator
{
public:
    void Update(float deltaTime);
    void Draw()const;

private:
    enum Animation
    {
        Rotating,
        Pulse,
        Bounce,
    };

    glm::mat4 GetAnimationTransform()const;

    Animation m_animation = Rotating;
    float m_animationPhase = 0;
};

void CAnimatedDecorator::Draw() const
{
    const glm::mat4 matrix = GetAnimationTransform();
    glPushMatrix();
    glMultMatrixf(glm::value_ptr(matrix));
    DrawChild();
    glPopMatrix();
}

После изменения способа анимирования и перемещения куба в класс CWindow добавлен метод InitBodies, который инициализирует линейные массивы непрозрачных и полупрозрачных тел.

Нормали гладких поверхностей

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

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

Иллюстрация

Можно достигнуть эффекта гладкости иным способом: воспользоваться интерполяцией освещения. На изображении выше сфера слева и сфера справа представлены одинаковым числом треугольников (это можно заметить, глядя на угловатые края правой сферы). Однако, для сферы справа освещение рассчитывается в каждом фрагменте треугольника (с использованием программируемого конвейера и GLSL). Поэтому зритель не замечает угловатость сферы — мозг в процессе восстановления трёхмерной картинки из двухмерного кадра на сетчатке глаза будет считать сферу гладкой, потому что она выглядит гладкой.

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

За установку модели закрашивания грани отвечает функция glShadeModel:

  • режим glShadeModel(GL_SMOOTH) выставлен по умолчанию: в таком режиме каждая вершина треугольника имеет свою нормаль и свой результат расчёта освещения, но фрагменты треугольника получают усреднённое значение цвета (с соответствующими весовыми коэффициентами).
  • режим glShadeModel(GL_FLAT) приведёт к тому, что для треугольника будет выбрана лишь одна нормаль одной вершины, остальные будут отброшены. В итоге весь треугольник при расчёте освещения будет окрашен в единый цвет.

Библиотека GLU

Библиотека GLU (OpenGL Utilities) развивалась параллельно с первыми версиями OpenGL. Она поставляется производителям видеодрайверов как часть OpenGL, и содержит

  • функции для некоторых операций над матрицами (однако, функции для матриц в GLM удобнее и мощнее, чем в GLU)
  • функции для некоторых операций над текстурами (генерация уменьшенных копий текстуры)
  • функции для операций над многоугольниками на плоскости (разделение на треугольники и логические операции над областями многоугольников)
  • функции для рисования сферы, цилиндра и кругового диска

Последнее обновление спецификации GLU произошло в 1998-м году, и на данный момент библиотека считается устаревшей. Кроме того, GLU отсутствует в мобильном OpenGL ES и в WebGL, оставаясь работоспособной только в составе видеодрайверов для настольных компьютеров. Не стоит привыкать к использованию GLU — однако, мы применим GLU в рамках статьи для рисования сферы и цилиндра. Мы воспользуемся типом GLUquadric и связанными с ним функциями.

Класс CSphereQuadric

Класс реализует интерфейс IBody, используя спецификатор final. Единственное поле класса хранит указатель на структуру GLUquadric, реализация которой скрыта внутри GLU.

// новые заголовки
#include <GL/glu.h>
#include <boost/noncopyable.hpp>

class CSphereQuadric final
        : public IBody
        , private boost::noncopyable
{
public:
    CSphereQuadric();
    ~CSphereQuadric();

    void Update(float) final {}
    void Draw()const final;

    void SetColor(const glm::vec3 &color);

private:
    GLUquadric *m_quadric = nullptr;
    glm::vec3 m_color;
};

Конструктор и деструктор написаны согласно идиоме RAII. Копирование класса CSphereQuadric запрещено путём приватного наследования от boost::noncopyable, чтобы обеспечить уникальное владение ресурсом.

CSphereQuadric::CSphereQuadric()
    : m_quadric(gluNewQuadric())
    , m_color({1, 1, 1})
{
}

CSphereQuadric::~CSphereQuadric()
{
    gluDeleteQuadric(m_quadric);
}

Для рисования вызывается фунция gluSphere, в параметрах которой передаётся радиус сферы и число делений по широте/долготе, от которого прямо зависит число созданных для приближения сферы треугольников.

void CSphereQuadric::Draw() const
{
    const double radius = 1;
    const int slices = 20;
    const int stacks = 20;
    glColor3fv(glm::value_ptr(m_color));
    gluSphere(m_quadric, radius, slices, stacks);
}

void CSphereQuadric::SetColor(const glm::vec3 &color)
{
    m_color = color;
}

Результат добавления сферы на сцену:

Скриншот

Ради эксперимента включим для сферы упомянутый ранее режим “плоского” расчёта освещения, в котором одна грань может иметь только одну нормаль:

void CSphereQuadric::Draw() const
{
    glShadeModel(GL_FLAT);
    const double radius = 1;
    const int slices = 20;
    const int stacks = 20;
    glColor3fv(glm::value_ptr(m_color));
    gluSphere(m_quadric, radius, slices, stacks);
    glShadeModel(GL_SMOOTH);
}

Скриншот

Класс CConoidQuadric

Класс усечённого конуса CConoidQuadric также реализует интерфейс IBody, используя спецификатор final, и хранит внутри указатель на объект типа GLUquadric. С помощью CConoidQuadric можно нарисовать не только усечённый конус, но и обычный конус либо цилиндр — результат рисования зависит от значения свойства TopRadius. По умолчанию TopRadius = 1., рисуется цилиндр:

// определение класса
class CConoidQuadric final
        : public IBody
        , private boost::noncopyable
{
public:
    CConoidQuadric();
    ~CConoidQuadric();

    void Update(float) final {}
    void Draw()const final;

    /// @param value - in range [0..1]
    void SetTopRadius(double value);
    void SetColor(const glm::vec3 &color);

private:
    GLUquadric *m_quadric = nullptr;
    double m_topRadius = 1.;
    glm::vec3 m_color;
};

// конструктор и деструктор

CConoidQuadric::CConoidQuadric()
    : m_quadric(gluNewQuadric())
    , m_color({1, 1, 1})
{
}

CConoidQuadric::~CConoidQuadric()
{
    gluDeleteQuadric(m_quadric);
}

Для рисования используется три функции-команды GLU: gluCylinder рисует только боковую поверхность усечённого конуса, а две “крышки” (верхняя и нижняя) рисуются двумя дисками с помощью gluDisk. Здесь также использованы устаревшие низкоуровневые средства для работы с матрицами — это оправдано, потому что библиотека GLU устарела одновременно с OpenGL 1.x, и весь код рисования усечённого конуса одинаково устарел для OpenGL 2.x и выше. Реализация рисования:

// Рисует усечённый конус высотой 2,
// с радиусом основания 1 и радиусом верхнего торца m_topRadius.
void CConoidQuadric::Draw() const
{
    const double baseRadius = 1;
    const double height = 2;
    const int slices = 20;
    const int stacks = 1;
    glColor3fv(glm::value_ptr(m_color));
    glTranslatef(0, 0, 1);
    gluCylinder(m_quadric, baseRadius, m_topRadius, height, slices, stacks);
    glFrontFace(GL_CW);
    gluDisk(m_quadric, 0, baseRadiuss, slices, stacks);
    glFrontFace(GL_CCW);
    glTranslatef(0, 0, 2);
    gluDisk(m_quadric, 0, baseRadius, slices, stacks);
    glTranslatef(0, 0, -1);
}

void CConoidQuadric::SetTopRadius(double value)
{
    m_topRadius = glm::clamp(value, 0.0, 1.0);
}

void CConoidQuadric::SetColor(const glm::vec3 &color)
{
    m_color = color;
}

После добавления цилиндра на сцену мы получим интересное явление, которое называется Z-Fighting: грань куба и диск цилиндра накладываются друг на друга, и фрагменты граней имеют одинаковую глубину. Спецификация OpenGL оставляет поведение в таких ситуациях неопределённым: на разных кадрах разные фрагменты грани куба и диска цилиндра будут “выигрывать” конфликт глубины и попадать на экран.

Скриншот

Универсального решения для Z-Fighting не существует. Но для большинства приложений Z-Fighting не является проблемой — например, в трёхмерных играх поверхности не могут накладываться друг на друга из-за работы физического движка, который не позволяет объектам совмещаться друг с другом.

Результат

Вы можете взять полный пример к статье на github. В этом примере на сцене находятся два куба, тетраэдр, сфера и цилиндр, к некоторым из них прикреплены объекты-декораторы:

Иллюстрация