Recipes for 3D Materials

Red line illustration of a person with glassesDave Pagurek
August 25, 2026Engineering
Close up on a semi-transparent rounded box

Sometimes when creating motion graphics, you end up dealing with 3D scenes. You then have to make a bunch of decisions about how to stage the scene, balancing a degree of realism with a degree of heightened reality to add punch. Those may not be decisions you're familiar with if you have been using code for more abstract visuals.

It's a pretty deep topic that bridges other fields, like photography, but there are a few patterns that will get you to a good starting place that I keep using in my own work.

Studio lighting

The standard lighting setup you'll see referenced both in photography and 3D graphics is three-point lighting, so named because it involves three lights:

  • The key light is in front and pointing at your subject. This will be the brightest light so it's the one that controls the look the most. But it's not enough on its own!
  • The fill light is used to make the shadows from the key light less harsh. To accomplish that, it's aimed at the part of your subject that's in shadow: if the key light is shining from the right, you'd want the fill to shine from the left. We also don't want the shadows to fully disappear, just be less harsh, so this light will be fairly dim.
  • The rim light or back light shines from the back of your subject. If this were physical photography, you'd also hide it behind the subject where it isn't visible in the frame. From the front, it ends up only lighting the sides of the subject, creating a rim of light around it. That outline helps visually separate the subject from the background.

Here's a demo of how that can look in p5.js code, using a directionalLight for each one:

let useKey
let useFill
let useRim
function setup() {
  createCanvas(200, 200, WEBGL)
  noStroke()
  useKey = createCheckbox('Key light', true)
  useFill = createCheckbox('Fill light', true)
  useRim = createCheckbox('Rim light', true)
}

function draw() {
  background(0)
  orbitControl()
  fill(255)
  specularMaterial(100)
  shininess(100)
  if (useKey.checked()) directionalLight(255, 255, 255, -1, 0, -1)
  if (useFill.checked()) directionalLight(50, 50, 50, 1, 0, -1)
  if (useRim.checked()) directionalLight(255, 255, 255, 0.4, 0, 1)
  sphere(80)
}

Having stuff to reflect

It's often not sufficient to just light an object like that. For reflective objects especially, you have to pay attention to what gets reflected. In the example above, each directional light made a little circular highlight on the sphere. It's hard to create a convincing metal material when all you have to reflect are those little highlights. Supposedly the one on the right is metal, but does it look metal?

async function setup() {
  createCanvas(200, 200, WEBGL)
}

function draw() {
  background(100)
  orbitControl()
  fill(200)
  noStroke()
  specularMaterial(100)
  shininess(200)
  directionalLight(255, 255, 255, -1, 0, -1)
  ambientLight(150)

  push()
  translate(-50, 0)
  sphere(40)
  pop()

  push()
  translate(50, 0)
  fill(200, 155, 50)
  metalness(100)
  sphere(40)
  pop()
}

There is another tool provided by p5 that helps here: imageLight(). It lets you take in a sphere map image of an environment and use that to cast light into a scene. A sphere map is kind of like a flat map of the world: it's a 3D sphere unrolled and stretched to fit onto a rectangle. But rather than capturing the surface of an object like the Earth, it is capturing the light coming in from outside from all directions, as if you were inside a giant sphere shining light at you. Star maps are essentially the same format.

These are possible to capture yourself, or there are websites like Poly Haven that have many for you to download and use. Here's one such image:

When you load that as an image in p5 and apply it to the scene via imageLight(), the same geometry and materials create a much more discernible result. Click and drag below to rotate the scene!

let env
async function setup() {
  createCanvas(200, 200, WEBGL)
  env = await loadImage('https://res.cloudinary.com/buttercreatives/image/upload/v1787519448/admin/blog/studio.jpg')
}

function draw() {
  panorama(env)
  orbitControl()
  fill(200)
  noStroke()
  specularMaterial(100)
  shininess(200)
  imageLight(env)
  ambientLight(150)

  push()
  translate(-50, 0)
  sphere(40)
  pop()

  push()
  translate(50, 0)
  fill(200, 155, 50)
  metalness(100)
  sphere(40)
  pop()
}

The lesson here is that it requires a certain amount of detail in reflections to make visuals convincing. Lighting design is called lighting design for a reason!

Tools for lighting design

There's not much flexibility in picking images from the web. So, sticking with the theme that code offers the most creative control and flexibility, we built a library called p5.env to help generate these environment maps dynamically in JavaScript. There are a number of shapes you can mix in, where you give it the angle they are visible at (similar to directionalLight() but opposite: the direction from you to the light source, not from it to you) and their color, and then it will work with the roughnesses and colors of your material system.

Now we can design our own lighting setups, like placing a large backlight, adding a window for more unique reflections, and a blinking red LED somewhere behind the camera. (When it's done with code, you can add animation like that!)

let envMaterial, pano
function setup() {
  createCanvas(200, 200, WEBGL)
  envMaterial = buildEnvMaterial(envFn)
  pano = buildEnvPanorama(envFn)
}

function envFn() {
  envColor.begin()
  let grad = envGradient(
    envColor.dir,
    [0, 0, -1],
    envColor.blur,
    { t: PI * 0.2,  color: vec3(1.5) },
    { t: PI * 0.4,  color: vec3(0.3) },
  )
  const l = envLight(grad, envColor.dir, envColor.blur)
  l.mix(
    l.window(normalize([1, -0.5, 0.2]), [PI*0.2, PI*0.2], [2, 2], PI*0.05),
    vec3(1.4)
  )
  l.mix(l.circle([0, 1, 0], PI * 0.5), vec3(0.1))
    l.mix(
    l.circle(normalize([-0.5, -0.5, 1]), PI * 0.01),
    [10, 0.5, 0.5] * step(0, sin(millis() * 0.005))
  )
  finalColor = l.get()
  envColor.set(l.get())
  envColor.end()
}

function draw() {
  orbitControl()
  pano(PI*0.4)
  shader(envMaterial)
  noStroke()
  ambientLight(100)

  push()
  translate(-50, 0)
  fill(255)
  specularMaterial(20)
  shininess(200)
  sphere(40)
  pop()

  push()
  translate(50, 0)
  fill(0)
  specularMaterial(200, 155, 50)
  shininess(200)
  sphere(40)
  pop()
}

Rounded edges make nice reflections

So far we've just been working with spheres. What about other shapes?

We were recently working on a project that involved making a 3D cassette tape case. Let's say we used a default p5 box() for that. Even with a shader adding rim lighting, it still looks unrealistically flat:

let glass
function setup() {
  createCanvas(200, 200, WEBGL)

  glass = buildMaterialShader(() => {
    pixelInputs.begin()
    pixelInputs.color.a *= lerp(1 - abs(pixelInputs.normal.z), 1, 0.5)
    pixelInputs.emissiveMaterial += [1, 1, 1] * pow(1 - abs(pixelInputs.normal.z), 2)
    pixelInputs.end()
  })
}

function draw() {
  background(0)
  orbitControl()
  noStroke()
  ambientLight(50)
  directionalLight(255, 255, 255, 0, 1, -1)
  directionalLight(255, 255, 255, -1, 0.5, -0.2)
  fill(255, 100)
  specularMaterial(255)
  shininess(200)
  shader(glass)
  rotateY(sin(millis() * 0.001) * PI * 0.1)
  rotateX(sin(millis() * 0.001) * PI * 0.07)
  drawTwoSided(() => box(100, 70, 30))
}

Barely anything in real life has that sharp of an edge. An edge that thin at the tip can be fragile! Most things are slightly rounded. Importantly for our purposes, that slight rounding creates new angles on the surface that reflect light differently, giving smoother reflections.

Try dragging the slider below to change the rounding to see how it affects the reflections:

let boxGeom
let rounding
let glass
function setup() {
  createCanvas(200, 200, WEBGL)
  setAttributes({ antialias: true })
  rounding = createSlider(0, 20, 5, 0.1)
  rounding.input(updateBox)
  updateBox()

  glass = buildMaterialShader(() => {
    pixelInputs.begin()
    pixelInputs.color.a *= lerp(1 - abs(pixelInputs.normal.z), 1, 0.5)
    pixelInputs.emissiveMaterial += [1, 1, 1] * pow(1 - abs(pixelInputs.normal.z), 2)
    pixelInputs.end()
  })
}

function updateBox() {
  if (boxGeom) freeGeometry(boxGeom)
  boxGeom = roundedCube(100, 70, 30, rounding.value())
}

function draw() {
  background(0)
  orbitControl()
  noStroke()
  ambientLight(50)
  directionalLight(255, 255, 255, 0, 1, -1)
  directionalLight(255, 255, 255, -1, 0.5, -0.2)
  fill(255, 100)
  specularMaterial(255)
  shininess(200)
  shader(glass)
  rotateY(sin(millis() * 0.001) * PI * 0.1)
  rotateX(sin(millis() * 0.001) * PI * 0.07)
  drawTwoSided(() => model(boxGeom))
}

function roundedCube(w, h, d, r) {
  if (r <= 0) {
    return buildGeometry(() => box(w, h, d))
  }
  const slices = []
  const n = max(1, round(TWO_PI * r / 4 * 0.5))
  
  const times = (n) => {
    let arr = []
    for (let i = 0; i < n; i++) {
      arr.push(i)
    }
    return arr
  }
  
  const makeSlice = (z, sr) => {
    return [
      ...times(n+1).map(i => createVector(-w/2, -h/2, z).add(sr*cos(i/n*HALF_PI+PI), sr*sin(i/n*HALF_PI+PI), 0)),
      ...times(n+1).map(i => createVector(w/2, -h/2, z).add(sr*cos(i/n*HALF_PI+PI*3/2), sr*sin(i/n*HALF_PI+PI*3/2), 0)),
      ...times(n+1).map(i => createVector(w/2, h/2, z).add(sr*cos(i/n*HALF_PI), sr*sin(i/n*HALF_PI), 0)),
      ...times(n+1).map(i => createVector(-w/2, h/2, z).add(sr*cos(i/n*HALF_PI+PI/2), sr*sin(i/n*HALF_PI+PI/2), 0)),
    ]
  }
  const makeSliceCenters = () => {
    return [
      ...times(n+1).map(i => createVector(-w/2, -h/2, 0)),
      ...times(n+1).map(i => createVector(w/2, -h/2, 0)),
      ...times(n+1).map(i => createVector(w/2, h/2, 0)),
      ...times(n+1).map(i => createVector(-w/2, h/2, 0)),
    ]
  }
  const sliceCenters = makeSliceCenters()
  
  const numSlices = n * 2 + 1
  for (let i = 0; i < numSlices; i++) {
    const z = map(i, 0, numSlices-1, -r, r)
    const fromCenter = map(abs(i - (numSlices-1)/2), 0, (numSlices-1)/2, 0, 1)
    const sr = lerp(sqrt(1 - fromCenter * fromCenter) * r, r, 0.05)
    slices.push(makeSlice(z, sr))
    sliceCenters.push(makeSliceCenters())
  }
  const sliceNormals = slices.map((slice) => {
    return slice.map((v, i) => {
      return v.copy().sub(sliceCenters[i]).normalize()
    })
  })
  const ptsPerSlice = slices[0].length
  const sliceIndices = []
  const mid = floor(numSlices/2)
  for (let i = 0; i <= mid; i++) {
    const off = -d/2
    sliceIndices.push({ i, off, idx: sliceIndices.length })
  }
  for (let i = mid; i < slices.length; i++) {
    const off = d/2
    sliceIndices.push({ i, off, idx: sliceIndices.length })
  }
  
  const g = buildGeometry(() => {
    for (const { idx } of sliceIndices.slice(0, -1)) {
      beginShape(QUAD_STRIP)
      for (let j = 0; j <= ptsPerSlice; j++) {
        for (const iOff of [0, 1]) {
          const { i, off } = sliceIndices[idx + iOff]
          const slice = slices[i]
          const pt = slice[j % slice.length]
          const n = sliceNormals[i][j % slice.length]
          normal(n.x, n.y, n.z)
          vertex(pt.x, pt.y, pt.z + off)
        }
      }
      endShape()
    }
    for (const face of [slices.at(-1).slice()]) {
      beginShape()
      for (const pt of face) {
        normal(0, 0, Math.sign(pt.z))
        vertex(pt.x, pt.y, pt.z + Math.sign(pt.z) * d/2)
      }
      endShape(CLOSE)
    }
  })
  
  const other = buildGeometry(() => {
    for (const face of [slices[0]]) {
      beginShape()
      for (const pt of face) {
        normal(0, 0, Math.sign(pt.z))
        vertex(pt.x, pt.y, pt.z + Math.sign(pt.z) * d/2)
      }
      endShape(CLOSE)
    }
  })
  
  const off = g.vertices.length
  g.vertices.push(...other.vertices)
  g.vertexNormals.push(...other.vertexNormals)
  g.uvs.push(...other.uvs)
  g.faces.push(...other.faces.map(f => f.map(i => i + off).reverse()))
  g.vertexColors.push(...other.vertexColors)
  
  return g
}

Even at low but nonzero values, the slight rounding gives you some subtle highlights and shadows around the corners that give them a sense of dimensionality that isn't there with no rounding.

Material properties are not uniform

When you look straight down into water, you can see through it, but when you look out across a lake, it acts much more like a mirror. This is because the amount of light that reflects versus passes through changes based on the viewing angle. If the viewing angle just grazes the surface, it is more likely to reflect; if it is more head on, it is more likely to go through. The amount that this happens varies by the material, and there are some formulas for it commonly used in graphics. However, we don't need physical accuracy, just hinting at the effect is sufficient!

This was actually quietly included in the previous code example. Let's break down a few things going on in it:

  • I added in the fact that the material should be more see-through when viewed head on. For this, we can create a custom material in p5.strands and look at the surface normal at each pixel, which tells us the orientation of the face relative to the camera. The more the normal faces us (which is to say, the more it faces along the z axis), the more transparent it will be.
  • I also faked the effect of more reflectivity at shallow angles by adding to the material's emissive light the further away from the z axis it's facing. Rather than reflecting an actual light source, it's emitting it itself as if there's a source somewhere. The result is crisper edges.

Also, on a more technical level: transparency is a famously hard problem in graphics and every system picks their preferred set of compromises. The result of this is that by default, rendering assumes faces are solid, and the order you draw faces in will affect whether or not it bothers to draw back faces at all. We made a library called p5.transparency that gives you two functions: drawTransparent() and drawTwoSided(). drawTransparent() will sort objects behind-the-scenes to make sure the back-to-front ordering is correct; drawTwoSided() does the same plus additionally draws the back faces of the object first before the front faces for 3D things that actually have two sides. Turn off two-sided drawing to see how the back face subtly disappears.

let geom
let glass
let useTransmission, useReflection, useTwoSided
function setup() {
  createCanvas(200, 200, WEBGL)
  geom = roundedCube(100, 70, 20, 10)

  useTwoSided = createCheckbox('Draw two-sided', true)
  useTransmission = createCheckbox('See-through when head-on', true)
  useReflection = createCheckbox('Reflective shallow angles', true)

  glass = buildMaterialShader(() => {
    let transmission = uniformFloat(() => useTransmission.checked() ? 1 : 0)
    let reflection = uniformFloat(() => useReflection.checked() ? 1 : 0)
    pixelInputs.begin()
    if (transmission > 0) {
      // Slightly less opaque viewed head-on
      pixelInputs.color *= [1, 1, 1, lerp(1 - abs(pixelInputs.normal.z), 1, 0.75)]
    }
    if (reflection > 0) {
      // Emit light when not head-on to imitate reflection
      pixelInputs.emissiveMaterial += vec3(0.5) * pow(1 - abs(pixelInputs.normal.z), 2)
    }
    pixelInputs.end()
  })
}

function draw() {
  background(0)

  rotateX(PI * -0.05)
  rotateY(sin(millis() * 0.001)*PI*0.1)
  noStroke()
  orbitControl()
  ambientLight(50)
  directionalLight(255, 255, 255, 0, 1, -1)
  directionalLight(255, 255, 255, -1, 0.5, -0.2)
  fill(255, 100)
  specularMaterial(255)
  shininess(200)
  shader(glass)

  if (useTwoSided.checked()) {
    drawTwoSided(() => model(geom))
  } else {
    model(geom)
  }
}

function roundedCube(w, h, d, r) {
  if (r <= 0) {
    return buildGeometry(() => box(w, h, d))
  }
  const slices = []
  const n = max(1, round(TWO_PI * r / 4 * 0.5))
  
  const times = (n) => {
    let arr = []
    for (let i = 0; i < n; i++) {
      arr.push(i)
    }
    return arr
  }
  
  const makeSlice = (z, sr) => {
    return [
      ...times(n+1).map(i => createVector(-w/2, -h/2, z).add(sr*cos(i/n*HALF_PI+PI), sr*sin(i/n*HALF_PI+PI), 0)),
      ...times(n+1).map(i => createVector(w/2, -h/2, z).add(sr*cos(i/n*HALF_PI+PI*3/2), sr*sin(i/n*HALF_PI+PI*3/2), 0)),
      ...times(n+1).map(i => createVector(w/2, h/2, z).add(sr*cos(i/n*HALF_PI), sr*sin(i/n*HALF_PI), 0)),
      ...times(n+1).map(i => createVector(-w/2, h/2, z).add(sr*cos(i/n*HALF_PI+PI/2), sr*sin(i/n*HALF_PI+PI/2), 0)),
    ]
  }
  const makeSliceCenters = () => {
    return [
      ...times(n+1).map(i => createVector(-w/2, -h/2, 0)),
      ...times(n+1).map(i => createVector(w/2, -h/2, 0)),
      ...times(n+1).map(i => createVector(w/2, h/2, 0)),
      ...times(n+1).map(i => createVector(-w/2, h/2, 0)),
    ]
  }
  const sliceCenters = makeSliceCenters()
  
  const numSlices = n * 2 + 1
  for (let i = 0; i < numSlices; i++) {
    const z = map(i, 0, numSlices-1, -r, r)
    const fromCenter = map(abs(i - (numSlices-1)/2), 0, (numSlices-1)/2, 0, 1)
    const sr = lerp(sqrt(1 - fromCenter * fromCenter) * r, r, 0.05)
    slices.push(makeSlice(z, sr))
    sliceCenters.push(makeSliceCenters())
  }
  const sliceNormals = slices.map((slice) => {
    return slice.map((v, i) => {
      return v.copy().sub(sliceCenters[i]).normalize()
    })
  })
  const ptsPerSlice = slices[0].length
  const sliceIndices = []
  const mid = floor(numSlices/2)
  for (let i = 0; i <= mid; i++) {
    const off = -d/2
    sliceIndices.push({ i, off, idx: sliceIndices.length })
  }
  for (let i = mid; i < slices.length; i++) {
    const off = d/2
    sliceIndices.push({ i, off, idx: sliceIndices.length })
  }
  
  const g = buildGeometry(() => {
    for (const { idx } of sliceIndices.slice(0, -1)) {
      beginShape(QUAD_STRIP)
      for (let j = 0; j <= ptsPerSlice; j++) {
        for (const iOff of [0, 1]) {
          const { i, off } = sliceIndices[idx + iOff]
          const slice = slices[i]
          const pt = slice[j % slice.length]
          const n = sliceNormals[i][j % slice.length]
          normal(n.x, n.y, n.z)
          vertex(pt.x, pt.y, pt.z + off)
        }
      }
      endShape()
    }
    for (const face of [slices.at(-1).slice()]) {
      beginShape()
      for (const pt of face) {
        normal(0, 0, Math.sign(pt.z))
        vertex(pt.x, pt.y, pt.z + Math.sign(pt.z) * d/2)
      }
      endShape(CLOSE)
    }
  })
  
  const other = buildGeometry(() => {
    for (const face of [slices[0]]) {
      beginShape()
      for (const pt of face) {
        normal(0, 0, Math.sign(pt.z))
        vertex(pt.x, pt.y, pt.z + Math.sign(pt.z) * d/2)
      }
      endShape(CLOSE)
    }
  })
  
  const off = g.vertices.length
  g.vertices.push(...other.vertices)
  g.vertexNormals.push(...other.vertexNormals)
  g.uvs.push(...other.uvs)
  g.faces.push(...other.faces.map(f => f.map(i => i + off).reverse()))
  g.vertexColors.push(...other.vertexColors)
  
  return g
}

The same point about uniformity applies more generally too. The easiest thing to do in graphics is create smooth shapes. It takes more effort to do anything else, but most things in real life have imperfections!

Let's take the three-point lighting example from the start. Let's make it have more texture. You can use noise() inside of a p5.strands shader. We can use that to do some bump mapping, where we don't actually make the shape bumpy, but just vary the normal as if it were. You can do this by sampling what the bump height would be in a few spots, figuring out what the slope is, and then applying that to the normal:

let useRoughness
let rough
function setup() {
  createCanvas(200, 200, WEBGL)
  noStroke()
  useRoughness = createCheckbox('Rough texture', true)

  rough = buildMaterialShader(() => {
    let pos = sharedVec3()

    worldInputs.begin()
    pos = worldInputs.position * 0.2
    worldInputs.end()
    
    pixelInputs.begin()
    noiseDetail(2, 0.5)
    const eps = 0.05
    const strength = 0.05

    // Finite differences to approximate the noise gradient
    const h  = noise(pos.x,       pos.y,       pos.z      )
    const hx = noise(pos.x + eps, pos.y,       pos.z      )
    const hy = noise(pos.x,       pos.y + eps, pos.z      )
    const hz = noise(pos.x,       pos.y,       pos.z + eps)
    const grad = [
      (hx - h) / eps,
      (hy - h) / eps,
      (hz - h) / eps
    ]

    // Project gradient onto tangent plane, then tilt the normal by it
    const n = pixelInputs.normal
    const tangent = grad - n * dot(n, grad)
    pixelInputs.normal = normalize(n + tangent * strength)

    pixelInputs.end()
  })
}

function draw() {
  background(0)
  orbitControl()
  fill(255)
  specularMaterial(100)
  shininess(100)
  directionalLight(255, 255, 255, -1, 0, -1)
  directionalLight(50, 50, 50, 1, 0, -1)
  directionalLight(255, 255, 255, 0.4, 0, 1)
  
  push()
  if (useRoughness.checked()) shader(rough)
  sphere(80)
  pop()
}

Making stuff with it

Here are a few things we've made with custom lighting and materials.

This interactive block lets you control some surface imperfections on an extruded logo in the form of scratches. The scratches themselves are thresholded stretched noise. The tint on the material also changes color based on view direction.

Prism Logo by Butter

And here's the aforementioned cassette, which can swap out textures for the labels. All the geometry is slightly rounded, and lights have been set up specifically to get some bright reflections right as it rotates without getting in the way when the text is most readable.

Nothing like some transparent plastic.

p5.js isn't known for its material system, but it's changing with bump and roughness maps coming in the next version and the ability to use custom p5.strands shaders right now. It just takes a little bit of technique! I hope there are some snippets in here that can help get you started.