💾 Archived View for yaky.dev › 2022-11-30-dark-streets-1 captured on 2024-05-10 at 10:50:41. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2024-03-21)
-=-=-=-=-=-=-
First steps in writing a first-person shooter in PICO-8
I have been playing around with PICO-8 for a few months now. PICO-8 is a fantasy console, emulating a device-that-could-have-existed somewhere around 30-40 years ago. It includes great editors and an API designed for game development. PICO-8 is rather limited in resources, but that is the part of its charm - it makes you think about the efficiency of the code you write and avoids feature creep.
In this post, I will start writing a first-person shooter similar to Wolfenstein 3D and other early FPS games, beginning with basic solid-color raycasting.
Moving around in first person around a 3D level with solid-color walls
Thanks to Lode Vandevenne for great tutorials on raycasting
Lode's Computer Graphics Tutorial
Most modern 3D graphics (at their core) are created by rendering scenes that consist of triangular polygons, which involves a lot of calculations dealing with coordinates, depth, occlusion, texture mapping, vertex and pixel shaders, all of which are heavily parallelized and processed by dedicated GPUs.
About 30 years ago, such amount of computing power was simply not available on a single machine. Even simple textured 3D meshes might be difficult to render in real time. Instead, game developers used various clever techniques to create an illusion of three dimensions, while the core of the game logic is two-dimensional. This approach is often referred to as 2.5D. Wolfenstein 3D, Rise of the Triad, Doom 1 and 2, Duke Nukem, and Blood are all examples of 2.5D graphics, as they do not use a "true" 3D engine.
One of these clever techniques is raycasting. Level's walls are drawn in thin vertical slices, which allows drawing sections of walls that are further, smaller, thus creating a perception of depth and perspective.
First, setup the PICO-8 cart.
In the sprite editor, create a sprite that will be used for a wall. Set sprite flag #0. This flag will make it easier to define what sprite is a considered a wall.
In the map editor, place several walls.
In the code editor:
Set up the _init, _update, and _draw functions.
function _init() end function _update() end function _draw() end
Create a camera object. This will be the point from which player looks at the world.
cam={ x=12, y=12, a=0, -- angle at which camera is looking w2d=2 -- width-to-distance (fov 90) }
Note: W2D is a width-to-distance ratio used in calculating the field-of-view angle and how large objects appear at a distance (more on that later, when we get to drawing walls). Value of 2 means the field-of-vision is approximately 90 degrees. Value of 1 means the field-of-vision is approximately 60 degree. For all intents and purposes, I found 90 degrees to work the best.
Create a function to handle camera movement and turning by changing cam.x, cam.y, and cam.a. Call this function in _update.
function controls() if btn(⬅️) then cam.a+=0.01 end if btn(➡️) then cam.a-=0.01 end if btn(⬆️) then cam.x+=cos(cam.a)*0.5 cam.y+=sin(cam.a)*0.5 end if btn(⬇️) then cam.x+=cos(plr.a)*-0.25 cam.y+=sin(plr.a)*-0.25 end end
For debug purposes, we can draw the map and the position of the camera on the screen in _draw. It will make it easier to see which 3D object correspond to which 2D objects on the map.
map(0,0,0,0,16,16) circ(cam.x,cam.y,2,12)
We should get something like this where we can freely move around the map.
Top-down view with a map and a camera position
This is the key algorithm that makes the 3D illusion possible. In this post, we will start with drawing simple solid-color walls and then add textures, multiple floors and transparency in the future.
Cast a ray for every vertical 1-pixel slice of the screen, totalling 128 rays (PICO-8's horizontal resolution). Call the raycast function in _draw. SCRX is the screen X coordinate for which the ray is cast.
for scrx=0,127 do raycast(cam,scrx) end
First step is to calculate the vector for each ray. Assuming that our field of view is 90 degrees wide, the calculation can be something like this. 0.25 is 90 degrees in PICO-8, so start at 45 degrees to the left (positive angle value), and cast a ray for each 0.25/128 degrees
function raycast(cam,scrx) local raya= cam.a+0.125-0.25*scrx/128 local rayvx,rayvy= cos(raya),sin(raya) end
(We will revisit this later)
To better visualize it in 2D, we can draw a line for each ray:
line(cam.x,cam.y, cam.x+rayvx*64, cam.y+rayvy*64,8+scrx%8)
We will get something like this. Walking and "looking" around should draw the rays correctly from the camera position.
Top-down view with map, camera position, and rays drawn from the camera
Looks good so far. To simplify what we see and do for the next few steps, we can cast a single ray for the middle of the screen. (We will reverse this later)
--for scrx=0,127 do -- raycast(cam,scrx) --end raycast(cam,64)
Now we need to figure out where the ray hits a wall. We can take advantage of the following facts:
The idea of the algorithm is: Starting at the camera position, move along the ray's vector, check every map cel, until that cel is a wall. How do we "move along the ray's vector" though? The simple approach is to move in steps of the same pre-defined length. However, that will not be precise at larger step sizes, and expensive (and still not precise enough) at smaller step sizes. Instead, we can calculate distances to the next map cel along the X axis and the Y axis, and move to the next closest map cel.
Raycasting algorithm: (original)
This is the approach I used in the first version of the engine. However, it can be simplified and improved further. First, DIST2X and DIST2Y do not need to be recalculated every step because the distance between map cels after the one we start in is always the same (8 pixels). Second, X and Y do not need to be recalculated every step. Instead, start with calculating the current map cel coordinate CELX, CELY, and update them when moving across a cel boundary.
Raycasting algorithm: (improved)
Visual explanation:
Diagram describing traversing the map cel-by-cel
A similar algorithm is used to rasterize lines, and is called Digital Differential Analyzer (DDA).
Don't worry if you don't grok this immediately. It took me a while to wrap my head around it, especially the improved version.
Anyway, here is the code:
DIRX and DIRY keep track of which way the ray is moving for handling negative directions.
DIST and HITA are used for drawing walls.
For visualization, we mark every vacant cel and every wall the ray passes.
-- distance traveled local dist=0 -- current coordinates local x,y=cam.x,cam.y -- map cel coordinate and value local celx,cely=x\8,y\8 local cel=mget(celx,cely) -- direction of ray local dirx,diry= sgn(rayvx),sgn(rayvy) -- distances across map cel local dist4x,dist4y= abs(8/rayvx), abs(8/rayvy) -- distances to next map cel local dx,dy= abs(4+4*dirx-x%8), abs(4+4*diry-y%8) local dist2x,dist2y= abs(dx/rayvx), abs(dy/rayvy) -- which direction / angle -- the wall was hit from local hita=0 -- perform dda repeat if dist2x<dist2y then celx+=dirx dist+=dist2x dist2y-=dist2x dist2x=dist4x hita=0.25-dirx*0.25 else cely+=diry dist+=dist2y dist2x-=dist2y dist2y=dist4y hita=0.5+diry*0.25 end local cel=mget(celx,cely) if fget(cel,0) then -- ray hit a wall rect(celx*8,cely*8,celx*8+7,cely*8+7,12) else rect(celx*8+2,cely*8+2,celx*8+5,cely*8+5,11) end until dist>=64
You should get something similar to this:
Top-down view with camera, ray, and highlighted map cels
This looks good. Now we can return to casting all 128 rays and remove the visualizations.
With wall collisions working, drawing a solid-color wall is simple. Using the distance to the wall (DIST) and its height (8), calculate the apparent size on the screen. At distance DIST units, the view height is DIST*CAM.W2D units (see the note about CAM.W2D above), which makes it easy to calculate wall's apparent height on the screen, SCRH. Use HITA to choose different colors for different sides. (HITA has another use for textures - more on that in the next post) Then, draw a line around the middle of the screen (64).
Visual explanation:
Diagram describing how to calculate visible height
Code:
-- ray hit a wall -- find the wall height -- on screen local scrh=8/(dist*cam.w2d)*128 -- find y-coordinate on screen --local scry=64-scrh/2 -- draw the wall line(scrx,64-scrh/2, scrx,64+scrh/2, 8+hita/0.25) break
We should see a colorful pseudo-3D environment that we can move around in:
First-person 3D view with solid-color walls
We did it!
In the next several posts, I will go over how to:
PICO-8 textured raycaster (Dark Streets devlog #2)
(C) 2024 CC BY Anton Yaky