Antonin Carette

A journey into a wild pointer

The Tunnel Effect

Posted at — Aug 28, 2023

The demoscene

The tunnel effect is a pretty old-school effect in graphics programming, coming directly from the demoscene. If you take a look at you may find many (many many) examples of a Tunnel effect for different hardware configurations, like this one using 3D raycasting.

In this collection of web demo effects you can find and experiment with this effect:

In one of the most well-known demos ever, “Second Reality” from Future Crew, the team presented a very nice Tunnel effect in… 1993, years before any graphics card in a personal computer. You can take a look at this effect, running on the CPU, on YouTube.

The key word for running this on any PC at this time was “pre-calculation”, as the pixel distances and angles of the tunnel are pre-computed, and the runtime is basically moving around those tables at each frame.
Pre-calculation is what makes this demo running so fast and so smoothly at 60 frames per second, on a very old PC’s CPU.

If you are interested in the CPU computation, you can take a look at this excellent blog post.

Running the code

Developing on “Frame Engine”, I wondered how long it would take to rewrite this effect using a modern graphics API like Metal.

First, as I transformed my 2D engine to full-3D, I forgot to include some very basic 2D meshes like a surface… so, I wrote a very basic one as an OBJ file:

mtllib 2D_surface.mtl

v 0.000000 1.000000 0.000000
v 0.000000 0.000000 0.000000
v 1.000000 0.000000 0.000000
v 1.000000 1.000000 0.000000
vt 0.000000 0.500000 0.000000s
vt 0.000000 0.000000 0.000000
vt 0.500000 0.000000 0.000000
vt 0.500000 0.500000 0.000000
# 4 vertices

f 1/1 2/2 3/3 4/4
# 1 element

Once I have the surface, let’s write the fragment shader.

The Metal fragment shader is actually very simple:

#define ANIM_DISTANCE 0.45
#define ANIM_ANGLE    0.25

float4 tunnel(float2 worldPosition,
              texture2d<float, access::sample> colorTexture,
              sampler colorSampler,
              constant FragmentUniforms& uniforms) {
    // Compute the angle for the effect
    float  angle = atan2(1.0 + worldPosition.y, 1.0 + worldPosition.x) / PI; 
    // More the pixel is near the center and more it is distant for the effet
    float  distance = sqrt(dot(position, position));                    
    // Compute the UV of the texture to render
    float2 uv = float2(angle, ANIM_DISTANCE + (ANIM_ANGLE / distance));
    // Return the color
    return colorTexture.sample(colorSampler, uv);

To make a movement, you can multiply for example the ANIM_DISTANCE by your timer, like this:

float2 uv = float2(angle, cos(uniforms.time) * ANIM_DISTANCE + (ANIM_ANGLE / distance));

Now that we have the metal shader, let’s take a look at the sampler…
We want to repeat the texture motif for s and t, we do want linear filter near the eye, and nearest filter far the eye.

Associated to the texture, we could set the sampler like this:

let samplerDescriptor = MTLSamplerDescriptor()
samplerDescriptor.normalizedCoordinates = true
samplerDescriptor.minFilter             = .nearest
samplerDescriptor.magFilter             = .linear
samplerDescriptor.sAddressMode          = .mirrorRepeat
samplerDescriptor.tAddressMode          = .mirrorRepeat
samplerDescriptor.maxAnisotropy         = 2
self.samplerState = device.makeSamplerState(descriptor: samplerDescriptor)

Using this setting, and a few textures, you should get the following results:

First example of the Tunnel effect, rendered in the Frame Engine editor

Second example of the Tunnel effect, rendered in the Frame Engine editor

The animated version is available on YouTube: