npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tinypoly/gpu-renderer

v0.4.0

Published

WebGPU path tracer for Three.js scenes: BVH traversal, progressive accumulation and tone mapping in WGSL compute shaders.

Readme

@tinypoly/gpu-renderer

npm version npm downloads CI types license

A WebGPU path tracer for Three.js scenes. It takes an ordinary THREE.Scene and traces it in WGSL compute shaders, accumulating paths until the image converges, and refines a canvas in place while it does.

It is the GPU sibling of @tinypoly/cpu-renderer, which powers the render mode of the Tinypoly editor: the same API, the same scene snapshot and the same settings, so a host picks one at startup and writes the rest once.

Live example

  • Unidirectional path tracing with multiple importance sampling: every light is aimed at with a shadow ray and weighed against the bounce that might find it, so a small bright lamp or a sun in an HDR converges in tens of samples.
  • Physically based shading as Three r185 lights it: Standard and Physical materials (transmission, dispersion, clearcoat, sheen, iridescence, anisotropy), Lambert, Phong, Toon, Basic, Matcap, Normal, Depth and ShadowMaterial, with all of their maps.
  • Real glass: thin walls that light crosses, and closed volumes that refract and absorb.
  • Perspective and orthographic cameras, any projection Three builds; meshes, lines (Line, LineSegments, LineLoop), dashed lines, fat lines (Line2), sprites and PointsMaterial.
  • Your custom ShaderMaterial GLSL, RawShaderMaterial and onBeforeCompile hooks: fragment shaders are translated to WGSL by naga and run in the kernel, with real derivatives; vertex shaders run on the CPU renderer's interpreter.
  • HDR, gradient or procedural sky environments, light probes, depth of field with polygonal bokeh, fog, volumetric lights, vignette and grain, and ACES, AgX or Neutral tone mapping.
  • What a photograph adds, on by default: subsurface scattering, volumes of fog and smoke, aerial perspective, a lens's bloom, aperture rays, chromatic aberration and distortion, a camera's white balance and exposure triangle.
  • Adaptive sampling: pixels whose noise is gone stop taking paths, and the work they leave goes to the dark, glossy and out of focus areas that still need it.
  • Intel's Open Image Denoise on the device, by default, and a variance-guided denoiser (the spatial filter of SVGF, with diffuse and specular light filtered apart as NVIDIA's RELAX does): each signal is separated from its albedo, filtered by an edge-avoiding wavelet that stops at normals, depths and roughness and trusts each pixel as far as its own variance says, and multiplied back, so textures stay sharp, a sharp reflection stays sharp and flat areas keep their exact mean.
  • Large scenes held lightly: a mesh repeated across the scene is kept once and traced as instances, shading attributes are shared per vertex, and a texture read through one channel only (roughness, bump) takes one byte a texel. Packing hands the page its thread back as it goes, and a lost device is acquired again on the next frame.
  • Progressive output: the canvas refines sample by sample, and the render can be paused, resumed and cancelled.
  • No worker pool and no SharedArrayBuffer: the scene lives on the device, so the page needs no cross-origin isolation. A single worker builds the BVH of a large scene, so the page keeps responding while it is prepared.

Install

npm install @tinypoly/gpu-renderer three

three is a peer dependency (0.185 or newer). @tinypoly/cpu-renderer comes along: the scene snapshot, the settings and the tone mapping are shared with it rather than written twice. The browser needs WebGPU.

Usage

import * as THREE from "three";
import { GpuRenderer, attachCanvas, requestWebGpuSupport } from "@tinypoly/gpu-renderer";

const { supported, reason } = await requestWebGpuSupport();
if (!supported) throw new Error(reason); // the host decides what to fall back to: the CPU renderer, for one

const scene = new THREE.Scene();
const sphere = new THREE.Mesh(new THREE.SphereGeometry(1, 48, 24), new THREE.MeshPhysicalMaterial({ color: "tomato" }));
scene.add(sphere);

const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
camera.position.set(0, 1, 5);
camera.lookAt(0, 0, 0);

const canvas = document.querySelector("canvas")!;

const renderer = new GpuRenderer({
  width: canvas.clientWidth,
  height: canvas.clientHeight,
  pixelRatio: window.devicePixelRatio,
  scene,
  camera,
  settings: { samples: 256 }, // paths per pixel
  environment: { kind: "gradient", topColor: "#9cb8d8", bottomColor: "#2b2a28" },
});

attachCanvas(renderer, canvas); // sizes the canvas and refines it in place while the image converges
renderer.on("sample", ({ samples, target }) => console.log(`${samples} / ${target}`));
const { width, height, data } = await renderer.render(); // RGBA bytes of the finished image

width and height are pixels; the render size multiplies them by pixelRatio and settings.renderScale. Every mesh takes part in shadows: a traced shadow comes from the geometry that casts it, so castShadow and receiveShadow on a mesh are not read.

Without a canvas, take the image when it is done:

renderer.on("start", ({ width, height }) => { /* the render size */ });
renderer.on("pixels", ({ x, y, width, height, data }) => { /* the whole image, once, when the last sample lands */ });

Setters record the inputs; render() starts a frame with the current ones:

camera.position.x += 1;                  // the camera is read at every render()
renderer.setSettings({ samples: 1024, globalIllumination: { bounces: 8 } });
const image = await renderer.render();

API

new GpuRenderer(options)

Every option has a setter of the same name, except bvhWorker and createBvhWorker, which are fixed for the renderer's life.

| Option | Type | | | --- | --- | --- | | scene | THREE.Scene or the result of serializeScene | A Three scene is serialized in the background. Defaults to an empty scene. | | camera | THREE.Camera or CameraState | A Three camera is read at every render(). Any projection works: perspective, orthographic, zoom and view offsets. Defaults to a perspective camera at (5, 5, 5) looking at the origin. | | settings | RenderSettingsInput | Merged over DEFAULT_RENDER_SETTINGS, group by group. See Settings. | | environment | RenderEnvironment | See Environment. Defaults to DEFAULT_RENDER_ENVIRONMENT, a gradient. | | width, height | number | Required. Image size in CSS pixels. | | pixelRatio | number | Defaults to 1. Pass window.devicePixelRatio for a sharp image on a high-density screen. | | denoiser | Partial<DenoiserSettings> | How the denoiser filters. Merged over DEFAULT_DENOISER. See Denoiser. | | bvhWorker | boolean | Builds the BVH of a scene with at least BVH_WORKER_MIN_TRIANGLES (50 000) triangles in a Web Worker, so the page keeps responding. The tree, and so the image, is the same either way. Defaults to true where Web Workers exist or when createBvhWorker is given; false builds on the main thread. See BVH worker. | | createBvhWorker | () => BvhWorker | Starts the BVH worker. Defaults to createDefaultBvhWorker. Passing one turns the worker on. |

Methods

| Method | | | --- | --- | | render() | Starts a frame with the current inputs and discards the one in progress. Resolves with the finished image, { width, height, data }: data is RGBA bytes, straight sRGB, width * height * 4 long. Rejects with an AbortError (see isAbortError) when a later render(), cancel() or dispose() replaces the frame, and with the error when the frame fails, a lost device included. | | setScene(scene) | A Three scene is serialized in the background; render() waits for it. Handing over the same snapshot again keeps what was prepared for it (packed triangles, BVH, textures and the baked sky), so the next frame of an unchanged scene starts tracing at once. | | setCamera(camera) | A Three camera is read as it is at every render(), so moving it or changing its aspect only needs a new frame. A CameraState from serializeCamera stays fixed. | | setSettings(settings) | Merges the given fields into the current settings, group by group: { fog: { enabled: true } } keeps the other fog fields. | | setEnvironment(environment) | Replaces the environment. A change of intensity or rotation alone does not load the image again. | | setSize(width, height, pixelRatio?) | Image size in CSS pixels for the next frame; the pixel ratio stays unless given. | | setPixelRatio(pixelRatio) | | | setDenoiser(denoiser) | Merges the given fields into the denoiser's settings. A finished image is filtered again at once, without tracing anything: image and the canvas take the result and pixels fires. | | pause(), resume() | Stops or continues the frame between two dispatches. | | cancel() | Stops the frame in flight and keeps the device for the next one. render() rejects with an AbortError and image keeps what it held. A paused frame is cancelled too, and the next one starts unpaused. | | dispose() | Releases the device. | | settings | The settings the next frame renders with. | | denoiser | The denoiser's settings. | | rendering, paused | Whether a frame is in flight and whether it is paused. | | image | The last finished image, in the shape render() resolves with, or an empty one of the render size while a frame traces. null before the first frame starts. Each frame allocates a new buffer, so the image one render() resolved with stays as it is. | | renderWidth, renderHeight | The size the next frame renders at: CSS pixels times pixelRatio and settings.renderScale. |

Events

renderer.on(event, listener) returns the function that removes the listener; once and off exist too. The payloads are the CPU renderer's; what differs is when they fire, because no pixel is finished before the last sample.

| Event | Payload | | | --- | --- | --- | | start | { width, height } | The scene is on the device and tracing starts. image has the render size and is empty. | | sample | { samples, target } | One more path per pixel over the whole image. Only this renderer has it. | | pixels | { x, y, width, height, data } | The finished image, once, as a single region at (0, 0). image already holds it. | | progress | { completed, total, phase } | Rows traced out of total, over every sample. phase is "prepare" while the scene uploads and "shade" while it traces. | | bucket | { worker, bucket, width, height } | The band of rows on the device, always as worker 0, or null when it is idle. The renderer sizes its dispatches to keep the page responsive. | | complete | | The image is finished. render() resolves with it right after. | | error | Error | The frame failed. render() rejects with the same error. |

attachCanvas(renderer, canvas)

Draws the output on an HTMLCanvasElement through a WebGPU context: the canvas takes the render size and is refined in place after every dispatch. A pixel no sample has reached yet stays see-through, so a page can show a preview under the canvas until the image covers it. The canvas must not already hold another context. Returns the function that detaches it.

A WebGPU canvas does not keep what it presented: to save the image, use the pixels render() resolves with, or image.

WebGPU support

| Function | | | --- | --- | | isWebGpuAvailable() | The cheap synchronous check: whether navigator.gpu exists. It does not prove an adapter does. | | requestWebGpuSupport() | Asks for an adapter, which is the only honest answer, because a browser can expose navigator.gpu and still hand back nothing. Resolves with { supported, reason }. |

The renderer never falls back on its own. Choosing another renderer when WebGPU is missing is the host's decision, and the CPU renderer takes the same calls.

Settings

GpuRenderSettings: the CPU renderer's RenderSettings, grouped by feature, with the groups only this renderer reads beside them in the same object, which the CPU renderer leaves alone. Defaults are DEFAULT_GPU_RENDER_SETTINGS, the CPU renderer's own with what a photograph has turned on; mergeGpuSettings(base, input) applies a partial GpuRenderSettingsInput the way setSettings does. A path tracer reads some of the shared ones differently:

| Field | Default | | | --- | --- | --- | | renderScale | 1 | Multiplier on the image size. | | samples | 4 | Paths per pixel, on average: one number does the antialiasing and the global illumination in a path tracer, and a few hundred is a clean image. Above 32, a pixel that has converged stops early and the noisiest ones take up to four times as many. | | toneMapping | "agx" | "aces", "agx", "neutral" or "linear". AgX rolls highlights off as film does; the CPU renderer defaults to ACES. | | exposure | 1 | | | background.mode | "environment" | "environment", "color" or "transparent", which makes the image's alpha the coverage of every pixel. | | background.color | "#1a1a1a" | Used by mode: "color". | | shadows.enabled | true | Whether Three's lights are occlusion tested. Emissive geometry always is. | | shadows.softness | 1 | Angular diameter of the lights in degrees. 0 gives hard shadows. | | globalIllumination.bounces | 12 | How far a path goes: enough for light to fill a room. The roulette below keeps it affordable. | | globalIllumination.clamp | 10 | Maximum luminance one indirect contribution may add, which trades a little bias for fewer fireflies, as Cycles' indirect light clamp does. 0 disables clamping. | | globalIllumination.russianRoulette | true | Ends a path that carries little light by chance, weighting the survivors up so the mean stays exact. | | globalIllumination.rouletteStart | 1 | Bounces every path takes before the roulette may end it. Past them a path carries on with the chance its own throughput gives, as in PBRT. | | globalIllumination.portals | true | Inside a room (something above the point), aims at the sky through the windows (every thin transmissive material, the ones near the point most) as well as by its brightness, as Cycles' portals do: daylight entering through glass is found instead of stumbled upon. | | globalIllumination.lightTree | true | Picks the light to aim at by what it delivers to the shaded point (power, distance, facing) through a tree over the scene's lights, rather than by power alone: a small lamp near a surface gets the samples it needs. | | depthOfField.enabled | false | | | depthOfField.focusDistance | 10 | World units. | | depthOfField.aperture | 2.8 | f-number. | | depthOfField.blades | 0 | Polygon sides of the bokeh. 0 is circular. | | fog.enabled | false | Linear fog over every surface. Without it, scene.fog applies. | | fog.color | "#b8c4d0" | | | fog.near, fog.far | 10, 60 | View depth. | | vignette | 0 | 0 to 1. | | grain | 0 | 0 to 1. |

What a photograph adds

Groups only this renderer reads, all on by default except the physical exposure, which needs a scene lit in physical units. Changing the lens, the camera's white balance or exposure, the bloom or the glare shows the finished image again without tracing it.

| Field | Default | | | --- | --- | --- | | bloom.enabled | true | The halo a lens and sensor scatter around bright light. Energy conserving and without a threshold: it only shows where something is far brighter than its surroundings. | | bloom.intensity | 0.04 | Share of the light the halo takes, 0 to 1. | | bloom.radius | 0.65 | How far it reaches, 0 to 1. | | glare.enabled | true | The rays a bright light throws through the aperture's blades. Energy conserving like the bloom. | | glare.intensity | 0.06 | Share of the light the rays take, 0 to 1. | | glare.blades | 6 | Blades of the aperture: an even count makes as many rays, an odd count twice as many. | | glare.length, glare.rotation | 0.25, 15 | How far the rays reach, as a share of the half diagonal, and the aperture's turn in degrees. | | camera.contrast | 1 | AgX's look, 0 its base curve (flat on purpose) to 1 a look like Blender's Punchy, pivoted on middle grey so brightness stands; other tone mappings ignore it. | | lens.chromaticAberration | 0.05 | Lateral colour fringes towards the corners, 0 to 1. | | lens.distortion | 0 | Radial distortion of the camera rays: positive is barrel, negative pincushion. | | camera.whiteBalance | 6500 | Kelvin the camera takes as white; 6500 is neutral, lower cools the image. | | camera.autoExposure | false | Meters the image as a camera in automatic does, the mean of the logarithms of its luminance weighed towards the centre, and exposes it to middle grey: a window or the sky cannot darken the room. Takes the place of exposure. | | camera.darkAdaptation | 0 | How much of a dark scene's darkness the automatic exposure keeps, 0 to 1. Below an average about three stops under middle grey, it keeps that share of the missing stops, so a night lit by a few lamps stays a night. 0 exposes every scene alike. | | camera.exposureCompensation | 0 | Stops over the metered exposure. | | camera.physicalExposure | false | Exposes from iso, shutterSpeed and depthOfField.aperture, with exposure as compensation. | | camera.iso, camera.shutterSpeed | 100, 1/125 | Seconds for the shutter. | | atmosphere.enabled | true | Aerial perspective: the air fades distant surfaces towards the light it scatters, from the surroundings and the brightest directional light. Scenes in metres. | | atmosphere.visibility | 20000 | Scene units at which a dark object keeps 2 percent contrast (Koschmieder): 20 km is a clear day. | | atmosphere.haze | 0.6 | 0 clean air, blue (Rayleigh); 1 haze, grey and bright towards the sun (Mie). | | atmosphere.color | "" | The air's glow as #rrggbb; empty takes the surroundings' average. | | subsurface.enabled | true | Materials with a subsurface medium scatter light inside. | | subsurface.depth | 2 | Bounces at which light still walks inside such a material; deeper it reflects as a diffuse surface of the same colour, which saves most of the walk's cost for what hardly shows. | | volumes.enabled | true | Meshes with a volume are filled with their medium; off leaves them out. | | bevel.enabled | true | Materials with a bevel radius round their edges at shading time. |

renderer.setSettings({ glare: { blades: 7 }, camera: { whiteBalance: 3200 }, atmosphere: { visibility: 3000 } });

globalIllumination.denoise (default true) turns the denoiser on: on the canvas a few times a second while the frame traces, and on the image render() resolves with. Switching it alone filters the finished image again without tracing.

Denoiser

Two denoisers, chosen by algorithm. "oidn", the default, is Intel's Open Image Denoise, the neural network Blender and most production renderers use, run on the device through oidn-webgpu: the image, its albedo and its normal go through a U-Net trained on path traced images, which keeps texture and fine detail at a handful of samples per pixel. Its weights (RenderKit's rt_hdr_alb_nrm.tza, Apache-2.0, 1.8 MB) ship in weights/; an app whose bundler does not carry them along serves its own copy and passes its URL as oidnWeights. Where the network cannot load, the renderer warns once and uses SVGF. Only strength applies to it.

"svgf" is a variance-guided wavelet filter, the spatial half of SVGF, with the diffuse and the specular light filtered apart, as NVIDIA's RELAX and REBLUR do. Every path is split at its first scatter by the lobe it took; each signal is divided by its own albedo (the diffuse colour, the GGX lobe's directional albedo), filtered by passes that stop at normals and depths, and multiplied back, so textures stay sharp. The specular filter also stops at roughness, and reaches only as far as the footprint the lobe leaves on the screen, from the roughness and the distance to what it reflects: a mirror is left alone, a rough reflection of something far is smoothed widely. Each pixel is trusted as far as its own variance says. DenoiserSettings, defaults in DEFAULT_DENOISER:

| Field | Default | | | --- | --- | --- | | algorithm | "oidn" | "oidn" (neural, keeps detail) or "svgf" (the wavelet filter, faster, with the fields below). | | oidnWeights | "" | Where OIDN's weights are fetched from; empty takes DEFAULT_OIDN_WEIGHTS, the copy in the package. | | strength | 1 | How much of the filtered image replaces the traced one, from 0 to 1. | | passes | 3 | Wavelet passes, 1 to 5: how far the filter reaches, 2, 6, 14, 30 or 62 pixels. | | edge | 2 | How large a luminance difference still counts as noise, in standard deviations of the pixel's own noise. Lower keeps more detail and removes less noise. | | onlyNoisy | true | Leaves the pixels that have converged as they were traced, and filters only where noise is still visible. | | antiFirefly | true | Before filtering, clamps a pixel brighter than every one of its eight neighbours to the brightest of them, in each signal. | | subsurface | 1 | How far the diffuse filter lets go of its normal and depth tests over a material that scatters light inside, 0 to 1: such light leaves the surface after wandering under it. | | mirrorRadius | 3 | The smallest footprint a smooth reflection (roughness under 0.3) is filtered over, in pixels, by taps that see the reflected thing at the same distance, which keeps its edges. 0 leaves a mirror alone. |

A denoiser trades noise for detail. What it smooths most is what its guides cannot see: the relief of a lacquer, an out of focus area. With onlyNoisy it leaves alone whatever has already converged, so more samples always mean less filtering.

renderer.setDenoiser({ strength: 0.6, edge: 1 }); // gentler: filters the finished image again, no tracing

Global illumination is always on, because tracing paths is what this renderer does: globalIllumination.enabled, samples, intensity, cache and cacheSpacing, ambientOcclusion and tileSize are the CPU renderer's and are not read.

Environment

RenderEnvironment. Lights the scene and paints the background when background.mode is "environment". An HDR image gets a luminance distribution, so a shadow ray can aim at its sun.

{ kind: "hdri", url: "studio.hdr" }                                            // equirectangular .hdr
{ kind: "gradient", topColor: "#9cb8d8", bottomColor: "#2b2a28", exponent: 2 } // exponent defaults to 2
{ kind: "hdri", url: "studio.hdr", intensity: 0.8, rotation: 90 }              // intensity 1 and rotation 0 by default

Object options

The renderer reads the CPU renderer's optional settings from userData.cpuRenderer on Three objects; the key keeps that name, so a scene renders the same in both. A plain scene needs none of them.

| Where | Field | | | --- | --- | --- | | Any object | exclude | Leaves the object and its children out of the render. | | Light | volumetric | Scatters the light in the air: true, or a medium { density, anisotropy, spread }. | | Scene | sky | A ProceduralSky: a shader that replaces the environment. | | Material | time | Value of the shader's time uniform. | | Material | planarCapture | Renders planar reflection and refraction textures for a flat surface, path traced from the mirrored camera. The surface's local +Y is its normal. | | Material | compute | A MaterialCompute: fragment passes baked on the CPU into the material's uniforms. | | Material | uniforms | The live uniforms of a material patched in onBeforeCompile. | | Material | prepare | Called with the scene before it is serialized. |

light.userData.cpuRenderer = { volumetric: { density: 0.05 } };

More on materials, which only this renderer reads:

  • subsurface (standard and physical materials): light enters the material and leaves it somewhere else, by a random walk inside it, as skin, wax, marble and milk do. true takes Blender's defaults. The material's colour, maps included, is what the medium reflects in all; radius is how far red, green and blue travel inside, as multiples of scale in scene units; weight is the share of the diffuse layer that scatters inside; anisotropy the phase function's.
  • bevel (lit materials): a radius in scene units, over which the shading normal turns from one face to the next, as Cycles' bevel node rounds edges without changing the geometry. Sharp CG corners catch light like real, slightly rounded ones. The normal map still applies.
  • water (physical materials with transmission and thickness, whose mesh bounds the water): the surface that faces up or down ripples with waves of its own, read only as a normal, so the geometry stays flat; and what lies under the water gets the sun's caustics through those waves, by manifold next event estimation: a few Newton steps find where the refraction sends the light of a directional light to each point, and the spread of the refracted beam there sets how much arrives. true takes the defaults; waveHeight and waveLength are those of the longest wave, in scene units (0.006 and 0.9, a pool in a breeze), wind the direction the waves travel to, in radians, and seed changes their arrangement. Give the water its colour with attenuationColor and attenuationDistance, and let the sun cast shadows: the caustics replace the straight shadow ray the water blocks.
  • volume (any material): the mesh only bounds a medium, which scatters and absorbs light in the space it encloses: fog, smoke, a beam of sunlight through dust, murky water. Its surface is invisible. density is per scene unit, color what each scattering keeps (white for fog, dark for soot), anisotropy how forward it scatters (about 0.7 for haze). The mesh has to be closed, with its normals out; the camera may stand inside one, and objects inside are lit through it.
skin.userData.cpuRenderer = { subsurface: { radius: [1, 0.4, 0.25], scale: 0.01 } };
table.userData.cpuRenderer = { bevel: 0.003 };
haze.material.userData.cpuRenderer = { volume: { density: 0.05, color: "#ffffff", anisotropy: 0.6 } };
pool.material.userData.cpuRenderer = { water: { waveHeight: 0.006, waveLength: 0.9 } };

Helpers

| Function | | | --- | --- | | serializeScene(scene, options?) | Async. The snapshot setScene builds from a Three scene, to reuse across renderers, the CPU one included. A snapshot only for this renderer can pass { mipmaps: false }: it samples the base level of each texture, so the mip chains would be built for nothing. | | serializeCamera(camera) | A fixed CameraState snapshot of a Three camera, including zoom and view offsets. | | sceneSignature(scene) | A string that changes whenever something the renderer reads changes. Compare it before re-serializing an interactive scene. | | isAbortError(error) | Whether a render() rejection means the frame was replaced or cancelled rather than failed. | | createDefaultBvhWorker() | The BVH worker this package ships, started from new URL("./bvhWorker.js", import.meta.url). | | respondToBvhRequest(request) | The worker's side of the exchange, for a worker of your own: returns the response and the buffers to transfer. | | packGeometry, buildBvh, packLights, packTextures, sceneByteLength | The scene packing, published because the sizes it produces are what a host checks against a device's limits before uploading. | | setMemoryLogging(on), deviceMemory() | Off by default. When on, each step of a frame logs what the device holds by buffer label, the page's JavaScript heap where the browser tells, and the sizes the step produced: the snapshot, repeated geometry kept as instances, the textures and which channels of each are read, and the slowest meshes to pack. deviceMemory() answers the same at any time. |

Bundlers

The renderer loads naga's WebAssembly the first time a scene has a shader, from new URL("naga_bg.wasm", import.meta.url) next to the naga-wasm module, which Vite, webpack 5, Rspack and Parcel resolve. With Vite, keep it out of dependency pre-bundling, or the dev server moves the module away from its binary:

// vite.config.ts
export default defineConfig({
  optimizeDeps: { exclude: ["naga-wasm"] },
});

The BVH worker is started the same way, from new URL("./bvhWorker.js", import.meta.url) next to the package's own module, which the same bundlers emit as a separate chunk. Where yours does not, pass createBvhWorker, or bvhWorker: false; see BVH worker.

BVH worker

Splitting a large scene into its BVH takes about a second per million triangles, which would hold the page if it ran there. The renderer sends a copy of the positions to a Web Worker instead, starts it on the first scene large enough to need it, and keeps it until dispose(). A smaller scene is built on the main thread, where it takes less time than the trip to the worker and back.

If the worker cannot start or fails to load, the renderer warns once in the console and builds every BVH on the main thread from then on: the image does not change, only the page holds while the scene is prepared.

// Off: every BVH on the main thread.
new GpuRenderer({ width, height, scene, bvhWorker: false });

// A worker of your own, when the bundler cannot follow the default one's URL.
new GpuRenderer({
  width, height, scene,
  createBvhWorker: () => new Worker(new URL("@tinypoly/gpu-renderer/bvhWorker.js", import.meta.url),
    { type: "module" }),
});

Anything shaped like a Web Worker fits BvhWorker: postMessage(request, transfer), terminate(), onmessage and onerror. A worker of your own answers each BvhRequest with respondToBvhRequest(request), posting its response with its transfer list.

Limits

This is a path tracer, not Three.js or WebGL conformance, and not a match for the CPU renderer's image: shadows, indirect light, reflections and transmission come out differently, usually better, never identical. Where it deliberately disagrees with Three (ambient lights as surroundings, stochastic transparency, unlit surfaces that emit, refracting glass) and what it reads, ARCHITECTURE.md lists in full. A scene has to fit the device's largest storage binding, and it is not available in Node, which has no WebGPU.

Development

pnpm dev opens the example gallery, the same scenes as the CPU renderer's, traced here. pnpm test runs the CI gate, which needs no GPU; pnpm verify renders scenes with answers known on paper on a real GPU in headless Chrome; pnpm capture:scenes renders the gallery pictures with the example running. pnpm verify --page divergence.html measures what the megakernel loses to divergence: the same grid of spheres with one material at a time and with all of them mixed. pnpm verify --page water.html checks the caustics: still water lets in the surface's share of the sun at any angle, and rippled water only moves that light around.

License

MIT