JavaScript GLSL 3D Raytracing Animation Graphics Web

This is a raytracer that runs entirely in a WebGL fragment shader. One quad covers the whole canvas, and the fragment shader runs once for every pixel: it makes a ray from the eye through that pixel, finds the nearest sphere the ray hits, and works out the colour there. There are no triangles anywhere in the scene. Each sphere is just a centre and a radius inside the shader.

A graphics card runs that shader for hundreds of thousands of pixels at once, so even with lighting, shadows and reflections it keeps up a decent frame rate. The catch is that a fragment shader is a very limited program. It has no recursion, its loops need fixed counts, and the only thing it gets from outside is the time, so the usual recursive raytracer has to be flattened out and the whole scene worked out again for every pixel.

These use the fragment shader framework to render everything in one fragment shader function which is called for every pixel in the canvas. It's built up in steps, each one a small change to the one before, from a framework that does nothing but colour a gradient, through basic rendering, to the bumpy spheres at the top of the page. Each step runs live beside its explanation. Let's see how it works.

StepWhat it adds
FrameworkA fragment shader run over every pixel, with the time
001Three spheres, one ray per pixel, normals shown as colours
002Moving spheres, diffuse and Phong lighting, a background
003Two reflected rays
004A shadow ray at each hit, and one reflection instead of two
005Six spheres, made in a loop
006Sixteen spheres on one curve, on a 320x320 canvas
007Back to three spheres, with rings from the distance to the eye
008Bumps, from a perturbed normal
The steps, and what each one adds to the one before

The fragment shader framework

The framework is a single JavaScript file, webgl_fragment_framework.001.js. Its header comment says what it's for: "You provide an HTML canvas element and a fragment shader, and this shader is executed for each pixel in the canvas. This is implemented by rendering one quad which takes up the whole area of the canvas."

A page gives it a canvas, a script element holding the fragment shader, and an element to show the frame rate in, and calls webgl_init( 'canvas', 'shader', 'status' ) when it loads. The framework gets a WebGL context, compiles the shader and draws the quad over and over.

The quad is four corners at z = 1, from -1 to 1 in x and y, drawn as a triangle strip. Those are already clip space coordinates, so the vertex shader passes them straight through, and hands each corner's position on to the fragment shader as vPosition:

    vs =
      '#ifdef GL_ES\nprecision highp float;\n#endif\n' +
      'attribute vec3 aVertexPosition; uniform float uTime; varying vec3 vPosition;' +
      'void main(void) { gl_Position = vec4(aVertexPosition, 1.0); vPosition = aVertexPosition; }'
The vertex shader, built as a string in initShaders()
    var vertices = [
         1.0, -1.0, 1.0,
        -1.0, -1.0, 1.0,
         1.0,  1.0, 1.0,
        -1.0,  1.0, 1.0,
    ];
The quad's four corners, in initBuffers()

The graphics card interpolates vPosition across the quad, so in the fragment shader it's the position of the pixel being drawn: (-1, -1, 1) at the bottom left of the canvas and (1, 1, 1) at the top right. On a 640x640 canvas, a pixel's x is about its column divided by 320, minus 1.

The other input is uTime, the time in seconds. A timer set with setInterval(webgl_timer, 15) adds up the milliseconds since the last tick, sets the uniform and draws the quad:

    var timeNow = new Date().getTime();

    if (lastTime != 0)
    {
      var elapsed = timeNow - lastTime;

      normalisedTime += elapsed;

      ...
    }

    var timeUniform = gl.getUniformLocation( shaderProgram, "uTime" )
    gl.uniform1f( timeUniform, normalisedTime / 1000.0 )

    lastTime = timeNow;

    //  Draw

    gl.clear( gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT )

    gl.bindBuffer( gl.ARRAY_BUFFER, vertexPositionBuffer )
    gl.vertexAttribPointer( aVertexPosition, vertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0 )

    gl.drawArrays( gl.TRIANGLE_STRIP, 0, vertexPositionBuffer.numItems )
webgl_timer(): count the time, set uTime and draw the quad (trimmed)

The part left out writes "FPS: " and 1000 divided by the milliseconds since the last tick into the status element, under the canvas in the full window versions.

The header comment lists a third input, uMousePos, the mouse position over the canvas. The shaders all declare it, but nothing in the framework ever sets it, so it stays at (0, 0) and none of the shaders use it. The framework also turns on blending and depth testing in webgl_init(), and a getShaderFromElement() function is never called. Neither makes any difference to a single quad drawn in opaque colours.

The test shader that comes with it colours each pixel from its position and pulses the blue with the time:

  void main(void)
  {
    float r = 0.5 + 0.5 * vPosition.x;
    float g = 0.5 + 0.5 * vPosition.y;
    float b = 0.5 + 0.5 * sin( uTime );

    gl_FragColor = vec4( r, g, b, 1.0 );
  }
The framework's test shader: red across, green up, and blue going up and down every 6.28 seconds
WebGL. Open full window

That's the whole interface: a position and a time in, a colour out. Everything else has to happen inside the shader.

Rays and spheres

Raytracer 001 puts three spheres in front of the eye and shows which way each one's surface faces at every pixel.

The eye is at the origin, looking along z. Since vPosition is a point on the plane z = 1, the direction from the eye through the pixel is just vPosition made to length 1. The quad's edges are at x and y = ±1 on that plane, so the view is 90 degrees across:

    ray = vec3( 0.0, 0.0, 0.0 );
    dir = normalize( vPosition );
Raytracer 001: one ray per pixel, from the eye through the pixel

Each sphere is a vec4, with the centre in x, y and z and the radius in w, and gets a colour too. They're set at the start of main(), so every pixel sets up the scene again for itself:

SphereCentreRadiusColour
0(-1, 0, 4)0.50.5, 0, 1
1(1, 0.5, 3)0.251, 0.75, 1
2(0.2, -0.75, 3.5)0.750, -0.75, 1
The three spheres in Raytracer 001

The colours aren't used yet (one of them even has a green of -0.75). This step only shows the normals.

Hitting a sphere

A point on the ray is ray + t dir, for a distance t along it. It's on the sphere when its distance from the centre is the radius r:

|ray + t dir - centre|² = r²

Multiplied out, that's a quadratic in t, at² + bt + c = 0, with

  • a = dir · dir
  • b = 2 dir · (ray - centre)
  • c = |ray - centre|² - r²

and intersectSphere() solves it with the usual formula, written out a component at a time:

  float intersectSphere( in vec3 ray, in vec3 dir, in vec3 sphere_pos, in float radius )
  {
    float x = sphere_pos.x;
    float y = sphere_pos.y;
    float z = sphere_pos.z;

    float a, b, c, d, t1, t2;

    a = dir.x * dir.x + dir.y * dir.y + dir.z * dir.z;
    b = 2.0 * (dir.x * (ray.x - x) + dir.y * (ray.y - y) + dir.z * (ray.z - z));
    c = (ray.x - x) * (ray.x - x) + (ray.y - y) * (ray.y - y) + (ray.z - z) * (ray.z - z) - radius * radius;
    d = b * b - 4.0 * a * c;

    if( d <= 0.0 ) return 0.0;

    d = sqrt(d);

    t2 = (-b + d) / (2.0 * a);
    t1 = (-b - d) / (2.0 * a);

    if( t1 > t2 ) return t2;
    return t1;
  }
intersectSphere(): the ray–sphere quadratic, returning the nearer root, or 0 for a miss

If the discriminant d is zero or less, the ray misses and the function returns 0. Otherwise there are two roots, where the ray goes into the sphere and where it comes out, and it returns the nearer one, t1. Since a is always positive, t1 can never be bigger than t2, so the if( t1 > t2 ) line never fires. dir has length 1, so a always comes out as 1, but it's worked out anyway.

Let's try a real pixel: the one on the middle row, 96 pixels left of the centre. Its vPosition is (-0.3, 0, 1), so dir is (-0.2873, 0, 0.9578). For sphere 0, with its centre at (-1, 0, 4) and radius 0.5:

  • a = 1
  • b = 2 × (-0.2873 × 1 + 0.9578 × -4) = -8.2373
  • c = (1² + 0² + 4²) - 0.5² = 16.75
  • d = 8.2373² - 4 × 16.75 = 67.853 - 67 = 0.853

d is positive, so the ray hits. Its square root is 0.9237, so t1 = (8.2373 - 0.9237) / 2 = 3.657 and t2 = 4.581: the ray goes into the sphere 3.657 units from the eye and comes out at 4.581. The same sums for the other two spheres give d = -14.0 and -5.73, two misses.

A ray from the eye through one pixel, hitting sphere 0
A ray from the eye through one pixel, hitting sphere 0

The pixel right in the middle only just misses. Its ray runs straight down the z axis, which passes 0.776 from the centre of sphere 2, and sphere 2's radius is 0.75. For that one d = -0.16.

The nearest hit

intersect() tries the ray against every sphere and keeps the nearest hit in front of the eye. Then it works out the point that was hit, the normal there, the reflected direction and the colour:

  int intersect( in vec3 ray, in vec3 dir, out vec3 col, out vec3 pos, out vec3 normal, out vec3 reflection )
  {
    float max_t = 9999999.0;
    int found_i = -1;

    for( int i = 0; i<3; i++ )
    {
      float t = intersectSphere( ray, dir, sphere_pos[i].xyz, sphere_pos[i].w );

      if( t > 0.0001 && t < max_t )
      {
        max_t = t;
        found_i = i;
      }
    }

    if( found_i == -1 )
    {
      col = vec3( 0.0, 0.0, 0.0 );
      pos = vec3( 0.0 );
      normal = vec3( 0.0 );
      reflection = vec3( 0.0 );
      return -1;
    }

    for( int i = 0; i<3; i++ )
    {
      if( found_i == i )
      {
        pos = ray + max_t * dir;

        normal = (pos - sphere_pos[i].xyz) / sphere_pos[i].w;

        reflection = dir - 2.0*normal*dot( dir, normal );

        col = sphere_col[i];
      }
    }

    return found_i;
  }
intersect(): find the nearest sphere, then fill in the hit point, normal, reflection and colour

The second loop looks odd: it goes through all the spheres again just to find the one it already knows, found_i. That's because of GLSL ES, the version of GLSL in WebGL. A fragment shader is only guaranteed to be able to index an array with a constant or a loop counter, and found_i is neither, so sphere_pos[found_i] isn't allowed. Looping with i and comparing gets round it. Loops need a constant count too, which is why the number of spheres is written into every loop.

The normal is the direction from the centre to the hit point. Dividing by the radius gives it length 1 without a call to normalize(). For our pixel, the hit point is (-1.0508, 0, 3.5026), so the normal is (-0.1016, 0, -0.9948), pointing back towards the eye and slightly to the left. The reflection isn't needed yet; it comes in at the next step.

The colour of the pixel is the normal, moved from -1 to 1 into 0 to 1:

    int sphere_index = intersect( ray, dir, col, pos, normal, reflection );

    gl_FragColor = vec4( normal*0.5 + 0.5, 1.0 );
Showing the normal as a colour

So our pixel comes out as red 0.449, green 0.5 and blue 0.003. Every normal you can see points back towards the eye, with z below zero, so the blue is under a half everywhere on the spheres, and close to nothing in the middle of each one. Redder parts face right, greener parts face up. A pixel that misses everything gets a normal of (0, 0, 0) from intersect(), which comes out as mid grey.

WebGL. Open full window

It doesn't move, since nothing uses uTime yet, but it's being drawn afresh every frame all the same.

Diffuse and Phong lighting

Raytracer 002 sets the spheres moving, gives them their colours and lights them with one point light at (-0.5, 3, 2), above the eye and a little to the left. Sphere 1 is bigger now, with a radius of 0.8, and the colours are purple, orange and lime green.

Since the only thing the shader gets from outside is the time, every pixel works out where the spheres are for itself. Each coordinate is a sine wave of the time, with its own phase and speed for each sphere, swinging 1.3 either way about a point 4 units in front of the eye:

    for( int i=0; i<3; i++ )
    {
      float x = 1.3*sin( float(i)*2.1768 - uTime*(0.3762+float(i)*0.1298) );
      float y = 1.3*sin( float(i)*3.1897 + uTime*(0.2467+float(i)*0.3178) );
      float z = 1.3*sin( float(i)*0.1123 + uTime*(0.3112+float(i)*0.5512) );

      sphere_pos[i] = vec4( x, y, z + 4.0, sphere_pos[i].w );
    }
Raytracer 002: moving the spheres, in every pixel, every frame

Sphere 0 takes 16.7, 25.5 and 20.2 seconds to go round in x, y and z, and sphere 2 only 9.9, 7.1 and 4.4, so it darts about the most. Because z moves too, they come towards you and go away. The loop overwrites the starting positions, so in 002 only the radii are used from them.

Then the lighting:

    if( sphere_index == -1 )
    {
      col = vec3( 0.25 ) * dir.z;
    }
    else
    {
      vec3 light_dir = normalize( light - pos );

      col *= max( dot( normal, light_dir ), 0.0 )*0.8 + 0.2;

      float phong = pow( max( dot( reflection, light_dir ), 0.0 ), 17.0 );

      col = vec3( 1.0, 1.0, 1.0 ) * phong + col*(1.0-phong);
    }
The background, the diffuse light and the Phong highlight

A ray that misses gets a dark grey of 0.25 times dir.z. dir.z is the cosine of the angle between the ray and straight ahead, so the background is 0.25 in the middle, 0.177 at the middle of each edge and 0.144 in the corners, a slight darkening towards the edges.

The vectors at the point a ray hits
The vectors at the point a ray hits

The diffuse light is the cosine of the angle between the normal and the direction to the light: the dot product of the two, when both have length 1. A surface facing the light gets all of it, and one at right angles or facing away gets none, so it's clamped at zero. Then it's scaled into 0.2 to 1, so the side away from the light still shows its colour at 20%, as an ambient light.

The highlight uses reflection, the view ray bounced off the surface, which intersect() has been working out since 001. It's the incoming direction with the part along the normal flipped: dir - 2(dir · normal) normal. Where that bounced ray points straight at the light you'd see the light mirrored in a shiny surface. The dot product of the two is raised to the 17th power, which keeps it near 1 only when they're very close: it's down to a half at 16.3 degrees and a tenth at 29.2. The colour is then mixed towards white by that amount. That's the Phong highlight.

Let's do the pixel in the middle of the canvas, at the start, when the time is 0. All the sines for sphere 0 are sin(0), so it's at (0, 0, 4), straight ahead, and the ray hits it at (0, 0, 3.5) with a normal of (0, 0, -1).

  • light - pos = (-0.5, 3, -1.5), which has length 3.391, so light_dir = (-0.1474, 0.8847, -0.4423)
  • normal · light_dir = 0.4423, so the colour is scaled by 0.4423 × 0.8 + 0.2 = 0.5539
  • the purple (0.5, 0, 1) becomes (0.277, 0, 0.554)
  • the ray hits head on, so reflection = (0, 0, -1), and 0.4423 to the 17th is less than 0.000001: no highlight there

The highlight is above the middle of each sphere and a little to the left, where the surface faces halfway between the eye and the light.

WebGL. Open full window

There are no shadows yet. A sphere passing between another one and the light doesn't darken it, because nothing checks.

Reflections

A raytracer gets reflections almost for free. When a ray hits a shiny surface, you send a new ray off in the reflected direction, see what that one hits, light it, and mix its colour into the first. Raytracer 003 does that twice, so with the first hit there are up to three surfaces in each pixel.

The normal way to write this is a recursive function: trace a ray, and at the hit, call the same function for the reflected ray, down to some depth. GLSL ES doesn't allow recursion at all. A function can't call itself, even indirectly, so each level has to be written out in full, inside the one before:

      final_col = col;

      sphere_index = intersect( pos+reflection*0.0001, reflection, col, pos, normal, reflection );

      if( sphere_index >= 0 )
      {
        vec3 light_dir = normalize( light - pos );
        col *= max( dot( normal, light_dir ), 0.0 )*0.8 + 0.2;
        float phong = pow( max( dot( reflection, light_dir ), 0.0 ), 17.0 );
        col = vec3( 1.0, 1.0, 1.0 ) * phong + col*(1.0-phong);

        final_col = final_col*0.5 + col*0.5;

        sphere_index = intersect( pos+reflection*0.0001, reflection, col, pos, normal, reflection );

        if( sphere_index >= 0 )
        {
          vec3 light_dir = normalize( light - pos );
          col *= max( dot( normal, light_dir ), 0.0 )*0.8 + 0.2;
          float phong = pow( max( dot( reflection, light_dir ), 0.0 ), 17.0 );
          col = vec3( 1.0, 1.0, 1.0 ) * phong + col*(1.0-phong);

          final_col = final_col*0.75 + col*0.25;
        }
      }
Raytracer 003: two levels of reflection, written out by hand after the first hit has been lit

Each new ray starts at the last hit point and goes along the last reflection. intersect() takes pos and reflection in and hands new ones back through the same variables, which works because GLSL copies in arguments when the function is called and only writes out arguments back when it returns.

The new ray starts 0.0001 out along the reflection, to keep it clear of the surface it's leaving. It would be fine without it. intersectSphere() only ever returns the nearer root, and for a ray leaving a sphere's surface, the rest of that sphere is behind it, so the nearer root is negative and the test t > 0.0001 throws it out. A ray can't hit the sphere it starts on.

The lighting is the same lines as before, copied into each level. Only the mixing differs:

Surfaces hitFirstSecondThird
11--
20.50.5-
30.3750.3750.25
How much of each surface's colour ends up in the pixel
One pixel's ray reflected from sphere to sphere
One pixel's ray reflected from sphere to sphere

A reflected ray that misses leaves the colour as it is. The background isn't mixed in, so the edges of the spheres, where the reflections point out into space, show their own colour rather than a mirrored sky. The ray from the third hit isn't traced at all.

The spheres move differently here too. Each coordinate is the sphere's starting position plus a sine wave whose phase and speed come from its other coordinates:

      float x = sphere_pos[i].x + 1.3*sin(sphere_pos[i].y*2.13 + uTime*(0.6762+sphere_pos[i].z*0.163));
      float y = sphere_pos[i].y + 1.3*sin(sphere_pos[i].z*1.13 + uTime*(0.8467+sphere_pos[i].x*0.193));
      float z = sphere_pos[i].z + 0.3*sin(sphere_pos[i].x*4.13 + uTime*(1.1112+sphere_pos[i].y*0.183));
Raytracer 003: the motion from 003 on, seeded by each sphere's starting position

z only moves 0.3 either way now, so the spheres stay at much the same distance and slide across the view and past each other, which keeps them close enough to reflect each other. They're quicker as well, going round every 4.7 to 9.6 seconds. Sphere 0 starts off at (-1, -1.28, 4.25), and sphere 2 at (-1.10, -1.69, 3.72), which is close enough for the two to overlap. Nothing stops spheres passing through each other.

WebGL. Open full window

The full window version is titled "Recursive reflection", although by now you can see it isn't, quite.

Shadows

A point is in shadow if something is between it and the light. Raytracer 004 finds out by sending one more ray from each hit point, towards the light, and seeing if it hits anything on the way.

That only needs a yes or no, not the nearest hit, so it has its own function, which gives up at the first sphere it finds:

  bool intersect_shadow( in vec3 ray, in vec3 dir )
  {
    for( int i = 0; i<3; i++ )
    {
      float t = intersectSphere( ray, dir, sphere_pos[i].xyz, sphere_pos[i].w );

      if( t > 0.0 )
      {
        return false;
      }
    }
    return true;
  }
Raytracer 004: true if nothing is in the way

The lighting from 002 now only happens if the shadow ray gets through. A point in shadow gets just the 20% ambient part of its colour, and no highlight:

      vec3 light_dir = light - pos;

      if( intersect_shadow( pos+light_dir*0.0000001, light_dir ) )
      {
        light_dir = normalize( light_dir );
        col *= max( dot( normal, light_dir ), 0.0 )*0.8 + 0.2;
        float phong = pow( max( dot( reflection, light_dir ), 0.0 ), 17.0 );
        col = vec3( 1.0, 1.0, 1.0 ) * phong + col*(1.0-phong);
      }
      else
      {
        col *= 0.2;
      }
Lighting a hit only if its shadow ray reaches the light
Shadow rays from two hit points
Shadow rays from two hit points

light_dir isn't normalised for the shadow ray. It's the whole way from the hit point to the light, so the light is at t = 1 along it, and a sphere beyond the light would block it just as well as one in front, since the test is only t > 0.0. In practice that can't happen. The light is at a height of 3, and the highest any sphere reaches is the top of sphere 1, at 0.5 + 1.3 + 0.8 = 2.6, while everything past the light on a shadow ray is higher than 3.

The same thing as with the reflections keeps a shadow ray from hitting its own sphere. On the side facing the light, the ray leaves the surface and the nearer root is behind it. On the side facing away, the shadow ray heads back into its own sphere, and whether that counts as in the way comes down to rounding. The diffuse light there is zero anyway, so it comes out at close to the same 20% either way.

The second reflection has gone. There's one reflected ray, with its own shadow ray, and it's mixed in at a quarter:

        final_col = final_col*0.75 + col*0.25;
Raytracer 004: one reflection, at a quarter

So a pixel can now need up to four rays: the first, its shadow ray, the reflection and the reflection's shadow ray. The offsets moved about too: the first shadow ray and the reflection start 0.0000001 along, the reflection's shadow ray 0.0001, and intersect() now takes any hit with t > 0.0.

WebGL. Open full window

The shadows are hard edged, since the light is a single point. Watch the lower spheres as the others pass above them.

More spheres

Raytracer 005 keeps the lighting, shadows and reflection from 004 and doubles the spheres to six. Instead of a list of positions and colours, they're made in a loop from the sphere's number:

    for( int i=0; i<6; i++ )
    {
      float ii = float(i);
      float x = 1.3*sin(ii*2.13 + uTime*(0.6762+ii*0.163));
      float y = 1.3*sin(ii*1.13 + uTime*(0.8467+ii*0.193));
      float z = 3.0 + 0.3*sin(ii*4.13 + uTime*(1.1112+ii*0.183));
      float w = 0.5 + 0.2*sin(ii*4.13 + uTime*0.01*(1.1112+ii*0.183));

      sphere_pos[i] = vec4( x, y, z, w );
      sphere_col[i] = vec3( 1.0-ii/12.0, 1.0-ii/6.0, 0.1 + ii/12.0 );
    }
Raytracer 005: six spheres from one loop, with the radius changing too

The motion is the formula from 003 with the sphere's number in place of its starting position, about a centre at (0, 0, 3). The radius moves as well, between 0.3 and 0.7, but with the time scaled by 0.01 it takes five to nine minutes to go round, so at the start they're 0.5, 0.33, 0.68, 0.46, 0.35 and 0.69, and they barely change while you watch. The colours run from yellow (1, 1, 0.1) for sphere 0 through to a dull purple (0.58, 0.17, 0.52) for sphere 5. The light has come down to (-0.5, 2, 2).

Otherwise it's 004 with 6 in place of 3 in every loop. There's a commented-out experiment on the line that picks up the colour in intersect(), // + vec3( 0.1*sin(max_t*10), ... ), which would have added stripes by distance. It comes back in a different form in Pearls.

WebGL. Open full window

Sixteen spheres

Raytracer 006 goes up to sixteen, and they move differently again. Every sphere now uses the same speeds, and only the phase depends on the sphere's number:

      float x = 1.3*sin(ii*0.313 + uTime*(0.716762));
      float y = 1.3*sin(ii*0.453 + uTime*(0.38467));
      float z = 3.0 + 1.3*sin(ii*0.73 + uTime*(0.9112));
      float w = 0.3 + 0.1*sin(ii*0.43 + uTime*(0.1112));
Raytracer 006: the same speeds for every sphere, with the phase stepping along

So at any moment the sixteen centres lie along one smooth curve, each a small step on from the one before, and the curve twists about as time goes on, like a string of beads being swung around. x goes round every 8.8 seconds, y every 16.3 and z every 6.9. The spheres are smaller, with radii from 0.2 to 0.4, and the size ripples along the string once every 56.5 seconds.

The colour formula is the one from 005, carried on past six. From sphere 7 the green goes below zero, from sphere 13 the red does too, and from sphere 11 the blue is over 1. The output is clamped to 0 to 1, so the far end of the string comes out deep blue and violet.

The reflection changed as well. The reflected surface gets diffuse light but no highlight, and it's added on at full strength, because the scaling at the end of the line is commented out:

        final_col = final_col * 0.85 + col;// * 0.15;
Raytracer 006: the reflection added at full strength

That can take the total over 1, where the output is clamped, so the reflections show up strongly. The light is at (-0.5, 2, 0), level with the eye and 2 above it, so the spheres are lit from above and in front.

And the canvas is 320x320, a quarter of the pixels. Here it's drawn at half the resolution of the other demos and scaled up to the same size.

WebGL. Open full window

The cost per pixel

Here's what the smaller canvas saves. There's no scene structure here, no bounding boxes and no way of skipping spheres a ray can't reach: every ray is tested against every sphere. So the work for each pixel goes up with the number of spheres times the number of rays.

StepTests per pixelTests per frame
001, 0023 × 1 = 31,228,800
0033 × 3 = 93,686,400
004, 007, 0083 × 4 = 124,915,200
0056 × 4 = 249,830,400
006 at 640x64016 × 4 = 6426,214,400
006 at 320x32016 × 4 = 646,553,600
The most sphere tests a frame can take: spheres times rays, times 409,600 pixels at 640x640 or 102,400 at 320x320

These are the worst cases. A ray that misses everything stops after its first round of tests, and a shadow ray stops at the first sphere in its way, so a frame with lots of background costs less. Each pixel also does its own animation first: 16 spheres with four sines each is 64 calls to sin() before a single ray is traced, in every one of the pixels.

At 640x640, sixteen spheres would have been more than five times the work of 004. At 320x320, it's about a third more.

Pearls

Raytracer 007 goes back to the three spheres, motion and lighting of 004, and adds one line after the first hit has been lit:

      final_col = col + 0.1 * vec3( sin( dot( pos, pos ) ) );
Raytracer 007: rings from the squared distance to the eye

The eye is at the origin, so dot( pos, pos ) is the square of the distance from the eye to the hit point. Its sine adds a grey of up to 0.1, or takes it away, in bands. Points the same distance from the eye lie on a sphere around it, and that cuts each ball in a circle, so the bands come out as rings around the point on each ball nearest the eye.

For a hit 3.5 away the squared distance is 12.25, and sin(12.25) is -0.354, so that point is 0.035 darker. The sine goes round once each time the squared distance goes up by 2π, which near 3.5 is every 0.9 in distance, so a ball with a radius of 0.8 shows about one ring on its near side. As the balls move nearer and further the rings slide over them, a bit like the sheen on a pearl.

Only the first surface gets the rings; the reflection is mixed in at a quarter as before. The full window version is titled "Recursive reflection", the same as 003.

WebGL. Open full window

Bumpy textures

Raytracer 008 is the one at the top of the page. It makes the spheres look bumpy without changing their shape: the ray still hits a perfect sphere, but before the hit is lit, its normal is pushed about a little by a pattern. The lighting, the highlight and the reflection all follow the normal, so the surface looks rippled. This is bump mapping.

      //  Perturb normal and direction vector

      float f = sin(normal.x*5.918+13.841)*0.43 + sin(normal.y*11.8218+7.41)*0.21 + sin(normal.z*16.821+11.841)*0.34;
      float g = sin(normal.y*2.167+14.841)*0.13 + sin(normal.z*18.1298+5.98)*0.17 + sin(normal.x*15.231+12.321)*0.70;
      float h = f*g;//sin(normal.z*3.324+11.312)*0.22 + sin(normal.x*13.4241+5.12)*0.34 + sin(normal.y*19.212+17.424)*0.51;

      normal += 0.1 * vec3( f, g, h );

      reflection = dir - 2.0*normal*dot( dir, normal );
Raytracer 008: bending the normal with a pattern made of sines

The pattern is a function of the normal itself. The normal is the direction from the sphere's centre, so it says where on the ball the point is, and the bumps stay put on each ball as it moves about, like a texture painted on it. f and g are each three sine waves of different frequencies added up, between about -1 and 1, and h is their product. There's a different sum for h after the //, commented out.

Scaled by 0.1, that tilts the normal by up to about 10 degrees. It isn't normalised again afterwards, so its length wobbles a little too, which brightens and darkens the diffuse light by the same small amount. Then the reflection is worked out again from the new normal, so the highlight and the reflected ray are bumpy as well.

The shadow ray is aimed differently too:

      if( intersect_shadow( pos+light_dir*0.0000001, light_dir+normal ) )
The shadow ray aimed along light_dir plus the bumped normal

light_dir is the full way to the light, a few units long, and adding the normal, of length about 1, tilts the shadow ray away from the light and out from the surface. So the shadow test isn't quite towards the light any more, and that tilt changes with the bumps.

The spheres are bigger, with radii of 0.8, 0.9 and 1.0, and the rings from 007 are still there. Only the first surface is bumped. The surface seen in the reflection is lit with its true normal.

All of that is still the same structure as 004: the first ray, a shadow ray, one reflection and its shadow ray, up to twelve sphere tests a pixel.

Running in the page

Each step's demo.html page uses the framework as it is, with a canvas, a status line for the frame rate and the shader in a script element. The links under the demos open those. The framework keeps its context, shader program and clock in global variables, though, so a page can only have one, and this page has nine.

So the demos here are run by raytracer.js, a small file in this article's folder that does what the framework does once for each canvas. Each canvas names its shader, which sits in a script element next to it, copied from its demo.html:

<canvas width="560" height="320" data-shader="shader-001"></canvas>
<script id="shader-001" type="x-shader/x-fragment"> ... </script>
How a demo is put in the page (attributes trimmed)

It uses the framework's vertex shader and quad, and the same GL state, and draws each frame the same way, with its own clock:

  Demo.prototype.draw = function ( timeNow )
  {
    var gl = this.gl;

    if( this.lastTime != 0 )
    {
      this.normalisedTime += timeNow - this.lastTime;
    }

    this.lastTime = timeNow;

    gl.uniform1f( this.uTime, this.normalisedTime / 1000.0 );

    gl.clear( gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT );

    gl.bindBuffer( gl.ARRAY_BUFFER, this.buffer );
    gl.vertexAttribPointer( this.aVertexPosition, 3, gl.FLOAT, false, 0, 0 );

    gl.drawArrays( gl.TRIANGLE_STRIP, 0, 4 );
  };
raytracer.js: one frame of one demo, as in webgl_timer()

Nine raytracers running at once would be a lot of work for nothing, as you can only see one or two at a time, and browsers limit how many WebGL contexts a page can have. So a demo only gets its context the first time it comes on screen, and only draws while it's on screen and the tab is showing. An IntersectionObserver says when each canvas comes and goes.

The differences from the full window versions:

  • A demo stops while it's off screen or the tab is hidden, and its clock stops with it, so it carries on from where it was. The framework's timer keeps counting.
  • Frames are drawn with requestAnimationFrame, once per screen refresh, rather than every 15 milliseconds. The motion follows the clock, so it goes at the same speed either way.
  • There's no frame rate shown. The full window versions still show it.
  • The originals are square, but here the demos are wider than they are tall: 960x480 at the top and 560x320 for the steps. The shaders still draw a square, as wide as the canvas, and the canvas shows its middle band, so nothing is stretched; the top and bottom of the original picture are just cut off.

Source 35 files

Each demo folder has its own copy of the framework. The demos all load webgl_fragment_framework.001.js. webgl_fragment_framework.lewpen.001.js is the same file, and webgl_fragment_framework.js is the framework with console logging added and mediump precision in its vertex shader, which none of them load. This page runs the demos with raytracer.js.

Fragment Shader Framework 3 files

Raytracer 001 - 3 spheres with normals 4 files

Raytracer 002 - Diffuse and phong lighting 4 files

Raytracer 003 - 3 levels of reflection 4 files

Raytracer 004 - Shadows 4 files

Raytracer 005 - More spheres 4 files

Raytracer 006 - Even more spheres 4 files

Raytracer 007 - Pearls 4 files

Raytracer 008 - Bumpy Textures 4 files