Deconstructing and texture mapping a can in WebGL

Juan Castro Varón
7 min readApr 14, 2018

--

So the idea for this week was to play with textures.

But… what are textures, really? Well, the idea is to project an image file (that is, a 2D bitmap) onto a 3D surface. I know it sounds complicated. And that's because, well, it kinda is. But we're not here to complain! We're here to learn.

Here, hold my Generic Brand Soda™️. I'll show you.

If you think about a soda can, you think about this shape right here.

A soda can is a relatively simple shape. If we assume the top and bottom of a can as uniform surfaces, we can separate these in 3 distinct shapes:

  1. A circle surface of radius r. (On the top and bottom)
  2. A bevel that meets the tops and bottoms with the main body.
  3. The main cylinder with the can label.

However, if you've been following along, you're aware that circles and cylinders are w o r t h l e s s. We may as well be dealing with aliens. We now know that the only legal tender in WebGL nation is triangles. Triangles everywhere.

This triangles thing is suspicious — Is WebGL an illuminati conspiracy? We’ll never know.

Of course there's multiple ways to do this. But we're gonna use the approach that's so cleverly explained in this post by Learning WebGL. Although the author is working with a uniform sphere, the gist of it is relatively simple and equally aplicable. (http://learningwebgl.com/blog/?p=1253)

Imagine you’re slicing our can with a sharp knife. Our objective is to filter it into more basic shapes. With these, we want these simpler surfaces to be broken up further, into squares, in a radial fashion. Then we will slice these squares in half diagonally, to create triangles.

Do not try this at home. I'm not responsible if you hurt yourself cutting an aluminum can with a sharp knife.

This is how these surfaces will be deconstructed in our example.

Let's think about how we would do this in WebGL. You may notice all of our points lie in concentric circles. And all of our concentric circles have just two properties that set them apart: a radius (which gets smaller on top and bottom) and a height (the distance along the length of the can).

With this in mind, we're gonna think of the first slicing as the latitude (meaning along the length of the can) and our second slicing as one of longitude (to break up the can radially).

This is what that looks like:

var latitudeBands = [2.3, 2, -2, -2.3];
var radii = [1.2, 1.5, 1.5, 1.2];
var longitudeBands = 30;
var vertexPositionData = [];
for (var i=0; i <= latitudeBands.length; i++) {
for (var j=0; j <= longitudeBands; j++) {
var phi = j * 2 * Math.PI / longitudeBands;
var sinPhi = Math.sin(phi);
var cosPhi = Math.cos(phi);
var x = cosPhi;
var y = latitudeBands[i];
var z = sinPhi;
vertexPositionData.push(radii[i] * x);
vertexPositionData.push(y);
vertexPositionData.push(radii[i] * z);
}
}

This is pretty straightforward. Our latitudeBands array holds our heights, and our radii array holds our radii. We iterate over those and then break our longitude (along the radius) into 30 pieces, of which we take the angles and use its sine and cosine to make the surface.

However, these aren't triangles, these are just points in radial order! We'll need to switch these around to create triangles. The magic of this approach is that doing so only requires we iterate over the points once to create squares, and then with those we'll make our triangles.

var indexData = [];
for (var lat=0; lat < latitudeBands.length; lat++) {
for (var long=0; long < longitudeBands; long++) {
var first = (lat * (longitudeBands + 1)) + long;
var second = first + longitudeBands + 1;
indexData.push(first);
indexData.push(second);
indexData.push(first + 1);
indexData.push(second);
indexData.push(second + 1);
indexData.push(first + 1);
}
}

If this doesn't make sense, you can this of it this way:

First and First + 1 are part of one latitude, as are Second and Second + 1. If we switch the order of the array around so that we have triangle coordinate triplets, then WebGL can work its magic!

Nice! But this is a soda can and a soda can needs a label. Let's think about how we can approach working with the actual texture of the can:

Each triangle will correspond to a coordinate on the image.

Just as we did our can, we're gonna slice the image into squares and triangles. However, we don't actually need to do it on the image, WebGL will do it for us if we provide the coordinates in the same order as our vertices. Like this:

var latitudeBands = [2.3, 2, -2, -2.3];
var radii = [1.2, 1.5, 1.5, 1.2];
var longitudeBands = 30;var vertexPositionData = [];
var normalData = [];
var textureCoordData = [];
for (var i=0; i <= latitudeBands.length; i++) {
for (var j=0; j <= longitudeBands; j++) {
var phi = j * 2 * Math.PI / longitudeBands;
var sinPhi = Math.sin(phi);
var cosPhi = Math.cos(phi);
var x = cosPhi;
var y = latitudeBands[i];
var z = sinPhi;
vertexPositionData.push(radii[i] * x);
vertexPositionData.push(y);
vertexPositionData.push(radii[i] * z);
var u = 1 - (j / longitudeBands);
var v = latitudeBands[i] / 4.6 + 0.5;
textureCoordData.push(u);
textureCoordData.push(v);

}
}

Each u,v corresponds to a pixel coordinate in our can. However, you may notice these are not absolute pixel coordinates. Instead, we're mapping thep points to [0,1], which WebGL will then use to interpolate the image onto the surface and fill in the intermediate spaces. For u , we'll map this to how har along the circumference we are, and for v , we're using a linear function (x/4.6 + 0.5) that maps our height array — whose upper and lower bounds are [-2.3, 2.3] — to [0,1].

This is what that looks like:

Wait…

"But Juan, this is a hollow can. Where’s the top and bottom?"

Ok, wait a minute. The way we're gonna do this is slightly different, because we're not dealing with a rectangular image, but a circular one.

What I mean by that is that a can label (like the one we just used) can be expanded into a rectangle, meaning we're sort of "wrapping" it around our can. Circles are different, and our texture coordinates need to be changed to accommodate them.

WebGL has weird restrictions on textures. One of those is that image resolutions have to be powers of 2 on both sides. So from that, we expanded our original image, which was 1024px * 512px, to a full 1024px wide square, and we separated the image into quadrants (which we'll refer to by cartesian quadrant conventions). Quadrants I and II correspond to the bottom and top of the can, quadrants III and IV span the width of our original texture.

Our new modified texture, with royalty free top and bottom images from dreamstime.com

Here's our plan: we're gonna add two new latitudes per side (meaning, 4 new latitudes).

  1. First, the center of the top and bottom, with radius cero and height of 2.3 and -2.3 respectively.
  2. The edge of the top and bottom, sitting exactly at the edge of the previous shape, with radius 1.2 and height of 2.3 and -2.3 respectively.

Why do we need two? The reason is simple: since our texture now has "dead" white space in between the images, we're gonna put that empty space between two identical latitudes to interpolate between the edges of our two images without showing white artifacts.

In our code, everything should stay the same, except the radii and latitudeBands arrays, to which we'll add the new ones, and the part where we set up u and v for our texture coordinates.

Remember u and v aren't pixel coordinates but they work from [0, 1] in the positive x and y directions.

This is what that looks like:

var latitudeBands = [2.3, 2.3, 2.3, 2, -2, -2.3, -2.3, -2.3];
var radii = [0, 1.2, 1.2, 1.5, 1.5, 1.2, 1.2, 0];
for (var i=0; i <= latitudeBands.length; i++) {
for (var j=0; j <= longitudeBands; j++) {
// ... var u = 0;
var v = 0;
switch (i) {
case 0:
u = 0.25;
v = 0.75;
break;
case 1:
u = cosPhi * 0.25 + 0.25;
v = sinPhi * 0.25 + 0.75;
break;
case latitudeBands.length - 2:
u = cosPhi * 0.25 + 0.75;
v = sinPhi * 0.25 + 0.75;
break;
case latitudeBands.length - 1:
u = 0.75;
v = 0.75;
break;
default:
u = 1 - (j / longitudeBands);
v = latitudeBands[i] / 9.2 + 0.25;
}
// ...

The main thing here is our new fancy switch statement to set up u and v. We're basically changing the game for the first, second, second-to-last and last latitudes.

For first and last, we're setting the center for the top and bottom at (0.25, 0.75) and (0.75, 0.75) respectively: the centers of quadrants II and I. For second and second-to-last, we're working our way around the circumference of the texture in quadrants I and II, drawing the border of our circular images. To do this, we just use the sine and cosine of the current angle, change its radius to 1/4 and offset the center to the appropriate spot on the texture image.

And we're done!

--

--