@tinypoly/gpu-renderer 0.0.4 → 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.
- package/ARCHITECTURE.md +81 -10
- package/README.md +53 -5
- package/dist/atmosphere.d.ts +21 -0
- package/dist/atmosphere.d.ts.map +1 -0
- package/dist/atmosphere.js +72 -0
- package/dist/atmosphere.js.map +1 -0
- package/dist/bvh.d.ts +11 -6
- package/dist/bvh.d.ts.map +1 -1
- package/dist/bvh.js +64 -11
- package/dist/bvh.js.map +1 -1
- package/dist/denoise.d.ts +17 -2
- package/dist/denoise.d.ts.map +1 -1
- package/dist/denoise.js +95 -27
- package/dist/denoise.js.map +1 -1
- package/dist/dispatchScheduler.d.ts +11 -4
- package/dist/dispatchScheduler.d.ts.map +1 -1
- package/dist/dispatchScheduler.js +14 -7
- package/dist/dispatchScheduler.js.map +1 -1
- package/dist/frameLayout.d.ts.map +1 -1
- package/dist/frameLayout.js +18 -0
- package/dist/frameLayout.js.map +1 -1
- package/dist/geometry.d.ts.map +1 -1
- package/dist/geometry.js +6 -3
- package/dist/geometry.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lensPass.d.ts +60 -0
- package/dist/lensPass.d.ts.map +1 -0
- package/dist/lensPass.js +235 -0
- package/dist/lensPass.js.map +1 -0
- package/dist/lightTree.d.ts +43 -0
- package/dist/lightTree.d.ts.map +1 -0
- package/dist/lightTree.js +107 -0
- package/dist/lightTree.js.map +1 -0
- package/dist/lights.d.ts +19 -1
- package/dist/lights.d.ts.map +1 -1
- package/dist/lights.js +63 -1
- package/dist/lights.js.map +1 -1
- package/dist/materialLayout.d.ts +24 -0
- package/dist/materialLayout.d.ts.map +1 -1
- package/dist/materialLayout.js +36 -1
- package/dist/materialLayout.js.map +1 -1
- package/dist/pathTracePass.d.ts +6 -3
- package/dist/pathTracePass.d.ts.map +1 -1
- package/dist/pathTracePass.js +16 -10
- package/dist/pathTracePass.js.map +1 -1
- package/dist/renderer.d.ts +19 -7
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +198 -36
- package/dist/renderer.js.map +1 -1
- package/dist/sceneUpload.d.ts +19 -1
- package/dist/sceneUpload.d.ts.map +1 -1
- package/dist/sceneUpload.js +25 -3
- package/dist/sceneUpload.js.map +1 -1
- package/dist/settings.d.ts +136 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +100 -0
- package/dist/settings.js.map +1 -0
- package/dist/subsurface.d.ts +76 -0
- package/dist/subsurface.d.ts.map +1 -0
- package/dist/subsurface.js +70 -0
- package/dist/subsurface.js.map +1 -0
- package/dist/texturePack.d.ts +16 -4
- package/dist/texturePack.d.ts.map +1 -1
- package/dist/texturePack.js +61 -8
- package/dist/texturePack.js.map +1 -1
- package/dist/volumeContainment.d.ts +17 -0
- package/dist/volumeContainment.d.ts.map +1 -0
- package/dist/volumeContainment.js +61 -0
- package/dist/volumeContainment.js.map +1 -0
- package/dist/wgsl/bsdf.wgsl.d.ts +1 -1
- package/dist/wgsl/bsdf.wgsl.d.ts.map +1 -1
- package/dist/wgsl/bsdf.wgsl.js +35 -3
- package/dist/wgsl/bsdf.wgsl.js.map +1 -1
- package/dist/wgsl/bvh.wgsl.d.ts +8 -2
- package/dist/wgsl/bvh.wgsl.d.ts.map +1 -1
- package/dist/wgsl/bvh.wgsl.js +167 -96
- package/dist/wgsl/bvh.wgsl.js.map +1 -1
- package/dist/wgsl/common.wgsl.d.ts.map +1 -1
- package/dist/wgsl/common.wgsl.js +24 -7
- package/dist/wgsl/common.wgsl.js.map +1 -1
- package/dist/wgsl/lens.wgsl.d.ts +17 -0
- package/dist/wgsl/lens.wgsl.d.ts.map +1 -0
- package/dist/wgsl/lens.wgsl.js +315 -0
- package/dist/wgsl/lens.wgsl.js.map +1 -0
- package/dist/wgsl/lights.wgsl.d.ts +1 -1
- package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
- package/dist/wgsl/lights.wgsl.js +206 -33
- package/dist/wgsl/lights.wgsl.js.map +1 -1
- package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
- package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
- package/dist/wgsl/pathTrace.wgsl.js +410 -25
- package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
- package/dist/wgsl/random.wgsl.d.ts +1 -1
- package/dist/wgsl/random.wgsl.d.ts.map +1 -1
- package/dist/wgsl/random.wgsl.js +9 -17
- package/dist/wgsl/random.wgsl.js.map +1 -1
- package/dist/wgsl/sampling.wgsl.d.ts +1 -1
- package/dist/wgsl/sampling.wgsl.d.ts.map +1 -1
- package/dist/wgsl/sampling.wgsl.js +25 -0
- package/dist/wgsl/sampling.wgsl.js.map +1 -1
- package/dist/wgsl/surface.wgsl.d.ts +1 -1
- package/dist/wgsl/surface.wgsl.d.ts.map +1 -1
- package/dist/wgsl/surface.wgsl.js +47 -4
- package/dist/wgsl/surface.wgsl.js.map +1 -1
- package/dist/wgsl/texture.wgsl.d.ts +1 -1
- package/dist/wgsl/texture.wgsl.d.ts.map +1 -1
- package/dist/wgsl/texture.wgsl.js +10 -11
- package/dist/wgsl/texture.wgsl.js.map +1 -1
- package/package.json +1 -1
- package/src/atmosphere.ts +95 -0
- package/src/bvh.ts +81 -12
- package/src/denoise.ts +110 -27
- package/src/dispatchScheduler.ts +16 -7
- package/src/frameLayout.ts +18 -0
- package/src/geometry.ts +6 -2
- package/src/index.ts +13 -0
- package/src/lensPass.ts +299 -0
- package/src/lightTree.ts +172 -0
- package/src/lights.ts +90 -2
- package/src/materialLayout.ts +52 -1
- package/src/pathTracePass.ts +19 -10
- package/src/renderer.ts +258 -43
- package/src/sceneUpload.ts +41 -4
- package/src/settings.ts +234 -0
- package/src/subsurface.ts +153 -0
- package/src/texturePack.ts +80 -13
- package/src/volumeContainment.ts +81 -0
- package/src/wgsl/bsdf.wgsl.ts +35 -3
- package/src/wgsl/bvh.wgsl.ts +167 -96
- package/src/wgsl/common.wgsl.ts +24 -7
- package/src/wgsl/lens.wgsl.ts +314 -0
- package/src/wgsl/lights.wgsl.ts +206 -33
- package/src/wgsl/pathTrace.wgsl.ts +410 -25
- package/src/wgsl/random.wgsl.ts +9 -17
- package/src/wgsl/sampling.wgsl.ts +25 -0
- package/src/wgsl/surface.wgsl.ts +47 -4
- package/src/wgsl/texture.wgsl.ts +10 -11
package/src/settings.ts
ADDED
|
@@ -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
|
+
}
|
package/src/texturePack.ts
CHANGED
|
@@ -10,10 +10,10 @@ import type { SerializedScene, SerializedTexture } from "./upstream.js";
|
|
|
10
10
|
*/
|
|
11
11
|
export interface PackedTextures {
|
|
12
12
|
/**
|
|
13
|
-
* One buffer for all of it: `TEXTURE_WORDS` words per texture
|
|
14
|
-
* the texels each entry points at. A byte texture takes one word per
|
|
15
|
-
* texture takes four, each the bits of one linear channel. Keeping
|
|
16
|
-
* inside the eight storage buffers a WebGPU device has to offer.
|
|
13
|
+
* One buffer for all of it: the lookup tables first (see `TEXTURE_TABLE`), then `TEXTURE_WORDS` words per texture, as
|
|
14
|
+
* `TextureInfo` in `wgsl/common.wgsl.ts`, then the texels each entry points at. A byte texture takes one word per
|
|
15
|
+
* texel, RGBA in the low to high bytes; a float texture takes four, each the bits of one linear channel. Keeping them
|
|
16
|
+
* in one binding is what holds the shader inside the eight storage buffers a WebGPU device has to offer.
|
|
17
17
|
*/
|
|
18
18
|
data: Uint32Array;
|
|
19
19
|
count: number;
|
|
@@ -32,6 +32,62 @@ export interface TextureExtras {
|
|
|
32
32
|
/** Sixteen words per entry: see `TextureInfo` in `wgsl/common.wgsl.ts`. */
|
|
33
33
|
export const TEXTURE_WORDS = 16;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Lookup tables that lead the buffer, at offsets the shader knows as constants: the second Sobol dimension one byte of
|
|
37
|
+
* the index at a time (four tables of 256), then the linear value of each of the 256 sRGB codes. Both replace work the
|
|
38
|
+
* kernel did on every read with the very numbers it computed, so nothing they feed changes.
|
|
39
|
+
*/
|
|
40
|
+
export const SOBOL_TABLE = 0;
|
|
41
|
+
export const SRGB_TABLE = SOBOL_TABLE + 4 * 256;
|
|
42
|
+
/** Where the texture table starts, after the lookup tables. */
|
|
43
|
+
export const TEXTURE_TABLE = SRGB_TABLE + 256;
|
|
44
|
+
|
|
45
|
+
/** The four byte tables of the Sobol sequence's second dimension, whose generator matrix is Pascal's triangle mod 2. */
|
|
46
|
+
function sobolTables(): Uint32Array {
|
|
47
|
+
const directions: number[] = [];
|
|
48
|
+
let direction = 0x80000000;
|
|
49
|
+
|
|
50
|
+
for (let bit = 0; bit < 32; bit++) {
|
|
51
|
+
directions.push(direction >>> 0);
|
|
52
|
+
direction = (direction ^ (direction >>> 1)) >>> 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const tables = new Uint32Array(4 * 256);
|
|
56
|
+
|
|
57
|
+
for (let byte = 0; byte < 4; byte++) {
|
|
58
|
+
for (let value = 0; value < 256; value++) {
|
|
59
|
+
let xor = 0;
|
|
60
|
+
|
|
61
|
+
for (let bit = 0; bit < 8; bit++) if (value & (1 << bit)) xor ^= directions[byte * 8 + bit];
|
|
62
|
+
|
|
63
|
+
tables[byte * 256 + value] = xor >>> 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return tables;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The linear value of every sRGB code, worked out in double precision and rounded once. */
|
|
71
|
+
function srgbTable(): Float32Array {
|
|
72
|
+
return Float32Array.from({ length: 256 }, (_, code) => {
|
|
73
|
+
const value = Math.fround(code / 255);
|
|
74
|
+
|
|
75
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const LOOKUP = (() => {
|
|
80
|
+
const words = new Uint32Array(TEXTURE_TABLE);
|
|
81
|
+
|
|
82
|
+
words.set(sobolTables(), SOBOL_TABLE);
|
|
83
|
+
words.set(new Uint32Array(srgbTable().buffer), SRGB_TABLE);
|
|
84
|
+
|
|
85
|
+
return words;
|
|
86
|
+
})();
|
|
87
|
+
|
|
88
|
+
/** Where a texture's entry starts in the buffer. */
|
|
89
|
+
export const textureEntry = (index: number) => TEXTURE_TABLE + index * TEXTURE_WORDS;
|
|
90
|
+
|
|
35
91
|
/** What a material's map index means when the material has no such map. */
|
|
36
92
|
export const NO_TEXTURE = 0xffffffff;
|
|
37
93
|
|
|
@@ -49,6 +105,9 @@ const MIRRORED_REPEAT = 1002;
|
|
|
49
105
|
/** How many UV sets the geometry carries, and so the highest channel a texture may ask for. */
|
|
50
106
|
export const UV_CHANNELS = 2;
|
|
51
107
|
|
|
108
|
+
/** Whether a word's first byte is its lowest, as it is on the device: then RGBA bytes are already RGBA words. */
|
|
109
|
+
const LITTLE_ENDIAN = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1;
|
|
110
|
+
|
|
52
111
|
const texelCount = (texture: SerializedTexture) => Math.max(0, texture.width) * Math.max(0, texture.height);
|
|
53
112
|
|
|
54
113
|
const isFloat = (texture: SerializedTexture) => texture.data instanceof Float32Array;
|
|
@@ -66,6 +125,7 @@ const isEncoded = (texture: SerializedTexture) => !isFloat(texture) && texture.c
|
|
|
66
125
|
export interface TexturePlan {
|
|
67
126
|
textures: SerializedTexture[];
|
|
68
127
|
indexOf: Map<string, number>;
|
|
128
|
+
/** The lookup tables and the texture table together: where the texels begin. */
|
|
69
129
|
tableWords: number;
|
|
70
130
|
/** Where the words appended after the texels begin. */
|
|
71
131
|
extraOffset: number;
|
|
@@ -80,7 +140,7 @@ export function planTextures(scene: SerializedScene, extraTextures: readonly Ser
|
|
|
80
140
|
.filter(texture => !seen.has(texture.id) && !!seen.add(texture.id));
|
|
81
141
|
|
|
82
142
|
const indexOf = new Map(textures.map((texture, index) => [texture.id, index]));
|
|
83
|
-
const tableWords = Math.max(1, textures.length) * TEXTURE_WORDS;
|
|
143
|
+
const tableWords = TEXTURE_TABLE + Math.max(1, textures.length) * TEXTURE_WORDS;
|
|
84
144
|
const texelWords = textures.reduce((sum, texture) => sum + texelCount(texture) * (isFloat(texture) ? 4 : 1), 0);
|
|
85
145
|
|
|
86
146
|
return { textures, indexOf, tableWords, extraOffset: tableWords + texelWords };
|
|
@@ -100,8 +160,10 @@ export function writeTextures(plan: TexturePlan, extraWords: Uint32Array): Packe
|
|
|
100
160
|
const floats = new Float32Array(data.buffer);
|
|
101
161
|
let texelAt = tableWords;
|
|
102
162
|
|
|
163
|
+
data.set(LOOKUP, 0);
|
|
164
|
+
|
|
103
165
|
textures.forEach((texture, index) => {
|
|
104
|
-
const at = index
|
|
166
|
+
const at = textureEntry(index);
|
|
105
167
|
const texels = texelCount(texture);
|
|
106
168
|
const float = isFloat(texture);
|
|
107
169
|
|
|
@@ -131,13 +193,18 @@ export function writeTextures(plan: TexturePlan, extraWords: Uint32Array): Packe
|
|
|
131
193
|
} else {
|
|
132
194
|
const source = texture.data as Uint8Array;
|
|
133
195
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
196
|
+
if (LITTLE_ENDIAN) {
|
|
197
|
+
// The bytes already sit in the order the words want them: copied whole, as the GPU reads them.
|
|
198
|
+
new Uint8Array(data.buffer, texelAt * 4, texels * 4).set(source.subarray(0, texels * 4));
|
|
199
|
+
} else {
|
|
200
|
+
for (let texel = 0; texel < texels; texel++) {
|
|
201
|
+
const offset = texel * 4;
|
|
202
|
+
|
|
203
|
+
words[texelAt + texel] = source[offset]
|
|
204
|
+
| (source[offset + 1] << 8)
|
|
205
|
+
| (source[offset + 2] << 16)
|
|
206
|
+
| (source[offset + 3] << 24);
|
|
207
|
+
}
|
|
141
208
|
}
|
|
142
209
|
|
|
143
210
|
texelAt += texels;
|
|
@@ -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
|
+
}
|