OpenGL - LWJGL
DDS texture compression
coding tutorial
by Christian Cohnen
chris@chriscohnen.de

 

relevant keywords:
glCompressedTexImage2DARB
GL_COMPRESSED_RGBA_S3TC_DXT1_EXT
GL_COMPRESSED_RGBA_S3TC_DXT3_EXT
GL_COMPRESSED_RGBA_S3TC_DXT5_EXT
DDSURFACEDESC2

Introduction

Many people are starting to play around with Java and OpenGL using LWJGL (like me). This small tutorial is directed at this audience, especially if they do not want to dig into Microsoft DirectX files themselves. You can waste your time better than this. I did take me quite some hours to find all the necessary info in the net to get myself started on a DDS file loader.
Of course there are lot of useful texture formats like TGA, PNG and JPG that are not platform dependent and M$ proprietary but that does not count here. Full/direct DDS support is quite useful if you don't want to break your tool chain. And DDS has quite good support in the windows world. For example plugins for 3DSMax and Photoshop[3] are available and the format is natively used in tools from ATI,NVIDIA and others for normal map generation. Compared to formats like TGA a plus for DDS is support for texture compression, and storing cubemaps and mipmap levels in the same file.

Starting point for getting into texture compression with DDS for me was this nice NVIDIA document [1] explaining the OpenGL extension for texture compression. Most of the java code was converted from the example given in the document.

If you need to uncompress and compress images in DDS format have a look at the DevIL image library C source. It supports DXT1/3/5 uncompressing. You can find DevIL here [2].

Simple DDSReader

So let`s start with the code. I implemented the loader in a separate class called DDSReader. The class also has one inner class to represent the DDS file header and makes use of a BinaryFileReader class for easy file handling. I have some lwjgl imports at the start of the DDSReader file.

import java.nio.IntBuffer;
import java.nio.ByteBuffer;
import org.lwjgl.opengl.*;
import org.lwjgl.BufferUtils;

public class DDSReader {

Loading the binary file is done via a Binary File Reader class that loads the file into memory and then give you access to the file content using different data return types. This is quite handy. You can substitute this easily with your own implementation. The BinaryFileReader is declared at the beginning.

BinaryFileReader bis;

A DDS file stores all relevant information in the file header. Format of this header is defined the Microsoft ddraw.h file as struct _DDSURFACEDESC2. We use a inner class for storing the header information that more or less corresponds to the _DDSURFACEDESC2 struct and sub structures DDPIXELFORMAT,DDSCAPS2 but is very simplified, because we only need it for loading.. We also have to declare some constant values that are required when fiddling around with the header flags. For a short explanation of the header values see java comments in the code:

static final long DDSD_MIPMAPCOUNT = 0x00020000l;
static final long DDSCAPS_COMPLEX = 0x00000008l;
static final long DDSCAPS2_CUBEMAP = 0x00000200l;
static final long DDSCAPS2_CUBEMAPSIDE[] = {0x00000400L, 0x00000800L, 0x00001000L, 0x00002000L, 0x00004000L, 0x00008000L};

class DDSURFACEDESC2 {
/** size of the DDSURFACEDESC structure */ int size;
/** determines what fields are valid*/ int flags;
/** height of surface to be created */ int height;
/** width of input surface*/ int width;
/** formless optimized surface size */ int linearSize;
/** the depth, if volume texture */ int depth;
/** number of mip-map levels */ int mipMapCount;
/** depth of alpha buffer */ int dwAlphaBitDepth;
// DDPIXELFORMAT pixelFormat pixel format ddsheader of the surface
/** size of structure */ int size2;
/** pixel format flags */ int flags2;
/**(FOURCC code) */ String fourCC;
/** how many bits per pixel */ int rgbBitCount;
/** mask for red bit */ int rBitMask;
/** mask for green bits */ int gBitMask;
/** mask for blue bits */ int bBitMask;
/** mask for alpha channel */ int rgbAlphaBitMask;
// DDPIXELFORMAT pixelFormat end
// DDSCAPS2 ddsCaps direct draw surface capabilities
int caps1;
int caps2;
int caps3;
/* dwVolumeDepth */ int caps4;
int dwTextureStage;

It is very easy to read the header as it has a fixed size of 128 bytes. It always starts with the 4 characters "DDS_" identifier.
Here is an example DDS header. I marked the identifier, the number of mipmap levels and the fourCC (four character code indicating the compression format):

00000000h: 44 44 53 20 7C 00 00 00 07 10 0A 00 00 02 00 00 ; DDS |...........
00000010h: 00 02 00 00 00 00 02 00 00 00 00 00 0A 00 00 00 ; ................
00000020h: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ; ................
00000030h: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ; ................
00000040h: 00 00 00 00 00 00 00 00 00 00 00 00 20 00 00 00 ; ............ ...
00000050h: 04 00 00 00 44 58 54 31 00 00 00 00 00 00 00 00 ; ....DXT1........

the following method reads the header variables (size 124 bytes) behind the DDS identifier.

public void read() {
    size = bis.readInt();
    flags = bis.readInt();
    height = bis.readInt();
    width = bis.readInt();
    linearSize = bis.readInt();
    depth = bis.readInt();
    mipMapCount = bis.readInt();
    dwAlphaBitDepth = bis.readInt();
    // skip until DDPixelformat
    bis.setIndex(bis.getIndex()+40);
    // DDPIXELFORMAT 2
    size2 = bis.readInt();
    flags2 = bis.readInt();
    fourCC = bis.readString(4);
    rgbBitCount = bis.readInt();
    rBitMask = bis.readInt();
    gBitMask = bis.readInt();
    bBitMask = bis.readInt();
    rgbAlphaBitMask = bis.readInt();
    // DDCAPS2 struct
    caps1 = bis.readInt();
    caps2 = bis.readInt();
    caps3 = bis.readInt();
    caps4 = bis.readInt();
    // DDCAPS2 end
    dwTextureStage = bis.readInt();
}

At this point we can start explaining the loader method. It takes the filename of the DDS file. First we test if the DDS identifier is ok then we load the header from above.

/**
* load DDS file
* @param filename
*/

public void loadDDSFile(String filename) {
    boolean isCubemap=false;

    bis = new BinaryFileReader(filename);
    if ((bis != null) && (bis.isReadOK())) {
        String filetype = bis.readString(4);
        if (!"DDS ".equals(filetype)) {
            System.out.println("not dds format");
            return;
        }
        ddsheader.read();

We also check the size variables of the structure these must always be 124 and 32, if values are ok we set the file pointer to the end of the header and printout the header information.

      if (ddsheader.size!=124) { System.out.println("not dds format"); return; }
      if (ddsheader.size2!=32) { System.out.println("not dds format"); return; }
      bis.setIndex(ddsheader.size + 4); // seek to texture data, pos= filetype ( is 4)+ ddsheader (is 124)
      System.out.println(ddsheader);

In order to determine the compression used in the DDS data for all surfaces we check the four character code. This should be DXT1, DXT3 or DXT5 (see NVIDIA document [1]). There are also other fourCCs like ATI2 (for 3Dc compression) and so on, but you have to add code yourself if you need to support those compressions.

    int format = 0;

    if ("DXT1".equals(ddsheader.fourCC)) {
         format = EXTTextureCompressionS3TC.GL_COMPRESSED_RGB_S3TC_DXT1_EXT;
     }
     if ("DXT3".equals(ddsheader.fourCC)) {
         format = EXTTextureCompressionS3TC.GL_COMPRESSED_RGBA_S3TC_DXT3_EXT;
     }
     if ("DXT5".equals(ddsheader.fourCC)) {
         format = EXTTextureCompressionS3TC.GL_COMPRESSED_RGBA_S3TC_DXT5_EXT;
     }

Next thing to do is calculate the size of the first surface stored in the DDS texture, depth should be 1 if this is a 2D texture. Compression uses blocks to store the image. One block represents 16 texels (4x4 tile). Depending on the compression format the blocksize is 8 (DXT1) or 16 (DXT3,DXT5) bytes. Size of the encoded image is ceil(width/4)*ceil(height/4)*blocksize see [3] .

     int blocksize=16;
     if (format == EXTTextureCompressionS3TC.GL_COMPRESSED_RGB_S3TC_DXT1_EXT) blocksize = 8;
     int size = ((ddsheader.width + 3)/4) * ((ddsheader.height + 3)/4) * ((ddsheader.depth + 3)/4)*blocksize;

To find out, if the DDS file stores a cubemap we check the caps2 field from the header and we fix missing mip map count value as well

     if ((ddsheader.caps1 & (DDSCAPS_COMPLEX)) > 0)
         if ((ddsheader.caps2 & DDSCAPS2_CUBEMAP) > 0) {
             isCubemap = true;
         }
     if (((ddsheader.flags & DDSD_MIPMAPCOUNT) == 0) || ddsheader.mipMapCount == 0) {
         ddsheader.mipMapCount = 1;
     }
     if (isCubemap) loadCubeMapTexture(size, format, blocksize,ddsheader.caps2);
     else load2DTexture(size, format, blocksize);
}// end of loadDDSFile(String filename)

If it is a cubemap, we finally call the loadCubemap(int size,int format,int blocksize) method otherwise we call load2DTexture(int size,int format,int blocksize)
I'll explain load2DTexture first:

In LWJGL we need to create an IntBuffer to hold the texture address that is returned from the GL11.glGenTextures function. As can be seen in LWJGL code looks nearly 1:1 to the standard OpenGL code. The address returned is used in the glBindTexture method.

private void load2DTexture(int size, int format, int blocksize) {
     IntBuffer buf = BufferUtils.createIntBuffer(1);
     GL11.glGenTextures(buf); // Create Texture In OpenGL
     GL11.glBindTexture(GL11.GL_TEXTURE_2D, buf.get(0));
     int dds_compressed_decal_map = buf.get(0);

Next part reads the compressed data from the BinaryFileReader at the current index with the calculated size and puts it into a fresh ByteBuffer scratch.The scratch needs to be rewinded else we get a LWJGL crash.

     ByteBuffer pixBuf = BufferUtils.createByteBuffer(size);
     pixBuf.put(bis.contents,bis.getIndex(),size);
     bis.setIndex(bis.getIndex()+size);
     pixBuf.rewind();

Before we create the texture with the compressed data we set the OpenGL filter values otherwise we would end with a white texture. The GL13.glCompressedTexImage2D call generates the texture for mip level 0. The compression format parsed into the format variable (see above) is used here.

     GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR);
     GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR);
     GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL11.GL_REPEAT);
     GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL11.GL_REPEAT);
     GL13.glCompressedTexImage2D(GL11.GL_TEXTURE_2D, 0, format, ddsheader.width, ddsheader.height, 0,size, pixBuf);

When the header indicates that we have mip levels stored in the file we also generate the mip map levels. For each mip level we retrieve the compressed data from the BinaryFileReader. The width and height is always divided by 2 and we recalculate the size. Please note that all levels down to 1x1 pixel need to be generated, otherwise we end with a white texture.

     // set mipmap Filtering and load all levels
     if (ddsheader.mipMapCount > 1) {
         GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR_MIPMAP_LINEAR);
         GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR);
         int width = ddsheader.width;
         int height = ddsheader.height;
         for (int i = 1; i < ddsheader.mipMapCount; i++) {
             width >>= 1;
             height >>= 1;
             if (width == 0) width = 1;
             if (height == 0) height = 1;
             size = ((width + 3) / 4) * ((height + 3) / 4) * ((ddsheader.depth + 3) / 4) * blocksize;
             pixBuf.put(bis.contents,bis.getIndex(),size);
             bis.setIndex(bis.getIndex()+size);
             pixBuf.rewind();
             GL13.glCompressedTexImage2D(GL11.GL_TEXTURE_2D, i, format, width, height, 0, size, pixBuf);
         }
     }
}

That's it, we have loaded the surface with all mip levels into an OpenGL texture. If the DDS header indicates a cubemap we call a different method: loadCubeMapTexture(...).

The method loadCubeMapTexture is nearly the same as load2DTexture(...) we just have a loop for the cubeside. For cubemaps we load up to six surfaces (and their mip levels) from the dds file. The cube surfaces are stored sequentially without data between them. The header caps2 value indicates which sides are present and is passed as a parameter to the method.

 

private void loadCubeMapTexture(int size, int format, int blocksize,int caps2) {
    IntBuffer buf = BufferUtils.createIntBuffer(1);
    GL11.glGenTextures(buf);
    // create cube texture
    GL11.glBindTexture(GL13.GL_TEXTURE_CUBE_MAP, buf.get(0));

    ByteBuffer pixBuf = BufferUtils.createByteBuffer(size);
    for (int side = 0; side < 6; side++) {
        if (!((caps2 & DDSCAPS2_CUBEMAPSIDE[side]) > 0)) {
             continue;
        }

        // load level 0

        pixBuf.put(bis.contents, bis.getIndex(), size);
        bis.setIndex(bis.getIndex() + size);
        pixBuf.rewind();
        GL11.glTexParameteri(GL13.GL_TEXTURE_CUBE_MAP, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR);
        GL11.glTexParameteri(GL13.GL_TEXTURE_CUBE_MAP, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_LINEAR);
        GL13.glCompressedTexImage2D(GL13.GL_TEXTURE_CUBE_MAP_POSITIVE_X+side, 0, format, ddsheader.width, ddsheader.height, 0, size, pixBuf);

        // set mipmap filtering and load all levels
        if (ddsheader.mipMapCount > 1) {
            GL11.glTexParameteri(GL13.GL_TEXTURE_CUBE_MAP, GL11.GL_TEXTURE_MIN_FILTER,GL11.GL_LINEAR_MIPMAP_LINEAR);
            GL11.glTexParameteri(GL13.GL_TEXTURE_CUBE_MAP, GL11.GL_TEXTURE_MAG_FILTER,GL11.GL_LINEAR);
            int width = ddsheader.width;
            int height = ddsheader.height;
            for (int i = 1; i < ddsheader.mipMapCount; i++) {
                width >>= 1;
                height >>= 1;
                if (width == 0) width = 1;
                if (height == 0) height = 1;
                int mipsize = ((width + 3) / 4) * ((height + 3) / 4) * ((ddsheader.depth + 3) / 4) * blocksize;
                pixBuf.put(bis.contents, bis.getIndex(), mipsize);
                bis.setIndex(bis.getIndex() + mipsize);
                pixBuf.rewind();

                GL13.glCompressedTexImage2D(GL13.GL_TEXTURE_CUBE_MAP_POSITIVE_X + side, i, format, width, height, 0, mipsize, pixBuf);
            }
        }
    }
}

Before processing the six surface we create the cube map by specifying GL13.GL_TEXTURE_CUBE_MAP in the glBindTexture call. For all surfaces we reuse one ByteBuffer scratch in the maximum size we have to handle. For the smaller sub surfaces (mip levels) we use the buffer but set how much of the data is valid when calling the glCompressedTexImage2D function.

In the simple example code (see download) a small main applet uses the DDSReader class to load a single DDS file. It draws the texture on the screen in different sizes triggering the mip levels. If the DDS file stores a cube map all six side are shown.

 

Conclusion

This tutorial should give you enough information to add support for DDS files in your LWJGL based app or 3D engine. Further improvement could be support for normal map compression and faster IO using New IO (NIO).

DOWNLOAD DDS tutorial source code (including this document).

Please send any comments to chris@chriscohnen.de

References

[1] NVIDIA Using Texture Compression in OpenGL PDF

[2] Developer's Image Library (DevIL)

[3] NVIDIA Texture Tools (Photoshop plugin)

 

© 2004 Christian Cohnen. All rights reserved.

/cc june@2.010