This is part 3 of me walking through the process of software rendering 3D objects on Pico 8. Click here to give it a try, and here to download it. Here are the previous posts:
This time I’m adding support for rotation, specifically rotating the 3D cube with its centre being the origin. As you move it around the screen, instead of it rotating around the middle of the screen it rotates like a Rubik’s Cube that you are holding in your hand.
The controls are slightly more complex this time because I didn’t want to lose the ability to move and zoom the cube. By default the d-pad changes the x and y rotation, if you hold down the X button they change to the x and y position. Finally, if you press the O button then they change to z position and rotation. I made the HUD display change colour based on what is currently being affected by the d-pad to hopefully make it clearer.
The _init function is mostly unchanged, the only difference is the final two lines. First I create a 3D vertex to store the current rotation state, one number for each of the axes. Then I declare a variable named pressed to store whether either of the Pico 8 buttons are pressed. If you press both buttons at once it is as if you are pressing X, it effectively ignores the O button at that point.
rot={x=0,y=0,z=0}
pressed=nil
The update function is now slightly more complex. There is an outer if/elseif
statement which is effectively a switch statement that branches based on which
of the buttons are currently pressed. I’m updating the pressed variable to
hold on to it as well, this is used in the draw function to highlight on the
screen what the d-pad is currently changing.
The remaining code is nearly identical to the previous post just copied twice. It’s then just changing which variables are updated in each branch.
function _update60()
if btn(❎) then
pressed=❎
if (btn(➡️)) pos.x+=.05
if (btn(⬅️)) pos.x-=.05
if (btn(⬇️)) pos.y+=.05
if (btn(⬆️)) pos.y-=.05
elseif btn(🅾️) then
pressed=🅾️
if (btn(➡️)) rot.z+=.01
if (btn(⬅️)) rot.z-=.01
if (btn(⬇️)) pos.z-=.05
if (btn(⬆️)) pos.z+=.05
else
pressed=nil
if (btn(➡️)) rot.y+=.01
if (btn(⬅️)) rot.y-=.01
if (btn(⬇️)) rot.x+=.01
if (btn(⬆️)) rot.x-=.01
end
end
The following three functions are the real guts of this cart. Each one rotates a vertex around one of the three (x, y, z) axes. If you imagine turning around on the spot with your arms out to face another direction, that is you rotating around the y axis. Your hands will have moved position in the other two, but they are no higher or lower than they were. That means in each of these functions the calculations do not touch the value for the axis in the name.
Each one accepts the angle of rotation for that axis, it then converts that into a vector which is pointing in that direction. The vector is then used to work out the new directions of the two axes being changed in the old coordinate system. This is then used to return a new vertex with the updated values.
--rotate around the y axis
--
--effectively a 2d (x,z) plane
--i.e. top down
--
--cos and sin of the given angle
--produce a unit vector pointing
--in the angles direction
--
--( c, s)=pointing down new x in
-- old coordinate system
--(-s, c)=pointing down new z in
-- old coordinate system
--
--since a unit vector is a ratio
--the vector will not change
--size, only point in a new dir
--
--p.x*c=how much old x in new x
--p.z*s=how much old z in new x
function rotate_y(p,a)
local c=cos(a)
local s=sin(a)
return {
--project (x,y) onto (c,s)
--z is in the opposite
--direction of the new x
--so is subtracted
x=p.x*c-p.z*s,
y=p.y,
--project (x,z) onto (-s,c)
z=p.x*s+p.z*c
}
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
}
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
}
end
This function is for drawing the current state of variables to the screen. I created this since there was some logic around setting the colour, and the two different displays (rotation and position) are nearly identical. The function takes four arguments, the first is the name of the vertex we’re printing on the screen, the second is the screen line to start writing, the third is the actual vertex being displayed and finally hl is a boolean which is used to decide whether to highlight the x and y lines. Since the z line highlighting is controlled with the same button state for both displays it is inline here.
There are two variations of the print statement, and this is using both of them. The first line calls it with four arguments, these are firstly the text, then the x and y position on the screen to print it, and finally the colour you want the text to be. The colour index to use can be found written out at the bottom of the sprite tool when you hover the mouse over the colours in the picker.
The lines setting the clr variable are using the Lua equivalent of what is
known as a ternary operator. This is effectively a single line if/else
statement, where each provide a value. It’s actually a bit of a trick in Lua, in
that it’s not truly a ternary operator and can have surprising behaviour if one
of the values is nil. I think here it is simple and clearer than the equivalent
fully written out if statement.
The .. operator is for string concatenation.
function hud(name,ln,v,hl)
print(name,0,ln,8)
local clr=hl and 9 or 8
print("x:"..v.x,clr)
print("y:"..v.y,clr)
clr=pressed==🅾️ and 9 or 8
print("z:"..v.z,clr)
end
There are two new lines in the _draw function to call the hud function. Once
for the current rotation values, once for position.
hud("rot",0,rot,not pressed)
hud("pos",98,pos,pressed==❎)
The next post I think will introduce back face culling. This technique allows you to only draw triangles facing towards the screen, making the cube appear like a solid object.