Loading PNG images as OpenGL textures

From DCEmulation
Jump to navigation Jump to search
The printable version is no longer supported and may have rendering errors. Please update your browser bookmarks and please use the default browser print function instead.

Introduction

In this tutorial you will learn how to load a PNG file and display it using OpenGL. This is the finished result:

Kos gl png tutorial.png

Please note that it is recommended to use compressed textures instead to save some memory. However, this tutorial should be useful to get you started with drawing things on the screen. You can find more information about this optimization in the "Texture Formats" article

Overview

Loading a texture in OpenGL consists of two phases.

Loading the image file into memory

Given an image file, parse its meta information (dimensions, color format) and get an array of pixel values.

In this tutorial libpng will be used to parse PNG image files. libpng has a really terrible API, but all we basically need it to do is give us the width, height, color format and pixel values.

Upload to the graphics chip

Although the texture has been parsed it's not ready for drawing yet. It first needs to get transferred from main memory to video memory (on the graphics chip).

The OpenGL API offers the function glTexImage2D to perform this upload. Make sure to read the documentation of glTexImage2D.

It is declared as follows:

 void glTexImage2D(GLenum target,  GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, const GLvoid * data)

data is the argument for the pixel values, width, height and format (e.g. RGBA) are also obtained via libpng. While loading the image file, libpng will be told to convert various image formats to RGB or RGBA, depending on whether they have transparency information. This way the loading code can deal with palettes, grayscale, 16 bit per color channel and transparent color masks as well.

Implementation

Loading the PNG file

In the end we want to fill the following data structure:

 struct texture {
   GLuint id;
   GLenum format;
   GLenum min_filter;
   GLenum mag_filter;
   uint16_t w, h;
 };

The id is a handle to the OpenGL driver's texture. This way the texture can be activated with the glBindTexture function (documentation of glBindTexture).

The following function is to be implemented:

 int png_to_gl_texture(struct texture * tex, char const * const filename) {

It will return an error code with 0 representing no error.

There are many possible error conditions and it must be ensured that memory is freed properly, so the code will set an error code and jump to a cleanup label at the end of the function and make sure to free all memory even on error.

   int ret = 0;
   FILE * file = 0;
   uint8_t * data = 0;
   png_structp parser = 0;
   png_infop info = 0;
   png_bytep * row_pointers = 0;
 
   png_uint_32 w, h;
   int bit_depth;
   int color_type;
 
 
   if(!tex || !filename) {
     CLEANUP(1);
   }
 
   file = fopen(filename, "rb");
   if(!file) {
     CLEANUP(2);
   }

The following code sets up some state for libpng to use during parsing. libpng uses setjmp to return from an error (which is a terrible design decision on their part, don't imitate this in your APIs).

   parser = png_create_read_struct(PNG_LIBPNG_VER_STRING, 0, 0, 0);
   if(!parser) {
     CLEANUP(3);
   }
 
   info = png_create_info_struct(parser);
   if(!info) {
     CLEANUP(4);
   }
 
   if(setjmp(png_jmpbuf(parser))) {
     CLEANUP(5);
   }

Here the meta information of the image file is read.

PNG images with dimensions that are not powers of two or smaller than 8 are forbidden because the graphics chip cannot handle them. If you would like to use a texture with dimensions such as 100x50, then you will have to resize it to 128x64 using an image editing program, or you will have to make the loading code more complicated and put the 100x50 image into a 128x64 texture. You may also use strided textures.

   png_init_io(parser, file);
   png_read_info(parser, info);
   png_get_IHDR(parser, info, &w, &h, &bit_depth, &color_type, 0, 0, 0);
 
   if((w & (w-1)) || (h & (h-1)) || w < 8 || h < 8) {
     CLEANUP(6);
   }


The following code instructs libpng to transform various image formats to RGB/RGBA with 8 bits per color channel.

   if(png_get_valid(parser, info, PNG_INFO_tRNS) || (color_type == PNG_COLOR_TYPE_GRAY && bit_depth < 8) || color_type == PNG_COLOR_TYPE_PALETTE) {
     png_set_expand(parser);
   }
   if(bit_depth == 16) {
     png_set_strip_16(parser);
   }
   if(color_type == PNG_COLOR_TYPE_GRAY || color_type == PNG_COLOR_TYPE_GRAY_ALPHA) {
     png_set_gray_to_rgb(parser);
   }
   png_read_update_info(parser, info);
 
   int rowbytes = png_get_rowbytes(parser, info);
   rowbytes += 3 - ((rowbytes-1) % 4); // align to 4 bytes
 
   data = malloc(rowbytes * h * sizeof(png_byte) + 15);
   if(!data) {
     CLEANUP(6);
   }
 
   row_pointers = malloc(h * sizeof(png_bytep));
   if(!row_pointers) {
     CLEANUP(7);
   }
 
   // set the individual row_pointers to point at the correct offsets of data
   for(png_uint_32 i = 0; i < h; ++i) {
     row_pointers[h - 1 - i] = data + i * rowbytes;
   }
 
   png_read_image(parser, row_pointers);

At this point we're finally done with libpng and have the image's meta information as well as image data. Now the texture needs to be uploaded to OpenGL.

   GLuint texture_id;
   glGenTextures(1, &texture_id);
   glBindTexture(GL_TEXTURE_2D, texture_id);
   GLenum texture_format = (color_type & PNG_COLOR_MASK_ALPHA) ? GL_RGBA : GL_RGB;
   glTexImage2D(GL_TEXTURE_2D, 0, texture_format, w, h, 0, texture_format, GL_UNSIGNED_BYTE, data);

In the end the result is stored in the texture struct that we defined earlier. By default no texture filter is used.

   tex->id = texture_id;
   tex->w = w;
   tex->h = h;
   tex->format = texture_format;
   tex->min_filter = tex->mag_filter = GL_NEAREST;

This is the cleanup label that was mentioned earlier. All the temporary memory is freed and on error the image data is freed as well.

 cleanup:
   if(parser) {
     png_destroy_read_struct(&parser, info ? &info : 0, 0);
   }
 
   if(row_pointers) {
     free(row_pointers);
   }
 
   if(ret && data) {
     free(data);
   }
 
   if(file) {
     fclose(file);
   }
 
   return ret;
 }

Drawing

The following code demonstrates how to activate the texture and draw a simple rectangle with the texture applied to it.

Main function

OpenGL is initialized using glKosInit on the Dreamcast.

The default coordinate system extends horizontally and vertically between -1 and +1, with (x=0,y=0) at the center. Here it is first moved into a corner and then scaled to make it go between [0,640]x[0,480] instead (screen dimensions). This is generally what you want for a 2D game.

 int main(int argc, char **argv) {
   glKosInit();
   glTranslatef(-1.f, -1.f, 0.f);
   glScalef(1.f/320.f, 1.f/240.f, 1.f);


The file /rd/image.png is loaded into struct texture tex. Later, tex is used to activate the texture when drawing.

   struct texture tex;
   int ret = png_to_gl_texture(&tex, "/rd/image.png");
   if(ret) {
     printf("Cannot load texture, error code %d.\n", ret);
     return 1;
   }

In the endless main loop the texture is drawn centered on the screen. A function called draw_textured_quad is used for this purpose and will be explained later.

   for(;;) {
     float x = (640 - tex.w) / 2.f;
     float y = (480 - tex.h) / 2.f;
     draw_textured_quad(&tex, x, y);
     glutSwapBuffers();
   }
 
   return 0;
 }

Drawing a textured rectangle

Above, the function draw_textured_quad is used. This function is defined as follows:

 void draw_textured_quad(struct texture const * const tex, float x0, float y0) {

x0 and y0 are passed in as arguments and represent the start of the rectangle. The width and height of the texture are taken from the texture object tex and added to these coordinates to obtain x1 and y1.

   float x1 = x0 + tex->w;
   float y1 = y0 + tex->h;

Next, OpenGL is told the geometry of what's going to be drawn. For a rectangle 4 points are required.

Since the rectangle is supposed to be textured, OpenGL also needs to know how to apply the texture to its geometry. This is done by using texture coordinates which go from 0.0 to 1.0.

   float const vertex_data[] = {
     /* 2D Coordinate, texture coordinate */
     x0, y1,  0.f, 1.f,
     x0, y0,  0.f, 0.f,
     x1, y1,  1.f, 1.f,
     x1, y0,  1.f, 0.f,
   };

Now texturing is enabled and the texture id in tex is bound. We also apply the filtering that was set up for this texture.

   glEnable(GL_TEXTURE_2D);
   glBindTexture(GL_TEXTURE_2D, tex->id);
 
   glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, tex->min_filter);
   glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, tex->mag_filter);

All the geometry information has been assembled and the texture is active. Now OpenGL is told what is contained in the vertex_data (position + texture coordinates).

   glEnableClientState(GL_VERTEX_ARRAY);
   glEnableClientState(GL_TEXTURE_COORD_ARRAY);
   glVertexPointer  (2, GL_FLOAT, 4 * sizeof(float), vertex_data);
   glTexCoordPointer(2, GL_FLOAT, 4 * sizeof(float), vertex_data + 2);

This command will send a draw command to the graphics chip. It starts with the first point (0) and draws 4 points in total.

GL_TRIANGLE_STRIP uses triangle strips to draw the points. For 4 points this results in a rectangle. You can read more about triangle strips in the Wikipedia triangle strip article.

   glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);

In the end the enabled client states are disabled again, just in case other code assumes it to be disabled.

   glDisableClientState(GL_VERTEX_ARRAY);
   glDisableClientState(GL_TEXTURE_COORD_ARRAY);
 }

Download

You can download the tutorial as a ZIP archive including an example image and compiled Dreamcast ELF binary here: File:Kos gl png tutorial.zip.