Using Scenic Draft to recreate a sculpture

I recently visited the Guggenheim museum and among other things particularly like these kinds of sculptures by Carol Bove.

As I've recently built a library for rendering 3D scenes in a photographic style, let's try and build something similar from scratch using it (Scenic Draft).

Start with a cube

Everything here is going to be made of boxes, so let's get one on screen. box takes half-extents, so box([1.2, 1.2, 1.2]) is actually 2.4 units across. Turning it 45° about the vertical axis gives us a corner facing the camera, which should make it clearer. materials.brass is a preset material close to the inspiration.

const one: Draft = draft(
  box([1.2, 1.2, 1.2])
    .rotateY(Math.PI / 4)
    .paint(materials.brass),
  backgrounds.sunset,
)

Cut it into a frame

The sculpture isn't made of solid cubes — it's made of edges. We can get there by subtracting three square tunnels, one along each axis. Each cutter is longer than the cube on one axis (so it passes all the way through) and thinner on the other two. Whatever survives all three cuts is exactly the material near two faces at once — the twelve edges.

const two: Draft = draft(
  box([1.2, 1.2, 1.2])
    .subtract(box([1.4, 1, 1]))
    .subtract(box([1, 1.4, 1]))
    .subtract(box([1, 1, 1.4]))
    .rotateY(Math.PI / 4)
    .paint(materials.brass),
  backgrounds.sunset,
)

Make it a function

Those numbers were hand-picked for one size of cube, which isn't much use when we want a lot of them. Pulling it into a function and expressing the cutters as fractions of the size gives us a frame we can ask for at any scale.

The 1.01 is deliberate: it makes the cutter poke out slightly rather than ending flush with the surface. Two coincident faces in a distance field give you a nasty flickering seam, and a 1% overshoot should avoidissues. The 0.85 sets how chunky the edges are — smaller means fatter bars. We could, but haven't parameterised that, a good first step if you want to take this further.

const buildOutlinedBox = (size: number) =>
  box([size, size, size])
    .subtract(box([size * 1.01, size * 0.85, size * 0.85]))
    .subtract(box([size * 0.85, size * 1.01, size * 0.85]))
    .subtract(box([size * 0.85, size * 0.85, size * 1.01]))
    .paint(materials.brass)

const three: Draft = draft(buildOutlinedBox(1.2), backgrounds.sunset)

A row of them

Now for the interesting part. Rather than building a list of cubes and positioning each one, repeat folds space itself, so a single frame is drawn everywhere on a grid at no real cost.

The first argument is the cell size per axis and the second is how many cells to allow either side of the centre — so [2, 0, 0] gives us five in a row along x, and one cell (i.e. no repetition) on y and z. The spacing of 2.05 against a cube that is 2 units across leaves a small gap between neighbours.

const buildOutlinedBoxAt = (size: number, position: [number, number, number]) =>
  box([size, size, size])
    .subtract(box([size * 1.01, size * 0.85, size * 0.85]))
    .subtract(box([size * 0.85, size * 1.01, size * 0.85]))
    .subtract(box([size * 0.85, size * 0.85, size * 1.01]))
    .translate(position)
    .paint(materials.brass)

const four: Draft = draft(
  buildOutlinedBoxAt(1, [0, 0, 0]).repeat([2.05, 1, 1], [2, 0, 0]),
  backgrounds.sunset,
)

Stack it in three dimensions

Now let's add spacing on every axis and a count of one either side: 3 × 3 × 3, twenty-seven frames, from one description. This is starting to look more like the sculpture.

const five: Draft = draft(
  buildOutlinedBoxAt(1, [0, 0, 0]).repeat([2.05, 2.05, 2.05], [1, 1, 1]),
  backgrounds.sunset,
)

Put it on a plinth

The pieces in the museum sit on solid bases. We can do this by union-ing a large box underneath the stack. The grid reaches down to -3.05, and a box of half-extent 3.07 centred at -6.14 has its top at -3.07 — just under the bottom row.

The trailing paint looks like it would turn the whole thing to concrete, but the innermost paint wins: the frames were already painted brass inside buildOutlinedBoxAt, so only the unpainted plinth picks up the concrete. Finally we'll customise the camera to get a better angle: setting the view position to [2, 5, 10] fits it in nicely.

const six: Draft = draft(
  buildOutlinedBoxAt(1, [0, 0, 0])
    .repeat([2.05, 2.05, 2.05], [1, 1, 1])
    .union(box([3.07, 3.07, 3.07]).translate([0, -6.14, 0]))
    .paint(materials.concrete),
  backgrounds.sunset,
).withCamera(camera([2, 5, 10]))

Carol Bove's sculptures are more sophisticated of course, but all the basic ideas are here to get started with. A few more transformations and shapes and you'd be able to make something quite similar to the inspiration.

Taking it further

You can experiment with this Code Sandbox version.

Some suggestions:

  • Make the relative (or absolute) thickness of the box a parameter
  • Add in a light source; Scenic Draft supports both a customised sun and emissive materials. A light source in the center or nearby would give dramatic shadows.
  • Add glass. Scenic Drafts has rich support for glass or other transparent materials, including refraction.