Feb 24, 2024

Watercolor Shader Experiments (Part 1)

I've been digging into shaders a lot lately - namely, stylized lighting in Unity's Shader Graph. I wanted to build a no post-process watercolor shader, so that the look felt more set in and didn't rely on screen space effects that would move with the camera. This ended up a bit more painterly than watercolor, but was a fun little experiment. I made this in an unlit Shader Graph in URP. I used this custom lighting HLSL shader code by Cyanilux to get access to light direction, color, and a few other handy nodes in Shader Graph. It essentially allows you to recreate the Lambert lighting model in Shader Graph, and then build on it to create a variety of toon shaded styles. You can read more about how to implement your own custom lighting code in this blog post by Unity. While there are light nodes in Shader Graph as of 2022.3, there's no way to get additional light data (spot/point lights) or cast shadows yet without custom functions as far as I'm aware.
The graph in most of its entirety. Note, this has not yet been optimized.

Toggle the dropdowns to read more.
1. Shadows
Watercolor texture.
First, I created a subgraph where I offset world position based on a seamless watercolor texture, which I used to drive the textured shadows.
World Position Offset subgraph

I then plugged this into the position input of the custom Main Light Shadows node. This texturized the shadow edge. I still lacked a bit of control over the shading here, as the HLSL code didn't give me a smooth shadow gradient to work with. However, this created a base for the shadows. I also gave the shadow a darker edge, to mimic the pigment build-up you often see with watercolor paint.
2. Shadow Detail
To add more detail to the shadows, I took the dot product of the main light direction and custom watercolor normals, to create object shadows I could mask within the base shadow I'd made.
The dot product
Using the same watercolor texture (set to normal), I transformed it into a normal vector, so that I could take this along with the main light direction and plug it into a dot product. This gave me a textured shading gradient.
Creating the banded light gradient.
I then multiplied the result by a value that would determine the number of bands I wanted the shading gradient to have (in this case, three). By flooring this, and then multiplying it again by a smaller value, I was able to create the stepped ramp effect, the same as you would if creating a toon shading ramp, only in this case with textured normals to give it that uneven, broken edge. I used this same method to create two masks that when multiplied together, created an edge to the shadow, as an additional layer of 'pigment build-up'. I then added it all back to the original dot product output to soften out the effect, before combining with the base shadows. There might be a better way of getting smoother transitions between the bands and avoiding the more posterized look, something I intend to look into for next time.
Left: The final result of textured main light shadows. Right: This combined with the base shadows.
3. Highlights
Next, I created highlights. I opted not to do specular based highlights after a bit of testing, as I felt having shine that moved around with view direction didn't fit with the matte look.
Instead I took the textured object shadows, and used that to mask off a noiser watercolor texture (a different texture than the one used to drive the shading). I then masked this output again by my final shadow output, so that the texture would largely affect only the areas of the material exposed to the light.
This also includes a rim edge shadow, as well a bit of subtle color shift for flavor.
This created splotches of lighter color, which gave the effect of the paint pigment thinning out in areas. It also added it back to the masked off object shadows, but at a lower intensity.
4. Color
I separated out the majority of color into a new subgraph for tidiness's sake, using the T input of a lerp node to change the base color and shadow color mostly independently of each other, with some influence of the base color applied to the shadow color. I also included the option to use a diffuse texture, as well as a black and white mask texture for a secondary color, which used much the same color switching setup as above. For highlights specifically, I added the option to increase saturation of the highlight color for some punchier colors. This applied largely to the whole material, as highlights are masked off by the object shading at a wide range of values between 0.0 and 1.0. However I liked the resulting look, especially in the areas where it brightened shadow colors.
Left: no saturation. Right: high saturation.
5. Additional Lights
Next was adding in influence from additional lights. The most interesting part of this was figuring out how to stylize them. I used the World Position Offset subgraph from earlier to texturize the light edges, then set about making it fit more with the style.
Left: before adding bands. Right: with the banding effect, texture, and the intensity toned down.
In the end I did this by separating value and color, and processing them separately before recombining them. I followed the same process I used to stylize the object shadows, by adding bands and then recombining them, as well as adding in more texture. I also once again added in that look of pigment build up on the edges of any cast shadows influenced by additional lights. This also lightened them slightly, which was reminiscent of color leaching watercolors can sometimes cause.
Left: Final output of the additional lights. Right: Combined with the rest of the shader.
The final result was a material that was influenced by both the color and intensity of spot and point lights.
6. Paper grain and extras
Lastly, was adding in a paper grain and speckling texture to complete the look. This was just involved either multiplying or adding the textures within the shader where necessary, with adjustments to get them looking as I intended. During prototyping I didn't optimize this stage, and when I return to this shader to improve it I would instead process these textures as required in Photoshop, then pack them into a single image to cut out any unnecessary fat. I also added more intense paper grain in shadowed areas, as well as a rim shadow outline, as mentioned earlier.
Shadow paper grain
Rim Outline
Left: no additional texture. Right: paper texture and rim outline.
Left: Cubes. Right: shadow artifacts on meshes with hard edges
This shader works best with meshes with soft normals and rounded edges. While it can look okay on meshes with hard edges, the issue comes into play when the light hits the mesh at a certain angle, causing shading artifacts. This is likely caused by shadow resolution and bias, combined with the world position offset over-scrambling the shadows, which is then made more pronounced by the 'pigment build-up' edge. Here it is tested on this cool stylized house model by Santy Frow. This shader has not been optimized or performance tested yet, and certainly still has a few things improve. In the future, I plan to use what I learned to recreate something similar to this, either in Shader Graph or combined with a custom textured normal generator in Substance Designer. First though, I intend to put together a 3D scene of my own and utilize this shader for it. I've put back on my modelling hat for now, and begun working on a few things that I look forward to sharing at some point soon. Some great resources that went into the making of this: