Back to Blog
3D Rendering Engine and Robot Kinematics Simulation

3D Rendering Engine and Robot Kinematics Simulation

A write-up on the robot kinematics simulation (and accidental software renderer) I made, exploring forward and inverse kinematics, Denavit-Hartenberg parameters, and practical applications on a physical robotic arm. Way too much math in this one :(

Pranav Sukesh
RoboticsKinematicsComputer GraphicsGame EngineSimulation

3D Rendering Engine

Objective

The 3D rendering engine, built in Python, utilizes pygame to render pixels on the screen and homogeneous transformation matrices to represent affine transformations in the ambient space. Used as the environment in which inverse kinematics with a robotic arm will be explored. Additionally supports dynamic mesh rendering and custom script support to subscribe to an update loop.

Posing Features

This section delineates the classes/components used in the engine to represent position and orientation in R3\mathbb{R}^3.

RotationMatrix Class

General Purpose

This class serves as a representation of rotation in the ambient space. Taking a roll, pitch, and yaw as parameters (stored internally as a 3x3 numpy matrix), the class provides the user with a packaged representation of rotations, which are a member of the Lie group SO(3)SO(3) (Special Orthogonal Group), the group of rotation matrices about the origin in R3\mathbb{R}^3.

Euler Angles

For ease of user instantiation, rotation matrices take a roll about the x-axis, a pitch about the y-axis, and a yaw about the z-axis, then convert them to a final rotation matrix by successively applying each axis of rotation. If the roll matrix is RxR_{x}, pitch RyR_{y}, and yaw RzR_{z}, the final rotation matrix is found as R=RzRyRx,R=R_{z}R_{y}R_{x}, Where the order of multiplication is standard convention for Euler angle representations. While Euler angles might suffer from gimbal lock in specific rotations, they are sufficient when considering a differential rotation, so they are a usable representation in this project. A more robust 3D engine might consider implementing quaternions instead to combat this issue.

Matrix Operations

Full support for matrix-matrix and matrix-vector multiplication, matrix inversion, and operator overloading for ease of usage in other mathematical operations (matrix multiplication included).

Vector3 Class

General Purpose

This class serves as a representation of position in the ambient space. Containing an xx, yy, and zz coordinate (internally stored as an numpy array), the class provides the user with a packaged representation of 3-vectors.

Homogeneous Coordinates

While a 3-vector is sufficient to represent position in R3\mathbb{R}^3, appending a ww component to the vector allows for affine transformations in conjunction with transformation matrices.

Vector Operations

Full support for 3-vector inner products, wedge products, and operator overloading for ease of usage in other mathematical operations (matrix multiplication included).

Skew Symmetric Matrices

In conjunction with the RotationMatrix class, the Vector3 class can represent itself as a rotation matrix through skew symmetric matrices. Defined as a matrix AA where AT=AA^T=-A, the skew symmetric matrix can also represent the cross product operator with a specific vector, defined as

[v]=[0zyz0xyx0][\mathbf{v}]= \begin{bmatrix} 0 & -z &y \\ z & 0 & -x \\ -y & x & 0 \end{bmatrix}

for some vector v=x,y,z\mathbf{v}= \langle x,y,z \rangle. More importantly, this matrix is a member of so(3)\mathfrak{so}(3), the Lie algebra of SO(3)SO(3) (it can be pictured as the tangent space of SO(3)SO(3) at the identity). Thus, because a Lie algebra can be taken to its corresponding Lie group through matrix exponentiation (logarithm for the reverse), we can compute the corresponding rotation matrix in SO(3)SO(3). For exponentiation, we utilize Rodrigues’s Formula: e[k^]θ=R=I+sinθk^+(1cosθ)k^2,e^{[\mathbf{\hat{k}}]\theta}=R = I + \sin\theta\, \hat{\mathbf{k}} + (1 - \cos\theta)\, \hat{\mathbf{k}}^2, letting us quickly obtain a rotation matrix RSO(3)R \in SO(3) equivalent to rotating θ\theta around the unit axis k^\mathbf{\hat{k}}.

Transform Class

General Purpose

This class serves as a representation of affine transformations in the ambient space. Taking advantage of both the RotationMatrix and Vector3 classes, the class provides the user with a packaged representation of transformation matrices, which are a member of the Lie group SE(3)SE(3) (Special Euclidean Group), the group of transformation matrices in R3\mathbb{R}^3.

Representation

Transformation matrices are defined as [Rp01]\begin{bmatrix} R & \mathbf{p} \\ 0 & 1 \end{bmatrix} where RSO(3)R \in SO(3) and pR3\mathbf{p} \in \mathbb{R}^3. By pre-multiplying by a transformation matrix, the rotation then translation is applied to the following homogenous 4-vector or following transformation matrix, similar to how pre-multiplication by a rotation matrix applies the rotation. The Transform class internally stores a transformation matrix, separating out positional and rotational information as the user requests it.

Child Hierarchy

Each transform possesses 1 parent and nn children, as well as a local transformation matrix representing its pose in the parent frame. When a transform’s position or rotation is updated, each child is recursively updated, effectively moving the child with the parent frame, along with any children of children, by applying this transformation to every child: Tchild=TparentTparent1TchildT_{child}'= T_{parent}'T_{parent}^{-1}T_{child} where primes denote the homogeneous transformation matrix after the updated pose, and all transformation matrices are in the global frame (standard basis). We take advantage of the fact that local coordinates do not change if a parent moves to preserve that value.

Twists

A twist is defined as ξ=[ωv]R6,\xi=\begin{bmatrix} \boldsymbol{\omega} \\ \mathbf{v} \end{bmatrix} \in\mathbb{R}^6, which represents an affine transformation with angular velocity ω\boldsymbol{\omega} and linear velocity v\mathbf{v}. A normalized twist, or screw SS, is defined as ξ=Sθ\xi=S\thetawhere θ\theta is some scalar displacement that represents motion along the screw axis (both rotation in radians or linear translations). In this implementation, twists and screws are utilized to achieve rotation and translation about a screw axis. To avoid using a 6-vector and for ease of computational implementation, ξ\xi can also be written as a matrix, a member of se(3)\mathfrak{se}(3) the Lie Algebra of SE(3)SE(3), represented as [ξ]=[[ω]v00][\xi]= \begin{bmatrix} [\boldsymbol{\omega}] & \mathbf{v} \\ 0 &0 \end{bmatrix} where [ω]so(3)[\boldsymbol{\omega}] \in \mathfrak{so}(3) and vR3\mathbf{v}\in\mathbb{R}^3. If normalized to be a screw, premultiplication by this matrix represents an infinitesimal affine transformation. similar to how skew-symmetric matrices represent infinitesimal rotations. Thus, we use these twists to apply an affine transformation on a transformation matrix through matrix exponentiation. In a screw matrix, v\mathbf{v} is expressed as the linear velocity in the plane of rotation defined by the bivector of rotation. We can calculate this linear velocity using a local transform’s offset from the point of rotation as follows: v=ω×q\mathbf{v}=-\boldsymbol{\omega} \times \mathbf{q} Where q\mathbf{q} is the offset from the point of rotation and ω\boldsymbol{\omega} is the angular velocity about the point of rotation.

Given a reference frame TSE(3)T \in SE(3), unit angular velocity ω^\boldsymbol{\hat{\omega}}, and unit linear velocity v^\mathbf{\hat{v}}, T=e[ξ]TT'=e^{[\xi]}\cdot T where e[ξ]=[e[ω^]θJ(θ)v01]e^{[\xi]}=\begin{bmatrix} e^{[\boldsymbol{\hat{\omega}}]\theta} & J(\theta)\mathbf{v} \\ 0 & 1 \end{bmatrix} and J(θ)J(\theta) is the left Jacobian of SO(3)SO(3), which is necessary to correct the velocity relative to the simultaneous rotation. This can be written in a closed form as J(θ)=I+1cosθθ[ω^]+θsinθθ[ω^]2.J(\theta)=I+\frac{1-\cos \theta}{\theta}[\boldsymbol{\hat{\omega}}]+\frac{\theta-\sin \theta}{\theta}[\boldsymbol{\hat{\omega}}]^{2}. The Transform class supports two abstracted transformations for ease of use when programming classes for a desired simulation: rotation around a specified axis by some angle and translation in a specified direction by some distance. Both methods utilize premultiplication by a twist matrix to cleanly update the stored transformation matrix in the class.

Adjoint Representation

When attempting to change the basis of a twist six-vector, we cannot merely pre-multiply by a transformation matrix due to the dimensional mismatch. Thus, we utilize the adjoint representation of TT instead. Given a transformation matrix TSE(3)T \in SE(3) with rotation component RSO(3)R \in SO(3) and position component pR3,\mathbf{p} \in \mathbb{R}^3, the adjoint representation AdTR6×6\mathrm{Ad}_T \in \mathbb{R}^{6 \times 6} is

AdT=[R0[p]RR].\mathrm{Ad}_T = \begin{bmatrix} R & 0 \\ [\mathbf{p}]R & R \end{bmatrix}.

Pre-multiplying by the adjoint representation in six dimensions is equivalent to premultiplication by a transformation in four dimensions, so using this, we can effectively apply a transformation to a twist six-vector.

Simulation Features

This section is an overview on the classes/components used in the engine to maintain an update loop that simulate real-time akin to reality.

Engine Class

General Purpose

This singleton class controls the update loop of the game engine, refreshing the state of the game every frame to discretely simulate real life. This class subscribes to Updater classes (see below) to discern what behaviors to invoke each frame. Additionally, it controls the engine frame rate (and delta time) and also maintains a reference to the SceneRenderer class that is responsible for redrawing the camera view.

Updater Class

General Purpose

This inheritable class is used to connect to the Engine’s update loop. Classes which extend this class are provided with different methods which the Engine subscribes to, allowing for dynamic gameplay. These methods include:

  • awake(): Called once upon instantiation of an Updater. Used for initial setup.

  • start(): Called once upon instantiation of an Updater after every awake() method is called during the frame. Used when initialization is dependent on other Updaters.

  • update(): Called once per frame for each Updater. Houses the general logic associated with an Updater that governs its behavior during the update loop.

  • late_update(): Called once per frame for each updater after every update() method is called during the frame. Used to delineate any behavior that is dependent on the processes that occur during the frame.

As a result, all objects that exist in the game view should be tied to an updater so they are recorded by the engine and renderer.

Component Structure

For ease of scalability, Updaters should act as the primary containers for any additional components that may exist on the object. For example, meshes, cameras, lights, and any custom-made component should all exist as a reference tied to an extended Updater class. In the scope of our robotic arm, we utilized a custom Updater class called CameraController to provide camera navigation to the user through keyboard input.

Rendering Features

This section covers the classes/components used to translate poses in R3\mathbb{R}^3 to screen space such that it can be viewed on a typical monitor.

Camera Class

General Purpose

The Camera class determines what is visible from the game view. While not an Updater class, instead acting as an additional component with an innate transform. The camera class maintains an aspect ratio, a FOV, a near-clip plane, and a far-clip plane to apply a perspective projection to determine what to render.

Perspective Projection

When computing the screen coordinate sR2\mathbf{s} \in \mathbb{R}^2 that corresponds to a homogeneous coordinate pR4\mathbf{p} \in \mathbb{R}^4, we utilize pre-multiplication by a perspective projection matrix MM. This matrix must account for the camera’s current orientation and position, then project a point between the near and far clip plane to screen coordinates. Thus, we separate PP into a view component VV and projection component PP such that M=PVR4×4.M=PV \in \mathbb{R}^{4\times 4}. Notably, this matrix does not directly take the homogeneous coordinate to the screen space, but first to a clip space vector cR4\mathbf{c} \in \mathbb{R}^4 such that Mp=c=[xcyczcwc].M\mathbf{p} = \mathbf{c} = \begin{bmatrix} x_c \\ y_c \\ z_c \\ w_c \end{bmatrix}. Through this clip space vector, we can then convert to a normalized device coordinate nR3\mathbf{n} \in \mathbb{R}^3 through the following nonlinear transformation: n=1wc[xcyczc]=[xnynzn].\mathbf{n}=\frac{1}{w_c}\begin{bmatrix} x_c \\ y_c \\ z_c \end{bmatrix} = \begin{bmatrix} x_n \\ y_n \\ z_n \end{bmatrix}. This transformation will result in a normalized device coordinate that is scaled to always range between 1-1 and 11. As a result, if the screen has height hh and width ww, s=[xn+12wyn+12h],\mathbf{s}=\begin{bmatrix} \frac{x_n+1}{2}w \\ \frac{y_n+1}{2}h \end{bmatrix}, providing us with a method to convert from homogeneous coordinates to screen space through simple operations. Note that the z component in this situation is not used, but in our implementation of this function, we additionally return znz_n to encode depth in normalized device coordinates.

Computing the View Matrix

To compute MM, we must define VV, a transformation matrix that when pre-multiplied, rotates a point to the camera’s orientation, then shifts the point such that the camera is at the origin, performing an affine transformation where the camera resides at the new origin.

To construct this, the camera provides its position pR3\mathbf{p}\in \mathbb{R}^3, as well as a target position tR3\mathbf{t}\in \mathbb{R}^3 that the camera is facing toward. Finally, it also provides a vector u^R3\mathbf{\hat{u}}\in \mathbb{R}^3 pointing in the "up" direction as seen by the global frame (if the camera were facing the z-axis, for instance, u^\mathbf{\hat{u}} would point in the y-axis). Through this, we can use a partial version of the Gram-Schmidt orthonormalization process to generate an orthonormal basis of the camera’s reference frame. First, we calculate the forward vector f^R3\mathbf{\hat{f}} \in \mathbb{R}^3 such that f^=tptp,\mathbf{\hat{f}}=\frac{\mathbf{t}-\mathbf{p}}{||\mathbf{t}-\mathbf{p}||}, the right vector r^R3\mathbf{\hat{r}} \in \mathbb{R}^3 such that r^=u×f^u×f^,\mathbf{\hat{r}}=\frac{\mathbf{u}\times\mathbf{\hat{f}}}{||\mathbf{u}\times\mathbf{\hat{f}}||}, and a new up vector u^R3\mathbf{\hat{u'}} \in \mathbb{R}^3 (we must calculate a new up vector to preserve orthonormality in all cases due to occasional floating point error) such that u^=f^×r^.\mathbf{\hat{u'}}=\mathbf{\hat{f}}\times\mathbf{\hat{r}}. It is important to note that we could provide the right vector and undergo the same process, but we begin with the up vector to preserve a convention. Thus, we can construct VV as V=[r^Tr^pu^Tu^pf^Tf^p01]R4×4.V=\begin{bmatrix} \mathbf{\hat{r}}^T && -\mathbf{\hat{r}} \cdot \mathbf{p}\\ \mathbf{\hat{u'}}^T && -\mathbf{\hat{u}} \cdot \mathbf{p}\\ \mathbf{-\hat{f}}^T && \mathbf{\hat{f}} \cdot \mathbf{p}\\ 0 && 1 \end{bmatrix} \in \mathbb{R}^{4 \times 4}. Notably, we must negate f^\mathbf{\hat{f}} to represent the vector direction from the target to the camera, as f^\mathbf{\hat{f}} represents the opposite. Additionally, the final column of VV encodes the translation necessary to shift the camera to the origin, represented through negative dot products between the orthonormal basis and the camera’s position. As a result, pre-multiplication by VV transforms a homogeneous coordinate to the orthonormal basis of the camera’s reference frame.

Computing the Projection Matrix

Next, we must construct a matrix PP that projects a homogeneous coordinate in the camera’s orthonormal basis to clip space, mapping the view frustum to a canonical viewing volume, which we choose to be a rectangular prism with half-extents 1,1,0.5\langle 1, 1,0.5\rangle and center 0,0,0.5\langle0, 0, 0.5\rangle (we choose this definition instead of a symmetrical cube to simplify calculations in the future). In clip space, the coordinates are scaled such that it preserves depth information through a nonlinear transformation.

To understand how the projection matrix operates, we will inspect each of the four components of view-projected homogeneous coordinate vR4\mathbf{v} \in \mathbb{R}^4 where v=[xvyvzvwv]\mathbf{v}=\begin{bmatrix} x_v \\ y_v \\ z_v \\ w_v \end{bmatrix} to convert to clip space coordinate cR4\mathbf{c} \in \mathbb{R}^4 such that Pv=c=[xcyczcwc].P\mathbf{v}=\mathbf{c}=\begin{bmatrix} x_c \\ y_c \\ z_c \\ w_c \end{bmatrix}. First, we inspect the y-component. We can scale this to clip space through the vertical field of view (fov\text{fov}), representing the angle that describes how wide the camera can see, from the bottom view plane to the top view plane. Thus, we can calculate scaling factor ss as s=1tan(fov/2),s=\frac{1}{\tan(\text{fov}/2)}, so yc=yvs.y_c=y_vs. Because the field of view is defined vertically, to calculate the x-component, we need to adjust for a non-square screen, which can be done through the aspect ratio, the proportion of width to height. Thus, xc=saspectxv.x_c=\frac{s}{\text{aspect}}x_v.

Next, we must inspect the w-component, the homogeneous element. Recall that after applying perspective projection, we normalize by ww a final time before rendering it on our camera, achieving nR4\mathbf{n} \in \mathbb{R}^4 such that n=1wc[xcyczc]=[xnynzn].\mathbf{n}=\frac{1}{w_c}\begin{bmatrix} x_c \\ y_c \\ z_c \end{bmatrix} = \begin{bmatrix} x_n \\ y_n \\ z_n \end{bmatrix}. When we apply this normalization, the effect we wish to achieve is a scaling of xcx_c and ycy_c such that objects further away are rendered comparatively smaller. Therefore, to achieve this effect, we want wcw_c to vary negatively with zvz_v, so wc=zv.w_c=-z_v.

Finally, when transforming the z-component, we must provide a notion for the distance where our camera starts and stops rendering so we can effectively convert depth to coordinates within a given range. Thus, we must define near clip plane nn and far clip plane ff to denote these distances. Because the camera looks in the negative z-direction, we wish to find a mapping such that zv=n    zn=0z_v=-n \implies z_n=0 and zv=f    zn=1.z_v=-f \implies z_n=-1. Because zcz_c should not depend on xvx_v or yvy_v, we guess that zc=Azv+B    zn=zcwc=ABzvz_c = Az_v+B \implies z_n=\frac{z_c}{w_c}=A-\frac{B}{z_v} where we wish to solve for some AA and BB that satisfy the previously stated condition. Applying the boundary conditions, we obtain A+Bn=0A+\frac{B}{n}=0 and A+Bf=1,A+\frac{B}{f}=1, which we can solve to obtain A=q=ffnA=q=-\frac{f}{f-n} and B=qn.B=qn. Thus, by combining the respective transformations for each component, we can express everything as a final projection matrix of P=[saspect0000s0000qqn0010].P=\begin{bmatrix} \frac{s}{\text{aspect}} & 0 & 0 & 0 \\ 0 & s & 0 & 0 \\ 0 & 0 & q & qn \\ 0 & 0 & -1 & 0 \end{bmatrix}.

Mesh Class

General Purpose

The Mesh class depicts a three-dimensional mesh constructed of vertices (coordinates in R3\mathbb{R}^3) and edges (indexed pairs of vertices). Through this, the SceneRenderer can render these meshes as a wireframe through perspective projection. Additionally, the Mesh class can compute faces (indexed triples of vertices) through the edge list, along with storing associated colors for rendering. The Mesh class can also read .obj files, which encode vertex positions and mesh triangles within the format.

Edge-Face Conversions

To calculate a list of faces from a set of edges, the Mesh class supports face detection. Because our list of edges is a finite graph, we can construct an adjacency matrix that fully represents our mesh. Next, we first iterate over each vertex, then over each vertex’s subgraph of neighbors, utilizing a stack to keep track of traversed vertices. If we return to the original vertex at any point, we have discovered a cycle, hence found a face, which we can add to a preexisting set of faces. It is important to also provide a max cycle length as well to avoid infinite loops. Upon completion of this process, we will have obtained the set of all faces present on the mesh within a certain cycle length.

We can run this process in reverse to generate edges from faces as well, by simply adding each edge in the face cycles to a set of edges. This method is useful to save computation time when provided with a .obj file and a wireframe render is desired.

Figure 1A convex hexagon on the left vs. a concave heptagon on the right. The convex hexagon is triangulated (in red) using fan triangulation, while the concave heptagon is triangulated (in red) using ear clipping triangulation.

Triangulation

To support efficient mesh rendering, the Mesh class also automatically triangulates all faces present in the mesh, assuming that all faces are simple polygons. To triangulate a face, we first distinguish between convex and concave polygons, depicted in Figure 1. Convex polygons are those with interior angles less than 180 degrees. As a result, we can triangulate these faces by selecting an arbitrary vertex and connecting it to every other vertex in the face by adding an edge. Thus, in linear time, for an nn-gon with nn edges, we add n3n-3 edges to our set of edges and convert our one face to n2n-2 faces.

To detect whether a face is convex, we take advantage of the fact that the enclosing contour of a convex polygon always curls in the same direction at any vertex. In other words, if you selected a vertex and began traveling clockwise, you would continue traveling clockwise until you reach the starting vertex without ever traveling counter-clockwise. Thus, we compute the cross product between the edges next to each vertex, and if every computed cross product returns the same direction, we can infer that the face is convex.

To triangulate a concave polygon, we utilize the ear clipping method. Consider three consecutive vertices vi0\textbf{v}_{i0}, vi1\textbf{v}_{i1}, and vi2\textbf{v}_{i2} such that vi1\textbf{v}_{i1} is a convex vertex (determined through the previously described convexity method). As such, the edge between vi0\textbf{v}_{i0} and vi2\textbf{v}_{i2} must enclose a triangle within the overall polygon. If the triangle does not enclose any other vertices, we can "clip" this "ear" of the polygon by connecting these two vertices. As a result, we obtain a new polygon with which we can iterate this process until we obtain only triangles, giving us a possible arrangement of subdivisions to triangulate our polygon. This process is demonstrated in Figure 2.

Figure 2The process for a possible ear clipping triangulation for the concave heptagon in Figure 1. Each iteration of ear clipping selects one ear triangle (shown in red) to clip by inserting an edge until only triangles remain.

SceneRenderer Class

Figure 3A wireframe (left) and triangle (right) rendering of "Impostor" from popular video game Among Us (2018), rendered in the 3D rendering engine.

General Purpose

The SceneRenderer class is responsible for rendering anything seen by the camera to the screen, converting a continuous range of coordinates to discrete screen pixels, along with rendering objects closer to the camera in front of further ones. Furthermore, it must run efficiently, as it must refresh the screen at a frame rate that appears continuous to our eyes. To accomplish this, we utilize a depth buffer to order entities in the ambient space for fast rendering. An example render is shown in Figure 3.

Occlusion Culling

To render meshes on the screen, the SceneRenderer employs occlusion culling using a depth buffer to manage the relative distances between meshes and the camera, enabling computationally efficient rendering. Every frame, the SceneRenderer iterates over every active mesh in the scene (all meshes linked to an Updater) and attempts to draw every face. Because our Camera class also returns the distance to the camera when projecting vertices to screen space, when rasterizing, we can track each individual pixel’s distance to the camera and only render the pixel on the mesh closest to the camera. As a result, we prevent ourselves from unnecessarily rendering obscured objects. Finally, we utilize a screen blit (block image transfer) to transfer a 2-dimensional array representing desired color values to the actual screen. The combination of these two methods greatly save on computation time, something that is necessary when running a frame-based engine.

Depth Buffers vs. Sorted Buffers

It is important to distinguish between different ways of quantifying distance to the camera. Our renderer utilizes a depth buffer to most accurately track each individual pixel’s distance to the screen, which accounts for overlapping meshes in the scene. An alternative approach would be using a sorted buffer, which instead computes the depth face-by-face by taking the average of the vertices’ camera distance. While this approach is less computationally heavy, removing the need to utilize barycentric coordinates (see the following section), we face an unexpected downside. Depending on subtle camera orientation fluctuations, meshes that obscure others will tend to flicker back and forth as the average depth changes. As a result, while certain orientations render as expected, others will fail to do so accurately, an issue that is especially apparent with a free-look camera. As such, we utilize a depth buffer for the purposes of this project.

Barycentric Coordinates

Barycentric coordinates provide a convenient way to express points relative to the vertices of a triangle. Let a triangle have vertices p0=[x0y0z0],p1=[x1y1z1],p2=[x2y2z2]R3.\mathbf{p}_0 = \begin{bmatrix} x_0 \\ y_0 \\ z_0 \end{bmatrix}, \quad \mathbf{p}_1 = \begin{bmatrix} x_1 \\ y_1 \\ z_1 \end{bmatrix}, \quad \mathbf{p}_2 = \begin{bmatrix} x_2 \\ y_2 \\ z_2 \end{bmatrix} \in \mathbb{R}^3. Any point p\mathbf{p} in the plane of the triangle can be written as a convex combination of the vertices: p=w0p0+w1p1+w2p2,\mathbf{p} = w_0 \mathbf{p}_0 + w_1 \mathbf{p}_1 + w_2 \mathbf{p}_2, subject to the constraint w0+w1+w2=1.w_0 + w_1 + w_2 = 1. A point lies inside the triangle if and only if w00,w10,w20.w_0 \ge 0, \quad w_1 \ge 0, \quad w_2 \ge 0. The barycentric coordinates w0,w1,w2w_0, w_1, w_2 also serve as weights for linear interpolation of any per-vertex quantity, such as depth, color, or normals. For instance, the interpolated depth at point p\mathbf{p} is z=w0z0+w1z1+w2z2.z = w_0 z_0 + w_1 z_1 + w_2 z_2.

Line Rasterization

Line rasterization proceeds by sampling points along a line segment connecting two screen-space vertices. Let p1=[x1y1z1],p2=[x2y2z2]R3.\mathbf{p}_1 = \begin{bmatrix} x_1 \\ y_1 \\ z_1 \end{bmatrix}, \quad \mathbf{p}_2 = \begin{bmatrix} x_2 \\ y_2 \\ z_2 \end{bmatrix} \in \mathbb{R}^3. The line is parameterized by p(t)=(1t)p1+tp2,t[0,1].\mathbf{p}(t) = (1-t) \mathbf{p}_1 + t \mathbf{p}_2, \quad t \in [0,1]. Pixels are generated by evaluating p(t)\mathbf{p}(t) at discrete intervals proportional to the Euclidean distance p2p12=(x2x1)2+(y2y1)2.\|\mathbf{p}_2 - \mathbf{p}_1\|_2 = \sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2}. For lines of finite thickness, neighboring pixels within a given radius are also considered. Each candidate pixel’s depth zz is compared to the depth buffer, and the pixel is drawn if it is closer than the current buffer value. Once this test passes, the pixel is assigned the specified line color.

Triangle Rasterization

Triangle rasterization uses barycentric coordinates to determine pixel coverage and interpolate attributes. Let the triangle vertices be p0=[x0y0z0],p1=[x1y1z1],p2=[x2y2z2].\mathbf{p}_0 = \begin{bmatrix} x_0 \\ y_0 \\ z_0 \end{bmatrix}, \quad \mathbf{p}_1 = \begin{bmatrix} x_1 \\ y_1 \\ z_1 \end{bmatrix}, \quad \mathbf{p}_2 = \begin{bmatrix} x_2 \\ y_2 \\ z_2 \end{bmatrix}. A pixel at screen-space location (x,y)(x, y) is considered inside the triangle if its barycentric coordinates (w0,w1,w2)(w_0, w_1, w_2), computed using the areas of sub-triangles or edge functions, satisfy w00,w10,w20,w0+w1+w2=1.w_0 \ge 0, \quad w_1 \ge 0, \quad w_2 \ge 0, \quad w_0 + w_1 + w_2 = 1. The barycentric coordinates allow linear interpolation of vertex attributes. In particular, the pixel depth is interpolated as z=w0z0+w1z1+w2z2,z = w_0 z_0 + w_1 z_1 + w_2 z_2, which is then incorporated into the depth buffer for visibility. Similarly, barycentric coordinates can interpolate color, normal vectors, or texture coordinates, enabling smooth shading and other per-pixel effects.

Robotic Arm

Objective

Replicate the behavior of a four-jointed robotic arm in the aforementioned 3D rendering engine. Utilize inverse kinematics to control the arm’s end effector to reach different target poses for simple tasks. The final goal is to remove a 1/4-20 nut from a bolt and relocate it to a different bolt.

Implementation

Joints

Features

The Joint class extends Updater, providing it with a world transform and access to the main update loop of the engine. Joints also possess an axis of rotation ω\boldsymbol{\omega} (defined globally) and a parent joint. As a result, the joint supports rotations by some angle θ\theta about ω\boldsymbol{\omega}, recursively updating any children (as per the behavior of the Transform class). Additionally, joints can also be bounded between two angles. Whichever arc of the unit circle the joint’s starting angle lies in determines the bounding arc of rotation, allowing for the replication of hard-limited servos, for example.

Joints can be instantiated and linked to other joints dynamically, enabling the creation of different arm configurations with an arbitrary number of joints. Currently, there is support for three types of rotational joints: revolute joints (one axis of rotation), universal joints (two axes of rotation), and (pseudo) ball-and-socket joints (three axes of rotation). However, the inverse kinematics program only supports revolute joints for ease of computation.

Manual Operation Controls

For revolute joints, the user can use "J" and "L" to rotate the arm counter-clockwise and clockwise, respectively, about the axis of rotation based on an angular velocity specified upon instantiation. For universal joints, "U" and "O" are added as additional controls for the second axis. Finally, for ball-and-socket joints, "M" and "." are used for the third axis of rotation.

Robotic Arm

Features

The Robotic Arm class also extends Updater, primarily for access to the main update loop of the engine. Acting as a manager for the different joints, this class instantiates and organizes the linkage of joints, while also allowing the user to swap between different joints in manual control. Finally, the class executes inverse kinematic operations based on a pre-specified list of target points to simulate the real-life robotic arm.

Manual Operation Controls

To swap between controlling different joints, the user can use "0"-"9" (currently a maximum of 10 joints are supported). To activate inverse kinematics to the next target location, the user can use "R".

Mathematical Algorithms

Forward Kinematics

The forward kinematics function f:RnSE(3)f: \mathbb{R}^n \to SE(3) describing an end effector position can be written as the following product of exponentials given nn linked joints represented by angle vector θRn\boldsymbol{\theta}\in\mathbb{R}^n, end-effector transformation matrix (in global coordinates) TSE(3)T \in SE(3), and individual joint screw matrices [Sn]se(3)[S_{n}]\in \mathfrak{se}(3): f(θ)=e[S1]θ1e[S2]θ2e[Sn]θnT.f(\boldsymbol{\theta})=e^{ [S_{1}]\theta_{1} }e^{ [S_{2}]\theta_{2} }\cdots e^{ [S_{n}]\theta_{n} }T. The joint screw matrices can be found as [Sn]=[[ω^]ω^×q00][S_{n}]=\begin{bmatrix} [\boldsymbol{\hat{\omega}}] & -\boldsymbol{\hat{\omega}}\times \mathbf{q} \\ 0 & 0 \end{bmatrix} where [ω^]so(3)[\boldsymbol{\hat{\omega}}] \in \mathfrak{so}(3), representing a rotation around axis of rotation ω^\boldsymbol{\hat{\omega}}, and qR3\mathbf{q} \in \mathbb{R}^3, representing the end effector offset from the screw’s point of rotation (encoding the linear velocity in the plane of rotation).

By specifying desired angles for each of the joints, this function outputs the end effector pose after each joint of the arm rotates by the specified angles. Next, we wish to reverse engineer this formula to find the angles corresponding with a specific target pose.

Inverse Kinematics

Temporarily ignoring rotation and focusing purely on position, the goal of inverse kinematics can be described as solving for θRn\boldsymbol{\theta} \in \mathbb{R}^n in the following equation: g(θ)=xdf(θ)=0,g(\boldsymbol{\theta})=\mathbf{x}_{d}-f(\boldsymbol{\theta})=0,where ff is some forward kinematics function RnRm\mathbb{R}^n \to \mathbb{R}^m and xdRm\mathbf{x}_{d} \in \mathbb{R}^m is the desired end effector position (in global coordinates). We can solve for θ\boldsymbol{\theta} numerically using the Newton-Raphson method.

Newton-Raphson Method

Given some initial guess αRn\boldsymbol{\alpha} \in \mathbb{R}^n, we can reorganize the Taylor expansion of ff at this initial guess, truncating any terms beyond first order, to approximate the true angles θ\boldsymbol{\theta} as follows: g(θ)=0=g(α)+θg(α)(θα).g(\boldsymbol{\theta})=0= g(\boldsymbol{\alpha})+\frac{\partial}{\partial \boldsymbol{\theta}}g(\boldsymbol{\alpha})(\boldsymbol{\theta}-\boldsymbol{\alpha}). Thus, we can solve for θ\boldsymbol{\theta} as θ=α(θg(α))1g(α).\boldsymbol{\theta}=\boldsymbol{\alpha}-\left( \frac{\partial}{\partial \boldsymbol{\theta}}g(\boldsymbol{\alpha}) \right)^{-1}g(\boldsymbol{\alpha}). We can iterate this process, using the newly approximated θ\boldsymbol{\theta} as our new α\boldsymbol{\alpha} until reaching a specified stopping criterion (e.g., reaching an error threshold).

The Jacobian

If θ\boldsymbol{\theta} is merely a single value, we have no issue taking the partial derivative of ff (Note: due to the θ\theta invariance of xdx_{d}, fθ=gθ\frac{\partial f}{\partial \boldsymbol{\theta}}=\frac{\partial g}{\partial \boldsymbol{\theta}}). However, as we increase the dimensionality, we run into ambiguity when regarding this. In fact, the partial derivative expands into a Jacobian matrix JJ: θf(θ)=J=[θ1f1(θ)θnf1(θ)θ1fm(θ)θnfm(θ)]Rm×n\frac{\partial}{\partial \boldsymbol{\theta}}f(\boldsymbol{\theta})=J=\begin{bmatrix} \frac{\partial}{\partial \theta_{1}}f_{1}(\boldsymbol{\theta})& \cdots& \frac{\partial}{\partial \theta_{n}}f_{1}(\boldsymbol{\theta}) \\ \vdots &\ddots & \vdots \\ \frac{\partial}{\partial \theta_{1}}f_{m}(\boldsymbol{\theta})& \cdots& \frac{\partial}{\partial \theta_{n}}f_{m}(\boldsymbol{\theta}) \end{bmatrix} \in \mathbb{R}^{m\times n} where there are nn joints, the arm resides in Rm\mathbb{R}^m, and fmf_{m} is the mmth component of the output in Rm.\mathbb{R}^m. If m=nm=n, we have no issues taking the inverse of this matrix; however, in the case where mnm\neq n, we turn toward the Moore-Penrose pseudoinverse, JJ^{\dagger}. Put simply, given x=Jb\boldsymbol{x}=J^{\dagger}\mathbf{b} where xRn\mathbf{x} \in \mathbb{R}^n, bRm\mathbf{b} \in \mathbb{R}^m, and JRm×nJ \in \mathbb{R}^{m\times n}, the pseudoinverse either minimizes x\lvert \lvert \mathbf{x} \rvert \rvert or Jxb\lvert \lvert J\mathbf{x}-\mathbf{b} \rvert \rvert depending on the dimensions of JJ, mimicking the behavior of a true inverse. For our purposes, J={JT(JJT)1n>mJ1n=m(JTJ)1JTn<mJ^{\dagger}=\begin{cases} J^T(JJ^T)^{-1} &n>m \\ J^{-1} &n=m \\ (J^TJ)^{-1}J^T &n<m \end{cases} allowing us to fairly accurately invert gg.

Space and Body Jacobians

When using transformation matrices instead of position vectors, we must slightly modify our interpretation of Jacobians to work in a Lie space instead of Euclidean space to account for both position and rotation. Thus, we now distinguish specifically between a space Jacobian JsJ_s and body Jacobian JbJ_b.

First, we handle the space Jacobian. In the previous section, we explained the Jacobian as a collection of velocities relative to a theta vector θ\boldsymbol{\theta}. This can be extended to the Lie space by treating the Jacobian as a collection of twists; however, the twists must be transformed to the global frame. The first column vector of this Jacobian is merely the joint twist S1S_1 because it is already in the global frame. However, for each subsequent column, we must apply the product of exponentials formula to convert the twist to the space frame. We can do so through the adjoint map (explained in a previous section). If space Jacobian column ii is JiJ_i, Ji=AdCiSiJ_i=\mathrm{Ad}_{C_i}S_i where Ci=e[S1]θ1e[S2]θ2e[Si1]θi1.C_{i}=e^{[S_1]\theta_1}e^{[S_2]\theta_2}\cdots e^{[S_{i-1}]\theta_{i-1}}. Thus, if there are nn joints, we can express the full space Jacobian as Js=[S1J2J3Jn]R6×n.J_s=\begin{bmatrix} S_1 & J_2 & J_3 & \cdots & J_n \end{bmatrix} \in \mathbb{R}^{6\times n}.

The body Jacobian can be found in a similar method by temporarily treating the body frame as the global frame. However, we can actually calculate the body Jacobian from the space Jacobian by once again using the adjoint map. Using our forward kinematics expression of the body frame f(θ)f(\boldsymbol{\theta}), we can find the body Jacobian as Jb=Ad(f(θ))1JsR6×n.J_b=\mathrm{Ad}_{(f(\boldsymbol{\theta}))^{-1}}J_s \in \mathbb{R}^{6\times n}.

Extending to Transformation Matrices

When using transformation matrices instead of position vectors, instead of using the velocity vector xdf(θ)\mathbf{x}_{d}-f(\boldsymbol{\theta}) (pointing toward the next guess), we must use a velocity twist ξR6\xi \in \mathbb{R}^6 that serves the same purpose. We can first find [ξ]se(3)[\xi]\in \mathfrak{se}(3) through the matrix logarithm, first expressing the desired pose in the body frame as follows: [ξ]=log((f(θ))1Td).[\xi]=\log((f(\boldsymbol{\theta}))^{-1}T_{d}) . Finally, we can convert back to vector form, extracting angular velocity ωR3\boldsymbol{\omega} \in \mathbb{R}^3 and linear velocity vR3\mathbf{v} \in \mathbb{R}^3.

Thus, we can now iterate θ=α+Jb(α)ξ,\boldsymbol{\theta}=\boldsymbol{\alpha}+J_b^{\dagger}(\boldsymbol{\alpha})\xi, using the body Jacobian and setting α\boldsymbol{\alpha} to θ\boldsymbol{\theta} each subsequent iteration until the components of ξ\xi reach a desired error, expressed as ω<ϵω\lvert \lvert \boldsymbol{\omega} \rvert \rvert < \epsilon_{\omega} and v<ϵv\lvert \lvert \mathbf{v} \rvert \rvert<\epsilon_{v} for small values of ϵω\epsilon_{\omega} and ϵv.\epsilon_{v}. As a result, we now have an algorithm that can converge on a θ\boldsymbol{\theta} such that the end effector of the robot arm reaches TdT_{d}.

Attempting the Task

Arm Overview

Figure 4A simplified side view of the arm in its rest position. Red vectors represent each joint’s axis of rotation.

Our robotic arm possesses five separate revolute joints controlled by servos, which we will label with numbers corresponding to their connected pins on the Raspberry Pi. Thus, we have S0,S1,S2,S3,S0,S1,S2,S3, and S5S5 (skipping S4S4). Figure 4 illustrates the arm’s default layout as well as its local axes of rotation. S0S0, positioned at the origin, rotates in the y^\mathbf{\hat{y}} direction. S1S1, positioned at rest at 0,80,0\langle0, 80, 0\rangle, rotates in the x^\mathbf{\hat{x}} direction. S2S2, positioned at rest at 0,185,0\langle0, 185, 0\rangle, rotates in the x^\mathbf{\hat{x}} direction. S3S3, positioned at rest at 0,185,60\langle0, 185, 60\rangle, rotates in the z^\mathbf{\hat{z}} direction. S5S5, positioned at rest at 0,185,160\langle0, 185, 160\rangle, instead represents the position of the tip of the gripper when fully closed. Note that our rest position is not defined when the arm is fully extended in any one direction, but rather as seen in Figure 4 to achieve a maximal range of motion.

Task Overview

Figure 5A top-down view of the task space for the robotic arm on the left, and a side view of the task space plane of motion on the right. a^\mathbf{\hat{a}} is a vector representing the task space plane when projected onto the xz plane, which is rotated by an angle ϕ\phi relative to the x^\mathbf{\hat{x}} axis.

With the robotic arm, we are tasked with unscrewing a nut on a bolt attached to a wall, then rescrewing the nut on a different bolt, as seen in Figure 5. In particular, for this specific task, the arm only moves in a single plane, greatly simplifying the computations needed to control the base of the arm. Thus, when defining points to compute inverse kinematics, we can treat z^\mathbf{\hat{z}} as a^\mathbf{\hat{a}}, then apply a separate rotation of S0S0 to match a^\mathbf{\hat{a}} in real life.

Thus, our arm first needs to reach the upper bolt, perform an unscrewing motion some number of times using joints S3S3 and S5S5, then move to the front of the lower bolt and reverse that motion while moving in slightly. In real life, our robotic arm’s gripper has an area of contact wide enough to unscrew the nut without needing to pull back slightly. As a result, we utilize inverse kinematics to determine optimal joint angles to reach these configurations.

Inverse Kinematics Results

Figure 6The robotic arm rendered in the 3D graphics engine. Each joint (represented by a sphere) has a local frame represented by the coordinate axes, and the gripper is a cube.

Figure 6 depicts the robotic arm as seen in the simulator, where inverse kinematics is run to determine the compatible joint angles. While running inverse kinematics, we specify ϵω=0.01\epsilon_\omega=0.01 and ϵv=0.0001\epsilon_v=0.0001. We observe that if we provide a target pose within the arm’s available task space, the algorithm converges very quickly, taking between three and ten iterations to converge for all poses. However, one shortcoming of this model is that the user must correctly specify feasible target orientations along with the position in order to reach convergence, which is often cumbersome to determine in real life.

Arm Implementation

After retrieving the required arm configurations through inverse kinematics, we attempted to perform the task in real life using the robotic arm. The arm controls each joint through servos, and it receives power from an external Raspberry Pi connected to a DC power supply. The servos each accept input from 0° to 180°, so we accordingly renormalize our obtained values from inverse kinematics. When performing calculations, we additionally clamp the angles between this range after each iteration, which prevents us from receiving joint angles outside the capabilities of our servos. It is also important to note that the rest position of the arm pictured in Figure 4 is defined where each joint is at the middle of its range, so we also accounted for that when renormalizing.

However, attempting this task on the arm is a much more complicated task than running it on the simulator, as there exists many sources of error that we have failed to account for in the simulator. For instance, the starting position of the arm must be near perfect, as any slight shifts throw off the entire inverse kinematics calculations, resulting in a failed task. Additionally, with no ability to receive angular feedback from the servos or any way to sense the arm’s surroundings, we cannot dynamically adjust the angles to account for any discrepancies in positioning. As a result, we have found better success by manually controlling the arm, setting joint angles through trial and error. When doing this, we find that the task is indeed possible, if not tedious. Nonetheless, in theory, our calculations for inverse kinematics are correctly implemented and seem to behave correctly inside the 3D engine.