Jump to content

Building a single-file 4K HDR skybox with BC6 compression


Josh

2,730 views

 Share

HDR skyboxes are important in PBR rendering because sky reflections get dampened by surface color. It's not really for the sky itself, but rather we don't want bright reflections to get clamped and washed out, so we need colors that go beyond the visible range of 0-1.

Polyhaven has a large collection of free photo-based HDR environments, but they are all stored in EXR format as sphere maps. What we want are cubemaps stored in a single DDS file, preferably using texture compression.

We're going to use this online tool to convert the sphere maps to cubemaps. There are other ways to do this, and some may be better, but this is what I was able to find right away. By default, the page shows a background we can use for testing:

Untitled.thumb.jpg.7b5a8f57c560bdd7f35fd2acdf27c3c3.jpg

Let's process and save the image. Since we are just testing, it's fine to use a low resolution for now:

Untitled.jpg.051a9e19c0af10e4b89d1adf0a3f5e20.jpg

The layout is very important. We will select the last option, which exports each face as a separate image:

Untitled.thumb.jpg.958e147dbe7fe0a153012973a75c9acf.jpg

Processing and saving the image will result in a zip file downloaded to your computer, with six files:

  • px.hdr
  • nx.hdr
  • py.hdr
  • ny.hdr
  • pz.hdr
  • nz.hdr

These images correspond to the positive and negative directions on the X, Y, and Z axes.

We will start by just converting a single image, px.hdr. We will convert this pixmap to the format TEXTURE_BC6H, which corresponds to VK_FORMAT_BC6H_UFLOAT_BLOCK in Vulkan, or DXGI_FORMAT_BC6H_UF16 in the DDS file format. We need to load the FreeImage plugin for HDR file import, and the ISPC plugin for fast BC6H compression. The R16G16B16A16_SFLOAT is an intermediate format we need to convert the 32-bit floating point HDR file to, because the ISPC compressor expects this as the input format for BC6H compression:

auto plg = LoadPlugin("Plugins/FITextureLoader");
auto plg2 = LoadPlugin("Plugins/ISPCTexComp");

auto pixmap = LoadPixmap(GetPath(PATH_DESKTOP) + "/px.hdr");
pixmap = pixmap->Convert(TextureFormat(VK_FORMAT_R16G16B16A16_SFLOAT));
pixmap = pixmap->Convert(TEXTURE_BC6H);
pixmap->Save(GetPath(PATH_DESKTOP) + "/px.dds");

When we open the resulting DDS file in Visual Studio it appear quite dark, but I think this is due to how the HDR image is stored. I'm guessing colors are being scaled to a range between zero and one in the HDR file:

Untitled.thumb.png.9ed36ac9af9b869f8c7149976bcfd386.png

Now that we know how to convert a save a single 2D texture, let's try saving a more complex cube map texture with mipmaps. to do this, we need to pass an an array of pixmaps to the SaveTexture function, containing all the mipmaps in the DDS file, in the correct order. Microsoft's documentation on the DDS file format is very helpful here.

std::vector<std::shared_ptr<Pixmap> > mipchain;
WString files[6] = { "px.hdr", "nx.hdr", "py.hdr", "ny.hdr", "pz.hdr", "nz.hdr" };
for (int n = 0; n < 6; ++n)
{
    auto pixmap = LoadPixmap(GetPath(PATH_DESKTOP) + "/" + files[n]);
    pixmap = pixmap->Convert(TextureFormat(VK_FORMAT_R16G16B16A16_SFLOAT));
    Assert(pixmap);
    mipchain.push_back(pixmap->Convert(TEXTURE_BC6H));
    while (true)
    {
        auto size = pixmap->size;
        size /= 2;
        pixmap = pixmap->Resize(size.x, size.y);
        Assert(pixmap);
        mipchain.push_back(pixmap->Convert(TEXTURE_BC6H));
        if (size.x == 4 and size.y == 4) break;
    }
}
SaveTexture(GetPath(PATH_DESKTOP) + "/skybox.dds", TEXTURE_CUBE, mipchain, 6);

when we open the resulting DDS file in Visual Studio we can select different cube map faces and mipmap levels, and see that things generally look like we expect them to:

cubemap.thumb.png.0e73270bcefa841ce01ad85adeb5234e.png

Now is a good time to load our cube map up in the engine and make sure it does what we think it does:

auto skybox = LoadTexture(GetPath(PATH_DESKTOP) + "/skybox.dds");
Assert(skybox);
world->SetSkybox(skybox);

When we run the program we can see the skybox appearing. It still appears very dark in the engine:

Untitled.thumb.jpg.bb08d6e14021f79340d1a20f209b015f.jpg

We can change the sky color to lighten it up, and the image comes alive. We don't have any problems with the dark colors getting washed out, because this is an HDR image:

world->SetSkyColor(4.0);

Untitled.thumb.jpg.54d8214b5620706facc1edb414f4b56b.jpg

Now that we have determined that our pipeline works, let's try converting a sky image at high resolution. I will use this image from Polyhaven because it has a nice interesting sky. However, there's one problem. Polyhaven stores the image in EXR format, and the cube map generator I am using only loads HDR files. I used this online converter to convert my EXR to HDR format, but there are probably lots of other tools that can do the job.

Once we have the new skybox loaded in the cubemap generator, we will again process and save it, but this time at full resolution:

Untitled.jpg.1096a30d5bcdf7e9d75b0af0500032cc.jpg

Save the file like before and run the DDS creation code. If you are running in debug mode, it will take a lot longer to process the image this time, but when it's finished the results will be worthwhile!:

Untitled.thumb.jpg.a5ae14d14b6549efebe9a04977061b36.jpg

This will be very interesting to view in VR. Our final DDS file is 120 MB, much smaller than the 768 MB an uncompressed R16G16B16A16 image would require. This gives us better performance and requires a lot less hard drive space. When we open the file in Visual Studio we can browser through the different faces and mipmap levels, and everything seems to be in order:

Untitled.thumb.jpg.9ede28f8845ac2cab4840b3da6473776.jpg

Undoubtedly this process will get easier and more automated with visual tools, but it's important to get the technical basis laid down first. With complete and in-depth support for the DDS file format, we can rely on a widely supported format for textures, instead of black-box file formats that lock your game content away.

  • Like 2
 Share

4 Comments


Recommended Comments

This shot shows why HDR environment maps are important. The reflective surface is very dark, but the bright sun is still shining in the reflection. This would not be possible with an LDR image.

Untitled.thumb.jpg.834b576763226dd20544fd7e5e4cad40.jpg

Link to comment

What I ended up doing is running an HDR file through the IBL sampler above, then loading the KTX2, converting the mipmaps to BC6H, and saving as a DDS:

    auto plg = LoadPlugin("Plugins/KTX2TextureLoader");
    auto plg2 = LoadPlugin("Plugins/ISPCTexComp");

    auto skybox = LoadTexture("Materials/Environments/Cloudy Blue/specular.ktx2", LOAD_MIPCHAIN);
    std::vector<shared_ptr<Pixmap> > mipmaps;
    for (int layer = 0; layer < skybox->mipchain.size(); ++layer)
    {
        for (int miplevel = 0; miplevel < skybox->CountMipmaps(); ++miplevel)
        {
            auto mipmap = skybox->mipchain[layer][miplevel];
            mipmaps.push_back(mipmap->Convert(TEXTURE_BC6H));
        }
    }
    SaveTexture("Materials/Environments/Cloudy Blue/specular.dds", TEXTURE_CUBE, mipmaps, 6);

 

Link to comment

Updated code for generating cubemap:

#include "UltraEngine.h"

using namespace UltraEngine;

// https://www.ultraengine.com/community/blogs/entry/2780-building-a-single-file-4k-hdr-skybox-with-bc6-compression/
int main(int argc, const char* argv[])
{
    //Settings
    const bool compression = true;

    // Load required plugin
    auto fiplugin = LoadPlugin("Plugins/FITextureLoader");
    auto cplugin = LoadPlugin("Plugins/ISPCTexComp");

    // Load cube faces output from https://matheowis.github.io/HDRI-to-CubeMap/
    std::vector<std::shared_ptr<Pixmap> > mipchain;
    WString files[6] = { "px.hdr", "nx.hdr", "py.hdr", "ny.hdr", "pz.hdr", "nz.hdr" };
    for (int n = 0; n < 6; ++n)
    {
        auto pixmap = LoadPixmap(files[n]);
        Assert(pixmap);
        if (pixmap->format != VK_FORMAT_R16G16B16A16_SFLOAT)
        {
            pixmap = pixmap->Convert(TextureFormat(VK_FORMAT_R16G16B16A16_SFLOAT));// this step is required for BC6H conversion
        }
        while (true)
        {
            auto mipmap = pixmap;

            if (compression) mipmap = mipmap->Convert(TEXTURE_BC6H);
            Assert(mipmap);
            mipchain.push_back(mipmap);
            auto size = pixmap->size;
            if (size.x == mipmap->blocksize and size.y == mipmap->blocksize) break;

            size /= 2;
            pixmap = pixmap->Resize(size.x, size.y);
            Assert(pixmap);            
        }
    }
    
    // Save cubemap
    SaveTexture("skybox.dds", TEXTURE_CUBE, mipchain, 6);

    return 0;
}

 

Link to comment
Guest
Add a comment...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

×
×
  • Create New...