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.

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.

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.

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.

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.


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.


This is rendered with faces that painted by cubes.

Good luck playing with that.