Skip to main content

Triplanar with Deep Parallax in Godot

A handful of people have asked if Godot can support height mapping in combination with triplanar sampling. Height mapping is a very cool technique that adds a ton of detail without extra geometry. While modern game engines and hardware can handle an insane number of triangles, my workflow for creating models that tile perfectly cannot.

The reason I’m using triplanar texture coordinates is that I rotate and flip tiles around. This could introduce seams unless I do something clever like flipping and rotating UVs around some point. I’m also pretty sure that even if I do that, it means I can’t scale the UVs arbitrarily.

While in the feature request one of the Godot maintainers warns that doing a texture fetch in a loop is already slow, and with a triplanar shader you’re multiplying the total number by 3.

I decided to ignore that advice and see how bad it performed. After taking their suggestion to reduce the number of iterations because it won’t be noticeable in practice, it didn’t cause any FPS drops on my beefy gaming PC.

The effect is turned up a bit to make it extra apparent int he sample. The floor shouldn’t be offset so strongly if you want to have a character walk on it.

Heightmap with Deep Parallax

There is a ton of detail in the floor and walls. Some bricks/stones occlude others. It’s really awesome.

Normal Map Only

If performance does become and issue, using the normal map textures seems to be good enough. This is using the simplest “swizzle” approach from Ben Golus’s Normal Mapping for a Triplanar Shader tutorial, and I think the results are just fine.

Without this fix, you get very wrong normals. It’s subtle in this sample scene but areas in negative directions start looking dark in a confusing way.

Mesh Normals vs Normal Map

It’s subtle, but I really think the normal texture adds a lot of depth and detail versus the flatness of the mesh normals.

The Shader

Here’s the code for those who are interested. It’s mostly me gluing code from various tutorials together. Also there’s some unnessary conditionals I have for toggling the ability to use two sets of textures for the walls and floor. In reality there should be a separate shader for each, or some IFDEF preprocessor stuff instead of a runtime check.

shader_type spatial;

uniform float blendSharpness; 

uniform sampler2D textureMap : source_color;
uniform sampler2D normalMap : hint_normal;
uniform sampler2D heightMap : hint_default_white;
uniform float normalMapStrength : hint_range(0, 1) = 1.0;
uniform float uvScale = 1.0;

uniform bool enableFloor = false;
uniform sampler2D floorTextureMap : source_color;
uniform sampler2D floorNormalMap : hint_normal;
uniform sampler2D floorHeightMap : hint_default_white;
uniform float floorUvScale = 1.0;

uniform bool enablePom = true;
uniform int heightMinLayers = 8;
uniform int heightMaxLayers = 64;
uniform float heightScale = 1.0;

varying vec3 worldPos;
varying vec3 worldNormal;

void vertex() {
	 // Transform the vertex position to world space
    worldPos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;

    // Transform the vertex normal to world space
    worldNormal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);

// TODO conditionals...
vec2 scaleUV(float yDot, vec2 uv) {
  return uv * (enableFloor && yDot > 0.0 ? floorUvScale : uvScale);

// TODO conditionals...
vec4 sampleColor(float yDot, vec2 uv) {
  return enableFloor && yDot > 0.0 ? texture(floorTextureMap, uv) : texture(textureMap, uv);

// TODO conditionals...
vec4 sampleHeight(float yDot, vec2 uv) {
  return enableFloor && yDot > 0.0 ? texture(floorHeightMap, uv) : texture(heightMap, uv);

// TODO conditionals...
vec4 sampleNormal(float yDot, vec2 uv) {
  return enableFloor && yDot > 0.0 ? texture(floorNormalMap, uv) : texture(normalMap, uv);

vec4 triplanarSample(vec2 uvX, vec2 uvY, vec2 uvZ, vec3 blend, float yDot) {
    // Sample the texture using the calculated texture coordinates
    vec4 texColorX = texture(textureMap, uvX);
    vec4 texColorY = sampleColor(yDot, uvY);
    vec4 texColorZ = texture(textureMap, uvZ);

    // Blend the samples together
    return texColorX * blend.x
            + texColorY * blend.y
            + texColorZ * blend.z;

// The simplest appoach suggested in the goat's article:
vec3 triplanarNormal(float yDot, vec2 uvX, vec2 uvY, vec2 uvZ, vec3 blend) {
    // Tangent space normal maps
    vec3 tnormalX = texture(normalMap, uvX).rgb;
    vec3 tnormalY = sampleNormal(yDot, uvY).rgb;
    vec3 tnormalZ = texture(normalMap, uvZ).rgb;

    // Get the sign (-1 or 1) of the surface normal
    vec3 axisSign = sign(worldNormal);

    // Flip tangent normal z to account for surface normal facing
    tnormalX.z *= axisSign.x;
    tnormalY.z *= axisSign.y;
    tnormalZ.z *= axisSign.z;

    // Swizzle tangent normals to match world orientation and triblend
    return normalize(
      tnormalX.zyx * blend.x +
      tnormalY.xzy * blend.y + * blend.z


// Adapted from the tutorial. Changed to accept a viewDir which represents each plane.
vec2 pomUV(float yDot, vec2 m_base_uv, vec3 viewDir) {
    float viewDot = dot(viewDir, vec3(1, 0, 0));
    float minLayers = float(min(heightMinLayers, heightMaxLayers));
    float maxLayers = float(max(heightMinLayers, heightMaxLayers));
    float numLayers = mix(maxLayers, minLayers, abs(viewDot));
    numLayers = clamp(numLayers, minLayers, maxLayers);
    float layerDepth = 1.0f / numLayers;

    vec2 uvOffset = viewDir.xy * heightScale / numLayers;

    // tracks how "deep" we are on each iteration
    float currentLayerDepth = 0.0;
    // tracks how deep the heightmap; adjusted on each iteration as UVs shift
    float depthMapValue = 1.0 - sampleHeight(yDot, m_base_uv).r;

    // loop until the current layer is deeper than the heightmap (hit)
    // the 100 iteration cap is because I'm paranoid
    for (int i = 0; i < 100 && currentLayerDepth < depthMapValue; i++) {
        m_base_uv -= uvOffset;
        depthMapValue = 1.0 - sampleHeight(yDot, m_base_uv).r;
        currentLayerDepth += layerDepth;

    // occlusion (interpolate with prev value)
    vec2 prevUV = m_base_uv + uvOffset;
    float afterDepth =  depthMapValue - currentLayerDepth;
    float beforeDepth = 1.0 - sampleHeight(yDot, prevUV).r - currentLayerDepth + layerDepth;
    float weight = afterDepth / (afterDepth - beforeDepth);
    m_base_uv = prevUV * weight + m_base_uv * (1.0 - weight);

    return m_base_uv;

void fragment() {
    // Calculate blending
    float yDot = dot(worldNormal, vec3(0.0, 1.0, 0.0));
    vec3 blend = vec3(
        smoothstep(blendSharpness, 1.0, abs(dot(worldNormal, vec3(1.0, 0.0, 0.0)))),
        smoothstep(blendSharpness, 1.0, abs(yDot)),
        smoothstep(blendSharpness, 1.0, abs(dot(worldNormal, vec3(0.0, 0.0, 1.0))))
    // view dir will be swizzled to match coordinates
    vec3 viewDir = normalize(CAMERA_POSITION_WORLD - worldPos);

    // Calculate texture coordinates
    vec2 texCoordX = worldPos.zy * uvScale;
    vec2 texCoordY = scaleUV(yDot, worldPos.zx);
    vec2 texCoordZ = worldPos.xy * uvScale;
    // TODO conditionals...
    if (enablePom) {
      texCoordX = pomUV(yDot, texCoordX, viewDir.zyx);
      texCoordY = pomUV(yDot, texCoordY, viewDir.zxy);
      texCoordZ = pomUV(yDot, texCoordZ,;

    // sample and output
    ALBEDO = triplanarSample(texCoordX, texCoordY, texCoordZ, blend, yDot).rgb;
    NORMAL = mix(worldNormal, triplanarNormal(yDot, texCoordX, texCoordY, texCoordZ, blend), normalMapStrength); 
    NORMAL = normalize((VIEW_MATRIX * vec4(NORMAL, 0.0)).xyz);