@tinypoly/gpu-renderer 0.0.5 → 0.1.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.
Files changed (110) hide show
  1. package/ARCHITECTURE.md +59 -3
  2. package/README.md +52 -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 +15 -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 +43 -0
  26. package/dist/lightTree.d.ts.map +1 -0
  27. package/dist/lightTree.js +107 -0
  28. package/dist/lightTree.js.map +1 -0
  29. package/dist/lights.d.ts +10 -0
  30. package/dist/lights.d.ts.map +1 -1
  31. package/dist/lights.js +54 -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 +19 -7
  42. package/dist/renderer.d.ts.map +1 -1
  43. package/dist/renderer.js +118 -14
  44. package/dist/renderer.js.map +1 -1
  45. package/dist/sceneUpload.d.ts +5 -0
  46. package/dist/sceneUpload.d.ts.map +1 -1
  47. package/dist/sceneUpload.js +4 -0
  48. package/dist/sceneUpload.js.map +1 -1
  49. package/dist/settings.d.ts +136 -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 +315 -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 +203 -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 +396 -25
  79. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  80. package/dist/wgsl/sampling.wgsl.d.ts +1 -1
  81. package/dist/wgsl/sampling.wgsl.d.ts.map +1 -1
  82. package/dist/wgsl/sampling.wgsl.js +25 -0
  83. package/dist/wgsl/sampling.wgsl.js.map +1 -1
  84. package/dist/wgsl/surface.wgsl.d.ts +1 -1
  85. package/dist/wgsl/surface.wgsl.d.ts.map +1 -1
  86. package/dist/wgsl/surface.wgsl.js +21 -1
  87. package/dist/wgsl/surface.wgsl.js.map +1 -1
  88. package/package.json +1 -1
  89. package/src/atmosphere.ts +95 -0
  90. package/src/denoise.ts +44 -6
  91. package/src/frameLayout.ts +15 -0
  92. package/src/geometry.ts +6 -2
  93. package/src/index.ts +13 -0
  94. package/src/lensPass.ts +299 -0
  95. package/src/lightTree.ts +172 -0
  96. package/src/lights.ts +71 -1
  97. package/src/materialLayout.ts +52 -1
  98. package/src/pathTracePass.ts +19 -10
  99. package/src/renderer.ts +166 -22
  100. package/src/sceneUpload.ts +9 -0
  101. package/src/settings.ts +234 -0
  102. package/src/subsurface.ts +153 -0
  103. package/src/volumeContainment.ts +81 -0
  104. package/src/wgsl/bsdf.wgsl.ts +35 -3
  105. package/src/wgsl/common.wgsl.ts +4 -0
  106. package/src/wgsl/lens.wgsl.ts +314 -0
  107. package/src/wgsl/lights.wgsl.ts +203 -31
  108. package/src/wgsl/pathTrace.wgsl.ts +396 -25
  109. package/src/wgsl/sampling.wgsl.ts +25 -0
  110. package/src/wgsl/surface.wgsl.ts +21 -1
@@ -0,0 +1,234 @@
1
+ import { DEFAULT_RENDER_SETTINGS, mergeSettings, type RenderSettings } from "./upstream.js";
2
+
3
+ /**
4
+ * The settings this renderer reads beyond the CPU renderer's own: what makes a path traced image look photographed.
5
+ * They ride in the same object as the shared settings, so a host hands one object to either renderer; the CPU renderer
6
+ * leaves the groups it does not know alone.
7
+ */
8
+ export interface GpuRenderSettings extends Omit<RenderSettings, "globalIllumination"> {
9
+ globalIllumination: RenderSettings["globalIllumination"] & {
10
+ /**
11
+ * Ends a path that carries little light by chance instead of by its bounce count, weighting the survivors up so
12
+ * the mean stays exact. It is what makes a high bounce count affordable.
13
+ */
14
+ russianRoulette: boolean;
15
+ /** Bounces every path takes before the roulette may end it: the first ones carry most of the light. */
16
+ rouletteStart: number;
17
+ /**
18
+ * Picks the light to aim at by what it can deliver to the point being shaded (its power, distance and the way it
19
+ * faces), through a tree over the scene's lights, instead of by its power alone. A small lamp near a surface then
20
+ * gets the samples a large panel far away would have taken.
21
+ */
22
+ lightTree: boolean;
23
+ };
24
+ /**
25
+ * The light a real lens and sensor scatter around anything bright: a soft halo that fades with distance. Energy
26
+ * conserving, with no threshold, so it only shows where something is much brighter than its surroundings.
27
+ */
28
+ bloom: {
29
+ enabled: boolean;
30
+ /** Share of the light the halo takes, `0` to `1`. A real lens scatters a few percent. */
31
+ intensity: number;
32
+ /** How far the halo reaches, `0` to `1`: from a tight glow to one across the frame. */
33
+ radius: number;
34
+ };
35
+ /**
36
+ * The rays that stream from a bright light through a lens: diffraction at the edges of the aperture's blades. An
37
+ * even number of blades makes as many rays, an odd number twice as many. Energy conserving like the bloom, so only
38
+ * light far brighter than its surroundings shows them.
39
+ */
40
+ glare: {
41
+ enabled: boolean;
42
+ /** Share of the light the rays take, `0` to `1`. */
43
+ intensity: number;
44
+ /** Blades of the aperture, `3` to `16`. */
45
+ blades: number;
46
+ /** How far the rays reach, as a share of the frame's half diagonal. */
47
+ length: number;
48
+ /** Degrees the aperture is turned by. */
49
+ rotation: number;
50
+ };
51
+ /** What an actual lens does to the image besides focusing it. */
52
+ lens: {
53
+ /**
54
+ * Lateral chromatic aberration, `0` to `1`: red and blue land slightly apart towards the corners, as through any
55
+ * real glass. `1` shifts them by half a percent of the frame's half diagonal at the corners.
56
+ */
57
+ chromaticAberration: number;
58
+ /** Radial distortion: positive bulges straight lines outwards (barrel), negative pinches them (pincushion). */
59
+ distortion: number;
60
+ };
61
+ /** A camera's exposure triangle and white balance. */
62
+ camera: {
63
+ /**
64
+ * Exposes from `iso`, `shutterSpeed` and `depthOfField.aperture` as a camera would, with `exposure` as a
65
+ * compensation on top. The scene's lights then have to be in physical units (lux, candela, nits).
66
+ */
67
+ physicalExposure: boolean;
68
+ iso: number;
69
+ /** Seconds. */
70
+ shutterSpeed: number;
71
+ /**
72
+ * The colour temperature the camera takes as white, in kelvin. `6500` is neutral; a lower value cools the image,
73
+ * as a camera set for tungsten light does in daylight.
74
+ */
75
+ whiteBalance: number;
76
+ };
77
+ /** Light that enters a material and leaves it somewhere else: skin, wax, marble, leaves, milk. */
78
+ subsurface: {
79
+ /** Materials with `userData.cpuRenderer.subsurface` scatter light inside themselves. Off renders them opaque. */
80
+ enabled: boolean;
81
+ };
82
+ /**
83
+ * Aerial perspective: the air between the camera and what it sees, which fades distant things towards the colour of
84
+ * the sky, bluer for clean air and greyer for haze. It needs a scene in metres to look right.
85
+ */
86
+ atmosphere: {
87
+ enabled: boolean;
88
+ /**
89
+ * Meteorological visibility, in scene units: the distance at which a dark object fades to 2 percent contrast.
90
+ * Twenty kilometres is a clear day, two a hazy one.
91
+ */
92
+ visibility: number;
93
+ /** `0` is clean air, scattering blue far more than red (Rayleigh); `1` is haze, scattering every colour alike. */
94
+ haze: number;
95
+ /** The colour the air glows with, `#rrggbb`; empty takes the average of the environment's light. */
96
+ color: string;
97
+ };
98
+ /** Edges rounded at shading time, where a material asks for it with `userData.cpuRenderer.bevel`. */
99
+ bevel: {
100
+ enabled: boolean;
101
+ };
102
+ /** Meshes filled with a medium: smoke, fog banks, murky water, a beam of light through dust. */
103
+ volumes: {
104
+ /** Meshes whose material has `userData.cpuRenderer.volume` scatter light inside. Off leaves them out. */
105
+ enabled: boolean;
106
+ };
107
+ }
108
+
109
+ /** Any subset of the settings, group by group: `{ bloom: { intensity: 0.1 } }` keeps the other fields. */
110
+ export type GpuRenderSettingsInput = {
111
+ [K in keyof GpuRenderSettings]?: GpuRenderSettings[K] extends object
112
+ ? Partial<GpuRenderSettings[K]>
113
+ : GpuRenderSettings[K];
114
+ };
115
+
116
+ /**
117
+ * The CPU renderer's defaults, with what a photograph has turned on: paths long enough for light to fill a room, a
118
+ * film's tone curve, a lens's halo, rays and colour fringes, the air's haze, and materials and volumes that scatter
119
+ * light inside where the scene asks for it.
120
+ */
121
+ export const DEFAULT_GPU_RENDER_SETTINGS: Readonly<GpuRenderSettings> = Object.freeze(mergeGpuSettings(
122
+ {
123
+ ...DEFAULT_RENDER_SETTINGS,
124
+ // AgX rolls highlights off as film does, where ACES pushes saturated colours to the gamut's edge.
125
+ toneMapping: "agx",
126
+ globalIllumination: { ...DEFAULT_RENDER_SETTINGS.globalIllumination, russianRoulette: true, rouletteStart: 3,
127
+ lightTree: true },
128
+ bloom: { enabled: true, intensity: 0.04, radius: 0.65 },
129
+ glare: { enabled: true, intensity: 0.06, blades: 6, length: 0.25, rotation: 15 },
130
+ lens: { chromaticAberration: 0.2, distortion: 0 },
131
+ camera: { physicalExposure: false, iso: 100, shutterSpeed: 1 / 125, whiteBalance: 6500 },
132
+ subsurface: { enabled: true },
133
+ atmosphere: { enabled: true, visibility: 20000, haze: 0.6, color: "" },
134
+ volumes: { enabled: true },
135
+ bevel: { enabled: true },
136
+ },
137
+ // Cycles' indirect light clamp: a rare bright path through a mirror or off a tiny lamp is cut down to ten, which
138
+ // trades a percent of indirect light for most of the fireflies it would leave.
139
+ { globalIllumination: { bounces: 12, clamp: 10 } }));
140
+
141
+ /** Settings with `input` applied over `base`, group by group, as the CPU renderer's `mergeSettings` does. */
142
+ export function mergeGpuSettings(base: GpuRenderSettings, input: GpuRenderSettingsInput = {}): GpuRenderSettings {
143
+ return mergeSettings(base as unknown as RenderSettings, input as never) as unknown as GpuRenderSettings;
144
+ }
145
+
146
+ /**
147
+ * What a camera's exposure triangle multiplies the scene's luminance by: one over the luminance that saturates the
148
+ * sensor, from the exposure value at ISO 100 (Lagarde and de Rousiers, "Moving Frostbite to PBR", 2014).
149
+ */
150
+ export function physicalExposure(aperture: number, shutterSpeed: number, iso: number): number {
151
+ const n = Math.max(aperture, 0.5), t = Math.max(shutterSpeed, 1e-6), s = Math.max(iso, 1);
152
+ const ev100 = Math.log2(n * n / t * 100 / s);
153
+
154
+ return 1 / (1.2 * 2 ** ev100);
155
+ }
156
+
157
+ /** The exposure the image is multiplied by: the setting itself, or a camera's on top of it as compensation. */
158
+ export function effectiveExposure(settings: GpuRenderSettings): number {
159
+ const { camera, depthOfField, exposure } = settings;
160
+
161
+ return camera.physicalExposure
162
+ ? exposure * physicalExposure(depthOfField.aperture, camera.shutterSpeed, camera.iso)
163
+ : exposure;
164
+ }
165
+
166
+ /** Chromaticity of a black body at this temperature (Kim et al.'s fit of the Planckian locus, 1667 K to 25000 K). */
167
+ function planckian(kelvin: number): [number, number] {
168
+ const t = Math.min(25000, Math.max(1667, kelvin));
169
+
170
+ const x = t <= 4000
171
+ ? -0.2661239e9 / t ** 3 - 0.2343589e6 / t ** 2 + 0.8776956e3 / t + 0.17991
172
+ : -3.0258469e9 / t ** 3 + 2.1070379e6 / t ** 2 + 0.2226347e3 / t + 0.24039;
173
+
174
+ const y = t <= 2222
175
+ ? -1.1063814 * x ** 3 - 1.3481102 * x ** 2 + 2.18555832 * x - 0.20219683
176
+ : t <= 4000
177
+ ? -0.9549476 * x ** 3 - 1.37418593 * x ** 2 + 2.09137015 * x - 0.16748867
178
+ : 3.081758 * x ** 3 - 5.8733867 * x ** 2 + 3.75112997 * x - 0.37001483;
179
+
180
+ return [x, y];
181
+ }
182
+
183
+ type Matrix = number[];
184
+
185
+ const multiply = (a: Matrix, b: Matrix): Matrix => Array.from({ length: 9 }, (_, k) => {
186
+ const row = Math.floor(k / 3), column = k % 3;
187
+
188
+ return a[row * 3] * b[column] + a[row * 3 + 1] * b[3 + column] + a[row * 3 + 2] * b[6 + column];
189
+ });
190
+
191
+ const apply = (m: Matrix, v: number[]) =>
192
+ [0, 1, 2].map(row => m[row * 3] * v[0] + m[row * 3 + 1] * v[1] + m[row * 3 + 2] * v[2]);
193
+
194
+ const SRGB_TO_XYZ = [
195
+ 0.4124564, 0.3575761, 0.1804375,
196
+ 0.2126729, 0.7151522, 0.072175,
197
+ 0.0193339, 0.119192, 0.9503041];
198
+
199
+ const XYZ_TO_SRGB = [
200
+ 3.2404542, -1.5371385, -0.4985314,
201
+ -0.969266, 1.8760108, 0.041556,
202
+ 0.0556434, -0.2040259, 1.0572252];
203
+
204
+ const BRADFORD = [
205
+ 0.8951, 0.2664, -0.1614,
206
+ -0.7502, 1.7135, 0.0367,
207
+ 0.0389, -0.0685, 1.0296];
208
+
209
+ const BRADFORD_INVERSE = [
210
+ 0.9869929, -0.1470543, 0.1599627,
211
+ 0.4323053, 0.5183603, 0.0492912,
212
+ -0.0085287, 0.0400428, 0.9684867];
213
+
214
+ /** The cone response to white light of this temperature. */
215
+ function coneWhite(kelvin: number): number[] {
216
+ const [x, y] = planckian(kelvin);
217
+
218
+ return apply(BRADFORD, [x / y, 1, (1 - x - y) / y]);
219
+ }
220
+
221
+ /** The temperature the image is neutral at: white balance at this value changes nothing. */
222
+ export const NEUTRAL_WHITE_BALANCE = 6500;
223
+
224
+ /**
225
+ * The linear sRGB matrix, row by row, that a camera balanced for this temperature applies: a von Kries scaling of the
226
+ * cone responses (Bradford), which renders light of that temperature as the white of 6500 K. Exactly the identity at
227
+ * 6500 K, since both whites come from the same curve.
228
+ */
229
+ export function whiteBalanceMatrix(kelvin: number): Matrix {
230
+ const source = coneWhite(kelvin), target = coneWhite(NEUTRAL_WHITE_BALANCE);
231
+ const scale = [target[0] / source[0], 0, 0, 0, target[1] / source[1], 0, 0, 0, target[2] / source[2]];
232
+
233
+ return multiply(XYZ_TO_SRGB, multiply(BRADFORD_INVERSE, multiply(scale, multiply(BRADFORD, SRGB_TO_XYZ))));
234
+ }
@@ -0,0 +1,153 @@
1
+ import type { SerializedMaterial } from "./upstream.js";
2
+
3
+ /**
4
+ * How a material scatters light inside itself, from `userData.cpuRenderer.subsurface`. `true` takes every default.
5
+ *
6
+ * The material's own colour (with its map) is what the medium reflects in all once light has wandered through it, as
7
+ * in Blender's Principled BSDF, so a textured skin keeps its texture; `radius` says how far each colour gets before it
8
+ * scatters, which is what makes red bleed through an ear and blue stay at the surface.
9
+ */
10
+ export type SubsurfaceInput = boolean | {
11
+ /** Share of the diffuse layer that scatters inside rather than at the surface, `0` to `1`. Defaults to `1`. */
12
+ weight?: number;
13
+ /**
14
+ * Mean free path of red, green and blue, as multiples of `scale`; one number for all three. Defaults to
15
+ * `[1, 0.2, 0.1]`, Blender's, which suits skin.
16
+ */
17
+ radius?: number | readonly number[];
18
+ /** World units `radius` is measured in. Defaults to `0.05`: five centimetres in a scene modelled in metres. */
19
+ scale?: number;
20
+ /** Henyey-Greenstein anisotropy of each scattering, `-1` (back) to `1` (forward). Defaults to `0`. */
21
+ anisotropy?: number;
22
+ };
23
+
24
+ /** A medium resolved to what the kernel reads. */
25
+ export interface SubsurfaceMedium {
26
+ weight: number;
27
+ /** Mean free path per channel, in world units. */
28
+ radius: [number, number, number];
29
+ anisotropy: number;
30
+ }
31
+
32
+ /** A snapshot material, with the medium `serializeLive` attaches; a snapshot built elsewhere can carry one too. */
33
+ export type SubsurfaceMaterial = SerializedMaterial & { subsurface?: SubsurfaceInput };
34
+
35
+ const DEFAULT_RADIUS: [number, number, number] = [1, 0.2, 0.1];
36
+ const DEFAULT_SCALE = 0.05;
37
+
38
+ const finite = (value: unknown, fallback: number) =>
39
+ typeof value === "number" && Number.isFinite(value) ? value : fallback;
40
+
41
+ /** The medium an input describes, with its defaults, or `null` when it describes none. */
42
+ export function subsurfaceMedium(input: unknown): SubsurfaceMedium | null {
43
+ if (input === true) input = {};
44
+ if (!input || typeof input !== "object") return null;
45
+
46
+ const value = input as Exclude<SubsurfaceInput, boolean>;
47
+ const weight = Math.min(1, Math.max(0, finite(value.weight, 1)));
48
+
49
+ if (weight <= 0) return null;
50
+
51
+ const scale = Math.max(0, finite(value.scale, DEFAULT_SCALE));
52
+
53
+ const radius = typeof value.radius === "number"
54
+ ? [value.radius, value.radius, value.radius]
55
+ : Array.isArray(value.radius) && value.radius.length >= 3
56
+ ? value.radius.slice(0, 3)
57
+ : DEFAULT_RADIUS;
58
+
59
+ // A path that never travels would divide by zero; a hair of distance keeps it a very dense medium instead.
60
+ const paths = radius.map((channel, k) => Math.max(1e-5, finite(channel, DEFAULT_RADIUS[k]) * scale));
61
+
62
+ return {
63
+ weight,
64
+ radius: [paths[0], paths[1], paths[2]],
65
+ anisotropy: Math.min(0.95, Math.max(-0.95, finite(value.anisotropy, 0))),
66
+ };
67
+ }
68
+
69
+ /** The medium a snapshot material carries, if any. */
70
+ export function subsurfaceOf(material: SerializedMaterial | undefined): SubsurfaceMedium | null {
71
+ return subsurfaceMedium((material as SubsurfaceMaterial | undefined)?.subsurface);
72
+ }
73
+
74
+ /**
75
+ * A mesh filled with a medium, from `userData.cpuRenderer.volume`: its surface is only the boundary, invisible, and
76
+ * light scatters and fades in the space it encloses. The mesh has to be closed, with its normals pointing out.
77
+ */
78
+ export type VolumeInput = {
79
+ /** How much of the light the medium takes per scene unit: one over the mean distance a ray gets. Defaults to `1`. */
80
+ density?: number;
81
+ /** What it keeps at each scattering, `#rrggbb` or linear rgb: white for fog and clouds, dark for soot. */
82
+ color?: string | readonly number[];
83
+ /** Henyey-Greenstein anisotropy, `-1` (back) to `1` (forward). Defaults to `0`; haze and dust are forward, ~0.7. */
84
+ anisotropy?: number;
85
+ };
86
+
87
+ export interface VolumeMedium {
88
+ density: number;
89
+ /** Single scattering albedo, linear rgb. */
90
+ albedo: [number, number, number];
91
+ anisotropy: number;
92
+ }
93
+
94
+ /** A snapshot material that may carry a volume, as `serializeLive` attaches it. */
95
+ export type VolumeMaterial = SerializedMaterial & { volume?: VolumeInput };
96
+
97
+ const srgbToLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
98
+
99
+ function albedoOf(color: unknown): [number, number, number] {
100
+ if (Array.isArray(color) && color.length >= 3) {
101
+ return [0, 1, 2].map(k => Math.min(1, Math.max(0, finite(color[k], 1)))) as [number, number, number];
102
+ }
103
+
104
+ const match = typeof color === "string" ? /^#?([0-9a-f]{6})$/i.exec(color) : null;
105
+
106
+ if (!match) return [1, 1, 1];
107
+
108
+ const value = parseInt(match[1], 16);
109
+
110
+ return [(value >> 16) & 255, (value >> 8) & 255, value & 255].map(c => srgbToLinear(c / 255)) as
111
+ [number, number, number];
112
+ }
113
+
114
+ /** The medium an input describes, with its defaults, or `null` when it describes none. */
115
+ export function volumeMedium(input: unknown): VolumeMedium | null {
116
+ if (input === true) input = {};
117
+ if (!input || typeof input !== "object") return null;
118
+
119
+ const value = input as VolumeInput;
120
+ const density = Math.max(0, finite(value.density, 1));
121
+
122
+ if (density <= 0) return null;
123
+
124
+ return {
125
+ density,
126
+ albedo: albedoOf(value.color),
127
+ anisotropy: Math.min(0.95, Math.max(-0.95, finite(value.anisotropy, 0))),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Rounded edges at shading time, from `userData.cpuRenderer.bevel`: the radius, in scene units, over which the
133
+ * shading normal turns from one face to the next, as Cycles' bevel node rounds them. A number, or `{ radius }`.
134
+ */
135
+ export type BevelInput = number | { radius?: number };
136
+
137
+ /** A snapshot material that may carry a bevel, as `serializeLive` attaches it. */
138
+ export type BevelMaterial = SerializedMaterial & { bevel?: BevelInput };
139
+
140
+ /** The bevel radius an input asks for, or zero. */
141
+ export function bevelRadius(input: unknown): number {
142
+ const radius = typeof input === "number" ? input : (input as { radius?: unknown } | null)?.radius;
143
+
144
+ return typeof radius === "number" && Number.isFinite(radius) && radius > 0 ? radius : 0;
145
+ }
146
+
147
+ export function bevelOf(material: SerializedMaterial | undefined): number {
148
+ return bevelRadius((material as BevelMaterial | undefined)?.bevel);
149
+ }
150
+
151
+ export function volumeOf(material: SerializedMaterial | undefined): VolumeMedium | null {
152
+ return volumeMedium((material as VolumeMaterial | undefined)?.volume);
153
+ }
@@ -0,0 +1,81 @@
1
+ import type { PackedGeometry } from "./geometry.js";
2
+ import { MATERIAL_VOLUME } from "./materialLayout.js";
3
+
4
+ /** What the kernel reads as no material at all. */
5
+ export const NO_MATERIAL = 0xffffffff;
6
+
7
+ /** The triangles that bound a volume, each with its material's index. */
8
+ export interface VolumeTriangles {
9
+ /** Nine floats a triangle. */
10
+ positions: Float32Array;
11
+ materials: Uint32Array;
12
+ }
13
+
14
+ /** The boundaries of every volume in a packed scene, or `null` when it has none. */
15
+ export function volumeTriangles(geometry: PackedGeometry): VolumeTriangles | null {
16
+ const volume = geometry.materialProperties.map(properties => (properties.flags & MATERIAL_VOLUME) !== 0);
17
+
18
+ if (!volume.some(Boolean)) return null;
19
+
20
+ const picked: number[] = [];
21
+
22
+ for (let triangle = 0; triangle < geometry.triangleCount; triangle++)
23
+ if (volume[geometry.materials[triangle]]) picked.push(triangle);
24
+
25
+ const positions = new Float32Array(picked.length * 9);
26
+ const materials = new Uint32Array(picked.length);
27
+
28
+ picked.forEach((triangle, k) => {
29
+ positions.set(geometry.positions.subarray(triangle * 9, triangle * 9 + 9), k * 9);
30
+ materials[k] = geometry.materials[triangle];
31
+ });
32
+
33
+ return { positions, materials };
34
+ }
35
+
36
+ /** An irrational direction, so a ray from the camera does not run along an edge of an axis aligned box. */
37
+ const PROBE = [0.4123, 0.7071, 0.5745].map((value, _, all) => value / Math.hypot(...all));
38
+
39
+ /**
40
+ * The volume a point stands inside, by the parity of the boundary crossings of a ray from it: an odd count of one
41
+ * volume's triangles means inside it. The camera usually stands in none, so the kernel only asks this of the camera.
42
+ */
43
+ export function containingVolume(volumes: VolumeTriangles | null, point: readonly number[]): number {
44
+ if (!volumes) return NO_MATERIAL;
45
+
46
+ const crossings = new Map<number, number>();
47
+ const p = volumes.positions;
48
+ const [dx, dy, dz] = PROBE;
49
+
50
+ for (let k = 0; k < volumes.materials.length; k++) {
51
+ const at = k * 9;
52
+ const e1x = p[at + 3] - p[at], e1y = p[at + 4] - p[at + 1], e1z = p[at + 5] - p[at + 2];
53
+ const e2x = p[at + 6] - p[at], e2y = p[at + 7] - p[at + 1], e2z = p[at + 8] - p[at + 2];
54
+ // Moller and Trumbore.
55
+ const hx = dy * e2z - dz * e2y, hy = dz * e2x - dx * e2z, hz = dx * e2y - dy * e2x;
56
+ const det = e1x * hx + e1y * hy + e1z * hz;
57
+
58
+ if (Math.abs(det) < 1e-12) continue;
59
+
60
+ const inverse = 1 / det;
61
+ const sx = point[0] - p[at], sy = point[1] - p[at + 1], sz = point[2] - p[at + 2];
62
+ const u = (sx * hx + sy * hy + sz * hz) * inverse;
63
+
64
+ if (u < 0 || u > 1) continue;
65
+
66
+ const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
67
+ const v = (dx * qx + dy * qy + dz * qz) * inverse;
68
+
69
+ if (v < 0 || u + v > 1) continue;
70
+
71
+ if ((e2x * qx + e2y * qy + e2z * qz) * inverse > 0) {
72
+ const material = volumes.materials[k];
73
+
74
+ crossings.set(material, (crossings.get(material) ?? 0) + 1);
75
+ }
76
+ }
77
+
78
+ for (const [material, count] of crossings) if (count % 2 === 1) return material;
79
+
80
+ return NO_MATERIAL;
81
+ }
@@ -175,6 +175,12 @@ fn coatTransmittance(surface: Surface, view: vec3<f32>) -> f32 {
175
175
  return 1.0 - surface.clearcoat * schlick3(vec3<f32>(0.04), 1.0, nv).x;
176
176
  }
177
177
 
178
+ /**
179
+ * A point inside a volume, where light scatters by the medium's phase function instead of off a surface. Only the
180
+ * integrator makes one: its normal faces back along the ray, its albedo is the medium's, its anisotropy the phase's.
181
+ */
182
+ const KIND_VOLUME: u32 = 100u;
183
+
178
184
  /** How often each lobe is chosen, as (diffuse, specular, clearcoat): by how much of the reflection each accounts for. */
179
185
  fn lobeWeights(surface: Surface, view: vec3<f32>) -> vec3<f32> {
180
186
  let nv = max(dot(surface.normal, view), 1e-4);
@@ -192,7 +198,8 @@ fn lobeWeights(surface: Surface, view: vec3<f32>) -> vec3<f32> {
192
198
 
193
199
  let base = coatTransmittance(surface, view);
194
200
  // The floors keep every lobe reachable: the diffuse one also carries the sheen and the multiple scattering term.
195
- let diffuse = (luminanceOf(surface.albedo * (1.0 - surface.metalness)) + max3(surface.sheenColor)) * base + 0.02;
201
+ let diffuse = (luminanceOf(surface.albedo * ((1.0 - surface.metalness) * (1.0 - surface.subsurface)))
202
+ + max3(surface.sheenColor)) * base + 0.02;
196
203
  let specular = luminanceOf(schlick3(surface.specularColor, surface.specularF90, nv)
197
204
  * (1.0 - surface.metalness) + surface.albedo * surface.metalness) * base + 0.05;
198
205
  let coat = select(0.0, (1.0 - base) + 0.02, surface.clearcoat > 0.0);
@@ -235,7 +242,8 @@ fn physicalValue(surface: Surface, view: vec3<f32>, light: vec3<f32>) -> vec3<f3
235
242
  let average = f0 + (vec3<f32>(1.0) - f0) * 0.047619;
236
243
  let multiple = essV * essL * average / (vec3<f32>(1.0) - emsV * emsL * average + 1e-7) * (emsV * emsL);
237
244
 
238
- var diffuse = surface.albedo * (1.0 - surface.metalness) / PI;
245
+ // What scatters inside the material leaves it elsewhere, by the random walk, not from this point.
246
+ var diffuse = surface.albedo * ((1.0 - surface.metalness) * (1.0 - surface.subsurface)) / PI;
239
247
  var specular = single + multiple;
240
248
 
241
249
  // Three's sheen: its lobe sits on top and takes back the energy it reflects from the layers below.
@@ -344,6 +352,16 @@ fn evaluateBsdf(surface: Surface, view: vec3<f32>, light: vec3<f32>) -> BsdfEval
344
352
  let nl = dot(normal, light);
345
353
  let nv = dot(normal, view);
346
354
 
355
+ // In a medium every direction is open: the phase function stands for the lobes, and samples itself exactly.
356
+ if (surface.kind == KIND_VOLUME) {
357
+ let phase = henyeyGreenstein(dot(-view, light), surface.anisotropy);
358
+
359
+ result.value = surface.albedo * phase;
360
+ result.pdf = phase;
361
+
362
+ return result;
363
+ }
364
+
347
365
  if (nv <= 0.0) {
348
366
  return result;
349
367
  }
@@ -507,6 +525,15 @@ fn sampleBsdf(surface: Surface, view: vec3<f32>, lobe: f32, u: vec2<f32>) -> Bsd
507
525
  result.pdf = 0.0;
508
526
  result.diffuse = true;
509
527
 
528
+ if (surface.kind == KIND_VOLUME) {
529
+ result.direction = sampleHenyeyGreenstein(-view, surface.anisotropy, u);
530
+ result.weight = surface.albedo;
531
+ result.pdf = henyeyGreenstein(dot(-view, result.direction), surface.anisotropy);
532
+ result.valid = true;
533
+
534
+ return result;
535
+ }
536
+
510
537
  if (dot(normal, view) <= 0.0) {
511
538
  return result;
512
539
  }
@@ -574,6 +601,11 @@ fn diffuseValue(surface: Surface, view: vec3<f32>, light: vec3<f32>) -> vec3<f32
574
601
  return surface.albedo / PI * toonRamp(surface.gradientMap, nl);
575
602
  }
576
603
 
604
+ // Light scattered in a medium is all diffuse, as the denoiser sees it.
605
+ if (surface.kind == KIND_VOLUME) {
606
+ return surface.albedo * henyeyGreenstein(dot(-view, light), surface.anisotropy);
607
+ }
608
+
577
609
  if (nl <= 0.0) {
578
610
  return vec3<f32>(0.0);
579
611
  }
@@ -582,7 +614,7 @@ fn diffuseValue(surface: Surface, view: vec3<f32>, light: vec3<f32>) -> vec3<f32
582
614
  return surface.albedo / PI * nl;
583
615
  }
584
616
 
585
- var diffuse = surface.albedo * (1.0 - surface.metalness) / PI;
617
+ var diffuse = surface.albedo * ((1.0 - surface.metalness) * (1.0 - surface.subsurface)) / PI;
586
618
 
587
619
  if (any(surface.sheenColor > vec3<f32>(0.0))) {
588
620
  diffuse = diffuse * (1.0 - max3(surface.sheenColor)
@@ -99,6 +99,10 @@ const MATERIAL_OBJECT_NORMAL_MAP: u32 = 256u;
99
99
  const MATERIAL_PATCHED: u32 = 512u;
100
100
  const MATERIAL_ALPHA_HASH: u32 = 2048u;
101
101
  const MATERIAL_SPECULAR: u32 = 4096u;
102
+ // The mesh only bounds a medium: its surface is invisible and the space inside scatters light.
103
+ const MATERIAL_VOLUME: u32 = 8192u;
104
+ // An unlit material of a fixed colour, which direct sampling aims at: it emits what it shows.
105
+ const MATERIAL_UNLIT_LIGHT: u32 = 16384u;
102
106
 
103
107
  const KIND_STANDARD: u32 = ${MATERIAL_KIND.standard}u;
104
108
  const KIND_PHYSICAL: u32 = ${MATERIAL_KIND.physical}u;