Daniel Keast

Pico 8 3D Rendering: Camera

Pico 8, Programming

As of the last post in this series we had a textured floating cube in space. You could rotate it, move it and zoom in and out. This falls down as soon as you have multiple objects however. That rotation and movement was performed directly on the cube, with the camera effectively staying in place looking down the z axis. We need to pick apart two concepts which are currently tied together. First we need to be able to move and rotate objects in the world, and then we need to be able to move the camera and look at things without them being related to each other.

As always, click here to give it a try, and here to download it. Here are the previous posts:

  1. Projection
  2. Wireframe
  3. Rotation
  4. Culling
  5. Inside Test
  6. Scanline Rasterisation
  7. Texture Mapping

Whereas before we just had a single set of position and rotation variables for the state of the cube in _init, now we have a table of cubes. I have created the values for two cubes here, each with its own state.

We then have a camera table, which also has the same data structure. Note, however, that it does not have a z angle. I have not implemented leaning left and right, and so do not need it.

cubes={
 {
  p={x=0,y=0,z=2.5},
  r={x=0,y=0,z=0}
 },
 {
  p={x=0,y=1,z=1.5},
  r={x=2,y=0,z=0}
 }
}  

cam={
 p={x=0,y=0,z=-2.5},
 r={x=0,y=0}
}

The _update function has had significant changes. Previously, since we were always looking down the z axis, we could move by simply adding or subtracting to each axis in the position table. If we carried on with this approach pressing down would walk backwards when the cart starts, but if you look to the left and press down you would still walk in the same direction. Now we need to use the y angle which is our rotation around the vertical axis. This is the direction we are facing. Applying different variations of the sin and cos of this angle allows us to walk forwards, backwards and strafe.

if btn(🅾️) then
 if btn(➡️) then
  cam.p.x+=cos(cam.r.y)/10
  cam.p.z+=sin(cam.r.y)/10
 end
 if btn(⬅️) then
  cam.p.x-=cos(cam.r.y)/10
  cam.p.z-=sin(cam.r.y)/10
 end
 if (btn(⬇️)) cam.r.x+=.01
 if (btn(⬆️)) cam.r.x-=.01
else
 if (btn(➡️)) cam.r.y+=.01
 if (btn(⬅️)) cam.r.y-=.01
 if btn(⬇️) then
  cam.p.x+=sin(cam.r.y)/10
  cam.p.z-=cos(cam.r.y)/10
 end
 if btn(⬆️) then
  cam.p.x-=sin(cam.r.y)/10
  cam.p.z+=cos(cam.r.y)/10
 end
end

I’ve added a new section to the end of the _update function which applies some changes to the rotation and position of the two cubes. There’s nothing particularly clever happening here, I’m just taking the sin and cos of the result of the time function to get a loop and give something interesting to look at.

--values selected through the
--methodical process of random
--choice
local t=time()/10
cubes[1].r.y=sin(t)
cubes[1].r.x=cos(t)
cubes[1].r.z=sin(t*0.3)
cubes[1].p.y=sin(t*5)-1
cubes[1].p.x=cos(t*5)+2
 
t/=4
cubes[2].r.x=sin(t)
cubes[2].r.y=cos(t)
cubes[2].r.z=cos(t*0.9)

I picked apart the move and project function again. This is because I need to run the move independently of projecting in the _draw function.

function move(a,x,y,z)
 return {
  x=a.x+x,
  y=a.y+y,
  z=a.z+z,
  u=a.u,
  v=a.v
 }
end

function project(v)
 return {
  x=flr((v.x/v.z+1)/2*128),
  y=flr((v.y/v.z+1)/2*128),
  u=v.u,
  v=v.v
 }
end

Now in the draw function we iterate through the entries in the cube table. For each cube we:

  1. Rotate it on its own axis so that it doesn’t go off centre.
  2. We then place it in the world using its position variables.
  3. We then subtract the camera position from the cube’s world position. This makes the camera the new origin point.
  4. Finally we rotate around the new origin, in the opposite direction of the camera to bring the cube “into frame”. This is akin to when you look right the world appears to move left.

When rotating the world for the camera transform, first we need to rotate on the y axis and then on the x. Otherwise when you look up and down your back will always be down the z axis. This means that if you look left or right and then look up, you would actually lean to the side.

After this we then project the points and rasterise the triangle using the exact same process as before.

for cube in all(cubes) do
 local proj={}
 for v in all(verts) do
  v=rotate_x(v,cube.r.x)
  v=rotate_y(v,cube.r.y)
  v=rotate_z(v,cube.r.z)
  v=move(v,
   cube.p.x,
   cube.p.y,
   cube.p.z)

  v=move(v,
   -cam.p.x,
   -cam.p.y,
   -cam.p.z)
  v=rotate_y(v,-cam.r.y)
  v=rotate_x(v,-cam.r.x)

  add(proj,project(v))
 end

 for t in all(tris) do
  local a=proj[t[1]]
  local b=proj[t[2]]
  local c=proj[t[3]]
  draw_tri(a,b,c)
 end
end

There are multiple things you might notice that are not correct now. Firstly one of the cubes is always drawn on top of the other one, even if it should be in front. This is actually the same problem pointed out in the post on filling in triangles using the inside test. Secondly when you walk into one of the cubes everything goes haywire. This is down to the z value becoming 0, and then going negative. First we get divide by zero errors, which in Pico 8 is not a crash but will produce results of infinity. Second we start getting inverted projections, possibly with it only happening to half of the triangle. I’ll get to fixing those over the next couple of posts.