How to make a Triplanar Shader in Godot
Here's how to make a simple triplanar shader that blends textures based on the surface angle (normal). You can find the full code at the end of this article.
Triplanar mapping solves a common terrain problem: standard UV mapping stretches or distorts textures on steep or irregular surfaces. By projecting textures along all 3 world axes and blending them by surface normal, you get clean results on any geometry. The technique samples the same texture from X, Y, and Z directions simultaneously, then mixes the 3 samples using weights derived from the surface normal. Each world axis contributes roughly in proportion to how much the face points in that direction, so flat ground gets mostly the top projection while cliffs get the side ones.
Initialization
To make texture mapping UV-independent we needed to find some sort of alternative coordinates space. We've decided to use world-space vertex coordinates, initialized like the following:
- Rotate the vertex position by the
triplanar_angleparameter. - Scale the result by
triplanar_scaleto control texture tiling. - Offset by
triplanar_offsetto shift the projection in world space.
uniform vec3 _triplanar_pos;
void vertex()
{
// initialize the world space sampling coordinate by
// rotating, scaling and offsetting the vertex position.
_triplanar_pos = rotate(triplanar_angle) * VERTEX;
_triplanar_pos *= triplanar_scale;
_triplanar_pos += triplanar_offset;
//...
}
Snippet pseudo code
Considering that triplanar mapping projects textures along all 3 axes, to avoid over-saturation we also calculate some blending weights as shown. The weights are based on the absolute value of the surface normal raised to the power of triplanar_sharpness. A sharpness value of 1.0 gives a smooth blend; higher values produce a sharper transition between projections. The weights are then normalized so they always sum to 1.0, keeping the final color in the correct range:
uniform vec3 _triplanar_weight;
void vertex()
{
//...
// compute the weights that will be used to get the correct
// final color after sampling the texture
vec3 _triplanar_weight = pow(abs(NORMAL), vec3(triplanar_sharpness));
_triplanar_weight /= dot(result, vec3(1.0));
}Snippet pseudo code
Texture sampling
Texture mapping uses only two coordinates (u and v) but the _triplanar_pos is a vec3! However, if we use only two components per time, we are actually projecting the texture on a plane aligned with each axes (up, right and forward).
- Sample the texture using XY components (projects along the Z axis).
- Sample using XZ components (projects along the Y axis, the "top" view).
- Sample using ZY components with a horizontal flip (projects along the X axis).
- Multiply each sample by its corresponding weight and sum them together.
void fragment()
{
vec4 color = vec4(0.0);
// sample the texture three times, one for each axes in world space,
// and multiply each axes with the relative weight
color += texture(sampler, _triplanar_pos.xy) * _triplanar_weight.z;
color += texture(sampler, _triplanar_pos.xz) * _triplanar_weight.y;
color += texture(sampler, _triplanar_pos.zy * vec2(-1.0, 1.0)) * _triplanar_weight.x;
color *= triplanar_strength;
//...
}Mixing
Finally, we can transition between two different textures based on the surface normal. The dot product between the up vector and the surface normal gives a value between -1 and 1, and smoothstep turns that into a clean interpolation factor controlled by _Angle and _Transition. The result is that flat ground uses one texture (e.g., grass) and steep surfaces use another (e.g., rock), with a configurable blend zone between them:
void fragment()
{
//...
// calulate the angle between up-vector and surface normal
float transition = dot(vec3(0.0, 1.0, 0.0), normal);
// calculate interpolation factor
float threshold = cos(degToRad(_Angle));
float interpolation = 1.0 - smoothstep(
threshold - _Transition,
threshold + _Transition,
transition);
// transition between two textures
vec3 color = mix(first_triplanar_texture.rgb,
second_triplanar_texture.rgb,
interpolation);
ALBEDO = color;
}Putting it all together
The complete shader below combines all 3 steps: the vertex function computes world-space position and blending weights, and the fragment function samples the texture 3 times (once per axis) then blends the two terrain textures based on surface angle. The shader exposes triplanar_sharpness, triplanar_scale, and _Angle/_Transition as uniform parameters, so you can tune the look directly in the Godot inspector without touching the GLSL.
The rest of this post is available for free!
Please create an account to continue reading! It helps us knowing that humans are reading us, and also helps us staying independent and keep posting what we love. Thanks!
Create a Free Account & Continue Reading