@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/dist/gltf.js CHANGED
@@ -428,6 +428,7 @@ function materialOf(material) {
428
428
  * A separate occlusion image is dropped rather than loaded into a second unit. One map, one
429
429
  * unit, and `textureUnitBudget.test.ts` says the next unit belongs to emissive.
430
430
  */
431
+ const glass = glassOf(material, modulated ? undefined : roughness);
431
432
  const occlusion = material?.occlusionTexture;
432
433
  const occlusionStrength = modulated && occlusion !== undefined && occlusion.index === ormMap
433
434
  ? (occlusion.strength ?? 1)
@@ -447,7 +448,7 @@ function materialOf(material) {
447
448
  * cannot say the material blends, and a leaf whose shape is all in its texture's alpha drew as a
448
449
  * solid card. So it is said.
449
450
  */
450
- blend: blends,
451
+ blend: blends || glass.transmission > 0,
451
452
  /* Seen from both faces: a curtain, a leaf card. The specification's default is one face. */
452
453
  doubleSided: material?.doubleSided === true,
453
454
  /* Only in MASK mode: the specification says `alphaCutoff` has no meaning in the other two. */
@@ -463,8 +464,42 @@ function materialOf(material) {
463
464
  metallicScale: modulated ? (pbr?.metallicFactor ?? 1) : 1,
464
465
  occlusionStrength,
465
466
  albedo: pbr?.baseColorTexture?.index ?? -1,
467
+ transmission: glass.transmission,
468
+ frost: glass.frost,
469
+ tint: glass.tint,
466
470
  };
467
471
  }
472
+ const unit = (value, fallback) => Math.min(1, Math.max(0, value ?? fallback));
473
+ /**
474
+ * What a material's extensions say about letting light through, as this engine's glass.
475
+ *
476
+ * **Rough glass is frosted glass.** `KHR_materials_transmission` says its roughness blurs what is
477
+ * seen through the surface, and that blur is exactly what `frost` is, so a transmissive material's
478
+ * roughness raises its frost and a diffusely transmitting share raises it too — whichever says
479
+ * more. Only transmission makes a material glass: a surface stating diffuse transmission alone is a
480
+ * leaf or a sheet of paper, which this engine's glass would draw see-through and let every shadow
481
+ * pass, so it stays the opaque surface it was. What that gives up is a paper shade stated by that
482
+ * extension alone, which arrives opaque; a per-material override is the door for it.
483
+ *
484
+ * `roughness` is `undefined` where a map carries the roughness per texel: the factor then only
485
+ * scales the map, so it says nothing about how rough the glass is, and frost comes from the
486
+ * diffuse share alone rather than from a number the file did not state.
487
+ */
488
+ function glassOf(material, roughness) {
489
+ const ext = material?.extensions;
490
+ const transmission = unit(ext?.KHR_materials_transmission?.transmissionFactor, 0);
491
+ if (transmission <= 0)
492
+ return { transmission: 0, frost: 0, tint: [1, 1, 1] };
493
+ const diffuse = ext?.KHR_materials_diffuse_transmission;
494
+ const frost = Math.max(unit(diffuse?.diffuseTransmissionFactor, 0), unit(roughness, 0));
495
+ const colour = diffuse?.diffuseTransmissionColorFactor ?? ext?.KHR_materials_volume?.attenuationColor;
496
+ const tint = [
497
+ unit(colour?.[0], 1),
498
+ unit(colour?.[1], 1),
499
+ unit(colour?.[2], 1),
500
+ ];
501
+ return { transmission, frost, tint };
502
+ }
468
503
  /** The required extensions this reader implements, or follows far enough to fail loudly. */
469
504
  const KNOWN_REQUIRED = new Set([
470
505
  'KHR_materials_pbrSpecularGlossiness',
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export type { DrftFit, DrftLoaderOptions } from './drftLoader.ts';
13
13
  export { drawKeyOf, resolveDrawGrouping } from './drawKey.ts';
14
14
  export type { DrawGrouping, DrawSurfaceOverride } from './drawKey.ts';
15
15
  export type { DrftLoadPhase, DrftLoadProgress, DrftPart } from './loadProgress.ts';
16
+ export type { LoadedRegion, LoadedRegionLevel } from './regionStore.ts';
16
17
  export type { DrftFieldPlacement } from './fieldPlacement.ts';
17
18
  export { TextureSet, textureColorSpaces } from './drftTextures.ts';
18
19
  export { assetCandidates, basenameOf } from './assetPath.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
  * The vocabulary of a load in flight: which phase it is in, how far it has got, and what a
4
4
  * finished part is.
@@ -120,6 +120,13 @@ export interface DrftPart {
120
120
  readonly blend: boolean;
121
121
  /** Whether both faces are seen: glTF's `doubleSided`. Drawn without culling, each face lit as the front. */
122
122
  readonly doubleSided: boolean;
123
+ /**
124
+ * The glass this part is, from its file or from `DrftLoaderOptions.surface`, or null for a part
125
+ * that lets no light through. A glass part is also `blend`, so a caller drawing blended parts
126
+ * translucent passes this as the draw's `glass` and every pane arrives as the file or the
127
+ * override said. One object per merged group, shared by its parts, so it is not rebuilt per draw.
128
+ */
129
+ readonly glass: GlassOptions | null;
123
130
  /**
124
131
  * The copies this part is drawn as, or null for a part drawn once.
125
132
  *
@@ -0,0 +1,94 @@
1
+ import type { MeshInstances, RendererApi } from '@driftengine/core';
2
+ import type { DrftAssembly, DrftRegion, DrftRegionCollision, PieceLookup } from '@driftengine/drft';
3
+ import type { MeshData } from '@driftengine/drft';
4
+ import type { DrftPart } from './loadProgress.ts';
5
+ /** How the loader placed the model: `p · scale + (x, y, z)`. */
6
+ export interface LoaderFit {
7
+ readonly scale: number;
8
+ readonly x: number;
9
+ readonly y: number;
10
+ readonly z: number;
11
+ }
12
+ export interface LoadedRegionLevel {
13
+ /** How far, in metres of the file, this level may stand from the real surface. */
14
+ readonly error: number;
15
+ /** One part a mesh, filled as they upload. */
16
+ readonly parts: DrftPart[];
17
+ /** Whether any of its meshes arrived as assemblies, kept small until paged in. */
18
+ paged: boolean;
19
+ /** Whether every mesh is up: always, for a level that is not paged. */
20
+ resident: boolean;
21
+ }
22
+ export interface LoadedRegion {
23
+ readonly id: number;
24
+ /** Min xyz then max xyz, placed. */
25
+ readonly bounds: Float32Array;
26
+ /** Finest first. `HlodSet.add` takes the errors, scaled by the fit. */
27
+ readonly levels: readonly LoadedRegionLevel[];
28
+ /** Its props, one instanced part a prototype, culled per instance. */
29
+ readonly batches: DrftPart[];
30
+ /**
31
+ * Which of the region's groups in the file each batch is, index for index. Batches are built as
32
+ * their prototypes arrive, not in the file's order, so a caller pairing something of its own with
33
+ * each group — a material, a blend — pairs through this, never by position.
34
+ */
35
+ readonly batchGroups: number[];
36
+ /** Six floats a box, placed, for `addOccluder`. */
37
+ readonly occluders: Float32Array;
38
+ /** What it collides as, placed, or null. */
39
+ readonly collision: DrftRegionCollision | null;
40
+ /** Meshes and batches still to come; 0 once every level and prop is up. */
41
+ pending: number;
42
+ }
43
+ /**
44
+ * Copies of a fitted prototype, each matrix conjugated by the fit: M becomes F·M·F⁻¹, the same turn
45
+ * and a translation of t − R·t + s·u for the fit's scale s and offset t and the copy's own
46
+ * translation u. The prototype already carries the fit, so the copies land where its geometry does
47
+ * and are not scaled twice.
48
+ */
49
+ export declare function fitCopies(transforms: Float32Array, fit: LoaderFit): MeshInstances;
50
+ export declare class RegionStore {
51
+ private readonly renderer;
52
+ private readonly fit;
53
+ readonly regions: Map<number, LoadedRegion>;
54
+ /** Level meshes by ordinal: whose, and which level. */
55
+ private readonly levelOwners;
56
+ /** Prototypes that have uploaded, by ordinal. */
57
+ private readonly prototypes;
58
+ /** Groups whose prototype has not uploaded yet, by its ordinal. */
59
+ private readonly waiting;
60
+ private readonly jobs;
61
+ private readonly made;
62
+ /** Assemblies held small, by ordinal, and each paged level's ordinals. */
63
+ private readonly compact;
64
+ private readonly levelCompact;
65
+ /** Paged meshes asked for and not yet up, and those that are. */
66
+ private readonly requested;
67
+ private readonly pagedParts;
68
+ constructor(renderer: RendererApi, fit: () => LoaderFit | null);
69
+ /** Whether a mesh is a region's, as a level or a prop, and so is not a part of the model. */
70
+ owns(ordinal: number): boolean;
71
+ /** Whether batches are queued. */
72
+ get busy(): boolean;
73
+ /** A region's chunk has landed, ahead of the meshes it introduces. */
74
+ admit(region: DrftRegion): void;
75
+ /** Whether a mesh is one of a region's levels. */
76
+ isLevelMesh(ordinal: number): boolean;
77
+ /** A level's assembly has arrived: held small, its level paged and not yet up. */
78
+ hold(ordinal: number, assembly: DrftAssembly, piece: PieceLookup): void;
79
+ /**
80
+ * Ask for a paged level's meshes, or free them. Coming up, returns each mesh still to upload with
81
+ * a way to expand it, for the loader's queue; going down, frees what is up and returns the
82
+ * ordinals released, so the loader can drop any still queued.
83
+ */
84
+ page(id: number, level: number, resident: boolean): {
85
+ ordinal: number;
86
+ expand: () => MeshData;
87
+ }[];
88
+ private release;
89
+ /** A region's mesh has uploaded, as the part the loader would have made of it. */
90
+ arrived(ordinal: number, part: DrftPart): void;
91
+ /** Build one queued batch inside the caller's budget; false when none was queued. */
92
+ buildNext(): boolean;
93
+ dispose(): void;
94
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * The regions of a streamed world as they arrive: each one's meshes at every level, its prop
3
+ * batches, its occluders and its collision, kept apart from the loader's parts and never merged.
4
+ *
5
+ * **Apart, because a region's levels are alternatives rather than pieces.** The loader merges its
6
+ * parts by material into one mesh a group, which is right for one model and wrong here twice over:
7
+ * it would weld a district's finest level to its coarsest, drawing both, and weld one district to
8
+ * the next, so none could be dropped when the eye leaves. So a region's meshes upload on the
9
+ * loader's clocked queue like any part and land here instead, one part a mesh, a list a level.
10
+ *
11
+ * **A prop's prototype arrives once and serves every region that places it.** A region naming a
12
+ * prototype already up gets its batch at the next budgeted build; one naming a prototype still to
13
+ * come waits for it. Either way the batch is built inside a frame's budget, never from the stream's
14
+ * callback, and culls instance by instance (`createInstanced`'s `cull`).
15
+ *
16
+ * **A level that arrives as assemblies is paged** (`MSHC`, `drftAssembly.ts`). A city's finest
17
+ * level is millions of triangles, more than any device keeps resident, so such a level is held as
18
+ * the copies the file carried — tens of kilobytes a district — and comes up only when the caller
19
+ * pages it in, expanded and uploaded on the same clocked queue; paged out, its meshes are freed and
20
+ * the copies stay. What it costs is a region that is not yet up when the eye arrives: the caller
21
+ * draws a coarser level until `resident` says the fine one is there, and pages ahead of the eye.
22
+ *
23
+ * Everything is in the loader's placement: a world loads with `{ fit: 'none' }`, where that is the
24
+ * file's own space.
25
+ */
26
+ import { createMeshInstances } from '@driftengine/core';
27
+ import { expandAssembly } from '@driftengine/drft';
28
+ /**
29
+ * Copies of a fitted prototype, each matrix conjugated by the fit: M becomes F·M·F⁻¹, the same turn
30
+ * and a translation of t − R·t + s·u for the fit's scale s and offset t and the copy's own
31
+ * translation u. The prototype already carries the fit, so the copies land where its geometry does
32
+ * and are not scaled twice.
33
+ */
34
+ export function fitCopies(transforms, fit) {
35
+ const count = transforms.length / 16;
36
+ const data = createMeshInstances(count);
37
+ for (let k = 0; k < count; k++) {
38
+ const at = k * 16;
39
+ for (let i = 0; i < 12; i++)
40
+ data.models[at + i] = transforms[at + i];
41
+ /* R·t, with R the copy's own 3×3 read column-major. */
42
+ const rtx = transforms[at] * fit.x +
43
+ transforms[at + 4] * fit.y +
44
+ transforms[at + 8] * fit.z;
45
+ const rty = transforms[at + 1] * fit.x +
46
+ transforms[at + 5] * fit.y +
47
+ transforms[at + 9] * fit.z;
48
+ const rtz = transforms[at + 2] * fit.x +
49
+ transforms[at + 6] * fit.y +
50
+ transforms[at + 10] * fit.z;
51
+ data.models[at + 12] = fit.x - rtx + fit.scale * transforms[at + 12];
52
+ data.models[at + 13] = fit.y - rty + fit.scale * transforms[at + 13];
53
+ data.models[at + 14] = fit.z - rtz + fit.scale * transforms[at + 14];
54
+ data.models[at + 15] = 1;
55
+ }
56
+ data.tints.fill(1);
57
+ data.count = count;
58
+ return data;
59
+ }
60
+ /** Points packed xyz, placed by the fit into a new array. */
61
+ function placed(points, fit) {
62
+ const out = new Float32Array(points.length);
63
+ for (let i = 0; i < points.length; i += 3) {
64
+ out[i] = points[i] * fit.scale + fit.x;
65
+ out[i + 1] = points[i + 1] * fit.scale + fit.y;
66
+ out[i + 2] = points[i + 2] * fit.scale + fit.z;
67
+ }
68
+ return out;
69
+ }
70
+ export class RegionStore {
71
+ renderer;
72
+ fit;
73
+ regions = new Map();
74
+ /** Level meshes by ordinal: whose, and which level. */
75
+ levelOwners = new Map();
76
+ /** Prototypes that have uploaded, by ordinal. */
77
+ prototypes = new Map();
78
+ /** Groups whose prototype has not uploaded yet, by its ordinal. */
79
+ waiting = new Map();
80
+ jobs = [];
81
+ made = [];
82
+ /** Assemblies held small, by ordinal, and each paged level's ordinals. */
83
+ compact = new Map();
84
+ levelCompact = new Map();
85
+ /** Paged meshes asked for and not yet up, and those that are. */
86
+ requested = new Set();
87
+ pagedParts = new Map();
88
+ constructor(renderer, fit) {
89
+ this.renderer = renderer;
90
+ this.fit = fit;
91
+ }
92
+ /** Whether a mesh is a region's, as a level or a prop, and so is not a part of the model. */
93
+ owns(ordinal) {
94
+ return (this.levelOwners.has(ordinal) || this.prototypes.has(ordinal) || this.waiting.has(ordinal));
95
+ }
96
+ /** Whether batches are queued. */
97
+ get busy() {
98
+ return this.jobs.length > 0;
99
+ }
100
+ /** A region's chunk has landed, ahead of the meshes it introduces. */
101
+ admit(region) {
102
+ const fit = this.fit() ?? { scale: 1, x: 0, y: 0, z: 0 };
103
+ const loaded = {
104
+ id: region.id,
105
+ bounds: new Float32Array([
106
+ ...placed([region.bounds[0], region.bounds[1], region.bounds[2]], fit),
107
+ ...placed([region.bounds[3], region.bounds[4], region.bounds[5]], fit),
108
+ ]),
109
+ levels: region.levels.map((level) => ({
110
+ error: level.error * fit.scale,
111
+ parts: [],
112
+ paged: false,
113
+ resident: true,
114
+ })),
115
+ batches: [],
116
+ batchGroups: [],
117
+ occluders: placed(region.occluders, fit),
118
+ collision: region.collision === null
119
+ ? null
120
+ : {
121
+ positions: placed(region.collision.positions, fit),
122
+ indices: region.collision.indices,
123
+ },
124
+ pending: 0,
125
+ };
126
+ region.levels.forEach((level, index) => {
127
+ for (const mesh of level.meshes) {
128
+ this.levelOwners.set(mesh, { region: loaded, level: index });
129
+ loaded.pending++;
130
+ }
131
+ });
132
+ region.instances.forEach((group, index) => {
133
+ loaded.pending++;
134
+ const prototype = this.prototypes.get(group.mesh);
135
+ if (prototype !== undefined) {
136
+ this.jobs.push({ region: loaded, prototype, transforms: group.transforms, group: index });
137
+ return;
138
+ }
139
+ const list = this.waiting.get(group.mesh) ?? [];
140
+ list.push({ region: loaded, transforms: group.transforms, group: index });
141
+ this.waiting.set(group.mesh, list);
142
+ });
143
+ this.regions.set(region.id, loaded);
144
+ }
145
+ /** Whether a mesh is one of a region's levels. */
146
+ isLevelMesh(ordinal) {
147
+ return this.levelOwners.has(ordinal);
148
+ }
149
+ /** A level's assembly has arrived: held small, its level paged and not yet up. */
150
+ hold(ordinal, assembly, piece) {
151
+ const owner = this.levelOwners.get(ordinal);
152
+ const level = owner?.region.levels[owner.level];
153
+ if (owner === undefined || level === undefined)
154
+ return;
155
+ this.compact.set(ordinal, { assembly, piece });
156
+ const list = this.levelCompact.get(level) ?? [];
157
+ list.push(ordinal);
158
+ this.levelCompact.set(level, list);
159
+ level.paged = true;
160
+ level.resident = false;
161
+ owner.region.pending--;
162
+ }
163
+ /**
164
+ * Ask for a paged level's meshes, or free them. Coming up, returns each mesh still to upload with
165
+ * a way to expand it, for the loader's queue; going down, frees what is up and returns the
166
+ * ordinals released, so the loader can drop any still queued.
167
+ */
168
+ page(id, level, resident) {
169
+ const lv = this.regions.get(id)?.levels[level];
170
+ const ordinals = lv === undefined ? undefined : this.levelCompact.get(lv);
171
+ if (lv === undefined || ordinals === undefined)
172
+ return [];
173
+ const out = [];
174
+ for (const ordinal of ordinals) {
175
+ const held = this.compact.get(ordinal);
176
+ if (held === undefined)
177
+ continue;
178
+ if (resident) {
179
+ if (this.pagedParts.has(ordinal) || this.requested.has(ordinal))
180
+ continue;
181
+ this.requested.add(ordinal);
182
+ out.push({ ordinal, expand: () => expandAssembly(held.assembly, held.piece) });
183
+ continue;
184
+ }
185
+ this.requested.delete(ordinal);
186
+ const part = this.pagedParts.get(ordinal);
187
+ out.push({ ordinal, expand: () => expandAssembly(held.assembly, held.piece) });
188
+ if (part === undefined)
189
+ continue;
190
+ this.pagedParts.delete(ordinal);
191
+ this.release(part);
192
+ const at = lv.parts.indexOf(part);
193
+ if (at >= 0)
194
+ lv.parts.splice(at, 1);
195
+ }
196
+ if (!resident)
197
+ lv.resident = false;
198
+ return out;
199
+ }
200
+ release(part) {
201
+ const at = this.made.indexOf(part.mesh);
202
+ if (at >= 0)
203
+ this.made.splice(at, 1);
204
+ this.renderer.disposeMesh(part.mesh);
205
+ }
206
+ /** A region's mesh has uploaded, as the part the loader would have made of it. */
207
+ arrived(ordinal, part) {
208
+ if (this.compact.has(ordinal)) {
209
+ /* Paged out again while it was on its way up: nothing wants it. */
210
+ if (!this.requested.delete(ordinal)) {
211
+ this.renderer.disposeMesh(part.mesh);
212
+ return;
213
+ }
214
+ this.made.push(part.mesh);
215
+ this.pagedParts.set(ordinal, part);
216
+ const owner = this.levelOwners.get(ordinal);
217
+ const level = owner?.region.levels[owner.level];
218
+ if (level === undefined)
219
+ return;
220
+ level.parts.push(part);
221
+ level.resident = (this.levelCompact.get(level) ?? []).every((o) => this.pagedParts.has(o));
222
+ return;
223
+ }
224
+ this.made.push(part.mesh);
225
+ const owner = this.levelOwners.get(ordinal);
226
+ if (owner !== undefined) {
227
+ owner.region.levels[owner.level]?.parts.push(part);
228
+ owner.region.pending--;
229
+ return;
230
+ }
231
+ this.prototypes.set(ordinal, part);
232
+ for (const group of this.waiting.get(ordinal) ?? []) {
233
+ this.jobs.push({
234
+ region: group.region,
235
+ prototype: part,
236
+ transforms: group.transforms,
237
+ group: group.group,
238
+ });
239
+ }
240
+ this.waiting.delete(ordinal);
241
+ }
242
+ /** Build one queued batch inside the caller's budget; false when none was queued. */
243
+ buildNext() {
244
+ const job = this.jobs.shift();
245
+ if (job === undefined)
246
+ return false;
247
+ const data = fitCopies(job.transforms, this.fit() ?? { scale: 1, x: 0, y: 0, z: 0 });
248
+ try {
249
+ const batch = this.renderer.createInstanced(job.prototype.mesh, data.count, { cull: true });
250
+ this.renderer.uploadInstanced(batch, data);
251
+ job.region.batches.push({ ...job.prototype, reveal: 1, instances: { batch, data } });
252
+ job.region.batchGroups.push(job.group);
253
+ }
254
+ catch (error) {
255
+ console.warn(`DrftLoader: region ${job.region.id}'s props would not upload`, error);
256
+ }
257
+ job.region.pending--;
258
+ return true;
259
+ }
260
+ dispose() {
261
+ for (const region of this.regions.values()) {
262
+ for (const part of region.batches) {
263
+ if (part.instances !== null)
264
+ this.renderer.disposeInstanced(part.instances.batch);
265
+ }
266
+ }
267
+ for (const mesh of this.made)
268
+ this.renderer.disposeMesh(mesh);
269
+ this.made.length = 0;
270
+ this.regions.clear();
271
+ }
272
+ }
package/dist/repeats.js CHANGED
@@ -9,6 +9,7 @@ const EQUAL = [
9
9
  'relief',
10
10
  'emissiveColor',
11
11
  'channel',
12
+ 'layers',
12
13
  ];
13
14
  /**
14
15
  * How far a copy's normal may sit from the prototype's once turned, as a chord on the unit sphere.
package/dist/weld.js CHANGED
@@ -59,6 +59,12 @@ const ATTRIBUTES = {
59
59
  * tip merged into the trunk it grows from would stop moving.
60
60
  */
61
61
  channel: 4,
62
+ /*
63
+ * The texture-array layer. In the key for the same reason as the channel: two vertices at one
64
+ * position on two faces wearing different images are two vertices, and welding them would put one
65
+ * face on the other's picture.
66
+ */
67
+ layers: 1,
62
68
  };
63
69
  const NAMES = Object.keys(ATTRIBUTES);
64
70
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/assets",
3
- "version": "4.4.2",
3
+ "version": "4.6.0",
4
4
  "description": "Model readers and the streaming loader for the .drft container",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -27,11 +27,11 @@
27
27
  ],
28
28
  "sideEffects": false,
29
29
  "dependencies": {
30
- "@driftengine/drft": "4.4.2",
31
- "@driftengine/texture": "4.4.2"
30
+ "@driftengine/drft": "4.6.0",
31
+ "@driftengine/texture": "4.6.0"
32
32
  },
33
33
  "peerDependencies": {
34
- "@driftengine/core": "4.4.2"
34
+ "@driftengine/core": "4.6.0"
35
35
  },
36
36
  "author": "Drift Technologies",
37
37
  "repository": {
package/src/drawKey.ts CHANGED
@@ -25,6 +25,15 @@ export interface DrawSurfaceOverride {
25
25
  * gallery and read as a black blotch. 0 says dielectric.
26
26
  */
27
27
  readonly metallicScale?: number;
28
+ /**
29
+ * Glass, in place of what the file says: the share of light that passes, how milky it is, and
30
+ * the colour it takes (`GlassOptions` in the renderer). For a model whose panes arrive as an
31
+ * opaque dark material, because its format or its exporter had no word for glass. Transmission
32
+ * above zero draws the part blended.
33
+ */
34
+ readonly transmission?: number;
35
+ readonly frost?: number;
36
+ readonly tint?: readonly [number, number, number];
28
37
  }
29
38
 
30
39
  /**
@@ -50,8 +59,17 @@ export interface DrawGrouping {
50
59
  /** Whether both faces are drawn and lit. A merge of a two-sided part and a one-sided one would cull
51
60
  * one of them or unhide the back of the other, so it is part of the key. */
52
61
  readonly doubleSided: boolean;
62
+ /**
63
+ * Glass. Zero transmission is not glass, and then frost is 0 and the tint white whatever the
64
+ * file or the override said, so a stray value on an opaque surface never splits a draw.
65
+ */
66
+ readonly transmission: number;
67
+ readonly frost: number;
68
+ readonly tint: readonly [number, number, number];
53
69
  }
54
70
 
71
+ const CLEAR: readonly [number, number, number] = [1, 1, 1];
72
+
55
73
  /**
56
74
  * Resolve what a part will actually be drawn with.
57
75
  *
@@ -60,7 +78,7 @@ export interface DrawGrouping {
60
78
  * 0, which is the identity that lets a file saying nothing about a property draw the same as one
61
79
  * that did not have the property to say.
62
80
  *
63
- * The override wins over the material for the three fields it may carry, because a consumer dressing
81
+ * The override wins over the material for the fields it may carry, because a consumer dressing
64
82
  * a surface by name knows more than a default an exporter wrote. A cutout is not among them: it is
65
83
  * a fact about the *image* a material wears, so nothing outside the file has anything to say
66
84
  * about it.
@@ -69,6 +87,8 @@ export function resolveDrawGrouping(
69
87
  material: DrftMaterial | undefined,
70
88
  override?: DrawSurfaceOverride,
71
89
  ): DrawGrouping {
90
+ const transmission = Math.max(0, override?.transmission ?? material?.transmission ?? 0);
91
+ const glass = transmission > 0;
72
92
  return {
73
93
  albedo: material?.albedo ?? -1,
74
94
  orm: material?.ormMap ?? -1,
@@ -80,8 +100,11 @@ export function resolveDrawGrouping(
80
100
  metallicScale: override?.metallicScale ?? material?.metallicScale ?? 1,
81
101
  occlusionStrength: material?.occlusionStrength ?? 0,
82
102
  cutout: material?.cutout ?? 0,
83
- blend: material?.blend === true,
103
+ blend: material?.blend === true || glass,
84
104
  doubleSided: material?.doubleSided === true,
105
+ transmission,
106
+ frost: glass ? (override?.frost ?? material?.frost ?? 0) : 0,
107
+ tint: glass ? (override?.tint ?? material?.tint ?? CLEAR) : CLEAR,
85
108
  };
86
109
  }
87
110
 
@@ -101,5 +124,5 @@ export function drawKeyOf(
101
124
  override?: DrawSurfaceOverride,
102
125
  ): string {
103
126
  const g = resolveDrawGrouping(material, override);
104
- 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}`;
127
+ 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]}`;
105
128
  }