Uniplanar mapping with dithered blending
This is a port of gehtsiegarnixan’s Shadertoy project: https://www.shadertoy.com/view/cdBfD3
I changed the code so the x/z-projections are not rotated, though I couldn’t quite figure out how to un-mirror the inverted projections (along minus xyz axis). For many use cases, that likely won’t matter much though. I also added support for normal and ORM maps.
It’s basically a branching triplanar shader, meaning that this shader will use only one projection per texture and fragment at a time instead of three (triplanar mapping), so there should be less of an overhead.
Since temporal dithering is used to mask the transitions, there will be very visible flickering if you don’t have TAA enabled in your project settings. With TAA, the dithering is hardly noticeable even from close up in most cases. The dithering makes for a better blending and less stretching artifacts on surfaces triplanar mapping typically has problems with (e.g. surfaces at 45° angles).
While I think it’s interesting from a technical point of view, I’m not sure the extra cost for TAA is worth the hassle, but if you use TAA anyway, give it a try! In my tests, I needed to have a rather big number of unique materials on screen for the dithererd uniplanar shader to give actual performance benefits over triplanar, but I guess your mileage may vary.
Shader code
shader_type spatial;
//ported from gehtsiegarnixan's Sahdertoy project: https://www.shadertoy.com/view/cdBfD3
// The MIT License
// Copyright © 2023 Gehtsiegarnixan
//adapted for practical use with Godot 4.x by Kalamster
uniform sampler2D albedo_tex : repeat_enable, filter_linear_mipmap, hint_default_white;
uniform sampler2D orm_tex : repeat_enable,filter_linear_mipmap, hint_default_white;
uniform float roughness :hint_range(0.0, 1.0) = 1.0;
uniform float metalness :hint_range(0.0, 1.0) = 0.0;
uniform float ao_strength :hint_range(0.0, 1.0) = 0.0;
uniform sampler2D normal_map : hint_normal,filter_linear_mipmap,repeat_enable;
uniform float normal_map_scale :hint_range(-15.0, 15.0) = 1.0;
uniform vec3 uv_scale = vec3(1.0);
group_uniforms uniplanar_settings;
uniform float blend_factor = 16.0;
uniform float _tOffset = 0.0;
varying vec3 my_pos;
varying vec3 my_normal;
float ScreenSpaceDither12(vec2 vScreenPos, float time)
{
float vDither = dot( vec2( 171.0, 231.0 ), vScreenPos.xy + time);
return fract( vDither / 103.0);
}
vec3 smoothContrast(vec3 alpha, float contrast) {
vec3 powAlpha = pow(alpha, vec3(contrast));
return powAlpha/(powAlpha.x + powAlpha.y + powAlpha.z);
}
vec4 uniplanar( sampler2D sam, vec3 normal, vec3 position, vec4 fragCoord, float contrast, float tOffset) {
vec2 uvX = position.zy;
uvX.y *= 1.0;
vec2 uvY = position.zx;
uvY.x *= 1.0;
vec2 uvZ = -position.xy;
uvZ.y *= -1.0;
vec3 alpha = abs(normal);
alpha = smoothContrast(alpha, contrast);
float dither = ScreenSpaceDither12(fragCoord.xy, TIME-float(tOffset));
dither = clamp(dither, 0.01, 0.99);
// Mip caculation as the automatic ones don't work
vec3 duvwdx = dFdx(position);
vec3 duvwdy = dFdy(position);
// uvs for derivatives Mips
vec2 duvdx;
vec2 duvdy;
// "interpolate" the UVs using dither
vec2 uv;
if (alpha.x > dither) {
uv = uvX;
duvdx = duvwdx.zy;
duvdy = duvwdy.zy;
} else if (1.0-alpha.z > dither) {
uv = uvY;
duvdx = duvwdx.zx;
duvdy = duvwdy.zx;
} else {
uv = uvZ;
duvdx = duvwdx.xy;
duvdy = duvwdy.xy;
}
vec4 col = textureGrad( sam, uv, duvdx, duvdy);
return col;
}
void vertex() {
my_pos = VERTEX;
my_pos *= vec3(-1.0,-1.0, 1.0);
my_pos = my_pos.xyz;
my_normal = abs(NORMAL);
//taken from a converted Spatial3D material
TANGENT = vec3(0.0,0.0,-1.0) * abs(my_normal.x);
TANGENT+= vec3(1.0,0.0,0.0) * abs(my_normal.y);
TANGENT+= vec3(1.0,0.0,0.0) * abs(my_normal.z);
TANGENT = normalize(TANGENT);
BINORMAL = vec3(0.0,1.0,0.0) * abs(my_normal.x);
BINORMAL+= vec3(0.0,0.0,-1.0) * abs(my_normal.y);
BINORMAL+= vec3(0.0,1.0,0.0) * abs(my_normal.z);
BINORMAL = normalize(BINORMAL);
}
void fragment() {
vec3 col = uniplanar(albedo_tex, my_normal, my_pos*uv_scale, FRAGCOORD, blend_factor, _tOffset).rgb;
ALBEDO *= col;
ALBEDO *= ALBEDO;
vec3 nm = uniplanar(normal_map, my_normal, my_pos*uv_scale, FRAGCOORD, blend_factor, _tOffset).rgb;
NORMAL_MAP = nm;
NORMAL_MAP_DEPTH *= normal_map_scale;
vec3 orm = uniplanar(orm_tex, my_normal, my_pos*uv_scale, FRAGCOORD, blend_factor, _tOffset).rgb;
ROUGHNESS = orm.g * roughness;
METALLIC = orm.b * metalness;
AO = orm.r;
AO_LIGHT_AFFECT = ao_strength;
}
//void light() {
// Called for every pixel for every light affecting the material.
// Uncomment to replace the default light processing function with this one.
//}
Very cool. Thanks for sharing. Since you seem to be on a planar mapping kick, I wonder if you’ve implemented the biplanar method yet?
actually, I have tried. It works in principle, but I can’t seem to figure out how to swizzle the normals for correct normal mapping. Additionally, there seems to be little actual performance benefit (at least in my testing). I’m using linear dithered uniplanar mapping now (based on code from the same Shadertoy user, haven’t posted it here yet though) for small or far away objects where the slight seams are not visible. Anyway, you’re welcome to play with the biplanar code:
Thanks!
actually I think the following finally fixes the flipped normals of the normal mapping, but I still have no clue what to do about the x-projections being rotated.
in fragment shader, between ALBEDO *= ALBEDO and NORMAL_MAP = normal_texture.rgb, insert:
vec3 normal_texture = biplanar(normal, my_pos, my_normal, blend_factor).rgb;
normal_texture = normal_texture * 2.0 -1.0;
normal_texture.g *= -1.0;
float sign_z = sign(my_normal.z);
float sign_x = sign(my_normal.x);
if (sign_z < 0.0)
{
normal_texture.x *= -1.0;
}
if (sign_x < 0.0){
normal_texture.x *= -1.0;
}
normal_texture = normal_texture * 0.5 + 0.5;