Fullscreen, Round 3Thanks everybody for all the suggestions. I tried everything, literally, and concluded that the best way to maintain the game's style and fix the fullscreen discomfort was to stabilize the swimming dither and subdue the flickering dots. I got there in the end, with a few compromises. This is the 3rd full devlog post I've written on this. For each previous version I'd get an idea or find something else to try while checking over it. At this point, don't even care.
Dithering ProcessFirst, a quick explanation. Obra Dinn renders everything internally in 8-bit grayscale then converts the final output to 1-bit in a post-processing pass. The conversion from 8-bit to 1-bit is handled by comparing each source image's pixel to the corresponding dot in a tiling dither pattern. If the image pixel value is greater than the dither pattern dot value, the output bit is set to 1. Otherwise it's 0. The output gets reduced to 1-bit and the viewer's eye will merge the pixels back together to approximate more bits.
Thresholding a source image by a dither pattern
The two components of this process are the source image and the dither pattern. Obra Dinn uses two distinct patterns for different cases: an 8x8 bayer matrix for a smoother range of shades, and a 128x128 blue noise field for a less ordered output.
bayer / blue noise
In-engine result without wireframe lines. Bayer on the sphere, blue noise everywhere else.
Hold Still PleaseThe basic dithering process works great for static images and much less great for moving or animated images. When the source image changes from frame-to-frame the static dither pattern and low resolution output become a major problem. What should be solid shapes and shades now read as a wiggling mess of pixels.
Moving the sphere
These days, dithering is mostly used when the source image is either static or the output has a high resolution. The first thought when seeing this low-res swimming dither effect is not "yeah that's how dither works" but "what is this warping shaking effect and how can I turn it off."
Exhibit A. Reduced contrast for your comfort.
Try to focus on something here when it moves and behold the crinkled heart of Obra Dinn's fullscreen problems. There are ways to fix this that mostly boiling down to "this style doesn't work, change it." I went pretty far down that path, experimenting with different styles, before swinging back and wondering if maybe I shouldn't let these bullshit little pixels push me around.
Stabilizing The DitherTo give your eyes the best chance at recombining everything, dithering works best when the dither pattern dots have a 1:1 correlation with the output pixels. But, correlating only with the output means that as a scene post effect there's no connection between the geometry being rendered and the pattern that thresholds it. Each frame, moving scene elements threshold against different values. What I want instead is for the dither pattern to be "pinned" to the geometry and to appear stable as it moves with the rest of the scene.
The core of this is a mapping problem. As told by the length of this post, there's a conflict between the ideal dither pattern mapping (1:1 with the screen) and the ideal scene mapping (x:1 with the geometry) so get ready for some compromises. Most of my work was focused on mapping the input dither pattern into different spaces that better correlate the pattern with the scene geometry. Everything here is done at the pre-thresholding stage.
Texel SpaceMy first try was to map the dither pattern in texel space. This is equivalent to dithering the object textures during scene rendering instead of in a post-processing pass on the 8-bit output. I didn't expect this to work well but wanted to see what a perfectly scene-matched mapping looked like anyways.
Dither pattern in texel space
Ok well, expectations solidly met. The objects are all mapped differently so their pattern scales don't match. Those could be unified. The real problem is the aliasing. Any resampling from one space to another like this will result in aliasing, and dither patterns can't be easily mipped or filtered like traditional textures. Still, to carry it through:
Applied to the moving scene
This isn't a total loss - the pattern is nicely pinned to geometry. The aliasing produces its own swimming effect and unifying or scaling the mappings won't help with that. Texels change size with distance from the camera so there will always be dither pattern pixels that alias badly when resampled to the screen.
Motion WarpingIf I want the dither pattern to track the moving geometry beneath it, why not just warp the pattern using the change in position of each rendered pixel in the scene? Indeed why not. This is a bit like a motion blur, where each pixel tracks its movement from the previous frame. In this case, I update the dither texture to keep its pattern moving with the scene. If a scene pixel was not represented in the previous frame, the dither pattern is reloaded there. This technique is made much simpler by the game's static-ness - I only need to worry about the movement of the camera, not individual objects.
Warping the dither pattern to maintain frame-to-frame coherence with the scene
This was a pretty quick & dirty try but a few things are clear. First, it kinda works. Second, a dither pattern needs a neighborhood - it can't be individual pixels. If you consider each pixel individually, as this method does, then you'll get breaks and discontinuities in the pattern which are obvious. I shifted the camera in this test scene to highlight those on the chest here. Viewing the warped dither pattern itself makes this a little easier to see.
Thresholding solid gray with the warping dither pattern
These discontinuities are down to the differing pixel depths and thresholds that I chose. I reasoned an elaborate fix based on tracking regions, averaging their depth and shifting all dither pattern dots in that region by the same amount. A discontinuity along a region boundary could be hidden by sharp lighting changes or a wireframe line. This would've been enabled by the game's existing setup of colored regions for the wireframe generation. When I sat down to implement all that, the depth term dropped out of the first equation I came up with and gave me a much simpler alternative:
Screen-mapping With OffsetWhen putting together the equations for the warping dither, a very simple transform fell out:
DitherOffset = ScreenSize * CameraRotation / CameraFov
Shifting the screen-mapped dither pattern based on camera rotation
Basically, this expresses that I want the screen-mapped dither pattern to shift by exactly one screen when the camera rotates through one field of view. That maintains a 1:1 mapping with the screen while also considering a simplified transform of the scene geometry in view. This really only matches the movement at the center of the screen but bless this fucked up world because it's nearly good enough.
Offsetting the dither pattern to track one screen per camera fov rotation
Note how the dithered pixels on the chair appear to mostly move with the geometry. Likewise for the sphere. Planes more perpendicular to the view don't match very well; the floor is still a mess.
So while not being perfect, simply shifting the screen-mapped dither keeps the overall pattern and scene movement close enough that the eyes can better track them together. I was pretty happy with this. While cleaning up the code and committing everything, maybe writing a devlog post or two, the idea of a perfectly-pinned dither kept nagging at me:
World Space - Cube MappingThe experiments so far suggest that any correlation between the dither pattern and scene geometry would have to ignore depth information from the scene. What this means practically is that the dither can be pinned to the geometry during camera rotation but not during camera translation. This isn't such a bad thing for Obra Dinn considering the slow pace of the game and the observational role of the player. You're normally walking around, stopping, and looking at things. When walking, so many things are changing onscreen that the swimming dither isn't as obvious.
With that in mind, my next attempt was mapping the dither pattern to the geometry indirectly by pre-rendering it onto the sides of a cube centered around the camera. The cube translates with the camera but stays oriented to the world. In the mix: little bit of screen, little bit of scene.
Dither pattern mapped to a cube centered around the camera
Camera's view looking up into a corner. Mapping scaled up for clarity.
The cube's mapping works well when looking directly into the sides, and not so well when aimed into a corner. Still, the dither pattern stays perfectly fixed in 3D space as the camera rotates. Even rough, the result is promising.
Thresholding scene with the cube-mapped dither pattern
Now we're talking. Being a post-processing pass makes this more general than texel-space mapping, which is good. The problem is now down to the particular cube mapping. An ideal mapping would have one texel on the cube always resolve to exactly one pixel on the screen, regardless of the camera rotation. That's not possible with a cube...
World Space - Sphere Mapping...but I got pretty close with a sphere.
Mapping the dither pattern onto the inside of a sphere
Finding this particular spherical mapping took some time. There's no way to perfectly tile a square texture onto a sphere. It would've been possible to redefine the dither matrices in terms of a hexagon grid or something else that does tile on a sphere. Possible maybe, I didn't try. Instead, I just hacked on the square tiling until this carefully tweaked "rings" mapping of the original dither pattern gave good results.
Applied to the scene
Better than the cube. Still lots of aliasing. The spherically-mapped dot size is very similar to the screen pixel size - off just enough to cause moire patterns. I could feel the closeness, and a very simple fix for this kind of aliasing is to supersample: apply the dither thresholding at a higher resolution and downsample.
Spherically-mapped dither pattern at 2x and downsampled to 1x
Thresholding at 2x, then downsampling to 1x
This is the best I got. There are a few compromises:
1 The dither pattern dots get larger and less effective at the edges of the screen
2 The pattern isn't aligned up-down-left-right for most camera rotations
3 The output is no longer 1-bit due to the final box-downsample
But the upside is pretty lofty:
1 The dithering is perfectly pinned for all camera rotations. This feels slightly uncanny in-game.
2 Discomfort from swimming dither is totally gone, even at fullscreen
3 The pixellated style of the game is preserved
It's possible eliminate compromise #3 by reducing the output back to 1-bit with a simple 50% threshold. The result is still better than without supersampling (the triple comparison directly below is thresholded).
Side by side, by side
In the game's default palette
WrapupIt feels a little weird to put 100 hours into something that won't be noticed by its absence. Exactly no one will think, "man this dithering is stable as shit. total magic going on here." I don't want to give people problems they didn't know they should have though so it was worth fixing.
The screenspace mapping with offset works best at 1x and the sphere mapping works best at 2x. All scene rendering is at 800x450 now (up from 640x360), which helps legibility without sacrificing the low-res style. The final game will have two display modes:
DIGITALBorder-boxed, screenspace offset dither, 1-bit output
ANALOGFullscreen, sphere-mapped dither, softened output
Quoting because pagination, and I'm sure there will be some useful discussion. For starters:
I guess the non-square pixels in the final output are also an inevitable compromise? It kind of breaks the suspension of disbelief.
I just realized that this compromise may not need to be inevitable, if you apply the treshold at the original pixel resolution (instead of this 2x blow-up). Of course,
would re-introduce the aliasing on the sphere, but if you only apply "lower-res tresholding" to the non-Bayer parts you would avoid that.