The Roller Coaster

loopspace

2014-06-13

Creative Commons License

Contents

  1. Home

  2. 1. Introduction

  3. 2. The Code Logic

  4. 3. Defining the Track

  5. 4. The draw Function

  6. 5. Defining the Meshes

  7. 6. Track Building

  8. 7. Auxiliary Functions

  9. 8. The Code

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:

o±w±h±d

where o is the origin, w the width, h the height, and d 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 ±1s 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 [0,1].

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