This article will go through the steps of constructing an infinite square tunnel with a minecraft-like feel (pixelated colors). We'll go through multiple simplified code samples to show what each part does - with screenshots.
Color gradient using selected colors instead of just randomness.
fn gradient_color(value: f32) -> vec3<f32> { // Clamp the input value to the range [0.0, 1.0] let t = clamp(value, 0.0, 1.0);
// Define gradient colors let colors = array<vec3<f32>, 5>( vec3<f32>(1.0, 1.0, 1.0), // White vec3<f32>(0.75, 0.75, 0.75), // Gray vec3<f32>(0.87, 0.72, 0.53), // Light Brown vec3<f32>(0.65, 0.45, 0.25), // Brown vec3<f32>(0.45, 0.27, 0.07) // Dark Brown );
// Determine segment and local interpolation factor let segment = t * 4.0; // Map t to range [0, 4] let index = floor(segment); // Segment index let local_t = fract(segment); // Local interpolation factor
// Interpolate between two consecutive colors return mix(colors[i32(index)], colors[i32(index + 1)], local_t); }
var normalized_coords = (2.0 * (input_coords) - 1.0); // -1.0 to 1.0
var abs_coords = abs( normalized_coords ); // 1->0->1
var quantized_coords = floor( 10.0 * abs_coords );
var rv = random2( quantized_coords );
var rgb = gradient_color( rv );
return vec4<f32>( rgb, 1.0 ); }
Other gradient colors - repeating values to emphasis particular colors.
Mix in a bit of green with more white and gray.
const numGradients:i32 = 8; fn gradient_color(value: f32) -> vec3<f32> { // Clamp the input value to the range [0.0, 1.0] let t = clamp(value, 0.0, 1.0);
// Define gradient colors let colors = array<vec3<f32>, numGradients>( vec3<f32>(1.0, 1.0, 1.0), // White vec3<f32>(1.0), vec3<f32>(0.75, 0.75, 0.75), vec3<f32>(0.75, 0.75, 0.75), // Gray vec3<f32>(0.75, 0.75, 0.75), // Gray vec3<f32>(0.37, 0.52, 0.33), vec3<f32>(0.65, 0.45, 0.25), // Brown vec3<f32>(0.45, 0.27, 0.07) // Dark Brown );
// Determine segment and local interpolation factor let segment = t * ( f32(numGradients) - 1.0 ); // Map t to range [0, numGradients] let index = floor(segment); // Segment index let local_t = fract(segment); // Local interpolation factor
// Interpolate between two consecutive colors return mix(colors[i32(index)], colors[i32(index + 1)], local_t); }
Towards Infinity
Simple example of taking a screen full of squares
floor(..)
function and the uv coordinates - then the vertical texture coordinate that goes from 0 to 1 - which is used for the
depth
(divide it by the distance so as it goes further away it gets smaller).
We need to make sur ew use the normalized coordinates with the origin in the middle of the screen so that is the far distance point.
Shows a grid of squares so they're scaled using the vertical value - as the value increases they get smaller (distance).
@fragment fn main(@location(0) input_coords: vec2<f32>) -> @location(0) vec4<f32> { // Scale the input coordinates var pixel_coords = input_coords * 512.0;
var vertical = input_coords.y; // 0 to 1
// map the 0->1 to lo->hi value let lo = 1.0; let hi = 0.1; var block_size = lo + (hi - lo) * (vertical);
// Calculate normalized coordinates -1 to 1 - we want to divide by the // normalize (0 in the middle) - so when we divide the value it goes towards // the middle and not the left (if 0,0 is on the left side). // Useful to know if you want to move the target point! var normalized_coords = (2.0 * pixel_coords - resolution.xy) / resolution.y;
var quantized_coords = floor( 10.0 * normalized_coords / block_size );
// Generate a random color based on the quantized coordinates var rr = random2(quantized_coords);
return vec4<f32>( vec3(rr), 1.0); }
If we modify the code so instead of just going from 0 to 1 for the vertical - we use the normalized value that goes from -1 to 1 - we pass it through an
abs(..)
function - so it remains positive - which gives us 1.0->0.0->1.0 for the texture coordinate.
var vertical = abs(normalized_coords.y); // 1->0->1
Gives us the following output:
Normalized vertical coordinates instead of just a vertical value of 0 to 1 - we use 1-0-1. Works but it's the wrong way around!
This is the full code - same as before - except the modified line:
@fragment fn main(@location(0) input_coords: vec2<f32>) -> @location(0) vec4<f32> { // Scale the input coordinates var pixel_coords = input_coords * 512.0;
// Calculate normalized coordinates -1 to 1 - we want to divide by the // normalize (0 in the middle) - so when we divide the value it goes towards // the middle and not the left (if 0,0 is on the left side). // Useful to know if you want to move the target point! var normalized_coords = (2.0 * pixel_coords - resolution.xy) / resolution.y;
var vertical = 1.0 - abs(normalized_coords.x); // 1->0->1
// map the 0->1 to lo->hi value let lo = 1.0; let hi = 0.3; var block_size = lo + (hi - lo) * (vertical);
var quantized_coords = floor( 10.0 * normalized_coords / block_size );
// Generate a random color based on the quantized coordinates var rr = random2(quantized_coords);
return vec4<f32>( vec3(rr), 1.0); }
It is working - but we need to fix the values; so instead of 1-0-1 we use 0-1-0 to get the correct output.
The following will invert the coordinates and give us a better result:
var vertical = 1.0 - abs(normalized_coords.y); // 0->1->0
Vertical top and bottom fade away towards the middle of the screen (into the distance).
Simply a matter of changing the
.y
to
.x
to see the same thing for the horizontal.
var vertical = 1.0 - abs(normalized_coords.x); // 1->0->1
Squares fade away on the horizontal - by checking the width instead of the height for the texture coordinates.
The big step after this - is to have both the horizontal and vertical! To create a tunnel effect.
We'll check which is the biggest value - the horizontal or vertical and use this to divide as the distance.
@fragment fn main(@location(0) input_coords: vec2<f32>) -> @location(0) vec4<f32> { // Scale the input coordinates var pixel_coords = input_coords * 512.0;
// Calculate normalized coordinates -1 to 1 - we want to divide by the // normalize (0 in the middle) - so when we divide the value it goes towards // the middle and not the left (if 0,0 is on the left side). // Useful to know if you want to move the target point! var normalized_coords = (2.0 * pixel_coords - resolution.xy) / resolution.y;
var delta = 1.0 - abs(normalized_coords); // 1->0->1 var distance = delta.y; if ( delta.y > delta.x ) { distance = delta.x; }
// map the 0->1 to lo->hi value let lo = 1.0; let hi = 0.3; var block_size = lo + (hi - lo) * (distance);
var quantized_coords = floor( 10.0 * normalized_coords / block_size );
// Generate a random color based on the quantized coordinates var rr = random2(quantized_coords);
return vec4<f32>( vec3(rr), 1.0); }
Squares fade away into the centre of the screen (origin point).
If you'll notice the squares seems stretched - and not correct, so we fix this by modifying the calculation.
@fragment fn main(@location(0) input_coords: vec2<f32>) -> @location(0) vec4<f32> { // Scale the input coordinates var pixel_coords = input_coords;
// Calculate normalized coordinates -1 to 1 - we want to divide by the // normalize (0 in the middle) - so when we divide the value it goes towards // the middle and not the left (if 0,0 is on the left side). // Useful to know if you want to move the target point! var normalized_coords = (2.0 * input_coords - 1.0);// -1.0 to 1.0
// Generate a random color based on the quantized coordinates var rr = random2( quantized_coords );
rr = mix( 0.0, rr, pow(length(abs_coords),2) );
return vec4<f32>( vec3(rr), 1.0); }
Add mytimer uniform value to the coordinates to create a scolling effect - looks like we're constantly going down the tunnel.
Tinker with Colors
While grayscale does look good - adding a bit of color can make things more interesting - and we'll set the ground to brown - so make some of the squares brown.
@fragment fn main(@location(0) input_coords: vec2<f32>) -> @location(0) vec4<f32> { // Scale the input coordinates var pixel_coords = input_coords;
let tt = mytimer;
var normalized_coords = (2.0 * (input_coords) - 1.0); // -1.0 to 1.0
var abs_coords = abs( normalized_coords ); // 1->0->1
// Generate a random color based on the quantized coordinates var rr = random2( quantized_coords );
let background_color = vec3<f32>(0.78, 0.57, 0.4);
// mix some squares with brown var color = mix( background_color, vec3(rr), clamp(pow(rr+0.7, 5),0.0, 1.0) );
// fade to black in the distance color = mix( vec3(0.0), color, pow(length(abs_coords),2) );
return vec4<f32>( color, 1.0); }
Mixing in brown color on top of the dark grayscale squares.
We can use a color gradient from the random value instead of just blending to give greater control over each squares color.
Brown color gradient - with a touch of green.
const numGradients:i32 = 8; fn gradient_color(value: f32) -> vec3<f32> { // Clamp the input value to the range [0.0, 1.0] let t = clamp(value, 0.0, 1.0);
// Determine segment and local interpolation factor let segment = t * ( f32(numGradients) - 1.0 ); // Map t to range [0, numGradients] let index = floor(segment); // Segment index let local_t = fract(segment); // Local interpolation factor
// Interpolate between two consecutive colors return mix(colors[i32(index)], colors[i32(index + 1)], local_t); }
Some other color values
Other gradient values for the color range.
const numGradients:i32 = 8; fn gradient_color(value: f32) -> vec3<f32> { // Clamp the input value to the range [0.0, 1.0] let t = clamp(value, 0.0, 1.0);
let col0 = vec3<f32>(111/255.0, 85/255.0, 60.0/255.0); let col1 = vec3<f32>(139/255.0, 118/255.0, 99.0/255.0); let col2 = vec3<f32>(45/255.0, 31/255.0, 21.0/255.0);
// Determine segment and local interpolation factor let segment = t * ( f32(numGradients) - 1.0 ); // Map t to range [0, numGradients] let index = floor(segment); // Segment index let local_t = fract(segment); // Local interpolation factor
// Interpolate between two consecutive colors return mix(colors[i32(index)], colors[i32(index + 1)], local_t); }
Minecraft Tunnel
Let's go a bit further - the tunnel is fun and interesting - but it can do with a few more tweaks! Add some multisampling to smooth out some of those jaggedy edges, also a few more colors and a train rail at the bottom.
Minecraft tunnel with some extra bells and whistles.
// Generates a pseudo-random value based on a 2D input vector fn random2(point: vec2<f32>) -> f32 { let seed = dot(point, vec2<f32>(127.1, 311.7)); // Generate a hash seed using dot product return fract(sin(seed) * 43758.5453123); // Return a pseudo-random fractional value }
// Custom smoothstep function for smooth interpolation fn mysmoothstep(edge_start: f32, edge_end: f32, value: f32) -> f32 { let t = clamp((value - edge_start) / (edge_end - edge_start), 0.0, 1.0); // Clamp and normalize value return t * t * (3.0 - 2.0 * t); // Cubic Hermite interpolation for smooth transition }
// Fragment shader main function @fragment fn main(@location(0) input_coords: vec2<f32>) -> @location(0) vec4<f32> { // Scale the input coordinates var pixel_coords = input_coords;
// Initialize colors var black = vec3<f32>(0); // Base color (black) var white = vec3<f32>(1); // Secondary color (white) var total_color = black; // Accumulator for final color
Multiple samples for each pixel to smooth out jaggedyness (anti-aliasing).
// Perform anti-aliasing by sampling the pixel multiple times for (var sample_x: i32 = 0; sample_x < NUM_SAMPLES; sample_x = sample_x + 1) { for (var sample_y: i32 = 0; sample_y < NUM_SAMPLES; sample_y = sample_y + 1) { // Offset for sub-pixel sampling var sample_offset = (vec2<f32>(f32(sample_x), f32(sample_y)) / f32(NUM_SAMPLES)) - 0.5; sample_offset *= 1.0/resolution;
// Calculate normalized coordinates -1.0 to 1.0 var normalized_coords = (2.0 * pixel_coords + sample_offset - 1.0);
// Compute additional parameters for distortion and patterns let normalized_x = normalize(normalized_coords).x;
Add a bit of normalization to the coordinates to 'round' the edges - so it's less square.
Visitor:
Copyright (c) 2002-2025 xbdev.net - All rights reserved.
Designated articles, tutorials and software are the property of their respective owners.