Creating a DOOM-inspired aesthetic with PlayCanvas

Omar Shehata — July 2021

Last week I made a game called Death's Clutch in 10 days for a game jam hosted by Newgrounds. Making it was a lot of fun because it involved a lot of customized rendering like making DOOM-inspired 2D billboards that move in 3D, or hacking PlayCanvas's material system to get stylized lighting.

I hope this "how it's made" article will be useful for others seeking to stylize their PlayCanvas games, and I'm curious if any readers know of better ways to implement these effects (especially curious about comparing with other engines/workflows).

The other reason I wanted to document this process was as a reminder to myself of how beautiful things are made — layer by layer.

Brief gameplay video of Death's Clutch. The goal of the game is to escape this dark maze. Your flashlight helps you see, but it also triggers/attracts monsters. See longer gameplay video here.

Overview

As you scroll through this section you'll see each effect added to the live demo below along with its explanation. Scroll back and forth to toggle the effects and see their impact. We'll start with a basic PlayCanvas scene and build up the horror game aesthetic from there.

This demo is interactive! Click the lock icon in the bottom right to enable orbit controls. Zoom with mousewheel or pinch. Rotate with click & drag, or touch & drag).

0. Setup

The first thing we did was attach a spotlight to the camera. Each game asset was added as a 2D texture applied to a flat plane.

1. Make objects face camera

Next we made these flat planes always rotate to face the camera. Some objects, like the eggs attached to the walls, do not rotate more than 30 degrees away from their original angle.

We also applied an opacity texture so they no longer look like images drawn on a flat plane.

2. Turn off ambient light

Next we turned off the ambient light by setting its color to black. This turns everything not directly in range of the spotlight completely black.

Try zooming in and out to bring the light closer or farther from the walls and objects.

3. Set camera clear color to black

Even with ambient light set to black, the scene still isn't pitch black everywhere outside of the light's range as expected. This is because the camera has a default clear color that isn't black. So any gaps/empty space will have this dark gray color.

To fix this, we just set the camera clear color to black.

4. Create custom material to fix "washed out" look

One big problem with the default lighting here is that everything looks washed out when the light hits it directly. This is most obvious when shining the light straight at a wall, or at the big monster.

This happens because we're using a physically based lighting model, but our materials are all these simple 2D images that only have a diffuse color.

The desired outcome is that these objects should always render using their original colors, regardless of how much light reaches them. This is usually done by creating an "unlit" material. But we also want them to NOT be visible if NO light reaches them.

To achieve this, we created a custom material that behaves just like a standard physically based material, but just interpolates between the original color and black based on the amount of light received. This removes any realistic lighting effects like reflection and specular that we don't want.

We elaborate more on how this was done later in the article.

5. Use emissive material to make objects glow in the dark

Next we applied an emissive texture to each object to define which areas should glow in the dark, such as lights on the walls or the eyes of the monsters.

This helps the player see a little bit even if their light is turned off, and it creates a very cool effect where you see just the eyes glowing in the dark before the full monster comes into view.

Try moving your light towards and away from the egg to see how it appears to glow in the dark.

The use of an emissive texture is significantly more efficient than adding lights to all these objects. The emissive areas will just behave as if they have more light reaching them, regardless of whether any light is reaching them.

This does create a problem where the player can see a glowing object even at the end of a very long dark hallway, which ruins the surprise.

6. Add fog to limit view distance

To fix this issue with emissive and limit how far the player can see glowing objects, we add fog.

The fog darkens the colors based on their distance to the camera, regardless of whether they are emissive or not.

It's important that the maximum range of the spotlight is shorter than the maximum range that the fog lets you see. This difference is what allows the glowing eyes to appear for a few moments before the full monsters comes into view when it is approaching you.

The fog is also critical for creating suspense. The player moves in the darkness not knowing what lies ahead, and it makes the levels feel bigger than they are.

Extending the standard material/lighting

The most challenging effect to create was this custom material that responds to light but mainly keeps the original image color.

PlayCanvas does provide ways to create custom materials, see BasicMaterial where you can create a completely custom shader. Any material also has a "customFragmentShader" property that can override an existing material's shader. This is nice in cases when you want to keep all existing inputs (such as the attached textures) which you'd need to re-setup with a completely new material.

Our problem was that we needed to keep all the complex functionality of the Standard Material (so it can be affected by fog, emissive, and any effects we add in the future), but just tweak how it computes the final color. We couldn't just copy it and use customFragmentShader because the shader is generated at runtime. For example, it changes based on how many lights are in the scene, or whether fog is active. Here is an example in the PlayCanvas source where the shader code for the fog is injected at runtime only if fog is in use.

I ended up essentially doing a string replacement on the final generated shader to customize it. This was very fragile because:

A better solution would have been to make a copy of the StandardMaterial class and tweak it, but it wasn't obvious to me how easy that would be without modifying the engine.

An even better solution which I've only learned about as I'm writing this, is to pass in a custom chunks object to override any default material chunks. That way you can tweak any part of the lighting system without having to rewrite the whole material, or worry about how it interacts with all the other features.

Final thoughts

This was my first complete game using PlayCanvas, and I am overall really impressed! It was really nice to be able to collaborate with my teammates directly in the browser. They had never used the editor before but it was easy to set up a sandbox for them where the artist could tweak material settings and placement, and the musician could see how all the positional sound effects and layers of background music sounded as he worked on them, without me having to update a build of the game every time they wanted to see their updates in the game. So it felt empowering in that way.

A few more thoughts I want to note down:

I hope you found this useful! I'd love to hear your thoughts. Find me @Omar4ur on Twitter or see more ways to reach me at omarshehata.me.