3D Wireframes in SVG (via Python)
Contents
Overview
SVG is great for line art. It scales nicely for high DPI displays without using much bandwidth. However SVG was not designed for 3D, so it does not provide mechanisms for applying perspective transformation or hidden surface elimination.
These limitations can be overcome for simple meshes by baking the perspective transformation, carefully ordering the paths within the SVG document, and paying attention to the winding direction of projected polygons.
In this post I will show how to use Python to generate vector art as seen at the top of the page, including the fully lit 3D Möbius tube.
To see the complete code that I used to generate all the SVG images on this page, go to this GitHub repo.
Designing the Rendering API
First we need to define the classic ingredients that you’ll find in almost any 3D renderer: classes for a viewport, camera, mesh, and scene:
import numpy
from typing import NamedTuple, Callable, Sequence
class Viewport(NamedTuple):
minx: float = -0.5
miny: float = -0.5
width: float = 1.0
height: float = 1.0
class Camera(NamedTuple):
view: numpy.ndarray
projection: numpy.ndarray
class Mesh(NamedTuple):
faces: numpy.ndarray
style: dict = None
shader: Callable[[int, float], dict] = None
class Scene(NamedTuple):
meshes: Sequence[Mesh]
The viewport defines the rectangular region within the final image that the camera projects to. This can be left set to its default values unless the image contains multiple panels.
The camera encompasses the view matrix and the projection matrix. We can use pyrr to generate these; it provides create_look_at
and create_perspective_projection
functions.
The mesh has a list of faces, a shader, and a style dictionary that gets applied to the SVG group that represents the mesh.
Wait, a shader in SVG? Well, in this context the “shader” is an optional callback function that consumes a mesh face and produces a style dictionary that gets applied to the projected polygon.
The mesh also contains a three-dimensional numpy array called faces whose shape is n⨯m⨯3 where n is the number of faces and m is the number of vertices per face (e.g. m=4 for quad meshes). The last axis has a length of 3 because the mesh consists of X Y Z coordinates.
Using the API
Before we get to the implementation of our SVG generator, let’s look at how we’d use the above classes to create an image that looks like this:
First, we need to come up with the face data for the octahedron. Simple enough:
def octahedron():
"""Construct an eight-sided polyhedron"""
f = sqrt(2.0) / 2.0
verts = numpy.float32([ ( 0, -1, 0), (-f, 0, f), ( f, 0, f), ( f, 0, -f), (-f, 0, -f), ( 0, 1, 0) ])
triangles = numpy.int32([ (0, 2, 1), (0, 3, 2), (0, 4, 3), (0, 1, 4), (5, 1, 2), (5, 2, 3), (5, 3, 4), (5, 4, 1) ])
return verts[triangles]
The above code snippet generates a 8⨯3⨯3 face array by dereferencing the vertex buffer using numpy’s “fancy indexing” feature.
Next, let’s set up the scene and invoke the renderer. Note the use of the aforementioned pyrr module to compute proper 4x4 matrices.
import pyrr
projection_matrix = pyrr.matrix44.create_perspective_projection(fovy=25, aspect=1, near=10, far=100)
view_matrix = pyrr.matrix44.create_look_at(eye=[25, -20, 60], target=[0, 0, 0], up=[0, 1, 0])
camera = Camera(view_matrix, projection_matrix)
style = dict(
fill='white', fill_opacity='0.75',
stroke='black', stroke_linejoin='round', stroke_width='0.005')
mesh = Mesh(15.0 * octahedron(), style=style)
view = View(camera, Scene([mesh]))
Engine([view]).render('octahedron.svg')
The stroke width is very small because our renderer normally sets up a SVG viewBox
with width and height of 1.0, spanning the region from [-0.5, -0.5] to [+0.5, +0.5].
Our implementation will use the svgwrite module, which accepts style dictionaries that map sensibly to SVG attributes. Note that we use round
for joining strokes, which is necessary for making a nice wireframe.
The render is kicked off using the Engine
class, which is the only API type that we haven’t mentioned yet. This brings us to the next section…
Implementation
The engine is responsible for consuming a scene description and generating an SVG file. At a high level it simply iterates though the views and creates a SVG group for each mesh:
import numpy
import svgwrite
class Engine:
def __init__(self, views):
self.views = views
def render(self, filename, size=(512,512), viewBox='-0.5 -0.5 1.0 1.0'):
drawing = svgwrite.Drawing(filename, size, viewBox=viewBox)
for view in self.views:
projection = numpy.dot(view.camera.view, view.camera.projection)
for mesh in view.scene.meshes:
drawing.add(self._create_group(drawing, projection, view.viewport, mesh))
drawing.save()
Note the usage of numpy.dot
to multiply one 4x4 matrix with another. The resulting matrix will be used to project the homogeneous coordinates onto the viewing plane.
The real meat of the renderer is in the engine’s _create_group
method, which consumes a mesh and produces an SVG group containing a list of polygons. Some of this code is similar to the OpenGL vertex pipeline.
def _create_group(self, drawing, projection, viewport, mesh):
faces = mesh.faces
shader = mesh.shader or (lambda face_index, winding: {})
default_style = mesh.style or {}
# Extend each point to a vec4, then transform to clip space.
faces = numpy.dstack([faces, numpy.ones(faces.shape[:2])])
faces = numpy.dot(faces, projection)
# Apply perspective transformation.
xyz, w = faces[:, :, :3], faces[:, :, 3:]
faces = xyz / w
# Apply the viewport transform to X and Y.
faces[:, :, 0:1] = (1.0 + faces[:, :, 0:1]) * viewport.width / 2
faces[:, :, 1:2] = (1.0 - faces[:, :, 1:2]) * viewport.height / 2
faces[:, :, 0:1] += viewport.minx
faces[:, :, 1:2] += viewport.miny
# Sort faces roughly from back to front.
z_centroids = -np.sum(faces[:, :, 2], axis=1)
for face_index in range(len(z_centroids)):
z_centroids[face_index] /= len(faces[face_index])
face_indices = np.argsort(z_centroids)
faces = faces[face_indices]
# Compute the winding direction of each polygon.
p0, p1, p2 = faces[:, 0, :], faces[:, 1, :], faces[:, 2, :]
windings = np.cross(p2 - p0, p1 - p0)
# Determine the style for each polygon and add it to the group.
group = drawing.g(**default_style)
for face_index, face in enumerate(faces):
style = shader(face_indices[face_index], windings[face_index])
if style is not None:
group.add(drawing.polygon(face[:, 0:2], **style))
return group
Some interesting things to note in the above implementation:
- Numpy is used to perform most of the math operations en masse, such as applying the 4x4 transform to each vertex in the mesh.
- Faces are sorted back-to-front in a very approximate way according to the Z centroid. (see my post about visibility sorting)
- The face winding direction (clockwise vs counterclockwise) is determined by evaluating a cross product and passing the result (positive vs negative) to the shading function.
- If the shading function returns
None
, the face is skipped. This can be used to achieve backface culling if desired.
Examples
Parametric Sphere and Klein Bottle
The above image was generated by evaluating parametric equations.
First we define a function that consumes LOD factors (slices and stacks) and a callback function that evaluates a parametric equation. It produces a quad mesh. In abbreviated form, the function looks like this:
def parametric_surface(slices, stacks, func):
verts = numpy.float32([])
for i in range(slices + 1):
for j in range(stacks):
...
verts.append(func(theta, phi))
faces = numpy.int32([])
for i in range(slices):
for j in range(stacks):
...
faces.append((a, b, c, d))
return verts[faces]
(For the complete code, see the GitHub repo.)
We also need to provide callback functions for the shapes of interest:
def sphere(u, v):
x = sin(u) * cos(v)
y = cos(u)
z = -sin(u) * sin(v)
return x, y, z
def klein(u, v):
u = u * 2
if u < pi:
x = 3 * cos(u) * (1 + sin(u)) + (2 * (1 - cos(u) / 2)) * cos(u) * cos(v)
z = -8 * sin(u) - 2 * (1 - cos(u) / 2) * sin(u) * cos(v)
else:
x = 3 * cos(u) * (1 + sin(u)) + (2 * (1 - cos(u) / 2)) * cos(v + pi)
z = -8 * sin(u)
y = -2 * (1 - cos(u) / 2) * sin(v)
return x, y, z
Parametric Sphere with Thick Borders
Note that the above wireframe has varying width. The trick is to completely avoid using stroke. Instead we vary the fill style of each face by examining the divisibility of the face index.
slices, stacks, radius = 64, 64, 12
faces = radius * parametric_surface(slices, stacks, sphere)
antialiasing = "auto" # use 'crispEdges' to fix cracks
def shader(face_index, winding):
slice = int(face_index / 64)
stack = int(face_index % 64)
if slice % 3 == 0 or stack % 3 == 0:
return dict(fill='black', fill_opacity='1.0', stroke='none', shape_rendering=antialiasing)
return dict(fill='white', fill_opacity='0.75', stroke='none', shape_rendering=antialiasing)
scene = Scene(Mesh(faces, shader))
Engine([View(camera, scene)]).render('parametric_sphere.svg')
Dashed Lines for Hidden Faces
The above scene culls away some of the faces to reveal the inside of the mesh. We draw the sphere in two passes: first backfacing triangles, then frontfacing triangles.
def backface_shader(face_index, winding):
if winding >= 0: return None
return dict(
fill='#7f7fff', fill_opacity='1.0',
stroke='black', stroke_linejoin='round',
stroke_width='0.001', stroke_dasharray='0.01')
def frontface_shader(face_index, winding):
if winding < 0 or faces[face_index][0][2] > 0.9: return None
return dict(
fill='#7fff7f', fill_opacity='0.6',
stroke='black', stroke_linejoin='round',
stroke_width='0.003')
scene = svg3d.Scene()
scene.add_mesh(svg3d.Mesh(faces, backface_shader))
scene.add_mesh(svg3d.Mesh(faces, frontface_shader))
svg3d.Engine([svg3d.View(camera, scene)]).render('sphere_shell.svg')
Diffuse and Specular Lighting
Since the shading callback is given a face index, it can look at the original face and compute a facet normal. This allows us to generate reasonable lighting. Not exactly photorealistic but this is vector art! Here’s the shader I used for the above effect. Note that it culls away backfaces to help optimize the SVG a bit.
def frontface_shader(face_index, winding):
if winding < 0: return None
face = eyespace_faces[face_index]
p0, p1, p2 = face[0], face[1], face[2]
normal = pyrr.vector.normalize(pyrr.vector3.cross(p1 - p0, p2 - p0))
df = max(0, numpy.dot(normal, LightDir))
sf = pow(max(0, numpy.dot(normal, Hhat)), Shininess)
color = df * numpy.float32([1, 1, 0]) + sf * numpy.float32([1, 1, 1])
color = numpy.power(color, 1.0 / 2.2)
return dict(
fill=rgb(*color), fill_opacity='1.0',
stroke='black', stroke_width='0.001')
Let’s wrap up with one more example of lighting:
The above shape is another parametric surface, similar to the Klein bottle and sphere. The parametric callback looks like this:
sign = numpy.sign
def mobius_tube(u, v):
R = 1.5
n = 3
u = u * 2
x = (1.0*R + 0.125*sin(u/2)*pow(abs(sin(v)), 2/n)*sign(sin(v)) + 0.5*cos(u/2)*pow(abs(cos(v)), 2/n)*sign(cos(v)))*cos(u)
y = (1.0*R + 0.125*sin(u/2)*pow(abs(sin(v)), 2/n)*sign(sin(v)) + 0.5*cos(u/2)*pow(abs(cos(v)), 2/n)*sign(cos(v)))*sin(u)
z = -0.5*sin(u/2)*pow(abs(cos(v)), 2/n)*sign(cos(v)) + 0.125*cos(u/2)*pow(abs(sin(v)), 2/n)*sign(sin(v))
return x, y, z
Thanks for reading this post! Some references:
- My ancient post on parametric surfaces.
- Another old Python article of mine (sympy).
- The pyrr module.
- The svgwrite module.
- The GitHub repo for the examples on this page.