Преобразование карт окружения при помощи dlib

Разрабатывая 3D-движок для D, я столкнулся с интересной задачей – преобразованием равнопромежуточной карты окружения (equirectangular environment map) в кубическую карту (cube map).


Равнопромежуточная проекция часто используется для создания HDRI-панорам – практически все карты окружения в высоком динамическом диапазоне, доступные в Интернете, используют именно эту проекцию. Такие карты можно использовать в игровых движках напрямую, что позволяет сэкономить видеопамять, но вынуждает использовать в шейдерах относительно дорогостоящие функции (acos и atan2). Для игр более распространенным форматом карт окружения являются кубические карты, представляющие собой развертку шести граней куба, отображающего окружение, соответственно, в шести направлениях (+X, -X, +Y, -Y, +Z, -Z). Кубические карты требуют больше памяти, однако для них поддерживается аппаратный сэмплинг, что делает их очень эффективными. К тому же, при использовании равнопромежуточных карт вы не можете полагаться на автоматическое построение mip-уровней (glGenerateMipmap в OpenGL) – то есть, конечно, mip-уровни будут сгенерированы, но они не будут учитывать равнопромежуточную проекцию при фильтрации, и в результате получится заметный шов вдоль меридиана стыка. Именно эта проблема вынудила меня отказаться от равнопромежуточных карт в рантайме в пользу кубических.

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

Для начала нам нужна функция равнопромежуточной проекции:

import std.math;
import dlib.math;
import dlib.image;

Vector2f equirectProj(Vector3f dir)
{
    float phi = acos(dir.y);
    float theta = atan2(dir.x, dir.z) + PI;
    return Vector2f(theta / (PI * 2.0f), phi / PI);
}

Эта функция сопоставляет произвольному вектору направления пару нормализованных текстурных координат для чтения из карты. Нам остается лишь рассчитать вектор направления для интересующей нас точки. Чтобы заполнить одну из сторон кубической карты, мы проходим циклом по всем ее пикселям и вычисляем направление текущего пикселя путем преобразования его координат в пространство куба со стороной 2 и центром в точке (0, 0, 0). Направление затем нормализуется и передается функции equirectProj. Полученные текстурные координаты мы используем для билинейного сэмплинга нашей карты окружения – для этого используется функция bilinearPixel из dlib.image.image.

SuperImage envmap = loadPNG("envmap.png"); 
SuperImage cubeFace = envmap.createSameFormat(256, 256); 

foreach(x; 0..256) 
foreach(y; 0..256) 
{ 
    float cubex = (cast(float)x / 256.0f) * 2.0f - 1.0f; 
    float cubey = (1.0f - cast(float)y / 256.0f) * 2.0f - 1.0f; 
    Vector3f dir = Vector3f(cubex, cubey, 1.0f).normalized; 
    Vector2f uv = equirectProj(dir); 
    Color4f c = bilinearPixel(envmap, uv.x * envmap.width, uv.y * envmap.height); 
    output[x, y] = c; 
} 

output.savePNG("cubemap-poitive-z.png");

Приведенный код заполняет только одну сторону кубической карты, а именно +Z. Необходимо его обобщить для всех направлений. Для этого вводим матрицу преобразования, которую генерируем при помощи функции rotationMatrix из dlib.math.transformation:

enum CubemapFace 
{ 
    PositiveX, 
    NegativeX, 
    PositiveY, 
    NegativeY, 
    PositiveZ, 
    NegativeZ 
} 

Matrix4x4f cubeFaceMatrix(CubemapFace cf) 
{ 
    switch(cf) 
    { 
        case CubemapFace.PositiveX: 
            return rotationMatrix(1, degtorad(-90.0f)); 
        case CubemapFace.NegativeX: 
            return rotationMatrix(1, degtorad(90.0f)); 
        case CubemapFace.PositiveY: 
            return rotationMatrix(0, degtorad(90.0f)); 
        case CubemapFace.NegativeY: 
            return rotationMatrix(0, degtorad(-90.0f)); 
        case CubemapFace.PositiveZ: 
            return rotationMatrix(1, degtorad(0.0f)); 
        case CubemapFace.NegativeZ: 
            return rotationMatrix(1, degtorad(180.0f)); 
        default: 
            return Matrix4x4f.identity; 
    } 
}

Этой матрицей преобразуем вектор dir в нужное направление. Я оформил код заполнения стороны кубической карты в виде отдельной функции:

SuperImage genCubeFace(SuperImage envmap, uint w, uint h, CubemapFace cf) 
{ 
    SuperImage output = envmap.createSameFormat(w, h); 

    Matrix4x4f dirTransform = cubeFaceMatrix(cf); 

    foreach(x; 0..w) 
    foreach(y; 0..h) 
    { 
        float cubex = (cast(float)x / cast(float)w) * 2.0f - 1.0f; 
        float cubey = (1.0f - cast(float)y / cast(float)h) * 2.0f - 1.0f; 
        Vector3f dir = Vector3f(cubex, cubey, 1.0f).normalized * dirTransform; 
        Vector2f uv = equirectProj(dir); 
        Color4f c = bilinearPixel(envmap, uv.x * envmap.width, uv.y * envmap.height); 
        output[x, y] = c; 
    } 

    return output; 
} 

SuperImage envmap = loadPNG("envmap.png"); 
SuperImage cubeFaceNegY = genCubeFace(envmap, 256, 256, CubemapFace.NegativeY); 
cubeFaceNegY.savePNG("cubemap-negative-y.png");

Теперь для получения полной кубической карты нужно аналогично вызвать genCubeFace для всех направлений CubemapFace.

Оригинал карты окружения взят отсюда.

Добавить комментарий