home / twitter / github / rss

Procedurally generating a rounded box mesh

Rye Terrell
September 24, 2021

If you haven’t heard the story about Steve Jobs, Bill Atkinson, and rounded corners, it’s a fun read. A big takeaway is that rounded corners are everywhere. Let’s take a look at procedurally generating a rounded box mesh.

Face definitions

First we’ll define the faces of our box. We’ll define a corner for each face (the start), then two vectors representing up and right that we can use to traverse the face and fill it with polygons:

We’ll define the dimensions of our faces such that they compose a box of unit length on each axis and centered at the origin. Later, we’ll scale these so that we can create a box of arbitrary size.

const faces = [
  // Positive X
  {
    start: vec3.fromValues(0.5, -0.5, 0.5),
    right: vec3.fromValues(0, 0, -1),
    up: vec3.fromValues(0, 1, 0),
  },
  // Negative X
  {
    start: vec3.fromValues(-0.5, -0.5, -0.5),
    right: vec3.fromValues(0, 0, 1),
    up: vec3.fromValues(0, 1, 0),
  },
  // Positive Y
  {
    start: vec3.fromValues(-0.5, 0.5, 0.5),
    right: vec3.fromValues(1, 0, 0),
    up: vec3.fromValues(0, 0, -1),
  },
  // Negative Y
  {
    start: vec3.fromValues(-0.5, -0.5, -0.5),
    right: vec3.fromValues(1, 0, 0),
    up: vec3.fromValues(0, 0, 1),
  },
  // Positive Z
  {
    start: vec3.fromValues(-0.5, -0.5, 0.5),
    right: vec3.fromValues(1, 0, 0),
    up: vec3.fromValues(0, 1, 0),
  },
  // Negative Z
  {
    start: vec3.fromValues(0.5, -0.5, -0.5),
    right: vec3.fromValues(-1, 0, 0),
    up: vec3.fromValues(0, 1, 0),
  },
];

Face meshes

Now that we have our face definitions, let’s turn them into a mesh that we can render. We’ll write a function that takes a face definition, a width and height, and the number of steps to take across the face in both directions. The return value will be a list of vertices representing the triangles composing the face.

function grid(
  start: vec3,
  right: vec3,
  up: vec3,
  width: number,
  height: number,
  widthSteps: number,
  heightSteps: number
) {
  // We'll store our vertices here.
  const positions: vec3[] = [];

  // Traverse the face.
  for (let x = 0; x < widthSteps; x++) {
    for (let y = 0; y < heightSteps; y++) {
      // Lower left corner of this quad.
      const pa = vec3.scaleAndAdd(vec3.create(), start, right, (width * x) / widthSteps);
      vec3.scaleAndAdd(pa, pa, up, (height * y) / heightSteps);

      // Lower right corner.
      const pb = vec3.scaleAndAdd(vec3.create(), pa, right, width / widthSteps);

      // Upper right corner.
      const pc = vec3.scaleAndAdd(vec3.create(), pb, up, height / heightSteps);

      // Upper left corner.
      const pd = vec3.scaleAndAdd(vec3.create(), pa, up, height / heightSteps);

      // Store the six vertices of the two triangles composing this quad.
      positions.push(pa, pb, pc, pa, pc, pd);
    }
  }
  return positions;
}

Generating the box

Now that we have our face definitions and a means of generating a mesh for each, building the whole box is straightforward:

for (const face of faces) {
  const positions = grid(face.start, face.right, face.up, 1, 1, 16, 16);
  faceGeometries.push({ positions });
}

Sizing the box

Of course, we don’t always want a unit cube! Let’s scale our box according to some size:

// Define a size and resolution.
const size = vec3.fromValues(1, 1.25, 1.5);
const resolution = 16;

for (const face of faces) {
  // Shift the start to accommodate the new size.
  const start = vec3.multiply(vec3.create(), face.start, size);

  // Calculate a width and height.
  const width = vec3.length(vec3.multiply(vec3.create(), face.right, size));
  const height = vec3.length(vec3.multiply(vec3.create(), face.up, size));

  // Use the scaled values when building each face.
  const positions = grid(start, face.right, face.up, width, height, resolution, resolution);
  faceGeometries.push({ positions });
}

Rounding the box

Alright, now for the fun part - making our box round. To keep things easy to conceptualize and visualize, let’s consider the 2D case first. Here’s how I think about it: Imagine you have a rectangle. Put a circle in it. Slide the circle around in the rectangle and note what happens at the corners and edges when the circle impacts the sides. At the edges, the circle can butt up against the side of the rectangle without issue. At the corners, however, the circle gets stuck and is prevented from filling the corners entirely. Conveniently, the arc the circle traces in the corner is precisely what we want to use for our rounded box!

So, here’s what we can do: For each vertex composing our rectangle, we move our circle as close to it as we can. Then we draw a line from the center of the circle to the vertex. Wherever that line intersects the circle is where we’ll move our vertex. Voilà - a rounded rectangle!

Here’s a visualization of the approach. Changing the radius changes the size of the circle, and, consequently, how rounded the rectangle is. Changing the resolution changes the number of vertices used to approximate the rounded rectangle.

The 3D case is pretty much the same - instead of a circle in a rectangle, we’ll put a sphere in a 3D box. The following function takes a vertex, the box size, and the radius of our rounded edges and corners. It returns a new position and the normal at that point (which we get for free when we calculate the position).

function roundedBoxPoint(point: vec3, size: vec3, radius: number) {
  // Calculate the min and max bounds of the sphere center.
  const boundMax = vec3.multiply(vec3.create(), size, vec3.fromValues(0.5, 0.5, 0.5));
  vec3.subtract(boundMax, boundMax, [radius, radius, radius]);
  const boundMin = vec3.multiply(vec3.create(), size, vec3.fromValues(-0.5, -0.5, -0.5));
  vec3.add(boundMin, boundMin, [radius, radius, radius]);

  // Clamp the sphere center to the bounds.
  const clamped = vec3.max(vec3.create(), boundMin, point);
  vec3.min(clamped, boundMax, clamped);

  // Calculate the normal and position of our new rounded box vertex and return them.
  const normal = vec3.normalize(vec3.create(), vec3.subtract(vec3.create(), point, clamped));
  const position = vec3.scaleAndAdd(vec3.create(), clamped, normal, radius);
  return {
    normal,
    position,
  };
}

Now we can use our new function when we generate our box face meshes:

// Define a size, radius, and resolution.
const size = vec3.fromValues(1, 1.25, 1.5);
const resolution = 16;
const radius = 0.25;

for (const face of faces) {
  const start = vec3.multiply(vec3.create(), face.start, size);
  const width = vec3.length(vec3.multiply(vec3.create(), face.right, size));
  const height = vec3.length(vec3.multiply(vec3.create(), face.up, size));
  const positions = grid(start, face.right, face.up, width, height, 16, 16);

  // Move each vertex to its rounded position.
  for (const position of positions) {
    const rounded = roundedBoxPoint(position, size, 0.25);
    vec3.copy(position, rounded.position);
  }
  faceGeometries.push({ positions });
}

A more efficient mesh

We’re wasting a lot of vertices in our mesh, but we can pare that down a bit. Notice that in the center of each face, there’s a large flat rectangular region that could be a single quad. And while the corners have curvature along two axes, the edges have curvature along only one, so we can use long skinny quads there instead of needlessly dividing them along their length. Take a look at the following diagram: the four corners have full subdivision, the four edges have reduced subdivison, and the center has only a single large quad:

Because we cleverly wrote our grid function to take a variable number of steps along each axis independently, we can reuse it as-is to generate our more efficient face mesh. We’ll need to determine a new “start” position for each section of the mesh, but we can recycle our “up” and “right” vectors:

for (const face of faces) {
  const { up, right, start } = face;
  const positions: vec3[] = [];
  const width = vec3.length(vec3.multiply(vec3.create(), right, size));
  const height = vec3.length(vec3.multiply(vec3.create(), up, size));

  // Calculate a start position for each section.
  const s0 = vec3.multiply(vec3.create(), start, size);
  const s1 = vec3.scaleAndAdd(vec3.create(), s0, right, radius);
  const s2 = vec3.scaleAndAdd(vec3.create(), s0, right, width - radius);
  const s3 = vec3.scaleAndAdd(vec3.create(), s0, up, radius);
  const s4 = vec3.scaleAndAdd(vec3.create(), s3, right, radius);
  const s5 = vec3.scaleAndAdd(vec3.create(), s3, right, width - radius);
  const s6 = vec3.scaleAndAdd(vec3.create(), s0, up, height - radius);
  const s7 = vec3.scaleAndAdd(vec3.create(), s6, right, radius);
  const s8 = vec3.scaleAndAdd(vec3.create(), s6, right, width - radius);

  // Generate a grid for each corner.
  positions.push(...grid(s0, right, up, radius, radius, resolution, resolution));
  positions.push(...grid(s2, right, up, radius, radius, resolution, resolution));
  positions.push(...grid(s6, right, up, radius, radius, resolution, resolution));
  positions.push(...grid(s8, right, up, radius, radius, resolution, resolution));

  // Then for the left and right edges.
  positions.push(...grid(s3, right, up, radius, height - 2 * radius, resolution, 1));
  positions.push(...grid(s5, right, up, radius, height - 2 * radius, resolution, 1));

  // Then the top and bottom edges.
  positions.push(...grid(s1, right, up, width - 2 * radius, radius, 1, resolution));
  positions.push(...grid(s7, right, up, width - 2 * radius, radius, 1, resolution));

  // And finally the middle face.
  positions.push(...grid(s4, right, up, width - 2 * radius, height - 2 * radius, 1, 1));

  // Move each vertex to its rounded position.
  for (const position of positions) {
    const rounded = roundedBoxPoint(position, size, 0.25);
    vec3.copy(position, rounded.position);
  }
  faceGeometries.push({ positions });
}

Normals

Finally, we’ll add some normals to our mesh. Since we get those for free when we calculate our rounded box positions, we can use them out of the box!

// Create an array to store our normals.
const normals: vec3[] = [];

for (const position of positions) {
  const rounded = roundedBoxPoint(position, size, 0.25);
  vec3.copy(position, rounded.position);

  // Store the normal.
  normals.push(rounded.normal);
}
faceGeometries.push({ positions, normals });

Final notes