@driftengine/assets 4.4.2 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Model readers for seven formats, and the streaming loader that uploads them.
4
4
 
5
- **Cost: 15.7 KB gzipped on top of core.** Measured by `scripts/size-gate.test.mjs`, which fails if it drifts more than
5
+ **Cost: 22.5 KB gzipped on top of core.** Measured by `scripts/size-gate.test.mjs`, which fails if it drifts more than
6
6
  3% — the number is a fact about the build rather than a claim in a document.
7
7
 
8
8
  Part of [DriftEngine](../../README.md). See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md)
package/dist/drawKey.d.ts CHANGED
@@ -23,6 +23,15 @@ export interface DrawSurfaceOverride {
23
23
  * gallery and read as a black blotch. 0 says dielectric.
24
24
  */
25
25
  readonly metallicScale?: number;
26
+ /**
27
+ * Glass, in place of what the file says: the share of light that passes, how milky it is, and
28
+ * the colour it takes (`GlassOptions` in the renderer). For a model whose panes arrive as an
29
+ * opaque dark material, because its format or its exporter had no word for glass. Transmission
30
+ * above zero draws the part blended.
31
+ */
32
+ readonly transmission?: number;
33
+ readonly frost?: number;
34
+ readonly tint?: readonly [number, number, number];
26
35
  }
27
36
  /**
28
37
  * The fields a merged group carries, resolved from a material and its override.
@@ -47,6 +56,13 @@ export interface DrawGrouping {
47
56
  /** Whether both faces are drawn and lit. A merge of a two-sided part and a one-sided one would cull
48
57
  * one of them or unhide the back of the other, so it is part of the key. */
49
58
  readonly doubleSided: boolean;
59
+ /**
60
+ * Glass. Zero transmission is not glass, and then frost is 0 and the tint white whatever the
61
+ * file or the override said, so a stray value on an opaque surface never splits a draw.
62
+ */
63
+ readonly transmission: number;
64
+ readonly frost: number;
65
+ readonly tint: readonly [number, number, number];
50
66
  }
51
67
  /**
52
68
  * Resolve what a part will actually be drawn with.
@@ -56,7 +72,7 @@ export interface DrawGrouping {
56
72
  * 0, which is the identity that lets a file saying nothing about a property draw the same as one
57
73
  * that did not have the property to say.
58
74
  *
59
- * The override wins over the material for the three fields it may carry, because a consumer dressing
75
+ * The override wins over the material for the fields it may carry, because a consumer dressing
60
76
  * a surface by name knows more than a default an exporter wrote. A cutout is not among them: it is
61
77
  * a fact about the *image* a material wears, so nothing outside the file has anything to say
62
78
  * about it.
package/dist/drawKey.js CHANGED
@@ -1,3 +1,4 @@
1
+ const CLEAR = [1, 1, 1];
1
2
  /**
2
3
  * Resolve what a part will actually be drawn with.
3
4
  *
@@ -6,12 +7,14 @@
6
7
  * 0, which is the identity that lets a file saying nothing about a property draw the same as one
7
8
  * that did not have the property to say.
8
9
  *
9
- * The override wins over the material for the three fields it may carry, because a consumer dressing
10
+ * The override wins over the material for the fields it may carry, because a consumer dressing
10
11
  * a surface by name knows more than a default an exporter wrote. A cutout is not among them: it is
11
12
  * a fact about the *image* a material wears, so nothing outside the file has anything to say
12
13
  * about it.
13
14
  */
14
15
  export function resolveDrawGrouping(material, override) {
16
+ const transmission = Math.max(0, override?.transmission ?? material?.transmission ?? 0);
17
+ const glass = transmission > 0;
15
18
  return {
16
19
  albedo: material?.albedo ?? -1,
17
20
  orm: material?.ormMap ?? -1,
@@ -23,8 +26,11 @@ export function resolveDrawGrouping(material, override) {
23
26
  metallicScale: override?.metallicScale ?? material?.metallicScale ?? 1,
24
27
  occlusionStrength: material?.occlusionStrength ?? 0,
25
28
  cutout: material?.cutout ?? 0,
26
- blend: material?.blend === true,
29
+ blend: material?.blend === true || glass,
27
30
  doubleSided: material?.doubleSided === true,
31
+ transmission,
32
+ frost: glass ? (override?.frost ?? material?.frost ?? 0) : 0,
33
+ tint: glass ? (override?.tint ?? material?.tint ?? CLEAR) : CLEAR,
28
34
  };
29
35
  }
30
36
  /**
@@ -40,5 +46,5 @@ export function resolveDrawGrouping(material, override) {
40
46
  */
41
47
  export function drawKeyOf(material, override) {
42
48
  const g = resolveDrawGrouping(material, override);
43
- return `${g.albedo}:${g.orm}:${g.normal}:${g.emissive}:${g.opacity}:${g.reflectivity}:${g.roughnessScale}:${g.metallicScale}:${g.occlusionStrength}:${g.cutout}:${g.blend ? 1 : 0}:${g.doubleSided ? 1 : 0}`;
49
+ return `${g.albedo}:${g.orm}:${g.normal}:${g.emissive}:${g.opacity}:${g.reflectivity}:${g.roughnessScale}:${g.metallicScale}:${g.occlusionStrength}:${g.cutout}:${g.blend ? 1 : 0}:${g.doubleSided ? 1 : 0}:${g.transmission}:${g.frost}:${g.tint[0]},${g.tint[1]},${g.tint[2]}`;
44
50
  }
@@ -7,7 +7,9 @@ import type { DrftFieldPlacement } from './fieldPlacement.ts';
7
7
  import { TextureSet } from './drftTextures.ts';
8
8
  import type { DrawSurfaceOverride } from './drawKey.ts';
9
9
  import type { AnimationClip, DrftLight, DrftMorph, DrftNode, DrftSkin } from '@driftengine/drft';
10
+ import type { DrftLightVolume, EntsScene, NavPolyMesh } from '@driftengine/drft';
10
11
  import type { DrftLoadProgress, DrftPart } from './loadProgress.ts';
12
+ import type { LoadedRegion } from './regionStore.ts';
11
13
  /**
12
14
  * How a loaded model is placed: fitted to a size, or left exactly where its file puts it.
13
15
  *
@@ -152,6 +154,23 @@ export interface DrftLoaderOptions {
152
154
  * of the file — which is why it is an option a caller weighs rather than a default.
153
155
  */
154
156
  readonly texturePreview?: number;
157
+ /**
158
+ * Take each image as it decodes, by the name its `TEXS` chunk gives it, and upload none: for a
159
+ * consumer that builds its own textures — a world packing every picture into a layer of an
160
+ * array — where a surface texture of each as well would spend the memory twice. On the same
161
+ * frame clock as an upload, one a frame; `null` for an image that did not decode. Materials in
162
+ * the file that name an image then draw untextured, which is the consumer's to answer.
163
+ */
164
+ readonly onImage?: (name: string, image: ImageBitmap | null) => void;
165
+ /**
166
+ * Offered each mesh no region holds as it arrives, by its ordinal in the file; return true to
167
+ * take it, and it is neither uploaded as a part nor merged. For a consumer that draws some of a
168
+ * file's meshes its own way — a crowd of kinds it instances and moves every frame — which a part
169
+ * cannot be, since the merge bakes a part into a static group by the image it wears. Counted as
170
+ * arrived, so a loading bar still finishes. What it gives up: the loader's upload budget and
171
+ * fade, which a taken mesh leaves to the consumer.
172
+ */
173
+ readonly onMesh?: (mesh: MeshData, ordinal: number) => boolean;
155
174
  }
156
175
  /**
157
176
  * A `.drft` loaded progressively: the model builds up on screen instead of appearing.
@@ -185,6 +204,11 @@ export declare class DrftLoader {
185
204
  private readonly revealed;
186
205
  /** Arrived, not yet uploaded. Drained by `update` at the budget. */
187
206
  private readonly queue;
207
+ /**
208
+ * Paged region meshes asked for and not yet up, drained after `queue` on the same clock: each is
209
+ * an expansion from the copies the file carried, then an upload.
210
+ */
211
+ private pageQueue;
188
212
  /** `INST`'s placements by mesh ordinal, in hand before the meshes they place arrive. */
189
213
  private readonly loadedInstances;
190
214
  /**
@@ -253,6 +277,10 @@ export declare class DrftLoader {
253
277
  */
254
278
  private loadedNodes;
255
279
  private loadedLights;
280
+ private loadedColliders;
281
+ private loadedNavigation;
282
+ private loadedEntities;
283
+ private loadedLightVolume;
256
284
  private loadedFields;
257
285
  /** The fields placed, built once the fit and the fields are both known. See `fields`. */
258
286
  private placedFields;
@@ -276,6 +304,8 @@ export declare class DrftLoader {
276
304
  private merged;
277
305
  private disposed;
278
306
  private arrived;
307
+ /** A streamed world's regions, kept apart from the parts and never merged. See `regionStore.ts`. */
308
+ private readonly regionStore;
279
309
  constructor(renderer: RendererApi, options?: DrftLoaderOptions);
280
310
  get progress(): DrftLoadProgress;
281
311
  /**
@@ -306,6 +336,41 @@ export declare class DrftLoader {
306
336
  * geometry. What a candela is in this scene's light units is the caller's decision.
307
337
  */
308
338
  get lights(): readonly DrftLight[];
339
+ /**
340
+ * The convex hulls the asset collides as, in the file's own space and unfitted, or empty. Handed
341
+ * on for the caller to build shapes from, as `hullShape` takes them.
342
+ */
343
+ get colliders(): readonly Float32Array[];
344
+ /** The asset's navigation mesh, unfitted, or null for a file carrying no `NAVM`. */
345
+ get navigation(): NavPolyMesh | null;
346
+ /**
347
+ * The asset's serialised entities, or null for a file carrying no `ENTS` — handed on as
348
+ * `deserializeWorld` takes them, because what an entity is belongs to the caller's schemas.
349
+ */
350
+ get entities(): EntsScene | null;
351
+ /**
352
+ * A streamed world's regions by id, each filling as its meshes and props upload — `pending` is 0
353
+ * once one is whole. Their meshes are never among `parts`: a region's levels are alternatives,
354
+ * drawn one at a time through `HlodSet`, and its batches cull instance by instance.
355
+ */
356
+ get regions(): ReadonlyMap<number, LoadedRegion>;
357
+ /** One mesh to draw, from the stream or expanded from an assembly, queued for upload. */
358
+ private take;
359
+ /**
360
+ * Bring a region's paged level up, or free it. A level that arrived as assemblies (`paged`) is
361
+ * held as the copies the file carried; asked for, each mesh is expanded and uploaded on the
362
+ * loader's clocked queue in later `update`s, and `resident` turns true when the last is up. Freed,
363
+ * its meshes go and the copies stay. A level that is not paged ignores this.
364
+ *
365
+ * The caller decides from what it will draw: page in the levels `HlodSet` is choosing or about
366
+ * to, draw a coarser one until the fine one is resident, and page out what the eye has left.
367
+ */
368
+ pageRegion(id: number, level: number, resident: boolean): void;
369
+ /**
370
+ * A world's summed lights, unfitted, or null until `LVOL` lands and for a file carrying none.
371
+ * Handed straight to `createWorldLightField`, whose volume it is shaped as.
372
+ */
373
+ get lightVolume(): DrftLightVolume | null;
309
374
  /**
310
375
  * The distance fields the file carries, each where it stands: at the loader's fit, and once a
311
376
  * copy for a mesh drawn many times. Empty until the fields and the fit have both arrived, and for
@@ -422,10 +487,9 @@ export declare class DrftLoader {
422
487
  /**
423
488
  * A mesh the file draws many times: uploaded once, placed by a batch, and kept out of the merge.
424
489
  *
425
- * **The fit is conjugated into each placement.** The prototype is uploaded with the fit already
426
- * applied, as every part is, so a copy's matrix M becomes F·M·F⁻¹: the same turn, and a translation
427
- * of t − R·t + s·u for the fit's scale s and offset t and the copy's own translation u. A fitted
428
- * model's copies then land where its geometry does, and are not scaled twice.
490
+ * **The fit is conjugated into each placement** by `fitCopies`, which a region's props share: the
491
+ * prototype is uploaded with the fit already applied, so the copies land where its geometry does
492
+ * and are not scaled twice.
429
493
  */
430
494
  private uploadInstanced;
431
495
  private buildGroup;
@@ -1,9 +1,10 @@
1
1
  /** Loading a `.drft` into drawable parts progressively, without stalling the frame. */
2
- import { MeshBuilder, concatMeshes, createMeshInstances, placeMesh } from '@driftengine/core';
2
+ import { MeshBuilder, concatMeshes, placeMesh } from '@driftengine/core';
3
3
  import { placeFields } from './fieldPlacement.js';
4
4
  import { TextureSet, textureColorSpaces } from './drftTextures.js';
5
5
  import { drawKeyOf, resolveDrawGrouping } from './drawKey.js';
6
- import { streamDrft, CODEC_JPEG, CODEC_PNG, CODEC_RAW, CODEC_WEBP, DrftError, } from '@driftengine/drft';
6
+ import { streamDrft, CODEC_JPEG, CODEC_PNG, CODEC_RAW, CODEC_WEBP, DrftError, expandAssembly, } from '@driftengine/drft';
7
+ import { RegionStore, fitCopies } from './regionStore.js';
7
8
  import { DEFAULT_REVEAL_SEC, DEFAULT_UPLOAD_MS_PER_FRAME, DEFAULT_UPLOADS_PER_FRAME, isDocumentResponse, mayBeginMore, } from './uploadBudget.js';
8
9
  /**
9
10
  * A `.drft` loaded progressively: the model builds up on screen instead of appearing.
@@ -37,6 +38,11 @@ export class DrftLoader {
37
38
  revealed = [];
38
39
  /** Arrived, not yet uploaded. Drained by `update` at the budget. */
39
40
  queue = [];
41
+ /**
42
+ * Paged region meshes asked for and not yet up, drained after `queue` on the same clock: each is
43
+ * an expansion from the copies the file carried, then an upload.
44
+ */
45
+ pageQueue = [];
40
46
  /** `INST`'s placements by mesh ordinal, in hand before the meshes they place arrive. */
41
47
  loadedInstances = new Map();
42
48
  /**
@@ -105,6 +111,10 @@ export class DrftLoader {
105
111
  */
106
112
  loadedNodes = [];
107
113
  loadedLights = [];
114
+ loadedColliders = [];
115
+ loadedNavigation = null;
116
+ loadedEntities = null;
117
+ loadedLightVolume = null;
108
118
  loadedFields = [];
109
119
  /** The fields placed, built once the fit and the fields are both known. See `fields`. */
110
120
  placedFields = null;
@@ -137,9 +147,12 @@ export class DrftLoader {
137
147
  merged = false;
138
148
  disposed = false;
139
149
  arrived = 0;
150
+ /** A streamed world's regions, kept apart from the parts and never merged. See `regionStore.ts`. */
151
+ regionStore;
140
152
  constructor(renderer, options = {}) {
141
153
  this.renderer = renderer;
142
154
  this.options = options;
155
+ this.regionStore = new RegionStore(renderer, () => this.fit);
143
156
  }
144
157
  get progress() {
145
158
  return this.state;
@@ -178,6 +191,91 @@ export class DrftLoader {
178
191
  get lights() {
179
192
  return this.loadedLights;
180
193
  }
194
+ /**
195
+ * The convex hulls the asset collides as, in the file's own space and unfitted, or empty. Handed
196
+ * on for the caller to build shapes from, as `hullShape` takes them.
197
+ */
198
+ get colliders() {
199
+ return this.loadedColliders;
200
+ }
201
+ /** The asset's navigation mesh, unfitted, or null for a file carrying no `NAVM`. */
202
+ get navigation() {
203
+ return this.loadedNavigation;
204
+ }
205
+ /**
206
+ * The asset's serialised entities, or null for a file carrying no `ENTS` — handed on as
207
+ * `deserializeWorld` takes them, because what an entity is belongs to the caller's schemas.
208
+ */
209
+ get entities() {
210
+ return this.loadedEntities;
211
+ }
212
+ /**
213
+ * A streamed world's regions by id, each filling as its meshes and props upload — `pending` is 0
214
+ * once one is whole. Their meshes are never among `parts`: a region's levels are alternatives,
215
+ * drawn one at a time through `HlodSet`, and its batches cull instance by instance.
216
+ */
217
+ get regions() {
218
+ return this.regionStore.regions;
219
+ }
220
+ /** One mesh to draw, from the stream or expanded from an assembly, queued for upload. */
221
+ take(mesh, ordinal) {
222
+ if (this.disposed)
223
+ return;
224
+ /* A region's own meshes are known by now: a region is written ahead of what it introduces. */
225
+ if (!this.regionStore.owns(ordinal) && this.options.onMesh?.(mesh, ordinal) === true) {
226
+ this.arrived++;
227
+ this.set({ phase: 'geometry', partsDone: this.arrived });
228
+ return;
229
+ }
230
+ const material = this.materials[ordinal];
231
+ /*
232
+ * Deltas onto the mesh they name, before it is queued for upload. The container writes a
233
+ * `MORP` immediately ahead of its `MESH` so this is in hand; a file that puts them the
234
+ * other way round still opens, and that mesh simply does not morph — a defined
235
+ * degradation rather than a wrong picture.
236
+ */
237
+ const morph = this.loadedMorphs.find((entry) => entry.mesh === ordinal);
238
+ const withMorph = morph === undefined
239
+ ? mesh
240
+ : { ...mesh, morphTargets: morph.deltas, morphTargetCount: morph.targetCount };
241
+ this.queue.push({
242
+ mesh: withMorph,
243
+ ordinal,
244
+ material,
245
+ instances: this.loadedInstances.get(ordinal) ?? null,
246
+ });
247
+ this.arrived++;
248
+ this.set({ phase: 'geometry', partsDone: this.arrived });
249
+ }
250
+ /**
251
+ * Bring a region's paged level up, or free it. A level that arrived as assemblies (`paged`) is
252
+ * held as the copies the file carried; asked for, each mesh is expanded and uploaded on the
253
+ * loader's clocked queue in later `update`s, and `resident` turns true when the last is up. Freed,
254
+ * its meshes go and the copies stay. A level that is not paged ignores this.
255
+ *
256
+ * The caller decides from what it will draw: page in the levels `HlodSet` is choosing or about
257
+ * to, draw a coarser one until the fine one is resident, and page out what the eye has left.
258
+ */
259
+ pageRegion(id, level, resident) {
260
+ const changes = this.regionStore.page(id, level, resident);
261
+ if (resident) {
262
+ for (const { ordinal, expand } of changes) {
263
+ this.pageQueue.push({ ordinal, material: this.materials[ordinal], expand });
264
+ }
265
+ return;
266
+ }
267
+ if (changes.length === 0)
268
+ return;
269
+ const released = new Set(changes.map((c) => c.ordinal));
270
+ this.pageQueue = this.pageQueue.filter((item) => !released.has(item.ordinal));
271
+ }
272
+ /**
273
+ * A world's summed lights, unfitted, or null until `LVOL` lands and for a file carrying none.
274
+ * Handed straight to `createWorldLightField`, whose volume it is shaped as.
275
+ */
276
+ get lightVolume() {
277
+ return this.loadedLightVolume;
278
+ }
181
279
  /**
182
280
  * The distance fields the file carries, each where it stands: at the loader's fit, and once a
183
281
  * copy for a mesh drawn many times. Empty until the fields and the fit have both arrived, and for
@@ -349,6 +447,15 @@ export class DrftLoader {
349
447
  onLights: (lights) => {
350
448
  this.loadedLights = lights;
351
449
  },
450
+ onColliders: (hulls) => {
451
+ this.loadedColliders = hulls;
452
+ },
453
+ onNavigation: (navigation) => {
454
+ this.loadedNavigation = navigation;
455
+ },
456
+ onEntities: (entities) => {
457
+ this.loadedEntities = entities;
458
+ },
352
459
  onFields: (fields) => {
353
460
  this.loadedFields = fields;
354
461
  this.placedFields = null;
@@ -357,6 +464,14 @@ export class DrftLoader {
357
464
  for (const group of groups)
358
465
  this.loadedInstances.set(group.mesh, group.transforms);
359
466
  },
467
+ onLightVolume: (volume) => {
468
+ this.loadedLightVolume = volume;
469
+ },
470
+ onRegion: (region) => {
471
+ if (this.disposed)
472
+ return;
473
+ this.regionStore.admit(region);
474
+ },
360
475
  onSkin: (skin) => {
361
476
  this.loadedSkins.push(skin);
362
477
  },
@@ -422,27 +537,23 @@ export class DrftLoader {
422
537
  this.materials = list;
423
538
  this.set({ phase: 'materials' });
424
539
  },
425
- onMesh: (mesh, ordinal) => {
540
+ onKit: (pieces) => {
541
+ /* A piece is drawn only as copies: it is not a part, and the count said it was. */
542
+ this.set({ partsTotal: Math.max(0, this.state.partsTotal - pieces.length) });
543
+ },
544
+ onAssembly: (assembly, ordinal, piece) => {
426
545
  if (this.disposed)
427
546
  return;
428
- const material = this.materials[ordinal];
429
- /*
430
- * Deltas onto the mesh they name, before it is queued for upload. The container writes a
431
- * `MORP` immediately ahead of its `MESH` so this is in hand; a file that puts them the
432
- * other way round still opens, and that mesh simply does not morph — a defined
433
- * degradation rather than a wrong picture.
434
- */
435
- const morph = this.loadedMorphs.find((entry) => entry.mesh === ordinal);
436
- const withMorph = morph === undefined
437
- ? mesh
438
- : { ...mesh, morphTargets: morph.deltas, morphTargetCount: morph.targetCount };
439
- this.queue.push({
440
- mesh: withMorph,
441
- material,
442
- instances: this.loadedInstances.get(ordinal) ?? null,
443
- });
444
- this.arrived++;
445
- this.set({ phase: 'geometry', partsDone: this.arrived });
547
+ if (this.regionStore.isLevelMesh(ordinal)) {
548
+ this.regionStore.hold(ordinal, assembly, piece);
549
+ this.arrived++;
550
+ this.set({ phase: 'geometry', partsDone: this.arrived });
551
+ return;
552
+ }
553
+ this.take(expandAssembly(assembly, piece), ordinal);
554
+ },
555
+ onMesh: (mesh, ordinal) => {
556
+ this.take(mesh, ordinal);
446
557
  },
447
558
  onTexture: (texture, ordinal) => {
448
559
  if (this.disposed)
@@ -572,10 +683,28 @@ export class DrftLoader {
572
683
  const next = this.queue.shift();
573
684
  if (next === undefined)
574
685
  break;
575
- if (this.uploadOne(next.mesh, next.material, next.instances))
686
+ if (this.uploadOne(next.mesh, next.material, next.instances, next.ordinal))
576
687
  changed = true;
577
688
  begun++;
578
689
  }
690
+ /* Paged region meshes, after the stream's own: an expansion from the copies, then an upload. */
691
+ while (this.pageQueue.length > 0 &&
692
+ mayBeginMore(begun, performance.now() - startedMs, msBudget)) {
693
+ const next = this.pageQueue.shift();
694
+ if (next === undefined)
695
+ break;
696
+ if (this.uploadOne(next.expand(), next.material, null, next.ordinal))
697
+ changed = true;
698
+ begun++;
699
+ }
700
+ /* A region's prop batches, on the same clock: each is an allocation and an upload. Drained
701
+ before the merge below can begin, which is what lets `ready` not wait on them separately. */
702
+ while (this.regionStore.busy &&
703
+ mayBeginMore(begun, performance.now() - startedMs, msBudget) &&
704
+ this.regionStore.buildNext()) {
705
+ changed = true;
706
+ begun++;
707
+ }
579
708
  /* One image a frame, and only into a frame that has room for it. A 2048 square decodes
580
709
  off-thread and then uploads with its mips, which is the single most expensive thing in a
581
710
  load and lands right after the geometry — on top of the parts, until this was clocked. */
@@ -583,7 +712,11 @@ export class DrftLoader {
583
712
  mayBeginMore(begun, performance.now() - startedMs, msBudget)) {
584
713
  const ordinal = this.imageQueue.shift();
585
714
  if (ordinal !== undefined) {
586
- this.rebuildTextures(ordinal);
715
+ const take = this.options.onImage;
716
+ if (take === undefined)
717
+ this.rebuildTextures(ordinal);
718
+ else
719
+ take(this.chunks[ordinal]?.name ?? '', this.images[ordinal] ?? null);
587
720
  /*
588
721
  * Counted here, once, and only for the image itself.
589
722
  *
@@ -655,6 +788,7 @@ export class DrftLoader {
655
788
  if (this.disposed)
656
789
  return;
657
790
  this.disposed = true;
791
+ this.regionStore.dispose();
658
792
  /*
659
793
  * Everything this class ever made, each disposed once.
660
794
  *
@@ -742,6 +876,7 @@ export class DrftLoader {
742
876
  cutout: 0,
743
877
  blend: false,
744
878
  doubleSided: false,
879
+ glass: null,
745
880
  reveal: this.outlinePart?.reveal ?? 0,
746
881
  instances: null,
747
882
  };
@@ -759,7 +894,7 @@ export class DrftLoader {
759
894
  return true;
760
895
  }
761
896
  /** One part, transformed, dressed and uploaded. Returns whether it landed. */
762
- uploadOne(mesh, material, instances) {
897
+ uploadOne(mesh, material, instances, ordinal) {
763
898
  const fit = this.fit;
764
899
  if (fit === null)
765
900
  return false;
@@ -778,6 +913,37 @@ export class DrftLoader {
778
913
  */
779
914
  const g = resolveDrawGrouping(material, override);
780
915
  const { albedo, orm, normal, emissive, opacity, reflectivity, roughnessScale, metallicScale, occlusionStrength, cutout, blend, doubleSided, } = g;
916
+ /*
917
+ * A region's mesh — a level or a prop's prototype — is one part of its own, never grouped: the
918
+ * merge would weld a region's levels together and one region to the next. See `regionStore.ts`.
919
+ */
920
+ if (this.regionStore.owns(ordinal)) {
921
+ try {
922
+ this.regionStore.arrived(ordinal, {
923
+ mesh: this.renderer.createMesh(placed),
924
+ albedo,
925
+ orm,
926
+ normal,
927
+ emissive,
928
+ opacity,
929
+ reflectivity,
930
+ roughnessScale,
931
+ metallicScale,
932
+ occlusionStrength,
933
+ cutout,
934
+ blend,
935
+ doubleSided,
936
+ glass: glassOf(g),
937
+ reveal: 1,
938
+ instances: null,
939
+ });
940
+ return true;
941
+ }
942
+ catch (error) {
943
+ console.warn(`DrftLoader: region mesh ${ordinal} would not upload and was skipped`, error);
944
+ return false;
945
+ }
946
+ }
781
947
  /*
782
948
  * Added to its merged group here, while there is a budget governing how much of this happens
783
949
  * in one frame.
@@ -796,6 +962,7 @@ export class DrftLoader {
796
962
  cutout,
797
963
  blend,
798
964
  doubleSided,
965
+ glass: glassOf(g),
799
966
  });
800
967
  }
801
968
  const key = drawKeyOf(material, override);
@@ -814,6 +981,7 @@ export class DrftLoader {
814
981
  cutout,
815
982
  blend,
816
983
  doubleSided,
984
+ glass: glassOf(g),
817
985
  members: [],
818
986
  };
819
987
  this.groups.set(key, group);
@@ -854,6 +1022,7 @@ export class DrftLoader {
854
1022
  cutout,
855
1023
  blend,
856
1024
  doubleSided,
1025
+ glass: group.glass,
857
1026
  reveal: 0,
858
1027
  instances: null,
859
1028
  };
@@ -879,42 +1048,15 @@ export class DrftLoader {
879
1048
  /**
880
1049
  * A mesh the file draws many times: uploaded once, placed by a batch, and kept out of the merge.
881
1050
  *
882
- * **The fit is conjugated into each placement.** The prototype is uploaded with the fit already
883
- * applied, as every part is, so a copy's matrix M becomes F·M·F⁻¹: the same turn, and a translation
884
- * of t − R·t + s·u for the fit's scale s and offset t and the copy's own translation u. A fitted
885
- * model's copies then land where its geometry does, and are not scaled twice.
1051
+ * **The fit is conjugated into each placement** by `fitCopies`, which a region's props share: the
1052
+ * prototype is uploaded with the fit already applied, so the copies land where its geometry does
1053
+ * and are not scaled twice.
886
1054
  */
887
1055
  uploadInstanced(prototype, instances, fit, surface) {
888
1056
  try {
889
1057
  const uploaded = this.renderer.createMesh(prototype);
890
- const count = instances.length / 16;
891
- const batch = this.renderer.createInstanced(uploaded, count);
892
- const data = createMeshInstances(count);
893
- const s = fit.scale;
894
- for (let k = 0; k < count; k++) {
895
- const at = k * 16;
896
- for (let i = 0; i < 12; i++)
897
- data.models[at + i] = instances[at + i];
898
- const ux = instances[at + 12];
899
- const uy = instances[at + 13];
900
- const uz = instances[at + 14];
901
- /* R·t, with R the copy's own 3×3 read column-major. */
902
- const rtx = instances[at] * fit.x +
903
- instances[at + 4] * fit.y +
904
- instances[at + 8] * fit.z;
905
- const rty = instances[at + 1] * fit.x +
906
- instances[at + 5] * fit.y +
907
- instances[at + 9] * fit.z;
908
- const rtz = instances[at + 2] * fit.x +
909
- instances[at + 6] * fit.y +
910
- instances[at + 10] * fit.z;
911
- data.models[at + 12] = fit.x - rtx + s * ux;
912
- data.models[at + 13] = fit.y - rty + s * uy;
913
- data.models[at + 14] = fit.z - rtz + s * uz;
914
- data.models[at + 15] = 1;
915
- }
916
- data.tints.fill(1);
917
- data.count = count;
1058
+ const data = fitCopies(instances, fit);
1059
+ const batch = this.renderer.createInstanced(uploaded, data.count);
918
1060
  this.renderer.uploadInstanced(batch, data);
919
1061
  const part = { mesh: uploaded, ...surface, reveal: 0, instances: { batch, data } };
920
1062
  this.revealed.push(part);
@@ -947,6 +1089,7 @@ export class DrftLoader {
947
1089
  cutout: group.cutout,
948
1090
  blend: group.blend,
949
1091
  doubleSided: group.doubleSided,
1092
+ glass: group.glass,
950
1093
  reveal: 1,
951
1094
  instances: null,
952
1095
  });
@@ -1068,6 +1211,12 @@ export class DrftLoader {
1068
1211
  };
1069
1212
  }
1070
1213
  }
1214
+ /** A group's glass as the renderer takes it, or null for a group that lets no light through. */
1215
+ function glassOf(g) {
1216
+ if (g.transmission <= 0)
1217
+ return null;
1218
+ return { transmission: g.transmission, frost: g.frost, tint: [g.tint[0], g.tint[1], g.tint[2]] };
1219
+ }
1071
1220
  /**
1072
1221
  * The MIME type a codec's bytes carry, or `null` for one the browser has no decoder for.
1073
1222
  *
package/dist/gltf.d.ts CHANGED
@@ -80,16 +80,35 @@ interface GltfSpecularGlossiness {
80
80
  texCoord?: number;
81
81
  };
82
82
  }
83
+ /**
84
+ * The three extensions that together say a material is glass: how much light passes
85
+ * (`KHR_materials_transmission`), how much of it is scattered on the way and the colour it takes
86
+ * (`KHR_materials_diffuse_transmission`), and the colour a thickness of it absorbs towards
87
+ * (`KHR_materials_volume`). Only the fields this reader maps are named.
88
+ */
89
+ interface GltfTransmission {
90
+ transmissionFactor?: number;
91
+ }
92
+ interface GltfDiffuseTransmission {
93
+ diffuseTransmissionFactor?: number;
94
+ diffuseTransmissionColorFactor?: number[];
95
+ }
96
+ interface GltfVolume {
97
+ attenuationColor?: number[];
98
+ }
83
99
  interface GltfMaterial {
84
100
  pbrMetallicRoughness?: GltfPbrMetallicRoughness;
85
101
  /**
86
- * The material extensions this reader knows. One, and everything else is ignored.
102
+ * The material extensions this reader knows, and everything else is ignored.
87
103
  *
88
- * Read as an optional field rather than as `Record<string, unknown>` so that adding a second
89
- * costs a name here and a branch in `pbrOf`, and so that a typo in either is a compile error.
104
+ * Read as optional fields rather than as `Record<string, unknown>` so that adding one costs a
105
+ * name here and a branch where it is read, and so that a typo in either is a compile error.
90
106
  */
91
107
  extensions?: {
92
108
  KHR_materials_pbrSpecularGlossiness?: GltfSpecularGlossiness;
109
+ KHR_materials_transmission?: GltfTransmission;
110
+ KHR_materials_diffuse_transmission?: GltfDiffuseTransmission;
111
+ KHR_materials_volume?: GltfVolume;
93
112
  };
94
113
  normalTexture?: {
95
114
  index: number;