@plasius/gpu-renderer 0.2.46 → 0.2.48

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plasius/gpu-renderer",
3
- "version": "0.2.46",
3
+ "version": "0.2.48",
4
4
  "description": "Framework-agnostic WebGPU renderer runtime for Plasius projects.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -53,6 +53,7 @@
53
53
  "license": "Apache-2.0",
54
54
  "dependencies": {
55
55
  "@plasius/gpu-camera": "^0.1.18",
56
+ "@plasius/gpu-model-core": "^0.4.4",
56
57
  "@plasius/gpu-shared": "^1.1.1",
57
58
  "@plasius/gpu-xr": "^0.1.17",
58
59
  "@types/webxr": "^0.5.24"
@@ -88,7 +89,7 @@
88
89
  }
89
90
  ],
90
91
  "overrides": {
91
- "brace-expansion": "5.0.9",
92
+ "brace-expansion": "5.0.12",
92
93
  "esbuild": "^0.28.1",
93
94
  "minimatch": "^10.2.1"
94
95
  },
@@ -0,0 +1,226 @@
1
+ const STANDARD_TEXTURE_BINDINGS = Object.freeze([
2
+ ["baseColor", "baseColorTexture"],
3
+ ["metallicRoughness", "metallicRoughnessTexture"],
4
+ ["normal", "normalTexture"],
5
+ ["occlusion", "occlusionTexture"],
6
+ ["emissive", "emissiveTexture"],
7
+ ]);
8
+
9
+ const EXTENSION_TEXTURE_BINDINGS = Object.freeze([
10
+ ["clearcoat", "clearcoat", "clearcoatTexture"],
11
+ ["clearcoatRoughness", "clearcoat", "clearcoatRoughnessTexture"],
12
+ ["clearcoatNormal", "clearcoat", "clearcoatNormalTexture"],
13
+ ["transmission", "transmission", "transmissionTexture"],
14
+ ["sheenColor", "sheen", "sheenColorTexture"],
15
+ ["sheenRoughness", "sheen", "sheenRoughnessTexture"],
16
+ ]);
17
+
18
+ function requireArray(value, label) {
19
+ if (!Array.isArray(value)) {
20
+ throw new TypeError(`${label} must be an array.`);
21
+ }
22
+ return value;
23
+ }
24
+
25
+ function requireMap(value, label) {
26
+ if (!value || typeof value !== "object" || typeof value.get !== "function") {
27
+ throw new TypeError(`${label} must provide a ReadonlyMap-compatible get method.`);
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function textureSample(binding, textures, label, extra = {}) {
33
+ if (binding === undefined || binding === null) {
34
+ return undefined;
35
+ }
36
+
37
+ const sample = textures.get(binding.textureId);
38
+ if (!sample) {
39
+ throw new Error(`Missing decoded texture sample ${binding.textureId} for ${label}.`);
40
+ }
41
+ const width = Number(sample.width);
42
+ const height = Number(sample.height);
43
+ const data = sample.data;
44
+ const dataLength = data && typeof data.length === "number" ? data.length : -1;
45
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
46
+ throw new TypeError(`Decoded texture sample ${binding.textureId} has invalid dimensions.`);
47
+ }
48
+ if (width > 4_096 || height > 4_096 || width * height > 4_096 * 4_096) {
49
+ throw new RangeError(`Decoded texture sample ${binding.textureId} exceeds the 4096-pixel resource limit.`);
50
+ }
51
+ if (!(Array.isArray(data) || ArrayBuffer.isView(data)) || dataLength < width * height * 4) {
52
+ throw new TypeError(`Decoded texture sample ${binding.textureId} must contain width × height × 4 RGBA values.`);
53
+ }
54
+
55
+ return Object.freeze({
56
+ ...sample,
57
+ ...(binding.texCoordSet === undefined ? {} : { texCoord: binding.texCoordSet }),
58
+ ...extra,
59
+ });
60
+ }
61
+
62
+ function cloneMaterialExtensions(material, textures) {
63
+ const extensions = { ...(material.extensions ?? {}) };
64
+ if (material.workflow === "unlit") {
65
+ const unlitKey = "KHR_materials_unlit";
66
+ const unlitValue = extensions[unlitKey] ?? extensions.unlit ?? {};
67
+ if (typeof unlitValue !== "object" || unlitValue === null || Array.isArray(unlitValue)) {
68
+ throw new TypeError(`Canonical material extension ${unlitKey} must be an object.`);
69
+ }
70
+ extensions[unlitKey] = { ...unlitValue, enabled: true };
71
+ }
72
+ for (const [bindingName, extensionName, textureKey] of EXTENSION_TEXTURE_BINDINGS) {
73
+ const binding = material.textures?.[bindingName];
74
+ if (!binding) {
75
+ continue;
76
+ }
77
+
78
+ const extensionKey = `KHR_materials_${extensionName}`;
79
+ const extensionValue = extensions[extensionKey] ?? extensions[extensionName] ?? {};
80
+ if (typeof extensionValue !== "object" || extensionValue === null || Array.isArray(extensionValue)) {
81
+ throw new TypeError(`Canonical material extension ${extensionKey} must be an object.`);
82
+ }
83
+ extensions[extensionKey] = {
84
+ ...extensionValue,
85
+ [textureKey]: textureSample(binding, textures, `${extensionKey}.${textureKey}`),
86
+ };
87
+ }
88
+ return extensions;
89
+ }
90
+
91
+ function mapMaterial(material, materialRefId, textures) {
92
+ if (!material) {
93
+ return { materialRefId };
94
+ }
95
+ if (material.alphaMode === "mask") {
96
+ throw new Error("Canonical alphaMode mask is unsupported by WavefrontMeshInput.");
97
+ }
98
+ if (material.workflow !== "metallic-roughness" && material.workflow !== "unlit") {
99
+ throw new Error(`Unsupported canonical material workflow: ${material.workflow}.`);
100
+ }
101
+
102
+ const mapped = {
103
+ materialRefId,
104
+ ...(material.alphaMode === "blend" ? { materialKind: "transparent" } : {}),
105
+ baseColor: material.baseColorFactor,
106
+ emission: material.emissiveFactor
107
+ ? [...material.emissiveFactor, 1]
108
+ : [0, 0, 0, 1],
109
+ roughness: material.roughnessFactor,
110
+ metallic: material.metallicFactor,
111
+ opacity: material.alphaMode === "opaque" ? 1 : material.baseColorFactor?.[3],
112
+ clearcoat: material.clearcoatFactor,
113
+ clearcoatRoughness: material.clearcoatRoughnessFactor,
114
+ transmission: material.transmissionFactor,
115
+ sheenColor: material.sheenColorFactor,
116
+ extensions: cloneMaterialExtensions(material, textures),
117
+ };
118
+
119
+ for (const [bindingName, inputName] of STANDARD_TEXTURE_BINDINGS) {
120
+ const binding = material.textures?.[bindingName];
121
+ if (!binding) {
122
+ continue;
123
+ }
124
+ const extra = bindingName === "normal" && material.normalScale !== undefined
125
+ ? { scale: material.normalScale }
126
+ : bindingName === "occlusion" && material.occlusionStrength !== undefined
127
+ ? { strength: material.occlusionStrength }
128
+ : {};
129
+ mapped[inputName] = textureSample(binding, textures, inputName, extra);
130
+ }
131
+
132
+ return mapped;
133
+ }
134
+
135
+ /**
136
+ * Maps decoded canonical mesh primitives into the renderer's existing Wavefront
137
+ * input contract. Geometry and image decoding remain upstream responsibilities.
138
+ */
139
+ export function createCanonicalWavefrontMeshInputs({ document, geometry, textures = new Map() } = {}) {
140
+ if (!document || typeof document !== "object") {
141
+ throw new TypeError("Canonical model document is required.");
142
+ }
143
+ const meshes = requireArray(document.meshes, "Canonical model meshes");
144
+ const materials = requireArray(document.materials, "Canonical model materials");
145
+ const geometryInputs = requireArray(geometry, "Renderer-ready geometry");
146
+ const textureSamples = requireMap(textures, "Decoded texture samples");
147
+
148
+ const geometryByPrimitiveId = new Map();
149
+ for (const entry of geometryInputs) {
150
+ if (!entry || typeof entry.primitiveId !== "string" || entry.primitiveId.length === 0) {
151
+ throw new TypeError("Renderer-ready geometry requires a non-empty primitiveId.");
152
+ }
153
+ if (geometryByPrimitiveId.has(entry.primitiveId)) {
154
+ throw new Error(`Duplicate renderer-ready geometry for primitive ${entry.primitiveId}.`);
155
+ }
156
+ geometryByPrimitiveId.set(entry.primitiveId, entry);
157
+ }
158
+
159
+ const materialsById = new Map();
160
+ const materialRefIds = new Map();
161
+ materials.forEach((material, index) => {
162
+ if (!material || typeof material.id !== "string" || material.id.length === 0) {
163
+ throw new TypeError("Canonical materials require a non-empty id.");
164
+ }
165
+ if (materialsById.has(material.id)) {
166
+ throw new Error(`Duplicate canonical material ${material.id}.`);
167
+ }
168
+ materialsById.set(material.id, material);
169
+ materialRefIds.set(material.id, index + 1);
170
+ });
171
+
172
+ const output = [];
173
+ const consumedPrimitiveIds = new Set();
174
+ const canonicalPrimitiveIds = new Set();
175
+ for (const mesh of meshes) {
176
+ if (!mesh || typeof mesh.id !== "string") {
177
+ throw new TypeError("Canonical meshes require a string id.");
178
+ }
179
+ for (const primitive of requireArray(mesh.primitives, `Primitives for mesh ${mesh.id}`)) {
180
+ if (!primitive || typeof primitive.id !== "string" || primitive.id.length === 0) {
181
+ throw new TypeError(`Canonical mesh ${mesh.id} contains a primitive without an id.`);
182
+ }
183
+ if (canonicalPrimitiveIds.has(primitive.id)) {
184
+ throw new Error(`Duplicate canonical primitive ${primitive.id}.`);
185
+ }
186
+ canonicalPrimitiveIds.add(primitive.id);
187
+ if (primitive.topology !== "triangles") {
188
+ throw new Error(`Unsupported canonical primitive topology: ${primitive.topology}.`);
189
+ }
190
+ const source = geometryByPrimitiveId.get(primitive.id);
191
+ if (!source) {
192
+ throw new Error(`Missing renderer-ready geometry for primitive ${primitive.id}.`);
193
+ }
194
+ consumedPrimitiveIds.add(primitive.id);
195
+
196
+ let material = null;
197
+ let materialRefId = 0;
198
+ if (primitive.materialId !== undefined) {
199
+ material = materialsById.get(primitive.materialId);
200
+ if (!material) {
201
+ throw new Error(`Missing canonical material ${primitive.materialId} for primitive ${primitive.id}.`);
202
+ }
203
+ materialRefId = materialRefIds.get(primitive.materialId);
204
+ }
205
+
206
+ output.push(Object.freeze({
207
+ id: output.length + 1,
208
+ positions: source.positions,
209
+ ...(source.indices === undefined ? {} : { indices: source.indices }),
210
+ ...(source.normals === undefined ? {} : { normals: source.normals }),
211
+ ...((source.texcoords ?? source.uvs) === undefined
212
+ ? {}
213
+ : { uvs: source.texcoords ?? source.uvs }),
214
+ ...mapMaterial(material, materialRefId, textureSamples),
215
+ }));
216
+ }
217
+ }
218
+
219
+ for (const primitiveId of geometryByPrimitiveId.keys()) {
220
+ if (!consumedPrimitiveIds.has(primitiveId)) {
221
+ throw new Error(`Renderer-ready geometry references unknown primitive ${primitiveId}.`);
222
+ }
223
+ }
224
+
225
+ return Object.freeze(output);
226
+ }
package/src/index.d.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  FeedbackGameDiagnosticViewportBucket,
14
14
  } from "@plasius/gpu-shared/feedback-diagnostics";
15
15
  import type { XrStoreState } from "@plasius/gpu-xr";
16
+ import type { GpuModelDocument } from "@plasius/gpu-model-core";
16
17
 
17
18
  export interface CreateFeedbackGameDiagnosticSnapshotInput {
18
19
  readonly featureEnabled: boolean;
@@ -332,6 +333,30 @@ export interface WavefrontTextureSampleInput {
332
333
  readonly data: Uint8ClampedArray | Uint8Array | readonly number[];
333
334
  }
334
335
 
336
+ export interface CanonicalRendererGeometryInput {
337
+ /** Canonical primitive id whose geometry has already been decoded and validated. */
338
+ readonly primitiveId: string;
339
+ readonly positions: readonly number[] | Float32Array;
340
+ readonly indices?: readonly number[] | Uint16Array | Uint32Array;
341
+ readonly normals?: readonly number[] | Float32Array | null;
342
+ readonly texcoords?: readonly number[] | Float32Array | null;
343
+ readonly uvs?: readonly number[] | Float32Array | null;
344
+ }
345
+
346
+ export interface CreateCanonicalWavefrontMeshInputsOptions {
347
+ /** Canonical model metadata. Parsing, resource resolution, and image decoding are upstream responsibilities. */
348
+ readonly document: Pick<GpuModelDocument, "meshes" | "materials">;
349
+ /** Renderer-ready, decoded geometry indexed by canonical primitive id. */
350
+ readonly geometry: readonly CanonicalRendererGeometryInput[];
351
+ /** Decoded image samples indexed by canonical texture id. */
352
+ readonly textures?: ReadonlyMap<string, WavefrontTextureSampleInput>;
353
+ }
354
+
355
+ /** Maps canonical primitives and materials to the renderer's existing Wavefront input shape. */
356
+ export function createCanonicalWavefrontMeshInputs(
357
+ options: CreateCanonicalWavefrontMeshInputsOptions,
358
+ ): readonly WavefrontMeshInput[];
359
+
335
360
  export interface WavefrontMediumInput {
336
361
  readonly id?: number;
337
362
  readonly mediumId?: number;
@@ -1582,9 +1607,34 @@ export interface RendererSnapshot {
1582
1607
  xrActive: boolean;
1583
1608
  }
1584
1609
 
1610
+ /** Decoded RGBA8 image, matching gpu-shared glTF material textures. */
1611
+ export interface NativeSurfaceImage {
1612
+ width: number;
1613
+ height: number;
1614
+ data: Uint8Array | Uint8ClampedArray;
1615
+ }
1616
+ export interface NativeSurfaceMaterial {
1617
+ /** sRGB colour; RGB is multiplied by each vertex's linear base-colour factor. */
1618
+ baseColor?: NativeSurfaceImage;
1619
+ /** Linear, OpenGL-style tangent-space normal map. */
1620
+ normal?: NativeSurfaceImage;
1621
+ /** Linear R occlusion, G roughness, B metalness. */
1622
+ orm?: NativeSurfaceImage;
1623
+ normalScale?: number;
1624
+ /** Independent dielectric coating strength, 0–1; defaults to 0 (off). */
1625
+ clearcoat?: number;
1626
+ /** Coating roughness, 0–1; independent of the base map; defaults to 0. */
1627
+ clearcoatRoughness?: number;
1628
+ /** Linear RGBA8: R multiplies clearcoat, G multiplies clearcoatRoughness; B/A ignored. White when omitted. */
1629
+ clearcoatMap?: NativeSurfaceImage;
1630
+ }
1585
1631
  /** Interleaved world-space triangles: position, normal, linear colour, roughness/metalness/detail. */
1586
1632
  export interface NativeSceneFrame {
1587
1633
  vertices: Float32Array;
1634
+ /** Two UV components per vertex. Required when surfaces are supplied. */
1635
+ texcoords?: Float32Array;
1636
+ /** Contiguous triangle ranges covering all vertices, using zero-based materials. */
1637
+ surfaces?: { firstVertex: number; vertexCount: number; materialIndex: number }[];
1588
1638
  camera: { eye: [number, number, number]; target: [number, number, number]; fov?: number };
1589
1639
  time?: number;
1590
1640
  water?: boolean;
@@ -1605,6 +1655,8 @@ export function createNativeSceneRenderer(options: {
1605
1655
  canvas: HTMLCanvasElement;
1606
1656
  navigator?: Navigator | { gpu?: GPU };
1607
1657
  maxVertices?: number;
1658
+ /** At most 16 materials, 64 MiB decoded RGBA8 total; maps are power-of-two, up to 2048. */
1659
+ materials?: NativeSurfaceMaterial[];
1608
1660
  onUnavailable?: (message: string) => void;
1609
1661
  }): Promise<{
1610
1662
  render(frame: NativeSceneFrame): NativeSceneSnapshot;
@@ -2028,3 +2080,6 @@ export const rendererWorkerProfiles: Readonly<
2028
2080
  export const rendererWorkerManifests: Readonly<
2029
2081
  Record<RendererWorkerProfileName, RendererWorkerManifest>
2030
2082
  >;
2083
+
2084
+ /** Validate native materials and bounded decoded maps without GPU allocation. */
2085
+ export declare function validateNativeSurfaceMaterials(materials: NativeSurfaceMaterial[]): void;
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { createNativeSceneRenderer } from "./native-scene-renderer.js";
2
+ export { createCanonicalWavefrontMeshInputs } from "./canonical-wavefront-meshes.js";
2
3
  export {
3
4
  createAnimatedSceneRenderer,
4
5
  } from "./animated-scene-renderer.js";
@@ -88,3 +89,5 @@ export {
88
89
  defaultRendererClearColor,
89
90
  supportsWebGpu,
90
91
  } from "./renderer-webgpu-runtime.js";
92
+
93
+ export { validateNativeMaterials as validateNativeSurfaceMaterials } from './native-surface-materials.js';
@@ -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
- pass.draw(vertexCount);
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, targets.reflectionGroup);
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(