@tinypoly/gpu-renderer 0.0.5 → 0.1.1

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.
Files changed (115) hide show
  1. package/ARCHITECTURE.md +59 -3
  2. package/README.md +53 -4
  3. package/dist/atmosphere.d.ts +21 -0
  4. package/dist/atmosphere.d.ts.map +1 -0
  5. package/dist/atmosphere.js +72 -0
  6. package/dist/atmosphere.js.map +1 -0
  7. package/dist/denoise.d.ts +11 -1
  8. package/dist/denoise.d.ts.map +1 -1
  9. package/dist/denoise.js +34 -6
  10. package/dist/denoise.js.map +1 -1
  11. package/dist/frameLayout.d.ts.map +1 -1
  12. package/dist/frameLayout.js +17 -0
  13. package/dist/frameLayout.js.map +1 -1
  14. package/dist/geometry.d.ts.map +1 -1
  15. package/dist/geometry.js +6 -3
  16. package/dist/geometry.js.map +1 -1
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/lensPass.d.ts +60 -0
  22. package/dist/lensPass.d.ts.map +1 -0
  23. package/dist/lensPass.js +235 -0
  24. package/dist/lensPass.js.map +1 -0
  25. package/dist/lightTree.d.ts +50 -0
  26. package/dist/lightTree.d.ts.map +1 -0
  27. package/dist/lightTree.js +146 -0
  28. package/dist/lightTree.js.map +1 -0
  29. package/dist/lights.d.ts +12 -0
  30. package/dist/lights.d.ts.map +1 -1
  31. package/dist/lights.js +56 -1
  32. package/dist/lights.js.map +1 -1
  33. package/dist/materialLayout.d.ts +24 -0
  34. package/dist/materialLayout.d.ts.map +1 -1
  35. package/dist/materialLayout.js +36 -1
  36. package/dist/materialLayout.js.map +1 -1
  37. package/dist/pathTracePass.d.ts +6 -3
  38. package/dist/pathTracePass.d.ts.map +1 -1
  39. package/dist/pathTracePass.js +16 -10
  40. package/dist/pathTracePass.js.map +1 -1
  41. package/dist/renderer.d.ts +27 -7
  42. package/dist/renderer.d.ts.map +1 -1
  43. package/dist/renderer.js +153 -16
  44. package/dist/renderer.js.map +1 -1
  45. package/dist/sceneUpload.d.ts +6 -0
  46. package/dist/sceneUpload.d.ts.map +1 -1
  47. package/dist/sceneUpload.js +5 -0
  48. package/dist/sceneUpload.js.map +1 -1
  49. package/dist/settings.d.ts +142 -0
  50. package/dist/settings.d.ts.map +1 -0
  51. package/dist/settings.js +100 -0
  52. package/dist/settings.js.map +1 -0
  53. package/dist/subsurface.d.ts +76 -0
  54. package/dist/subsurface.d.ts.map +1 -0
  55. package/dist/subsurface.js +70 -0
  56. package/dist/subsurface.js.map +1 -0
  57. package/dist/volumeContainment.d.ts +17 -0
  58. package/dist/volumeContainment.d.ts.map +1 -0
  59. package/dist/volumeContainment.js +61 -0
  60. package/dist/volumeContainment.js.map +1 -0
  61. package/dist/wgsl/bsdf.wgsl.d.ts +1 -1
  62. package/dist/wgsl/bsdf.wgsl.d.ts.map +1 -1
  63. package/dist/wgsl/bsdf.wgsl.js +35 -3
  64. package/dist/wgsl/bsdf.wgsl.js.map +1 -1
  65. package/dist/wgsl/common.wgsl.d.ts.map +1 -1
  66. package/dist/wgsl/common.wgsl.js +4 -0
  67. package/dist/wgsl/common.wgsl.js.map +1 -1
  68. package/dist/wgsl/lens.wgsl.d.ts +17 -0
  69. package/dist/wgsl/lens.wgsl.d.ts.map +1 -0
  70. package/dist/wgsl/lens.wgsl.js +301 -0
  71. package/dist/wgsl/lens.wgsl.js.map +1 -0
  72. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  73. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  74. package/dist/wgsl/lights.wgsl.js +237 -31
  75. package/dist/wgsl/lights.wgsl.js.map +1 -1
  76. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  77. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  78. package/dist/wgsl/pathTrace.wgsl.js +406 -25
  79. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  80. package/dist/wgsl/random.wgsl.d.ts +1 -1
  81. package/dist/wgsl/random.wgsl.d.ts.map +1 -1
  82. package/dist/wgsl/random.wgsl.js +28 -0
  83. package/dist/wgsl/random.wgsl.js.map +1 -1
  84. package/dist/wgsl/sampling.wgsl.d.ts +1 -1
  85. package/dist/wgsl/sampling.wgsl.d.ts.map +1 -1
  86. package/dist/wgsl/sampling.wgsl.js +25 -0
  87. package/dist/wgsl/sampling.wgsl.js.map +1 -1
  88. package/dist/wgsl/surface.wgsl.d.ts +1 -1
  89. package/dist/wgsl/surface.wgsl.d.ts.map +1 -1
  90. package/dist/wgsl/surface.wgsl.js +21 -1
  91. package/dist/wgsl/surface.wgsl.js.map +1 -1
  92. package/package.json +1 -1
  93. package/src/atmosphere.ts +95 -0
  94. package/src/denoise.ts +44 -6
  95. package/src/frameLayout.ts +17 -0
  96. package/src/geometry.ts +6 -2
  97. package/src/index.ts +13 -0
  98. package/src/lensPass.ts +299 -0
  99. package/src/lightTree.ts +218 -0
  100. package/src/lights.ts +75 -1
  101. package/src/materialLayout.ts +52 -1
  102. package/src/pathTracePass.ts +19 -10
  103. package/src/renderer.ts +206 -24
  104. package/src/sceneUpload.ts +11 -0
  105. package/src/settings.ts +240 -0
  106. package/src/subsurface.ts +153 -0
  107. package/src/volumeContainment.ts +81 -0
  108. package/src/wgsl/bsdf.wgsl.ts +35 -3
  109. package/src/wgsl/common.wgsl.ts +4 -0
  110. package/src/wgsl/lens.wgsl.ts +300 -0
  111. package/src/wgsl/lights.wgsl.ts +237 -31
  112. package/src/wgsl/pathTrace.wgsl.ts +406 -25
  113. package/src/wgsl/random.wgsl.ts +28 -0
  114. package/src/wgsl/sampling.wgsl.ts +25 -0
  115. package/src/wgsl/surface.wgsl.ts +21 -1
@@ -1,4 +1,5 @@
1
1
  import { layoutStruct, writeStruct, type StructField } from "./structLayout.js";
2
+ import { bevelOf, subsurfaceOf, volumeOf } from "./subsurface.js";
2
3
  import { NO_TEXTURE } from "./texturePack.js";
3
4
  import type { SerializedMaterial, UniformValue } from "./upstream.js";
4
5
 
@@ -59,6 +60,20 @@ export interface MaterialProperties {
59
60
  combine: number;
60
61
  side: number;
61
62
  blending: number;
63
+ /**
64
+ * Share of the diffuse layer that scatters light inside the material, from `userData.cpuRenderer.subsurface`; zero
65
+ * for none. Physical and standard materials only.
66
+ */
67
+ subsurface: number;
68
+ /** Mean free path of red, green and blue inside the material, in world units. */
69
+ subsurfaceRadius: number[];
70
+ subsurfaceAnisotropy: number;
71
+ /** The medium a volume's boundary encloses: its single scattering albedo, density per scene unit and anisotropy. */
72
+ volumeAlbedo: number[];
73
+ volumeDensity: number;
74
+ volumeAnisotropy: number;
75
+ /** The radius over which edges are rounded at shading time, in scene units; zero leaves them sharp. */
76
+ bevel: number;
62
77
  /** An index into the texture table per slot, or `NO_TEXTURE`. */
63
78
  maps: Record<MaterialMap, number>;
64
79
  }
@@ -137,6 +152,18 @@ export const MATERIAL_ALPHA_HASH = 2048;
137
152
  /** A physical material, whose specular colour and intensity reach the lobe and whose grazing reflectance follows them. */
138
153
  export const MATERIAL_SPECULAR = 4096;
139
154
 
155
+ /**
156
+ * The mesh only bounds a medium, from `userData.cpuRenderer.volume`: its surface is invisible and the space inside
157
+ * scatters light.
158
+ */
159
+ export const MATERIAL_VOLUME = 8192;
160
+
161
+ /**
162
+ * An unlit material whose colour is known without running anything: opaque, normal blending, no light map, environment
163
+ * map, alpha map or shader. What it shows is what it emits, so direct sampling can aim at it like an emissive one.
164
+ */
165
+ export const MATERIAL_UNLIT_LIGHT = 16384;
166
+
140
167
  /** What a material's `program` reads as when it has no shader. */
141
168
  export const NO_PROGRAM = 0xffffffff;
142
169
 
@@ -186,6 +213,11 @@ const FIELDS: StructField[] = [
186
213
  { name: "dielectric", type: "f32" },
187
214
  { name: "afterProgram", type: "u32", doc: "What a hook does after <opaque_fragment>, or NO_PROGRAM." },
188
215
  { name: "afterData", type: "u32" },
216
+ { name: "subsurface", type: "vec4f", doc: "rgb is the mean free path of each colour inside, w the share of the diffuse layer." },
217
+ { name: "subsurfaceAnisotropy", type: "f32" },
218
+ { name: "volume", type: "vec4f", doc: "rgb is the medium's single scattering albedo, w its density per scene unit." },
219
+ { name: "volumeAnisotropy", type: "f32" },
220
+ { name: "bevel", type: "f32", doc: "The radius edges are rounded over at shading time; zero leaves them sharp." },
189
221
  { name: "normalRow0", type: "vec4f", doc: "The mesh's world normal matrix, for an object space normal map." },
190
222
  { name: "normalRow1", type: "vec4f" },
191
223
  { name: "normalRow2", type: "vec4f" },
@@ -263,6 +295,8 @@ export function materialProperties(
263
295
  const sheen = physical ? Math.max(0, number(values.sheen, 0)) : 0;
264
296
  const anisotropy = physical ? number(values.anisotropy, 0) : 0;
265
297
  const thickness = vector(values.iridescenceThicknessRange, [100, 400], 2);
298
+ const medium = pbr ? subsurfaceOf(material) : null;
299
+ const volume = volumeOf(material);
266
300
 
267
301
  const maps = Object.fromEntries(MATERIAL_MAPS.map(slot => {
268
302
  const id = material?.maps?.[slot];
@@ -285,7 +319,12 @@ export function materialProperties(
285
319
  | (number(values.normalMapType, 0) === 1 ? MATERIAL_OBJECT_NORMAL_MAP : 0)
286
320
  | (shader?.patched ? MATERIAL_PATCHED : 0)
287
321
  | (flag(values.alphaHash, false) ? MATERIAL_ALPHA_HASH : 0)
288
- | (physical ? MATERIAL_SPECULAR : 0);
322
+ | (physical ? MATERIAL_SPECULAR : 0)
323
+ | (volume ? MATERIAL_VOLUME : 0)
324
+ | (type === "MeshBasicMaterial" && !shader && !transparent && !flag(values.alphaHash, false)
325
+ && !flag(values.wireframe, false) && number(values.blending, 1) === 1 && number(values.opacity, 1) >= 1
326
+ && number(values.alphaTest, 0) <= 0 && maps.alphaMap === NO_TEXTURE && maps.envMap === NO_TEXTURE
327
+ && maps.lightMap === NO_TEXTURE && !volume ? MATERIAL_UNLIT_LIGHT : 0);
289
328
 
290
329
  return {
291
330
  kind,
@@ -330,6 +369,13 @@ export function materialProperties(
330
369
  combine: number(values.combine, 0),
331
370
  side: number(values.side, 0),
332
371
  blending: number(values.blending, 1),
372
+ subsurface: medium?.weight ?? 0,
373
+ subsurfaceRadius: medium?.radius ?? [1, 1, 1],
374
+ subsurfaceAnisotropy: medium?.anisotropy ?? 0,
375
+ volumeAlbedo: volume?.albedo ?? [1, 1, 1],
376
+ volumeDensity: volume?.density ?? 0,
377
+ volumeAnisotropy: volume?.anisotropy ?? 0,
378
+ bevel: LIT.has(type) ? bevelOf(material) : 0,
333
379
  maps,
334
380
  };
335
381
  }
@@ -400,6 +446,11 @@ export function writeMaterial(
400
446
  dielectric: p.dielectric,
401
447
  afterProgram: placement.afterProgram,
402
448
  afterData: placement.afterData,
449
+ subsurface: [...p.subsurfaceRadius, p.subsurface],
450
+ subsurfaceAnisotropy: p.subsurfaceAnisotropy,
451
+ volume: [...p.volumeAlbedo, p.volumeDensity],
452
+ volumeAnisotropy: p.volumeAnisotropy,
453
+ bevel: p.bevel,
403
454
  normalRow0: [n[0], n[3], n[6], 0],
404
455
  normalRow1: [n[1], n[4], n[7], 0],
405
456
  normalRow2: [n[2], n[5], n[8], 0],
@@ -23,8 +23,12 @@ export type FrameUniforms = Record<keyof typeof FRAME_LAYOUT.offsets & string, n
23
23
  * declarations. The scene's translated shaders sit between the texture code they call and the surface code that calls
24
24
  * them.
25
25
  */
26
- export function pathTraceSource(programs: ShaderPrograms | null = null): string {
27
- return COMMON_WGSL + RANDOM_WGSL + BVH_WGSL + SAMPLING_WGSL + TEXTURE_WGSL + SHADER_SUPPORT_WGSL
26
+ export function pathTraceSource(
27
+ programs: ShaderPrograms | null = null, subsurface = true, volumes = true, bevel = true): string {
28
+ // The random walk inside materials and the media inside volumes are compiled in only for a scene that has them: the
29
+ // kernel is bound by its size, and either would cost every other scene a few percent for code that never runs.
30
+ return `const SUBSURFACE_WALK: bool = ${subsurface};\nconst VOLUMES: bool = ${volumes};\nconst BEVEL: bool = ${bevel};\n`
31
+ + COMMON_WGSL + RANDOM_WGSL + BVH_WGSL + SAMPLING_WGSL + TEXTURE_WGSL + SHADER_SUPPORT_WGSL
28
32
  + programsWgsl(programs) + ENVIRONMENT_WGSL + SURFACE_WGSL + BSDF_WGSL + LIGHTS_WGSL + PATH_TRACE_WGSL;
29
33
  }
30
34
 
@@ -37,26 +41,31 @@ export class PathTracePass {
37
41
  private readonly uniforms: GPUBuffer;
38
42
  private readonly values = new ArrayBuffer(FRAME_BYTES);
39
43
  private bindGroup: GPUBindGroup | null = null;
40
- private programKey = "";
44
+ private programKey = "|false|false|false";
41
45
 
42
46
  constructor(private readonly device: GPUDevice) {
43
- this.pipeline = this.build(null);
47
+ this.pipeline = this.build(null, false, false, false);
44
48
  this.uniforms = device.createBuffer({ size: FRAME_BYTES, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
45
49
  }
46
50
 
47
- /** Recompiles the kernel when the scene's shaders changed; the same set of programs keeps the pipeline it has. */
48
- setPrograms(programs: ShaderPrograms | null) {
49
- const key = programs?.key ?? "";
51
+ /**
52
+ * Recompiles the kernel when the scene's shaders changed, or whether it has a material that scatters light inside;
53
+ * the same set keeps the pipeline it has.
54
+ */
55
+ setPrograms(programs: ShaderPrograms | null, subsurface = false, volumes = false, bevel = false) {
56
+ const key = `${programs?.key ?? ""}|${subsurface}|${volumes}|${bevel}`;
50
57
 
51
58
  if (key === this.programKey) return;
52
59
 
53
60
  this.programKey = key;
54
- this.pipeline = this.build(programs);
61
+ this.pipeline = this.build(programs, subsurface, volumes, bevel);
55
62
  this.bindGroup = null;
56
63
  }
57
64
 
58
- private build(programs: ShaderPrograms | null): GPUComputePipeline {
59
- const module = createShaderModule(this.device, "path trace", pathTraceSource(programs));
65
+ private build(programs: ShaderPrograms | null, subsurface: boolean, volumes: boolean,
66
+ bevel: boolean): GPUComputePipeline {
67
+ const module = createShaderModule(this.device, "path trace",
68
+ pathTraceSource(programs, subsurface, volumes, bevel));
60
69
 
61
70
  return this.device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } });
62
71
  }
package/src/renderer.ts CHANGED
@@ -12,6 +12,26 @@ import { NO_TEXTURE, planTextures, textureEntry, writeTextures } from "./texture
12
12
  import { PathTracePass, type FrameUniforms } from "./pathTracePass.js";
13
13
  import { ImageResolvePass } from "./imageResolve.js";
14
14
  import { PresentPass } from "./presentPass.js";
15
+ import { LensPass, lensEffects } from "./lensPass.js";
16
+ import {
17
+ DEFAULT_GPU_RENDER_SETTINGS,
18
+ effectiveExposure,
19
+ mergeGpuSettings,
20
+ type GpuRenderSettings,
21
+ type GpuRenderSettingsInput,
22
+ } from "./settings.js";
23
+ import {
24
+ bevelOf,
25
+ bevelRadius,
26
+ type BevelMaterial,
27
+ subsurfaceMedium,
28
+ subsurfaceOf,
29
+ volumeMedium,
30
+ volumeOf,
31
+ type SubsurfaceMaterial,
32
+ type VolumeMaterial,
33
+ } from "./subsurface.js";
34
+ import { containingVolume, NO_MATERIAL, volumeTriangles, type VolumeTriangles } from "./volumeContainment.js";
15
35
  import {
16
36
  CONVERGED_ERROR,
17
37
  DEFAULT_DENOISER,
@@ -24,11 +44,10 @@ import { CaptureResolvePass, planarCaptures, type PlanarCaptures } from "./plana
24
44
  import { prepareSceneAssets, type SceneAssets } from "./sceneAssets.js";
25
45
  import { packVolumetricLights, VOLUMETRIC_WORDS } from "./volumetric.js";
26
46
  import { environmentPower, uploadScene, type SceneBuffers } from "./sceneUpload.js";
47
+ import { atmosphereExtinction, averageRadiance, brightestSun, gradientAverage } from "./atmosphere.js";
27
48
  import {
28
49
  DEFAULT_RENDER_ENVIRONMENT,
29
- DEFAULT_RENDER_SETTINGS,
30
50
  EventEmitter,
31
- mergeSettings,
32
51
  serializeCamera,
33
52
  serializeScene,
34
53
  srgbToLinear,
@@ -41,8 +60,6 @@ import {
41
60
  type RenderPhase,
42
61
  type RenderPixels,
43
62
  type RenderScene,
44
- type RenderSettings,
45
- type RenderSettingsInput,
46
63
  type SerializedScene,
47
64
  } from "./upstream.js";
48
65
 
@@ -74,8 +91,11 @@ export interface GpuRendererOptions {
74
91
  scene?: RenderScene | SerializedScene;
75
92
  /** Defaults to a perspective camera at (5, 5, 5) looking at the origin. */
76
93
  camera?: RenderCamera;
77
- /** Merged over `DEFAULT_RENDER_SETTINGS`, group by group. */
78
- settings?: RenderSettingsInput;
94
+ /**
95
+ * Merged over `DEFAULT_GPU_RENDER_SETTINGS`, group by group: the CPU renderer's settings, and the groups only this
96
+ * renderer reads (`bloom`, `lens`, `camera`, `subsurface`, the roulette under `globalIllumination`).
97
+ */
98
+ settings?: GpuRenderSettingsInput;
79
99
  /** Defaults to `DEFAULT_RENDER_ENVIRONMENT`. */
80
100
  environment?: RenderEnvironment;
81
101
  /** Image size in CSS pixels; the render size also applies `pixelRatio` and `settings.renderScale`. */
@@ -136,9 +156,30 @@ const SHOW_EVERY_MS = 16;
136
156
  */
137
157
  const BANDS_IN_FLIGHT = 2;
138
158
 
139
- /** How often the canvas takes a freshly denoised image while a frame traces. */
159
+ /**
160
+ * Throughput under which Russian roulette may end a path, with the chance of carrying on equal to the throughput. One
161
+ * plays it on every path past the first bounces, as PBRT does: a path off a light wall carries on as long as it
162
+ * carries light, instead of seven bounces before the roulette ever looks at it. The mean stays exact.
163
+ */
164
+ const ROULETTE_THRESHOLD = 1;
165
+
166
+ /** How often the lens pass grades the canvas again while a frame traces without the denoiser. */
167
+ const LENS_EVERY_MS = 100;
168
+
169
+ /** How often the canvas takes a freshly denoised image while a frame traces, for an image of DENOISE_REFERENCE work. */
140
170
  const DENOISE_EVERY_MS = 200;
141
171
 
172
+ /** The filtering of a 1080p image with three passes, which the interval above was set for. */
173
+ const DENOISE_REFERENCE = 1920 * 1080 * 3;
174
+
175
+ /**
176
+ * How long the canvas waits between denoised images: longer for a larger image or more passes, in proportion, so the
177
+ * filter takes the same share of the device from tracing whatever the size. The finished image is filtered regardless.
178
+ */
179
+ export function denoiseInterval(width: number, height: number, passes: number): number {
180
+ return DENOISE_EVERY_MS * Math.max(1, width * height * Math.max(1, passes) / DENOISE_REFERENCE);
181
+ }
182
+
142
183
  /** The most paths per pixel one dispatch takes, while no pixel can stop yet. */
143
184
  const BATCH_PATHS = 4;
144
185
 
@@ -201,6 +242,8 @@ interface PackedTarget {
201
242
  skyData: number;
202
243
  volumetricData: number;
203
244
  volumetricCount: number;
245
+ /** The boundaries of the scene's volumes, which say whether the camera stands inside one. */
246
+ volumes: VolumeTriangles | null;
204
247
  }
205
248
 
206
249
  /**
@@ -208,7 +251,7 @@ interface PackedTarget {
208
251
  * `render()` opens a frame with whatever is current and accumulates samples until it reaches `settings.samples`.
209
252
  */
210
253
  export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
211
- private current: RenderSettings;
254
+ private current: GpuRenderSettings;
212
255
  private environment: RenderEnvironment;
213
256
  // Serializing walks the whole scene, so it is started by the setter and awaited by the frame that needs it.
214
257
  private scene: Promise<SerializedScene> = Promise.resolve(EMPTY_SCENE);
@@ -248,6 +291,11 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
248
291
  private planar = false;
249
292
  private resolve: CaptureResolvePass | null = null;
250
293
  private denoisePass: DenoisePass | null = null;
294
+ private lensPass: LensPass | null = null;
295
+ // What the lens pass last graded, and when: its input only changes when the denoiser runs or the accumulation grows.
296
+ private lensKey = "";
297
+ private lensAt = -Infinity;
298
+ private denoiseVersion = 0;
251
299
  private denoiserSettings: DenoiserSettings = { ...DEFAULT_DENOISER };
252
300
  // The size of the last frame that finished, which setDenoiser filters again; null while a frame traces.
253
301
  private finished: { width: number; height: number } | null = null;
@@ -257,6 +305,8 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
257
305
  private denoiseView: DenoiseView = { pixelScale: 1, orthographic: false };
258
306
  // Reading an environment image is a fetch, so it is started once and awaited by the frame that needs it.
259
307
  private environmentSource = "";
308
+ // The surroundings' radiance averaged over the sphere, which the air glows with; null for the gradient.
309
+ private environmentAverage: [number, number, number] | null = null;
260
310
  private environmentImage: Promise<PackedEnvironment | null> = Promise.resolve(null);
261
311
  private canvas: HTMLCanvasElement | null = null;
262
312
  private context: GPUCanvasContext | null = null;
@@ -274,7 +324,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
274
324
  this.width = Math.max(1, Math.floor(options.width));
275
325
  this.height = Math.max(1, Math.floor(options.height));
276
326
  this.pixelRatio = checkedPixelRatio(options.pixelRatio ?? 1);
277
- this.current = mergeSettings(DEFAULT_RENDER_SETTINGS, options.settings);
327
+ this.current = mergeGpuSettings(DEFAULT_GPU_RENDER_SETTINGS, options.settings);
278
328
  this.denoiserSettings = mergeDenoiser(DEFAULT_DENOISER, options.denoiser);
279
329
  this.environment = DEFAULT_RENDER_ENVIRONMENT;
280
330
  this.scheduler = new DispatchScheduler({ maxRows: this.renderHeight, maxSamples: BATCH_PATHS });
@@ -285,7 +335,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
285
335
  }
286
336
 
287
337
  /** The settings the next frame renders with. */
288
- get settings(): Readonly<RenderSettings> {
338
+ get settings(): Readonly<GpuRenderSettings> {
289
339
  return this.current;
290
340
  }
291
341
 
@@ -396,15 +446,16 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
396
446
  }
397
447
 
398
448
  /**
399
- * Merges the given fields into the current settings, group by group. Turning the denoiser on or off also filters the
400
- * finished image again at once, as `setDenoiser` does, since that needs no tracing.
449
+ * Merges the given fields into the current settings, group by group. Turning the denoiser on or off, or changing
450
+ * what is done to the light after it is traced (exposure, tone mapping, vignette, bloom, chromatic aberration, white
451
+ * balance), shows the finished image again at once, as `setDenoiser` does, since none of that needs tracing.
401
452
  */
402
- setSettings(settings: RenderSettingsInput) {
403
- const denoise = this.current.globalIllumination.denoise;
453
+ setSettings(settings: GpuRenderSettingsInput) {
454
+ const before = developing(this.current);
404
455
 
405
- this.current = mergeSettings(this.current, settings);
456
+ this.current = mergeGpuSettings(this.current, settings);
406
457
 
407
- if (this.current.globalIllumination.denoise !== denoise) this.refilter();
458
+ if (developing(this.current) !== before) this.refilter();
408
459
  }
409
460
 
410
461
  setEnvironment(environment: RenderEnvironment) {
@@ -507,6 +558,8 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
507
558
  this.buffers?.destroy();
508
559
  this.accumulator?.destroy();
509
560
  this.denoisePass?.destroy();
561
+ this.lensPass?.destroy();
562
+ this.lensPass = null;
510
563
  this.imageResolve?.dispose();
511
564
  this.imageResolve = null;
512
565
  this.gpu?.device.destroy();
@@ -685,7 +738,8 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
685
738
 
686
739
  // The canvas shows the denoised image, refreshed a few times a second while the bands refine underneath it:
687
740
  // filtering the whole image after every pass would cost a cheap scene as much as tracing it.
688
- if (denoise && this.canvas && this.context && performance.now() - denoisedAt >= DENOISE_EVERY_MS) {
741
+ if (denoise && this.canvas && this.context && performance.now() - denoisedAt
742
+ >= denoiseInterval(width, height, this.denoiserSettings.passes)) {
689
743
  const encoder = gpu.device.createCommandEncoder();
690
744
 
691
745
  this.encodeDenoise(gpu, encoder, width, height);
@@ -769,10 +823,20 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
769
823
  height: number,
770
824
  image: PackedEnvironment | null) {
771
825
  this.pathTrace ??= new PathTracePass(gpu.device);
772
- this.pathTrace.setPrograms(assets.programs);
826
+ const materials = assets.scene.meshes.flatMap(mesh => mesh.materials);
827
+
828
+ this.pathTrace.setPrograms(assets.programs,
829
+ this.current.subsurface.enabled && materials.some(material =>
830
+ (material.type === "MeshStandardMaterial" || material.type === "MeshPhysicalMaterial")
831
+ && subsurfaceOf(material) !== null),
832
+ // Even turned off, a volume's boundary has to stay invisible, which takes the same code.
833
+ materials.some(material => volumeOf(material) !== null),
834
+ this.current.bevel.enabled && materials.some(material => bevelOf(material) > 0));
773
835
 
774
836
  // A procedural sky stands in for the renderer's own environment, as it does in the CPU renderer.
775
837
  const environment = assets.sky?.environment ?? image;
838
+
839
+ this.environmentAverage = environment ? averageRadiance(environment) : null;
776
840
  const matrices = cameraMatrices(camera, width, height);
777
841
  const view = `${JSON.stringify(camera)}|${width}|${height}`;
778
842
  const moved = (!!this.packed?.cameraDependent || this.planar) && view !== this.packedView;
@@ -902,6 +966,7 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
902
966
  skyData,
903
967
  volumetricData,
904
968
  volumetricCount: Math.min(volumetric.length / VOLUMETRIC_WORDS, 8),
969
+ volumes: volumeTriangles(geometry),
905
970
  };
906
971
  }
907
972
 
@@ -940,6 +1005,10 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
940
1005
  const uniforms = {
941
1006
  ...this.frameUniforms(capture.camera, width, height, assets, target, capture.background),
942
1007
  lens: [0, 1, 0, 0],
1008
+ lensDistortion: 0,
1009
+ // A capture is a texture of the scene, seen again by the camera, which lays the air over it then.
1010
+ atmosphereColor: [0, 0, 0, 0],
1011
+ cameraVolume: NO_MATERIAL,
943
1012
  volumetricCount: 0,
944
1013
  clipPlane: capture.clip ?? [0, 0, 0, 0],
945
1014
  bandOrigin: [0, 0],
@@ -993,27 +1062,56 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
993
1062
  }
994
1063
 
995
1064
  private encodePresent(encoder: GPUCommandEncoder, width: number, height: number) {
996
- if (!this.context || !this.present) return;
1065
+ if (!this.context || !this.present || !this.gpu) return;
997
1066
 
998
1067
  this.present.encode(encoder, this.context.getCurrentTexture().createView(), {
999
1068
  width,
1000
1069
  height,
1001
- exposure: this.current.exposure,
1070
+ exposure: effectiveExposure(this.current),
1002
1071
  toneMapping: this.current.toneMapping,
1003
1072
  vignette: this.current.vignette,
1004
1073
  grain: this.current.grain,
1005
1074
  frameIndex: this.frameIndex,
1006
1075
  transparent: this.current.background.mode === "transparent",
1007
- denoised: this.denoised ? this.denoisePass?.output : null,
1076
+ denoised: this.encodeLens(this.gpu, encoder, width, height,
1077
+ this.denoised ? this.denoisePass?.output ?? null : null),
1008
1078
  });
1009
1079
  }
1010
1080
 
1081
+ /**
1082
+ * The lens and the sensor over the radiance (or the denoiser's output), when the settings ask for any of them: the
1083
+ * buffer of graded colour the display transform reads instead, or the input as it was.
1084
+ */
1085
+ private encodeLens(gpu: RenderDevice, encoder: GPUCommandEncoder, width: number, height: number,
1086
+ denoised: GPUBuffer | null, force = false): GPUBuffer | null {
1087
+ const effects = lensEffects(this.current);
1088
+
1089
+ if (!effects) return denoised;
1090
+
1091
+ this.lensPass ??= new LensPass(gpu.device);
1092
+
1093
+ // The canvas is drawn about once a display frame, but a denoised image only changes a few times a second: grading
1094
+ // the same one again would take the device from tracing for nothing. Straight from the accumulation, the image
1095
+ // grows with every pass, and is graded again at most every LENS_EVERY_MS.
1096
+ const key = JSON.stringify([this.revision, width, height, denoised ? this.denoiseVersion : -1, effects]);
1097
+ const now = performance.now();
1098
+ const fresh = key === this.lensKey && (denoised !== null || now - this.lensAt < LENS_EVERY_MS);
1099
+
1100
+ if (!force && fresh && this.lensPass.output) return this.lensPass.output;
1101
+
1102
+ this.lensKey = key;
1103
+ this.lensAt = now;
1104
+
1105
+ return this.lensPass.encode(encoder, (this.accumulator as Accumulator).buffer, denoised, width, height, effects);
1106
+ }
1107
+
1011
1108
  /** Denoises the accumulation as it stands, for the canvas and for the finished image. */
1012
1109
  private encodeDenoise(gpu: RenderDevice, encoder: GPUCommandEncoder, width: number, height: number) {
1013
1110
  this.denoisePass ??= new DenoisePass(gpu.device);
1014
1111
  this.denoisePass.encode(encoder, (this.accumulator as Accumulator).buffer, width, height, this.denoiserSettings,
1015
1112
  this.denoiseView);
1016
1113
  this.denoised = true;
1114
+ this.denoiseVersion++;
1017
1115
  }
1018
1116
 
1019
1117
  private frameUniforms(
@@ -1041,6 +1139,16 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1041
1139
  : 0;
1042
1140
 
1043
1141
  const backgroundMode = background.mode === "color" ? 1 : background.mode === "transparent" ? 2 : 0;
1142
+ const { atmosphere } = this.current;
1143
+ const strength = sky ? sky.intensity : this.environment.intensity ?? 1;
1144
+
1145
+ const airGlow = atmosphere.color
1146
+ ? linearColor(atmosphere.color, [0, 0, 0])
1147
+ : (this.environmentAverage ?? gradientAverage(linearColor(gradient?.topColor, [1, 1, 1]),
1148
+ linearColor(gradient?.bottomColor, [0, 0, 0]), gradient?.exponent ?? 2)).map(channel => channel * strength);
1149
+
1150
+ const extinction = atmosphereExtinction(atmosphere);
1151
+ const sun = brightestSun(assets.scene.lights);
1044
1152
 
1045
1153
  return {
1046
1154
  cameraToWorld: matrices.cameraWorld,
@@ -1069,11 +1177,30 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1069
1177
  resolution: [width, height],
1070
1178
  cameraKind: matrices.orthographic ? 1 : 0,
1071
1179
  bounces: Math.max(1, Math.round(this.current.globalIllumination.bounces)),
1180
+ rouletteThreshold: this.current.globalIllumination.russianRoulette === false ? 0 : ROULETTE_THRESHOLD,
1181
+ rouletteStart: Math.max(0, Math.round(this.current.globalIllumination.rouletteStart ?? 0)),
1182
+ lensDistortion: Number.isFinite(this.current.lens.distortion) ? this.current.lens.distortion : 0,
1183
+ subsurfaceEnabled: this.current.subsurface.enabled,
1184
+ subsurfaceDepth: Math.max(0, Math.round(Number(this.current.subsurface.depth ?? 2))),
1185
+ bevelEnabled: this.current.bevel.enabled,
1186
+ volumesEnabled: this.current.volumes.enabled,
1187
+ cameraVolume: this.current.volumes.enabled
1188
+ ? containingVolume(target?.volumes ?? null, matrices.cameraWorld.slice(12, 15))
1189
+ : NO_MATERIAL,
1190
+ atmosphereColor: [...airGlow, extinction.some(value => value > 0) ? 1 : 0],
1191
+ atmosphereExtinction: [...extinction, Math.min(1, Math.max(0, Number(atmosphere.haze) || 0))],
1192
+ atmosphereSun: [...sun?.direction ?? [0, 1, 0], 0],
1193
+ atmosphereSunColor: [...sun?.irradiance ?? [0, 0, 0], 0],
1072
1194
  triangleCount: buffers?.triangleCount ?? 0,
1073
1195
  lightCount: buffers?.lightCount ?? 0,
1074
1196
  areaLightCount: buffers?.areaLightCount ?? 0,
1075
1197
  lightCdf: buffers?.lightCdf ?? 0,
1076
1198
  lightTriangles: buffers?.lightTriangles ?? 0,
1199
+ localLightCount: buffers?.localLightCount ?? 0,
1200
+ localLightPick: buffers?.localLightPick ?? 0,
1201
+ lightTree: this.current.globalIllumination.lightTree === false ? 0 : buffers?.lightTree ?? 0,
1202
+ lightLeaves: buffers?.lightLeaves ?? 0,
1203
+ lightList: buffers?.lightList ?? 0,
1077
1204
  clampIndirect: Math.max(0, this.current.globalIllumination.clamp),
1078
1205
  // The setting is the angular diameter a light covers, in degrees, as it is for the CPU renderer's shadow blur.
1079
1206
  shadowSpread: shadows.enabled ? Math.tan(Math.max(0, shadows.softness) * Math.PI / 360) : 0,
@@ -1108,13 +1235,20 @@ export class GpuRenderer extends EventEmitter<GpuRendererEvents> {
1108
1235
  private async readImage(
1109
1236
  gpu: RenderDevice, width: number, height: number, denoised: GPUBuffer | null): Promise<RenderImage> {
1110
1237
  const accumulator = this.accumulator as Accumulator;
1111
- const { exposure, toneMapping, vignette } = this.current;
1238
+ const { toneMapping, vignette } = this.current;
1239
+ const exposure = effectiveExposure(this.current);
1112
1240
  const transparent = this.current.background.mode === "transparent";
1113
1241
 
1114
1242
  // Resolved on the device, so the page only copies the bytes of the image.
1115
1243
  this.imageResolve ??= new ImageResolvePass(gpu.device);
1116
1244
 
1117
- const data = await this.imageResolve.read(accumulator.buffer, denoised, width, height,
1245
+ // The lens's effects are encoded on their own, ahead of the resolve, which reads the colour they leave.
1246
+ const encoder = gpu.device.createCommandEncoder({ label: "lens" });
1247
+ const graded = this.encodeLens(gpu, encoder, width, height, denoised, true);
1248
+
1249
+ gpu.device.queue.submit([encoder.finish()]);
1250
+
1251
+ const data = await this.imageResolve.read(accumulator.buffer, graded, width, height,
1118
1252
  { exposure, toneMapping, vignette, transparent });
1119
1253
 
1120
1254
  return { width, height, data };
@@ -1135,6 +1269,9 @@ const SERIALIZABLE = new Set([
1135
1269
  */
1136
1270
  async function serializeLive(scene: Scene): Promise<SerializedScene> {
1137
1271
  const renamed = new Map<Material, string>();
1272
+ // The snapshot keeps a material's name but not its media, so the name carries a mark to them.
1273
+ const named = new Map<Material, string>();
1274
+ const media: { subsurface?: unknown; volume?: unknown; bevel?: unknown }[] = [];
1138
1275
 
1139
1276
  scene.traverse(object => {
1140
1277
  const value = (object as Object3D & { material?: Material | Material[] }).material;
@@ -1146,15 +1283,60 @@ async function serializeLive(scene: Scene): Promise<SerializedScene> {
1146
1283
  renamed.set(material, material.type);
1147
1284
  material.type = shader.isRawShaderMaterial ? "RawShaderMaterial" : "ShaderMaterial";
1148
1285
  }
1286
+
1287
+ const data = material.userData?.cpuRenderer as
1288
+ { subsurface?: unknown; volume?: unknown; bevel?: unknown } | undefined;
1289
+
1290
+ if (!named.has(material)
1291
+ && (subsurfaceMedium(data?.subsurface) || volumeMedium(data?.volume) || bevelRadius(data?.bevel) > 0)) {
1292
+ named.set(material, material.name);
1293
+ material.name = `${material.name}${MEDIA_MARK}${media.length}`;
1294
+ media.push({ subsurface: data?.subsurface, volume: data?.volume, bevel: data?.bevel });
1295
+ }
1149
1296
  }
1150
1297
  });
1151
1298
 
1299
+ let snapshot: SerializedScene;
1300
+
1152
1301
  try {
1153
1302
  // The path tracer samples only the base level of a texture: the mip chain would be built and thrown away.
1154
- return (await serializeScene(scene, { mipmaps: false })).scene;
1303
+ snapshot = (await serializeScene(scene, { mipmaps: false })).scene;
1155
1304
  } finally {
1156
1305
  for (const [material, type] of renamed) material.type = type;
1306
+ for (const [material, name] of named) material.name = name;
1307
+ }
1308
+
1309
+ // Materials are shared between the meshes that use them, so each one is unmarked once and seen marked no more.
1310
+ for (const mesh of snapshot.meshes) {
1311
+ for (const material of mesh.materials as (SubsurfaceMaterial & VolumeMaterial & BevelMaterial)[]) {
1312
+ const at = material.name.lastIndexOf(MEDIA_MARK);
1313
+
1314
+ if (at < 0) continue;
1315
+
1316
+ const medium = media[Number(material.name.slice(at + MEDIA_MARK.length))];
1317
+
1318
+ material.name = material.name.slice(0, at) || material.type;
1319
+ if (medium.subsurface !== undefined) material.subsurface = medium.subsurface as SubsurfaceMaterial["subsurface"];
1320
+ if (medium.volume !== undefined) material.volume = medium.volume as VolumeMaterial["volume"];
1321
+ if (medium.bevel !== undefined) material.bevel = medium.bevel as BevelMaterial["bevel"];
1322
+ }
1157
1323
  }
1324
+
1325
+ return snapshot;
1326
+ }
1327
+
1328
+ /** Ends a material's name while it is serialized, followed by the index of its media. */
1329
+ const MEDIA_MARK = "\u0000media:";
1330
+
1331
+ /**
1332
+ * Everything that changes the finished image without tracing it again, as one string: the denoiser switch and what
1333
+ * happens to the light between the radiance and the screen.
1334
+ */
1335
+ function developing(settings: GpuRenderSettings): string {
1336
+ const { exposure, toneMapping, vignette, bloom, glare, lens, camera, depthOfField, globalIllumination } = settings;
1337
+
1338
+ return JSON.stringify([globalIllumination.denoise, exposure, toneMapping, vignette, bloom, glare,
1339
+ lens.chromaticAberration, camera, camera.physicalExposure ? depthOfField.aperture : 0]);
1158
1340
  }
1159
1341
 
1160
1342
  /**
@@ -47,6 +47,12 @@ export interface SceneBuffers {
47
47
  /** Where the lights' cumulative probabilities and the emissive ones' triangles sit in `textures`. */
48
48
  lightCdf: number;
49
49
  lightTriangles: number;
50
+ /** The light tree over the lights that sit in the scene: see lightTree.ts. */
51
+ localLightCount: number;
52
+ localLightPick: number;
53
+ lightTree: number;
54
+ lightLeaves: number;
55
+ lightList: number;
50
56
  /** Radiance an ambient or hemisphere light adds, which no shadow ray can reach. */
51
57
  ambient: PackedLights["ambient"];
52
58
  /** Where the surrounding image and its distributions ended up, for the frame uniform. */
@@ -197,6 +203,11 @@ export function uploadScene(
197
203
  areaLightCount: lights.areaCount,
198
204
  lightCdf: lights.cdfAt,
199
205
  lightTriangles: lights.trianglesAt,
206
+ localLightCount: lights.localCount,
207
+ localLightPick: lights.localPick,
208
+ lightTree: lights.treeAt,
209
+ lightLeaves: lights.leavesAt,
210
+ lightList: lights.listAt,
200
211
  ambient: lights.ambient,
201
212
  environment: {
202
213
  texture: environment ? textures.indexOf.get(environment.texture.id) ?? NO_TEXTURE : NO_TEXTURE,