@tinypoly/gpu-renderer 0.2.1 → 0.2.3

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 (67) hide show
  1. package/dist/environment.d.ts.map +1 -1
  2. package/dist/environment.js +16 -3
  3. package/dist/environment.js.map +1 -1
  4. package/dist/frameLayout.d.ts.map +1 -1
  5. package/dist/frameLayout.js +2 -0
  6. package/dist/frameLayout.js.map +1 -1
  7. package/dist/grass.d.ts +106 -0
  8. package/dist/grass.d.ts.map +1 -0
  9. package/dist/grass.js +350 -0
  10. package/dist/grass.js.map +1 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/materialLayout.d.ts +6 -0
  16. package/dist/materialLayout.d.ts.map +1 -1
  17. package/dist/materialLayout.js +2 -0
  18. package/dist/materialLayout.js.map +1 -1
  19. package/dist/pathTracePass.d.ts +2 -2
  20. package/dist/pathTracePass.d.ts.map +1 -1
  21. package/dist/pathTracePass.js +11 -9
  22. package/dist/pathTracePass.js.map +1 -1
  23. package/dist/renderer.d.ts.map +1 -1
  24. package/dist/renderer.js +14 -3
  25. package/dist/renderer.js.map +1 -1
  26. package/dist/shaderProgram.d.ts.map +1 -1
  27. package/dist/shaderProgram.js +10 -1
  28. package/dist/shaderProgram.js.map +1 -1
  29. package/dist/subsurface.d.ts +2 -0
  30. package/dist/subsurface.d.ts.map +1 -1
  31. package/dist/subsurface.js +3 -2
  32. package/dist/subsurface.js.map +1 -1
  33. package/dist/wgsl/bsdf.wgsl.d.ts +1 -1
  34. package/dist/wgsl/bsdf.wgsl.d.ts.map +1 -1
  35. package/dist/wgsl/bsdf.wgsl.js +39 -3
  36. package/dist/wgsl/bsdf.wgsl.js.map +1 -1
  37. package/dist/wgsl/grass.wgsl.d.ts +14 -0
  38. package/dist/wgsl/grass.wgsl.d.ts.map +1 -0
  39. package/dist/wgsl/grass.wgsl.js +456 -0
  40. package/dist/wgsl/grass.wgsl.js.map +1 -0
  41. package/dist/wgsl/lights.wgsl.d.ts +1 -1
  42. package/dist/wgsl/lights.wgsl.d.ts.map +1 -1
  43. package/dist/wgsl/lights.wgsl.js +5 -0
  44. package/dist/wgsl/lights.wgsl.js.map +1 -1
  45. package/dist/wgsl/pathTrace.wgsl.d.ts +1 -1
  46. package/dist/wgsl/pathTrace.wgsl.d.ts.map +1 -1
  47. package/dist/wgsl/pathTrace.wgsl.js +34 -5
  48. package/dist/wgsl/pathTrace.wgsl.js.map +1 -1
  49. package/dist/wgsl/surface.wgsl.d.ts +1 -1
  50. package/dist/wgsl/surface.wgsl.d.ts.map +1 -1
  51. package/dist/wgsl/surface.wgsl.js +2 -0
  52. package/dist/wgsl/surface.wgsl.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/environment.ts +19 -2
  55. package/src/frameLayout.ts +2 -0
  56. package/src/grass.ts +523 -0
  57. package/src/index.ts +2 -0
  58. package/src/materialLayout.ts +7 -0
  59. package/src/pathTracePass.ts +11 -9
  60. package/src/renderer.ts +21 -6
  61. package/src/shaderProgram.ts +11 -1
  62. package/src/subsurface.ts +3 -2
  63. package/src/wgsl/bsdf.wgsl.ts +39 -3
  64. package/src/wgsl/grass.wgsl.ts +456 -0
  65. package/src/wgsl/lights.wgsl.ts +5 -0
  66. package/src/wgsl/pathTrace.wgsl.ts +34 -5
  67. package/src/wgsl/surface.wgsl.ts +2 -0
@@ -150,6 +150,22 @@ export function environmentDistribution(grid: Float32Array, width: number, heigh
150
150
  return { distribution, total };
151
151
  }
152
152
 
153
+ /**
154
+ * The distribution of an image with no light in it. Its total is zero, which the shader reads as nothing to sample;
155
+ * the rows and columns are uniform only so that the buffer keeps its layout.
156
+ */
157
+ function darkDistribution(width: number, height: number) {
158
+ const distribution = new Float32Array(height + width * height * 2);
159
+
160
+ for (let y = 0; y < height; y++) {
161
+ distribution[y] = (y + 1) / height;
162
+
163
+ for (let x = 0; x < width; x++) distribution[height + y * width + x] = (x + 1) / width;
164
+ }
165
+
166
+ return { distribution, total: 0 };
167
+ }
168
+
153
169
  /**
154
170
  * Loads an equirectangular image and builds everything the shader needs from it. Returns null for an environment that
155
171
  * is not an image, or one whose file could not be read, which leaves the renderer on its gradient.
@@ -209,9 +225,10 @@ export function environmentFromImage(
209
225
 
210
226
  const gridWidth = Math.min(GRID_WIDTH, width);
211
227
  const gridHeight = Math.min(GRID_HEIGHT, height);
212
- const built = environmentDistribution(reduce(scaled, width, height, gridWidth, gridHeight), gridWidth, gridHeight);
213
228
 
214
- if (!built) return null;
229
+ // A black image is still an environment: a night sky lights nothing, and it must not give way to the default one.
230
+ const built = environmentDistribution(reduce(scaled, width, height, gridWidth, gridHeight), gridWidth, gridHeight)
231
+ ?? darkDistribution(gridWidth, gridHeight);
215
232
 
216
233
  const texture: SerializedTexture = {
217
234
  id,
@@ -76,6 +76,8 @@ export const FRAME_LAYOUT = layoutStruct("Frame", [
76
76
  { name: "atmosphereExtinction", type: "vec4f", doc: "rgb is how much of each colour the air takes per scene unit, w the haze." },
77
77
  { name: "atmosphereSun", type: "vec4f", doc: "xyz is the direction towards the brightest directional light." },
78
78
  { name: "atmosphereSunColor", type: "vec4f", doc: "rgb is that light's irradiance, which the air scatters." },
79
+ { name: "grassShells", type: "u32", doc: "Where the hierarchy of the triangles that grow grass starts in the texture buffer, or GRASS_NONE." },
80
+ { name: "grassEntries", type: "u32", doc: "Where those triangles' entries start in the texture buffer." },
79
81
  { name: "clipPlane", type: "vec4f", doc: "A plane the camera ray drops surfaces behind, for planar captures; w = 0 and xyz = 0 is none." },
80
82
  ]);
81
83
 
package/src/grass.ts ADDED
@@ -0,0 +1,523 @@
1
+ import { buildBvh, NODE_WORDS } from "./bvh.js";
2
+ import type { PackedGeometry } from "./geometry.js";
3
+ import { linearAlbedo } from "./subsurface.js";
4
+ import type { SerializedMaterial } from "./upstream.js";
5
+
6
+ /**
7
+ * A lawn grown on a material's surface, from `userData.cpuRenderer.grass`: only the path tracer draws it, and nothing
8
+ * of it is geometry the host holds. `true` takes every default.
9
+ *
10
+ * The blades are real triangles, in a periodic patch of lawn a few metres across that tiles the world on the horizontal
11
+ * plane and follows each triangle's slope by shearing, so the lawn costs the same whatever the size of the surface.
12
+ */
13
+ export type GrassInput = boolean | {
14
+ /** The tallest blade, in scene units. Defaults to `0.09`. */
15
+ height?: number;
16
+ /** Blades per square scene unit. Defaults to `6000`, a full lawn. */
17
+ density?: number;
18
+ /** The widest a blade gets, in scene units. Defaults to `0.005`. */
19
+ width?: number;
20
+ /** How far blades lean and curl over, `0` (upright) to `1`. Defaults to `0.35`. */
21
+ bend?: number;
22
+ /** Colour at the root, `#rrggbb` or linear rgb. */
23
+ color?: string | readonly number[];
24
+ /** Colour at the tip. */
25
+ tipColor?: string | readonly number[];
26
+ /** Colour of the dry blades. */
27
+ dryColor?: string | readonly number[];
28
+ /** Share of dry blades, `0` to `1`. Defaults to `0.12`. */
29
+ dryness?: number;
30
+ /** How much the colour changes from blade to blade and across the lawn, `0` to `1`. Defaults to `0.35`. */
31
+ variation?: number;
32
+ /** How much the lawn thins out in patches, `0` to `1`. Defaults to `0.2`. */
33
+ patchiness?: number;
34
+ /** Share of the light a blade lets through to its far side, `0` to `1`. Defaults to `0.45`. */
35
+ translucency?: number;
36
+ /** Roughness of the blade's waxy surface. Defaults to `0.5`. */
37
+ roughness?: number;
38
+ /** The steepest slope, in degrees, that still grows grass. Defaults to `50`. */
39
+ maxSlope?: number;
40
+ /** Changes the arrangement of the blades. */
41
+ seed?: number;
42
+ };
43
+
44
+ /** A lawn resolved to its defaults. */
45
+ export interface GrassField {
46
+ height: number;
47
+ density: number;
48
+ width: number;
49
+ bend: number;
50
+ color: [number, number, number];
51
+ tipColor: [number, number, number];
52
+ dryColor: [number, number, number];
53
+ dryness: number;
54
+ variation: number;
55
+ patchiness: number;
56
+ translucency: number;
57
+ roughness: number;
58
+ maxSlope: number;
59
+ seed: number;
60
+ }
61
+
62
+ /** A snapshot material that may carry a lawn, as `serializeLive` attaches it. */
63
+ export type GrassMaterial = SerializedMaterial & { grass?: GrassInput };
64
+
65
+ const DEFAULT_COLOR = "#365a1c";
66
+ const DEFAULT_TIP = "#739a42";
67
+ const DEFAULT_DRY = "#a8975a";
68
+
69
+ /** Blades one patch may hold, so a dense lawn cannot take the whole device. */
70
+ const MAX_PATCH_BLADES = 24000;
71
+
72
+ /** Different patches the tiles pick from, which keeps the repetition out of sight. */
73
+ const VARIANTS = 4;
74
+
75
+ const finite = (value: unknown, fallback: number) =>
76
+ typeof value === "number" && Number.isFinite(value) ? value : fallback;
77
+
78
+ const unit = (value: unknown, fallback: number) => Math.min(1, Math.max(0, finite(value, fallback)));
79
+
80
+ /** The lawn an input describes, with its defaults, or `null` when it describes none. */
81
+ export function grassField(input: unknown): GrassField | null {
82
+ if (input === true) input = {};
83
+ if (!input || typeof input !== "object") return null;
84
+
85
+ const value = input as Exclude<GrassInput, boolean>;
86
+ const height = finite(value.height, 0.09);
87
+ const density = finite(value.density, 6000);
88
+
89
+ if (!(height > 0) || !(density > 0)) return null;
90
+
91
+ return {
92
+ height,
93
+ density,
94
+ width: Math.max(1e-4, finite(value.width, 0.005)),
95
+ bend: unit(value.bend, 0.35),
96
+ color: linearAlbedo(value.color ?? DEFAULT_COLOR),
97
+ tipColor: linearAlbedo(value.tipColor ?? DEFAULT_TIP),
98
+ dryColor: linearAlbedo(value.dryColor ?? DEFAULT_DRY),
99
+ dryness: unit(value.dryness, 0.12),
100
+ variation: unit(value.variation, 0.35),
101
+ patchiness: unit(value.patchiness, 0.2),
102
+ translucency: unit(value.translucency, 0.45),
103
+ roughness: Math.min(1, Math.max(0.05, finite(value.roughness, 0.5))),
104
+ maxSlope: Math.min(89, Math.max(0, finite(value.maxSlope, 50))),
105
+ seed: Math.round(finite(value.seed, 0)),
106
+ };
107
+ }
108
+
109
+ export function grassOf(material: SerializedMaterial | undefined): GrassField | null {
110
+ return grassField((material as GrassMaterial | undefined)?.grass);
111
+ }
112
+
113
+ /** What the frame uniform says when no triangle grows grass. */
114
+ export const GRASS_NONE = 0xffffffff;
115
+
116
+ /** Words the kernel reads per patch triangle: its corners, the height along the blade and the side of each, the blade. */
117
+ export const GRASS_TRIANGLE_WORDS = 16;
118
+ /** Words per blade: its root, a random number of its own and the angle its width runs at. */
119
+ export const GRASS_BLADE_WORDS = 4;
120
+ /** Words per lawn: see writeRecord. */
121
+ export const GRASS_RECORD_WORDS = 32;
122
+ /** Words per triangle that grows grass: see writeEntry. */
123
+ export const GRASS_ENTRY_WORDS = 12;
124
+
125
+ /** One patch of lawn, every variant of it: triangles in patch space, with x and z across the tile and y up. */
126
+ export interface GrassPatch {
127
+ tile: number;
128
+ /** The tallest point of any blade. */
129
+ top: number;
130
+ /** Sixteen words a triangle, every variant one after the other in its own hierarchy's leaf order. */
131
+ triangles: Uint32Array;
132
+ blades: Uint32Array;
133
+ /** Each variant's hierarchy, its leaves pointing into `triangles`. */
134
+ nodes: Uint32Array[];
135
+ }
136
+
137
+ /** Mulberry32: small, fast and the same everywhere, so a lawn grows the same way on every machine. */
138
+ function random(seed: number): () => number {
139
+ let state = seed >>> 0;
140
+
141
+ return () => {
142
+ state = (state + 0x6d2b79f5) >>> 0;
143
+ let t = state;
144
+ t = Math.imul(t ^ (t >>> 15), t | 1);
145
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
146
+
147
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
148
+ };
149
+ }
150
+
151
+ /** A normally distributed number, by Box and Muller. */
152
+ const gaussian = (next: () => number) => Math.sqrt(-2 * Math.log(1 - next())) * Math.cos(2 * Math.PI * next());
153
+
154
+ interface Blade {
155
+ x: number;
156
+ z: number;
157
+ rand: number;
158
+ yaw: number;
159
+ /** Corners in patch space relative to the root, and per corner the height along the blade and the side. */
160
+ corners: number[];
161
+ heights: number[];
162
+ sides: number[];
163
+ /** Three corner indices a triangle. */
164
+ faces: number[];
165
+ reach: number;
166
+ }
167
+
168
+ /** How far a blade's tip can get from its root, which the border shared by every variant has to cover. */
169
+ function bladeReach(field: GrassField): number {
170
+ return field.height * (0.15 + 1.1 * field.bend) + field.width * 2;
171
+ }
172
+
173
+ /**
174
+ * One blade: a spine curving away from its root as a quadratic, tapering to a point, with a slight twist. It leans away
175
+ * from the middle of its clump, as grass grows.
176
+ */
177
+ function makeBlade(field: GrassField, next: () => number, x: number, z: number, lean: [number, number]): Blade {
178
+ const height = field.height * (0.45 + 0.55 * Math.pow(next(), 0.6));
179
+ const width = field.width * (0.7 + 0.6 * next());
180
+ const amount = field.bend * (0.35 + 0.75 * next());
181
+ const twist = (next() - 0.5) * 0.9;
182
+ const facing = Math.atan2(lean[1], lean[0]) + Math.PI / 2 + (next() - 0.5) * 0.7;
183
+ const segments = height > 0.18 ? 6 : 4;
184
+ const corners: number[] = [];
185
+ const heights: number[] = [];
186
+ const sides: number[] = [];
187
+ const faces: number[] = [];
188
+ let reach = 0;
189
+
190
+ const spine = (v: number) => {
191
+ const out = amount * height * v * v;
192
+ // A blade that bends over loses height, roughly as an arc of the same length would.
193
+ const up = height * v * (1 - 0.4 * amount * v);
194
+
195
+ return [lean[0] * out, up, lean[1] * out];
196
+ };
197
+
198
+ for (let k = 0; k < segments; k++) {
199
+ const v = k / segments;
200
+ const point = spine(v);
201
+ // Widest a little above the root, closing to the tip.
202
+ const half = width * 0.5 * Math.pow(Math.max(0, 1 - Math.pow(v, 2.2)), 0.6) * (0.8 + 0.2 * Math.min(1, v / 0.15));
203
+ const angle = facing + twist * v;
204
+ const across = [Math.cos(angle) * half, Math.sin(angle) * half];
205
+
206
+ corners.push(point[0] - across[0], point[1], point[2] - across[1],
207
+ point[0] + across[0], point[1], point[2] + across[1]);
208
+ heights.push(v, v);
209
+ sides.push(-1, 1);
210
+ reach = Math.max(reach, Math.hypot(point[0], point[2]) + half);
211
+ }
212
+
213
+ const tip = spine(1);
214
+
215
+ corners.push(tip[0], tip[1], tip[2]);
216
+ heights.push(1);
217
+ sides.push(0);
218
+ reach = Math.max(reach, Math.hypot(tip[0], tip[2]));
219
+
220
+ for (let k = 0; k < segments - 1; k++) {
221
+ const l0 = 2 * k, r0 = 2 * k + 1, l1 = 2 * k + 2, r1 = 2 * k + 3;
222
+
223
+ faces.push(l0, r0, r1, l0, r1, l1);
224
+ }
225
+
226
+ faces.push(2 * (segments - 1), 2 * (segments - 1) + 1, 2 * segments);
227
+
228
+ return { x, z, rand: next(), yaw: facing + twist * 0.5, corners, heights, sides, faces, reach };
229
+ }
230
+
231
+ /**
232
+ * Every blade of one arrangement over the tile: clumps on a jittered grid, each a handful of blades scattered around its
233
+ * middle, wrapped around the tile so the arrangement repeats without a seam.
234
+ */
235
+ function arrangement(field: GrassField, tile: number, seed: number, count: number): Blade[] {
236
+ const next = random(seed);
237
+ const spacing = Math.max(0.025, Math.min(0.12, field.height * 0.7));
238
+ const cells = Math.max(1, Math.round(tile / spacing));
239
+ const step = tile / cells;
240
+ const perClump = count / (cells * cells);
241
+ const blades: Blade[] = [];
242
+
243
+ for (let i = 0; i < cells; i++) {
244
+ for (let j = 0; j < cells; j++) {
245
+ const cx = (i + next()) * step;
246
+ const cz = (j + next()) * step;
247
+ // A whole number of blades on average, with the fraction drawn.
248
+ const whole = Math.floor(perClump);
249
+ const n = whole + (next() < perClump - whole ? 1 : 0);
250
+
251
+ for (let b = 0; b < n; b++) {
252
+ const dx = gaussian(next) * step * 0.4;
253
+ const dz = gaussian(next) * step * 0.4;
254
+ const away = Math.hypot(dx, dz);
255
+ const angle = next() * Math.PI * 2;
256
+ // Away from the middle of the clump, with some of every direction mixed in.
257
+ const mixX = (away > 1e-6 ? dx / away : 0) * 0.7 + Math.cos(angle) * 0.5;
258
+ const mixZ = (away > 1e-6 ? dz / away : 0) * 0.7 + Math.sin(angle) * 0.5;
259
+ const length = Math.hypot(mixX, mixZ) || 1;
260
+ const x = ((cx + dx) % tile + tile) % tile;
261
+ const z = ((cz + dz) % tile + tile) % tile;
262
+
263
+ blades.push(makeBlade(field, next, x, z, [mixX / length, mixZ / length]));
264
+ }
265
+ }
266
+ }
267
+
268
+ return blades;
269
+ }
270
+
271
+ /** The patches already built, by the parameters that shape them: a scene packed again reuses them. */
272
+ const patches = new Map<string, GrassPatch>();
273
+
274
+ /**
275
+ * The patch of a lawn. The band along the tile's border, as wide as a blade reaches, is the same in every variant and
276
+ * wraps around, so any variant meets any other without a seam; inside it each variant grows its own blades.
277
+ */
278
+ export function grassPatch(field: GrassField): GrassPatch {
279
+ const key = JSON.stringify([field.height, field.density, field.width, field.bend, field.seed]);
280
+ const cached = patches.get(key);
281
+
282
+ if (cached) return cached;
283
+
284
+ const reach = bladeReach(field);
285
+ const tile = Math.max(1, reach * 4);
286
+ const count = Math.min(MAX_PATCH_BLADES, Math.round(field.density * tile * tile));
287
+ const inBorder = (blade: Blade) => Math.min(blade.x, tile - blade.x, blade.z, tile - blade.z) < reach;
288
+ const border = arrangement(field, tile, field.seed * 7919 + 1, count).filter(inBorder);
289
+ const triangles: number[] = [];
290
+ const blades: number[] = [];
291
+ const floats = new Float32Array(1);
292
+ const words = new Uint32Array(floats.buffer);
293
+ const word = (value: number) => (floats[0] = value, words[0]);
294
+ const nodes: Uint32Array[] = [];
295
+ let top = 0;
296
+
297
+ for (let variant = 0; variant < VARIANTS; variant++) {
298
+ const inner = arrangement(field, tile, field.seed * 7919 + 101 + variant * 31, count)
299
+ .filter(blade => !inBorder(blade));
300
+
301
+ // A blade over the border also shows, shifted by a whole tile, in the tiles on the other side of it.
302
+ const placed: { blade: Blade; ox: number; oz: number }[] = [];
303
+
304
+ for (const blade of [...border, ...inner]) {
305
+ for (const ox of [-tile, 0, tile]) {
306
+ for (const oz of [-tile, 0, tile]) {
307
+ const x = blade.x + ox, z = blade.z + oz;
308
+
309
+ if (x + blade.reach < 0 || x - blade.reach > tile || z + blade.reach < 0 || z - blade.reach > tile) continue;
310
+
311
+ placed.push({ blade, ox, oz });
312
+ }
313
+ }
314
+ }
315
+
316
+ const positions: number[] = [];
317
+ const records: number[][] = [];
318
+
319
+ for (const { blade, ox, oz } of placed) {
320
+ const index = blades.length / GRASS_BLADE_WORDS;
321
+ const x = blade.x + ox, z = blade.z + oz;
322
+
323
+ blades.push(word(x), word(z), word(blade.rand), word(blade.yaw));
324
+
325
+ for (let f = 0; f < blade.faces.length; f += 3) {
326
+ const record: number[] = [];
327
+
328
+ for (let c = 0; c < 3; c++) {
329
+ const corner = blade.faces[f + c];
330
+ const px = blade.corners[corner * 3] + x;
331
+ const py = blade.corners[corner * 3 + 1];
332
+ const pz = blade.corners[corner * 3 + 2] + z;
333
+
334
+ positions.push(px, py, pz);
335
+ record.push(px, py, pz);
336
+ top = Math.max(top, py);
337
+ }
338
+
339
+ for (let c = 0; c < 3; c++) record.push(blade.heights[blade.faces[f + c]]);
340
+ for (let c = 0; c < 3; c++) record.push(blade.sides[blade.faces[f + c]]);
341
+
342
+ records.push([...record, index]);
343
+ }
344
+ }
345
+
346
+ const bvh = buildBvh(new Float32Array(positions), records.length);
347
+ const start = triangles.length / GRASS_TRIANGLE_WORDS;
348
+
349
+ for (let at = 0; at < bvh.order.length; at++) {
350
+ const record = records[bvh.order[at]];
351
+
352
+ for (let k = 0; k < 15; k++) triangles.push(word(record[k]));
353
+ triangles.push(record[15]);
354
+ }
355
+
356
+ nodes.push(offsetLeaves(bvh.nodes, start));
357
+ }
358
+
359
+ const patch: GrassPatch = {
360
+ tile,
361
+ top,
362
+ triangles: new Uint32Array(triangles),
363
+ blades: new Uint32Array(blades),
364
+ nodes,
365
+ };
366
+
367
+ patches.set(key, patch);
368
+
369
+ return patch;
370
+ }
371
+
372
+ /** A hierarchy's nodes with every leaf moved along by `start` entries, so several can share one list. */
373
+ function offsetLeaves(source: ArrayBuffer, start: number): Uint32Array {
374
+ const nodes = new Int32Array(source.slice(0));
375
+
376
+ for (let at = 0; at < nodes.length; at += NODE_WORDS) {
377
+ for (const link of [3, 11]) {
378
+ if (nodes[at + link] < 0 && nodes[at + link + 4] > 0) nodes[at + link] = -1 - (-1 - nodes[at + link] + start);
379
+ }
380
+ }
381
+
382
+ return new Uint32Array(nodes.buffer);
383
+ }
384
+
385
+ /** What packGrass adds to the texture buffer's extra words, and where its root is. */
386
+ export interface PackedGrass {
387
+ words: Uint32Array;
388
+ /** Where the hierarchy of the triangles that grow grass starts, in the texture buffer. */
389
+ shells: number;
390
+ /** Where their entries start. */
391
+ entries: number;
392
+ /** How many triangles grow grass. */
393
+ count: number;
394
+ }
395
+
396
+ /**
397
+ * The lawns of a packed scene, laid out to sit at `base` in the texture buffer: each lawn's record, patch triangles,
398
+ * blades and hierarchies, then one entry per triangle that grows grass and the hierarchy over those triangles. `null`
399
+ * when nothing grows any.
400
+ */
401
+ export function packGrass(geometry: PackedGeometry, base: number): PackedGrass | null {
402
+ const fields = geometry.materialProperties.map(properties => properties.grass ?? null);
403
+
404
+ if (!fields.some(Boolean)) return null;
405
+
406
+ const words: number[] = [];
407
+ const floats = new Float32Array(1);
408
+ const view = new Uint32Array(floats.buffer);
409
+ const word = (value: number) => (floats[0] = value, view[0]);
410
+ const records = new Map<number, { at: number; field: GrassField; patch: GrassPatch }>();
411
+
412
+ fields.forEach((field, material) => {
413
+ if (!field) return;
414
+
415
+ const patch = grassPatch(field);
416
+ const at = base + words.length;
417
+ const trianglesAt = at + GRASS_RECORD_WORDS;
418
+ const bladesAt = trianglesAt + patch.triangles.length;
419
+ let nodesAt = bladesAt + patch.blades.length;
420
+ const roots: number[] = [];
421
+
422
+ for (const nodes of patch.nodes) {
423
+ roots.push(nodesAt);
424
+ nodesAt += nodes.length;
425
+ }
426
+
427
+ const record = new Array<number>(GRASS_RECORD_WORDS).fill(0);
428
+
429
+ record[0] = word(patch.tile);
430
+ record[1] = word(patch.top);
431
+ record[2] = patch.nodes.length;
432
+ record[3] = trianglesAt;
433
+ record[4] = bladesAt;
434
+ roots.forEach((root, k) => (record[5 + k] = root));
435
+ field.color.forEach((channel, k) => (record[9 + k] = word(channel)));
436
+ field.tipColor.forEach((channel, k) => (record[12 + k] = word(channel)));
437
+ field.dryColor.forEach((channel, k) => (record[15 + k] = word(channel)));
438
+ record[18] = word(field.dryness);
439
+ record[19] = word(field.translucency);
440
+ record[20] = word(field.roughness);
441
+ record[21] = word(field.variation);
442
+ record[22] = word(field.patchiness);
443
+ // The size of the patches the lawn thins out in and changes colour over, a few metres, larger for taller grass.
444
+ record[23] = word(1 / Math.max(1.5, field.height * 30));
445
+
446
+ words.push(...record);
447
+ pushAll(words, patch.triangles);
448
+ pushAll(words, patch.blades);
449
+ for (const nodes of patch.nodes) pushAll(words, nodes);
450
+
451
+ records.set(material, { at, field, patch });
452
+ });
453
+
454
+ const entries: number[][] = [];
455
+ const boxes: number[] = [];
456
+ const p = geometry.positions;
457
+ const n = geometry.normals;
458
+
459
+ for (let triangle = 0; triangle < geometry.triangleCount; triangle++) {
460
+ const lawn = records.get(geometry.materials[triangle]);
461
+
462
+ if (!lawn) continue;
463
+
464
+ const at = triangle * 9;
465
+ const e1 = [p[at + 3] - p[at], p[at + 4] - p[at + 1], p[at + 5] - p[at + 2]];
466
+ const e2 = [p[at + 6] - p[at], p[at + 7] - p[at + 1], p[at + 8] - p[at + 2]];
467
+ const geometric = [e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]];
468
+ const length = Math.hypot(geometric[0], geometric[1], geometric[2]);
469
+
470
+ if (length < 1e-12) continue;
471
+
472
+ // The side the surface faces: its vertex normals where it has them, which survive a winding turned around.
473
+ const authored = [0, 1, 2].map(axis => n[at + axis] + n[at + 3 + axis] + n[at + 6 + axis]);
474
+ const facing = Math.hypot(authored[0], authored[1], authored[2]) > 1e-6 ? authored[1] : geometric[1];
475
+ const up = Math.abs(geometric[1]) / length;
476
+
477
+ if (facing <= 0 || up < Math.cos(lawn.field.maxSlope * Math.PI / 180)) continue;
478
+
479
+ // The plane as a height over the ground: y = p0 + sx x + sz z.
480
+ const sx = -geometric[0] / geometric[1];
481
+ const sz = -geometric[2] / geometric[1];
482
+ const p0 = p[at + 1] - sx * p[at] - sz * p[at + 2];
483
+ const a = [p[at], p[at + 2]];
484
+ let b = [p[at + 3], p[at + 5]], c = [p[at + 6], p[at + 8]];
485
+
486
+ // Anticlockwise on the ground, which the kernel's edge test assumes.
487
+ if ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) < 0) [b, c] = [c, b];
488
+
489
+ entries.push([lawn.at, a[0], a[1], b[0], b[1], c[0], c[1], p0, sx, sz]);
490
+
491
+ const low = [Math.min(a[0], b[0], c[0]), Math.min(p[at + 1], p[at + 4], p[at + 7]), Math.min(a[1], b[1], c[1])];
492
+
493
+ const high = [Math.max(a[0], b[0], c[0]), Math.max(p[at + 1], p[at + 4], p[at + 7]) + lawn.patch.top,
494
+ Math.max(a[1], b[1], c[1])];
495
+
496
+ const middle = low.map((value, axis) => (value + high[axis]) / 2);
497
+
498
+ // A stand-in triangle with the shell's box as its bounds and its middle as its centroid.
499
+ boxes.push(...low, ...high, ...middle);
500
+ }
501
+
502
+ if (!entries.length) return null;
503
+
504
+ const bvh = buildBvh(new Float32Array(boxes), entries.length);
505
+ const entriesAt = base + words.length;
506
+
507
+ for (let at = 0; at < bvh.order.length; at++) {
508
+ const entry = entries[bvh.order[at]];
509
+
510
+ words.push(entry[0], ...entry.slice(1).map(word), 0, 0);
511
+ }
512
+
513
+ const shells = base + words.length;
514
+
515
+ pushAll(words, new Uint32Array(bvh.nodes));
516
+
517
+ return { words: new Uint32Array(words), shells, entries: entriesAt, count: entries.length };
518
+ }
519
+
520
+ /** Appends a typed array without spreading it, which a large patch would overflow the stack with. */
521
+ function pushAll(target: number[], source: Uint32Array) {
522
+ for (let i = 0; i < source.length; i++) target.push(source[i]);
523
+ }
package/src/index.ts CHANGED
@@ -33,6 +33,8 @@ export {
33
33
 
34
34
  export type { GpuRenderSettings, GpuRenderSettingsInput } from "./settings.js";
35
35
  export { subsurfaceMedium } from "./subsurface.js";
36
+ export { grassField } from "./grass.js";
37
+ export type { GrassField, GrassInput, GrassMaterial } from "./grass.js";
36
38
  export type { SubsurfaceInput, SubsurfaceMaterial, SubsurfaceMedium } from "./subsurface.js";
37
39
 
38
40
  export type { Bucket, Listener } from "./upstream.js";
@@ -1,4 +1,5 @@
1
1
  import { layoutStruct, writeStruct, type StructField } from "./structLayout.js";
2
+ import { grassOf, type GrassField } from "./grass.js";
2
3
  import { bevelOf, subsurfaceOf, volumeOf } from "./subsurface.js";
3
4
  import { NO_TEXTURE } from "./texturePack.js";
4
5
  import type { SerializedMaterial, UniformValue } from "./upstream.js";
@@ -74,6 +75,11 @@ export interface MaterialProperties {
74
75
  volumeAnisotropy: number;
75
76
  /** The radius over which edges are rounded at shading time, in scene units; zero leaves them sharp. */
76
77
  bevel: number;
78
+ /**
79
+ * The lawn grown on the surface, from `userData.cpuRenderer.grass`. It lives in the texture buffer's extra words
80
+ * rather than in the struct: see grass.ts.
81
+ */
82
+ grass?: GrassField | null;
77
83
  /** An index into the texture table per slot, or `NO_TEXTURE`. */
78
84
  maps: Record<MaterialMap, number>;
79
85
  }
@@ -376,6 +382,7 @@ export function materialProperties(
376
382
  volumeDensity: volume?.density ?? 0,
377
383
  volumeAnisotropy: volume?.anisotropy ?? 0,
378
384
  bevel: LIT.has(type) ? bevelOf(material) : 0,
385
+ grass: LIT.has(type) ? grassOf(material) : null,
379
386
  maps,
380
387
  };
381
388
  }
@@ -6,6 +6,7 @@ import { writeStruct } from "./structLayout.js";
6
6
  import { BSDF_WGSL } from "./wgsl/bsdf.wgsl.js";
7
7
  import { BVH_WGSL } from "./wgsl/bvh.wgsl.js";
8
8
  import { COMMON_WGSL } from "./wgsl/common.wgsl.js";
9
+ import { GRASS_STUB_WGSL, GRASS_WGSL } from "./wgsl/grass.wgsl.js";
9
10
  import { ENVIRONMENT_WGSL } from "./wgsl/environment.wgsl.js";
10
11
  import { LIGHTS_WGSL } from "./wgsl/lights.wgsl.js";
11
12
  import { PATH_TRACE_WGSL } from "./wgsl/pathTrace.wgsl.js";
@@ -25,12 +26,13 @@ export type FrameUniforms = Record<keyof typeof FRAME_LAYOUT.offsets & string, n
25
26
  */
26
27
  export function pathTraceSource(
27
28
  programs: ShaderPrograms | null = null, subsurface = true, volumes = true, bevel = true,
28
- portals = true): string {
29
+ portals = true, grass = true): string {
29
30
  // The random walk inside materials and the media inside volumes are compiled in only for a scene that has them: the
30
31
  // kernel is bound by its size, and either would cost every other scene a few percent for code that never runs.
31
32
  return `const SUBSURFACE_WALK: bool = ${subsurface};\nconst VOLUMES: bool = ${volumes};\nconst BEVEL: bool = ${bevel};\n`
32
- + `const PORTALS: bool = ${portals};\n` + COMMON_WGSL + RANDOM_WGSL + BVH_WGSL + SAMPLING_WGSL + TEXTURE_WGSL + SHADER_SUPPORT_WGSL
33
- + programsWgsl(programs) + ENVIRONMENT_WGSL + SURFACE_WGSL + BSDF_WGSL + LIGHTS_WGSL + PATH_TRACE_WGSL;
33
+ + `const PORTALS: bool = ${portals};\nconst GRASS: bool = ${grass};\n` + COMMON_WGSL + RANDOM_WGSL + BVH_WGSL
34
+ + SAMPLING_WGSL + TEXTURE_WGSL + SHADER_SUPPORT_WGSL + programsWgsl(programs) + ENVIRONMENT_WGSL + SURFACE_WGSL
35
+ + (grass ? GRASS_WGSL : GRASS_STUB_WGSL) + BSDF_WGSL + LIGHTS_WGSL + PATH_TRACE_WGSL;
34
36
  }
35
37
 
36
38
  const FRAME_BYTES = FRAME_WORDS * 4;
@@ -45,7 +47,7 @@ export class PathTracePass {
45
47
  private programKey = "|false|false|false|false|false";
46
48
 
47
49
  constructor(private readonly device: GPUDevice) {
48
- this.pipeline = this.build(null, false, false, false, false);
50
+ this.pipeline = this.build(null, false, false, false, false, false);
49
51
  this.uniforms = device.createBuffer({ size: FRAME_BYTES, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
50
52
  }
51
53
 
@@ -54,20 +56,20 @@ export class PathTracePass {
54
56
  * the same set keeps the pipeline it has.
55
57
  */
56
58
  setPrograms(programs: ShaderPrograms | null, subsurface = false, volumes = false, bevel = false,
57
- portals = false) {
58
- const key = `${programs?.key ?? ""}|${subsurface}|${volumes}|${bevel}|${portals}`;
59
+ portals = false, grass = false) {
60
+ const key = `${programs?.key ?? ""}|${subsurface}|${volumes}|${bevel}|${portals}|${grass}`;
59
61
 
60
62
  if (key === this.programKey) return;
61
63
 
62
64
  this.programKey = key;
63
- this.pipeline = this.build(programs, subsurface, volumes, bevel, portals);
65
+ this.pipeline = this.build(programs, subsurface, volumes, bevel, portals, grass);
64
66
  this.bindGroup = null;
65
67
  }
66
68
 
67
69
  private build(programs: ShaderPrograms | null, subsurface: boolean, volumes: boolean,
68
- bevel: boolean, portals: boolean): GPUComputePipeline {
70
+ bevel: boolean, portals: boolean, grass: boolean): GPUComputePipeline {
69
71
  const module = createShaderModule(this.device, "path trace",
70
- pathTraceSource(programs, subsurface, volumes, bevel, portals));
72
+ pathTraceSource(programs, subsurface, volumes, bevel, portals, grass));
71
73
 
72
74
  return this.device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } });
73
75
  }