1/18 Here’s our second tech art thread on the stylized rendering of “Dungeons of #Hinterberg”. This time, I’ll talk about how we do our outlines!
If an indie mix of Zelda-like/ARPG and Persona-style social sim is your jam, follow us at @MicrobirdGames. #gamedev#indiedev
2/18 Main point of the first thread: we use deferred rendering with a custom gbuffer that stores material IDs, to essentially create a second material system that runs on the GPU. The full thread is here:
3/18 Our main goals for outlines were: resolution independence, good results with minimum tweaking & artistic control. Our material system helps a lot with the latter two, offering control over outline types, colors, width, & a handful of tweak factors that we hardly need to use.
4/18 We have 4 kinds of outlines, which we can enable/disable per material: z-distance, normals, material IDs and “submaterial/section IDs”. “Sections” here are different parts within a material, between which we always want to draw an outline (e.g. the player’s shoulder pads).
5/18 Section IDs and thresholds between them actually get two channels in our gbuffer. Alongside material IDs, they provide the greatest control over where outlines are placed, whereas z-outlines are more organic and normals allow adding lots of scribbly details with normal maps.
6/18 Our material system treats material ID & z-outlines as “outer” outlines, and normal & section ID outlines as “inner” outlines. We can control the colors for inner/outer outlines separately per material, and can assign color variations for lit and dim parts of meshes.
7/18 We can also set outlines for different materials to fade to other colors or become transparent over distance. Note the distant trees in this screenshot - everything beyond a certain distance turns light purple.
8/18 Again, all of this flexibility springs from the basic idea of storing material IDs in the gbuffer. And storing a map of brightly/dimly lit areas in our lighting pass lets us create different outline colors in lit areas, and even vary width based on diffuse brightness.
9/18 We’re rendering outlines with a post-processing effect. The first pass extracts outlines from the gbuffer. We then do a resolution-aware blur/dilate and then soft-threshold the image. This is what gives us resolution-independence (within limits :)).
10/18 In October, @edgeonline asked us for 4K screenshots of our game for their print edition. It was the first time we ran the game at that resolution. I was really pleased with how the blur/threshold steps added a bit of a felt tip vibe to our outlines! (pic: close-up)
11/18 Technical side-note: a lot of the literature / blog posts on outline rendering use the Sobel operator for edge detection. I consistently found that the Laplacian gives better results for 3D rendering. aishack.in/tutorials/sobe…
12/18 Sobel approximates a 1st derivative, e.g. takes change in depth between neighboring pixels. This is hard to tweak for planes parallel to view direction. Laplacian approximates a 2nd derivative (change in slope), which is prone to noise but a better fit for 3D renderings.
13/18 Finally, let’s talk a bit about antialiasing: We had problems with small details like grass whose outlines flickered a lot at medium distance when the camera was moving. The blur/threshold step alleviated this a bit, but it was still pretty bad.
14/18 FXAA smeared everything to a mess, TAA predictably had problems with ghosting (not sure how TAA would work with outlines at all?). I ended up modifying #unity3d's implementation of SMAA, and it works quite well:
15/18 I’m really no expert on antialiasing, but the gist of SMAA is that it first does an edge detection pass, then locally classifies the found edges into different shapes and smoothes those out. Quality of AA depends on the edge detection.
16/18 We’re already doing a much better edge detection step than a generic solution can ever do, when we’re creating our outlines. I replaced Unity SMAA’s edge detection step with that, and the results are great. Unfortunately, no way I can show this with Twitter vid compression.
17/18 Future work: specular / diffuse aliasing is still an issue, but I bet we can incorporate that into the edge detection from our outline extraction pass, if we find the time.
18/18 And that concludes our 2nd tech thread about rendering in #Hinterberg. Next time I’ll look at our different “pipeline” stages. Follow us @MicrobirdGames, and/or for major updates, subscribe to our newsletter at dungeonsofhinterberg.com
• • •
Missing some Tweet in this thread? You can try to
force a refresh
1/8 Rewrote our camera system last week, from a basic FSM to a stack of behaviour layers with blend weights. I don't know if there's a name for this design pattern, but I've been using it a lot lately, so here's a description: #gamedev#indiedev
2/8 Suppose you have a set of values that can be produced by different behaviours. Usually only one behaviour is dominant, but you want behaviours to transition smoothly and some behaviours should be able to offset the values rather than overwrite them.
3/8 Example: Our camera is mostly player-controlled, but we want to smoothly transition to cut scenes, & screenshakes or skill aim offsets should work on top of the general logic. Other ex: rumble intensity, global time scale (if you have slowdown mechanics), post-processing.
1/x Over the next weeks, I want to post a few threads about the stylized rendering we’ve implemented for our game “Dungeons of #Hinterberg”. I’ll start with an overview, but I'll get pretty technical. Follow @MicrobirdGames, if you think the game looks rad! :) #gamedev#indiedev
2/x #Hinterberg is #MadeWithUnity. We use a completely custom deferred solution, based on the built-in render pipeline. Our standard shader mimics Unity's, but was built from scratch, as were the lighting pass shaders. @catlikecoding's tutorials were an invaluable resource here.
3/x The main secret ingredient is that we store collections of various material properties in a StructuredBuffer on the GPU, and let deferred shaders write their material IDs (i.e. their index into the StructuredBuffer) into the gbuffer.