Daniel Keast

Pico 8 3D Rendering: Texture Mapping

Pico 8, Programming

Now it’s time to add a texture to the cube. I’ve done this with the use of the tline function in Pico 8.

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

When adding a texture to a polygon we need to know where we are getting the colour data from, and how to map it across the face. In this implementation I use tline which uses map coordinates for the starting position, and two values to tell it how much to increment in both directions after each pixel it draws. This allows it to sample colour data as it draws the lines of our triangles. Since the function increments the map position by a linear amount after every pixel it means that you get warping. This is known as affine texture mapping, and is exactly what caused that behaviour on the PlayStation 1. To have perspective correct textures for a triangle angled away from you tline would need to increase the amount it’s stepping along the map as the triangle recedes away from the screen.

The first change is in the setup of the verts table in _init. We now have two more coordinates: u and v. These have nothing to do with UV radiation, but are the horizontal and vertical texture coordinates. These point to tiles on the Pico 8 map data. I’ve gone with an 8x8 section of the map to give it a reasonable amount of detail.

There are also now significantly more vertices. This is because the points that used to be shared across triangles now need to have different texture coordinates from each other. Each corner of the old cube was used in three triangles, which worked well when it is only representing position. Now each face of the cube using that point needs to pin a different part of the texture to it.

verts={
 --front
 {x=-1,y=-1,z=-1,u= 0,v= 0},
 {x=-1,y= 1,z=-1,u= 0,v= 8},
 {x= 1,y= 1,z=-1,u= 8,v= 8},
 {x= 1,y=-1,z=-1,u= 8,v= 0},

 --back
 {x= 1,y=-1,z= 1,u= 0,v= 0},
 {x= 1,y= 1,z= 1,u= 0,v= 8},
 {x=-1,y= 1,z= 1,u= 8,v= 8},
 {x=-1,y=-1,z= 1,u= 8,v= 0},

 --top
 {x=-1,y=-1,z=-1,u= 0,v= 0},
 {x= 1,y=-1,z=-1,u= 8,v= 0},
 {x= 1,y=-1,z= 1,u= 8,v= 8},
 {x=-1,y=-1,z= 1,u= 0,v= 8},

 --bottom
 {x=-1,y= 1,z= 1,u= 0,v= 0},
 {x= 1,y= 1,z= 1,u= 8,v= 0},
 {x= 1,y= 1,z=-1,u= 8,v= 8},
 {x=-1,y= 1,z=-1,u= 0,v= 8},

 --left
 {x=-1,y=-1,z= 1,u= 0,v= 0},
 {x=-1,y= 1,z= 1,u= 0,v= 8},
 {x=-1,y= 1,z=-1,u= 8,v= 8},
 {x=-1,y=-1,z=-1,u= 8,v= 0},

 --right
 {x= 1,y=-1,z=-1,u= 0,v= 0},
 {x= 1,y= 1,z=-1,u= 0,v= 8},
 {x= 1,y= 1,z= 1,u= 8,v= 8},
 {x= 1,y=-1,z= 1,u= 8,v= 0},
}

The tris table has had its colour value removed, and has been updated to use the extra vertices.

tris={
 { 1, 2, 3},
 { 1, 3, 4},
 { 5, 6, 7},
 { 5, 7, 8},
 { 9,10,11},
 { 9,11,12},
 {13,14,15},
 {13,15,16},
 {17,18,19},
 {17,19,20},
 {21,22,23},
 {21,23,24}
}

The projection and rotation functions have had a small change to pass through the new u and v coordinates. Everything else is identical.

function project(v)
 --move
 local x=v.x+pos.x
 local y=v.y+pos.y
 local z=v.z+pos.z

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

function rotate_x(p,a)
 local c=cos(a)
 local s=sin(a)

 return {
  x=p.x,
  y=p.y*c-p.z*s,
  z=p.y*s+p.z*c,
  u=p.u,
  v=p.v
 }
end

function rotate_y(p,a)
 local c=cos(a)
 local s=sin(a)

 return {
  x=p.x*c-p.z*s,
  y=p.y,
  z=p.x*s+p.z*c,
  u=p.u,
  v=p.v
 }
end

function rotate_z(p,a)
 local c=cos(a)
 local s=sin(a)

 return {
  x=p.x*c-p.y*s,
  y=p.x*s+p.y*c,
  z=p.z,
  u=p.u,
  v=p.v
 }
end

The calculation of d in the draw_tri function has been updated to also interpolate the u and v coordinates for the new point.

local d={
 x=a.x+ac_ratio*(c.x-a.x),
 y=b.y,
 u=a.u+ac_ratio*(c.u-a.u),
 v=a.v+ac_ratio*(c.v-a.v)
}

The main change for this post is in the two flat triangle drawing functions. We now have to check that the points are in order horizontally, otherwise the tline function will not draw anything. This is because the mdx and mdy arguments, which are used to walk along the map data, need to be in the same direction as the line.

We interpolate the u and v values for each point in a very similar way to the x and y values. They are effectively simply different pieces of data that we need to perform the same calculations on. We then use these to tell tline where to look in the map data for the pixel colour.

function ⬆️tri(a,b,c)
 --if b.x is to the right of c.x
 --then the increment for the
 --texture sampling will be in
 --the wrong direction.
 if (b.x>c.x) b,c=c,b

 local height=b.y-a.y
 local slpx1=(b.x-a.x)/height
 local slpx2=(c.x-a.x)/height
 local slpu1=(b.u-a.u)/height
 local slpu2=(c.u-a.u)/height
 local slpv1=(b.v-a.v)/height
 local slpv2=(c.v-a.v)/height

 --flat bottomed, so all the
 --starting points are at a 
 --
 -- a
 --b c
 local curx1=a.x
 local curx2=a.x
 local curu1=a.u
 local curu2=a.u
 local curv1=a.v
 local curv2=a.v

 for y=a.y,b.y do
  local width=curx2-curx1
  if width>0 then
   tline(
    curx1,y,curx2,y,
    curu1,curv1,
    (curu2-curu1)/width,
    (curv2-curv1)/width)
  end
  curx1+=slpx1
  curx2+=slpx2
  curu1+=slpu1
  curu2+=slpu2
  curv1+=slpv1
  curv2+=slpv2
 end
end

function ⬇️tri(a,b,c)
 --if a.x is to the right of b.x
 --then the increment for the
 --texture sampling will be in
 --the wrong direction.
 if (a.x>b.x) a,b=b,a
 
 local height=c.y-a.y
 local slpx1=(c.x-a.x)/height
 local slpx2=(c.x-b.x)/height
 local slpu1=(c.u-a.u)/height
 local slpu2=(c.u-b.u)/height
 local slpv1=(c.v-a.v)/height
 local slpv2=(c.v-b.v)/height

 --flat topped, so the starting
 --points are at a and b 
 --
 --a b
 -- c
 local curx1=a.x
 local curx2=b.x
 local curu1=a.u
 local curu2=b.u
 local curv1=a.v
 local curv2=b.v

 for y=a.y,c.y do
  local width=curx2-curx1
  if width>0 then
   tline(
    curx1,y,curx2,y,
    curu1,curv1,
    (curu2-curu1)/width,
    (curv2-curv1)/width)
  end
  curx1+=slpx1
  curx2+=slpx2
  curu1+=slpu1
  curu2+=slpu2
  curv1+=slpv1
  curv2+=slpv2
 end
end

This feels pretty cool. A textured cube just from drawing lines using maths. Next will either be fixing the issue mentioned in the inside test post where if you do not cull any face you get a corrupted image, or it might be drawing multiple cubes.