@plasius/gpu-renderer 0.2.45 → 0.2.47

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.
@@ -0,0 +1,550 @@
1
+ import { createGpuRenderer } from "./renderer-webgpu-runtime.js";
2
+ import { nativeSceneShader } from "./native-scene-shader.js";
3
+
4
+ const STRIDE = 12;
5
+ const UNIFORM_BYTES = 256;
6
+ const SHADOW_SIZE = 2048;
7
+ const sub = (a, b) => a.map((v, i) => v - b[i]);
8
+ const cross = (a, b) => [
9
+ a[1] * b[2] - a[2] * b[1],
10
+ a[2] * b[0] - a[0] * b[2],
11
+ a[0] * b[1] - a[1] * b[0],
12
+ ];
13
+ const unit = (a) => {
14
+ const length = Math.hypot(...a);
15
+ return a.map((v) => v / length);
16
+ };
17
+ const finiteVector = (a) =>
18
+ Array.isArray(a) && a.length === 3 && a.every(Number.isFinite);
19
+
20
+ function validateFrame(frame, maxVertices) {
21
+ const { vertices, camera, time = 0 } = frame ?? {};
22
+ if (
23
+ !(vertices instanceof Float32Array) ||
24
+ vertices.length % (STRIDE * 3) !== 0 ||
25
+ vertices.length > maxVertices * STRIDE ||
26
+ !vertices.every(Number.isFinite)
27
+ ) {
28
+ throw new Error(
29
+ `vertices must contain bounded, finite interleaved triangles (12 floats per vertex); received ${vertices?.length ?? 0} floats; first nonfinite component ${vertices instanceof Float32Array ? vertices.findIndex((v) => !Number.isFinite(v)) : "unknown"}.`,
30
+ );
31
+ }
32
+ if (
33
+ !finiteVector(camera?.eye) ||
34
+ !finiteVector(camera?.target) ||
35
+ Math.hypot(...sub(camera.eye, camera.target)) < 0.001 ||
36
+ !Number.isFinite(camera.fov ?? 54) ||
37
+ (camera.fov ?? 54) < 10 ||
38
+ (camera.fov ?? 54) > 120
39
+ ) {
40
+ throw new Error(
41
+ "camera must provide distinct finite eye/target vectors and a 10–120 degree fov.",
42
+ );
43
+ }
44
+ if (!Number.isFinite(time)) throw new Error("time must be finite.");
45
+ if (!Number.isFinite(frame.waterLevel ?? 0))
46
+ throw new Error("waterLevel must be finite.");
47
+ if (
48
+ frame.wakes !== undefined &&
49
+ (!Array.isArray(frame.wakes) ||
50
+ frame.wakes.length > 4 ||
51
+ frame.wakes.some(
52
+ (w) => !Array.isArray(w) || w.length !== 3 || !w.every(Number.isFinite),
53
+ ))
54
+ ) {
55
+ throw new Error(
56
+ "wakes must contain at most four finite [x, z, heading] entries.",
57
+ );
58
+ }
59
+ return { ...frame, time };
60
+ }
61
+
62
+ function frameUniforms(frame, width, height, reflected = false) {
63
+ const eye = [...frame.camera.eye];
64
+ let forward = unit(sub(frame.camera.target, eye));
65
+ const referenceUp = Math.abs(forward[1]) > 0.999 ? [0, 0, 1] : [0, 1, 0];
66
+ const right = unit(cross(forward, referenceUp));
67
+ const up = unit(cross(right, forward));
68
+ const waterLevel = frame.waterLevel ?? 0;
69
+ if (reflected) {
70
+ eye[1] = 2 * waterLevel - eye[1];
71
+ forward = [forward[0], -forward[1], forward[2]];
72
+ right[1] *= -1;
73
+ up[1] *= -1;
74
+ }
75
+ const sun = unit([-0.62, 0.48, 0.62]);
76
+ const lightForward = sun.map((x) => -x);
77
+ const lightRight = unit(cross(lightForward, [0, 1, 0]));
78
+ const lightUp = unit(cross(lightRight, lightForward));
79
+ const uniforms = new Float32Array(64);
80
+ uniforms.set(eye, 0);
81
+ uniforms.set(right, 4);
82
+ uniforms.set(up, 8);
83
+ uniforms.set(forward, 12);
84
+ uniforms.set(
85
+ [
86
+ Math.tan(((frame.camera.fov ?? 54) * Math.PI) / 360),
87
+ width / height,
88
+ frame.time,
89
+ 0.86,
90
+ ],
91
+ 16,
92
+ );
93
+ uniforms.set(sun, 20);
94
+ uniforms.set(lightRight, 24);
95
+ uniforms.set(lightUp, 28);
96
+ uniforms.set(lightForward, 32);
97
+ uniforms.set([sun[0] * 45, sun[1] * 45, sun[2] * 45 + 4], 36);
98
+ uniforms.set([width, height, waterLevel, 0], 40);
99
+ (frame.wakes ?? []).forEach((wake, index) =>
100
+ uniforms.set([...wake, 0], 44 + index * 4),
101
+ );
102
+ uniforms.set([(frame.wakes ?? []).length, reflected ? 1 : 0, 0, 0], 60);
103
+ return uniforms;
104
+ }
105
+
106
+ /** Native raster surfaces. The caller owns simulation, animation and scheduling. */
107
+ export async function createNativeSceneRenderer(options = {}) {
108
+ const maxVertices = options.maxVertices ?? 300_000;
109
+ if (
110
+ !Number.isInteger(maxVertices) ||
111
+ maxVertices < 3 ||
112
+ maxVertices > 600_000
113
+ ) {
114
+ throw new Error("maxVertices must be an integer between 3 and 600000.");
115
+ }
116
+ let base;
117
+ let destroyed = false;
118
+ let available = true;
119
+ let targets = null;
120
+ let vertexBuffer = null;
121
+ let vertexCapacity = 0;
122
+ let pendingFrame = null;
123
+ let submittedFrames = 0;
124
+ let vertexCount = 0;
125
+ const owned = new Set();
126
+ const own = (resource) => {
127
+ owned.add(resource);
128
+ return resource;
129
+ };
130
+ const release = (resource) => {
131
+ resource.destroy();
132
+ owned.delete(resource);
133
+ };
134
+ const destroy = () => {
135
+ if (destroyed) return;
136
+ destroyed = true;
137
+ base?.destroy();
138
+ for (const resource of owned) resource.destroy();
139
+ owned.clear();
140
+ base?.device.destroy?.();
141
+ };
142
+
143
+ try {
144
+ base = await createGpuRenderer({
145
+ ...options,
146
+ alpha: false,
147
+ encodeFrame: encode,
148
+ });
149
+ const { device, format } = base;
150
+ device.lost?.then(() => {
151
+ available = false;
152
+ if (!destroyed)
153
+ options.onUnavailable?.(
154
+ "WebGPU device lost. Reload the demo to reconnect.",
155
+ );
156
+ });
157
+ const module = device.createShaderModule({
158
+ label: "plasius.native-scene",
159
+ code: nativeSceneShader,
160
+ });
161
+ const compilation = await module.getCompilationInfo?.();
162
+ const shaderErrors =
163
+ compilation?.messages.filter((m) => m.type === "error") ?? [];
164
+ if (shaderErrors.length)
165
+ throw new Error(
166
+ `Native scene shader compilation failed: ${shaderErrors.map((m) => `${m.lineNum}: ${m.message}`).join("; ")}`,
167
+ );
168
+ const uniformLayout = device.createBindGroupLayout({
169
+ entries: [
170
+ {
171
+ binding: 0,
172
+ visibility: 3,
173
+ buffer: { type: "uniform", minBindingSize: UNIFORM_BYTES },
174
+ },
175
+ ],
176
+ });
177
+ const shadowLayout = device.createBindGroupLayout({
178
+ entries: [
179
+ { binding: 0, visibility: 2, texture: { sampleType: "depth" } },
180
+ { binding: 1, visibility: 2, sampler: { type: "comparison" } },
181
+ ],
182
+ });
183
+ const reflectionLayout = device.createBindGroupLayout({
184
+ entries: [
185
+ { binding: 0, visibility: 2, texture: { sampleType: "float" } },
186
+ { binding: 1, visibility: 2, sampler: { type: "filtering" } },
187
+ ],
188
+ });
189
+ const uniformBuffer = own(
190
+ device.createBuffer({
191
+ label: "native.frame",
192
+ size: UNIFORM_BYTES,
193
+ usage: 0x48,
194
+ }),
195
+ );
196
+ const reflectionBuffer = own(
197
+ device.createBuffer({
198
+ label: "native.reflected-frame",
199
+ size: UNIFORM_BYTES,
200
+ usage: 0x48,
201
+ }),
202
+ );
203
+ const bindUniform = (buffer) =>
204
+ device.createBindGroup({
205
+ layout: uniformLayout,
206
+ entries: [{ binding: 0, resource: { buffer } }],
207
+ });
208
+ const mainGroup = bindUniform(uniformBuffer);
209
+ const reflectedGroup = bindUniform(reflectionBuffer);
210
+ const shadowTexture = own(
211
+ device.createTexture({
212
+ label: "native.shadow",
213
+ size: [SHADOW_SIZE, SHADOW_SIZE],
214
+ format: "depth32float",
215
+ usage: 0x14,
216
+ }),
217
+ );
218
+ const shadowView = shadowTexture.createView();
219
+ const shadowGroup = device.createBindGroup({
220
+ layout: shadowLayout,
221
+ entries: [
222
+ { binding: 0, resource: shadowView },
223
+ {
224
+ binding: 1,
225
+ resource: device.createSampler({
226
+ compare: "less-equal",
227
+ minFilter: "linear",
228
+ magFilter: "linear",
229
+ }),
230
+ },
231
+ ],
232
+ });
233
+ const reflectionSampler = device.createSampler({
234
+ minFilter: "linear",
235
+ magFilter: "linear",
236
+ });
237
+ const attributes = [0, 1, 2, 3].map((i) => ({
238
+ shaderLocation: i,
239
+ offset: i * 12,
240
+ format: "float32x3",
241
+ }));
242
+ const createPipeline =
243
+ device.createRenderPipelineAsync?.bind(device) ??
244
+ device.createRenderPipeline.bind(device);
245
+ const pipeline = (
246
+ label,
247
+ vertex,
248
+ fragment,
249
+ colourFormat,
250
+ samples,
251
+ layouts,
252
+ geometry = false,
253
+ ) =>
254
+ createPipeline({
255
+ label: `native.${label}`,
256
+ layout: device.createPipelineLayout({ bindGroupLayouts: layouts }),
257
+ vertex: {
258
+ module,
259
+ entryPoint: vertex,
260
+ ...(geometry
261
+ ? {
262
+ buffers: [
263
+ {
264
+ arrayStride: STRIDE * 4,
265
+ attributes:
266
+ vertex === "shadowVertex"
267
+ ? attributes.slice(0, 1)
268
+ : attributes,
269
+ },
270
+ ],
271
+ }
272
+ : {}),
273
+ },
274
+ ...(fragment
275
+ ? {
276
+ fragment: {
277
+ module,
278
+ entryPoint: fragment,
279
+ targets: [{ format: colourFormat }],
280
+ },
281
+ }
282
+ : {}),
283
+ primitive: { topology: "triangle-list", cullMode: "none" },
284
+ depthStencil: {
285
+ format: "depth32float",
286
+ depthWriteEnabled: label !== "sky" && label !== "reflection-sky",
287
+ depthCompare: "less-equal",
288
+ },
289
+ multisample: { count: samples },
290
+ });
291
+ const shadowPipeline = await pipeline(
292
+ "shadow",
293
+ "shadowVertex",
294
+ null,
295
+ null,
296
+ 1,
297
+ [uniformLayout],
298
+ true,
299
+ );
300
+ const surfacePipeline = await pipeline(
301
+ "surface",
302
+ "surfaceVertex",
303
+ "surfaceFragment",
304
+ format,
305
+ 4,
306
+ [uniformLayout, shadowLayout],
307
+ true,
308
+ );
309
+ const skyPipeline = await pipeline(
310
+ "sky",
311
+ "fullscreenVertex",
312
+ "skyFragment",
313
+ format,
314
+ 4,
315
+ [uniformLayout],
316
+ );
317
+ const waterPipeline = await pipeline(
318
+ "water",
319
+ "fullscreenVertex",
320
+ "waterFragment",
321
+ format,
322
+ 4,
323
+ [uniformLayout, shadowLayout, reflectionLayout],
324
+ );
325
+ const reflectedSurfacePipeline = await pipeline(
326
+ "reflection-surface",
327
+ "surfaceVertex",
328
+ "reflectionSurface",
329
+ "rgba16float",
330
+ 1,
331
+ [uniformLayout, shadowLayout],
332
+ true,
333
+ );
334
+ const reflectedSkyPipeline = await pipeline(
335
+ "reflection-sky",
336
+ "fullscreenVertex",
337
+ "reflectionSky",
338
+ "rgba16float",
339
+ 1,
340
+ [uniformLayout],
341
+ );
342
+
343
+ function ensureTargets() {
344
+ const limit = Math.min(device.limits.maxTextureDimension2D, 3840);
345
+ const requestedWidth = Math.max(1, Number(base.canvas.width) || 1);
346
+ const requestedHeight = Math.max(1, Number(base.canvas.height) || 1);
347
+ const scale = Math.min(
348
+ 1,
349
+ limit / requestedWidth,
350
+ limit / requestedHeight,
351
+ Math.sqrt((1920 * 1080) / (requestedWidth * requestedHeight)),
352
+ );
353
+ const width = Math.max(1, Math.floor(requestedWidth * scale));
354
+ const height = Math.max(1, Math.floor(requestedHeight * scale));
355
+ base.canvas.width = width;
356
+ base.canvas.height = height;
357
+ if (targets?.width === width && targets?.height === height) return;
358
+ if (targets) for (const texture of targets.textures) release(texture);
359
+ const reflectionWidth = Math.min(1280, Math.max(1, Math.ceil(width / 2)));
360
+ const reflectionHeight = Math.max(
361
+ 1,
362
+ Math.round((height * reflectionWidth) / width),
363
+ );
364
+ const texture = (
365
+ label,
366
+ w,
367
+ h,
368
+ pixelFormat,
369
+ sampleCount = 1,
370
+ usage = 0x10,
371
+ ) =>
372
+ own(
373
+ device.createTexture({
374
+ label,
375
+ size: [w, h],
376
+ format: pixelFormat,
377
+ sampleCount,
378
+ usage,
379
+ }),
380
+ );
381
+ const colour = texture("native.msaa-colour", width, height, format, 4);
382
+ const depth = texture(
383
+ "native.msaa-depth",
384
+ width,
385
+ height,
386
+ "depth32float",
387
+ 4,
388
+ );
389
+ const reflection = texture(
390
+ "native.reflection",
391
+ reflectionWidth,
392
+ reflectionHeight,
393
+ "rgba16float",
394
+ 1,
395
+ 0x14,
396
+ );
397
+ const reflectionDepth = texture(
398
+ "native.reflection-depth",
399
+ reflectionWidth,
400
+ reflectionHeight,
401
+ "depth32float",
402
+ );
403
+ const reflectionView = reflection.createView();
404
+ targets = {
405
+ width,
406
+ height,
407
+ reflectionWidth,
408
+ reflectionHeight,
409
+ colour: colour.createView(),
410
+ depth: depth.createView(),
411
+ reflection: reflectionView,
412
+ reflectionDepth: reflectionDepth.createView(),
413
+ textures: [colour, depth, reflection, reflectionDepth],
414
+ reflectionGroup: device.createBindGroup({
415
+ layout: reflectionLayout,
416
+ entries: [
417
+ { binding: 0, resource: reflectionView },
418
+ { binding: 1, resource: reflectionSampler },
419
+ ],
420
+ }),
421
+ };
422
+ }
423
+
424
+ function encode({ encoder, view }) {
425
+ const depthAttachment = (depthView) => ({
426
+ view: depthView,
427
+ depthLoadOp: "clear",
428
+ depthClearValue: 1,
429
+ depthStoreOp: "store",
430
+ });
431
+ const drawGeometry = (pass, p, uniforms) => {
432
+ pass.setPipeline(p);
433
+ pass.setBindGroup(0, uniforms);
434
+ if (p !== shadowPipeline) pass.setBindGroup(1, shadowGroup);
435
+ if (vertexCount > 0) {
436
+ pass.setVertexBuffer(0, vertexBuffer);
437
+ pass.draw(vertexCount);
438
+ }
439
+ };
440
+ const shadowPass = encoder.beginRenderPass({
441
+ label: "native.shadow",
442
+ colorAttachments: [],
443
+ depthStencilAttachment: depthAttachment(shadowView),
444
+ });
445
+ drawGeometry(shadowPass, shadowPipeline, mainGroup);
446
+ shadowPass.end();
447
+ const reflectedPass = encoder.beginRenderPass({
448
+ label: "native.reflection",
449
+ colorAttachments: [
450
+ {
451
+ view: targets.reflection,
452
+ loadOp: "clear",
453
+ storeOp: "store",
454
+ clearValue: [0, 0, 0, 1],
455
+ },
456
+ ],
457
+ depthStencilAttachment: depthAttachment(targets.reflectionDepth),
458
+ });
459
+ reflectedPass.setPipeline(reflectedSkyPipeline);
460
+ reflectedPass.setBindGroup(0, reflectedGroup);
461
+ reflectedPass.draw(3);
462
+ drawGeometry(reflectedPass, reflectedSurfacePipeline, reflectedGroup);
463
+ reflectedPass.end();
464
+ const pass = encoder.beginRenderPass({
465
+ label: "native.scene",
466
+ colorAttachments: [
467
+ {
468
+ view: targets.colour,
469
+ resolveTarget: view,
470
+ loadOp: "clear",
471
+ storeOp: "discard",
472
+ clearValue: [0, 0, 0, 1],
473
+ },
474
+ ],
475
+ depthStencilAttachment: depthAttachment(targets.depth),
476
+ });
477
+ pass.setPipeline(skyPipeline);
478
+ pass.setBindGroup(0, mainGroup);
479
+ pass.draw(3);
480
+ if (pendingFrame.water !== false) {
481
+ pass.setPipeline(waterPipeline);
482
+ pass.setBindGroup(1, shadowGroup);
483
+ pass.setBindGroup(2, targets.reflectionGroup);
484
+ pass.draw(3);
485
+ }
486
+ drawGeometry(pass, surfacePipeline, mainGroup);
487
+ pass.end();
488
+ }
489
+
490
+ return {
491
+ render(input) {
492
+ if (destroyed) throw new Error("Native scene renderer was destroyed.");
493
+ if (!available) throw new Error("WebGPU device was lost.");
494
+ pendingFrame = validateFrame(input, maxVertices);
495
+ const data = pendingFrame.vertices;
496
+ ensureTargets();
497
+ if (data.byteLength > vertexCapacity) {
498
+ if (vertexBuffer) release(vertexBuffer);
499
+ vertexCapacity = Math.min(
500
+ maxVertices * STRIDE * 4,
501
+ Math.max(4096, 2 ** Math.ceil(Math.log2(data.byteLength))),
502
+ );
503
+ vertexBuffer = own(
504
+ device.createBuffer({
505
+ label: "native.surface-vertices",
506
+ size: vertexCapacity,
507
+ usage: 0x28,
508
+ }),
509
+ );
510
+ }
511
+ vertexCount = data.length / STRIDE;
512
+ if (data.byteLength) device.queue.writeBuffer(vertexBuffer, 0, data);
513
+ device.queue.writeBuffer(
514
+ uniformBuffer,
515
+ 0,
516
+ frameUniforms(pendingFrame, targets.width, targets.height),
517
+ );
518
+ device.queue.writeBuffer(
519
+ reflectionBuffer,
520
+ 0,
521
+ frameUniforms(
522
+ pendingFrame,
523
+ targets.reflectionWidth,
524
+ targets.reflectionHeight,
525
+ true,
526
+ ),
527
+ );
528
+ base.renderOnce(pendingFrame.time * 1000);
529
+ submittedFrames++;
530
+ return this.getSnapshot();
531
+ },
532
+ getSnapshot() {
533
+ return {
534
+ backend: "webgpu-raster",
535
+ available: available && !destroyed,
536
+ submittedFrames,
537
+ vertexCount,
538
+ shadowMapSize: SHADOW_SIZE,
539
+ samples: 4,
540
+ width: targets?.width ?? 0,
541
+ height: targets?.height ?? 0,
542
+ };
543
+ },
544
+ destroy,
545
+ };
546
+ } catch (error) {
547
+ destroy();
548
+ throw error;
549
+ }
550
+ }