@plasius/gpu-renderer 0.2.47 → 0.2.49
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/CHANGELOG.md +54 -0
- package/README.md +201 -0
- package/dist/index.cjs +8273 -7876
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +8272 -7876
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.d.ts +38 -1
- package/src/index.js +2 -0
- package/src/native-scene-renderer.js +152 -7
- package/src/native-scene-shader.js +68 -18
- package/src/native-surface-materials.js +124 -0
- package/src/wavefront-compute.js +10 -2
- package/src/wavefront-config.js +13 -2
- package/src/wavefront-frame-dispatcher.js +12 -8
- package/src/wavefront-frame-encoder.js +7 -0
- package/src/wavefront-progressive-shader.js +25 -0
- package/src/wavefront-progressive.js +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plasius/gpu-renderer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.49",
|
|
4
4
|
"description": "Framework-agnostic WebGPU renderer runtime for Plasius projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
}
|
|
90
90
|
],
|
|
91
91
|
"overrides": {
|
|
92
|
-
"brace-expansion": "5.0.
|
|
92
|
+
"brace-expansion": "5.0.12",
|
|
93
93
|
"esbuild": "^0.28.1",
|
|
94
94
|
"minimatch": "^10.2.1"
|
|
95
95
|
},
|
package/src/index.d.ts
CHANGED
|
@@ -869,6 +869,7 @@ export interface WavefrontEnvironmentMapSnapshot {
|
|
|
869
869
|
}
|
|
870
870
|
|
|
871
871
|
export interface WavefrontPathTracingComputeConfig {
|
|
872
|
+
readonly progressive: boolean;
|
|
872
873
|
readonly mode: typeof rendererWavefrontComputeMode;
|
|
873
874
|
readonly width: number;
|
|
874
875
|
readonly height: number;
|
|
@@ -1036,6 +1037,7 @@ export interface WavefrontPathTracingMemoryEstimate {
|
|
|
1036
1037
|
readonly queuePairBytes: number;
|
|
1037
1038
|
readonly hitBytes: number;
|
|
1038
1039
|
readonly accumulationBytes: number;
|
|
1040
|
+
readonly progressiveBytes: number;
|
|
1039
1041
|
readonly pathVertexBytes: number;
|
|
1040
1042
|
readonly sceneObjectBytes: number;
|
|
1041
1043
|
readonly triangleBytes: number;
|
|
@@ -1097,6 +1099,8 @@ export interface WavefrontGpuParallelismDiagnostics {
|
|
|
1097
1099
|
}
|
|
1098
1100
|
|
|
1099
1101
|
export interface CreateWavefrontPathTracingComputeRendererOptions {
|
|
1102
|
+
/** Accumulate linear radiance between frames until camera/scene/reset changes. Default false. */
|
|
1103
|
+
readonly progressive?: boolean;
|
|
1100
1104
|
readonly canvas?: HTMLCanvasElement | string;
|
|
1101
1105
|
readonly navigator?: Navigator | { gpu?: GPU };
|
|
1102
1106
|
readonly document?: Document;
|
|
@@ -1160,7 +1164,8 @@ export interface WavefrontPathTracingComputeRenderer {
|
|
|
1160
1164
|
readonly device: GPUDevice;
|
|
1161
1165
|
readonly format: GPUTextureFormat | string;
|
|
1162
1166
|
readonly config: WavefrontPathTracingComputeConfig;
|
|
1163
|
-
renderOnce(): WavefrontPathTracingComputeFrameStats;
|
|
1167
|
+
renderOnce(options?: { samplesPerPixel?: number }): WavefrontPathTracingComputeFrameStats;
|
|
1168
|
+
resetAccumulation(): void;
|
|
1164
1169
|
renderFrame(options?: {
|
|
1165
1170
|
readStats?: boolean;
|
|
1166
1171
|
readOutputProbe?: boolean;
|
|
@@ -1182,6 +1187,8 @@ export interface WavefrontPathTracingComputeRenderer {
|
|
|
1182
1187
|
updateSceneObjects(sceneObjects: readonly WavefrontSceneObjectInput[]): WavefrontPathTracingComputeConfig;
|
|
1183
1188
|
updateCamera(camera: WavefrontCameraOptions): WavefrontPathTracingComputeConfig;
|
|
1184
1189
|
getSnapshot(): Readonly<{
|
|
1190
|
+
/** Submitted samples; await GPU completion before reporting completed progress. */
|
|
1191
|
+
accumulatedSamplesPerPixel: number;
|
|
1185
1192
|
frame: number;
|
|
1186
1193
|
width: number;
|
|
1187
1194
|
height: number;
|
|
@@ -1607,9 +1614,34 @@ export interface RendererSnapshot {
|
|
|
1607
1614
|
xrActive: boolean;
|
|
1608
1615
|
}
|
|
1609
1616
|
|
|
1617
|
+
/** Decoded RGBA8 image, matching gpu-shared glTF material textures. */
|
|
1618
|
+
export interface NativeSurfaceImage {
|
|
1619
|
+
width: number;
|
|
1620
|
+
height: number;
|
|
1621
|
+
data: Uint8Array | Uint8ClampedArray;
|
|
1622
|
+
}
|
|
1623
|
+
export interface NativeSurfaceMaterial {
|
|
1624
|
+
/** sRGB colour; RGB is multiplied by each vertex's linear base-colour factor. */
|
|
1625
|
+
baseColor?: NativeSurfaceImage;
|
|
1626
|
+
/** Linear, OpenGL-style tangent-space normal map. */
|
|
1627
|
+
normal?: NativeSurfaceImage;
|
|
1628
|
+
/** Linear R occlusion, G roughness, B metalness. */
|
|
1629
|
+
orm?: NativeSurfaceImage;
|
|
1630
|
+
normalScale?: number;
|
|
1631
|
+
/** Independent dielectric coating strength, 0–1; defaults to 0 (off). */
|
|
1632
|
+
clearcoat?: number;
|
|
1633
|
+
/** Coating roughness, 0–1; independent of the base map; defaults to 0. */
|
|
1634
|
+
clearcoatRoughness?: number;
|
|
1635
|
+
/** Linear RGBA8: R multiplies clearcoat, G multiplies clearcoatRoughness; B/A ignored. White when omitted. */
|
|
1636
|
+
clearcoatMap?: NativeSurfaceImage;
|
|
1637
|
+
}
|
|
1610
1638
|
/** Interleaved world-space triangles: position, normal, linear colour, roughness/metalness/detail. */
|
|
1611
1639
|
export interface NativeSceneFrame {
|
|
1612
1640
|
vertices: Float32Array;
|
|
1641
|
+
/** Two UV components per vertex. Required when surfaces are supplied. */
|
|
1642
|
+
texcoords?: Float32Array;
|
|
1643
|
+
/** Contiguous triangle ranges covering all vertices, using zero-based materials. */
|
|
1644
|
+
surfaces?: { firstVertex: number; vertexCount: number; materialIndex: number }[];
|
|
1613
1645
|
camera: { eye: [number, number, number]; target: [number, number, number]; fov?: number };
|
|
1614
1646
|
time?: number;
|
|
1615
1647
|
water?: boolean;
|
|
@@ -1630,6 +1662,8 @@ export function createNativeSceneRenderer(options: {
|
|
|
1630
1662
|
canvas: HTMLCanvasElement;
|
|
1631
1663
|
navigator?: Navigator | { gpu?: GPU };
|
|
1632
1664
|
maxVertices?: number;
|
|
1665
|
+
/** At most 16 materials, 64 MiB decoded RGBA8 total; maps are power-of-two, up to 2048. */
|
|
1666
|
+
materials?: NativeSurfaceMaterial[];
|
|
1633
1667
|
onUnavailable?: (message: string) => void;
|
|
1634
1668
|
}): Promise<{
|
|
1635
1669
|
render(frame: NativeSceneFrame): NativeSceneSnapshot;
|
|
@@ -2053,3 +2087,6 @@ export const rendererWorkerProfiles: Readonly<
|
|
|
2053
2087
|
export const rendererWorkerManifests: Readonly<
|
|
2054
2088
|
Record<RendererWorkerProfileName, RendererWorkerManifest>
|
|
2055
2089
|
>;
|
|
2090
|
+
|
|
2091
|
+
/** Validate native materials and bounded decoded maps without GPU allocation. */
|
|
2092
|
+
export declare function validateNativeSurfaceMaterials(materials: NativeSurfaceMaterial[]): void;
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createGpuRenderer } from "./renderer-webgpu-runtime.js";
|
|
2
2
|
import { nativeSceneShader } from "./native-scene-shader.js";
|
|
3
|
+
import {
|
|
4
|
+
validateNativeMaterials,
|
|
5
|
+
createNativeMaterialTextures,
|
|
6
|
+
} from "./native-surface-materials.js";
|
|
3
7
|
|
|
4
8
|
const STRIDE = 12;
|
|
5
9
|
const UNIFORM_BYTES = 256;
|
|
@@ -17,7 +21,7 @@ const unit = (a) => {
|
|
|
17
21
|
const finiteVector = (a) =>
|
|
18
22
|
Array.isArray(a) && a.length === 3 && a.every(Number.isFinite);
|
|
19
23
|
|
|
20
|
-
function validateFrame(frame, maxVertices) {
|
|
24
|
+
function validateFrame(frame, maxVertices, materialCount) {
|
|
21
25
|
const { vertices, camera, time = 0 } = frame ?? {};
|
|
22
26
|
if (
|
|
23
27
|
!(vertices instanceof Float32Array) ||
|
|
@@ -56,6 +60,48 @@ function validateFrame(frame, maxVertices) {
|
|
|
56
60
|
"wakes must contain at most four finite [x, z, heading] entries.",
|
|
57
61
|
);
|
|
58
62
|
}
|
|
63
|
+
const count = vertices.length / STRIDE;
|
|
64
|
+
if (
|
|
65
|
+
frame.texcoords !== undefined &&
|
|
66
|
+
(!(frame.texcoords instanceof Float32Array) ||
|
|
67
|
+
frame.texcoords.length !== count * 2 ||
|
|
68
|
+
!frame.texcoords.every(Number.isFinite))
|
|
69
|
+
) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"texcoords must contain two finite coordinates per vertex.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (frame.surfaces !== undefined) {
|
|
75
|
+
if (
|
|
76
|
+
!Array.isArray(frame.surfaces) ||
|
|
77
|
+
frame.surfaces.length > 256 ||
|
|
78
|
+
!frame.texcoords
|
|
79
|
+
) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
"surfaces require texcoords and at most 256 contiguous draw ranges.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
let end = 0;
|
|
85
|
+
for (const range of frame.surfaces) {
|
|
86
|
+
if (
|
|
87
|
+
!range ||
|
|
88
|
+
range.firstVertex !== end ||
|
|
89
|
+
!Number.isInteger(range.vertexCount) ||
|
|
90
|
+
range.vertexCount <= 0 ||
|
|
91
|
+
range.vertexCount % 3 !== 0 ||
|
|
92
|
+
!Number.isInteger(range.materialIndex) ||
|
|
93
|
+
range.materialIndex < 0 ||
|
|
94
|
+
range.materialIndex >= materialCount
|
|
95
|
+
) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"surface ranges must cover triangles in order and reference a valid material.",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
end += range.vertexCount;
|
|
101
|
+
}
|
|
102
|
+
if (end !== count)
|
|
103
|
+
throw new Error("surface ranges must cover every vertex exactly once.");
|
|
104
|
+
}
|
|
59
105
|
return { ...frame, time };
|
|
60
106
|
}
|
|
61
107
|
|
|
@@ -106,6 +152,9 @@ function frameUniforms(frame, width, height, reflected = false) {
|
|
|
106
152
|
/** Native raster surfaces. The caller owns simulation, animation and scheduling. */
|
|
107
153
|
export async function createNativeSceneRenderer(options = {}) {
|
|
108
154
|
const maxVertices = options.maxVertices ?? 300_000;
|
|
155
|
+
const materials = options.materials ?? [];
|
|
156
|
+
validateNativeMaterials(materials);
|
|
157
|
+
const materialCount = materials.length;
|
|
109
158
|
if (
|
|
110
159
|
!Number.isInteger(maxVertices) ||
|
|
111
160
|
maxVertices < 3 ||
|
|
@@ -118,6 +167,7 @@ export async function createNativeSceneRenderer(options = {}) {
|
|
|
118
167
|
let available = true;
|
|
119
168
|
let targets = null;
|
|
120
169
|
let vertexBuffer = null;
|
|
170
|
+
let uvBuffer = null;
|
|
121
171
|
let vertexCapacity = 0;
|
|
122
172
|
let pendingFrame = null;
|
|
123
173
|
let submittedFrames = 0;
|
|
@@ -186,6 +236,63 @@ export async function createNativeSceneRenderer(options = {}) {
|
|
|
186
236
|
{ binding: 1, visibility: 2, sampler: { type: "filtering" } },
|
|
187
237
|
],
|
|
188
238
|
});
|
|
239
|
+
const materialLayout = device.createBindGroupLayout({
|
|
240
|
+
entries: [
|
|
241
|
+
...[0, 1, 2, 5].map((binding) => ({
|
|
242
|
+
binding,
|
|
243
|
+
visibility: 2,
|
|
244
|
+
texture: { sampleType: "float" },
|
|
245
|
+
})),
|
|
246
|
+
{ binding: 3, visibility: 2, sampler: { type: "filtering" } },
|
|
247
|
+
{
|
|
248
|
+
binding: 4,
|
|
249
|
+
visibility: 2,
|
|
250
|
+
buffer: { type: "uniform", minBindingSize: 16 },
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
const materialSampler = device.createSampler({
|
|
255
|
+
addressModeU: "repeat",
|
|
256
|
+
addressModeV: "repeat",
|
|
257
|
+
minFilter: "linear",
|
|
258
|
+
magFilter: "linear",
|
|
259
|
+
mipmapFilter: "linear",
|
|
260
|
+
maxAnisotropy: 4,
|
|
261
|
+
});
|
|
262
|
+
const materialGroups = createNativeMaterialTextures(
|
|
263
|
+
device,
|
|
264
|
+
materials,
|
|
265
|
+
own,
|
|
266
|
+
).map((material) => {
|
|
267
|
+
const params = own(
|
|
268
|
+
device.createBuffer({
|
|
269
|
+
size: 16,
|
|
270
|
+
usage: 0x48,
|
|
271
|
+
label: "native.material-parameters",
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
device.queue.writeBuffer(
|
|
275
|
+
params,
|
|
276
|
+
0,
|
|
277
|
+
new Float32Array([
|
|
278
|
+
material.normalScale,
|
|
279
|
+
material.authored ? 1 : 0,
|
|
280
|
+
material.clearcoat,
|
|
281
|
+
material.clearcoatRoughness,
|
|
282
|
+
]),
|
|
283
|
+
);
|
|
284
|
+
return device.createBindGroup({
|
|
285
|
+
layout: materialLayout,
|
|
286
|
+
entries: [
|
|
287
|
+
{ binding: 0, resource: material.baseColor },
|
|
288
|
+
{ binding: 1, resource: material.normal },
|
|
289
|
+
{ binding: 2, resource: material.orm },
|
|
290
|
+
{ binding: 3, resource: materialSampler },
|
|
291
|
+
{ binding: 4, resource: { buffer: params } },
|
|
292
|
+
{ binding: 5, resource: material.clearcoatMap },
|
|
293
|
+
],
|
|
294
|
+
});
|
|
295
|
+
});
|
|
189
296
|
const uniformBuffer = own(
|
|
190
297
|
device.createBuffer({
|
|
191
298
|
label: "native.frame",
|
|
@@ -267,6 +374,20 @@ export async function createNativeSceneRenderer(options = {}) {
|
|
|
267
374
|
? attributes.slice(0, 1)
|
|
268
375
|
: attributes,
|
|
269
376
|
},
|
|
377
|
+
...(vertex === "shadowVertex"
|
|
378
|
+
? []
|
|
379
|
+
: [
|
|
380
|
+
{
|
|
381
|
+
arrayStride: 8,
|
|
382
|
+
attributes: [
|
|
383
|
+
{
|
|
384
|
+
shaderLocation: 4,
|
|
385
|
+
offset: 0,
|
|
386
|
+
format: "float32x2",
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
},
|
|
390
|
+
]),
|
|
270
391
|
],
|
|
271
392
|
}
|
|
272
393
|
: {}),
|
|
@@ -303,7 +424,7 @@ export async function createNativeSceneRenderer(options = {}) {
|
|
|
303
424
|
"surfaceFragment",
|
|
304
425
|
format,
|
|
305
426
|
4,
|
|
306
|
-
[uniformLayout, shadowLayout],
|
|
427
|
+
[uniformLayout, shadowLayout, materialLayout],
|
|
307
428
|
true,
|
|
308
429
|
);
|
|
309
430
|
const skyPipeline = await pipeline(
|
|
@@ -320,7 +441,7 @@ export async function createNativeSceneRenderer(options = {}) {
|
|
|
320
441
|
"waterFragment",
|
|
321
442
|
format,
|
|
322
443
|
4,
|
|
323
|
-
[uniformLayout, shadowLayout, reflectionLayout],
|
|
444
|
+
[uniformLayout, shadowLayout, materialLayout, reflectionLayout],
|
|
324
445
|
);
|
|
325
446
|
const reflectedSurfacePipeline = await pipeline(
|
|
326
447
|
"reflection-surface",
|
|
@@ -328,7 +449,7 @@ export async function createNativeSceneRenderer(options = {}) {
|
|
|
328
449
|
"reflectionSurface",
|
|
329
450
|
"rgba16float",
|
|
330
451
|
1,
|
|
331
|
-
[uniformLayout, shadowLayout],
|
|
452
|
+
[uniformLayout, shadowLayout, materialLayout],
|
|
332
453
|
true,
|
|
333
454
|
);
|
|
334
455
|
const reflectedSkyPipeline = await pipeline(
|
|
@@ -434,7 +555,20 @@ export async function createNativeSceneRenderer(options = {}) {
|
|
|
434
555
|
if (p !== shadowPipeline) pass.setBindGroup(1, shadowGroup);
|
|
435
556
|
if (vertexCount > 0) {
|
|
436
557
|
pass.setVertexBuffer(0, vertexBuffer);
|
|
437
|
-
|
|
558
|
+
if (p === shadowPipeline) {
|
|
559
|
+
pass.draw(vertexCount);
|
|
560
|
+
} else {
|
|
561
|
+
pass.setVertexBuffer(1, uvBuffer);
|
|
562
|
+
if (pendingFrame.surfaces) {
|
|
563
|
+
for (const range of pendingFrame.surfaces) {
|
|
564
|
+
pass.setBindGroup(2, materialGroups[range.materialIndex + 1]);
|
|
565
|
+
pass.draw(range.vertexCount, 1, range.firstVertex);
|
|
566
|
+
}
|
|
567
|
+
} else {
|
|
568
|
+
pass.setBindGroup(2, materialGroups[0]);
|
|
569
|
+
pass.draw(vertexCount);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
438
572
|
}
|
|
439
573
|
};
|
|
440
574
|
const shadowPass = encoder.beginRenderPass({
|
|
@@ -480,7 +614,8 @@ export async function createNativeSceneRenderer(options = {}) {
|
|
|
480
614
|
if (pendingFrame.water !== false) {
|
|
481
615
|
pass.setPipeline(waterPipeline);
|
|
482
616
|
pass.setBindGroup(1, shadowGroup);
|
|
483
|
-
pass.setBindGroup(2,
|
|
617
|
+
pass.setBindGroup(2, materialGroups[0]);
|
|
618
|
+
pass.setBindGroup(3, targets.reflectionGroup);
|
|
484
619
|
pass.draw(3);
|
|
485
620
|
}
|
|
486
621
|
drawGeometry(pass, surfacePipeline, mainGroup);
|
|
@@ -491,15 +626,23 @@ export async function createNativeSceneRenderer(options = {}) {
|
|
|
491
626
|
render(input) {
|
|
492
627
|
if (destroyed) throw new Error("Native scene renderer was destroyed.");
|
|
493
628
|
if (!available) throw new Error("WebGPU device was lost.");
|
|
494
|
-
pendingFrame = validateFrame(input, maxVertices);
|
|
629
|
+
pendingFrame = validateFrame(input, maxVertices, materialCount);
|
|
495
630
|
const data = pendingFrame.vertices;
|
|
496
631
|
ensureTargets();
|
|
497
632
|
if (data.byteLength > vertexCapacity) {
|
|
498
633
|
if (vertexBuffer) release(vertexBuffer);
|
|
634
|
+
if (uvBuffer) release(uvBuffer);
|
|
499
635
|
vertexCapacity = Math.min(
|
|
500
636
|
maxVertices * STRIDE * 4,
|
|
501
637
|
Math.max(4096, 2 ** Math.ceil(Math.log2(data.byteLength))),
|
|
502
638
|
);
|
|
639
|
+
uvBuffer = own(
|
|
640
|
+
device.createBuffer({
|
|
641
|
+
label: "native.surface-uvs",
|
|
642
|
+
size: Math.ceil(vertexCapacity / (STRIDE * 4)) * 8,
|
|
643
|
+
usage: 0x28,
|
|
644
|
+
}),
|
|
645
|
+
);
|
|
503
646
|
vertexBuffer = own(
|
|
504
647
|
device.createBuffer({
|
|
505
648
|
label: "native.surface-vertices",
|
|
@@ -508,6 +651,8 @@ export async function createNativeSceneRenderer(options = {}) {
|
|
|
508
651
|
}),
|
|
509
652
|
);
|
|
510
653
|
}
|
|
654
|
+
if (pendingFrame.texcoords?.byteLength)
|
|
655
|
+
device.queue.writeBuffer(uvBuffer, 0, pendingFrame.texcoords);
|
|
511
656
|
vertexCount = data.length / STRIDE;
|
|
512
657
|
if (data.byteLength) device.queue.writeBuffer(vertexBuffer, 0, data);
|
|
513
658
|
device.queue.writeBuffer(
|
|
@@ -11,12 +11,18 @@ struct Frame {
|
|
|
11
11
|
@group(0) @binding(0) var<uniform> frame: Frame;
|
|
12
12
|
@group(1) @binding(0) var shadowMap: texture_depth_2d;
|
|
13
13
|
@group(1) @binding(1) var shadowSampler: sampler_comparison;
|
|
14
|
-
@group(
|
|
15
|
-
@group(
|
|
14
|
+
@group(3) @binding(0) var reflectionMap: texture_2d<f32>;
|
|
15
|
+
@group(3) @binding(1) var reflectionSampler: sampler;
|
|
16
|
+
@group(2) @binding(0) var baseColourMap: texture_2d<f32>;
|
|
17
|
+
@group(2) @binding(1) var normalMap: texture_2d<f32>;
|
|
18
|
+
@group(2) @binding(2) var ormMap: texture_2d<f32>;
|
|
19
|
+
@group(2) @binding(3) var materialSampler: sampler;
|
|
20
|
+
@group(2) @binding(4) var<uniform> materialParameters: vec4f;
|
|
21
|
+
@group(2) @binding(5) var clearcoatMap: texture_2d<f32>;
|
|
16
22
|
struct Surface {
|
|
17
23
|
@builtin(position) clip: vec4f,
|
|
18
24
|
@location(0) world: vec3f, @location(1) normal: vec3f,
|
|
19
|
-
@location(2) colour: vec3f, @location(3) material: vec3f,
|
|
25
|
+
@location(2) colour: vec3f, @location(3) material: vec3f, @location(4) uv: vec2f,
|
|
20
26
|
};
|
|
21
27
|
fn project(p: vec3f) -> vec4f {
|
|
22
28
|
let d = p - frame.eye.xyz;
|
|
@@ -30,8 +36,8 @@ fn lightProject(p: vec3f) -> vec4f {
|
|
|
30
36
|
dot(d, frame.lightUp.xyz) / 34.0, dot(d, frame.lightForward.xyz) / 100.0, 1.0);
|
|
31
37
|
}
|
|
32
38
|
@vertex fn surfaceVertex(@location(0) p: vec3f, @location(1) n: vec3f,
|
|
33
|
-
@location(2) c: vec3f, @location(3) m: vec3f) -> Surface {
|
|
34
|
-
return Surface(project(p), p, n, c, m);
|
|
39
|
+
@location(2) c: vec3f, @location(3) m: vec3f, @location(4) uv: vec2f) -> Surface {
|
|
40
|
+
return Surface(project(p), p, n, c, m, uv);
|
|
35
41
|
}
|
|
36
42
|
@vertex fn shadowVertex(@location(0) p: vec3f) -> @builtin(position) vec4f {
|
|
37
43
|
return lightProject(p);
|
|
@@ -84,17 +90,25 @@ fn visibility(p: vec3f, n: vec3f) -> f32 {
|
|
|
84
90
|
}
|
|
85
91
|
return result / 9.0;
|
|
86
92
|
}
|
|
87
|
-
|
|
93
|
+
// Dielectric coating is evaluated independently from the coloured substrate.
|
|
94
|
+
// This returns the microfacet lobe without Fresnel, including the cosine term.
|
|
95
|
+
fn specularLobe(n: vec3f, v: vec3f, roughness: f32) -> f32 {
|
|
88
96
|
let l = frame.sun.xyz; let h = normalize(l + v);
|
|
89
97
|
let nl = max(dot(n,l),0.0); let nv = max(dot(n,v),0.001);
|
|
90
|
-
let nh = max(dot(n,h),0.0);
|
|
98
|
+
let nh = max(dot(n,h),0.0);
|
|
91
99
|
let a = max(0.035, roughness * roughness); let a2 = a * a;
|
|
92
100
|
let d = a2 / max(0.00001, 3.14159265 * pow(nh * nh * (a2 - 1.0) + 1.0, 2.0));
|
|
93
101
|
let k = pow(roughness + 1.0, 2.0) / 8.0;
|
|
94
102
|
let g = (nl / (nl * (1.0-k) + k)) * (nv / (nv * (1.0-k) + k));
|
|
103
|
+
return d * g / max(0.001, 4.0*nl*nv) * nl;
|
|
104
|
+
}
|
|
105
|
+
fn brdf(c: vec3f, n: vec3f, v: vec3f, roughness: f32, metal: f32) -> vec3f {
|
|
106
|
+
let h = normalize(frame.sun.xyz + v);
|
|
107
|
+
let nl = max(dot(n,frame.sun.xyz),0.0);
|
|
108
|
+
let vh = max(dot(v,h),0.0);
|
|
95
109
|
let f0 = mix(vec3f(0.04), c, metal);
|
|
96
110
|
let f = f0 + (1.0-f0) * pow(1.0-vh, 5.0);
|
|
97
|
-
return (
|
|
111
|
+
return (1.0-f) * (1.0-metal) * c / 3.14159265 * nl + specularLobe(n,v,roughness) * f;
|
|
98
112
|
}
|
|
99
113
|
fn fog(c: vec3f, p: vec3f) -> vec3f {
|
|
100
114
|
let amount = 1.0 - exp(-distance(frame.eye.xyz, p) * 0.0018);
|
|
@@ -102,23 +116,58 @@ fn fog(c: vec3f, p: vec3f) -> vec3f {
|
|
|
102
116
|
}
|
|
103
117
|
fn surfaceRadiance(s: Surface, front: bool) -> vec3f {
|
|
104
118
|
let facing = select(select(-1.0,1.0,front),select(1.0,-1.0,front),frame.info.y>0.5);
|
|
105
|
-
|
|
119
|
+
let coatNormal = normalize(s.normal) * facing;
|
|
120
|
+
var n = coatNormal;
|
|
106
121
|
let v = normalize(frame.eye.xyz - s.world);
|
|
107
|
-
|
|
108
|
-
|
|
122
|
+
// Derivatives and samples must be uniform across each fragment quad.
|
|
123
|
+
// Relative degeneracy: raw derivative magnitudes shrink with model scale and
|
|
124
|
+
// zoom. An absolute tangent cutoff silently disabled normal maps in close-ups.
|
|
125
|
+
let dpScale = max(length(dpdx(s.world)), length(dpdy(s.world)));
|
|
126
|
+
let uvScale = max(length(dpdx(s.uv)), length(dpdy(s.uv)));
|
|
127
|
+
let dx = dpdx(s.world) / max(dpScale, 1e-20);
|
|
128
|
+
let dy = dpdy(s.world) / max(dpScale, 1e-20);
|
|
129
|
+
let ux = dpdx(s.uv) / max(uvScale, 1e-20);
|
|
130
|
+
let uy = dpdy(s.uv) / max(uvScale, 1e-20);
|
|
131
|
+
let determinant = ux.x * uy.y - ux.y * uy.x;
|
|
132
|
+
let tangent = dx * uy.y - dy * ux.y;
|
|
133
|
+
let bitangent = dy * ux.x - dx * uy.x;
|
|
134
|
+
let sampledNormal = textureSample(normalMap, materialSampler, s.uv).xyz * 2.0 - 1.0;
|
|
135
|
+
var c = s.colour * textureSample(baseColourMap, materialSampler, s.uv).rgb;
|
|
136
|
+
let orm = textureSample(ormMap, materialSampler, s.uv).rgb;
|
|
137
|
+
let coatSample = textureSample(clearcoatMap, materialSampler, s.uv).rg;
|
|
138
|
+
let roughness = clamp(s.material.x * orm.g, 0.04, 1.0);
|
|
139
|
+
let metalness = clamp(s.material.y * orm.b, 0.0, 1.0);
|
|
140
|
+
let orthogonalTangent = tangent - n * dot(n,tangent);
|
|
141
|
+
let orthogonalBitangent = bitangent - n * dot(n,bitangent);
|
|
142
|
+
if (dpScale > 1e-20 && uvScale > 1e-20 && abs(determinant) > 1e-6 && dot(orthogonalTangent,orthogonalTangent) > 1e-12 && dot(orthogonalBitangent,orthogonalBitangent) > 1e-12) {
|
|
143
|
+
let t = normalize(orthogonalTangent) * sign(determinant);
|
|
144
|
+
let b = normalize(orthogonalBitangent) * sign(determinant);
|
|
145
|
+
n = normalize(t * sampledNormal.x * materialParameters.x + b * sampledNormal.y * materialParameters.x + n * max(0.001,sampledNormal.z));
|
|
146
|
+
}
|
|
147
|
+
if (materialParameters.y < 0.5 && s.material.z > 2.5) {
|
|
109
148
|
let rock = smoothstep(0.45,0.68,noise(s.world*0.27));
|
|
110
149
|
c = mix(c,vec3f(0.16,0.145,0.11),rock*0.7);
|
|
111
150
|
c *= 0.75+0.45*noise(s.world*2.4);
|
|
112
151
|
n = normalize(n+vec3f(noise(s.world*4.0)-0.5,0,noise(s.world*4.0+vec3f(13))-0.5)*0.28);
|
|
113
|
-
} else if (s.material.z > 0.5 && s.material.z < 1.5) {
|
|
152
|
+
} else if (materialParameters.y < 0.5 && s.material.z > 0.5 && s.material.z < 1.5) {
|
|
114
153
|
let grain = noise(s.world * vec3f(18, 3, 1.6));
|
|
115
154
|
c *= 0.82 + 0.30 * grain;
|
|
116
|
-
} else if (s.material.z > 1.5) { c *= 0.88 + 0.18 * noise(s.world * 12.0); }
|
|
155
|
+
} else if (materialParameters.y < 0.5 && s.material.z > 1.5) { c *= 0.88 + 0.18 * noise(s.world * 12.0); }
|
|
117
156
|
let hemi = mix(vec3f(0.06,0.05,0.035), vec3f(0.16,0.22,0.29), n.y * 0.5 + 0.5);
|
|
118
|
-
let direct = brdf(c, n, v,
|
|
119
|
-
let ambient = c * hemi * (1.0 -
|
|
120
|
-
let env = sky(reflect(-v,n)) * mix(vec3f(0.04), c,
|
|
121
|
-
|
|
157
|
+
let direct = brdf(c, n, v, roughness, metalness) * vec3f(3.0, 2.65, 2.15) * visibility(s.world, n);
|
|
158
|
+
let ambient = c * hemi * (1.0 - metalness * 0.7) * orm.r;
|
|
159
|
+
let env = sky(reflect(-v,n)) * mix(vec3f(0.04), c, metalness) * (1.0-roughness) * orm.r * 0.30;
|
|
160
|
+
let baseRadiance = direct + ambient + env;
|
|
161
|
+
let coatCoverage = materialParameters.z * coatSample.r;
|
|
162
|
+
if (coatCoverage <= 0.0) { return fog(baseRadiance,s.world); }
|
|
163
|
+
// KHR_materials_clearcoat's simple Fresnel layering: varnish has its own
|
|
164
|
+
// smooth normal and roughness. Its reflection never inherits the wood colour.
|
|
165
|
+
let coatFresnel = 0.04 + 0.96 * pow(1.0-clamp(abs(dot(coatNormal,v)),0.0,1.0),5.0);
|
|
166
|
+
let coatWeight = coatCoverage * coatFresnel;
|
|
167
|
+
let coatRoughness = clamp(materialParameters.w * coatSample.g,0.04,1.0);
|
|
168
|
+
let coatDirect = vec3f(3.0,2.65,2.15) * specularLobe(coatNormal,v,coatRoughness) * visibility(s.world,coatNormal);
|
|
169
|
+
let coatEnvironment = sky(reflect(-v,coatNormal)) * (1.0-coatRoughness) * orm.r * 0.30;
|
|
170
|
+
return fog(baseRadiance * (1.0-coatWeight) + (coatDirect+coatEnvironment) * coatWeight, s.world);
|
|
122
171
|
}
|
|
123
172
|
@fragment fn surfaceFragment(s: Surface, @builtin(front_facing) front: bool) -> @location(0) vec4f {
|
|
124
173
|
return vec4f(display(surfaceRadiance(s,front)),1);
|
|
@@ -172,7 +221,8 @@ struct WaterResult { @location(0) colour: vec4f, @builtin(frag_depth) depth: f32
|
|
|
172
221
|
return vec4f(sky(cameraRay(pixel.xy)),1);
|
|
173
222
|
}
|
|
174
223
|
@fragment fn reflectionSurface(s: Surface, @builtin(front_facing) front: bool) -> @location(0) vec4f {
|
|
224
|
+
let radiance = surfaceRadiance(s,front);
|
|
175
225
|
if (s.world.y < frame.viewport.z) { discard; }
|
|
176
|
-
return vec4f(
|
|
226
|
+
return vec4f(radiance,1);
|
|
177
227
|
}
|
|
178
228
|
`;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Decoded RGBA contract matches gpu-shared's glTF loader. No network or DOM work.
|
|
2
|
+
const MAX_BYTES = 64 * 1024 * 1024;
|
|
3
|
+
const MAPS = ["baseColor", "normal", "orm", "clearcoatMap"];
|
|
4
|
+
const defaults = {
|
|
5
|
+
baseColor: [255, 255, 255, 255],
|
|
6
|
+
normal: [128, 128, 255, 255],
|
|
7
|
+
orm: [255, 255, 255, 255],
|
|
8
|
+
clearcoatMap: [255, 255, 255, 255],
|
|
9
|
+
};
|
|
10
|
+
const dimension = (n) =>
|
|
11
|
+
Number.isInteger(n) && n >= 1 && n <= 2048 && (n & (n - 1)) === 0;
|
|
12
|
+
|
|
13
|
+
export function validateNativeMaterials(materials) {
|
|
14
|
+
if (!Array.isArray(materials) || materials.length > 16)
|
|
15
|
+
throw new Error("materials must contain at most 16 entries.");
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
for (const material of materials) {
|
|
18
|
+
if (!material || typeof material !== "object")
|
|
19
|
+
throw new Error("Each material must be an object.");
|
|
20
|
+
if (
|
|
21
|
+
!Number.isFinite(material.normalScale ?? 1) ||
|
|
22
|
+
(material.normalScale ?? 1) < 0 ||
|
|
23
|
+
(material.normalScale ?? 1) > 4
|
|
24
|
+
) {
|
|
25
|
+
throw new Error("material normalScale must be between 0 and 4.");
|
|
26
|
+
}
|
|
27
|
+
for (const key of ["clearcoat", "clearcoatRoughness"]) {
|
|
28
|
+
const value = material[key] ?? 0;
|
|
29
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) {
|
|
30
|
+
throw new Error(`material ${key} must be between 0 and 1.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const key of MAPS) {
|
|
34
|
+
const map = material[key];
|
|
35
|
+
if (map === undefined) continue;
|
|
36
|
+
if (
|
|
37
|
+
!map ||
|
|
38
|
+
!dimension(map.width) ||
|
|
39
|
+
!dimension(map.height) ||
|
|
40
|
+
!(
|
|
41
|
+
map.data instanceof Uint8Array ||
|
|
42
|
+
map.data instanceof Uint8ClampedArray
|
|
43
|
+
) ||
|
|
44
|
+
map.data.length !== map.width * map.height * 4
|
|
45
|
+
) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`material ${key} texture must contain RGBA8 data with power-of-two dimensions between 1 and 2048.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
bytes += map.data.byteLength;
|
|
51
|
+
if (bytes > MAX_BYTES)
|
|
52
|
+
throw new Error("material textures exceed the 64 MiB source budget.");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const linear = (x) => (x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4);
|
|
57
|
+
const srgb = (x) =>
|
|
58
|
+
x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055;
|
|
59
|
+
|
|
60
|
+
function nextMip(image, colour) {
|
|
61
|
+
const width = Math.max(1, image.width / 2),
|
|
62
|
+
height = Math.max(1, image.height / 2);
|
|
63
|
+
const data = new Uint8Array(width * height * 4);
|
|
64
|
+
for (let y = 0; y < height; y++)
|
|
65
|
+
for (let x = 0; x < width; x++)
|
|
66
|
+
for (let c = 0; c < 4; c++) {
|
|
67
|
+
let sum = 0;
|
|
68
|
+
for (let dy = 0; dy < 2; dy++)
|
|
69
|
+
for (let dx = 0; dx < 2; dx++) {
|
|
70
|
+
const offset =
|
|
71
|
+
(Math.min(image.height - 1, y * 2 + dy) * image.width +
|
|
72
|
+
Math.min(image.width - 1, x * 2 + dx)) *
|
|
73
|
+
4 +
|
|
74
|
+
c;
|
|
75
|
+
const value = image.data[offset] / 255;
|
|
76
|
+
sum += colour && c < 3 ? linear(value) : value;
|
|
77
|
+
}
|
|
78
|
+
data[(y * width + x) * 4 + c] = Math.round(
|
|
79
|
+
255 * (colour && c < 3 ? srgb(sum / 4) : sum / 4),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return { width, height, data };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createNativeMaterialTextures(device, materials, own) {
|
|
86
|
+
// Index zero is the backwards-compatible material; caller materials start at one.
|
|
87
|
+
return [{}, ...materials].map((material, index) => {
|
|
88
|
+
const result = {
|
|
89
|
+
normalScale: material.normal ? (material.normalScale ?? 1) : 0,
|
|
90
|
+
authored: ["baseColor", "normal", "orm"].some((key) => material[key]),
|
|
91
|
+
clearcoat: material.clearcoat ?? 0,
|
|
92
|
+
clearcoatRoughness: material.clearcoatRoughness ?? 0,
|
|
93
|
+
};
|
|
94
|
+
for (const key of MAPS) {
|
|
95
|
+
let level = material[key] ?? {
|
|
96
|
+
width: 1,
|
|
97
|
+
height: 1,
|
|
98
|
+
data: new Uint8Array(defaults[key]),
|
|
99
|
+
};
|
|
100
|
+
const mipLevelCount = 1 + Math.log2(Math.max(level.width, level.height));
|
|
101
|
+
const texture = own(
|
|
102
|
+
device.createTexture({
|
|
103
|
+
label: `native.material.${index}.${key}`,
|
|
104
|
+
size: { width: level.width, height: level.height },
|
|
105
|
+
format: key === "baseColor" ? "rgba8unorm-srgb" : "rgba8unorm",
|
|
106
|
+
mipLevelCount,
|
|
107
|
+
usage: 0x06,
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
for (let mipLevel = 0; mipLevel < mipLevelCount; mipLevel++) {
|
|
111
|
+
device.queue.writeTexture(
|
|
112
|
+
{ texture, mipLevel },
|
|
113
|
+
level.data,
|
|
114
|
+
{ bytesPerRow: level.width * 4, rowsPerImage: level.height },
|
|
115
|
+
{ width: level.width, height: level.height, depthOrArrayLayers: 1 },
|
|
116
|
+
);
|
|
117
|
+
if (mipLevel + 1 < mipLevelCount)
|
|
118
|
+
level = nextMip(level, key === "baseColor");
|
|
119
|
+
}
|
|
120
|
+
result[key] = texture.createView();
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
});
|
|
124
|
+
}
|