Skip to content
Materials · Wood & marbleIntermediate

Wood rings

Solid wood carved out of 3D space, growth rings bent by gradient noise and streaked with fine fibers.

PositionUVSurface
Open in Playground
  1. 01GoalMaterials
  2. 02TechniqueWood & marble
  3. 03ExecutionWood rings
Test mesh

Parameters

9.0
0.070
2.5
2.0
0.20
#dba86e
#663317

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. lengthLength2Dlengthlengthlength
    Position
    UV

    01 Distance from the trunk's axis

  2. gradientNoiseTexture3DgradientNoisegradientNoisegradientNoise

    02 Gradient noise

  3. fractFractfractfractfract

    03 Repeat into rings

  4. powPowpowpowpow

    04 Shape each year

  5. gradientNoiseTexture3DgradientNoisegradientNoisegradientNoise

    05 Fine fibers

  6. mixMixmixmixmix

    06 Color the wood

How it works

The sphere looks carved from a log: concentric end grain at the top and bottom, long wavy stripes around the sides. No UVs are involved. The shader measures every surface point’s distance from an imaginary trunk running up the y axis, turns that distance into rings, and lets noise push the rings around. Because the inputs are a point in space, the pattern is solid: the same wood continues through the whole volume, whatever the mesh’s shape.

Step by step

  1. Distance from the trunk’s axis. length(p.xz) ignores height, so it is the distance from the y axis: zero at the trunk’s heart and growing outward. The heart is moved off the object’s center, at (0.3, 0, −0.2), as in most cut timber, so the rings don’t center on the sphere’s poles.
  2. Gradient noise. One octave of 3D gradient noise, its input squashed along y to a fifth of the scale across it: the grain wanders slowly up a tree and quickly around it. The preview shows only the positive half; black is where the noise is negative and pushes the rings inward.
  3. Repeat into rings. The noise, times Distortion, is added to the radius before fract repeats it Rings per unit times. Each ring is pushed in or out by the noise underneath it, and neighbouring rings move together, so they wobble without crossing. Raise Distortion (or Noise scale, which steepens the same push) far enough and the push folds back on itself: rings close into islands. A few read as knots; many read as marble.
  4. Shape each year. fract alone is a sawtooth from 0 to 1. Raising it to a power keeps most of the ring near 0 (light earlywood, grown fast in spring) and rises steeply at the end (dense latewood from late summer). Then fract restarts at 0: the dark edge of one year meets the pale start of the next in a hard line, which is what makes wood rings readable. Latewood sharpness is that power.
  5. Fine fibers. The same noise at 40 cells per unit across the trunk and 1.5 along it draws thin streaks parallel to the grain, the cut cells of the wood. On the end-grain faces they look like faint speckle.
  6. Color the wood. The ring shape plus the fibers, scaled by Fiber contrast and clamped, picks between the two colors with mix. Both colors arrive linear, so the blend between them is even.

The noise

gradientNoise is Perlin’s improved noise in 3D. The lattice point’s hash picks one of twelve gradient directions, toward the edges of a cube, as in his 2002 revision: cheaper than a random direction and never clumped. Each of the cube’s eight corners contributes the dot product of its gradient with the offset to the point, and a quintic fade blends them. The hash is integer math (a multiply by three large primes, then MurmurHash3’s finalizer), so it gives the same bits on every GPU, where the common fract(sin(x) * 43758.5) hash loses precision and stripes far from the origin.

Cost

About 680 arithmetic operations and no texture reads: each noise call is roughly 320, eight corner hashes and dot products plus the fade and seven lerps, and the card makes two. That is heavy next to a texture lookup, and it is why the alternatives above matter. The ring edge is a discontinuity, so it aliases once a ring is narrower than a few pixels; a baked, mipmapped texture filters that for free, and the Screen-space derivatives fundamental shows how to soften it with fwidth.

Make it in GLSL

The rings are measured from the y axis of whatever space v_position is in. The preview's mesh has no transform, so world and object space agree there; in a scene, pass the object-space position (the vertex attribute before the model matrix) in v_position, or the wood stays put in the world while the object moves through it.

The copy button gives a complete WebGL2 fragment shader. Your vertex shader fills v_position (see the note on which space) and v_normal, v_view and v_uv, which this card doesn’t read but the generated main() declares. Upload the two color params decoded to linear, as the header comments say; main() encodes the result to sRGB once.

Everything the effect needs is in the file: mix32, gradient, cornerDot and gradientNoise are plain functions, so you can lift gradientNoise into another shader as it is. It needs GLSL ES 3.00 (WebGL2) for the unsigned integers and bit operations; on WebGL1 replace the hash with a float one and keep the coordinates small.

To make marble from the same code, keep gradientNoise, sum four or five octaves of it at doubling frequencies and halving amplitudes, add that to p.x * frequency, and draw veins with 1.0 - abs(sin(value)) raised to a power.

Make it in Godot

VisualShader has no gradient noise node. The node route samples a NoiseTexture3D (FastNoiseLite, type Perlin, Seamless on) with a Texture3D node at the squashed position; it returns 0..1 rather than -1..1, so subtract 0.5 before the Distortion multiply, and its resolution limits how fine the fibers can be. The exported shader computes the noise itself and needs no texture.

Make a MeshInstance3D, give it a new ShaderMaterial and paste the shader. It is unshaded, so the wood shows exactly the card’s colors; to light it, remove unshaded from the render_mode line and the color becomes the material’s albedo under the scene’s lights.

In VisualShader the chain is: an Input node for VERTEX transformed to world space (or kept in model space, as the pitfall says), a Vector3 Decompose to drop y, a Vector2 Compose and a Length2D for the radius. The noise is a Texture3D node sampling a NoiseTexture3D at the position times a Vector3 Constant like (2.5, 0.5, 2.5), minus 0.5. Then a MultiplyAdd (noise × Distortion + radius), a Multiply by Rings per unit, a Fract, a Pow by Latewood sharpness, a second Texture3D for the fibers, and a Mix between two ColorParameters into Albedo.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

wood-rings.fragGLSL
#version 300 es// Wood & marble → Wood rings, 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_density; // Rings per unit: 9.0 (2.0 to 24.0)uniform float p_distortion; // Distortion: 0.07 (0.0 to 0.3)uniform float p_noiseScale; // Noise scale: 2.5 (0.5 to 8.0)uniform float p_sharpness; // Latewood sharpness: 2.0 (1.0 to 10.0)uniform float p_fiberContrast; // Fiber contrast: 0.2 (0.0 to 0.6)uniform vec3 p_earlywood; // Earlywood: vec3(0.86, 0.66, 0.43), sRGB; upload it decoded to linearuniform vec3 p_latewood; // Latewood: vec3(0.4, 0.2, 0.09), sRGB; upload it decoded to linearin 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));}// MurmurHash3's finalizer (public domain): integer math gives the same bits on every GPU, where a sin() hash drifts// and stripes at large coordinatesuint mix32(uint x) {	x ^= x >> 16u;	x *= 0x85ebca6bu;	x ^= x >> 13u;	x *= 0xc2b2ae35u;	x ^= x >> 16u;	return x;}// One of the twelve directions toward a cube's edges, as in Perlin's 2002 noise: cheaper than a random direction and// never clumped. The primes spread neighbouring cells apart before the finalizer mixes the bits.vec3 gradient(ivec3 cell) {	uint h = mix32(uint(cell.x) * 73856093u ^ uint(cell.y) * 19349663u ^ uint(cell.z) * 83492791u) % 12u;	float a = (h & 1u) == 0u ? 1.0 : -1.0;	float b = (h & 2u) == 0u ? 1.0 : -1.0;	uint zeroAxis = h >> 2u;	return zeroAxis == 0u ? vec3(0.0, a, b) : zeroAxis == 1u ? vec3(a, 0.0, b) : vec3(a, b, 0.0);}float cornerDot(ivec3 cell, vec3 f, ivec3 corner) {	return dot(gradient(cell + corner), f - vec3(corner));}// 3D gradient noise, about -1..1. Solid noise, so the grain runs through the object like a block carved from a log.float gradientNoise(vec3 p) {	ivec3 cell = ivec3(floor(p));	vec3 f = fract(p);	vec3 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);	float x00 = mix(cornerDot(cell, f, ivec3(0, 0, 0)), cornerDot(cell, f, ivec3(1, 0, 0)), u.x);	float x10 = mix(cornerDot(cell, f, ivec3(0, 1, 0)), cornerDot(cell, f, ivec3(1, 1, 0)), u.x);	float x01 = mix(cornerDot(cell, f, ivec3(0, 0, 1)), cornerDot(cell, f, ivec3(1, 0, 1)), u.x);	float x11 = mix(cornerDot(cell, f, ivec3(0, 1, 1)), cornerDot(cell, f, ivec3(1, 1, 1)), u.x);	return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);}vec4 effect(Inputs i) {	// The trunk runs along y with its heart off the object's center, as in most cut timber	vec3 p = i.position - vec3(0.3, 0.0, -0.2);	float radius = length(p.xz);	// Squashed along the trunk: rings wander slowly up a tree and quickly around it	float warp = gradientNoise(p * vec3(p_noiseScale, p_noiseScale * 0.2, p_noiseScale));	float rings = fract((radius + warp * p_distortion) * p_density);	// A year's wood grows light in spring and darkens toward late summer, then the next year starts light again: the	// dark side of each ring ends in a sharp edge	float tone = pow(rings, p_sharpness);	// Fine streaks along the grain: high frequency across the trunk, very low along it	float fibers = gradientNoise(p * vec3(40.0, 1.5, 40.0));	vec3 color = mix(p_earlywood, p_latewood, clamp(tone + fibers * p_fiberContrast, 0.0, 1.0));	return vec4(color, 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);}
wood-rings.gdshaderGodot shader
// Wood & marble → Wood rings, 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 density : hint_range(2.0, 24.0, 0.5) = 9.0;uniform float distortion : hint_range(0.0, 0.3, 0.005) = 0.07;uniform float noiseScale : hint_range(0.5, 8.0, 0.1) = 2.5;uniform float sharpness : hint_range(1.0, 10.0, 0.1) = 2.0;uniform float fiberContrast : hint_range(0.0, 0.6, 0.01) = 0.2;uniform vec3 earlywood : source_color = vec3(0.86, 0.66, 0.43);uniform vec3 latewood : source_color = vec3(0.4, 0.2, 0.09);struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// MurmurHash3's finalizer (public domain): integer math gives the same bits on every GPU, where a sin() hash drifts// and stripes at large coordinatesuint mix32(uint x) {	x ^= x >> 16u;	x *= 0x85ebca6bu;	x ^= x >> 13u;	x *= 0xc2b2ae35u;	x ^= x >> 16u;	return x;}// One of the twelve directions toward a cube's edges, as in Perlin's 2002 noise: cheaper than a random direction and// never clumped. The primes spread neighbouring cells apart before the finalizer mixes the bits.vec3 gradient(ivec3 cell) {	uint h = mix32(uint(cell.x) * 73856093u ^ uint(cell.y) * 19349663u ^ uint(cell.z) * 83492791u) % 12u;	float a = (h & 1u) == 0u ? 1.0 : -1.0;	float b = (h & 2u) == 0u ? 1.0 : -1.0;	uint zeroAxis = h >> 2u;	return zeroAxis == 0u ? vec3(0.0, a, b) : zeroAxis == 1u ? vec3(a, 0.0, b) : vec3(a, b, 0.0);}float cornerDot(ivec3 cell, vec3 f, ivec3 corner) {	return dot(gradient(cell + corner), f - vec3(corner));}// 3D gradient noise, about -1..1. Solid noise, so the grain runs through the object like a block carved from a log.float gradientNoise(vec3 p) {	ivec3 cell = ivec3(floor(p));	vec3 f = fract(p);	vec3 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);	float x00 = mix(cornerDot(cell, f, ivec3(0, 0, 0)), cornerDot(cell, f, ivec3(1, 0, 0)), u.x);	float x10 = mix(cornerDot(cell, f, ivec3(0, 1, 0)), cornerDot(cell, f, ivec3(1, 1, 0)), u.x);	float x01 = mix(cornerDot(cell, f, ivec3(0, 0, 1)), cornerDot(cell, f, ivec3(1, 0, 1)), u.x);	float x11 = mix(cornerDot(cell, f, ivec3(0, 1, 1)), cornerDot(cell, f, ivec3(1, 1, 1)), u.x);	return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);}vec4 effect(Inputs i) {	// The trunk runs along y with its heart off the object's center, as in most cut timber	vec3 p = i.position - vec3(0.3, 0.0, -0.2);	float radius = length(p.xz);	// Squashed along the trunk: rings wander slowly up a tree and quickly around it	float warp = gradientNoise(p * vec3(noiseScale, noiseScale * 0.2, noiseScale));	float rings = fract((radius + warp * distortion) * density);	// A year's wood grows light in spring and darkens toward late summer, then the next year starts light again: the	// dark side of each ring ends in a sharp edge	float tone = pow(rings, sharpness);	// Fine streaks along the grain: high frequency across the trunk, very low along it	float fibers = gradientNoise(p * vec3(40.0, 1.5, 40.0));	vec3 color = mix(earlywood, latewood, clamp(tone + fibers * fiberContrast, 0.0, 1.0));	return vec4(color, 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