Skip to content
Materials · Triplanar mappingIntermediate

Cliff rock

One tiling rock texture projected along x, y and z and blended by the normal, so a mesh with no UVs is covered without stretching.

PositionNormalTextureSurface
Open in Playground
  1. 01GoalMaterials
  2. 02TechniqueTriplanar mapping
  3. 03ExecutionCliff rock
Test mesh

Parameters

0.75
4.0

Copy for your platform

  • GLSLCopy
  • GodotCopy

Node chain

Built step by step, in GLSL style, with its node names.

Built step by step, in Godot style, with its node names.

No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.

No Unity version of this card yet, so the nodes show the GLSL names.

No Blender version of this card yet, so the nodes show the GLSL names.

  1. powPowpowpowpow
    Position
    Normal
    Texture

    01 Facing each axis

  2. /Divide///

    02 Weights that sum to 1

  3. textureTexture2Dtexturetexturetexture

    03 Project along X

  4. textureTexture2Dtexturetexturetexture

    04 Project along Y

  5. textureTexture2Dtexturetexturetexture

    05 Project along Z

  6. x * w.x + y * w.y + z * w.zMultiplyAddx * w.x + y * w.y + z * w.zx * w.x + y * w.y + z * w.zx * w.x + y * w.y + z * w.z

    06 Blend the three

How it works

The test sphere has UVs, but this card ignores them: the rock comes from the surface position alone. A single 512×512 rock texture is projected three times, once along each world axis, and each pixel blends the three by how squarely the surface faces each axis. The top and the sides each get an undistorted projection, and the slopes in between cross-fade. Orbit the preview: the cracks stay sharp all the way round, with no pole pinching and no seam.

Step by step

  1. Facing each axis. abs(i.normal) is how much the surface looks along x, y and z, whichever side it faces; shown as color it is red on the left and right, green at the top and bottom, blue in front and behind. Raising it to Blend sharpness narrows the zones where two of them are both large.
  2. Weights that sum to 1. Dividing by the sum of the three makes them add up to 1, so where two projections overlap their mix is as bright as either alone. Without it, the 45° slopes, where the raw components sum to about 1.4, come out lighter than the faces.
  3. Project along X. texture(t_rock, p.zy): the position seen from the side, with y as the texture’s vertical. On the left and right of the sphere this is a clean image; toward the front it smears into streaks, which the weights will hide. It is shifted half a tile sideways: on a 45° edge x equals z, and unshifted, this projection and the Z one would show the same texels mirrored there, a symmetric seam that gives the mapping away.
  4. Project along Y. p.xz, the view from above, shifted half a tile in z for the same reason on the edges where y equals z. Clean at the top and bottom, streaked on the sides.
  5. Project along Z. p.xy, the view from the front, with y vertical again, so both side projections keep the rock’s layers level.
  6. Blend the three. Each projection times its weight, summed. Every pixel takes most of its color from the projection that sees it best, and the streaks of the other two are weighted out.

Tiles per unit scales the position before sampling, so it sets how big the rock is in world units; the texture tiles seamlessly, which matters more here than with UVs because the projections run across the whole object. The texture is sRGB and arrives decoded, so the weighted sum blends linear color.

Cost

Three texture reads and about 31 arithmetic operations: three for abs, nine for the three pows (a log, a multiply and an exponent each), five for the normalization, five to scale and shift the position and nine for the weighted sum. The reads dominate. Every extra texture in a triplanar material costs three reads instead of one, so a full color, normal and roughness set is nine; that is when biplanar mapping, or skipping the weakest projection when its weight is near zero, pays off.

Make it in GLSL

Ship rock.jpg from the card's assets (or any tileable texture) as t_rock. Upload it as SRGB8_ALPHA8 with UNPACK_FLIP_Y_WEBGL on, generate mipmaps, and set both wrap modes to REPEAT; the shader expects all four.

The copy button gives a complete WebGL2 fragment shader. Your vertex shader fills v_position and v_normal (in world space for terrain that should line up across objects, in object space for props that carry their texture with them) and declares v_view and v_uv, which this card doesn’t read. The generated main() normalizes the normal again per pixel, so the weights stay right on large triangles.

To texture different materials per direction, sample a second texture in the top line, moss or grass for example, and leave rock on the two sides; the weights already put it on surfaces that face up. Replace the pow with a smoothstep on weights.y if the moss should stop at a sharp line instead of fading.

Make it in Godot

Without custom code, a StandardMaterial3D does the same with the rock as Albedo texture, UV1 Triplanar on, World Triplanar on (to match this world-space export) and UV1 Scale for the tiling; Triplanar Sharpness is the same power as Blend sharpness (set it to 4 to match), though Godot computes the weights per vertex, so they interpolate across large triangles. In VisualShader, the TextureParameterTriplanar node is the whole chain in one node.

Make a MeshInstance3D, give it a new ShaderMaterial, paste the shader and set t_rock to the card’s rock.jpg (or any tileable texture). The source_color hint decodes it to linear as the card does. Godot generates mipmaps for it the first time a 3D material uses it; if the rock shimmers at a distance, check that Mipmaps > Generate is on in the texture’s Import dock.

In VisualShader, build it with an Input node for NORMAL transformed to world space, a VectorFunc Abs and a VectorOp Pow by Blend sharpness, then a Divide by the sum of the three components (a Vector3 Decompose and two Adds). Three Texture2D nodes sample the rock at the world position’s zy, xz and xy (each a Vector3 Decompose into a Vector2 Compose), and two MultiplyAdd nodes after a Multiply sum them by their weights into Albedo, with the material unshaded.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

cliff-rock.fragGLSL
#version 300 es// Triplanar mapping → Cliff rock, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform float p_scale; // Tiles per unit: 0.75 (0.25 to 4.0)uniform float p_sharpness; // Blend sharpness: 4.0 (1.0 to 16.0)uniform sampler2D t_rock; // rock.jpg: sRGB color, uploaded as SRGB8_ALPHA8 so it samples linear; linear filter with mipmaps, repeatin vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}vec4 effect(Inputs i) {	// How much the surface faces each world axis; the power narrows the seams where two projections cross-fade	vec3 facing = pow(abs(i.normal), vec3(p_sharpness));	// Dividing by the sum keeps the three weights adding up to 1, so the blend neither brightens nor darkens	vec3 weights = facing / (facing.x + facing.y + facing.z);	vec3 p = i.position * p_scale;	// Each projection drops the axis it looks along; y stays the texture's vertical on both sides, so the rock's	// layers stay level there. On a 45° edge two coordinates are equal, so without the half-tile shifts two	// projections would show the same texels mirrored there, a symmetric seam.	vec3 sideX = texture(t_rock, p.zy + vec2(0.5, 0.0)).rgb;	vec3 top = texture(t_rock, p.xz + vec2(0.0, 0.5)).rgb;	vec3 sideZ = texture(t_rock, p.xy).rgb;	vec3 rock = sideX * weights.x + top * weights.y + sideZ * weights.z;	return vec4(rock, 1.0);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	vec4 frag_out = effect(i);	// The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}
cliff-rock.gdshaderGodot shader
// Triplanar mapping → Cliff rock, Godot 4. From Creative Center, MIT license.shader_type spatial;// Unshaded shows the effect's color as is, like the site's preview; scene lights don't touch itrender_mode unshaded;uniform float scale : hint_range(0.25, 4.0, 0.05) = 0.75;uniform float sharpness : hint_range(1.0, 16.0, 0.5) = 4.0;uniform sampler2D t_rock : source_color, filter_linear_mipmap, repeat_enable; // rock.jpgstruct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// Godot's textures start at the top-left and the effect's uv at the bottom-left: each sample flips v backvec2 u_flipUv(vec2 uv) {	return vec2(uv.x, 1.0 - uv.y);}vec4 effect(Inputs i) {	// How much the surface faces each world axis; the power narrows the seams where two projections cross-fade	vec3 facing = pow(abs(i.normal), vec3(sharpness));	// Dividing by the sum keeps the three weights adding up to 1, so the blend neither brightens nor darkens	vec3 weights = facing / (facing.x + facing.y + facing.z);	vec3 p = i.position * scale;	// Each projection drops the axis it looks along; y stays the texture's vertical on both sides, so the rock's	// layers stay level there. On a 45° edge two coordinates are equal, so without the half-tile shifts two	// projections would show the same texels mirrored there, a symmetric seam.	vec3 sideX = texture(t_rock, u_flipUv(p.zy + vec2(0.5, 0.0))).rgb;	vec3 top = texture(t_rock, u_flipUv(p.xz + vec2(0.0, 0.5))).rgb;	vec3 sideZ = texture(t_rock, u_flipUv(p.xy)).rgb;	vec3 rock = sideX * weights.x + top * weights.y + sideZ * weights.z;	return vec4(rock, 1.0);}void fragment() {	Inputs i;	// Godot's UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(UV.x, 1.0 - UV.y);	// VERTEX, NORMAL and VIEW are in view space here; the effect works in world space	i.position = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;	i.normal = normalize((INV_VIEW_MATRIX * vec4(NORMAL, 0.0)).xyz);	i.view = normalize((INV_VIEW_MATRIX * vec4(VIEW, 0.0)).xyz);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = VIEWPORT_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	ALBEDO = frag_out.rgb;}

No Unreal Engine 5 code for this card yet.

No Unity code for this card yet.

No Blender code for this card yet.

Further reading