Add more lighting and shadows before that dithering pass and then see how far you can push this :)
Very cool, thanks for sharing!
But using 16-bit Z-buffer for 1-bit display seems to be an overkill. Sorting polygons works mostly fine if done right, especially if BSP tree is already used.
Surface-Stable Fractal Dithering
Today I want to talk about my journey writing a 3D software renderer for the Playdate.
At the beginning, I didn't have any performance baseline. I had no idea how feasible my idea was, so I started with a simple test: a raycaster.
I had written one many years ago, based on the code examples from Ken Silverman's webpage, and since then it has basically become what I write whenever I want to test the 3D processing power and screen drawing performance of a low-power device.
You don't need much to write, compile, and run this kind of test, and it gives you a rough idea of the device's processing power. More specifically, it lets you benchmark the areas that matter most for a 3D renderer: how fast it handles floats and vector math, how fast it performs memory operations, how quickly it can draw to the screen, and whether there are any issues with setting up and using a framebuffer.
The initial results were worse than I expected. The performance was bad enough that it became clear this was not going to be an easy project.
To be clear, my raycaster was not written with top performance in mind. This was never about getting a raycasting engine working on Playdate. I'm pretty sure that, with enough optimizations, that can be done. The point was to get a feeling of how much of a powerhouse this device is (or isn't).
This test helped me to get a better understanding of the capabilities of the handheld. It was clear that it didn't have enough power to draw 3D scenes at the level of early 3D accelerators, such as the 3dfx Voodoo, or compete with what you can do on the original PlayStation. But there was hope. The Playdate has a small, low resolution screen, and because it uses a 1-bit display, there are huge memory gains to be had.
From the results I was still confident that I could make something that, from a player's perspective, felt close to the 3DO or Sega Saturn era.
I should clarify what I mean by that. When I talk about something similar to a 3DO or Saturn game, I mean the perceived result on screen. I don't mean it in a technical sense, because the hardware of those consoles is very different from the Playdate.
Both the 3DO and Saturn had custom hardware for drawing polygons (i.e. quads) and modest CPUs. Since the graphics hardware was supposed to do most of the heavy lifting, the manufacturers could get away with a cheaper CPU. The downside was that these machines were not very flexible. They were good at rendering graphics in the specific way their hardware was designed for, but struggled when a game needed a different approach. This is one of the reasons why their Doom ports performed so poorly: Doom's renderer was not a natural fit for the way those consoles wanted to draw 3D.

The Playdate, on the other hand, does not have 3D hardware. If we want to render 3D graphics, everything has to be done on the CPU.
There is no polygon sorting. No rasterization hardware. No depth buffer. No automatic clipping of triangles against the view frustum. No texture mapping, filtering or perspective correction. Nothing.
Before going further, it's worth explaining what we actually mean when we talk about 3D graphics.
We live in a three dimensional world. Objects have depth, height, and width. They can be far away or close to us, above or below us, to the left or to the right. We are able to perceive these three spatial dimensions and move through them.
But our eyes only receive flat, two dimensional images projected from that world onto the retina. Our brains infer the 3D world through cues such as perspective, motion, scale, shadows, and occlusion.
Computer graphics use similar tricks.
A 3D game does not draw a world in actual 3D. The screen is flat. Even what we call 3D screens still work by displaying flat, 2D images. What we do is process the information that describes a 3D scene, place a camera inside that space, then project the scene onto a 2D plane, in a similar way to how the real 3D world is projected onto the 2D plane of our eyes.
The rasterizer is the part of the renderer that takes the projected geometry and turns it into pixels, which is what we use to draw on computer screens. It's the one that figures out how to paint the textures, handles overlapping polygons, clipping, etc. and writes the results in the framebuffer.
That framebuffer contains the 2D image of what we call a frame, and it is what gets displayed on the screen.
On modern hardware, most of this work is done by the GPU. On the Playdate, since there is no GPU, the CPU has to transform the polygon vertices, project them, clip them, sort them, shade/texture them, and write the resulting pixel into the framebuffer. And it needs to do it fast enough to produce a new frame many times per second.
And that's the challenge. The problem is not just "can it draw 3D?". Even a ZX Spectrum can draw 3D, if you give it enough time. The real question is "can it do it fast enough to be playable in real time?".
My renderer loads Quake BSP map files. I picked this format for two main reasons.
First, it means I did not have to write a level editor and map compiler from scratch. I can use TrenchBroom for level design and ericw-tools to compile the map, visibility data, and lighting. That saves me a huge amount of time.
Second, Quake's BSP format is designed to precompile everything to speed up rendering in slow machines, exactly what I needed.
BSP stands for binary space partitioning. The basic idea is to recursively split space with planes. In 3D, each split divides the world into two half-spaces. Repeating that process creates a tree. Internal nodes contain splitting planes, and the leaves contain regions of space. In a Quake style BSP, those leaves represent convex chunks of the world, and each leaf knows which faces touch it and which other leaves might be visible from it.
The map compiler does a lot of expensive work ahead of time. It takes the brush geometry, splits it into a BSP tree, computes visibility between leaves, and stores that visibility as the PVS (the potentially visible set). At runtime, the renderer finds the leaf that contains the camera and uses its PVS to reject large parts of the map before rasterization even begins. That's huge time savings.
The downside is that this approach prefers static worlds. It also requires levels to be designed with visibility in mind. For example, a straight corridor connecting two large rooms may expose too much geometry at once, so an L-shaped corridor is often better because it blocks the line of sight and gives the PVS more opportunities to remove unseen geometry.
Using BSPs made this project much more realistic. Reusing tools saved me time. It's always a good idea to check existing tools and see whether you can reuse and adapt them to your needs instead of reinventing the wheel. Using mature, battle-tested tools for mapping, compilation, visibility, and lighting let me spend my time on the parts of the project that mattered most.
What I made entirely from scratch was the actual 3D software renderer. I didn't want to just port Quake or use someone else's code. I wanted to build it myself so I could be sure I understood it, so I could iterate on it, to test all kinds of ideas, to be able to find the look of the game.
The z-buffer is a depth buffer. A standard depth buffer stores how far away the currently drawn pixel is from the camera. When the renderer wants to draw a new pixel, it compares that pixel's depth against the value already stored in the z-buffer. This is how the renderer prevents distant objects from being drawn over closer ones.
My first versions didn't use a z-buffer, I tried the classic painter's algorithm approach: drawing from back to front.
But after testing, this version without a z-buffer did not give me meaningful performance gains, it only gave me more headaches.
So I added a z-buffer.
I used a 16-bit reciprocal depth buffer. Instead of storing linear distance directly, it stores a value based on inverse depth. That gives me more precision close to the camera, where small depth errors are easier to notice, and it's large enough to avoid the artifacts I saw when using a smaller 8-bit depth buffer.
The z-buffer also makes dynamic objects and masked textures much easier to deal with. Doors, elevators or pickups can use the same depth test as the level geometry, and cutout textures, such as water, glass, railings or sprites, can simply skip invisible pixels while drawing and depth-testing the visible ones normally.
I experimented with different approaches to see what I could get away with.
The cheapest method is affine texture mapping. With affine mapping, you interpolate texture coordinates directly across the screen. It is fast, but it is only correct for polygons that are parallel to the screen, otherwise, the texture starts to warp. Remember PlayStation textures?
In my case the distortion was visible enough. Usually what you do to prevent this is to subdivide your geometry, but I decided against it and benchmark perspective-correct mapping first instead.
I went with the classic perspective-correct texture mapping. Instead of directly interpolating the texture coordinates across a polygon (which causes the warping you saw in the PlayStation example), the renderer interpolates values that stay mathematically correct after projection. Specifically, it interpolates 1/z (inverse depth), u/z, and v/z, then reconstructs the actual texture coordinates per pixel by dividing.
This gives accurate looking textures with no warping, but it comes at a cost: every pixel needs a division (reciprocal) to reconstruct the coordinates, which is expensive on the Playdate’s CPU. So I cheat a little: I only calculate the full reciprocal every few pixels, then use a fast approximation for the pixels in between, refining from the previous value. The visual difference is negligible, but the performance win is meaningful, especially since texture mapping is one of the most expensive parts of the renderer.
Lighting was one of the strangest parts of the renderer because the Playdate screen cannot show color, not even a grayscale. Each pixel is either black or white.
Quake BSP files include precomputed lightmaps, and ericw-tools can produce improved lighting with features such as bounce lighting and radiosity. My renderer reads that data and converts it into precomputed vertex lighting information that is cheaper to use at runtime.
Still, with a 1-bit display you can only output a black or white pixel. So how do you show brightness on a 1-bit screen?
The answer is dithering.
Dithering is a way of simulating intermediate tones by arranging black and white pixels in patterns. A single pixel is still only black or white, but a group of pixels can look darker or lighter depending on the ratio and placement of those pixels. From a distance, or while the image is moving, the eye blends the pattern into a perceived tone.
The renderer uses an 8x8 Bayer matrix. For every pixel, the renderer compares the computed brightness against the threshold value at that pixel's position in the Bayer pattern. If the brightness is above the threshold, the pixel becomes white. If it is below the threshold, the pixel becomes black.
Across an area, this creates the impression of grayscale, even though the final image uses only black and white.
The hard part is that dithering can easily become noise. A fully textured, fully lit scene may be technically impressive but visually hard to read on a 1-bit display. And that was my next fight.
One of my main visual references was Return of the Obra Dinn. I also love the look of Jet Set Radio. So from the start I knew I wanted strong outlines and simple textures. Kind of a 1-bit cel-shading look.
I still tested the default approach: standard textures, applying lighting and then 1-bit dithering.
As I suspected, it was too noisy. The geometry and textures were hard to read. With more iterations, it could have been improved, but that test proved to me that the minimalist, cel-shaded approach made more sense.
Then I tested the map using simple textures and strong outlines, the 1-bit cel-shading look.

That immediately improved readability. And most importantly, it gave the game a stronger identity. I loved the look. I knew this was the way.
The textures look a bit silly in the map editor, but they work in-game, which is what matters.

I tried all kinds of tricks and ran benchmarks for every change, trying to find the best approach for the Playdate. These are my findings:
#if TARGET_PLAYDATE #define PD_HOT __attribute__((section(".text.hot"))) __attribute__((aligned(32))) #define PD_FORCEINLINE inline __attribute__((always_inline)) #else #define PD_HOT #define PD_FORCEINLINE inline #endif
If you want to optimize your Playdate game, treat everything I wrote here as an idea, not as objective truth. The things that worked for me may not work for you, and you may need to make different changes. Run your own benchmarks, and make sure every optimization you keep is actually doing something.
Remember: the best optimizer is you.
Writing a 3D renderer for the Playdate has been a long and fun journey. I wasn't sure if it was even possible at the start. And each time I tried something, it ran so slowly that it was hard not to feel a bit demotivated. But I pushed on.
My current renderer is far from perfect, but it's good enough. If you want to make actual progress, try to aim for "good enough".
Getting the renderer to a playable state has allowed me to start prototyping gameplay ideas. It has allowed me to get a playable prototype that is starting to feel like an actual game. And as a game developer, that's what matters.
Thanks for reading, and have an excellent day!