Blended Normals, or Soft Flat Shading

-2017.12.15 7:00pmBlended normals

Flat shading's harsh edges and flat colors have never been something I find visually appealing, but I always feel like there's something nice still hidden in there! Well, I finally found a way to soften the harshness of it all, and I really, really love the results!

The key is blended normals! Take flat shading normals, and blend them with the usual vertex normals! This will preserve the hard edges created by the flat shading, but also add in some of the smoothness of traditional gouraud! Add in some sliders for tweaking, and you have yourself a beautiful retro/modern hybrid approach to lighting.

ddx, ddy.

Not too long ago, I stumbled across the ddx/ddy shader functions. I was a little surprised I hadn't noticed them before, but also positively delighted by the possibilities these little functions unlock!

So what do they do? These functions are used internally by the GPU to select mipmap levels, but are thankfully exposed to us as well. The idea is from calculus, they're used to find the "approximate partial derivative" of an interpolator value in the fragment shader. It basically measures change in the interpolator's value along the window's x or y axis. For example, if you have a float3 worldPosition:TEXCOORD1 in your fragment shader's parameters, you can use ddx(worldPosition) to get the change in world position from one pixel to the next!

Specifically, from what I understand, the GPU will process pixels in 2x2 chunks, and the ddx/ddy values will be calculated from the values in this chunk. Right side minus left side, and top side minus bottom side. Which also means that the ddx/ddy values will be the same for all pixels in the chunk!

Normal Reconstruction.

One of the easiest things to do with ddx/ddy, is use them to reconstruct the normal of a face. So that's what we're gonna do, this is basically a fun way to do flat shaded geometry! The ddx and ddy of a position value are perpendicular vectors pointing out along the current face, so all we have to do is send them through the cross product to get the normal! Check it out, short and sweet! :)

float3 GetFaceNormal(float3 position) { float3 dx = ddx(position); float3 dy = ddy(position); return normalize(cross(dy, dx)); }

One of the nice things here, is that you should be able to get a normal in the same space as the position you put in! Want a world space normal? Pass in a world space position! Want a view space normal? Pass in a view space position! Cool!

Blended Normals.

The first thing I thought of when I figured this all out, was this tweet by Oskar Stålberg. I had an Aha! moment, and understood what was likely happening here.

Previously I had wondered what sort of nightmarish content pipeline was necessary to store both vertex normals and face normals, or generate vertex normals on the fly, but now we know that face normals can be pretty trivially calculated using our newfound calculus friend! So if we want to recreate this trick, all we need to do now is lerp our mesh's vertex normal with the calculated face normal.

final = lerp(i.normal, faceNormal, _BlendNormal * i.color.r);

Painting.

You may have noticed that I tossed some vertex color values into that blend! Well, I recently finished my vertex painting tool, so I'm a little enamoured with it at the moment. The vertex color basically lets me control which areas get the normal blending effect and which don't, just by painting! :)

Subtle, but oh... so smooth.

Here's the final shader that I used! It bypasses a lot of Unity's standard lighting model, and just pulls in a single directional light plus ambient lighting. It's simple, and hopefully easy enough for you to pick apart for your own purposes!

I tried for a while to get this working in Unity's Surface shader format too. While I'm convinced it's still possible, it certainly doesn't look simple! And it definitely wouldn't be great for a reference like this. The stumbling block I ran into was that the surface shader was looking for a tangent space normal, and that conversion isn't easy to get working. If you can find a way to manage it though, I'd love to hear from you!

Feel free to use the code for whatever you like, and I'd love to see examples of it if you use it on anything!