Texture Setup

How to 3D

Chapter 9: Textures

Texture Setup

Once an image has been read in by a renderer, it must be shuttled off to the graphics card. It needs to be in VRAM, just like the vertex attributes, because we want to be able to read texture data very quickly. Getting it there requires going through WebGL.

The WebGL API for handling textures is a mishmash of ideas that have developed over several decades. Graphics technology has changed significantly in that time, and the WebGL API has become a little disjointed as it has adapted.

The graphics card has special hardware called a texture unit that looks up texture data quickly. In fact, to comply with the WebGL standard, a card must have at least eight texture units. That means we can use up to eight textures per draw call. Your card may support more. Issue this query to find how many units your card has:

const unitCount = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS);
console.log(unitCount);
const unitCount = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS);
console.log(unitCount);

The units are named gl.TEXTURE0, gl.TEXTURE1, and so on.

Each unit can be in one of several different modes. A mode is called a texture target in the WebGL specification. Some modes correspond to different dimensionalities of the data. If the texture is a plain 2D image, then the target is gl.TEXTURE_2D. If the texture is volumetric data, such as that produced by medical equipment like a CT scanner or a scientific simulation, then the target is gl.TEXTURE_3D. The full OpenGL standard allows one-dimensional textures via gl.TEXTURE_1D, but WebGL does not. We will encounter some additional targets later on.

The data is uploaded to a texture objects. A texture object is a data structure on the graphics card that consists of the pixel data and some other settings that influence how the texture data is looked up.

The following function creates a texture object, uploads an image's pixel data into the object, and associates the object with a given texture unit's gl.TEXTURE_2D target:

function createTexture2d(image, textureUnit = gl.TEXTURE0) {
  gl.activeTexture(textureUnit);
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
  gl.generateMipmap(gl.TEXTURE_2D);
  return texture;
}
function createTexture2d(image, textureUnit = gl.TEXTURE0) {
  gl.activeTexture(textureUnit);
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
  gl.generateMipmap(gl.TEXTURE_2D);
  return texture;
}

There are many functions at work here: activeTexture chooses the texture unit, createTexture creates a new texture object, and texImage2D allocates storage in VRAM for the pixels and transfers the image into it. Mipmaps are discussed soon.

Generally, we associate each texture object with a different texture unit. For example, if we have three different images for terrain textures, we put them on texture units 0, 1, and 2 as follows:

const grassImage = await readImage('grass.jpg');
createTexture2d(grassImage, gl.TEXTURE0);

const sandImage = await readImage('sand.jpg');
createTexture2d(sandImage, gl.TEXTURE1);

const dirtImage = await readImage('dirt.jpg');
createTexture2d(dirtImage, gl.TEXTURE2);
const grassImage = await readImage('grass.jpg');
createTexture2d(grassImage, gl.TEXTURE0);

const sandImage = await readImage('sand.jpg');
createTexture2d(sandImage, gl.TEXTURE1);

const dirtImage = await readImage('dirt.jpg');
createTexture2d(dirtImage, gl.TEXTURE2);

This code will fetch the three images in strict sequence, which might be slow. We speed things up by fetching them in parallel but waiting for them all to finish by awaiting Promise.all before uploading them:

const [grassImage, sandImage, dirtImage] = await Promise.all([
  readImage('grass.jpg'),
  readImage('sand.jpg'),
  readImage('dirt.jpg'),
]);

createTexture2d(grassImage, gl.TEXTURE0);
createTexture2d(sandImage, gl.TEXTURE1);
createTexture2d(dirtImage, gl.TEXTURE2);
const [grassImage, sandImage, dirtImage] = await Promise.all([
  readImage('grass.jpg'),
  readImage('sand.jpg'),
  readImage('dirt.jpg'),
]);

createTexture2d(grassImage, gl.TEXTURE0);
createTexture2d(sandImage, gl.TEXTURE1);
createTexture2d(dirtImage, gl.TEXTURE2);

The next step is to establish a correspondence between a 3D model and a 2D texture.

← Reading ImagesMapping Vertices to Texels →