home
Rendering a bezier surface

Previously I wrote a blog post about how to interpolate a bezier curve. A bezier curve helps us to create smooth lines, surfaces, type faces. A bezier surface is set of bezier curves that is used for imaging a part of something. Before rendering a bezier surface, I will mention about how to render something with Python programming language. I will demonstrate it by rendering a set of cubes, then I will create marbles that made of cubes. A marble will stay at position of an interpolated point of a bezier curve. I will create an optimized cube for making the drawing process easier.

def create_cube(x, y, z, rotate_horizontal, rotate_vertical):
  # only up to horizontally 90 degrees implemented in this example
  if rotate_vertical > 0 and rotate_vertical < 90 and rotate_horizontal < 90 and rotate_horizontal > 0:
    return [
      [
        20, # this is in percent
        [x+0.5, y+0.5, z-0.5],
        [x+0.5, y+0.5, z+0.5],
        [x+0.5, y-0.5, z+0.5],
        [x+0.5, y-0.5, z-0.5]
      ],
      [
        5,
        [x-0.5, y+0.5, z-0.5],
        [x+0.5, y+0.5, z-0.5],
        [x+0.5, y+0.5, z+0.5],
        [x-0.5, y+0.5, z+0.5]
      ],
      [
        30,
        [x-0.5, y+0.5, z+0.5],
        [x+0.5, y+0.5, z+0.5],
        [x+0.5, y-0.5, z+0.5],
        [x-0.5, y-0.5, z+0.5]
      ]
    ]

The function returns diamond shapes. Set of diamond shapes, ordered in correct way, will be painted to an image file in Python or to a canvas on a browser. Vertically minus degrees are facing upwards. The visible faces must be set in correct order. It is possible to do that by ordering faces by their average z-index, but I prefer not to complicate that process further. Z-sort will be used for sorting the cubes.

import math

def rotate(x, y, deg):
  cos = math.cos(math.radians(deg))
  sin = math.sin(math.radians(deg))
  return [cos * x - sin * y, cos * y + sin * x]

def rotate_xyz(x, y, z, rh, rv):
  xn, zn = rotate(x, z, rh)
  yn, zn = rotate(y, zn, rv)
  return [xn, yn, zn]

A point in three-d should be rotated first. The values x-y will be the position on canvas. On canvas, the when the y is getting higher, it means it is being drawn to the end of canvas in vertical. So the height should be inreversed. I also scale them with a multipier in order to see the cube's borders. At the end, the scale may be minimized as less as possible. So the cubes won't be visible in result.

def darken(r, g, b, percent):
  rblacks = int(r * (percent/100))
  gblacks = int(g * (percent/100))
  bblacks = int(b * (percent/100))
  return (r - rblacks, g - gblacks, b - bblacks)

A darken function like above will be needed in order to make a cube's faces visually differentiable.

from render import render

render(
  "render-1",
  [
    [0, 0, 0, 255, 255, 255]
  ],
  4,
  4,
  1024,
  40,
  20,
)

I am using Python's PIL library for drawing in two-d. The render will be looking like below when we run it.

from create_cube import create_cube
from darken import darken
 
from PIL import Image, ImageDraw
 
def render(name, cubes, width, height, unit_scale, rotate_horizontal, rotate_vertical, save=True):
  width *= unit_scale
  height *= unit_scale
  image = Image.new("RGB", (width, height), (255, 255, 255))
  draw = ImageDraw.Draw(image)
  for x, y, z, r, g, b in cubes:
    cube = create_cube(x, y, z, rotate_horizontal, rotate_vertical)
    for darken_percent, *diamond in cube:
      points = []
      for xx, yy, zz in diamond:
        xn, yn, zn = rotate_xyz(xn * unit_scale, yn * unit_scale, zn * unit_scale, rotate_horizontal, rotate_vertical)
        x = w/2 + xn
        y = h/2 + (yn * -1)
        points.append((x, y))
      draw.polygon(points, darken(r, g, b, darken_percent))
  if save:
    image.save(open("{}.png".format(name), "wb"), "PNG")
  return image

The unit of width and height will be cubes.

/examples/render-1.png

Unit scale is too much for demonstration purpuse. It will be very less when using the cubes to create something that looks like not made of cubes. A bezier surface will be rendered with balls that mades of cubes.

def create_ball(r, origin_x=0, origin_y=0, origin_z=0):
  ball = []
  for x in range(-r, r):
    for y in range(-r, r):
      for z in range(-r, r):
        if x**2 + y**2 + z**2 < r**2:
          ball.append([origin_x + x, origin_y + y, origin_z + z])
  return ball

It is easy to create a ball when we think a ball's radius is it's distance to the origin in a three-d space. The ball will be created in that way, starting at 0, 0, 0 origin.

from render import render
from ball import create_ball

cubes = []

for x, y, z in create_ball(128):
 cubes.append([x, y, z, 255, 255, 255])

render(
  "render-2-1",
  cubes,
  1024,
  1024,
  4,
  40,
  20,
)

The result will be this.

/examples/render-2.png

A bezier surface is made of four bezier curves. Lets try to render a single bezier curve and complete the render process first.

from render import render
from ball import create_ball
from bezier import bezier

cubes = []

curve = [
  [-60, 0, -80],
  [0, 0, -80],
  [0, 0, 60],
  [60, 0, 60],
]

balls = 200
for i in range(0, balls):
  px, py, pz = bezier(i/balls, curve[0], curve[1], curve[2], curve[3])
  for x, y, z in create_ball(10):
    cubes.append([px + x, py + y, pz + z, 255, 255, 255])

def zsort(cube):
  x, y, z, r, g, b = cube
  return (y, z, x)

cubes.sort(key=zsort)

render(
  "render-3",
  cubes,
  512,
  512,
  4,
  40,
  20,
)

Cubes are sorted in their rendering order after the bezier interpolation.

/examples/render-3.png

Each interpolated point in four bezier curves, will be used for creating another curve. So in that way it will be possible shape a solid surface. A surface in this example has four curves and two properties, wide and fall. Wide is the one is longing, and fall is a curve that created by longing interpolated points.

from render import render
from ball import create_ball
from bezier import bezier

cubes = []

surface = [
  [[-40,0,-40],[4,0,-40],[40,0,-4],[40,0,40]],
  [[-40,0,-16],[-6,0,-16],[16,0,6],[16,0,40]],
  [[-40,-16,0],[-16,-16,0],[0,-16,16],[0,-16,40]],
  [[-40,-40,0],[-16,-40,0],[0,-40,16],[0,-40,40]]
]

scale_surface = 4

for curve in surface:
  for p in curve:
    p[0] *= scale_surface
    p[1] *= scale_surface
    p[2] *= scale_surface

wide = 40
fall = 40
ball_size = 4

for w in range(0, wide):
  wa = bezier(w/wide, surface[0][0], surface[0][1], surface[0][2], surface[0][3])
  wb = bezier(w/wide, surface[1][0], surface[1][1], surface[1][2], surface[1][3])
  wc = bezier(w/wide, surface[2][0], surface[2][1], surface[2][2], surface[2][3])
  wd = bezier(w/wide, surface[3][0], surface[3][1], surface[3][2], surface[3][3])
  for f in range(0, fall):
    px, py, pz = bezier(f/fall, wa, wb, wc, wd)
    for x, y, z in create_ball(ball_size):
      cubes.append([px + x, py + y, pz + z, 255, 255, 255])

def zsort(cube):
  x, y, z, r, g, b = cube
  return (y, z, x)

cubes.sort(key=zsort)

render(
  "render-4-3",
  cubes,
  1024,
  1024,
  4,
  40,
  20,
)

The result is looking like this.

/examples/render-4-3.png

Keeping the wide and tall parameters less, drawing lines between sparse points with their segments calculated by their distance may give better results. Lets try that.

from render import render
from ball import create_ball
from bezier import bezier, lerp

def distance(a, b):
  ax, ay, az = a
  bx, by, bz = b
  dx = max(ax, bx) - min(ax, bx)
  dy = max(ay, by) - min(ay, by)
  dz = max(az, bz) - min(az, bz)
  # the longest distance of axises will be used for interpolating the line
  return max(dx, dy, dz)

cubes = []

surface = [
  [[-40,0,-40],[4,0,-40],[40,0,-4],[40,0,40]],
  [[-40,0,-16],[-6,0,-16],[16,0,6],[16,0,40]],
  [[-40,-16,0],[-16,-16,0],[0,-16,16],[0,-16,40]],
  [[-40,-40,0],[-16,-40,0],[0,-40,16],[0,-40,40]]
]

scale_surface = 4

for curve in surface:
  for p in curve:
    p[0] *= scale_surface
    p[1] *= scale_surface
    p[2] *= scale_surface

wide = 4
fall = 4
ball_size = 4

for w in range(0, wide):
  wa = bezier(w/wide, surface[0][0], surface[0][1], surface[0][2], surface[0][3])
  wb = bezier(w/wide, surface[1][0], surface[1][1], surface[1][2], surface[1][3])
  wc = bezier(w/wide, surface[2][0], surface[2][1], surface[2][2], surface[2][3])
  wd = bezier(w/wide, surface[3][0], surface[3][1], surface[3][2], surface[3][3])
  wa_next = bezier((w+1)/wide, surface[0][0], surface[0][1], surface[0][2], surface[0][3])
  wb_next = bezier((w+1)/wide, surface[1][0], surface[1][1], surface[1][2], surface[1][3])
  wc_next = bezier((w+1)/wide, surface[2][0], surface[2][1], surface[2][2], surface[2][3])
  wd_next = bezier((w+1)/wide, surface[3][0], surface[3][1], surface[3][2], surface[3][3])
  for f in range(0, fall):
    px, py, pz = bezier(f/fall, wa, wb, wc, wd)
    for x, y, z in create_ball(ball_size):
      cubes.append([px + x, py + y, pz + z, 255, 255, 255])
    a = bezier(f/fall, wa, wb, wc, wd)
    b = bezier(f/fall, wa_next, wb_next, wc_next, wd_next)
    c = bezier((f+1)/fall, wa_next, wb_next, wc_next, wd_next)
    d = bezier((f+1)/fall, wa, wb, wc, wd)

    segments_wide = max(distance(a,b), distance(d, c))
    for i in range(0, int(segments_wide)):
      linea = lerp(i/segments_wide, a, b)
      lineb = lerp(i/segments_wide, d, c)
      segments_line = distance(linea, lineb)
      for j in range(0, int(segments_line)):
        px, py, pz = lerp(j/segments_line, linea, lineb)
        for x, y, z in create_ball(ball_size):
          cubes.append([px + x, py + y, pz + z, 255, 255, 255])

def zsort(cube):
  x, y, z, r, g, b = cube
  return (y, z, x)

cubes.sort(key=zsort)

render(
  "render-5",
  cubes,
  1024,
  1024,
  4,
  40,
  20,
)

Next step, instead of showing the faces with balls made of cubes, finding the shaded color of a face by calculating the average of rendered cubes that stays in the area of it. It works well, plus it is possible to scale or see them in different angles without a need to build and render again. It may be possible to find shading of a face, without need to render them with cubes first, but I am afraid of doing that math. This is a painter's way to draw something on canvas, I prefer this way of rendering. Math stuff is not easy as it is written on black-white blackboard, and most likely the result is going to be disappointment.

/examples/render-6.png

/examples/render-6-1.png

from render import render
from ball import create_ball
from bezier import bezier, lerp

from PIL import Image, ImageDraw

def distance(a, b):
  ax, ay, az = a
  bx, by, bz = b
  dx = max(ax, bx) - min(ax, bx)
  dy = max(ay, by) - min(ay, by)
  dz = max(az, bz) - min(az, bz)
  return max(dx, dy, dz)

WIDTH = 1024
HEIGHT = 1024
UNIT_SCALE = 2
ROTATE_H = 40
ROTATE_V = 20

surface = [
  [[-40,0,-40],[4,0,-40],[40,0,-4],[40,0,40]],
  [[-40,0,-16],[-6,0,-16],[16,0,6],[16,0,40]],
  [[-40,-16,0],[-16,-16,0],[0,-16,16],[0,-16,40]],
  [[-40,-40,0],[-16,-40,0],[0,-40,16],[0,-40,40]]
]

scale_surface = 4

for curve in surface:
  for p in curve:
    p[0] *= scale_surface
    p[1] *= scale_surface
    p[2] *= scale_surface

wide = 10
fall = 10
ball_size = 2
faces = []

for w in range(0, wide):
  wa = bezier(w/wide, surface[0][0], surface[0][1], surface[0][2], surface[0][3])
  wb = bezier(w/wide, surface[1][0], surface[1][1], surface[1][2], surface[1][3])
  wc = bezier(w/wide, surface[2][0], surface[2][1], surface[2][2], surface[2][3])
  wd = bezier(w/wide, surface[3][0], surface[3][1], surface[3][2], surface[3][3])
  wa_next = bezier((w+1)/wide, surface[0][0], surface[0][1], surface[0][2], surface[0][3])
  wb_next = bezier((w+1)/wide, surface[1][0], surface[1][1], surface[1][2], surface[1][3])
  wc_next = bezier((w+1)/wide, surface[2][0], surface[2][1], surface[2][2], surface[2][3])
  wd_next = bezier((w+1)/wide, surface[3][0], surface[3][1], surface[3][2], surface[3][3])
  for f in range(0, fall):
    a = bezier(f/fall, wa, wb, wc, wd)
    b = bezier(f/fall, wa_next, wb_next, wc_next, wd_next)
    c = bezier((f+1)/fall, wa_next, wb_next, wc_next, wd_next)
    d = bezier((f+1)/fall, wa, wb, wc, wd)
    faces.append([a, b, c, d, 255, 255, 255])

def zsort(cube):
  x, y, z, r, g, b = cube
  return (y, z, x)

faces_rendered = []

for a, b, c, d, *rgb in faces:
  cubes = []
  segments_wide = max(distance(a,b), distance(d, c))
  for i in range(0, int(segments_wide)):
    linea = lerp(i/segments_wide, a, b)
    lineb = lerp(i/segments_wide, d, c)
    segments_line = distance(linea, lineb)
    for j in range(0, int(segments_line)):
      px, py, pz = lerp(j/segments_line, linea, lineb)
      for x, y, z in create_ball(ball_size):
        cubes.append([px + x, py + y, pz + z, *rgb])
  cubes.sort(key=zsort)
  img = render(
    "face",
    cubes,
    WIDTH,
    HEIGHT,
    UNIT_SCALE,
    ROTATE_H,
    ROTATE_V,
    False
  )
  widthimg, heightimg = img.size
  pixels = img.load()
  shade_len = 0
  sum_r = 0
  sum_g = 0
  sum_b = 0
  for x in range(widthimg):
    for y in range(heightimg):
      rgb = pixels[x, y]
      if not (rgb[0] == 255 and rgb[1] == 255 and rgb[2] == 255):
        # white is the background color
        sum_r += rgb[0]
        sum_g += rgb[1]
        sum_b += rgb[2]
        shade_len += 1
  avg_r = sum_r / shade_len
  avg_g = sum_g / shade_len
  avg_b = sum_b / shade_len
  faces_rendered.append([a, b, c, d, [int(avg_r), int(avg_g), int(avg_b)]])

name = "render-6-1"
width = WIDTH * UNIT_SCALE
height = HEIGHT * UNIT_SCALE
image = Image.new("RGB", (width, height), (255, 255, 255))
draw = ImageDraw.Draw(image)
faces_on_canvas = []

for a, b, c, d, rgb in faces_rendered:
  points = []
  for x, y, z in [a, b, c, d]:
    xn, yn, zn = rotate_xyz(x * unit_scale, y * unit_scale, z * unit_scale, rh, rv)
    points.append((xn, yn, zn))
  faces_on_canvas.append([points, rgb])

def centerz_sort(face):
  points, rgb = face
  sumz = 0
  for x, y, z in points:
    z += sumz
  return sumz / len(points)
faces_on_canvas.sort(key=centerz_sort)
for face in faces_on_canvas:
  points, rgb = face
  points_wo_z = []
  for x, y, z in points:
    points_wo_z.append(( width/2 + x, height/2 + y*-1))
  draw.polygon(points_wo_z, tuple(rgb))

image.save(open("{}.png".format(name), "wb"), "PNG")

The render part is written in Python language. I edit the surfaces by an application that runs on a browser written with javascript when I am creating something. Here are some examples that I rendered in this way.

These are the examples with only cubes.

/examples/balconykarmelicka.png

/examples/sink.png

This is rendered with faces that painted by cubes.

/examples/potato.png

Good luck playing with that.