Previous parts
- Simple cross-platform game engine - Introduction
- Universal Box2D debug draw for OpenGL ES 1.x and OpenGL ES 2.0
- Loading images under Windows
- Load images under Android with NDK and JNI
- Using JNI_OnLoad() in Adroid NDK development
- Simple streaming audio mixer for Android with OpenSL ES - part 1
- Simple streaming audio mixer for Android with OpenSL ES - part 2
- Crunch - very lightweight unpacker used in our Android and bada games
- Using zlib compression library in Adroid NDK
In this post I will describe how to work with ETC1 textures.
What are compressed textures good for?
You are probably familiar with image formats like .png and .jpg. The first one employs lossless compression while the second uses lossy compression. Usually the .jpg files are smaller for the same image for price of decreased quality.
Regardless the way the image is stored compressed on the disk, when you load and extract it into memory it bloats to its uncompressed size. For RGBA 8888 it is 32 bits per each one pixel. So when your 1024x1024 image as a texture may eat 4MB of precious video memory
Beside these well known image formats there are also formats that compress the texture and it stays compressed on GPU. These formats can drastically reduce number of bits required for each pixel.
Unfortunately, when coding for Android these formats are vendor specific. Currently there are four of them: ETC1, PVRTC, ATITC, S3TC. See more here.
Only ETC1 is available on all Android devices so the rest of the article is about this format. But it lacks one important thing - it does not support textures with alpha. Fortunately, there are ways how to bypass this. As mentioned before, these format means that texture stays compressed on GPU being decompresssed on the fly which will reduce texture space used as well as increase performance of your openGL game (reduced data bandwidth).
Another atractiviy of these texture formats comes from fact that you do not need to include any image unpacking library like libpng into your game. Nor you need to load images on the java side like I was describing in my older post (Load images under Android with NDK and JNI).
The drawback is that textures in its compressed form are bigger than .jpg or .png files with the same image, so more disk space will be consumed. Further in the article I will also describe way how I solved it.
Creating ETC1 texture
Every GPU vendor has its own set of tools including tool for compressing textures. I am using tool from ARM (load it here). After you run the tool you will get initial screen. Open image file with you texture (do not forget that open GL ES needs height and width to be power of 2) and you should get screen like this:
Select the texture(s) in left panel and press "Compress" icon (). The compression parameters panel will pop up:Choose ETC1/ETC2 tab (1.) and select PKM as output format. PKM is very simple format that ads small header to compressed data. The header is this:
+0: 4 bytes header "PKM "
+4: 2 bytes version "10"
+6: 2 bytes data type (always zero)
+8: 2 bytes extended width
+10: 2 bytes extended height
+12: 2 bytes original width
+14: 2 bytes original height
+16: compressed texture data
In ETC1 format each 4x4 pixel bloc is compressed into 64 bits. So the extended width and height are the original dimensions rounded up to multiple of four. If you are using power of 2 textures then the original and extended dimensions are the same.
From these parameters you can calculate the size of compressed data like this:
(extended width / 4) * (extended height / 4) * 8
This formula just says: there is so many 4x4 pixel blocks and each of them is 8 bytes long (64 bits).
Parameters marked 2. and 5. on the picture will affect quality of compression. The compression takes quite a lot of time. So during development you can use worse quality if you do not want to wait. But be sure that when finishing your game you use maximum quality - the size of output remains the same.
Under 3. do not forget to check that ETC1 is chosen and udder 4. choose to create separate texture for alpha channel. This texture will have the same dimensions as the original one. But in the red channel of it there will be stored alpha instead of color. The green and blue channels are unused so theoretically you can put any additional information there (but not with the tool - it would be up to you how to do it).
Loading ETC1 texture
Now when you have the texture compressed it is time to load it into GPU.//------------------------------------------------------------------------ u16 TextureETC1::swapBytes(u16 aData) { return ((aData & 0x00FF) << 8) | ((aData & 0xFF00) >> 8); } //------------------------------------------------------------------------ void TextureETC1::construct(SBC::System::Collections::ByteBuffer& unpacked) { // check if data is ETC1 PKM file - should start with text "PKM " (notice the space in the end) // read byte by byte to prevent endianness problems u8 header[4]; header[0] = (u8) unpacked.getChar(); header[1] = (u8) unpacked.getChar(); header[2] = (u8) unpacked.getChar(); header[3] = (u8) unpacked.getChar(); if (header[0] != 'P' || header[1] != 'K' || header[2] != 'M' || header[3] != ' ') LOGE("data are not in valid PKM format");
swapBytes is just help method the real work is done in construct method. ByteBuffer is our simple wrapper around array of bytes holding not only data but also its size. This is not important here it just increases readability.
We start with header to check whether the input data are really PKM file.
// read version - 2 bytes. Should be "10". Just skipping unpacked.getShort(); // data type - always zero. Just skip unpacked.getShort(); // sizes of texture follows: 4 shorts in big endian order u16 extWidth = swapBytes((u16) unpacked.getShort()); u16 extHeight = swapBytes((u16) unpacked.getShort()); u16 width = swapBytes((u16) unpacked.getShort()); u16 height = swapBytes((u16) unpacked.getShort()); // calculate size of data with formula (extWidth / 4) * (extHeight / 4) * 8 u32 dataLength = ((extWidth >> 2) * (extHeight >> 2)) << 3;
In next step we skip additional information on version and data type as we do not use them. Then dimensions are read and with swapByte it is converted from big endian to little endian. The size of compressed data is calculated with already mentioned formula.
// openGL part // create and bind texture - all next texture ops will be related to it glGenTextures(1, &mTextureID); glBindTexture(GL_TEXTURE_2D, mTextureID); // load compressed data (skip 16 bytes of header) glCompressedTexImage2D(GL_TEXTURE_2D, 0, GL_ETC1_RGB8_OES, extWidth, extHeight, 0, dataLength, unpacked.getPositionPtr()); // set texture parameters glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); // save size mWidth = extWidth; mHeight = extHeight; }
Now it is time to create texture with OpenGL calls. We first allocate texture ID and then we sent data for it. Note that internal format is GL_ETC1_RGB8_OES. This says that we are using OES_compressed_ETC1_RGB8_texture extension that ads support for ETC1 textures.
Now we can use this texture as any other loaded from .png.
Handling alpha with ETC1 textures
As previously said, ETC1 does not support alpha. During texture creation time we exported alpha into separate texture. This texture has the same dimension as the one with colors. You can employ fragment shader to compose the final color containing alpha from these two textures.
The simple fragment shader doing this may look like this:
#ifdef GL_ES precision mediump float; #endif uniform lowp sampler2D u_map[2]; varying mediump vec2 v_texture; void main(void) { gl_FragColor = vec4(texture2D(u_map[0], v_texture).rgb, texture2D(u_map[1], v_texture).r); }
It took me less than 1 hour to rewrite parts of our game to support ETC1 with alpha in separate texture instead of previously used textures from .png files. And I got really sweet reward in increasing the frame rate by about 10% on my Samsung Galaxy Tab.
Compression to the power of two
The last thing that I did not liked first was that ETC1 textures without alpha were 2 times bigger than .jpg files (in our prepared game Shards we are using .jpg for backgrounds - so no need for alpha for these). I finally solved this with additional compression of .pkm file with zlib library. It took me some time to find how to use it on Android, but you can read it here (Using zlib compression library in Adroid NDK) - it is really worth of it.
Now I am almost on the size of .jpg file. When creating texture I first uncompress the file with zlib and the uncompressed .pkm is sent to previously described routine.
Conclusion
ETC1 texture compression is the only one supported by all Android devices (having OpenGL ES 2.0 of course). It lacks alpha so small overhead is present when bypassing this. It pays as you are saving texture memory and increasing your frame rate - 10% in case of my Samsung Galaxy Tab.