A frame of Waves 2: Notorious
Waves 2: Notorious is an arcade twin-stick shooter set in Cyberspace filled to the brim with juicy neon effects and thumping electronic music. It was developed by solo developer Rob “Squid” Hale, who unfortunately lost their battle with cancer back in August 2022. Waves 2 is available for free on Steam (along with the original Waves), and I’ve embedded the trailer below to give some context for what we’re about to analyze.
The game is quite pretty, and after seeing a question about the game on /r/gamedev I thought it would be fun to analyze how the game is rendered using Microsoft PIX. This won’t be the most in-depth rendering analysis ever and I’m not expecting anything groundbreaking from Waves 2, but it’s always fun to see how pixels are made 😁
I’ll start by going over the render passes of the game, and towards the end I’ll speculate on how I think Rob accomplished some of the more interesting effects.
I don’t think this game is especially spoil-able, but in case you’re concerned I will mention that the screenshots I am focusing on are from the tutorial, with a few from early on in the first level.
A frame of Waves 2: Notorious
We’ll primarily be focusing on the frame below:
In retrospect I wish I went with a more exciting frame. (When I initially made the captures I was not planning on digging into them quite as much as I did.) There’s a few things the game does that are not visible in this frame and I’ll be addressing them separately.
Last things first, the HUD is drawn to its own render target:
Next the entire world is drawn with only the depth buffer bound:
Rendering the world
The game world is drawn using deferred shading, which results in a few different outputs:
There is an additional B8G8R8A8_UNORM texture bound to RTV 4, but it is all black. (Possibly some Unreal feature which is not in use.)
Unsurprisingly, writing to the depth buffer is disabled here since we did the depth pre-pass earlier. However, writing to the stencil buffer is enabled:
These stencil values were all contributed while the floor was being rendered. This is seemingly not used anywhere.
The floor hexes are instanced. (In this particular frame they’re the only thing which is.) However, the tops and sides of the hexes are drawn separately using different pixel shaders. The sides don’t contribute to the emissive color at all, only the tops do. All 22,500 hexes are drawn in just two draw calls.
The tops of all floor hexes are completely flat, the highlights are thanks to this normal map:
The top shader is also what’s responsible for drawing the circuit pattern around the player, we’ll be looking at that in a bit more detail later on.
One detail worth noting is the red-magenta glow on the hexes. That glow reflects the color of the level boundary rings. It’s not super visible in the final version of this frame, so here’s a different screenshot where it’s more apparent:
I believe this glow is simply a function of the pixel’s Z position. (This might make you wonder if it affects the hexes which rise up to guard power-ups. It does not, those hexes are actually their own objects which are rendered separately from the floor.)
Speaking of boundaries, the floor extends quite a bit outside of them! Below is the mesh for the floor tops after the vertex shader is finished, the small blue box represents the screen.
Not sure if there’s ever a situation during normal play where you can see out so far. Maybe at one point Rob wanted the camera to fly away when you completed the level?
One thing that you might notice is that the enemies look a bit weird in the normal buffer. Interestingly, enemies are two separate meshes (the ones for the one in this scene is pictured below.) One for the body and one for the glowy bits. Not sure if Rob did this intentionally or if Unreal did it automatically as a side-effect of how the models were authored.
I found it slightly odd that the highlights mesh isn’t contributing to the normal map. I suppose it just doesn’t affect anything in a meaningful way if a fully emissive object is missing from it.
Unused floor depth-stencil
After rendering the world the floor (and only the floor) is rendered again with only a depth-stencil buffer bound. This buffer is not the same as the one used in the depth pre-pass and is never used again during this frame, not sure what this might’ve been intended for.
Unknown object-related buffer
Next up, all active (IE: non-floor) objects are drawn again resulting in the following R16G16_UNORM buffer:
I am not sure what this is. Later on it is used by a compute shader to produce a screen-sized black texture used during the final composite.
It’s maybe worth noting that the values vary between each object and the pixels of each object, but they’re all around
Screen-space ambient occlusion
Screen-space ambient occlusion (SSAO) is now calculated. (In my opinion, SSAO does not make a ton of sense for Waves 2 due to the perspective and atmosphere. If I were to guess it’s probably just here because Unreal was doing it by default and Rob didn’t disable it.)
First the normals and depth are combined into a half-screen-sized R16G16B16A16_FLOAT buffer containing normals in RGB and depth in alpha:
Those normals and depth are then used to calculate SSAO:
Which is then upscaled and blurred to create the final SSAO buffer:
Unknown emissive color pass
A full-screen pass occurs at this point which takes the diffuse, SSAO, and material ID buffers as inputs and targets the emissive color buffer. The target is identical before and after this pass, so it isn’t having any effect on this frame and as such its purpose remains a mystery.
Player light is drawn
A point light for the player is drawn to the emissive texture:
Normals, “material IDs”, diffuse, the SSAO texture, the emissive, and a few unused (blank) buffers are combined to make the scene composite:
This composite is subsequently dimmed, which I would assume is exposure being adjusted:
Transparents (AKA the neon buffer)
Finally it’s time to render the level bounds and the hexes lit up by enemies as they roll around. This is rendered to its own R16G16B16A16_FLOAT buffer I’ve dubbed “the neon buffer”.
It dawned on me after finding the particles here that this is just the transparents pass, but I like the idea of games having a dedicated neon buffer so I’ve kept the name. It’s a bit odd these are rendered to a separate buffer rather than onto the scene composite buffer directly. Does Unreal normally do this or was Rob planning to do something that required it to be separate? The scene composite and the neon buffer aren’t used for anything after they’re blended together.
Here’s the some of the meshes used in this stage:
This is also when power-ups and the grids under them are rendered when present. Here’s a different frame which shows them:
This is also when the circuit texture is added under the power-up, but at this point it’s only visible if you over-expose the neon buffer:
This is also when (most*) particles are rendered. Below is yet another different frame which shows them.
(*Most because some fully opaque particles would have been rendered earlier along with the rest of the opaque objects.)
Apply neon buffer
The scene composite and the neon buffer are combined:
Apply HUD and pincushion distortion
The composite and the HUD are combined and the pincushion distortion is applied:
(An additional half-sized and down-sampled copy of this buffer is also made for later use when calculating bloom.)
The star of the show is finally here! Bloom highlights are extracted:
This is rendered from the half-sized copy mentioned earlier and is half-sized itself.
Additional mips down to 1/64 size are created as well. (Not sure why though, they are not even bound during the final bloom composite.)
Each mip is separately blurred in two passes:
Apply bloom, color grading, and calculate luminance
Finally the bloom highlights are applied to the scene, color grading is applied, and luminance is calculated and stored in the alpha channel. (Quite the busy pass!)
(Also note that at this point the texture is 8-bits per channel.)
Anti-aliasing is applied. I’m not an anti-aliasing expert, but I believe the presence of luminance and being done in a single pass implies this is probably FXAA. (Looking at the Unreal 4 anti-aliasing documentation confirms that’s almost certainly correct since this is definitely not TAA.)
Unknown no-op pass
A full screen pass happens at this point which has no effect (the output buffer is identical to the input.)
However the DXIL of the bound pixel shader has three loops in it, so it clearly wants to do a lot of work. The idea of it being a full-screen blur crossed my mind, but it has fairly large constant buffer inputs (1024 and 256 bytes respectively) which seems odd for blur.
Unfortunately there’s not really any other hints as to what it might be. I poked around in a handful of other captures (such as ones including the main menu, more enemies, etc) but never found it being used. Might just be something that’s currently unused
Player damage distortion pass
There is one final pass before presenting the back buffer, but it’s not actually used in our reference frame. It’s time for the player damage distortion pass!
Quite a pretty glitch effect, I’d say! I particularly like that the chromatic aberration is modulated by that interlacing effect and chaotically distorted horizontally. I feel like this is what modern displays would look like if we had to degauss them.
(If you have a keen eye, you might notice our 8bpp texture is now 10bpp for some reason. I don’t think this was intentional since I don’t have an HDR display – and even if I did going down to 8bpp and back up to 10bpp would not make much sense. My guess is Rob either used to have this pass earlier in the pipeline or they accidentally left it on a 10bpp default.)
This pass is also responsible for the distortion during the countdown timer when you begin the game:
The main look of Waves 2: Notorious can primarily be attributed to good ol’ bloom. Put lots of emissive materials in a dark environment and you get lots of shiny.
Follows are some ideas on how I think Rob accomplished some specific effects that stood out to me which didn’t fit into the pipeline overview above. Do note that I don’t have access to shade source and I didn’t want to read DXIL, so these are educated guesses.
As the enemies roll around the arena, the floor hexes they’ve passed over light up around the edges.
I believe this is done simply by spawning a trail of emissive decals using the textures shown below. It’s pretty subtle, but there’s actually two decals, the top of the hex lights up too and it’s drawn first.
Using decals for this is not the most elegant solution, but you can’t argue with the results.
Before poking at things in PIX, I speculated this was going to be done either by a texture hovering slightly above the ground (which would be fine since the playable area is flat) or by embedding a hex-shaped mesh (or a plane with a hex-shaped texture) slightly into the floor.
Another alternative which doesn’t rely on the floor being flat would be to make a small mesh in the shape of the outline decal texture and distort it to match the floor. (With vertices at the corners doubled up so they form walls when distorted. You could use something similar with the inner ring to accomplish a glow which reaches up the sides of the hex. Accomplishing this in Unity or WebGL will likely be the subject of a future blog post so make sure to smash that like button.)
Floor circuit effect
I mentioned earlier that I’d be addressing the floor circuit effect in more detail later, and that’s because it’s actually rendered differently depending on whether we’re talking about the effect under the player or the power-ups.
For the player
Focusing on the player first: The circuit texture is rendered as part of the shader used to render the tops of the floor hexes. Before I started looking at PIX my initial thought was that the easiest thing to do would be to feed the player’s position into the shader used for the ground and used it to influence how the circuit texture is blended onto it. (And map that texture using world coordinates rather than local UVs since the effect is world-relative.)
Seems great minds think alike because that seems to be exactly how Rob did it!
(I made a quick-and-dirty 2D Shadertoy playground demonstrating how you might write this if it’s not totally clear from my description.)
The effect under power-ups looks similar, but it’s rendered much later during the transparents/neon pass. Like the enemy hex trail effect, I’m pretty sure this is rendered as a decal with a custom shader. The mesh it uses is quite a bit different though:
The glowing hex grid around the power-up and the circuit effect is rendered all at once, with the circuit texture projected using the same world-space coordinates as the player to keep things seamless. The hex grid is also different than the enemy trails here as the power-ups light up the edges of multiple hexes, so the shader is responsible for limiting the effect and fading it out at the edges.
Here are the primary textures involved in rendering this single decal:
My assumption is that the blurry hexagon is used to give the falloff of the hex grid a hexagon shape.
I’m not entirely sure about that light halo, but it might be the same thing for the circuit texture. It’s a bit dim for that, but maybe that’d explain why the circuit texture is so dim in the neon buffer.
Waves 2: Notorious might not be the latest pixel-blistering AAA blockbuster, but it’s still quite a pretty game and I think it’s interesting to look at how games are rendered - both big and small. Rob clearly put a lot of love and care into Waves 2, and I hope I’ve helped you gain an appreciation of their legacy.
This is my first time doing a write-up like this, so if I glossed over something you wish I talked about or if you have feedback in general feel free to politely yell at me on Twitter. I’m planning on doing this again soon, so make sure to follow for updates!