teaching machines

CS 488: Lecture 15 – Extra Extra Texturing

March 17, 2021 by . Filed under graphics-3d, lectures, spring-2021.

Dear students:

Today we visit just a few more miscellaneous topics related to texturing. We’ll examine texturing a cube, dealing with constraints on a texture’s dimensions, and apply textures to produce more discrete shading.

Texture Atlas

Texturing a quadrilateral is like biking down hill. You don’t have to exert much effort to paste a rectangular texture on a quadrilateral. We must try to texture something more difficult, like a cube. Suppose we want to put a different image on each face. Our first impulse might be to use six textures, but it’s not clear how we would apply different textures to different faces within a single draw call. We either need the faces to be separated or we need to use only a single texture.

When we load multiple images into a single texture, we have a texture atlas. For a cube, we can use an atlas that is a 4×2 grid of square cells, filling 6 of the cells with the cube’s images. The texture coordinates must be carefully assigned so that each face maps to the right “page” of the atlas.

Powers of 2

Some graphics libraries expect textures to have dimensions that are powers of 2, like 1024×256. We reap several advantages by constraining textures in this way. First, we are guaranteed to have a clean mipmap set since the dimensions will divide by two with no remainder. Second, texture lookups may be faster. Let’s explore a few reasons why that might be.

What is 5 << 1? In binary, that’s 101 << 1, which is $1010_2$ or $10_{10}$. Extending this, 5 << 2 is $10100_2$ or $20_{10}$. Shifting binary digits is the same multiplying by 2. Hang on to that truth for a second.

If you’ve got integer texture coordinates s and t and width w, then you can turn a 2D texel coordinate into a 1D index into a row-major buffer using these equations:

i = w * t + s

However, if w is 2 to the power of n, then the multiplication can be replaced by shifting:

i = t << n + s

Shifting in hardware is generally faster to perform than multiplication.

WebGL allows non-power of 2 textures (NPOT), but a call to gl.generateMipmap will fail on such textures. The minification filter must not be set to any interpolation based on mipmap levels. Only gl.LINEAR and gl.NEAREST are supported.

Additionally, one cannot use gl.REPEAT or gl.MIRRORED_REPEAT for wrapping coordinates on NPOT textures. Only gl.CLAMP_TO_EDGE is supported. Let’s explore why this might be. Suppose we have a texture that is 64 texels wide. We have these binary representations of texture coordinate 50 and its successive counterparts in the repetitions that will reduce to 50:

50          =   110010
50 + 64 * 1 =  1110010
50 + 64 * 2 = 10110010
50 + 64 * 3 = 11110010

We see from these examples that we can reduce the out-of-range texture coordinates down to an in-range texture coordinate with a masking operation. In this case, we want to mask with bits 111111, which is 63 in decimal. In general, we calculate our in-range coordinate like this:

texcoord = texcoord & (width - 1)

Computing the mask only works when the size is a power of 2, since one less than a power of 2 has all its bits on.

If you have an NPOT texture, you have several options:

  1. Live with it. The graphics API may accept NPOT textures.
  2. Pad the image to the nearest higher powers of 2 in your graphics editor. The padding wastes disk and increases download times.
  3. Pad the image to the nearest higher powers of 2 programmatically at runtime. JavaScript’s Image class doesn’t give us much control over pixels. However, its canvas element does. We can write this code to draw the NPOT image into a POT canvas:
    function padToPot(image) {
      const canvas = document.createElement('canvas');
      const context = canvas.getContext('2d');
      canvas.width = powerOfTwoCeiling(image.width);
      canvas.height = powerOfTwoCeiling(image.height);
      context.drawImage(image, 0, 0);
      return context.getImageData(0, 0, canvas.width, canvas.height);
    }
    
  4. Allocate a texture on the GPU whose dimensions are the nearest higher powers of 2 and upload the texels as a sub-image within the texture.
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, powerOfTwoCeiling(image.width), powerOfTwoCeiling(image.height), 0, gl.RGBA, gl.UNSIGNED_BYTE,
    gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, image.width, image.height, gl.RGBA, gl.UNSIGNED_BYTE, image);
    

The code in these solutions depends on a powerOfTwoCeiling function. We compute the power of 2 that’s greater than or equal to a number, by taking the base-2 logarithm, rounding up, and raising 2 to that power. The function can be written as a gauntlet of math functions:

function powerOfTwoCeiling(x) {
  return Math.pow(2, Math.ceil(Math.log2(x)));
}

Alternatively, if you prefer to overengineering things, there are bit twiddling hacks that can be used to find the leftmost 1-bit, which gets you pretty close to the power of 2.

With the padding options, you must adapt the texture coordinates to the new resolution.

Someday these limitations on NPOT textures may go away. The practical concerns that led to these limitations might not even be relevant on modern hardware, yet we continue to abide by them for backward compatibility.

Toon Shading

The Blinn-Phong lighting model that we’ve implemented produces smooth shading. In some games and movies, we see a different kind of lighting that uses a small number of discrete bands of illumination. This effect is sometimes called toon shading or cel shading because it mimics the practice of some cartoon animators who painted the animated foreground on sheets of celluloid. Celluloid is transparent and can be overlaid on static and painterly backgrounds. Many cels were needed to orchestrate the animation, and animators saved time by using few colors and no gradients.

In standard diffuse shading, we modulate the surface’s albedo according to the degree of alignment between the normal and the light vector. The dot product—the cosine—that we use to compute this alignment produces a continuous dropoff. To get a discrete dropoff, we can use the dot product as a texture coordinate into a 1D lookup table that gives a small set of “litness” values.

Unlike the full OpenGL, WebGL doesn’t allow 1D textures. But we can create a 2D texture that has a height of 1. We could make our lookup table in an image editor, but it’s also possible to synthesize it programmatically:

function loadTable() {
  // Make an array of unsigned bytes with a handful of illumination levels.
  const table = new Uint8Array(128);
  for (let i = 0; i < table.length; i += 1) {
    if (i < 20) {
      table[i] = 0;
    } else if (i < 30) {
      table[i] = 50;
    } else if (i < 70) {
      table[i] = 128;
    } else if (i < 120) {
      table[i] = 200;
    } else {
      table[i] = 255;
    }
  }

  // Upload the texture.
  gl.activeTexture(gl.TEXTURE0);
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, table.length, 1, 0, gl.RED, gl.UNSIGNED_BYTE, table);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.generateMipmap(gl.TEXTURE_2D);

  return texture;
}

We change our fragment shader to index into this shader to determine the litness level:

uniform sampler2D table;

const vec3 lightDirection = normalize(vec3(1.0, 1.0, 1.0));
const vec3 albedo = vec3(1.0, 1.0, 1.0);

in vec3 fnormal;

out vec4 fragmentColor;

void main() {
  vec3 normal = normalize(fnormal);
  float litness = max(0.0, dot(normal, lightDirection));
  float level = texture(table, vec2(litness, 0.0)).r;
  fragmentColor = vec4(albedo * level, 1.0);
}

TODO

Here’s your TODO list:

See you next time.

Sincerely,

P.S. It’s time for a haiku!

Computers on Mars
They’ll use binary for sure
Because it’s base 2