Daniel Keast

Pico 8 3D Rendering: Inside Test

Pico 8, Programming

For the latest update to my Pico 8 software renderer I have filled in the triangles with colour so that the cube is no longer a wireframe. To do this I reused the front function in the previous culling post. The function takes three points, a, b, and c. Previously I used the three points of the triangle I was drawing. The function returns true if the points go in a clockwise direction, and false if not. The triangle is made of three lines (a->b), (b->c), and (c->a). For any arbitrary screen pixel if we use the points of one of the lines and then the coordinates of the pixel the function will return whether the clockwise direction is unchanged. If that direction is unchanged when using the pixel coordinates with all three lines, then that point must be inside the triangle. We can then walk through the pixels of the screen and set a colour when this condition is true.

The frame rate is truly awful in this cart. The Pico 8 performance display says I am using 6 times the CPU available. It’s not unlike the experience of playing things like Hard Drivin’ on the old home computer systems. The reason is that for every pixel in the bounding box surrounding the triangle we are doing multiple calculations and setting them on one-by-one. The poor little system cannot cope with what I am asking of it.

Conceptually how this cart works is similar to how modern GPUs rasterise triangles. The difference is though that they have thousands of tiny processing units that can run the calculations for each pixel in parallel. With this method of rasterising each pixel is completely independent and can be checked separately. This is definitely not the case for Pico 8, and so in the next post this will all be replaced with the approach that is much more common for software rendering. In the meantime, 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

The first change to make is adding a fourth field to each of the triangle tables. This is to denote its colour.

tris={
 --front
 {1,2,3,2},
 {1,3,4,3},
 --back
 {8,7,6,4},
 {8,6,5,5},
 --left
 {5,6,2,6},
 {5,2,1,7},
 --right
 {4,3,7,8},
 {4,7,8,9},
 --top
 {5,1,4,10},
 {5,4,8,11},
 --bottom
 {2,6,7,12},
 {2,7,3,13}
}

I changed the _update60 function in all the previous posts to _update. This is because the frame rate is so bad. It doesn’t make a significant improvement since the bottleneck is in the drawing rather than the update, but it feels sensible.

function _update()

I made a slight change to the front function. Previously I was checking whether the calculation result is smaller than 0. Now it is less than or equal to. This is to minimise the gaps between the triangles. Otherwise when the result is 0 both triangles on a line will not own the pixel. This is still not perfect however, when rotating the cube you can still see some gaps occasionally. This is due to floating point arithmetic, and more visible on Pico 8 because of the resolution of the screen. It is fixable, but the next approach I will take does not have this issue at all so it doesn’t feel worthwhile here.

function front(a,b,c)
 return (b.x-a.x)
  *(c.y-a.y)
  -(b.y-a.y)
  *(c.x-a.x)<=0
end

Here is the function performing the inside check for each pixel. The arguments a, b, and c are the points of the triangle in question. The point v is the vertex for the pixel we are testing.

The final argument, f, is for facing. I am passing in which direction the triangle is expected to be wound. This means that I can perform the inside check correctly for triangles facing away. If the result for the call to front is the same as the call to front for the entire triangle then it should be drawn. This however highlights another issue. If you disable both front and back culling in this cart you will get a corrupted image. The triangles are drawn in an arbitrary order, and so overwrite each other. This is another problem for a future post!

function inside(a,b,c,v,f)
 return front(a,b,v)==f
  and front(b,c,v)==f
  and front(c,a,v)==f
end

This following function is the one which actually renders each triangle. First we check if should be culled, and if so return early. Then we find the bounding box of the triangle on the screen. Since the min and max functions only take two arguments they need to be nested calls. Then we iterate through every pixel in that box, checking if it’s inside the triangle, and setting it to the correct colour if so. Here I am declaring the pixel table outside of the loop just so that we’re not creating a new one for every single pixel and causing the garbage collector unnecessary work.

function draw_tri(a,b,c,clr)
 local f=front(a,b,c)

 if (f and cull.front)
   or (not f and cull.back) then
  return
 end

 --find the bounding box of the
 --triangle
 local minx=min(min(a.x,b.x),c.x)
 local maxx=max(max(a.x,b.x),c.x)
 local miny=min(min(a.y,b.y),c.y)
 local maxy=max(max(a.y,b.y),c.y)

 --walk every pixel in the
 --bounding box, setting it if
 --it's in the triangle
 local p={}
 for y=miny,maxy do
  for x=minx,maxx do
   p.x=x
   p.y=y
   if inside(a,b,c,p,f) then
    pset(x,y,clr)
   end
  end
 end
end

Then there is a small change to the _draw function to remove the previous triangle drawing code, and call out to the new function.

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,t[4])
end