npm.io
1.17.0 • Published 1 month ago

webgl-path-tracing

Licence
MIT
Version
1.17.0
Deps
0
Size
158 kB
Vulns
0
Weekly
0
Stars
1

WebGL Path Tracing

Screenshot

Try it live

Path tracing is a realistic lighting algorithm that simulates light bouncing around a scene. This path tracer uses WebGL for realtime performance and supports diffuse, mirrored, and glossy surfaces. The path tracer is continually rendering, so the scene will start off grainy and become smoother over time.

The entire scene is dynamically compiled into a GLSL shader. Everything can be repositioned using the current shader, but any geometry or material change means a recompilation. To calculate a pixel color, a ray is shot into the scene and allowed to bounce around five times. At each bounce, the direct light incoming at that point (including shadows) is multiplied by all previous material colors and accumulated. Soft shadows come from jittering the light position per pixel at every bounce.

Rather than drawing those jitters independently at random, each pixel walks a scrambled Halton sequence, which spreads its samples out evenly instead of letting them clump and leave gaps. That takes roughly two to three times fewer samples to reach the same amount of grain.

The renderer also accumulates as many samples per animation frame as fit in a frame's worth of time, instead of exactly one. A scene that costs a fraction of a frame would otherwise spend the rest of every frame waiting for the next vsync, capped at the refresh rate no matter how fast the hardware is. The count is measured and adjusted continuously, and settles back at one sample per frame on hardware that a single sample already saturates. Pass samplesPerFrame in the config to pin it instead.

Samples accumulate into a floating point buffer where the browser can render to one, so the image keeps converging instead of settling at the precision of an 8 bit buffer. The canvas can be any size, square or not.

Rendering in a worker

The tracing runs in a web worker, against an OffscreenCanvas the page hands over when the tracer starts. The page keeps the canvas element and its event listeners and nothing else.

This is not just tidiness. Sizing the per-frame sample budget means knowing how long a frame of tracing really took, which means waiting for the GPU to catch up with the commands rather than for the commands to be queued, and that wait blocks whichever thread does it. On the main thread it blocks the page: with the budget aiming to fill most of a frame with tracing, scrolling, layout and everything else on the page have to wait for the tracer between every frame, and a scene heavy enough to run at a few frames a second takes the page down with it. In a worker it blocks nobody. Compiling the scene into a shader, which happens on every geometry or material change, moves off the main thread with it.

Browsers without OffscreenCanvas, or without WebGL inside a worker, fall back to tracing on the main thread as before — the worker is asked whether it can render before the canvas is handed over, since handing it over cannot be undone. Pass worker: false to always trace on the main thread:

makePathTracer(canvas, objects, {worker: false});

makePathTracer() returns a controller rather than the renderer itself, since the renderer is usually on the other side of a message port: setObjects, addSphere, addCube, addExtrudedRectangle, selectLight, deleteSelection, setLightPosition, setLightVal, updateMaterial, updateEnvironment, updateProjection, updateOrthoHeight, updateGlossiness, renderer.pause(), renderer.resume() and dispose(). Calls made before the worker has started are queued and replayed, so there is nothing to wait for.

Camera

The camera orbits the centre of the room and can project either way:

makePathTracer(canvas, objects, {projection: 'orthographic'});

A perspective camera (the default) shoots all of its rays through one point, so objects shrink with distance and the walls of the room converge. Pass fov to change how wide it is.

An orthographic camera shoots parallel rays instead, so parallel edges stay parallel and an object is the same size wherever it sits in the room, which is the projection technical and isometric looking renders use. Its rays do not spread out with distance, so anything the view frames beyond the walls of the room is rays that pass the room by and hit nothing: by default the view is sized to the room. Pass orthoHeight to set the height of the view in world units yourself, which is what zooming means without a vanishing point (zoom only moves an orthographic camera closer, it does not change what it frames).

Both are also switchable at runtime, through the object makePathTracer() returns:

const ui = makePathTracer(canvas, objects);
ui.updateProjection('orthographic');
ui.updateOrthoHeight(1.5); // null goes back to the default framing

Shapes

Three shapes are available:

  • new Sphere(center, radius, id, color)
  • new Cube(minCorner, maxCorner, id, color)
  • new ExtrudedRectangle(minCorner, maxCorner, borderRadius, id, color, axis)

An extruded rectangle is a box whose cross section is a rectangle with rounded corners, like a CSS border-radius extruded into a solid: the four edges running along axis ('x', 'y' or 'z', defaults to 'z') are rounded off by borderRadius and the two ends stay flat. The radius is clamped to half of the smaller side of the cross section, and a radius of 0 gives a plain box.

color is optional and defaults to a neutral grey. id has to be unique within a scene, since it is used to name the shape's uniforms in the generated shader.

Use the module

Install the module with npm install webgl-path-tracing

Use your favorite bundler or add to your page with:

<script type="importmap">
	{
		"imports": {
			"webgl-path-tracing": "./node_modules/webgl-path-tracing/webgl-path-tracing.js",
			"sylvester": "./node_modules/webgl-path-tracing/sylvester.src.js"
		}
	}
</script>

<script type="module">
	import {makePathTracer, Cube, Sphere, ExtrudedRectangle} from 'webgl-path-tracing';
	import {Vector} from 'sylvester';

	let objects = [];
	let nextObjectId = 0;
	objects.push(new Cube(Vector.create([-0.25, -1, -0.25]), Vector.create([0.25, -0.75, 0.25]), nextObjectId++));
  objects.push(new Sphere(Vector.create([0, -0.75, 0]), 0.25, nextObjectId++)); 
  objects.push(new ExtrudedRectangle(Vector.create([-0.5, -0.5, -0.1]), Vector.create([0.5, 0, 0.1]), 0.15, nextObjectId++));

	window.onload = function() {
		makePathTracer(document.getElementById('canvas'), objects);
	}
</script>
<canvas id="canvas" width="512" height="512"></canvas>

Keywords