@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/src/drftLoader.ts CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  import type { MeshData } from '@driftengine/drft';
4
4
  import type { MeshHandle, RendererApi } from '@driftengine/core';
5
- import type { SurfaceTextureHandle } from '@driftengine/core';
6
- import { MeshBuilder, concatMeshes, createMeshInstances, placeMesh } from '@driftengine/core';
5
+ import type { GlassOptions, SurfaceTextureHandle } from '@driftengine/core';
6
+ import { MeshBuilder, concatMeshes, placeMesh } from '@driftengine/core';
7
7
  import type { DrftMaterial } from '@driftengine/drft';
8
8
  import type { DrftSdfvEntry } from '@driftengine/drft';
9
9
  import { placeFields } from './fieldPlacement.ts';
@@ -11,7 +11,7 @@ import type { DrftFieldPlacement } from './fieldPlacement.ts';
11
11
  import type { DrftTexture } from '@driftengine/drft';
12
12
  import { TextureSet, textureColorSpaces } from './drftTextures.ts';
13
13
  import { drawKeyOf, resolveDrawGrouping } from './drawKey.ts';
14
- import type { DrawSurfaceOverride } from './drawKey.ts';
14
+ import type { DrawGrouping, DrawSurfaceOverride } from './drawKey.ts';
15
15
  import {
16
16
  streamDrft,
17
17
  CODEC_JPEG,
@@ -19,9 +19,13 @@ import {
19
19
  CODEC_RAW,
20
20
  CODEC_WEBP,
21
21
  DrftError,
22
+ expandAssembly,
22
23
  } from '@driftengine/drft';
23
24
  import type { AnimationClip, DrftLight, DrftMorph, DrftNode, DrftSkin } from '@driftengine/drft';
25
+ import type { DrftLightVolume, EntsScene, NavPolyMesh } from '@driftengine/drft';
24
26
  import type { DrftLoadProgress, DrftPart } from './loadProgress.ts';
27
+ import { RegionStore, fitCopies } from './regionStore.ts';
28
+ import type { LoadedRegion } from './regionStore.ts';
25
29
  import {
26
30
  DEFAULT_REVEAL_SEC,
27
31
  DEFAULT_UPLOAD_MS_PER_FRAME,
@@ -178,6 +182,23 @@ export interface DrftLoaderOptions {
178
182
  * of the file — which is why it is an option a caller weighs rather than a default.
179
183
  */
180
184
  readonly texturePreview?: number;
185
+ /**
186
+ * Take each image as it decodes, by the name its `TEXS` chunk gives it, and upload none: for a
187
+ * consumer that builds its own textures — a world packing every picture into a layer of an
188
+ * array — where a surface texture of each as well would spend the memory twice. On the same
189
+ * frame clock as an upload, one a frame; `null` for an image that did not decode. Materials in
190
+ * the file that name an image then draw untextured, which is the consumer's to answer.
191
+ */
192
+ readonly onImage?: (name: string, image: ImageBitmap | null) => void;
193
+ /**
194
+ * Offered each mesh no region holds as it arrives, by its ordinal in the file; return true to
195
+ * take it, and it is neither uploaded as a part nor merged. For a consumer that draws some of a
196
+ * file's meshes its own way — a crowd of kinds it instances and moves every frame — which a part
197
+ * cannot be, since the merge bakes a part into a static group by the image it wears. Counted as
198
+ * arrived, so a loading bar still finishes. What it gives up: the loader's upload budget and
199
+ * fade, which a taken mesh leaves to the consumer.
200
+ */
201
+ readonly onMesh?: (mesh: MeshData, ordinal: number) => boolean;
181
202
  }
182
203
 
183
204
  /**
@@ -213,10 +234,20 @@ export class DrftLoader {
213
234
  /** Arrived, not yet uploaded. Drained by `update` at the budget. */
214
235
  private readonly queue: {
215
236
  mesh: MeshData;
237
+ ordinal: number;
216
238
  material: DrftMaterial | undefined;
217
239
  /** Sixteen floats a copy when the file draws this mesh many times, else null. */
218
240
  instances: Float32Array | null;
219
241
  }[] = [];
242
+ /**
243
+ * Paged region meshes asked for and not yet up, drained after `queue` on the same clock: each is
244
+ * an expansion from the copies the file carried, then an upload.
245
+ */
246
+ private pageQueue: {
247
+ ordinal: number;
248
+ material: DrftMaterial | undefined;
249
+ expand: () => MeshData;
250
+ }[] = [];
220
251
  /** `INST`'s placements by mesh ordinal, in hand before the meshes they place arrive. */
221
252
  private readonly loadedInstances = new Map<number, Float32Array>();
222
253
  /**
@@ -260,6 +291,8 @@ export class DrftLoader {
260
291
  cutout: number;
261
292
  blend: boolean;
262
293
  doubleSided: boolean;
294
+ /** Built once when the group is made, and shared by every part drawn from it. */
295
+ glass: GlassOptions | null;
263
296
  /** The group's parts, placed, joined once when the group is built. See `concatMeshes`. */
264
297
  members: MeshData[];
265
298
  }
@@ -303,6 +336,10 @@ export class DrftLoader {
303
336
  */
304
337
  private loadedNodes: readonly DrftNode[] = [];
305
338
  private loadedLights: readonly DrftLight[] = [];
339
+ private loadedColliders: readonly Float32Array[] = [];
340
+ private loadedNavigation: NavPolyMesh | null = null;
341
+ private loadedEntities: EntsScene | null = null;
342
+ private loadedLightVolume: DrftLightVolume | null = null;
306
343
  private loadedFields: readonly DrftSdfvEntry[] = [];
307
344
  /** The fields placed, built once the fit and the fields are both known. See `fields`. */
308
345
  private placedFields: readonly DrftFieldPlacement[] | null = null;
@@ -335,10 +372,13 @@ export class DrftLoader {
335
372
  private merged = false;
336
373
  private disposed = false;
337
374
  private arrived = 0;
375
+ /** A streamed world's regions, kept apart from the parts and never merged. See `regionStore.ts`. */
376
+ private readonly regionStore: RegionStore;
338
377
 
339
378
  constructor(renderer: RendererApi, options: DrftLoaderOptions = {}) {
340
379
  this.renderer = renderer;
341
380
  this.options = options;
381
+ this.regionStore = new RegionStore(renderer, () => this.fit);
342
382
  }
343
383
 
344
384
  get progress(): DrftLoadProgress {
@@ -382,6 +422,97 @@ export class DrftLoader {
382
422
  return this.loadedLights;
383
423
  }
384
424
 
425
+ /**
426
+ * The convex hulls the asset collides as, in the file's own space and unfitted, or empty. Handed
427
+ * on for the caller to build shapes from, as `hullShape` takes them.
428
+ */
429
+ get colliders(): readonly Float32Array[] {
430
+ return this.loadedColliders;
431
+ }
432
+
433
+ /** The asset's navigation mesh, unfitted, or null for a file carrying no `NAVM`. */
434
+ get navigation(): NavPolyMesh | null {
435
+ return this.loadedNavigation;
436
+ }
437
+
438
+ /**
439
+ * The asset's serialised entities, or null for a file carrying no `ENTS` — handed on as
440
+ * `deserializeWorld` takes them, because what an entity is belongs to the caller's schemas.
441
+ */
442
+ get entities(): EntsScene | null {
443
+ return this.loadedEntities;
444
+ }
445
+
446
+ /**
447
+ * A streamed world's regions by id, each filling as its meshes and props upload — `pending` is 0
448
+ * once one is whole. Their meshes are never among `parts`: a region's levels are alternatives,
449
+ * drawn one at a time through `HlodSet`, and its batches cull instance by instance.
450
+ */
451
+ get regions(): ReadonlyMap<number, LoadedRegion> {
452
+ return this.regionStore.regions;
453
+ }
454
+
455
+ /** One mesh to draw, from the stream or expanded from an assembly, queued for upload. */
456
+ private take(mesh: MeshData, ordinal: number): void {
457
+ if (this.disposed) return;
458
+ /* A region's own meshes are known by now: a region is written ahead of what it introduces. */
459
+ if (!this.regionStore.owns(ordinal) && this.options.onMesh?.(mesh, ordinal) === true) {
460
+ this.arrived++;
461
+ this.set({ phase: 'geometry', partsDone: this.arrived });
462
+ return;
463
+ }
464
+ const material = this.materials[ordinal];
465
+ /*
466
+ * Deltas onto the mesh they name, before it is queued for upload. The container writes a
467
+ * `MORP` immediately ahead of its `MESH` so this is in hand; a file that puts them the
468
+ * other way round still opens, and that mesh simply does not morph — a defined
469
+ * degradation rather than a wrong picture.
470
+ */
471
+ const morph = this.loadedMorphs.find((entry) => entry.mesh === ordinal);
472
+ const withMorph =
473
+ morph === undefined
474
+ ? mesh
475
+ : { ...mesh, morphTargets: morph.deltas, morphTargetCount: morph.targetCount };
476
+ this.queue.push({
477
+ mesh: withMorph,
478
+ ordinal,
479
+ material,
480
+ instances: this.loadedInstances.get(ordinal) ?? null,
481
+ });
482
+ this.arrived++;
483
+ this.set({ phase: 'geometry', partsDone: this.arrived });
484
+ }
485
+
486
+ /**
487
+ * Bring a region's paged level up, or free it. A level that arrived as assemblies (`paged`) is
488
+ * held as the copies the file carried; asked for, each mesh is expanded and uploaded on the
489
+ * loader's clocked queue in later `update`s, and `resident` turns true when the last is up. Freed,
490
+ * its meshes go and the copies stay. A level that is not paged ignores this.
491
+ *
492
+ * The caller decides from what it will draw: page in the levels `HlodSet` is choosing or about
493
+ * to, draw a coarser one until the fine one is resident, and page out what the eye has left.
494
+ */
495
+ pageRegion(id: number, level: number, resident: boolean): void {
496
+ const changes = this.regionStore.page(id, level, resident);
497
+ if (resident) {
498
+ for (const { ordinal, expand } of changes) {
499
+ this.pageQueue.push({ ordinal, material: this.materials[ordinal], expand });
500
+ }
501
+ return;
502
+ }
503
+ if (changes.length === 0) return;
504
+ const released = new Set(changes.map((c) => c.ordinal));
505
+ this.pageQueue = this.pageQueue.filter((item) => !released.has(item.ordinal));
506
+ }
507
+
508
+ /**
509
+ * A world's summed lights, unfitted, or null until `LVOL` lands and for a file carrying none.
510
+ * Handed straight to `createWorldLightField`, whose volume it is shaped as.
511
+ */
512
+ get lightVolume(): DrftLightVolume | null {
513
+ return this.loadedLightVolume;
514
+ }
515
+
385
516
  /**
386
517
  * The distance fields the file carries, each where it stands: at the loader's fit, and once a
387
518
  * copy for a mesh drawn many times. Empty until the fields and the fit have both arrived, and for
@@ -561,6 +692,15 @@ export class DrftLoader {
561
692
  onLights: (lights) => {
562
693
  this.loadedLights = lights;
563
694
  },
695
+ onColliders: (hulls) => {
696
+ this.loadedColliders = hulls;
697
+ },
698
+ onNavigation: (navigation) => {
699
+ this.loadedNavigation = navigation;
700
+ },
701
+ onEntities: (entities) => {
702
+ this.loadedEntities = entities;
703
+ },
564
704
  onFields: (fields) => {
565
705
  this.loadedFields = fields;
566
706
  this.placedFields = null;
@@ -568,6 +708,13 @@ export class DrftLoader {
568
708
  onInstances: (groups) => {
569
709
  for (const group of groups) this.loadedInstances.set(group.mesh, group.transforms);
570
710
  },
711
+ onLightVolume: (volume) => {
712
+ this.loadedLightVolume = volume;
713
+ },
714
+ onRegion: (region) => {
715
+ if (this.disposed) return;
716
+ this.regionStore.admit(region);
717
+ },
571
718
  onSkin: (skin) => {
572
719
  this.loadedSkins.push(skin);
573
720
  },
@@ -636,27 +783,22 @@ export class DrftLoader {
636
783
  this.set({ phase: 'materials' });
637
784
  },
638
785
 
639
- onMesh: (mesh, ordinal) => {
786
+ onKit: (pieces) => {
787
+ /* A piece is drawn only as copies: it is not a part, and the count said it was. */
788
+ this.set({ partsTotal: Math.max(0, this.state.partsTotal - pieces.length) });
789
+ },
790
+ onAssembly: (assembly, ordinal, piece) => {
640
791
  if (this.disposed) return;
641
- const material = this.materials[ordinal];
642
- /*
643
- * Deltas onto the mesh they name, before it is queued for upload. The container writes a
644
- * `MORP` immediately ahead of its `MESH` so this is in hand; a file that puts them the
645
- * other way round still opens, and that mesh simply does not morph — a defined
646
- * degradation rather than a wrong picture.
647
- */
648
- const morph = this.loadedMorphs.find((entry) => entry.mesh === ordinal);
649
- const withMorph =
650
- morph === undefined
651
- ? mesh
652
- : { ...mesh, morphTargets: morph.deltas, morphTargetCount: morph.targetCount };
653
- this.queue.push({
654
- mesh: withMorph,
655
- material,
656
- instances: this.loadedInstances.get(ordinal) ?? null,
657
- });
658
- this.arrived++;
659
- this.set({ phase: 'geometry', partsDone: this.arrived });
792
+ if (this.regionStore.isLevelMesh(ordinal)) {
793
+ this.regionStore.hold(ordinal, assembly, piece);
794
+ this.arrived++;
795
+ this.set({ phase: 'geometry', partsDone: this.arrived });
796
+ return;
797
+ }
798
+ this.take(expandAssembly(assembly, piece), ordinal);
799
+ },
800
+ onMesh: (mesh, ordinal) => {
801
+ this.take(mesh, ordinal);
660
802
  },
661
803
 
662
804
  onTexture: (texture, ordinal) => {
@@ -792,7 +934,27 @@ export class DrftLoader {
792
934
  ) {
793
935
  const next = this.queue.shift();
794
936
  if (next === undefined) break;
795
- if (this.uploadOne(next.mesh, next.material, next.instances)) changed = true;
937
+ if (this.uploadOne(next.mesh, next.material, next.instances, next.ordinal)) changed = true;
938
+ begun++;
939
+ }
940
+ /* Paged region meshes, after the stream's own: an expansion from the copies, then an upload. */
941
+ while (
942
+ this.pageQueue.length > 0 &&
943
+ mayBeginMore(begun, performance.now() - startedMs, msBudget)
944
+ ) {
945
+ const next = this.pageQueue.shift();
946
+ if (next === undefined) break;
947
+ if (this.uploadOne(next.expand(), next.material, null, next.ordinal)) changed = true;
948
+ begun++;
949
+ }
950
+ /* A region's prop batches, on the same clock: each is an allocation and an upload. Drained
951
+ before the merge below can begin, which is what lets `ready` not wait on them separately. */
952
+ while (
953
+ this.regionStore.busy &&
954
+ mayBeginMore(begun, performance.now() - startedMs, msBudget) &&
955
+ this.regionStore.buildNext()
956
+ ) {
957
+ changed = true;
796
958
  begun++;
797
959
  }
798
960
 
@@ -805,7 +967,9 @@ export class DrftLoader {
805
967
  ) {
806
968
  const ordinal = this.imageQueue.shift();
807
969
  if (ordinal !== undefined) {
808
- this.rebuildTextures(ordinal);
970
+ const take = this.options.onImage;
971
+ if (take === undefined) this.rebuildTextures(ordinal);
972
+ else take(this.chunks[ordinal]?.name ?? '', this.images[ordinal] ?? null);
809
973
  /*
810
974
  * Counted here, once, and only for the image itself.
811
975
  *
@@ -882,6 +1046,7 @@ export class DrftLoader {
882
1046
  dispose(): void {
883
1047
  if (this.disposed) return;
884
1048
  this.disposed = true;
1049
+ this.regionStore.dispose();
885
1050
  /*
886
1051
  * Everything this class ever made, each disposed once.
887
1052
  *
@@ -964,6 +1129,7 @@ export class DrftLoader {
964
1129
  cutout: 0,
965
1130
  blend: false,
966
1131
  doubleSided: false,
1132
+ glass: null,
967
1133
  reveal: this.outlinePart?.reveal ?? 0,
968
1134
  instances: null,
969
1135
  };
@@ -983,6 +1149,7 @@ export class DrftLoader {
983
1149
  mesh: MeshData,
984
1150
  material: DrftMaterial | undefined,
985
1151
  instances: Float32Array | null,
1152
+ ordinal: number,
986
1153
  ): boolean {
987
1154
  const fit = this.fit;
988
1155
  if (fit === null) return false;
@@ -1015,6 +1182,37 @@ export class DrftLoader {
1015
1182
  doubleSided,
1016
1183
  } = g;
1017
1184
 
1185
+ /*
1186
+ * A region's mesh — a level or a prop's prototype — is one part of its own, never grouped: the
1187
+ * merge would weld a region's levels together and one region to the next. See `regionStore.ts`.
1188
+ */
1189
+ if (this.regionStore.owns(ordinal)) {
1190
+ try {
1191
+ this.regionStore.arrived(ordinal, {
1192
+ mesh: this.renderer.createMesh(placed),
1193
+ albedo,
1194
+ orm,
1195
+ normal,
1196
+ emissive,
1197
+ opacity,
1198
+ reflectivity,
1199
+ roughnessScale,
1200
+ metallicScale,
1201
+ occlusionStrength,
1202
+ cutout,
1203
+ blend,
1204
+ doubleSided,
1205
+ glass: glassOf(g),
1206
+ reveal: 1,
1207
+ instances: null,
1208
+ });
1209
+ return true;
1210
+ } catch (error) {
1211
+ console.warn(`DrftLoader: region mesh ${ordinal} would not upload and was skipped`, error);
1212
+ return false;
1213
+ }
1214
+ }
1215
+
1018
1216
  /*
1019
1217
  * Added to its merged group here, while there is a budget governing how much of this happens
1020
1218
  * in one frame.
@@ -1033,6 +1231,7 @@ export class DrftLoader {
1033
1231
  cutout,
1034
1232
  blend,
1035
1233
  doubleSided,
1234
+ glass: glassOf(g),
1036
1235
  });
1037
1236
  }
1038
1237
 
@@ -1052,6 +1251,7 @@ export class DrftLoader {
1052
1251
  cutout,
1053
1252
  blend,
1054
1253
  doubleSided,
1254
+ glass: glassOf(g),
1055
1255
  members: [],
1056
1256
  };
1057
1257
  this.groups.set(key, group);
@@ -1093,6 +1293,7 @@ export class DrftLoader {
1093
1293
  cutout,
1094
1294
  blend,
1095
1295
  doubleSided,
1296
+ glass: group.glass,
1096
1297
  reveal: 0,
1097
1298
  instances: null,
1098
1299
  };
@@ -1117,10 +1318,9 @@ export class DrftLoader {
1117
1318
  /**
1118
1319
  * A mesh the file draws many times: uploaded once, placed by a batch, and kept out of the merge.
1119
1320
  *
1120
- * **The fit is conjugated into each placement.** The prototype is uploaded with the fit already
1121
- * applied, as every part is, so a copy's matrix M becomes F·M·F⁻¹: the same turn, and a translation
1122
- * of t − R·t + s·u for the fit's scale s and offset t and the copy's own translation u. A fitted
1123
- * model's copies then land where its geometry does, and are not scaled twice.
1321
+ * **The fit is conjugated into each placement** by `fitCopies`, which a region's props share: the
1322
+ * prototype is uploaded with the fit already applied, so the copies land where its geometry does
1323
+ * and are not scaled twice.
1124
1324
  */
1125
1325
  private uploadInstanced(
1126
1326
  prototype: MeshData,
@@ -1130,36 +1330,8 @@ export class DrftLoader {
1130
1330
  ): boolean {
1131
1331
  try {
1132
1332
  const uploaded = this.renderer.createMesh(prototype);
1133
- const count = instances.length / 16;
1134
- const batch = this.renderer.createInstanced(uploaded, count);
1135
- const data = createMeshInstances(count);
1136
- const s = fit.scale;
1137
- for (let k = 0; k < count; k++) {
1138
- const at = k * 16;
1139
- for (let i = 0; i < 12; i++) data.models[at + i] = instances[at + i] as number;
1140
- const ux = instances[at + 12] as number;
1141
- const uy = instances[at + 13] as number;
1142
- const uz = instances[at + 14] as number;
1143
- /* R·t, with R the copy's own 3×3 read column-major. */
1144
- const rtx =
1145
- (instances[at] as number) * fit.x +
1146
- (instances[at + 4] as number) * fit.y +
1147
- (instances[at + 8] as number) * fit.z;
1148
- const rty =
1149
- (instances[at + 1] as number) * fit.x +
1150
- (instances[at + 5] as number) * fit.y +
1151
- (instances[at + 9] as number) * fit.z;
1152
- const rtz =
1153
- (instances[at + 2] as number) * fit.x +
1154
- (instances[at + 6] as number) * fit.y +
1155
- (instances[at + 10] as number) * fit.z;
1156
- data.models[at + 12] = fit.x - rtx + s * ux;
1157
- data.models[at + 13] = fit.y - rty + s * uy;
1158
- data.models[at + 14] = fit.z - rtz + s * uz;
1159
- data.models[at + 15] = 1;
1160
- }
1161
- data.tints.fill(1);
1162
- data.count = count;
1333
+ const data = fitCopies(instances, fit);
1334
+ const batch = this.renderer.createInstanced(uploaded, data.count);
1163
1335
  this.renderer.uploadInstanced(batch, data);
1164
1336
  const part: DrftPart = { mesh: uploaded, ...surface, reveal: 0, instances: { batch, data } };
1165
1337
  this.revealed.push(part);
@@ -1190,6 +1362,7 @@ export class DrftLoader {
1190
1362
  cutout: group.cutout,
1191
1363
  blend: group.blend,
1192
1364
  doubleSided: group.doubleSided,
1365
+ glass: group.glass,
1193
1366
  reveal: 1,
1194
1367
  instances: null,
1195
1368
  });
@@ -1309,6 +1482,12 @@ export class DrftLoader {
1309
1482
  }
1310
1483
  }
1311
1484
 
1485
+ /** A group's glass as the renderer takes it, or null for a group that lets no light through. */
1486
+ function glassOf(g: DrawGrouping): GlassOptions | null {
1487
+ if (g.transmission <= 0) return null;
1488
+ return { transmission: g.transmission, frost: g.frost, tint: [g.tint[0], g.tint[1], g.tint[2]] };
1489
+ }
1490
+
1312
1491
  /**
1313
1492
  * The MIME type a codec's bytes carry, or `null` for one the browser has no decoder for.
1314
1493
  *
package/src/gltf.ts CHANGED
@@ -77,15 +77,36 @@ interface GltfSpecularGlossiness {
77
77
  /** RGB the specular colour, A the glossiness. Both multiply the factors above. */
78
78
  specularGlossinessTexture?: { index: number; texCoord?: number };
79
79
  }
80
+ /**
81
+ * The three extensions that together say a material is glass: how much light passes
82
+ * (`KHR_materials_transmission`), how much of it is scattered on the way and the colour it takes
83
+ * (`KHR_materials_diffuse_transmission`), and the colour a thickness of it absorbs towards
84
+ * (`KHR_materials_volume`). Only the fields this reader maps are named.
85
+ */
86
+ interface GltfTransmission {
87
+ transmissionFactor?: number;
88
+ }
89
+ interface GltfDiffuseTransmission {
90
+ diffuseTransmissionFactor?: number;
91
+ diffuseTransmissionColorFactor?: number[];
92
+ }
93
+ interface GltfVolume {
94
+ attenuationColor?: number[];
95
+ }
80
96
  interface GltfMaterial {
81
97
  pbrMetallicRoughness?: GltfPbrMetallicRoughness;
82
98
  /**
83
- * The material extensions this reader knows. One, and everything else is ignored.
99
+ * The material extensions this reader knows, and everything else is ignored.
84
100
  *
85
- * Read as an optional field rather than as `Record<string, unknown>` so that adding a second
86
- * costs a name here and a branch in `pbrOf`, and so that a typo in either is a compile error.
101
+ * Read as optional fields rather than as `Record<string, unknown>` so that adding one costs a
102
+ * name here and a branch where it is read, and so that a typo in either is a compile error.
87
103
  */
88
- extensions?: { KHR_materials_pbrSpecularGlossiness?: GltfSpecularGlossiness };
104
+ extensions?: {
105
+ KHR_materials_pbrSpecularGlossiness?: GltfSpecularGlossiness;
106
+ KHR_materials_transmission?: GltfTransmission;
107
+ KHR_materials_diffuse_transmission?: GltfDiffuseTransmission;
108
+ KHR_materials_volume?: GltfVolume;
109
+ };
89
110
  normalTexture?: { index: number; texCoord?: number; scale?: number };
90
111
  /** `strength` is a mix from 1, not a multiply: 0 means unoccluded. */
91
112
  occlusionTexture?: { index: number; texCoord?: number; strength?: number };
@@ -640,6 +661,8 @@ function materialOf(material: GltfMaterial | undefined): DrftMaterial {
640
661
  * A separate occlusion image is dropped rather than loaded into a second unit. One map, one
641
662
  * unit, and `textureUnitBudget.test.ts` says the next unit belongs to emissive.
642
663
  */
664
+ const glass = glassOf(material, modulated ? undefined : roughness);
665
+
643
666
  const occlusion = material?.occlusionTexture;
644
667
  const occlusionStrength =
645
668
  modulated && occlusion !== undefined && occlusion.index === ormMap
@@ -662,7 +685,7 @@ function materialOf(material: GltfMaterial | undefined): DrftMaterial {
662
685
  * cannot say the material blends, and a leaf whose shape is all in its texture's alpha drew as a
663
686
  * solid card. So it is said.
664
687
  */
665
- blend: blends,
688
+ blend: blends || glass.transmission > 0,
666
689
  /* Seen from both faces: a curtain, a leaf card. The specification's default is one face. */
667
690
  doubleSided: material?.doubleSided === true,
668
691
  /* Only in MASK mode: the specification says `alphaCutoff` has no meaning in the other two. */
@@ -678,9 +701,49 @@ function materialOf(material: GltfMaterial | undefined): DrftMaterial {
678
701
  metallicScale: modulated ? (pbr?.metallicFactor ?? 1) : 1,
679
702
  occlusionStrength,
680
703
  albedo: pbr?.baseColorTexture?.index ?? -1,
704
+ transmission: glass.transmission,
705
+ frost: glass.frost,
706
+ tint: glass.tint,
681
707
  };
682
708
  }
683
709
 
710
+ const unit = (value: number | undefined, fallback: number): number =>
711
+ Math.min(1, Math.max(0, value ?? fallback));
712
+
713
+ /**
714
+ * What a material's extensions say about letting light through, as this engine's glass.
715
+ *
716
+ * **Rough glass is frosted glass.** `KHR_materials_transmission` says its roughness blurs what is
717
+ * seen through the surface, and that blur is exactly what `frost` is, so a transmissive material's
718
+ * roughness raises its frost and a diffusely transmitting share raises it too — whichever says
719
+ * more. Only transmission makes a material glass: a surface stating diffuse transmission alone is a
720
+ * leaf or a sheet of paper, which this engine's glass would draw see-through and let every shadow
721
+ * pass, so it stays the opaque surface it was. What that gives up is a paper shade stated by that
722
+ * extension alone, which arrives opaque; a per-material override is the door for it.
723
+ *
724
+ * `roughness` is `undefined` where a map carries the roughness per texel: the factor then only
725
+ * scales the map, so it says nothing about how rough the glass is, and frost comes from the
726
+ * diffuse share alone rather than from a number the file did not state.
727
+ */
728
+ function glassOf(
729
+ material: GltfMaterial | undefined,
730
+ roughness: number | undefined,
731
+ ): { transmission: number; frost: number; tint: [number, number, number] } {
732
+ const ext = material?.extensions;
733
+ const transmission = unit(ext?.KHR_materials_transmission?.transmissionFactor, 0);
734
+ if (transmission <= 0) return { transmission: 0, frost: 0, tint: [1, 1, 1] };
735
+ const diffuse = ext?.KHR_materials_diffuse_transmission;
736
+ const frost = Math.max(unit(diffuse?.diffuseTransmissionFactor, 0), unit(roughness, 0));
737
+ const colour =
738
+ diffuse?.diffuseTransmissionColorFactor ?? ext?.KHR_materials_volume?.attenuationColor;
739
+ const tint: [number, number, number] = [
740
+ unit(colour?.[0], 1),
741
+ unit(colour?.[1], 1),
742
+ unit(colour?.[2], 1),
743
+ ];
744
+ return { transmission, frost, tint };
745
+ }
746
+
684
747
  /** What a caller can tell the reader about what it is going to do with the result. */
685
748
  export interface GltfReadOptions {
686
749
  /**
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export type { DrftFit, DrftLoaderOptions } from './drftLoader.ts';
33
33
  export { drawKeyOf, resolveDrawGrouping } from './drawKey.ts';
34
34
  export type { DrawGrouping, DrawSurfaceOverride } from './drawKey.ts';
35
35
  export type { DrftLoadPhase, DrftLoadProgress, DrftPart } from './loadProgress.ts';
36
+ export type { LoadedRegion, LoadedRegionLevel } from './regionStore.ts';
36
37
  export type { DrftFieldPlacement } from './fieldPlacement.ts';
37
38
  /* Addressing a texture by name, so a consumer never holds an ordinal. */
38
39
  export { TextureSet, textureColorSpaces } from './drftTextures.ts';
@@ -1,4 +1,4 @@
1
- import type { MeshHandle, InstancedHandle, MeshInstances } from '@driftengine/core';
1
+ import type { GlassOptions, MeshHandle, InstancedHandle, MeshInstances } from '@driftengine/core';
2
2
 
3
3
  /**
4
4
  * The vocabulary of a load in flight: which phase it is in, how far it has got, and what a
@@ -134,6 +134,13 @@ export interface DrftPart {
134
134
  readonly blend: boolean;
135
135
  /** Whether both faces are seen: glTF's `doubleSided`. Drawn without culling, each face lit as the front. */
136
136
  readonly doubleSided: boolean;
137
+ /**
138
+ * The glass this part is, from its file or from `DrftLoaderOptions.surface`, or null for a part
139
+ * that lets no light through. A glass part is also `blend`, so a caller drawing blended parts
140
+ * translucent passes this as the draw's `glass` and every pane arrives as the file or the
141
+ * override said. One object per merged group, shared by its parts, so it is not rebuilt per draw.
142
+ */
143
+ readonly glass: GlassOptions | null;
137
144
  /**
138
145
  * The copies this part is drawn as, or null for a part drawn once.
139
146
  *