Screen-Space Reflections

November 19th, 2024
Mika and the Witch's Mountain

Motivation

In modern computer graphics, achieving realistic reflections is crucial for creating visually convincing water. However, in the context of more stylized o cartoonish games, this is not always the case. By the time of release, Mika and the Witch's Mountain had no reflections on any surface, but I always wondered whether water in particular would benefit from this effect or not. There was only one way to find out: Test it.

Reflections Off Reflections On
Reflections Off vs On

Up until this point, I had a lot of experience using reflection probes and had toyed a bit with ray tracing. Sadly, these techniques were not applicable for this game:

In the end, I decided to learn something new and implement Screen-Space Reflections. This technique was widely used during the PS4 era and is still very relevant today. It has many positive aspects:

The technique has its downsides too:

Implementation

In Mika and the Witch's Mountain, Screen-space Reflections are rendered as a part of the water forward pass, using two pre-existing buffers:

Depth and Opaque Buffers

Firstly, a reflection vector is calculated given the water normal and the direction from the camera to the water position in world space. What if we could just raycast against the geometry and get the color of the scene where the ray hits a surface? Well, we can't do that because we're not using ray tracing. Instead, we can raymarch in screen-space until the ray depth is further from the camera than the depth retrieved from the depth buffer at that point.

Raymarching implies that we need to execute a loop and check a different ray position in each iteration, which in turn means sampling the depth buffer each time. The ray origin will be the water pixel position, and given a maximum reflection distance in world space the ray destiny is calculated. Both of these positions are transformed into clip space and the ray destiny is clipped so that it doesn't extend beyond the screen, as that would be a waste of resources.

Reflection and Raymarching

Getting a good result with this basic approach would require using a lot of samples per pixel (spp). This could easily eat a great chunk of the GPU budget if left as is. Instead, let's try using a very low spp count for a demonstration (6spp):

6 spp
Initial Demonstration

Can you spot the issue? There is very visible banding, which is even worse in motion. This is an aliasing problem as the low amount of samples is unable to properly capture the irregularity of the world geometry. There are two approaches I used to fix this: Dithering and interpolation.

Dithering can be used to displace the first ray sample in the ray direction. The dither value affects the loop so that all samples are displaced a fraction of the distance between two iterations.

+ Dithering
Dithering Demonstration

That cleared banding to a great extent, but sacrificed clarity entirely at the same time. This is where interpolation comes in. When the ray collides against geometry during an iteration, we don't have the exact collision point, just the first ray position that is inside the geometry. If we assume that the actual intersection lies somewhere between the last ray position and the current one, we can use interpolation to estimate that point:

Raymarching Interpolation

Of course, this is just an estimation and won't accurately represent highly irregular shapes. However, the geometry in this game isn't that complex and this solution works well enough:

+ Interpolation
Interpolation Demonstration

As you can see, noise is present around the silhouette of objects because that geometry is being skipped on some pixels and the interpolation estimation can't solve that issue. Dithering mitigates it but only increasing the sample count actually solves this issue. Before we do that, you may have noticed that the calculated reflections look like a mirror. Let's render this again using the distorted normals from the water shader:

+ Distortion
Distortion Demonstration

Noise is now mitigated even more thanks to the irregularity of the reflection. However, it doesn't eliminate it completely, and thus in the final game a slightly higher spp count is used to ensure a noise-free look. Regardless, I think this is a very good result for just 6 texture samples, and could be viable for weaker hardware. This is the final result once the reflections are blended with the water color and a screen edge fade is applied:

+ Blending
Blending Demonstration

Results

SSR Off SSR On
SSR Off vs On
SSR Off SSR On
SSR Off vs On
SSR Off SSR On
SSR Off vs On
SSR Off SSR On
SSR Off vs On
SSR Off SSR On
SSR Off vs On
SSR Off SSR On
SSR Off vs On
SSR Off SSR On
SSR Off vs On
SSR Off SSR On
SSR Off vs On
SSR Off SSR On
SSR Off vs On

Conclusion

The team was very satisfied with the results and SSR made it into the final game. Personally, I think it fits the game's world and helps elevate the visual fidelity to new grounds. This project's biggest source of inspiration is The Legend of Zelda: The Wind Waker, that's no secret. In that game, water had no reflections but this didn't have to define how our game had to look like. This inclusion helps the world of Mika depart from its main artistic reference and create its own visual identity.

Going forward

Because of how late into development this was pitched, it was too late to use more sophisticated approaches such as: