@tinypoly/gpu-renderer 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +334 -0
- package/LICENSE +21 -0
- package/README.md +258 -2
- package/dist/accumulator.d.ts +27 -0
- package/dist/accumulator.d.ts.map +1 -0
- package/dist/accumulator.js +57 -0
- package/dist/accumulator.js.map +1 -0
- package/dist/bvh.d.ts +26 -0
- package/dist/bvh.d.ts.map +1 -0
- package/dist/bvh.js +207 -0
- package/dist/bvh.js.map +1 -0
- package/dist/camera.d.ts +29 -0
- package/dist/camera.d.ts.map +1 -0
- package/dist/camera.js +48 -0
- package/dist/camera.js.map +1 -0
- package/dist/canvas.d.ts +9 -0
- package/dist/canvas.d.ts.map +1 -0
- package/dist/canvas.js +11 -0
- package/dist/canvas.js.map +1 -0
- package/dist/denoise.d.ts +69 -0
- package/dist/denoise.d.ts.map +1 -0
- package/dist/denoise.js +455 -0
- package/dist/denoise.js.map +1 -0
- package/dist/device.d.ts +37 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/device.js +52 -0
- package/dist/device.js.map +1 -0
- package/dist/dispatchScheduler.d.ts +43 -0
- package/dist/dispatchScheduler.d.ts.map +1 -0
- package/dist/dispatchScheduler.js +49 -0
- package/dist/dispatchScheduler.js.map +1 -0
- package/dist/environment.d.ts +39 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/environment.js +168 -0
- package/dist/environment.js.map +1 -0
- package/dist/frameLayout.d.ts +4 -0
- package/dist/frameLayout.d.ts.map +1 -0
- package/dist/frameLayout.js +58 -0
- package/dist/frameLayout.js.map +1 -0
- package/dist/geometry.d.ts +75 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +671 -0
- package/dist/geometry.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/lights.d.ts +64 -0
- package/dist/lights.d.ts.map +1 -0
- package/dist/lights.js +264 -0
- package/dist/lights.js.map +1 -0
- package/dist/materialLayout.d.ts +143 -0
- package/dist/materialLayout.d.ts.map +1 -0
- package/dist/materialLayout.js +297 -0
- package/dist/materialLayout.js.map +1 -0
- package/dist/math.d.ts +10 -0
- package/dist/math.d.ts.map +1 -0
- package/dist/math.js +40 -0
- package/dist/math.js.map +1 -0
- package/dist/pathTracePass.d.ts +27 -0
- package/dist/pathTracePass.d.ts.map +1 -0
- package/dist/pathTracePass.js +83 -0
- package/dist/pathTracePass.js.map +1 -0
- package/dist/planarCapture.d.ts +38 -0
- package/dist/planarCapture.d.ts.map +1 -0
- package/dist/planarCapture.js +163 -0
- package/dist/planarCapture.js.map +1 -0
- package/dist/presentPass.d.ts +31 -0
- package/dist/presentPass.d.ts.map +1 -0
- package/dist/presentPass.js +78 -0
- package/dist/presentPass.js.map +1 -0
- package/dist/renderer.d.ts +196 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +839 -0
- package/dist/renderer.js.map +1 -0
- package/dist/sceneAssets.d.ts +24 -0
- package/dist/sceneAssets.d.ts.map +1 -0
- package/dist/sceneAssets.js +77 -0
- package/dist/sceneAssets.js.map +1 -0
- package/dist/sceneUpload.d.ts +61 -0
- package/dist/sceneUpload.d.ts.map +1 -0
- package/dist/sceneUpload.js +98 -0
- package/dist/sceneUpload.js.map +1 -0
- package/dist/shaderModule.d.ts +7 -0
- package/dist/shaderModule.d.ts.map +1 -0
- package/dist/shaderModule.js +19 -0
- package/dist/shaderModule.js.map +1 -0
- package/dist/shaderProgram.d.ts +124 -0
- package/dist/shaderProgram.d.ts.map +1 -0
- package/dist/shaderProgram.js +1030 -0
- package/dist/shaderProgram.js.map +1 -0
- package/dist/structLayout.d.ts +28 -0
- package/dist/structLayout.d.ts.map +1 -0
- package/dist/structLayout.js +58 -0
- package/dist/structLayout.js.map +1 -0
- package/dist/texturePack.d.ts +54 -0
- package/dist/texturePack.d.ts.map +1 -0
- package/dist/texturePack.js +86 -0
- package/dist/texturePack.js.map +1 -0
- package/dist/upstream.d.ts +7 -0
- package/dist/upstream.d.ts.map +1 -0
- package/dist/upstream.js +11 -0
- package/dist/upstream.js.map +1 -0
- package/dist/volumetric.d.ts +10 -0
- package/dist/volumetric.d.ts.map +1 -0
- package/dist/volumetric.js +34 -0
- package/dist/volumetric.js.map +1 -0
- package/dist/wgsl/bsdf.wgsl.d.ts +13 -0
- package/dist/wgsl/bsdf.wgsl.d.ts.map +1 -0
- package/dist/wgsl/bsdf.wgsl.js +619 -0
- package/dist/wgsl/bsdf.wgsl.js.map +1 -0
- package/dist/wgsl/bvh.wgsl.d.ts +3 -0
- package/dist/wgsl/bvh.wgsl.d.ts.map +1 -0
- package/dist/wgsl/bvh.wgsl.js +218 -0
- package/dist/wgsl/bvh.wgsl.js.map +1 -0
- package/dist/wgsl/common.wgsl.d.ts +3 -0
- package/dist/wgsl/common.wgsl.d.ts.map +1 -0
- package/dist/wgsl/common.wgsl.js +193 -0
- package/dist/wgsl/common.wgsl.js.map +1 -0
- package/dist/wgsl/environment.wgsl.d.ts +10 -0
- package/dist/wgsl/environment.wgsl.d.ts.map +1 -0
- package/dist/wgsl/environment.wgsl.js +145 -0
- package/dist/wgsl/environment.wgsl.js.map +1 -0
- package/dist/wgsl/lights.wgsl.d.ts +10 -0
- package/dist/wgsl/lights.wgsl.d.ts.map +1 -0
- package/dist/wgsl/lights.wgsl.js +434 -0
- package/dist/wgsl/lights.wgsl.js.map +1 -0
- package/dist/wgsl/pathTrace.wgsl.d.ts +14 -0
- package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -0
- package/dist/wgsl/pathTrace.wgsl.js +1020 -0
- package/dist/wgsl/pathTrace.wgsl.js.map +1 -0
- package/dist/wgsl/present.wgsl.d.ts +7 -0
- package/dist/wgsl/present.wgsl.d.ts.map +1 -0
- package/dist/wgsl/present.wgsl.js +154 -0
- package/dist/wgsl/present.wgsl.js.map +1 -0
- package/dist/wgsl/random.wgsl.d.ts +9 -0
- package/dist/wgsl/random.wgsl.d.ts.map +1 -0
- package/dist/wgsl/random.wgsl.js +91 -0
- package/dist/wgsl/random.wgsl.js.map +1 -0
- package/dist/wgsl/sampling.wgsl.d.ts +3 -0
- package/dist/wgsl/sampling.wgsl.d.ts.map +1 -0
- package/dist/wgsl/sampling.wgsl.js +31 -0
- package/dist/wgsl/sampling.wgsl.js.map +1 -0
- package/dist/wgsl/shaderSupport.wgsl.d.ts +6 -0
- package/dist/wgsl/shaderSupport.wgsl.d.ts.map +1 -0
- package/dist/wgsl/shaderSupport.wgsl.js +120 -0
- package/dist/wgsl/shaderSupport.wgsl.js.map +1 -0
- package/dist/wgsl/surface.wgsl.d.ts +10 -0
- package/dist/wgsl/surface.wgsl.d.ts.map +1 -0
- package/dist/wgsl/surface.wgsl.js +794 -0
- package/dist/wgsl/surface.wgsl.js.map +1 -0
- package/dist/wgsl/texture.wgsl.d.ts +11 -0
- package/dist/wgsl/texture.wgsl.d.ts.map +1 -0
- package/dist/wgsl/texture.wgsl.js +132 -0
- package/dist/wgsl/texture.wgsl.js.map +1 -0
- package/package.json +76 -3
- package/src/accumulator.ts +64 -0
- package/src/bvh.ts +272 -0
- package/src/camera.ts +77 -0
- package/src/canvas.ts +13 -0
- package/src/denoise.ts +514 -0
- package/src/device.ts +81 -0
- package/src/dispatchScheduler.ts +71 -0
- package/src/environment.ts +233 -0
- package/src/frameLayout.ts +59 -0
- package/src/geometry.ts +899 -0
- package/src/index.ts +61 -0
- package/src/lights.ts +365 -0
- package/src/materialLayout.ts +408 -0
- package/src/math.ts +48 -0
- package/src/pathTracePass.ts +97 -0
- package/src/planarCapture.ts +225 -0
- package/src/presentPass.ts +99 -0
- package/src/renderer.ts +1117 -0
- package/src/sceneAssets.ts +118 -0
- package/src/sceneUpload.ts +185 -0
- package/src/shaderModule.ts +19 -0
- package/src/shaderProgram.ts +1370 -0
- package/src/structLayout.ts +93 -0
- package/src/texturePack.ts +152 -0
- package/src/upstream.ts +56 -0
- package/src/volumetric.ts +42 -0
- package/src/wgsl/bsdf.wgsl.ts +618 -0
- package/src/wgsl/bvh.wgsl.ts +217 -0
- package/src/wgsl/common.wgsl.ts +193 -0
- package/src/wgsl/environment.wgsl.ts +144 -0
- package/src/wgsl/lights.wgsl.ts +433 -0
- package/src/wgsl/pathTrace.wgsl.ts +1019 -0
- package/src/wgsl/present.wgsl.ts +153 -0
- package/src/wgsl/random.wgsl.ts +90 -0
- package/src/wgsl/sampling.wgsl.ts +30 -0
- package/src/wgsl/shaderSupport.wgsl.ts +119 -0
- package/src/wgsl/surface.wgsl.ts +793 -0
- package/src/wgsl/texture.wgsl.ts +131 -0
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
How `@tinypoly/gpu-renderer` works inside, and the decisions behind it. For installation and usage, see the
|
|
4
|
+
[README](README.md).
|
|
5
|
+
|
|
6
|
+
The renderer takes a serialized Three.js scene, uploads geometry, a BVH, materials, textures and lights to the GPU, and
|
|
7
|
+
runs a path tracing compute shader that accumulates radiance into a float buffer. A small render pipeline presents that
|
|
8
|
+
buffer, dividing by the sample count and applying exposure, tone mapping, vignette and grain. There is no worker pool and
|
|
9
|
+
no `SharedArrayBuffer`: the state lives on the device.
|
|
10
|
+
|
|
11
|
+
## Relationship with @tinypoly/cpu-renderer
|
|
12
|
+
|
|
13
|
+
The two renderers share a contract instead of a code base. Everything that reads Three.js or defines the shape of the
|
|
14
|
+
data comes from the CPU renderer, through a single module (`src/upstream.ts`), so a version bump has one place to audit:
|
|
15
|
+
|
|
16
|
+
- `serializeScene`, `serializeCamera`, `sceneSignature` and the `Serialized*` types: the scene snapshot.
|
|
17
|
+
- `USER_DATA_KEY` and the `userData.cpuRenderer` extensions. The key stays `cpuRenderer` in both renderers: it is
|
|
18
|
+
historical, and renaming it would break scenes that already carry it.
|
|
19
|
+
- `RenderSettings`, `FrameSettings` and the tone mapping transforms.
|
|
20
|
+
- The GPGPU pipeline descriptors a module publishes.
|
|
21
|
+
|
|
22
|
+
What is rewritten in WGSL: material evaluation, the integrator, texture sampling and the environment. The CPU sources
|
|
23
|
+
stay the algorithmic reference, line by line.
|
|
24
|
+
|
|
25
|
+
## Modules
|
|
26
|
+
|
|
27
|
+
- `device.ts`: adapter and device acquisition, requested limits, loss handling.
|
|
28
|
+
- `camera.ts`: the camera's matrices. Every ray goes through the inverse of the camera's own projection.
|
|
29
|
+
- `geometry.ts`, `bvh.ts`: flat triangle and attribute buffers, and the BVH built straight into 32 byte nodes. Packing
|
|
30
|
+
also builds what Three draws in screen space (points, lines, sprites, dashes) as triangles facing the camera, applies
|
|
31
|
+
displacement maps, and runs vertex shaders.
|
|
32
|
+
- `structLayout.ts`, `frameLayout.ts`, `materialLayout.ts`: one description of each struct, from which both the WGSL
|
|
33
|
+
declaration and the TypeScript writer are generated, so their word offsets cannot drift apart.
|
|
34
|
+
- `texturePack.ts`, `lights.ts`: the remaining storage buffer layouts. `lights.ts` holds the emissive triangles and
|
|
35
|
+
Three's analytic lights in one cumulative distribution, the triangles first and in triangle order, so the same buffer
|
|
36
|
+
answers both questions the integrator asks: pick a light by probability, and, given a triangle a bounce ray hit, how
|
|
37
|
+
likely direct sampling was to have found it.
|
|
38
|
+
- `environment.ts`: the surrounding image and the distribution that lets a shadow ray aim at its bright parts.
|
|
39
|
+
- `shaderProgram.ts`, `sceneAssets.ts`: the GLSL translation, the procedural sky and the compute bakes (see below).
|
|
40
|
+
- `planarCapture.ts`, `volumetric.ts`: the CPU renderer's planar captures and volumetric lights.
|
|
41
|
+
- `sceneUpload.ts`: the uploads, and the joining of buffers described below.
|
|
42
|
+
- `accumulator.ts`, `pathTracePass.ts`, `presentPass.ts`, `dispatchScheduler.ts`: the frame loop.
|
|
43
|
+
- `wgsl/*.wgsl.ts`: the shader source.
|
|
44
|
+
|
|
45
|
+
## Decisions worth not relitigating
|
|
46
|
+
|
|
47
|
+
**A megakernel, not a wavefront.** One compute invocation per pixel, the bounce loop inside the kernel, `8 x 8`
|
|
48
|
+
workgroups so neighbouring pixels stay coherent. A wavefront pays off with many distinct materials and high register
|
|
49
|
+
pressure; here there is a single evaluator behind flags, and the queue traffic (a ray state per pixel per bounce) costs
|
|
50
|
+
most on exactly the weak hardware this has to serve. The WGSL is split into pure functions so that moving to a wavefront
|
|
51
|
+
later rewrites the scheduler, not the BRDFs. The signal to revisit: a kernel past roughly 700 lines, or throughput
|
|
52
|
+
falling off a cliff when a lobe is added.
|
|
53
|
+
|
|
54
|
+
**Arrays of the same kind share a binding.** A WebGPU device only has to offer eight storage buffers per shader stage,
|
|
55
|
+
and the kernel wants eleven arrays. Normals and the UV sets go in one buffer, the per-triangle material indices and the
|
|
56
|
+
hierarchy's triangle order in another, and the texture table sits in front of the texels it points at, with the uniform
|
|
57
|
+
saying where each second half begins. Positions, materials, nodes, lights and the accumulation buffer keep their own.
|
|
58
|
+
Merging costs a scene some headroom against `maxStorageBufferBindingSize`, which is why positions stay separate: they are
|
|
59
|
+
the largest array and the hottest one.
|
|
60
|
+
|
|
61
|
+
**Textures in a storage buffer, sampled by hand.** Core WebGPU has no binding arrays, and `texture_2d_array` demands one
|
|
62
|
+
size and one format for every layer, which the scene snapshot does not provide (arbitrary sizes, per texture wrap and UV
|
|
63
|
+
matrix, authored mipmaps). Compute shaders have no implicit derivatives either, so the level of detail is computed by
|
|
64
|
+
hand regardless and half of a hardware sampler's advantage disappears. A fast path can add `texture_2d_array` later, for
|
|
65
|
+
scenes where every texture happens to be uniform.
|
|
66
|
+
|
|
67
|
+
**WGSL lives in `src/wgsl/*.wgsl.ts`, as template literals.** The build is plain `tsc`, and `tsc` does not copy `.wgsl`
|
|
68
|
+
files into `dist`. The Tinypoly rule of keeping shader source in its own file exists because of a build time obfuscation
|
|
69
|
+
plugin that does not apply to a published library.
|
|
70
|
+
|
|
71
|
+
**The hierarchy is built here, not imported.** The CPU renderer has a binned SAH builder, but it stores bounds as
|
|
72
|
+
doubles for a scalar traversal, and re-packing that tree would still leave every minimum needing to be rounded down and
|
|
73
|
+
every maximum up before a float32 shader may use it. Building from the same `Float32Array` the shader reads makes every
|
|
74
|
+
corner exactly representable and the widening unnecessary. Revisit if the two builders start to drift.
|
|
75
|
+
|
|
76
|
+
**Shaders: vertex stages interpreted, fragment stages translated by naga.** A Three shader is two different problems.
|
|
77
|
+
|
|
78
|
+
- **A vertex shader decides where the triangles are**, so it has to run before the hierarchy is built, which happens on
|
|
79
|
+
the CPU. It runs there, once per vertex, on the CPU renderer's own GLSL interpreter (`CpuShader`), with the inputs the
|
|
80
|
+
CPU renderer gives it. Its `gl_Position` is taken back to world space through the camera, which is how displacement,
|
|
81
|
+
billboards and `gl_PointSize` come out exactly as drawn; its varyings are stored per triangle corner.
|
|
82
|
+
- **A fragment shader runs per sample**, inside the kernel. `naga-wasm` (naga 30) translates it to WGSL after a rewrite
|
|
83
|
+
that turns a program into functions: every uniform, varying and built-in becomes a module scope global the kernel
|
|
84
|
+
fills in, every texture call becomes a function the kernel provides (reading the texture table by hand), and `discard`
|
|
85
|
+
sets a flag. Uniforms are loaded from the texture buffer by code generated from the types naga wrote, so arrays,
|
|
86
|
+
structs and matrices need no table of their own. What comes back is renamed into the kernel's namespace and called
|
|
87
|
+
through a `switch` on the material's program. naga is loaded only for a scene that has shaders.
|
|
88
|
+
- **Derivatives are real.** A shader that takes `dFdx`, `dFdy` or `fwidth` runs three times per hit, for the pixel to the
|
|
89
|
+
right, the pixel above and the pixel itself; each call site stores what the neighbours computed there, and the third run
|
|
90
|
+
reads the difference. The neighbours' varyings come from the barycentrics of their camera rays on the hit triangle's
|
|
91
|
+
plane. It is a quad's work in sequence, correct wherever the three runs take the same path through the shader, which is
|
|
92
|
+
the assumption a GPU's own quads make.
|
|
93
|
+
- **An `onBeforeCompile` hook** runs on the material's values before lighting (`diffuseColor`, `normal` in view space,
|
|
94
|
+
`roughnessFactor`, `metalnessFactor`, `totalEmissiveRadiance`), which is the part of Three's shader the CPU renderer's
|
|
95
|
+
snapshot captures. The path tracer then lights the result itself.
|
|
96
|
+
|
|
97
|
+
The CPU renderer's tree-walking front end, considered in an earlier version of this decision, is not used: naga parses,
|
|
98
|
+
type checks and restructures, which was the bulk of the work. A shader that naga refuses (a `switch` that falls through
|
|
99
|
+
was the one known case, and is now rewritten) is reported with `console.warn` and its material renders as its colour,
|
|
100
|
+
rather than failing the frame.
|
|
101
|
+
|
|
102
|
+
**Adaptive sampling spends a fixed budget where the noise is.** A frame has the work of `settings.samples` passes over
|
|
103
|
+
the whole image to spend. Every pixel keeps the sum of its squared luminance beside its radiance, which gives its
|
|
104
|
+
variance; after 32 paths, a pixel whose standard error (over the square root of its luminance, roughly how visible the
|
|
105
|
+
error is once encoded) is under half an 8 bit step stops, provided its 3 x 3 neighbourhood has too. The neighbourhood is
|
|
106
|
+
what keeps a rare bright path honest: a pixel judged alone would stop where it happened not to have seen one yet, and
|
|
107
|
+
that area would converge too dark. Each traced row sets a flag, read back after the pass, and the next pass dispatches
|
|
108
|
+
only the rows that still have work, until the budget is spent or a pixel reaches four times its share. Stopping on the
|
|
109
|
+
samples themselves is a slight bias, the usual price of adaptive sampling.
|
|
110
|
+
|
|
111
|
+
**The denoiser is the spatial filter of SVGF, with diffuse and specular light apart.** The temporal half of SVGF, and of
|
|
112
|
+
NVIDIA's NRD, has nothing to do here: the image does not move while it converges, and the accumulation buffer already
|
|
113
|
+
is a perfect history. What carries over from NRD's RELAX and REBLUR is splitting the signal.
|
|
114
|
+
|
|
115
|
+
- **Two signals.** Every path is split at its first scatter: what the diffuse lobe carried on (and what the camera sees
|
|
116
|
+
directly) is diffuse, what a specular lobe, the clearcoat or transmission carried on is specular. Direct light at that
|
|
117
|
+
vertex is split by evaluating the diffuse term alone. Each pixel keeps the diffuse radiance beside the total, and the
|
|
118
|
+
squared luminance of each signal, which gives each its own variance.
|
|
119
|
+
- **Guides**, taken at the first surface the camera ray stops at: the diffuse albedo, the specular one (the GGX lobe's
|
|
120
|
+
directional albedo from Karis' fit, with the clearcoat, white through glass), the normal, the distance, the roughness,
|
|
121
|
+
and how far the specular continuation went before it hit something.
|
|
122
|
+
- **Filtering.** Each signal is divided by its albedo, so the filter sees light and never blurs a texture. A pixel
|
|
123
|
+
brighter than all eight neighbours is clamped to the brightest of them (anti-firefly). Then up to five passes of a
|
|
124
|
+
5 x 5 a-trous wavelet, taps 1 to 16 pixels apart. Diffuse taps are weighed by the agreement of normals and depths and
|
|
125
|
+
by their luminance against the pixel's own variance, prefiltered over 3 x 3. Specular taps also by roughness, by
|
|
126
|
+
normals more sharply the smoother the surface, and within the footprint the lobe leaves on the screen: the lobe's angle
|
|
127
|
+
(alpha) times the distance to what it reflects, over that distance plus the surface's, in pixels. A mirror is left
|
|
128
|
+
alone, a rough reflection of something far is smoothed widely. The variance is filtered along, so each pass trusts the
|
|
129
|
+
last.
|
|
130
|
+
- **Mixing back.** Weights are normalized, so a flat area keeps its mean exactly. Each signal is then mixed with what
|
|
131
|
+
was traced by how noisy it still is, by the error adaptive sampling measures, from where it counts as converged to four
|
|
132
|
+
times that: a converged pixel keeps exactly what was traced. `DenoiserSettings` scales the mix, the reach, the
|
|
133
|
+
luminance tolerance and the anti-firefly; changing it filters the finished accumulation again, without tracing.
|
|
134
|
+
|
|
135
|
+
It runs in its own pipelines, outside the kernel's eight storage buffers, on the canvas at the end of every pass and on
|
|
136
|
+
the image `render()` resolves with. The accumulation holds six vec4 per pixel for all of this, 96 bytes, and the filter
|
|
137
|
+
five more buffers of 16: a 2000 x 1350 image takes some 460 MB on the device.
|
|
138
|
+
|
|
139
|
+
## Compatibility
|
|
140
|
+
|
|
141
|
+
This is a path tracer, **not Three.js/WebGL conformance, and not a match for the CPU renderer's image**. What follows is
|
|
142
|
+
what the renderer reads today. Anything absent is ignored in silence unless it is marked as an error, so a host reading
|
|
143
|
+
this table is the only way to know.
|
|
144
|
+
|
|
145
|
+
### Geometry and cameras
|
|
146
|
+
|
|
147
|
+
| Works | Notes |
|
|
148
|
+
| --- | --- |
|
|
149
|
+
| Triangle meshes, transformed to world space | Every triangle carries its own three vertices |
|
|
150
|
+
| `InstancedMesh`, with `instanceColor` | The scene snapshot expands it to one mesh per instance |
|
|
151
|
+
| `SkinnedMesh`, morph targets | The snapshot bakes the deformed positions |
|
|
152
|
+
| Per-group materials, indexed or not, `drawRange` | |
|
|
153
|
+
| Vertex normals, or a face normal when absent; `flatShading` | |
|
|
154
|
+
| Vertex colours (`vertexColors`), three or four components | |
|
|
155
|
+
| Two UV sets, `uv` and `uv1` | A texture asking for a higher channel falls back to `uv1` |
|
|
156
|
+
| A mirroring transform | The winding is turned around, as Three flips its front face |
|
|
157
|
+
| `Points` | A square of `size` pixels facing the camera, with `sizeAttenuation`, as `gl_PointCoord` maps it |
|
|
158
|
+
| `Line`, `LineSegments`, `LineLoop`, `LineDashedMaterial` | One pixel wide, as WebGL draws every line; dashes are cut into pieces |
|
|
159
|
+
| `Sprite` | Laid out around its `center`, with `rotation` and `sizeAttenuation` |
|
|
160
|
+
| Any camera with a projection matrix | Perspective, orthographic, zoom, `setViewOffset`, film offset |
|
|
161
|
+
|
|
162
|
+
Camera rays start on the near plane and stop at the far plane, which clip a surface as Three clips a fragment.
|
|
163
|
+
Points, lines, sprites and vertex shaders face or depend on the camera, so a scene with them is packed again whenever the
|
|
164
|
+
camera or the image size changes.
|
|
165
|
+
|
|
166
|
+
`material.side` culls the faces Three culls, for the camera's own rays only: a mesh still casts shadows and holds light
|
|
167
|
+
in from both sides, which is what a closed room drawn `BackSide` for a cutaway view needs. Per-mesh `castShadow` and
|
|
168
|
+
`receiveShadow` are ignored: a traced shadow comes from the geometry that casts it.
|
|
169
|
+
|
|
170
|
+
### Materials
|
|
171
|
+
|
|
172
|
+
| Type | What happens |
|
|
173
|
+
| --- | --- |
|
|
174
|
+
| `MeshStandardMaterial` | Three r185's direct lighting: Lambert diffuse, GGX with its multiple scattering term |
|
|
175
|
+
| `MeshPhysicalMaterial` | Adds transmission, dispersion, clearcoat, sheen, iridescence, anisotropy, specular colour and intensity |
|
|
176
|
+
| `MeshLambertMaterial` | Lambert diffuse |
|
|
177
|
+
| `MeshPhongMaterial` | Lambert diffuse and Three's Blinn-Phong highlight, sampled by its own lobe |
|
|
178
|
+
| `MeshToonMaterial` | Three's ramp, from `gradientMap` or its two steps, in place of the cosine |
|
|
179
|
+
| `MeshBasicMaterial`, `PointsMaterial`, `LineBasicMaterial`, `SpriteMaterial` | Unlit, which in a path tracer means the surface emits its own colour |
|
|
180
|
+
| `MeshNormalMaterial`, `MeshDepthMaterial`, `MeshDistanceMaterial`, `MeshMatcapMaterial` | Unlit, computed as Three's shaders compute them |
|
|
181
|
+
| `ShadowMaterial` | A shadow catcher: its colour where a shadow casting light is blocked, nothing elsewhere |
|
|
182
|
+
| `ShaderMaterial`, `RawShaderMaterial` | The fragment shader runs, translated; its output is the colour the surface shows, unlit |
|
|
183
|
+
| A built-in material patched with `onBeforeCompile` | The hooks run on the material's values before lighting |
|
|
184
|
+
|
|
185
|
+
Maps read: `map`, `alphaMap`, `emissiveMap`, `roughnessMap`, `metalnessMap`, `normalMap` (tangent or object space),
|
|
186
|
+
`bumpMap`, `displacementMap`, `lightMap`, `envMap`, `specularMap`, `specularColorMap`, `specularIntensityMap`,
|
|
187
|
+
`transmissionMap`, `thicknessMap`, `clearcoatMap`, `clearcoatRoughnessMap`, `clearcoatNormalMap`, `sheenColorMap`,
|
|
188
|
+
`sheenRoughnessMap`, `iridescenceMap`, `iridescenceThicknessMap`, `anisotropyMap`, `gradientMap`, `matcap`. Each one reads
|
|
189
|
+
the channel Three's chunks read and honours the texture's own UV transform, wrap modes, filter and colour space.
|
|
190
|
+
|
|
191
|
+
Coverage follows Three's chunks: `alphaTest` cuts, `alphaHash` keeps the share, and only a `transparent` material with
|
|
192
|
+
normal blending lets the rest through; an opaque one is opaque whatever its opacity. Additive blending adds the surface
|
|
193
|
+
to what is behind it, multiply and subtractive filter it. Material clipping planes (with `clipIntersection`),
|
|
194
|
+
`wireframe` (one pixel wide edges) and `visible`/`colorWrite` are honoured.
|
|
195
|
+
|
|
196
|
+
A **material's own `envMap`** is what the paths leaving it see instead of the scene's surroundings; on Basic, Lambert
|
|
197
|
+
and Phong it blends over the colour through `combine` and `reflectivity`, mirrored or bent by `refractionRatio`. A
|
|
198
|
+
**light map** adds its light to what the camera sees of the surface and is not traced further: the path traces that light
|
|
199
|
+
itself, and counting the bake again would double it.
|
|
200
|
+
|
|
201
|
+
A shader's output is taken as linear light, as in the CPU renderer: the tone mapping and colour space chunks are emptied,
|
|
202
|
+
because the renderer applies both to the image. Its fog chunk is emptied too, because the integrator lays Three's fog over
|
|
203
|
+
every material that accepts it. A fragment shader runs only where a path hits the surface, so a shader whose output
|
|
204
|
+
depends on `gl_FragCoord` sees the camera's projection of the hit, which off screen can be anywhere.
|
|
205
|
+
|
|
206
|
+
**Transmission** follows Three's split: `transmission`, times its map and less metalness, is the share of the diffuse
|
|
207
|
+
layer that lets light through, and the specular lobe stays on top. A path picks that share by chance and then meets a
|
|
208
|
+
dielectric of index `ior`, reflecting by the exact Fresnel term and refracting the rest, rough or smooth. The base colour
|
|
209
|
+
tints light once, on the way in. `thickness` decides what the surface is:
|
|
210
|
+
|
|
211
|
+
- **Zero (Three's default) is a thin wall**, a window: light crosses it without bending, and so do shadow rays, tinted
|
|
212
|
+
by the same amount. A room lit by the sun through its glazing works as it should.
|
|
213
|
+
- **Above zero the mesh bounds a volume** that refracts, and `attenuationColor` over `attenuationDistance` absorbs along
|
|
214
|
+
the path inside it (Beer-Lambert). The mesh has to be closed with outward normals. A shadow ray cannot bend, so a volume
|
|
215
|
+
blocks it: **a glass ball under a directional, point or spot light casts a solid shadow**, with no caustic, because
|
|
216
|
+
those lights occupy no area a refracted path could find. Under an area light, emissive geometry or the environment
|
|
217
|
+
the caustic appears, slowly. **Dispersion** splits a path into one colour the first time it enters such a volume, which
|
|
218
|
+
then refracts with that colour's index, spread as Three spreads it.
|
|
219
|
+
|
|
220
|
+
A refraction does not spend a bounce; a reflection inside the glass does.
|
|
221
|
+
|
|
222
|
+
`aoMap` is ignored deliberately, not by omission. It bakes the occlusion this renderer traces, and applying both would
|
|
223
|
+
darken the same shadow twice.
|
|
224
|
+
|
|
225
|
+
Only the top mip level of a texture is read. A rasterizer needs the lower levels so minified detail does not alias; this
|
|
226
|
+
renderer already draws many jittered samples through every pixel, which averages that detail properly. Levels would buy
|
|
227
|
+
memory locality, not correctness. A bump map's slope is taken over one texel, as in the CPU renderer, where Three takes
|
|
228
|
+
it over one pixel.
|
|
229
|
+
|
|
230
|
+
### Lights
|
|
231
|
+
|
|
232
|
+
| Works | Notes |
|
|
233
|
+
| --- | --- |
|
|
234
|
+
| `DirectionalLight` | Aimed at directly, with `shadows.softness` as its angular diameter |
|
|
235
|
+
| `PointLight` | Three's own distance falloff and cutoff |
|
|
236
|
+
| `SpotLight` | Cone and penumbra as Three computes them, and its projected `map` |
|
|
237
|
+
| `RectAreaLight` | A real area light, sampled over its rectangle, so its penumbra is traced rather than approximated |
|
|
238
|
+
| `AmbientLight`, `HemisphereLight` | See the divergence below |
|
|
239
|
+
| Emissive geometry | Any triangle whose material emits, weighted by area and power |
|
|
240
|
+
|
|
241
|
+
`light.castShadow` gates the occlusion ray, as does `settings.shadows.enabled`. A `RectAreaLight` always occludes,
|
|
242
|
+
because Three has no shadow switch for one and an area light that cast none would look broken once traced. A toon
|
|
243
|
+
surface lit from behind is not shadowed by its own mesh, as Three's shadow map leaves it.
|
|
244
|
+
|
|
245
|
+
### The environment and the background
|
|
246
|
+
|
|
247
|
+
A vertical gradient, an equirectangular `.hdr` image, or the scene's procedural sky (`userData.cpuRenderer.sky`), with a
|
|
248
|
+
luminance distribution built over the image so a shadow ray can aim at a sun. `intensity` and `rotation` apply to the
|
|
249
|
+
gradient and the image. A procedural sky is baked by the CPU renderer's interpreter, lights with its own `intensity`,
|
|
250
|
+
ignores the rotation so its sun stays with its light, and paints the background without its intensity, with its
|
|
251
|
+
`foregroundShader` evaluated per pixel over it.
|
|
252
|
+
|
|
253
|
+
`settings.background` decides what the camera sees where nothing is in the way: the environment, a colour, or nothing,
|
|
254
|
+
in which case the image's alpha is the coverage of every pixel and the canvas is configured premultiplied. The
|
|
255
|
+
background shows through transmission, as Three's transmission shows it, and not in a reflection, which shows the
|
|
256
|
+
environment.
|
|
257
|
+
|
|
258
|
+
`scene.fog` (`Fog` and `FogExp2`) is laid over what the camera sees through the first surface, at that surface's view
|
|
259
|
+
depth, for every material that accepts fog, and the background stays clear, as in Three. `settings.fog`, when enabled,
|
|
260
|
+
covers every surface instead.
|
|
261
|
+
|
|
262
|
+
### Settings
|
|
263
|
+
|
|
264
|
+
Read: `samples`, `renderScale`, `exposure`, `toneMapping`, `vignette`, `grain`, `background`, `fog`, `depthOfField`
|
|
265
|
+
(the CPU renderer's 35 mm lens, `focusDistance`, `aperture` as an f-number, `blades` for polygonal bokeh),
|
|
266
|
+
`globalIllumination.bounces`, `globalIllumination.clamp`, `shadows.enabled`, `shadows.softness`.
|
|
267
|
+
|
|
268
|
+
Ignored: `tileSize`, `ambientOcclusion`, and everything else under `globalIllumination` (`enabled`, `samples`,
|
|
269
|
+
`intensity`, `cache`, `cacheSpacing`, `denoise`).
|
|
270
|
+
|
|
271
|
+
### The CPU renderer's extensions, and the rest
|
|
272
|
+
|
|
273
|
+
- **Light probes.** A `LightProbe`'s harmonics are radiance in Three's SH3 basis; they join the ambient surrounding,
|
|
274
|
+
which delivers exactly the irradiance Three's shader convolves from them. The snapshot does not carry them, so they are
|
|
275
|
+
read from the live scene `setScene` receives: a scene handed over already serialized has none.
|
|
276
|
+
- **Volumetric lights** (`userData.cpuRenderer.volumetric` on a point or spot light) are marched along every camera ray
|
|
277
|
+
as the CPU renderer's `volumetricLight.js` marches them, up to the first surface, and added over the image the same way.
|
|
278
|
+
- **Planar captures** (`planarCapture`) are rendered by this renderer, path traced from the CPU renderer's mirrored and
|
|
279
|
+
real cameras with its clip planes, into the textures the material samples. They follow the camera, so a scene with one
|
|
280
|
+
is captured again whenever it moves.
|
|
281
|
+
- **Compute simulations** (`compute`) are baked on the CPU, by the CPU renderer's own pipeline.
|
|
282
|
+
- **Instanced geometry drawn by a shader**, such as `Line2`, `LineSegments2` and `Wireframe`: the vertex shader runs over
|
|
283
|
+
the template once per instance, with the per-instance attributes at that instance.
|
|
284
|
+
- **A `switch` whose cases fall through**, which WGSL cannot express, is rewritten for that shader only: one pass of a
|
|
285
|
+
loop, so `break` still leaves it, with an `if` per case. A `continue` inside such a case leaves the switch instead of
|
|
286
|
+
continuing the loop around it.
|
|
287
|
+
- **Hooks after `<opaque_fragment>`** run on the radiance the path found on the camera's line of sight from that
|
|
288
|
+
surface on, before the fog, as Three runs them on the lit colour. For a hook that is not linear in `gl_FragColor` this
|
|
289
|
+
is applied per path rather than to the converged colour.
|
|
290
|
+
|
|
291
|
+
### Where this deliberately diverges from Three
|
|
292
|
+
|
|
293
|
+
- **`settings.samples` means paths per pixel here**, where the CPU renderer reads it as antialiasing samples and takes
|
|
294
|
+
its path count from `globalIllumination.samples`. One number does both jobs in a path tracer.
|
|
295
|
+
- **An `AmbientLight` or a `HemisphereLight` becomes an equally bright surrounding.** Three adds them straight to a
|
|
296
|
+
surface's diffuse term, which no path can carry; light that arrives from every direction is light from outside the
|
|
297
|
+
scene. The irradiance matches Three exactly wherever the sky is open, and **contributes nothing inside a closed room**,
|
|
298
|
+
which is the difference a host has to know about. It is not painted in the background, as in Three.
|
|
299
|
+
- **Transparency is stochastic.** A surface with an opacity below one lets that share of rays straight through, which is
|
|
300
|
+
what makes a cutout cast a cutout shadow, and what makes the result converge rather than composite.
|
|
301
|
+
- **Unlit surfaces emit.** A basic material, a shader material, a sprite: whatever colour Three would draw, the surface
|
|
302
|
+
radiates, so it also lights its surroundings (found by bounce rays, not aimed at).
|
|
303
|
+
- Global illumination is always on. There is no flag for it: tracing paths is what this renderer does.
|
|
304
|
+
- **Glass refracts** where Three's transmission samples a blurred copy of the opaque scene behind it. A volume bends what
|
|
305
|
+
is behind it and reflects its surroundings properly; a thin wall is exact. Neither is what Three draws.
|
|
306
|
+
|
|
307
|
+
## Testing
|
|
308
|
+
|
|
309
|
+
The CI gate needs no GPU: scene packing (points, lines, sprites, dashes, displacement and winding included), BVH
|
|
310
|
+
packing, buffer layouts and alignment, the scheduler's feedback loop, and the upstream symbols imported from the CPU
|
|
311
|
+
renderer. `kernel.test.ts` validates the whole kernel with naga, with translated shaders of every kind in it, so a WGSL
|
|
312
|
+
mistake fails in CI rather than on a device. The hierarchy is checked against
|
|
313
|
+
brute force over every triangle, with a traversal in the test file that mirrors the WGSL one step for step, so a packing
|
|
314
|
+
mistake fails here instead of on a device where nothing can be printed.
|
|
315
|
+
|
|
316
|
+
GPU suites are named `*.gpu.test.ts` and skip themselves when no adapter is available. They exist to catch WGSL that does
|
|
317
|
+
not compile, which is the most common and most tedious error to find in a browser.
|
|
318
|
+
|
|
319
|
+
Headless browsers do not judge images here. The software path for WebGPU differs from real drivers in floating point
|
|
320
|
+
results, optional features and limits, which are the three dimensions a path tracer diverges in.
|
|
321
|
+
|
|
322
|
+
What does judge the integrator is `pnpm verify`, which needs a real GPU and so stays out of CI. It drives headless Chrome
|
|
323
|
+
over `scripts/verify.html` and prints a reading against an expectation for each check. Every scene in it has an answer
|
|
324
|
+
known on paper rather than a stored reference image: a unit albedo Lambert surface facing a light of irradiance `pi/4`
|
|
325
|
+
leaves a radiance of exactly `0.25`, whichever kind of light delivered it, so the same number checks the directional
|
|
326
|
+
falloff, the inverse square law, the spot cone, the rectangle's projected area, the ambient conversion, the sRGB decode
|
|
327
|
+
of a texture and the Fresnel split of the specular lobe. A wrong density or a misread texel compiles perfectly and simply
|
|
328
|
+
produces the wrong picture; these numbers say which part is wrong.
|
|
329
|
+
|
|
330
|
+
`pnpm verify --page other.html` runs another page under `scripts/` instead, for chasing one check without running all
|
|
331
|
+
of them.
|
|
332
|
+
|
|
333
|
+
It also reports how far neighbouring pixels sit apart at a low sample count, which is what a broken estimator and a badly
|
|
334
|
+
weighted light both show up as, and it is the measurement to trust over any single image.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Danilo Gadelha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,259 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tinypoly/gpu-renderer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@tinypoly/gpu-renderer)
|
|
4
|
+
[](https://www.npmjs.com/package/@tinypoly/gpu-renderer)
|
|
5
|
+
[](https://github.com/tinypoly/gpu-renderer/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/@tinypoly/gpu-renderer)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
A WebGPU path tracer for [Three.js](https://threejs.org) 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.
|
|
10
|
+
|
|
11
|
+
It is the GPU sibling of [@tinypoly/cpu-renderer](https://github.com/tinypoly/cpu-renderer), which powers the render mode of the [Tinypoly](https://tinypoly.com) editor: the same API, the same scene snapshot and the same settings, so a host picks one at startup and writes the rest once.
|
|
12
|
+
|
|
13
|
+
**[Live example](https://tinypoly.github.io/gpu-renderer/)**
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- Real glass: thin walls that light crosses, and closed volumes that refract and absorb.
|
|
18
|
+
- Perspective and orthographic cameras, any projection Three builds; meshes, lines (`Line`, `LineSegments`, `LineLoop`), dashed lines, fat lines (`Line2`), sprites and `PointsMaterial`.
|
|
19
|
+
- Your custom `ShaderMaterial` GLSL, `RawShaderMaterial` and `onBeforeCompile` hooks: fragment shaders are translated to WGSL by [naga](https://github.com/gfx-rs/wgpu/tree/trunk/naga) and run in the kernel, with real derivatives; vertex shaders run on the CPU renderer's interpreter.
|
|
20
|
+
- 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.
|
|
21
|
+
- 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.
|
|
22
|
+
- 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.
|
|
23
|
+
- Progressive output: the canvas refines sample by sample, and the render can be paused, resumed and cancelled.
|
|
24
|
+
- No worker pool and no `SharedArrayBuffer`: the scene lives on the device, so the page needs no cross-origin isolation.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install @tinypoly/gpu-renderer three
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`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.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import * as THREE from "three";
|
|
38
|
+
import { GpuRenderer, attachCanvas, requestWebGpuSupport } from "@tinypoly/gpu-renderer";
|
|
39
|
+
|
|
40
|
+
const { supported, reason } = await requestWebGpuSupport();
|
|
41
|
+
if (!supported) throw new Error(reason); // the host decides what to fall back to: the CPU renderer, for one
|
|
42
|
+
|
|
43
|
+
const scene = new THREE.Scene();
|
|
44
|
+
const sphere = new THREE.Mesh(new THREE.SphereGeometry(1, 48, 24), new THREE.MeshPhysicalMaterial({ color: "tomato" }));
|
|
45
|
+
scene.add(sphere);
|
|
46
|
+
|
|
47
|
+
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
|
|
48
|
+
camera.position.set(0, 1, 5);
|
|
49
|
+
camera.lookAt(0, 0, 0);
|
|
50
|
+
|
|
51
|
+
const canvas = document.querySelector("canvas")!;
|
|
52
|
+
|
|
53
|
+
const renderer = new GpuRenderer({
|
|
54
|
+
width: canvas.clientWidth,
|
|
55
|
+
height: canvas.clientHeight,
|
|
56
|
+
pixelRatio: window.devicePixelRatio,
|
|
57
|
+
scene,
|
|
58
|
+
camera,
|
|
59
|
+
settings: { samples: 256 }, // paths per pixel
|
|
60
|
+
environment: { kind: "gradient", topColor: "#9cb8d8", bottomColor: "#2b2a28" },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
attachCanvas(renderer, canvas); // sizes the canvas and refines it in place while the image converges
|
|
64
|
+
renderer.on("sample", ({ samples, target }) => console.log(`${samples} / ${target}`));
|
|
65
|
+
const { width, height, data } = await renderer.render(); // RGBA bytes of the finished image
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`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.
|
|
69
|
+
|
|
70
|
+
Without a canvas, take the image when it is done:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
renderer.on("start", ({ width, height }) => { /* the render size */ });
|
|
74
|
+
renderer.on("pixels", ({ x, y, width, height, data }) => { /* the whole image, once, when the last sample lands */ });
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Setters record the inputs; `render()` starts a frame with the current ones:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
camera.position.x += 1; // the camera is read at every render()
|
|
81
|
+
renderer.setSettings({ samples: 1024, globalIllumination: { bounces: 8 } });
|
|
82
|
+
const image = await renderer.render();
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## API
|
|
86
|
+
|
|
87
|
+
### `new GpuRenderer(options)`
|
|
88
|
+
|
|
89
|
+
Every option has a setter of the same name.
|
|
90
|
+
|
|
91
|
+
| Option | Type | |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `scene` | `THREE.Scene` or the result of `serializeScene` | A Three scene is serialized in the background. Defaults to an empty scene. |
|
|
94
|
+
| `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. |
|
|
95
|
+
| `settings` | `RenderSettingsInput` | Merged over `DEFAULT_RENDER_SETTINGS`, group by group. See [Settings](#settings). |
|
|
96
|
+
| `environment` | `RenderEnvironment` | See [Environment](#environment). Defaults to `DEFAULT_RENDER_ENVIRONMENT`, a gradient. |
|
|
97
|
+
| `width`, `height` | `number` | Required. Image size in CSS pixels. |
|
|
98
|
+
| `pixelRatio` | `number` | Defaults to 1. Pass `window.devicePixelRatio` for a sharp image on a high-density screen. |
|
|
99
|
+
| `denoiser` | `Partial<DenoiserSettings>` | How the denoiser filters. Merged over `DEFAULT_DENOISER`. See [Denoiser](#denoiser). |
|
|
100
|
+
|
|
101
|
+
### Methods
|
|
102
|
+
|
|
103
|
+
| Method | |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| `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. |
|
|
106
|
+
| `setScene(scene)` | A Three scene is serialized in the background; `render()` waits for it. |
|
|
107
|
+
| `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. |
|
|
108
|
+
| `setSettings(settings)` | Merges the given fields into the current settings, group by group: `{ fog: { enabled: true } }` keeps the other fog fields. |
|
|
109
|
+
| `setEnvironment(environment)` | Replaces the environment. A change of intensity or rotation alone does not load the image again. |
|
|
110
|
+
| `setSize(width, height, pixelRatio?)` | Image size in CSS pixels for the next frame; the pixel ratio stays unless given. |
|
|
111
|
+
| `setPixelRatio(pixelRatio)` | |
|
|
112
|
+
| `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. |
|
|
113
|
+
| `pause()`, `resume()` | Stops or continues the frame between two dispatches. |
|
|
114
|
+
| `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. |
|
|
115
|
+
| `dispose()` | Releases the device. |
|
|
116
|
+
| `settings` | The settings the next frame renders with. |
|
|
117
|
+
| `denoiser` | The denoiser's settings. |
|
|
118
|
+
| `rendering`, `paused` | Whether a frame is in flight and whether it is paused. |
|
|
119
|
+
| `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. |
|
|
120
|
+
| `renderWidth`, `renderHeight` | The size the next frame renders at: CSS pixels times `pixelRatio` and `settings.renderScale`. |
|
|
121
|
+
|
|
122
|
+
### Events
|
|
123
|
+
|
|
124
|
+
`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.
|
|
125
|
+
|
|
126
|
+
| Event | Payload | |
|
|
127
|
+
| --- | --- | --- |
|
|
128
|
+
| `start` | `{ width, height }` | The scene is on the device and tracing starts. `image` has the render size and is empty. |
|
|
129
|
+
| `sample` | `{ samples, target }` | One more path per pixel over the whole image. Only this renderer has it. |
|
|
130
|
+
| `pixels` | `{ x, y, width, height, data }` | The finished image, **once**, as a single region at (0, 0). `image` already holds it. |
|
|
131
|
+
| `progress` | `{ completed, total, phase }` | Rows traced out of `total`, over every sample. `phase` is `"prepare"` while the scene uploads and `"shade"` while it traces. |
|
|
132
|
+
| `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. |
|
|
133
|
+
| `complete` | | The image is finished. `render()` resolves with it right after. |
|
|
134
|
+
| `error` | `Error` | The frame failed. `render()` rejects with the same error. |
|
|
135
|
+
|
|
136
|
+
### `attachCanvas(renderer, canvas)`
|
|
137
|
+
|
|
138
|
+
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.
|
|
139
|
+
|
|
140
|
+
A WebGPU canvas does not keep what it presented: to save the image, use the pixels `render()` resolves with, or `image`.
|
|
141
|
+
|
|
142
|
+
### WebGPU support
|
|
143
|
+
|
|
144
|
+
| Function | |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| `isWebGpuAvailable()` | The cheap synchronous check: whether `navigator.gpu` exists. It does not prove an adapter does. |
|
|
147
|
+
| `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 }`. |
|
|
148
|
+
|
|
149
|
+
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.
|
|
150
|
+
|
|
151
|
+
### Settings
|
|
152
|
+
|
|
153
|
+
`RenderSettings`, grouped by feature, the CPU renderer's own. Defaults are `DEFAULT_RENDER_SETTINGS`; `mergeSettings(base, input)` applies a partial `RenderSettingsInput` the way `setSettings` does. A path tracer reads some of them differently:
|
|
154
|
+
|
|
155
|
+
| Field | Default | |
|
|
156
|
+
| --- | --- | --- |
|
|
157
|
+
| `renderScale` | `1` | Multiplier on the image size. |
|
|
158
|
+
| `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. |
|
|
159
|
+
| `toneMapping` | `"aces"` | `"aces"`, `"agx"`, `"neutral"` or `"linear"`. |
|
|
160
|
+
| `exposure` | `1` | |
|
|
161
|
+
| `background.mode` | `"environment"` | `"environment"`, `"color"` or `"transparent"`, which makes the image's alpha the coverage of every pixel. |
|
|
162
|
+
| `background.color` | `"#1a1a1a"` | Used by `mode: "color"`. |
|
|
163
|
+
| `shadows.enabled` | `true` | Whether Three's lights are occlusion tested. Emissive geometry always is. |
|
|
164
|
+
| `shadows.softness` | `1` | Angular diameter of the lights in degrees. `0` gives hard shadows. |
|
|
165
|
+
| `globalIllumination.bounces` | `3` | How far a path goes. Glass wants four or more. |
|
|
166
|
+
| `globalIllumination.clamp` | `0` | Maximum luminance one indirect contribution may add, which trades a little bias for fewer fireflies. `0` disables clamping. |
|
|
167
|
+
| `depthOfField.enabled` | `false` | |
|
|
168
|
+
| `depthOfField.focusDistance` | `10` | World units. |
|
|
169
|
+
| `depthOfField.aperture` | `2.8` | f-number. |
|
|
170
|
+
| `depthOfField.blades` | `0` | Polygon sides of the bokeh. `0` is circular. |
|
|
171
|
+
| `fog.enabled` | `false` | Linear fog over every surface. Without it, `scene.fog` applies. |
|
|
172
|
+
| `fog.color` | `"#b8c4d0"` | |
|
|
173
|
+
| `fog.near`, `fog.far` | `10`, `60` | View depth. |
|
|
174
|
+
| `vignette` | `0` | `0` to `1`. |
|
|
175
|
+
| `grain` | `0` | `0` to `1`. |
|
|
176
|
+
|
|
177
|
+
`globalIllumination.denoise` (default `true`) turns the denoiser on: on the canvas at the end of every pass, and on the image `render()` resolves with. Switching it alone filters the finished image again without tracing.
|
|
178
|
+
|
|
179
|
+
### Denoiser
|
|
180
|
+
|
|
181
|
+
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`:
|
|
182
|
+
|
|
183
|
+
| Field | Default | |
|
|
184
|
+
| --- | --- | --- |
|
|
185
|
+
| `strength` | `1` | How much of the filtered image replaces the traced one, from `0` to `1`. |
|
|
186
|
+
| `passes` | `3` | Wavelet passes, `1` to `5`: how far the filter reaches, 2, 6, 14, 30 or 62 pixels. |
|
|
187
|
+
| `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. |
|
|
188
|
+
| `onlyNoisy` | `true` | Leaves the pixels that have converged as they were traced, and filters only where noise is still visible. |
|
|
189
|
+
| `antiFirefly` | `true` | Before filtering, clamps a pixel brighter than every one of its eight neighbours to the brightest of them, in each signal. |
|
|
190
|
+
|
|
191
|
+
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.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
renderer.setDenoiser({ strength: 0.6, edge: 1 }); // gentler: filters the finished image again, no tracing
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
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.
|
|
198
|
+
|
|
199
|
+
### Environment
|
|
200
|
+
|
|
201
|
+
`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.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
{ kind: "hdri", url: "studio.hdr" } // equirectangular .hdr
|
|
205
|
+
{ kind: "gradient", topColor: "#9cb8d8", bottomColor: "#2b2a28", exponent: 2 } // exponent defaults to 2
|
|
206
|
+
{ kind: "hdri", url: "studio.hdr", intensity: 0.8, rotation: 90 } // intensity 1 and rotation 0 by default
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Object options
|
|
210
|
+
|
|
211
|
+
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.
|
|
212
|
+
|
|
213
|
+
| Where | Field | |
|
|
214
|
+
| --- | --- | --- |
|
|
215
|
+
| Any object | `exclude` | Leaves the object and its children out of the render. |
|
|
216
|
+
| Light | `volumetric` | Scatters the light in the air: `true`, or a medium `{ density, anisotropy, spread }`. |
|
|
217
|
+
| Scene | `sky` | A `ProceduralSky`: a shader that replaces the environment. |
|
|
218
|
+
| Material | `time` | Value of the shader's `time` uniform. |
|
|
219
|
+
| 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. |
|
|
220
|
+
| Material | `compute` | A `MaterialCompute`: fragment passes baked on the CPU into the material's uniforms. |
|
|
221
|
+
| Material | `uniforms` | The live uniforms of a material patched in `onBeforeCompile`. |
|
|
222
|
+
| Material | `prepare` | Called with the scene before it is serialized. |
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
light.userData.cpuRenderer = { volumetric: { density: 0.05 } };
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Helpers
|
|
229
|
+
|
|
230
|
+
| Function | |
|
|
231
|
+
| --- | --- |
|
|
232
|
+
| `serializeScene(scene)` | Async. The snapshot `setScene` builds from a Three scene, to reuse across renderers, the CPU one included. |
|
|
233
|
+
| `serializeCamera(camera)` | A fixed `CameraState` snapshot of a Three camera, including zoom and view offsets. |
|
|
234
|
+
| `sceneSignature(scene)` | A string that changes whenever something the renderer reads changes. Compare it before re-serializing an interactive scene. |
|
|
235
|
+
| `isAbortError(error)` | Whether a `render()` rejection means the frame was replaced or cancelled rather than failed. |
|
|
236
|
+
| `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. |
|
|
237
|
+
|
|
238
|
+
## Bundlers
|
|
239
|
+
|
|
240
|
+
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:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
// vite.config.ts
|
|
244
|
+
export default defineConfig({
|
|
245
|
+
optimizeDeps: { exclude: ["naga-wasm"] },
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Limits
|
|
250
|
+
|
|
251
|
+
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](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.
|
|
252
|
+
|
|
253
|
+
## Development
|
|
254
|
+
|
|
255
|
+
`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.
|
|
256
|
+
|
|
257
|
+
## License
|
|
258
|
+
|
|
259
|
+
[MIT](LICENSE)
|