Repeating a 3D Scene
by GameDev.net · GLSL ES 3.00 (WebGL2) · 25 Aug 2026
Run the shader to adjust these controls.
What it demonstrates
An infinite field of props for the cost of one. Wrapping the sample coordinate into a single cell before evaluating a shape makes that shape repeat forever in every direction, with no instancing, no draw calls and no memory. This is the trick behind most endless raymarched environments, and it comes with a real artefact that this shader is set up to let you produce.
Shader inputs
void mainImage(out vec4 fragColor, in vec2 fragCoord)
Called once per pixel. Write the colour to fragColor.
-
iResolutionvec3 - Viewport size in pixels (z is the pixel aspect ratio).
-
iTimefloat - Seconds since the shader started.
-
iTimeDeltafloat - Seconds since the previous frame.
-
iFrameRatefloat - Frames per second, smoothed.
-
iFrameint - Frames rendered since the start.
-
iMousevec4 - Mouse position: xy while held, zw of the last click.
-
iDatevec4 - Year, month, day, and seconds within the day.
-
iChannel0sampler2D - Texture bound to channel 0.
-
iChannel1sampler2D - Texture bound to channel 1.
-
iChannel2sampler2D - Texture bound to channel 2.
-
iChannel3sampler2D - Texture bound to channel 3.
-
iChannelResolutionvec3[4] - Pixel size of each bound channel texture.
-
iChannelTimefloat[4] - Playback time of each channel, in seconds.
-
iSampleRatefloat - Audio sample rate, always 44100.
Common
// The Common pass is prepended to the Image pass before compilation, so this
// is shared source rather than a second shader. The scene itself is declared
// here and defined in the Image pass, which is where the lesson lives.
float mapScene(vec3 position);
vec3 cameraRay(vec2 fragCoord, vec3 origin, vec3 target, float lens) {
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
vec3 forward = normalize(target - origin);
vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), forward));
vec3 up = cross(forward, right);
return normalize(forward * lens + right * uv.x + up * uv.y);
}
float marchScene(vec3 origin, vec3 direction, float maxDistance) {
float travelled = 0.0;
for (int i = 0; i < 96; i++) {
vec3 position = origin + direction * travelled;
float dist = mapScene(position);
if (dist < 0.001) return travelled;
travelled += dist;
if (travelled > maxDistance) break;
}
return -1.0;
}
vec3 sceneNormal(vec3 position, float epsilon) {
vec2 offset = vec2(epsilon, 0.0);
return normalize(vec3(
mapScene(position + offset.xyy) - mapScene(position - offset.xyy),
mapScene(position + offset.yxy) - mapScene(position - offset.yxy),
mapScene(position + offset.yyx) - mapScene(position - offset.yyx)));
}
vec3 skyColor(vec3 direction) {
return mix(vec3(0.05, 0.07, 0.12), vec3(0.16, 0.22, 0.34), direction.y * 0.5 + 0.5);
}
Main Image7
uniform float uSpacing; // @param 1.6..6.0 = 2.6 "Cell spacing"
uniform float uPropRadius; // @param 0.18..0.70 = 0.42 "Prop radius"
uniform float uViewDistance; // @param 6.0..40.0 = 24.0 "View distance"
float sdSphere(vec3 p, float radius) {
return length(p) - radius;
}
float sdBox(vec3 p, vec3 halfSize) {
vec3 q = abs(p) - halfSize;
return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}
float mapScene(vec3 position) {
float ground = position.y + 1.0;
vec3 p = position;
p.z += iTime * 0.9;
float cell = uSpacing;
p.x = mod(p.x + 0.5 * cell, cell) - 0.5 * cell;
p.z = mod(p.z + 0.5 * cell, cell) - 0.5 * cell;
float ball = sdSphere(p - vec3(0.0, -0.10, 0.0), uPropRadius);
float post = sdBox(p - vec3(0.0, -0.68, 0.0), vec3(0.09, 0.32, 0.09));
return min(ground, min(ball, post));
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec3 origin = vec3(0.0, 1.35, -3.0);
vec3 direction = cameraRay(fragCoord, origin, vec3(0.0, -0.35, 2.0), 1.5);
float hit = marchScene(origin, direction, uViewDistance);
if (hit < 0.0) {
fragColor = vec4(skyColor(direction), 1.0);
return;
}
vec3 position = origin + direction * hit;
vec3 normal = sceneNormal(position, 0.002);
vec3 toLight = normalize(vec3(0.55, 0.85, -0.7));
float diffuse = max(dot(normal, toLight), 0.0);
float isGround = smoothstep(0.04, -0.04, position.y + 0.99);
vec3 albedo = mix(vec3(0.78, 0.42, 0.30), vec3(0.26, 0.29, 0.34), isGround);
vec3 color = albedo * (0.15 + 0.85 * diffuse);
color = mix(skyColor(direction), color, exp(-0.10 * hit));
fragColor = vec4(color, 1.0);
}
Learn from this shader
How it works
Before the scene is evaluated, the horizontal coordinates are wrapped with a modulo centred on the cell, mapping every point into the same local space around one prop. The ground is computed from the original coordinate so it stays continuous, and the drift is applied before wrapping so the camera flies through the field rather than the field sliding under it. The catch is that a wrapped field only knows its own copy. Near a boundary the true nearest surface may be the neighbour, so the distance is too large, the march oversteps, and slices of geometry vanish.
Try changing
Raise Prop radius toward the cell spacing and watch spheres develop bites and hard seams where rays step past the neighbour they could not see. Reduce Cell spacing for the same effect from the other direction. Raise View distance to see how quickly cost grows when distant cells stay in range, and note that fog is what makes the far field affordable to hide.
Using it in a game
Repetition is how endless corridors, pillar halls, cityscapes and grated surfaces are built in distance field renderers. Where an artefact free result matters, clamp the repetition to a finite count so edge cells can see their real neighbours, or evaluate the nearest few cells instead of one.
Explore the techniques
Continue with curated explanations and progressively related examples.
Discussion