Texture Setup

Once the image has been read in to your JavaScript program, it must be shuttled off to the graphics card. You want to be able to read texture data very quickly, so it needs to be in VRAM just like the vertex attributes. 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 sometimes graphics libraries become disjointed as they adapt to the changes. That's the case for the texture API.

The graphics card has special hardware called a texture unit that can look up texture data quickly. In fact, to comply with the WebGL standard, the card must have at least eight texture units. That means you can use up to eight textures per draw call. Your card might 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);

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. If the texture is a plain 2D image, then the target is gl.TEXTURE_2D. You can access volumetric textures with the target gl.TEXTURE_3D. The full OpenGL standard allows one-dimensional textures via gl.TEXTURE_1D, but WebGL does not. There are some other targets that you will encounter later on.

Then you have the textures themselves, which are sometimes called texture objects. A texture object is a data structure 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;
}

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 a topic for another day.

Generally, you associate each texture object with a different texture unit. For example, if you have three different images for ground textures, you might 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);

This code will fetch the three images in strict sequence, which might be slow. You could instead fetch them in parallel but wait for them all to finish 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);

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