Long Exposure Light Animation
Designs for all the hardware and software used in this blog post is available here under a Creative Commons Attribution-ShareAlike 3.0 Unported license.
Introduction
I have been experimenting with using a computer controlled delta robot to draw long exposure light animations.
By using a precisely controllable robot to draw each frame of the animation a level of precision is achieved which gives the animation a quality similar to that of CGI animations, while still maintaining natural lighting.
The process of drawing a single frame can be seen in the following video. This would of course take place in a darkened room with a camera set to take a long exposure photograph pointing towards the base of the delta robot.
Delta Robot
The delta robot used to create these animations is a custom design. All the hardware designs and control software and firmware are available here. The electronics is all based around an STM32F4 Discovery board because it has a floating point unit which is required to perform the inverse kinematics code fast enough using floating point arithmetic. The electronics for the project is largely undocumented as the design evolved while the robot was being built. Hardware connections to the STM32F4 Discovery board can be found in the “hardware.h” file in the git repository. The motor drivers used are Pololu A4988 driver boards.
Software
The delta robot is controlled using GCode. The following python script was used to generate the GCode for the animations. The spindle on/off command is used to control the LED. The GCode is then fed to the delta robot via a serial link, where it is then interpreted and the actions performed.
#!/usr/bin/env python
import random
import math
from random import randint
def ease_in_quad(time, begin, change, duration):
time = float(time)
begin = float(begin)
change = float(change)
duration = float(duration)
val = change*(time/duration)*(time/duration) + begin
return val
def ease_out_quad(time, begin, change, duration):
time = float(time)
begin = float(begin)
change = float(change)
duration = float(duration)
val = -change*(time/duration)*((time/duration)-2.0) + begin
return val
def generate_circle(raidus=1.0, degree_steps=10):
points = []
points.append((raidus, 0, 0))
for val in range(degree_steps - 1):
angle = (360.0 / degree_steps) * (val + 1)
r = math.radians(angle)
x = points[0][0]
y = points[0][1]
z = points[0][2]
x2 = x * math.cos(r) - y * math.sin(r)
y2 = y * math.cos(r) + x * math.sin(r)
z2 = z
points.append((x2, y2, z2))
return points
def generate_line(start=(0,0,50), end=(0,0,45)):
points = [start, end]
return points
def circle(position, radius=10.0, degree_steps=10):
points = []
face = []
points = generate_circle(radius, degree_steps)
points = add_offset(points, position)
face = range(len(points))
face.append(0)
return points, face
def line(start, end):
points = generate_line(start, end)
face = range(len(points))
return points, face
def add_offset(points, (x_offset, y_offset, z_offset)):
new_points = []
for index in range(len(points)):
x = points[index][0] + x_offset
y = points[index][1] + y_offset
z = points[index][2] + z_offset
new_points.append((x, y, z))
return new_points
def add_shape((points, faces), (new_points, new_face)):
if new_face == None or new_points == None:
return (points, faces)
new_face = [f+len(points) for f in new_face]
faces.append(new_face)
points += new_points
return (points, faces)
def add_object((points, faces), (new_points, new_faces)):
if new_faces == None or new_points == None:
return (points, faces)
for new_face in new_faces:
new_face = [f+len(points) for f in new_face]
faces.append(new_face)
points += new_points
return points, faces
def save_as_gcode(points, faces, filename = 'output.gcode'):
spindle_on = "M3"
spindle_off = "M5"
f = open(filename, 'w')
drawn = []
for face in faces:
for index, vertex_index in enumerate(face):
if index == 1:
f.write(spindle_on+'\n')
vertex = points[vertex_index]
x = vertex[0]
y = vertex[1]
z = vertex[2]
cmd = "G1 X"+str(x)+" Y"+str(y)+" Z"+str(z)+ " F"+'3200.0'+'\n'
f.write(cmd)
drawn.append(cmd)
f.write(spindle_off+'\n')
f.close()
class ExpandingCircleAnimation:
def __init__(self, position, start_tick, end_tick, tween_function, begin_radius, end_radius, degree_steps=20.0):
self.start_tick = start_tick
self.end_tick = end_tick
self.tween_function = tween_function
self.begin_radius = begin_radius
self.end_radius = end_radius
self.position = position
self.degree_steps = degree_steps
def get(self, tick):
if tick < self.start_tick or tick >= self.end_tick:
return None, None
time = tick - self.start_tick
begin = self.begin_radius
change = self.end_radius - self.begin_radius
duration = self.end_tick - self.start_tick
radius = self.tween_function(time, begin, change, duration)
points, faces = circle(self.position, radius, int(self.degree_steps))
return (points, faces)
class FallingLineAnimation:
def __init__(self, position, start_tick, end_tick, tween_function, begin_z, end_z, length = 5.0):
self.start_tick = start_tick
self.end_tick = end_tick
self.tween_function = tween_function
self.begin_z = begin_z
self.end_z = end_z
self.position = position
self.length = length
def get(self, tick):
if tick < self.start_tick or tick >= self.end_tick:
return None, None
time = tick - self.start_tick
begin = self.begin_z
change = self.end_z - self.begin_z
duration = self.end_tick - self.start_tick
z = self.tween_function(time, begin, change, duration)
l_start = (self.position[0], self.position[1], z)
l_end = (self.position[0], self.position[1], z - self.length)
points, faces = line(l_start, l_end)
return (points, faces)
class RainDropAnimation:
def __init__(self, position, start_tick, end_tick, height=50.0, radius=30.0, loop=False, debug =False):
self.loop = loop
self.debug = debug
self.start_tick = start_tick
self.end_tick = end_tick
self.height = height
self.radius = radius
self.duration = self.end_tick - self.start_tick
self.drop = FallingLineAnimation(position, start_tick, start_tick+self.duration/2.0, ease_in_quad, position[2]+height, position[2], 5.0)
self.ripple1 = ExpandingCircleAnimation(position,(start_tick+(self.duration/2.0)),(start_tick+self.duration),ease_out_quad, 0.0,radius)
def get(self, tick):
if self.loop:
tick = math.fabs(math.fmod(tick,self.duration))
if self.debug:
print tick
points = []
faces = []
points, faces = add_shape((points, faces), self.drop.get(tick))
points, faces = add_shape((points, faces), self.ripple1.get(tick))
return points, faces
def main():
points = []
faces = []
d1 = RainDropAnimation((20.0,20.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
d2 = RainDropAnimation((0.0,20.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
d3 = RainDropAnimation((-20.0,20.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
d4 = RainDropAnimation((20.0,0.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
d5 = RainDropAnimation((0.0,0.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
d6 = RainDropAnimation((-20.0,0.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
d7 = RainDropAnimation((20.0,-20.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
d8 = RainDropAnimation((0.0,-20.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
d9 = RainDropAnimation((-20.0,-20.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
points = []
faces = []
duration = 60
o1 = randint(0,30)
o2 = randint(0,30)
o3 = randint(0,30)
o4 = randint(0,30)
o5 = randint(0,30)
o6 = randint(0,30)
o7 = randint(0,30)
o8 = randint(0,30)
o9 = randint(0,30)
for tick in range(30):
points = []
faces = []
points, faces = add_object((points, faces),d1.get(tick+o1))
points, faces = add_object((points, faces),d2.get(tick+o2))
points, faces = add_object((points, faces),d3.get(tick+o3))
points, faces = add_object((points, faces),d4.get(tick+o4))
points, faces = add_object((points, faces),d5.get(tick+o5))
points, faces = add_object((points, faces),d6.get(tick+o6))
points, faces = add_object((points, faces),d7.get(tick+o7))
points, faces = add_object((points, faces),d8.get(tick+o8))
points, faces = add_object((points, faces),d9.get(tick+o9))
filename = str(tick).zfill(3)+".gcode"
save_as_gcode(points, faces, filename)
if __name__ == "__main__":
main()