Skip to main content
GameDev.net gamedev.net

Repeating a 3D Scene

by GameDev.net · GLSL ES 3.00 (WebGL2) · 25 Aug 2026

Use in your engine

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.

Source Revision 1

Author notes are linked to specific lines.

Shader inputs

void mainImage(out vec4 fragColor, in vec2 fragCoord)

Called once per pixel. Write the colour to fragColor.

iResolution vec3
Viewport size in pixels (z is the pixel aspect ratio).
iTime float
Seconds since the shader started.
iTimeDelta float
Seconds since the previous frame.
iFrameRate float
Frames per second, smoothed.
iFrame int
Frames rendered since the start.
iMouse vec4
Mouse position: xy while held, zw of the last click.
iDate vec4
Year, month, day, and seconds within the day.
iChannel0 sampler2D
Texture bound to channel 0.
iChannel1 sampler2D
Texture bound to channel 1.
iChannel2 sampler2D
Texture bound to channel 2.
iChannel3 sampler2D
Texture bound to channel 3.
iChannelResolution vec3[4]
Pixel size of each bound channel texture.
iChannelTime float[4]
Playback time of each channel, in seconds.
iSampleRate float
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
1 uniform float uSpacing; // @param 1.6..6.0 = 2.6 "Cell spacing"
2 uniform float uPropRadius; // @param 0.18..0.70 = 0.42 "Prop radius"
3 uniform float uViewDistance; // @param 6.0..40.0 = 24.0 "View distance"
4
5 float sdSphere(vec3 p, float radius) {
6 return length(p) - radius;
7 }
8
9 float sdBox(vec3 p, vec3 halfSize) {
10 vec3 q = abs(p) - halfSize;
11 return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
12 }
13
14 float mapScene(vec3 position) {
15 float ground = position.y + 1.0;
16 vec3 p = position;
17 p.z += iTime * 0.9;
18 float cell = uSpacing;
19 p.x = mod(p.x + 0.5 * cell, cell) - 0.5 * cell;
20 p.z = mod(p.z + 0.5 * cell, cell) - 0.5 * cell;
21 float ball = sdSphere(p - vec3(0.0, -0.10, 0.0), uPropRadius);
22 float post = sdBox(p - vec3(0.0, -0.68, 0.0), vec3(0.09, 0.32, 0.09));
23 return min(ground, min(ball, post));
24 }
25
26 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
27 vec3 origin = vec3(0.0, 1.35, -3.0);
28 vec3 direction = cameraRay(fragCoord, origin, vec3(0.0, -0.35, 2.0), 1.5);
29 float hit = marchScene(origin, direction, uViewDistance);
30 if (hit < 0.0) {
31 fragColor = vec4(skyColor(direction), 1.0);
32 return;
33 }
34 vec3 position = origin + direction * hit;
35 vec3 normal = sceneNormal(position, 0.002);
36 vec3 toLight = normalize(vec3(0.55, 0.85, -0.7));
37 float diffuse = max(dot(normal, toLight), 0.0);
38 float isGround = smoothstep(0.04, -0.04, position.y + 0.99);
39 vec3 albedo = mix(vec3(0.78, 0.42, 0.30), vec3(0.26, 0.29, 0.34), isGround);
40 vec3 color = albedo * (0.15 + 0.85 * diffuse);
41 color = mix(skyColor(direction), color, exp(-0.10 * hit));
42 fragColor = vec4(color, 1.0);
43 }
44

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.

LicenseMIT
Views0
Forks0

Discussion

Loading comments...