Reflective metallic surfaces react in specific ways, with shiny chrome surfaces having stark contrasts between their light or dark sides. But calculating all that involves realistic rendering, which defeats the purpose of a toon shader.
Enter the matcap, or Material Capture.
If you are at all familiar with ZBrush or Blender, you probably know about matcaps. It’s a way to color 3D objects that feel like they’re semi-realistically reacting to camera angles. In reality, a matcap is a square 2D texture of a sphere mapped to 3D objects using fragment normals instead of UVs.
UVs are generated out of the normals, so it doesn’t matter what shape the object is. If the normals are accurate, the effect is convincing.
There’s no shortage of freely available matcaps on the internet. The technique here will work regardless.
We use a black and white, simple chrome ball matcap available in the project files. That way, we can tint the dark and the light sides to our choosing instead of depending on the matcap having color information.
We add five uniforms: metalness, light metal color, dark metal color, a texture for the matcap, and a control for the contrast.
uniform sampler2D metalness_texture : hint_black_albedo; uniform vec4 dark_metalness_color : hint_color = vec4(0, 0, 0, 1); uniform vec4 light_metalness_color : hint_color = vec4(1, 1, 1, 1); uniform float metalness_contrast_factor : hint_range(0, 5) = 1.0; uniform float metalness : hint_range(0, 1) = 0.0;
The matcap uses the hint_black_albedo
hint because it makes Godot use it with an appropriate color space instead of raw data. After all, we’re dealing with color which we’ll apply directly instead of using image-encoded data.
The code to apply the metalness happens before the specular and kick light. Otherwise, the metalness muddies the highlights.
The first step is to generate the UVS out of our normals. Normals are in the [-1..1] range, with 1 being right and up, but UVs are in [0..1] with 1 being right and down. To map normals into UV space, we halve them and add 0.5. To flip the vertical, we negate the Y-axis.
vec2 metalness_uv = (NORMAL.xy * vec2(0.5, -0.5) + vec2(0.5, 0.5));
These are the UVs we use to sample from the matcap.
vec3 metalness_value = texture(metalness_texture, metalness_uv).rgb;
To apply the contrast, we amplify the result with pow
but clamp the final result to a [0..1] range.
metalness_value = clamp(pow(metalness_value, vec3(metalness_contrast_factor)), 0, 1);
We use max
to tint the shadow colors, and min
to tint the light colors.
metalness_value = max(metalness_value, dark_metalness_color.rgb); metalness_value = min(metalness_value, light_metalness_color.rgb);
Or, even better, combine the two using clamp
:
//metalness_value = max(metalness_value, dark_metalness_color.rgb); //metalness_value = min(metalness_value, light_metalness_color.rgb); metalness_value = clamp(metalness_value, dark_metalness_color.rgb, light_metalness_color.rgb);
Finally, the metalness acts as the mix
factor to blend with the original color.
out_color = mix(out_color, metalness_value, metalness);
shader_type spatial; render_mode unshaded; //Specular constants const float SPECULAR_SOFT_MIN = 0.0; const float SPECULAR_SOFT_MAX = 0.64; const float SPECULAR_HARD_MIN = 0.17; const float SPECULAR_HARD_MAX = 0.18; //Outline constants const float OUTLINE_MIN = 0.45; const float OUTLINE_MAX = 0.47; //Data textures uniform sampler2D light_data : hint_black; uniform sampler2D specular_data : hint_black; uniform sampler2D key_light_ramp : hint_black; uniform sampler2D fill_light_ramp : hint_black; uniform sampler2D kick_light_ramp : hint_black; uniform sampler2D metalness_texture : hint_black_albedo; //Outline uniform vec4 outline_color : hint_color = vec4(0, 0, 0, 1.0); uniform float outline_size : hint_range(0, 1) = 0.5; //Metalness uniform vec4 dark_metalness_color : hint_color = vec4(0, 0, 0, 1); uniform vec4 light_metalness_color : hint_color = vec4(1, 1, 1, 1); uniform float metalness_contrast_factor : hint_range(0, 5) = 1.0; uniform float metalness : hint_range(0, 1) = 0.0; //Specular uniform float specular_softness : hint_range(0, 1); uniform float specular_size : hint_range(0, 4); uniform vec4 specular_color : hint_color; //Light colors uniform vec4 key_light_color : hint_color; uniform vec4 shadow_color : hint_color; uniform vec4 fill_light_color : hint_color; uniform vec4 kick_light_color : hint_color; void fragment() { //Data vec3 diffuse = texture(light_data, SCREEN_UV).rgb; //Key light float key_light_value = texture(key_light_ramp, vec2(diffuse.r, 0)).r; vec3 out_color = key_light_value * key_light_color.rgb; out_color = max(out_color, shadow_color.rgb); //Fill light float fill_light_value = texture(fill_light_ramp, vec2(diffuse.g, 0)).r; out_color += fill_light_value * fill_light_color.rgb; //Kick light float kick_light_value = texture(kick_light_ramp, vec2(diffuse.b, 0)).r; out_color += kick_light_value * kick_light_color.rgb; //Metalness vec2 metalness_uv = (NORMAL.xy * vec2(0.5, -0.5) + vec2(0.5, 0.5)); vec3 metalness_value = texture(metalness_texture, metalness_uv).rgb; metalness_value = clamp(pow(metalness_value, vec3(metalness_contrast_factor)), 0, 1); metalness_value = clamp(metalness_value, dark_metalness_color.rgb, light_metalness_color.rgb); out_color = mix(out_color, metalness_value, metalness); //Specular float specular = texture(specular_data, SCREEN_UV).r; float soft_specular = smoothstep(SPECULAR_SOFT_MIN, SPECULAR_SOFT_MAX, specular * specular_size); float hard_specular = smoothstep(SPECULAR_HARD_MIN, SPECULAR_HARD_MAX, specular * specular_size); vec3 specular_out = mix(hard_specular, soft_specular, specular_softness) * specular_color.rgb; out_color += specular_out; //Outline float outline_factor = outline_size * (1.0 - diffuse.r); float rim_value = pow(dot(NORMAL, VIEW), outline_factor); float outline_amount = smoothstep(OUTLINE_MIN, OUTLINE_MAX, rim_value); out_color = mix(outline_color.rgb, out_color, outline_amount); ALBEDO = out_color; }