Contents
1 Introduction
I was asked about the Roller Coaster application that is bundled with Codea. This is an explanation of what's going on in that code.
Almost, that is. I simplified it slightly by removing the extra classes. These classes were only used lightly in this application but they are things that I use a lot in other things that I've written so it's easier for me to use them than not. However, in explaining the code then they are distractions.
It's also important to know when it was written. I'm a beta tester for Codea and this was written when meshes and matrices were the new features being tested. So this code was written as a test of these features. It was thrown together a little quickly so should not be taken as an example of elegance in coding.
2 The Code Logic
Let's start with the overall plan of the code. A Codea program reads in its code, executes setup
, then goes into a loop where draw
is continually executed. We'll ignore touched
for the time being.
What happens in the program is quite simple. There is a track laid out, marked by planks, which you follow, hovering a little above the planks. The track is static, it is you that moves. In the setup
function the main task is to define the mesh representing the track. In the draw
function the main task is to define the matrix representing your current point on the track.
There are a few little extras. There are two star fields, one scattered around the track and one forming stars "far off". It is also possible to rotate your point of view using touch.
3 Defining the Track
As said above, the main task of the setup
function is to define the track. We need a function defining the path of the track. We're going to lay planks along the track which means that we need more than just the path of the track, we need to know the normal direction at each point as well. The normal direction is the "up" direction at each point of the track. Using this we can figure out how to lay the planks.
Defining these is the line:
trackFunction, trackNormal, maxHeight = Tracks.loop(3,2)
There are a variety of tracks that can be defined and they take a couple of parameters. The Tracks.loop
tracks are a family that include a circle and a figure eight. They also oscillate vertically as they go around. There are also Tracks.torus
which defines tracks that wind round a torus and Track.mobius
which follows a Möbius band.
We want to place the planks at regular intervals around the track. The function returned by the track creation will be parameterised for convenience, not by arc length. We therefore cannot simply divide the track into equal lengths using its parametrisation. Rather than reparametrising the track, we walk along it marking off points at regular intervals. This is the function:
local track = TrackPoints({
pathFunction = trackFunction,
normalFunction = trackNormal,
delta = .01,
step = .3,
})
The result is a table of points along the track which are close to step
apart.
The next step is to create the mesh of planks. At each point in the track
table we add a plank, using the aptly named AddPlank
function. The plank is also lit from a particular direction. This is done by modifying the colours.
Once the track is created, we create the star field. This consists of two families, one scattered around the immediate space and one forming a "star field" out at a far distance. The stars themselves are created by the AddStar
function.
Lastly, the setup
function initialises some variables.
4 The draw Function
The main purpose of the draw
function is to figure out where on the track we are and what direction we are facing.
We travel along the track using its natural parametrisation. Our speed is such that our total energy is constant, thus as the track goes up and down we get slower and faster.
Once we know our position, we need to know what direction to face. We face along the track, mathematically this is the tangent to the track at that point. This might be zero, in which case we keep the same direction as we had on the previous run. This provides our forward direction, we also need to know the "up" direction which is the normal direction.
The code that controls all of this is the following.
if not paused then
time = time + speed*DeltaTime
local pos = trackFunction(time)
local tmp
tmp = tangent({delta = .1,
pathFunction = trackFunction,
time = time})
if tmp ~= vec3(0,0,0) then
dp = tmp
end
nml = trackNormal(time)
speed = math.sqrt(energy - 2*g*pos.y)
local ob = SO3(dp,nml)
At the end, ob
is a table of three orthonormal vectors such that the first is in the tangent direction and the second is roughly in the direction of the normal direction.
Once we have our vectors (such a family is sometimes referred to as a frame), we get to work. We lift ourselves off the track a little:
pos = pos + ht*ob[2]
The information given to the camera
function is our position (the pos
vector), the point we're looking at, and the "up" vector. The point that we're looking at is given by adding the direction in which we're looking to our position. This ought to be:
local dir = pos + size * ob[2]
but we also allow for the user to modify the direction in which we're looking so it is actually:
local dir = pos + size*(sz*(ca*ob[1] + sa*ob[3]) + cz*ob[2])
Once we have set up our viewpoint, we draw the planks and the stars.
There is one extra here in that we allow for the motion to be paused. If this is so, we display the track (and stars) from a point above the track.
5 Defining the Meshes
The two functions AddPlank
and AddStar
add shapes to a mesh. Well, actually they add the information to two tables which hold the vertices and colours of the mesh. These are then assigned to the mesh in one fell swoop afterwards.
(This code was written at a time when it was not possible to add vertices to a mesh incrementally.)
The AddPlank
function adds a plank to the vertex and colour tables, which are passed to the function. The parameters passed to the function also consist of its origin, vectors representing its width, height, and depth. In addition, we have the colour of the plank and the direction of a light source.
The eight vertices of the plank are the vectors:
where is the origin, the width, the height, and the depth.
The part of AddPlank
that actually adds the planks is the following. There are six sides which we index by the variable i
. The variables n
and m
are a more useful encoding where n
selects the direction of the face and m
whether it is forward or back facing.
A rectangle consists of two triangles and therefore six vertices. These six vertices are indexed by j
. The table tt
defines the s that select the vertices for that particular face.
for i=0,5 do
n = math.floor(i/2)
m = 2*(i%2) - 1
for j=0,5 do
tt = {m,2*(j%2)-1,2*(math.floor((j+1)/3)%2) - 1}
u = o + tt[n+1]*w + tt[(n+1)%3+1]*h + tt[(n+2)%3+1]*d
table.insert(vertices,u)
table.insert(colours,
ColourShade(c,75 + 25*m*v[n+1])
)
end
end
The function AddStar
has a similar goal: to add a shape to a mesh. The input data is slightly different: we just need the centre of the star, its size, and the light. The shape is defined by two intersecting tetrahedra, thus eight triangles. The orientation of the shape is chosen at random.
6 Track Building
The track defining functions return two functions and the maximum height of the track. The two functions define the positions of the track and the normal directions. The parameter is taken in the interval .
The function TrackPoints
then creates the points at (roughly) equal distance along the track. This steps along the track, evaluating the track function at small intervals and continuing until we've accumulated enough that we're over the specified distance. Then we add it to the table of points. We also add the tangent at that point, which is computed by the tangent
function. This computes the numerical derivative at the specified time.
7 Auxiliary Functions
The main auxiliary functions are to do with vector manipulation. The function RandomBasis
returns a basis of orthonormal vectors selected at random, it uses RandomVec3
to get started. The function SO3
takes in two vectors and creates an orthonormal basis out of them. This uses GramSchmidt
internally.
The last auxiliary function, ColourShade
, provides an easy way to shade the given colour.
8 The Code
supportedOrientations(LANDSCAPE_ANY)
function setup()
displayMode(FULLSCREEN)
-- gravitational constant, used in speed calculations
g = .0000981
-- define the track
trackFunction, trackNormal, maxHeight = Tracks.loop(3,2)
-- initialise the plank mesh and its vertex and colour tables
plank = mesh()
local ver = {}
local col = {}
-- get the track points
local track = TrackPoints({
pathFunction = trackFunction,
normalFunction = trackNormal,
delta = .01,
step = .3,
})
-- add the planks to the tables
local u
for k,v in ipairs(track) do
-- v[2] and v[3] are the tangent and normal directions
u = SO3(v[2],v[3])
AddPlank({
-- at the track point,
-- depth points along the track,
-- height points off the track,
-- width points across the track
origin = v[1],
width = 1*u[3],
height = .1*u[2],
depth = .2*u[1],
light = vec3(1,2,3),
colour = color(205,170,124,255),
vertices = ver,
colours = col
})
end
-- add the tables to the plank mesh
plank.vertices = ver
plank.colors = col
-- now create the stars
-- initialise the mesh and tables
size = .1
stars = mesh()
ver = {}
col = {}
-- add 200 stars,
-- 100 scattered in a block 40x10x40
-- 100 in the background
for i=1,200 do
AddStar({
origin = vec3(
40*(2*math.random()-1),
10*(2*math.random()-1),
40*(2*math.random()-1)
),
size = .25,
vertices = ver,
colours = col,
light = vec3(1,2,3)
})
AddStar({
origin = 100*RandomVec3(),
size = .25,
vertices = ver,
colours = col,
light = vec3(1,2,3)
})
end
stars.vertices = ver
stars.colors = col
-- initialise various parameters:
-- initial time
time = 0
-- initial speed
speed = .05
-- height of point off track
ht = .5
-- total energy
energy = speed^2 + 2*g*(maxHeight +5)
-- initial orientation
nml = vec3(1,0,0)
dp = vec3(0,0,1)
-- initial angles (varied by touch)
azimuth = 0
zenith = math.pi/2
ca = 1
sa = 0
cz = 0
sz = 1
-- start paused
paused = true
ptext = "Tap to begin"
end
function draw()
-- clear the screen
background(0, 0, 0, 255)
-- set the perspective matrix
perspective(45,WIDTH/HEIGHT)
-- are we paused?
if not paused then
-- no, so update our time
time = time + speed*DeltaTime
-- compute our position
local pos = trackFunction(time)
-- compute our frame of reference
local tmp
-- get the tangent vector
tmp = tangent({delta = .1,
pathFunction = trackFunction,
time = time})
-- might have been zero, if not update our direction
if tmp ~= vec3(0,0,0) then
dp = tmp
end
-- get the normal vector
nml = trackNormal(time)
-- update the speed
speed = math.sqrt(energy - 2*g*pos.y)
-- compute the frame of reference as an orthonormal basis
local ob = SO3(dp,nml)
-- adjust the position
pos = pos + ht*ob[2]
-- set the "look at" point
local dir = pos + size*(sz*(ca*ob[1] + sa*ob[3]) + cz*ob[2])
-- set up the camera (aka viewmatrix)
camera(pos.x,pos.y,pos.z,
dir.x,dir.y,dir.z,
ob[2].x,ob[2].y,ob[2].z)
else
-- this is the "if paused" part, set the view to be above
camera(0,8*maxHeight,15,0,0,0,0,1,0)
end
-- draw the plank and stars
plank:draw()
stars:draw()
-- if paused display a text
if paused then
resetMatrix()
ortho()
viewMatrix(matrix())
font("Copperplate-Bold")
fontSize(30)
fill(255,104,179,255)
text(ptext,WIDTH/2,HEIGHT/2)
end
end
function touched(touch)
-- if we're paused, a touch un-pauses us
if paused then
paused = false
return
end
-- a double tap pauses
if touch.state == ENDED and touch.tapCount == 2 then
paused = true
ptext = "Tap to resume"
return
end
-- otherwise, movement adjusts the angles
azimuth = azimuth - touch.deltaX/WIDTH*math.pi
zenith = zenith - touch.deltaY/HEIGHT*math.pi
ca = math.cos(azimuth)
sa = math.sin(azimuth)
cz = math.cos(zenith)
sz = math.sin(zenith)
end
-- This adds a plank to the tables to be given to the mesh
function AddPlank(t)
-- initial conditions
local w = t.width
local h = t.height
local d = t.depth
local o = t.origin
local vertices = t.vertices or {}
local colours = t.colours or {}
local c = t.colour or color(205,170,124,255)
local n,m
local tt = {}
local l = t.light:normalize()
local v = {
w:normalize():dot(l),
h:normalize():dot(l),
d:normalize():dot(l)
}
local u
for i=0,5 do
n = math.floor(i/2)
m = 2*(i%2) - 1
for j=0,5 do
tt = {m,2*(j%2)-1,2*(math.floor((j+1)/3)%2) - 1}
u = o + tt[n+1]*w + tt[(n+1)%3+1]*h + tt[(n+2)%3+1]*d
table.insert(vertices,u)
table.insert(colours,
ColourShade(c,75 + 25*m*v[n+1])
)
end
end
return vertices,colours
end
function TrackPoints(a)
a = a or {}
local pts = a.points or {}
local t = a.start or 0
local r = a.step or .1
r = r*r
local s = a.delta or .1
local f = a.pathFunction or function(q) return q*vec3(1,0,0) end
local nf = a.normalFunction or function(q) return vec3(0,1,0) end
local b = a.finish or 1
local tpt = f(t)
table.insert(pts,{tpt,
tangent({delta = s, pathFunction = f, time = t}),
nf(t),t})
local dis
local p
while t < b do
dis = 0
while dis < r do
t = t + s
p = f(t)
dis = dis + p:distSqr(tpt)
tpt = p
end
if t > b then
t = b
p = f(b)
end
table.insert(pts,{p,
tangent({delta = s, pathFunction = f, time = t}),
nf(t),t})
tpt = p
end
return pts
end
local StarColours = {
color(255,249,205,255),
color(237,232,191,255),
color(205,201,165,255),
color(138,136,112,255)
}
function AddStar(t)
local o = t.origin
local s = t.size
local l = t.light:normalize()/(math.sqrt(3))
local vertices = t.vertices or {}
local colours = t.colours or {}
local b = RandomBasis()
local v,n,c
local bb = {}
for i=0,7 do
bb[1] = 2*(i%2)-1
bb[2] = 2*(math.floor(i/2)%2)-1
bb[3] = 2*(math.floor(i/4)%2)-1
v = bb[1]*b[1] + bb[2]*b[2] + bb[3]*b[3]
n = math.abs(v:dot(l))
c = ColourShade(StarColours[math.random(1,4)], 70+n*30)
for m=1,3 do
table.insert(colours,c)
table.insert(vertices,(o+s*(v - 2*bb[m]*b[m])))
end
end
return vertices,colours
end
function tangent(a)
local s = a.delta/2 or .1
local f = a.pathFunction or function(q) return q*vec3(1,0,0) end
local t = a.time or 0
local u = f(t-s)
local v = f(t+s)
return (v-u)/(2*s)
end
Tracks = {}
function Tracks.torus(p,q)
local innerRa = 10
local innerRb = 10
local outerR = 30
local trackFunction = function(t)
local it = p*t*2*math.pi
local ot = q*t*2*math.pi
return vec3(
(outerR + innerRb*math.cos(it))*math.cos(ot),
innerRa*math.sin(it),
(outerR + innerRb*math.cos(it))*math.sin(ot)
)
end
local trackNormal = function(t)
local it = p*t*2*math.pi
local ot = q*t*2*math.pi
return vec3(
innerRa*math.cos(it)*math.cos(ot),
innerRb*math.sin(it),
innerRa*math.cos(it)*math.sin(ot)
)
end
local coreFunction = function(t)
local ot = q*t*2*math.pi
return vec3(
outerR*math.cos(ot),
0,
outerR*math.sin(ot)
)
end
local maxHeight = innerRa
return trackFunction, trackNormal, maxHeight
end
function Tracks.mobius()
local r = 30
local trackFunction = function(t)
local a = 2*math.pi*t
return vec3(r*math.cos(a),0,r*math.sin(a))
end
local trackNormal = function(t)
local a = math.pi*t
return vec3(
math.sin(a)*math.cos(2*a),
math.cos(a),
math.sin(a)*math.sin(2*a))
end
return trackFunction,trackNormal,0
end
function Tracks.loop(p,q)
local r = 30
local h = 10
local w = vec3(0,30,0)
local trackFunction = function(t)
local a = 2*math.pi*t
return vec3(
r*math.cos(a),
h*math.sin(p*a),
r*math.sin(q*a))
end
local trackNormal = function(t)
return w - trackFunction(t)
end
return trackFunction,trackNormal,h
end
function GramSchmidt(t)
local o = {}
local w
for k,v in ipairs(t) do
w = v
for l,u in ipairs(o) do
w = w - w:dot(u)*u
end
if w ~= vec3(0,0,0) then
w = w:normalize()
table.insert(o,w)
end
end
return o
end
function RandomVec3()
local th = 2*math.pi*math.random()
local z = 2*math.random() - 1
local r = math.sqrt(1 - z*z)
return vec3(r*math.cos(th),r*math.sin(th),z)
end
function RandomBasis()
local th = 2*math.pi*math.random()
local cth = math.cos(th)
local sth = math.sin(th)
local a = vec3(cth,sth,0)
local b = vec3(-sth,cth,0)
local c = vec3(0,0,1)
local v = RandomVec3()
a = a - 2*v:dot(a)*v
b = b - 2*v:dot(b)*v
c = c - 2*v:dot(c)*v
return {a,b,c}
end
function SO3(u,v)
if u == vec3(0,0,0) and v == vec3(0,0,0) then
return {vec3(1,0,0),vec3(0,1,0),vec3(0,0,1)}
end
if u == vec3(0,0,0) then
u,v = v,u
end
if u:cross(v) == vec3(0,0,0) then
if u.x == 0 and u.y == 0 then
v = vec3(0,0,1)
else
v = vec3(u.y,-u.x,0)
end
end
local t = GramSchmidt({u,v})
t[3] = t[1]:cross(t[2])
return t
end
function ColourShade(c,t)
local s,r,g,b,a
s = t / 100
r = s * c.r
g = s * c.g
b = s * c.b
a = c.a
return color(r,g,b,a)
end