@driftengine/assets 3.61.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/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +9 -0
- package/dist/assetPath.d.ts +51 -0
- package/dist/assetPath.js +59 -0
- package/dist/coarseLevel.d.ts +94 -0
- package/dist/coarseLevel.js +606 -0
- package/dist/dds.d.ts +41 -0
- package/dist/dds.js +425 -0
- package/dist/drawKey.d.ts +64 -0
- package/dist/drawKey.js +42 -0
- package/dist/drftLoader.d.ts +420 -0
- package/dist/drftLoader.js +996 -0
- package/dist/drftTextures.d.ts +87 -0
- package/dist/drftTextures.js +138 -0
- package/dist/fbx.d.ts +86 -0
- package/dist/fbx.js +792 -0
- package/dist/fbxHarness.d.ts +68 -0
- package/dist/fbxHarness.js +185 -0
- package/dist/fbxInflate.d.ts +60 -0
- package/dist/fbxInflate.js +204 -0
- package/dist/fbxMaterial.d.ts +87 -0
- package/dist/fbxMaterial.js +345 -0
- package/dist/fbxRig.d.ts +130 -0
- package/dist/fbxRig.js +601 -0
- package/dist/gltf.d.ts +242 -0
- package/dist/gltf.js +904 -0
- package/dist/gltfAccessor.d.ts +2 -0
- package/dist/gltfAccessor.js +106 -0
- package/dist/gltfSkin.d.ts +32 -0
- package/dist/gltfSkin.js +144 -0
- package/dist/iesPhotometry.d.ts +44 -0
- package/dist/iesPhotometry.js +94 -0
- package/dist/imageInfo.d.ts +31 -0
- package/dist/imageInfo.js +119 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +132 -0
- package/dist/kn5.d.ts +158 -0
- package/dist/kn5.js +557 -0
- package/dist/loadProgress.d.ts +118 -0
- package/dist/loadProgress.js +1 -0
- package/dist/localise.d.ts +35 -0
- package/dist/localise.js +253 -0
- package/dist/obj.d.ts +54 -0
- package/dist/obj.js +335 -0
- package/dist/orient.d.ts +104 -0
- package/dist/orient.js +241 -0
- package/dist/radianceHdr.d.ts +36 -0
- package/dist/radianceHdr.js +172 -0
- package/dist/readModel.d.ts +153 -0
- package/dist/readModel.js +386 -0
- package/dist/recognise.d.ts +38 -0
- package/dist/recognise.js +97 -0
- package/dist/stl.d.ts +22 -0
- package/dist/stl.js +131 -0
- package/dist/tangentOrder.d.ts +24 -0
- package/dist/tangentOrder.js +31 -0
- package/dist/threemf.d.ts +46 -0
- package/dist/threemf.js +254 -0
- package/dist/uploadBudget.d.ts +82 -0
- package/dist/uploadBudget.js +86 -0
- package/dist/usd.d.ts +49 -0
- package/dist/usd.js +542 -0
- package/dist/weld.d.ts +44 -0
- package/dist/weld.js +237 -0
- package/dist/zip.d.ts +35 -0
- package/dist/zip.js +110 -0
- package/package.json +61 -0
- package/src/assetPath.ts +79 -0
- package/src/coarseLevel.ts +720 -0
- package/src/dds.ts +507 -0
- package/src/drawKey.ts +91 -0
- package/src/drftLoader.ts +1197 -0
- package/src/drftTextures.ts +153 -0
- package/src/fbx.ts +934 -0
- package/src/fbxHarness.ts +215 -0
- package/src/fbxInflate.ts +258 -0
- package/src/fbxMaterial.ts +410 -0
- package/src/fbxRig.ts +652 -0
- package/src/gltf.ts +1161 -0
- package/src/gltfAccessor.ts +127 -0
- package/src/gltfSkin.ts +210 -0
- package/src/iesPhotometry.ts +136 -0
- package/src/imageInfo.ts +130 -0
- package/src/index.ts +162 -0
- package/src/kn5.ts +705 -0
- package/src/loadProgress.ts +131 -0
- package/src/localise.ts +275 -0
- package/src/obj.ts +386 -0
- package/src/orient.ts +262 -0
- package/src/radianceHdr.ts +195 -0
- package/src/readModel.ts +515 -0
- package/src/recognise.ts +124 -0
- package/src/stl.ts +147 -0
- package/src/tangentOrder.ts +32 -0
- package/src/threemf.ts +303 -0
- package/src/uploadBudget.ts +90 -0
- package/src/usd.ts +642 -0
- package/src/weld.ts +264 -0
- package/src/zip.ts +130 -0
|
@@ -0,0 +1,1197 @@
|
|
|
1
|
+
/** Loading a `.drft` into drawable parts progressively, without stalling the frame. */
|
|
2
|
+
|
|
3
|
+
import type { MeshData } from '@driftengine/drft';
|
|
4
|
+
import type { Mesh } from '@driftengine/core';
|
|
5
|
+
import type { MeshHandle, RendererApi } from '@driftengine/core';
|
|
6
|
+
import type { SurfaceTextureHandle } from '@driftengine/core';
|
|
7
|
+
import { MeshBuilder } from '@driftengine/core';
|
|
8
|
+
import type { DrftMaterial } from '@driftengine/drft';
|
|
9
|
+
import type { DrftTexture } from '@driftengine/drft';
|
|
10
|
+
import { TextureSet, textureColorSpaces } from './drftTextures.ts';
|
|
11
|
+
import { drawKeyOf, resolveDrawGrouping } from './drawKey.ts';
|
|
12
|
+
import { streamDrft, CODEC_PNG, CODEC_RAW, CODEC_WEBP, DrftError } from '@driftengine/drft';
|
|
13
|
+
import type { AnimationClip, DrftMorph, DrftNode, DrftSkin } from '@driftengine/drft';
|
|
14
|
+
import type { DrftLoadProgress, DrftPart } from './loadProgress.ts';
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_REVEAL_SEC,
|
|
17
|
+
DEFAULT_UPLOAD_MS_PER_FRAME,
|
|
18
|
+
DEFAULT_UPLOADS_PER_FRAME,
|
|
19
|
+
isDocumentResponse,
|
|
20
|
+
mayBeginMore,
|
|
21
|
+
} from './uploadBudget.ts';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How a loaded model is placed: fitted to a size, or left exactly where its file puts it.
|
|
25
|
+
*
|
|
26
|
+
* **`'none'` exists because the way to ask for nothing was to ask for the answer.** A game whose
|
|
27
|
+
* importer has already put a model in its own frame — metres, y = 0 at the road — wanted no fit
|
|
28
|
+
* at all, and the only way to say so was to hand `load` the footprint and height it would have
|
|
29
|
+
* computed, which means measuring the model to tell the loader not to measure it. The obvious
|
|
30
|
+
* shortcut is a trap and a consumer fell in it: `footprint: Number.MAX_SAFE_INTEGER` does not mean
|
|
31
|
+
* "do not scale", it means a scale of 2.3e15, and a car drawn nine quadrillion metres wide looks
|
|
32
|
+
* from inside exactly like a model that failed to load.
|
|
33
|
+
*/
|
|
34
|
+
export type DrftFit =
|
|
35
|
+
| {
|
|
36
|
+
/** The size the larger of the model's footprint and height is scaled to. */
|
|
37
|
+
readonly footprint: number;
|
|
38
|
+
readonly height: number;
|
|
39
|
+
/** Where its lowest point sits, for a scene whose floor is not at zero. Zero unless stated. */
|
|
40
|
+
readonly baseY?: number;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
/** Scale 1 and no offset: the model arrives in the coordinates the file states. */
|
|
44
|
+
readonly fit: 'none';
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export interface DrftLoaderOptions {
|
|
48
|
+
/**
|
|
49
|
+
* The most parts one `update` may take, however cheap they turn out to be.
|
|
50
|
+
*
|
|
51
|
+
* **A ceiling, not the budget.** The budget is `uploadMsPerFrame`, because what a part costs
|
|
52
|
+
* cannot be known by counting parts — see `mayBeginMore`. This stays as the guard against the
|
|
53
|
+
* opposite shape of asset, a model of hundreds of tiny parts where the clock would happily take
|
|
54
|
+
* a hundred of them in one frame and spend the whole reveal in two.
|
|
55
|
+
*/
|
|
56
|
+
readonly uploadsPerFrame?: number;
|
|
57
|
+
/**
|
|
58
|
+
* How long one `update` may spend starting new stream work, in milliseconds.
|
|
59
|
+
*
|
|
60
|
+
* `DEFAULT_UPLOAD_MS_PER_FRAME` unless stated. Raise it for a tool that would rather have the
|
|
61
|
+
* model in front of it sooner than hold a rate; lower it for a scene where the frame matters
|
|
62
|
+
* more than the wait. Zero is the slowest honest setting rather than a stall: one piece a frame
|
|
63
|
+
* still begins, because a load has to finish.
|
|
64
|
+
*/
|
|
65
|
+
readonly uploadMsPerFrame?: number;
|
|
66
|
+
/** Seconds for a part to fade from nothing to itself. Zero makes it appear. */
|
|
67
|
+
readonly revealSec?: number;
|
|
68
|
+
/**
|
|
69
|
+
* Anisotropic filtering asked of every image, where the extension exists.
|
|
70
|
+
*
|
|
71
|
+
* A model's maps are the case for it: tread on a tyre, a panel gap, a plate, all bands of fine
|
|
72
|
+
* detail seen at a grazing angle, which is exactly where a trilinear sample gives up.
|
|
73
|
+
*/
|
|
74
|
+
readonly anisotropy?: number;
|
|
75
|
+
/**
|
|
76
|
+
* How every image this asset carries behaves past its own edge. `repeat` unless stated.
|
|
77
|
+
*
|
|
78
|
+
* **Reported from outside, and it is a real trap on a bought model.** A surface texture repeats
|
|
79
|
+
* by default, which is right for a tiling map and wrong at the border of a UV island: a sample
|
|
80
|
+
* whose footprint crosses the edge wraps to the far side of the image, and a mip level high
|
|
81
|
+
* enough averages across islands that have nothing to do with each other. On a model whose
|
|
82
|
+
* untextured surfaces sit at an exporter's default `0.8` grey, the result is single bright pixels
|
|
83
|
+
* tracing every seam of every textured part, which is invisible against a light background and
|
|
84
|
+
* obvious against black paint.
|
|
85
|
+
*
|
|
86
|
+
* `clamp` is the repair where an asset's own UV padding is thin, and it is stated rather than
|
|
87
|
+
* assumed because a model that genuinely tiles a map needs `repeat` and would band without it.
|
|
88
|
+
*/
|
|
89
|
+
readonly textureWrap?: 'repeat' | 'clamp';
|
|
90
|
+
/**
|
|
91
|
+
* A last chance to change a mesh's vertex data before it is uploaded.
|
|
92
|
+
*
|
|
93
|
+
* For a scene that dresses an import rather than taking it as it comes: a bought asset that
|
|
94
|
+
* stores every surface as the same default grey while naming them `body`, `glass` and `chrome`
|
|
95
|
+
* can have its paint decided here, per material, without the loader knowing what a car is.
|
|
96
|
+
*/
|
|
97
|
+
readonly transform?: (mesh: MeshData, material: DrftMaterial | undefined) => MeshData;
|
|
98
|
+
/** Per-material opacity and reflectivity overrides, for the same reason as `transform`. */
|
|
99
|
+
readonly surface?: (
|
|
100
|
+
material: DrftMaterial | undefined,
|
|
101
|
+
) => { readonly opacity?: number; readonly reflectivity?: number } | undefined;
|
|
102
|
+
/**
|
|
103
|
+
* Draw a coarse whole model while the parts arrive, where the file carries one.
|
|
104
|
+
*
|
|
105
|
+
* **On when the file carries one, and `false` refuses it.** This was off by default for two
|
|
106
|
+
* releases, on the argument that a `LODM` chunk is a *decimated* model and how good it looks
|
|
107
|
+
* is a property of the asset rather than of this code. That argument was about the old hull,
|
|
108
|
+
* which was built by clustering vertices and joined clusters lying on different surfaces of
|
|
109
|
+
* the model, so a car grew spikes through its own bodywork and somebody waiting for it saw
|
|
110
|
+
* white cliffs. A bad preview reads as a broken import where an empty stage reads as a load,
|
|
111
|
+
* so refusing to guess was right.
|
|
112
|
+
*
|
|
113
|
+
* The hull is the surface of an occupancy grid now (`buildCoarseLevel`), which cannot do that:
|
|
114
|
+
* it emits the boundary of a solid, so the worst case is a coarse version of the shape rather
|
|
115
|
+
* than a shape that was never there. And nothing carries a `LODM` by accident. A chunk exists
|
|
116
|
+
* only because somebody baked one, so ignoring it by default meant the file said one thing and
|
|
117
|
+
* the reader did another.
|
|
118
|
+
*
|
|
119
|
+
* `outline: false` still refuses, for a caller swapping a model already on screen for a better
|
|
120
|
+
* one, where a coarse version of the new one is a step backwards.
|
|
121
|
+
*/
|
|
122
|
+
readonly outline?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Keep the arrival state after the load, so `replay` can put any point of it back on screen.
|
|
125
|
+
*
|
|
126
|
+
* **Off by default, because it costs a second copy of the model on the GPU.** The load
|
|
127
|
+
* normally ends by disposing the outline and the per-part meshes, since the merged groups
|
|
128
|
+
* draw the same geometry in seven draws rather than 188. Holding them is what makes the
|
|
129
|
+
* reveal *inspectable* rather than a thing that happens once and cannot be looked at again:
|
|
130
|
+
* a stage that is only ever seen while the bytes are arriving can only be studied by
|
|
131
|
+
* reloading, at whatever speed the network happens to give.
|
|
132
|
+
*
|
|
133
|
+
* The cost is stated rather than hidden: on a 187-part car the per-part meshes are about the
|
|
134
|
+
* same vertex count as the merged ones, so this roughly doubles the geometry the asset holds.
|
|
135
|
+
* For a tool, a documentation page or a demo that is worth it. For a game shipping a level
|
|
136
|
+
* it is not, which is why it is not the default.
|
|
137
|
+
*/
|
|
138
|
+
readonly keepReveal?: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* Put a small version of each image on its surfaces before the full one, at this many pixels
|
|
141
|
+
* on the longest side. Absent means every image arrives once, at full size.
|
|
142
|
+
*
|
|
143
|
+
* **This is the blur-to-sharp arrival of docs/FORMAT.md §4.6, done from the bytes already in
|
|
144
|
+
* the file.** The section asks for mip levels stored smallest first as separate chunks, which
|
|
145
|
+
* is the better answer for a metered connection because it can stop early and keep the small
|
|
146
|
+
* one. It needs the *baker* to resize an image, and the baker runs in Node where there is no
|
|
147
|
+
* decoder for a JPEG, so that half is a dependency decision rather than an implementation.
|
|
148
|
+
*
|
|
149
|
+
* What is available without one: `createImageBitmap` resizes while it decodes. A 256 pixel
|
|
150
|
+
* version of a 2048 square costs a fraction of the full decode, so a surface stops being
|
|
151
|
+
* flat about a second earlier on a real model, and the full image then replaces it in the
|
|
152
|
+
* same GPU object. It costs a second decode per image, off the main thread, and not one byte
|
|
153
|
+
* of the file — which is why it is an option a caller weighs rather than a default.
|
|
154
|
+
*/
|
|
155
|
+
readonly texturePreview?: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A `.drft` loaded progressively: the model builds up on screen instead of appearing.
|
|
160
|
+
*
|
|
161
|
+
* **What this exists to prevent is the two obvious ways of doing it, both of which are worse.**
|
|
162
|
+
* Reading the whole file and uploading it at the end switches from nothing to everything after
|
|
163
|
+
* several seconds, which demonstrates nothing about a format built to arrive in priority order.
|
|
164
|
+
* Uploading each part the moment its bytes land puts an unbounded number of GPU allocations in
|
|
165
|
+
* one animation frame and hitches the page. This does neither: bytes stream, arrivals queue, and
|
|
166
|
+
* a bounded number of parts are uploaded per frame with a fade.
|
|
167
|
+
*
|
|
168
|
+
* **It starts on an outline where the file carries one.** A `LODM` chunk is a coarse whole model
|
|
169
|
+
* laid out ahead of everything else, so it is uploaded first and drawn until the real geometry is
|
|
170
|
+
* complete — which turns "nothing, then parts appearing" into "an object, sharpening". The parts
|
|
171
|
+
* fade in over it and the swap to the finished model is atomic, because a frame showing both is
|
|
172
|
+
* showing the same car twice. A caller that would rather hold the last picture says
|
|
173
|
+
* `outline: false`. See `DrftLoaderOptions.outline`.
|
|
174
|
+
*
|
|
175
|
+
* **It ends in one mesh per material group.** The reveal wants one upload per part and the steady
|
|
176
|
+
* state wants as few draws as possible, so the load does the first and finishes by doing the
|
|
177
|
+
* second: after the last byte the parts are merged by the image and blend they wear, uploaded
|
|
178
|
+
* once, and the per-part meshes are disposed. On a 187-part car that is 187 draws while loading
|
|
179
|
+
* and seven afterwards.
|
|
180
|
+
*
|
|
181
|
+
* Nothing here touches WebGL. Every GPU object comes from the `Renderer` it is handed, which is
|
|
182
|
+
* what keeps the engine's rule about where raw GL may live intact.
|
|
183
|
+
*/
|
|
184
|
+
export class DrftLoader {
|
|
185
|
+
private readonly renderer: RendererApi;
|
|
186
|
+
private readonly options: DrftLoaderOptions;
|
|
187
|
+
private readonly revealed: DrftPart[] = [];
|
|
188
|
+
/** Arrived, not yet uploaded. Drained by `update` at the budget. */
|
|
189
|
+
private readonly queue: { mesh: MeshData; material: DrftMaterial | undefined }[] = [];
|
|
190
|
+
/**
|
|
191
|
+
* Coarse levels that have arrived and not yet been uploaded, in arrival order.
|
|
192
|
+
*
|
|
193
|
+
* Its own queue rather than the part queue, because it must not wait behind a budget meant
|
|
194
|
+
* for parts: the entire value of an outline is that it is on screen before them.
|
|
195
|
+
*/
|
|
196
|
+
private readonly lodQueue: { mesh: MeshData; level: number }[] = [];
|
|
197
|
+
/** The outline currently on screen, and its level. -1 means none has landed. */
|
|
198
|
+
private outlinePart: DrftPart | null = null;
|
|
199
|
+
private outlineMesh: MeshHandle | null = null;
|
|
200
|
+
private outlineLevel = -1;
|
|
201
|
+
/**
|
|
202
|
+
* The merged groups, accumulated *as parts arrive* rather than all at once at the end.
|
|
203
|
+
*
|
|
204
|
+
* This is the second of the two stalls this class exists to avoid, and it was the worse one.
|
|
205
|
+
* Building seven merged meshes out of 187 parts after the last byte is a million vertices of
|
|
206
|
+
* copying in a single frame, and it landed exactly where a viewer had just started looking at a
|
|
207
|
+
* finished car. Adding each part to its group as it is uploaded spreads that cost over the whole
|
|
208
|
+
* load, where there is already a budget governing it, and leaves only one `build` and one upload
|
|
209
|
+
* per group to do at the end — which `update` then spends one group per frame.
|
|
210
|
+
*/
|
|
211
|
+
private readonly groups = new Map<
|
|
212
|
+
string,
|
|
213
|
+
{
|
|
214
|
+
albedo: number;
|
|
215
|
+
orm: number;
|
|
216
|
+
normal: number;
|
|
217
|
+
emissive: number;
|
|
218
|
+
opacity: number;
|
|
219
|
+
reflectivity: number;
|
|
220
|
+
roughnessScale: number;
|
|
221
|
+
metallicScale: number;
|
|
222
|
+
occlusionStrength: number;
|
|
223
|
+
cutout: number;
|
|
224
|
+
builder: MeshBuilder;
|
|
225
|
+
}
|
|
226
|
+
>();
|
|
227
|
+
/** Group keys still to be built and uploaded, drained one per frame after the stream ends. */
|
|
228
|
+
private readonly mergeQueue: string[] = [];
|
|
229
|
+
private readonly mergedParts: DrftPart[] = [];
|
|
230
|
+
/**
|
|
231
|
+
* Every part in the order it arrived, and the finished model, held only for `keepReveal`.
|
|
232
|
+
*
|
|
233
|
+
* The load itself needs neither: it draws from `revealed` and throws the singles away at the
|
|
234
|
+
* merge. These exist so `replay` can put an arrival state back, which needs the *order* as
|
|
235
|
+
* much as the meshes — a reveal replayed in the order the groups merged would be a different
|
|
236
|
+
* sequence from the one the file delivered.
|
|
237
|
+
*/
|
|
238
|
+
private readonly arrivals: DrftPart[] = [];
|
|
239
|
+
private readonly finished: DrftPart[] = [];
|
|
240
|
+
/** Decoded images waiting for the GPU, uploaded one per frame for the same reason. */
|
|
241
|
+
private readonly imageQueue: number[] = [];
|
|
242
|
+
/** Which images have arrived at full size, so a late preview cannot blur one of them again. */
|
|
243
|
+
private readonly sharp: boolean[] = [];
|
|
244
|
+
/**
|
|
245
|
+
* Which images are on the GPU, and how many, which is what `imagesDone` reports.
|
|
246
|
+
*
|
|
247
|
+
* Counted at the upload rather than at the arrival of the bytes, and the difference is a
|
|
248
|
+
* second and three quarters on a real asset. See `DrftLoadProgress`.
|
|
249
|
+
*/
|
|
250
|
+
private readonly bound: boolean[] = [];
|
|
251
|
+
private boundCount = 0;
|
|
252
|
+
private readonly singles: MeshHandle[] = [];
|
|
253
|
+
private readonly images: (ImageBitmap | null)[] = [];
|
|
254
|
+
private readonly chunks: DrftTexture[] = [];
|
|
255
|
+
private materials: readonly DrftMaterial[] = [];
|
|
256
|
+
/**
|
|
257
|
+
* The rig, as its chunks land.
|
|
258
|
+
*
|
|
259
|
+
* **Collected here rather than dropped**, which is not a hypothetical worry about this class:
|
|
260
|
+
* the normal-map texture index was written, carried and readable for a whole release while this
|
|
261
|
+
* was the single layer that discarded it. A field the container carries and the loader does not
|
|
262
|
+
* hand on is invisible to every test of either side.
|
|
263
|
+
*/
|
|
264
|
+
private loadedNodes: readonly DrftNode[] = [];
|
|
265
|
+
private readonly loadedSkins: DrftSkin[] = [];
|
|
266
|
+
private readonly loadedClips: AnimationClip[] = [];
|
|
267
|
+
/**
|
|
268
|
+
* Deltas as their chunks land, paired to their meshes when a part is built.
|
|
269
|
+
*
|
|
270
|
+
* Collected rather than applied on arrival because a `MORP` chunk can precede the `MESH` it
|
|
271
|
+
* names, and the mesh ordinal in its payload is what makes the pairing independent of order.
|
|
272
|
+
*/
|
|
273
|
+
private readonly loadedMorphs: DrftMorph[] = [];
|
|
274
|
+
private textureSet: TextureSet<SurfaceTextureHandle> | null = null;
|
|
275
|
+
private fit: { scale: number; x: number; y: number; z: number } | null = null;
|
|
276
|
+
private bounds: readonly number[] = [];
|
|
277
|
+
private meshShare = 0.5;
|
|
278
|
+
private imageShare = 0.5;
|
|
279
|
+
private state: DrftLoadProgress = {
|
|
280
|
+
phase: 'idle',
|
|
281
|
+
fraction: 0,
|
|
282
|
+
partsDone: 0,
|
|
283
|
+
partsTotal: 0,
|
|
284
|
+
imagesDone: 0,
|
|
285
|
+
imagesTotal: 0,
|
|
286
|
+
totalBytes: 0,
|
|
287
|
+
message: '',
|
|
288
|
+
};
|
|
289
|
+
/** Set once the last byte has landed, so `update` knows when to merge. */
|
|
290
|
+
private streamed = false;
|
|
291
|
+
private merged = false;
|
|
292
|
+
private disposed = false;
|
|
293
|
+
private arrived = 0;
|
|
294
|
+
|
|
295
|
+
constructor(renderer: RendererApi, options: DrftLoaderOptions = {}) {
|
|
296
|
+
this.renderer = renderer;
|
|
297
|
+
this.options = options;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
get progress(): DrftLoadProgress {
|
|
301
|
+
return this.state;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The parts to draw this frame. Grows while loading, then becomes the merged groups.
|
|
306
|
+
*
|
|
307
|
+
* While a coarse level is on screen it is the **first** entry, so a caller drawing them in
|
|
308
|
+
* order draws the outline behind the parts that are replacing it. It leaves the list in the
|
|
309
|
+
* same frame the merged model enters it.
|
|
310
|
+
*/
|
|
311
|
+
get parts(): readonly DrftPart[] {
|
|
312
|
+
return this.revealed;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Whether a coarse level is on screen, or held for a replay.
|
|
317
|
+
*
|
|
318
|
+
* False for most loads: the file has to carry one *and* the consumer has to have asked. It is
|
|
319
|
+
* here because a caller wording a readout needs it — with an outline, state 1 of a replay is
|
|
320
|
+
* the outline alone and state 2 is the first part, and without one the states shift down by
|
|
321
|
+
* one. A caller counting that out for itself would be guessing at what the file contained.
|
|
322
|
+
*/
|
|
323
|
+
/**
|
|
324
|
+
* The asset's hierarchy, or empty for a file carrying no `NODE`.
|
|
325
|
+
*
|
|
326
|
+
* Handed on rather than consumed here, because what to do with a hierarchy is the caller's:
|
|
327
|
+
* this class draws parts, and placing them under a graph is a scene decision.
|
|
328
|
+
*/
|
|
329
|
+
get nodes(): readonly DrftNode[] {
|
|
330
|
+
return this.loadedNodes;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Skins the file carried, in the order their chunks appeared. Empty for a file with none. */
|
|
334
|
+
get skins(): readonly DrftSkin[] {
|
|
335
|
+
return this.loadedSkins;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Clips the file carried, in the order their chunks appeared. Empty for a file with none. */
|
|
339
|
+
get clips(): readonly AnimationClip[] {
|
|
340
|
+
return this.loadedClips;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Morph deltas the file carried, each naming the mesh ordinal it deforms. */
|
|
344
|
+
get morphs(): readonly DrftMorph[] {
|
|
345
|
+
return this.loadedMorphs;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
get hasOutline(): boolean {
|
|
349
|
+
return this.outlinePart !== null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* How many states the finished reveal can be put back into, or 0 when none were kept.
|
|
354
|
+
*
|
|
355
|
+
* A **count of states rather than a fraction**, because the states are what a viewer is
|
|
356
|
+
* actually choosing between and they are not evenly spaced in anything: an outline is one
|
|
357
|
+
* thing, a part is one thing, and the merge is one thing. A caller drives it with an integer
|
|
358
|
+
* so every state is reachable — a fraction would make the outline a band 1/188th of a slider
|
|
359
|
+
* wide, which is a state nobody can stop on.
|
|
360
|
+
*
|
|
361
|
+
* State 0 is an empty stage. State 1 is the outline where the file carried one, and the first
|
|
362
|
+
* part where it did not. The last state is the finished model, drawn exactly as `ready` draws
|
|
363
|
+
* it. Everything between adds one more part, in the order the file delivered them.
|
|
364
|
+
*/
|
|
365
|
+
get revealSteps(): number {
|
|
366
|
+
if (this.options.keepReveal !== true || !this.merged) return 0;
|
|
367
|
+
return 2 + (this.outlinePart === null ? 0 : 1) + this.arrivals.length;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Put one state of the reveal back on screen. Needs `keepReveal`, and does nothing without it.
|
|
372
|
+
*
|
|
373
|
+
* **This is a presentation control, not a second loader.** Nothing is re-read, re-uploaded or
|
|
374
|
+
* re-merged: the meshes the load made are all still there and this decides which of them
|
|
375
|
+
* `parts` names. So it is free to call every frame, and a caller may scrub it as fast as it
|
|
376
|
+
* likes without touching the GPU.
|
|
377
|
+
*/
|
|
378
|
+
replay(step: number): void {
|
|
379
|
+
if (this.disposed) return;
|
|
380
|
+
const total = this.revealSteps;
|
|
381
|
+
if (total === 0) return;
|
|
382
|
+
const outline = this.outlinePart;
|
|
383
|
+
const at = Math.max(0, Math.min(total - 1, Math.round(step)));
|
|
384
|
+
|
|
385
|
+
this.revealed.length = 0;
|
|
386
|
+
if (at === 0) return;
|
|
387
|
+
if (at === total - 1) {
|
|
388
|
+
for (const part of this.finished) this.revealed.push(part);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
/* The outline sits under the parts that are replacing it, exactly as it does during a load. */
|
|
392
|
+
if (outline !== null) this.revealed.push(outline);
|
|
393
|
+
const parts = outline === null ? at : at - 1;
|
|
394
|
+
for (let i = 0; i < parts && i < this.arrivals.length; i++) {
|
|
395
|
+
const part = this.arrivals[i];
|
|
396
|
+
if (part === undefined) break;
|
|
397
|
+
/* Fully arrived rather than mid-fade: a state being *looked at* is a state that finished. */
|
|
398
|
+
part.reveal = 1;
|
|
399
|
+
this.revealed.push(part);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The asset's images by the name its source gave them, or null before any have arrived. */
|
|
404
|
+
get textures(): TextureSet<SurfaceTextureHandle> | null {
|
|
405
|
+
return this.textureSet;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The model's own bounds, from `HEAD`, which arrives before any geometry. */
|
|
409
|
+
get modelBounds(): readonly number[] {
|
|
410
|
+
return this.bounds;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* How the model was fitted: a uniform scale and an offset that centres it on the origin with
|
|
415
|
+
* its base at zero. Null until `HEAD` lands.
|
|
416
|
+
*/
|
|
417
|
+
get placement(): {
|
|
418
|
+
readonly scale: number;
|
|
419
|
+
readonly x: number;
|
|
420
|
+
readonly y: number;
|
|
421
|
+
readonly z: number;
|
|
422
|
+
} | null {
|
|
423
|
+
return this.fit;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Start loading, and resolve when the last byte has been read.
|
|
428
|
+
*
|
|
429
|
+
* Deliberately not the thing that produces the parts: the parts appear through `update`, so a
|
|
430
|
+
* caller draws its frames as usual and never awaits anything. A failed load leaves the phase at
|
|
431
|
+
* `failed` with a message rather than throwing, because a scene is usually still valid without
|
|
432
|
+
* its model and a blank screen would report the wrong problem.
|
|
433
|
+
*
|
|
434
|
+
* `fitTo` is the size the model's larger of footprint and height is scaled to fit, which is the
|
|
435
|
+
* one thing a loader cannot infer: a bought asset arrives in whatever units its author used.
|
|
436
|
+
* `baseY` is where its lowest point should sit, for a scene whose floor is not at zero — a
|
|
437
|
+
* plinth, a turntable, a deck. It defaults to zero, and getting it wrong is very visible: the
|
|
438
|
+
* first version of this had no such parameter and put a car's wheels through the turntable it
|
|
439
|
+
* was standing on.
|
|
440
|
+
*
|
|
441
|
+
* **`{ fit: 'none' }` declines all of it** — scale 1, no centring, no base — for a caller whose
|
|
442
|
+
* importer has already placed the model. See `DrftFit` for why that is a stated option rather
|
|
443
|
+
* than a very large footprint.
|
|
444
|
+
*/
|
|
445
|
+
async load(url: string, fitTo: DrftFit): Promise<void> {
|
|
446
|
+
this.set({ phase: 'connecting', message: '' });
|
|
447
|
+
try {
|
|
448
|
+
/*
|
|
449
|
+
* `no-store`, because a model is the thing most likely to be re-baked while somebody is
|
|
450
|
+
* looking at it. A browser holding fifty megabytes it fetched an hour ago does mean a fix
|
|
451
|
+
* to the *baker* appears to have changed nothing, and the time lost to that is out of all
|
|
452
|
+
* proportion to the saving.
|
|
453
|
+
*/
|
|
454
|
+
const response = await fetch(url, { cache: 'no-store' });
|
|
455
|
+
if (!response.ok) {
|
|
456
|
+
this.set({ phase: 'absent', message: `no model at ${url}` });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
/*
|
|
460
|
+
* A 200 carrying a page is a model that was never deployed, not a model that is broken.
|
|
461
|
+
* See `isDocumentResponse`: the phase has to be `absent` here, because `failed` sends
|
|
462
|
+
* whoever reads it looking for a corrupt file that does not exist.
|
|
463
|
+
*/
|
|
464
|
+
if (isDocumentResponse(response.headers.get('content-type'))) {
|
|
465
|
+
this.set({ phase: 'absent', message: `${url} is a page, not a model` });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
await this.consume(response, fitTo);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
471
|
+
this.set({ phase: 'failed', message });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The same load, from a container this class did not fetch.
|
|
477
|
+
*
|
|
478
|
+
* Split out because a `.drft` is not the only way a model arrives. A source format read at
|
|
479
|
+
* runtime is parsed and written to a container in a worker, and what comes back is bytes
|
|
480
|
+
* that have never been near a URL. Everything after the first byte is identical, so it would
|
|
481
|
+
* be a second copy of the stream, the upload budget, the fade and the merge, kept in step by
|
|
482
|
+
* hand. A `Response` is the seam because it is what `streamDrft` already takes, and one can
|
|
483
|
+
* be built over an `ArrayBuffer` with no copy and no server.
|
|
484
|
+
*/
|
|
485
|
+
async consume(response: Response, fitTo: DrftFit): Promise<void> {
|
|
486
|
+
try {
|
|
487
|
+
await streamDrft(response, {
|
|
488
|
+
onMorph: (morph) => {
|
|
489
|
+
this.loadedMorphs.push(morph);
|
|
490
|
+
},
|
|
491
|
+
onNodes: (nodes) => {
|
|
492
|
+
this.loadedNodes = nodes;
|
|
493
|
+
},
|
|
494
|
+
onSkin: (skin) => {
|
|
495
|
+
this.loadedSkins.push(skin);
|
|
496
|
+
},
|
|
497
|
+
onClip: (clip) => {
|
|
498
|
+
this.loadedClips.push(clip);
|
|
499
|
+
},
|
|
500
|
+
onManifest: (manifest) => {
|
|
501
|
+
const carried = manifest.meshBytes + manifest.textureBytes;
|
|
502
|
+
if (carried > 0) {
|
|
503
|
+
this.meshShare = manifest.meshBytes / carried;
|
|
504
|
+
this.imageShare = manifest.textureBytes / carried;
|
|
505
|
+
}
|
|
506
|
+
this.set({
|
|
507
|
+
phase: 'manifest',
|
|
508
|
+
partsTotal: manifest.meshCount,
|
|
509
|
+
imagesTotal: manifest.textureCount,
|
|
510
|
+
totalBytes: manifest.totalBytes,
|
|
511
|
+
});
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
onHead: (head) => {
|
|
515
|
+
/*
|
|
516
|
+
* Fitted by height *and* footprint, not by the longest axis alone. Scaling the longest
|
|
517
|
+
* span is right for a car, whose longest axis is horizontal, and wrong for anything
|
|
518
|
+
* standing up: the same rule made a character nine metres tall in a nine metre room.
|
|
519
|
+
* Whichever cap bites is the one the shape needed.
|
|
520
|
+
*/
|
|
521
|
+
this.bounds = head.bounds;
|
|
522
|
+
if ('fit' in fitTo) {
|
|
523
|
+
/* Nothing at all, which is what a caller whose model is already placed asked for. */
|
|
524
|
+
this.fit = { scale: 1, x: 0, y: 0, z: 0 };
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const [minX, minY, minZ, maxX, maxY, maxZ] = head.bounds as number[];
|
|
528
|
+
const spanX = (maxX as number) - (minX as number);
|
|
529
|
+
const spanY = (maxY as number) - (minY as number);
|
|
530
|
+
const spanZ = (maxZ as number) - (minZ as number);
|
|
531
|
+
const footprint = Math.max(spanX, spanZ) || 1;
|
|
532
|
+
const height = spanY || 1;
|
|
533
|
+
const scale = Math.min(fitTo.footprint / footprint, fitTo.height / height);
|
|
534
|
+
this.fit = {
|
|
535
|
+
scale,
|
|
536
|
+
x: -(((minX as number) + (maxX as number)) / 2) * scale,
|
|
537
|
+
y: -(minY as number) * scale + (fitTo.baseY ?? 0),
|
|
538
|
+
z: -(((minZ as number) + (maxZ as number)) / 2) * scale,
|
|
539
|
+
};
|
|
540
|
+
},
|
|
541
|
+
|
|
542
|
+
/*
|
|
543
|
+
* A coarse whole model, which is the first thing in a file laid out by this baker.
|
|
544
|
+
*
|
|
545
|
+
* Queued rather than uploaded here for the same reason a part is: this runs on the
|
|
546
|
+
* stream's own callback, not inside a frame, and creating GPU objects from there is how
|
|
547
|
+
* an unbounded amount of work lands in whichever frame happens to be running.
|
|
548
|
+
*/
|
|
549
|
+
onLod: (mesh, level) => {
|
|
550
|
+
/* Drawn unless this consumer refused one: see `DrftLoaderOptions.outline`. The chunk is
|
|
551
|
+
read and validated either way, so a malformed one still fails loudly when refused. */
|
|
552
|
+
if (this.disposed || this.options.outline === false) return;
|
|
553
|
+
this.lodQueue.push({ mesh, level });
|
|
554
|
+
this.set({ phase: 'outline' });
|
|
555
|
+
},
|
|
556
|
+
|
|
557
|
+
onMaterials: (list) => {
|
|
558
|
+
this.materials = list;
|
|
559
|
+
this.set({ phase: 'materials' });
|
|
560
|
+
},
|
|
561
|
+
|
|
562
|
+
onMesh: (mesh, ordinal) => {
|
|
563
|
+
if (this.disposed) return;
|
|
564
|
+
const material = this.materials[ordinal];
|
|
565
|
+
/*
|
|
566
|
+
* Deltas onto the mesh they name, before it is queued for upload. The container writes a
|
|
567
|
+
* `MORP` immediately ahead of its `MESH` so this is in hand; a file that puts them the
|
|
568
|
+
* other way round still opens, and that mesh simply does not morph — a defined
|
|
569
|
+
* degradation rather than a wrong picture.
|
|
570
|
+
*/
|
|
571
|
+
const morph = this.loadedMorphs.find((entry) => entry.mesh === ordinal);
|
|
572
|
+
const withMorph =
|
|
573
|
+
morph === undefined
|
|
574
|
+
? mesh
|
|
575
|
+
: { ...mesh, morphTargets: morph.deltas, morphTargetCount: morph.targetCount };
|
|
576
|
+
this.queue.push({ mesh: withMorph, material });
|
|
577
|
+
this.arrived++;
|
|
578
|
+
this.set({ phase: 'geometry', partsDone: this.arrived });
|
|
579
|
+
},
|
|
580
|
+
|
|
581
|
+
onTexture: (texture, ordinal) => {
|
|
582
|
+
if (this.disposed) return;
|
|
583
|
+
this.chunks.push(texture);
|
|
584
|
+
/*
|
|
585
|
+
* The phase moves here and the count does not. The bytes arriving is not the picture
|
|
586
|
+
* being right: what follows is a decode this deliberately does not await, and until it
|
|
587
|
+
* lands the surfaces wearing this image draw untextured. Counting here made
|
|
588
|
+
* `imagesDone` equal `imagesTotal` and `fraction` reach 1 while the model was still
|
|
589
|
+
* visibly wrong, which a consumer's loading bar reported to a person as finished.
|
|
590
|
+
*/
|
|
591
|
+
this.set({ phase: 'textures' });
|
|
592
|
+
/*
|
|
593
|
+
* Decoded through the browser's own image path, so the engine ships no decoder, and
|
|
594
|
+
* **each one fails on its own**: a texture is the part of an asset most likely to be
|
|
595
|
+
* wrong, and none of the ways it can be is a reason to lose the model. Not awaited
|
|
596
|
+
* here, so a slow decode never holds up the next chunk of the stream.
|
|
597
|
+
*/
|
|
598
|
+
/*
|
|
599
|
+
* A small version first, where a caller asked for one, then the image itself.
|
|
600
|
+
*
|
|
601
|
+
* **This is the blur-to-sharp arrival docs/FORMAT.md §4.6 wanted, and it costs the file
|
|
602
|
+
* nothing.** The section proposed mip levels stored smallest first as separate chunks,
|
|
603
|
+
* which is right and needs the *baker* to resize an image — and the baker runs in Node,
|
|
604
|
+
* where there is no decoder for a JPEG and adding one is a dependency decision rather
|
|
605
|
+
* than an implementation. `createImageBitmap` resizes while it decodes, so the same
|
|
606
|
+
* effect is available from the bytes already in the file: a 256 pixel version of a
|
|
607
|
+
* 2048 square costs a fraction of the full decode and puts something on the surface
|
|
608
|
+
* about a second earlier, and the full decode then replaces it in the same GPU object.
|
|
609
|
+
*/
|
|
610
|
+
const preview = this.options.texturePreview;
|
|
611
|
+
if (preview !== undefined && preview > 0) {
|
|
612
|
+
void decodeImage(texture, preview)
|
|
613
|
+
.then((bitmap) => {
|
|
614
|
+
/* Dropped if the real thing beat it here: a preview arriving after the image it
|
|
615
|
+
previews would put a blurred texture on a surface that already had a sharp one. */
|
|
616
|
+
if (this.disposed || this.sharp[ordinal] === true) return;
|
|
617
|
+
this.images[ordinal] = bitmap;
|
|
618
|
+
this.imageQueue.push(ordinal);
|
|
619
|
+
})
|
|
620
|
+
.catch(() => {
|
|
621
|
+
/* A preview is an optimisation. Losing one costs nothing and says nothing about
|
|
622
|
+
whether the image itself will decode, so it is not even worth a warning. */
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
void decodeImage(texture)
|
|
626
|
+
.then((bitmap) => {
|
|
627
|
+
this.images[ordinal] = bitmap;
|
|
628
|
+
this.sharp[ordinal] = true;
|
|
629
|
+
this.imageQueue.push(ordinal);
|
|
630
|
+
})
|
|
631
|
+
.catch((error: unknown) => {
|
|
632
|
+
/*
|
|
633
|
+
* **What arrived, not only that it was refused.** A decoder's own message is
|
|
634
|
+
* `The source image could not be decoded` whatever went wrong, which is true of a
|
|
635
|
+
* truncated file, a codec byte that disagrees with the bytes, and an empty view
|
|
636
|
+
* alike — and those have completely different causes. The codec, the length and the
|
|
637
|
+
* first four bytes separate them at a glance: a PNG opens `89 50 4e 47` and a JPEG
|
|
638
|
+
* `ff d8 ff`, so a header that matches means the bytes are sound and the fault is
|
|
639
|
+
* downstream of them.
|
|
640
|
+
*/
|
|
641
|
+
const head = Array.from(texture.bytes.slice(0, 4))
|
|
642
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
643
|
+
.join(' ');
|
|
644
|
+
console.warn(
|
|
645
|
+
`DrftLoader: image ${ordinal} did not decode; its surfaces stay untextured` +
|
|
646
|
+
` (codec ${texture.codec}, ${texture.bytes.length} bytes, ${texture.width}x${texture.height}, starts ${head})`,
|
|
647
|
+
error,
|
|
648
|
+
);
|
|
649
|
+
this.images[ordinal] = null;
|
|
650
|
+
this.sharp[ordinal] = true;
|
|
651
|
+
this.imageQueue.push(ordinal);
|
|
652
|
+
});
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
this.streamed = true;
|
|
656
|
+
} catch (error) {
|
|
657
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
658
|
+
this.set({ phase: 'failed', message });
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Spend this frame's upload budget and advance the fades. Call once per frame.
|
|
664
|
+
*
|
|
665
|
+
* Returns whether anything changed, for a caller that wants to know it should redraw a
|
|
666
|
+
* progress readout rather than rebuild one every frame.
|
|
667
|
+
*/
|
|
668
|
+
update(dtSec: number): boolean {
|
|
669
|
+
if (this.disposed) return false;
|
|
670
|
+
let changed = false;
|
|
671
|
+
|
|
672
|
+
/*
|
|
673
|
+
* One clock over every kind of work, and that is the correction.
|
|
674
|
+
*
|
|
675
|
+
* These used to be three budgets, on the argument that a part, an image and a merged group are
|
|
676
|
+
* not interchangeable and that spending "three things" of any kind would be three very
|
|
677
|
+
* different frames. The observation was right and the conclusion did not follow: budgets that
|
|
678
|
+
* cannot see each other still *sum*, so one frame could take three parts and an image and a
|
|
679
|
+
* group merge with nothing anywhere counting the total. What the three have in common is the
|
|
680
|
+
* only thing that ever mattered — they are all spending this frame — and a clock measures that
|
|
681
|
+
* for all of them without needing to know what any of them is.
|
|
682
|
+
*/
|
|
683
|
+
const startedMs = performance.now();
|
|
684
|
+
const msBudget = this.options.uploadMsPerFrame ?? DEFAULT_UPLOAD_MS_PER_FRAME;
|
|
685
|
+
let begun = 0;
|
|
686
|
+
|
|
687
|
+
/*
|
|
688
|
+
* The outline first, and still ahead of the parts. It is one mesh of a few thousand triangles
|
|
689
|
+
* and the whole point of it is to be on screen first, so making it wait behind part uploads
|
|
690
|
+
* would spend the one advantage it has. Only the best level that has arrived is uploaded: a
|
|
691
|
+
* level the stream has already passed is work with nothing to show.
|
|
692
|
+
*
|
|
693
|
+
* It goes *on* the clock rather than beside it. Being allowed to start first is what it was
|
|
694
|
+
* owed; being free was never part of it, and the parts that followed it into the same frame
|
|
695
|
+
* are the ones that paid for that.
|
|
696
|
+
*/
|
|
697
|
+
if (this.lodQueue.length > 0) {
|
|
698
|
+
const best = this.lodQueue[this.lodQueue.length - 1];
|
|
699
|
+
this.lodQueue.length = 0;
|
|
700
|
+
if (best !== undefined && this.uploadOutline(best.mesh, best.level)) changed = true;
|
|
701
|
+
begun++;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const budget = this.options.uploadsPerFrame ?? DEFAULT_UPLOADS_PER_FRAME;
|
|
705
|
+
for (
|
|
706
|
+
let spent = 0;
|
|
707
|
+
spent < budget &&
|
|
708
|
+
this.queue.length > 0 &&
|
|
709
|
+
mayBeginMore(begun, performance.now() - startedMs, msBudget);
|
|
710
|
+
spent++
|
|
711
|
+
) {
|
|
712
|
+
const next = this.queue.shift();
|
|
713
|
+
if (next === undefined) break;
|
|
714
|
+
if (this.uploadOne(next.mesh, next.material)) changed = true;
|
|
715
|
+
begun++;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/* One image a frame, and only into a frame that has room for it. A 2048 square decodes
|
|
719
|
+
off-thread and then uploads with its mips, which is the single most expensive thing in a
|
|
720
|
+
load and lands right after the geometry — on top of the parts, until this was clocked. */
|
|
721
|
+
if (
|
|
722
|
+
this.imageQueue.length > 0 &&
|
|
723
|
+
mayBeginMore(begun, performance.now() - startedMs, msBudget)
|
|
724
|
+
) {
|
|
725
|
+
const ordinal = this.imageQueue.shift();
|
|
726
|
+
if (ordinal !== undefined) {
|
|
727
|
+
this.rebuildTextures(ordinal);
|
|
728
|
+
/*
|
|
729
|
+
* Counted here, once, and only for the image itself.
|
|
730
|
+
*
|
|
731
|
+
* A preview passes through this same queue and binding one is not the image being done —
|
|
732
|
+
* it is a blurred stand-in for it, so counting it would put the old lie back in a
|
|
733
|
+
* smaller size. `sharp` is set by the full decode and by a failed one, which is what
|
|
734
|
+
* keeps a bar that cannot finish from being the price of an image that never decoded.
|
|
735
|
+
*/
|
|
736
|
+
if (this.sharp[ordinal] === true && this.bound[ordinal] !== true) {
|
|
737
|
+
this.bound[ordinal] = true;
|
|
738
|
+
this.boundCount++;
|
|
739
|
+
this.set({ imagesDone: this.boundCount });
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
changed = true;
|
|
743
|
+
begun++;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const revealSec = this.options.revealSec ?? DEFAULT_REVEAL_SEC;
|
|
747
|
+
for (const part of this.revealed) {
|
|
748
|
+
if (part.reveal >= 1) continue;
|
|
749
|
+
part.reveal = revealSec <= 0 ? 1 : Math.min(1, part.reveal + dtSec / revealSec);
|
|
750
|
+
changed = true;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/*
|
|
754
|
+
* The merge starts once the stream has finished and every arrival has been uploaded, so the
|
|
755
|
+
* swap can never throw away a part that is still queued.
|
|
756
|
+
*/
|
|
757
|
+
if (
|
|
758
|
+
this.streamed &&
|
|
759
|
+
!this.merged &&
|
|
760
|
+
this.queue.length === 0 &&
|
|
761
|
+
mayBeginMore(begun, performance.now() - startedMs, msBudget)
|
|
762
|
+
) {
|
|
763
|
+
if (this.mergeQueue.length === 0 && this.mergedParts.length === 0) {
|
|
764
|
+
for (const key of this.groups.keys()) this.mergeQueue.push(key);
|
|
765
|
+
}
|
|
766
|
+
/* One group a frame, and not in a frame the parts have already spent. A merge is a copy of
|
|
767
|
+
every vertex in its group and it lands in exactly the frames where the last parts are
|
|
768
|
+
still arriving: 11 to 36 ms measured, on top of 34 ms of parts. */
|
|
769
|
+
const key = this.mergeQueue.shift();
|
|
770
|
+
if (key !== undefined) {
|
|
771
|
+
this.buildGroup(key);
|
|
772
|
+
changed = true;
|
|
773
|
+
}
|
|
774
|
+
if (this.mergeQueue.length === 0) {
|
|
775
|
+
this.merged = true;
|
|
776
|
+
this.swapInMerged();
|
|
777
|
+
this.set({ phase: 'ready', fraction: 1 });
|
|
778
|
+
changed = true;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return changed;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
dispose(): void {
|
|
785
|
+
if (this.disposed) return;
|
|
786
|
+
this.disposed = true;
|
|
787
|
+
/*
|
|
788
|
+
* Everything this class ever made, each disposed once.
|
|
789
|
+
*
|
|
790
|
+
* Through a set, because the same `Mesh` legitimately appears in more than one of these
|
|
791
|
+
* lists: a part is in `revealed` and in `singles` and, under `keepReveal`, in `arrivals` as
|
|
792
|
+
* well. Disposing it twice is a second `deleteBuffer` on a name the driver has already
|
|
793
|
+
* freed, which is exactly the class of double-free that is silent until it is not.
|
|
794
|
+
*/
|
|
795
|
+
const meshes = new Set<MeshHandle>();
|
|
796
|
+
for (const part of this.revealed) meshes.add(part.mesh);
|
|
797
|
+
for (const part of this.arrivals) meshes.add(part.mesh);
|
|
798
|
+
for (const part of this.finished) meshes.add(part.mesh);
|
|
799
|
+
for (const part of this.mergedParts) meshes.add(part.mesh);
|
|
800
|
+
for (const mesh of this.singles) meshes.add(mesh);
|
|
801
|
+
if (this.outlineMesh !== null) meshes.add(this.outlineMesh);
|
|
802
|
+
for (const mesh of meshes) this.renderer.disposeMesh(mesh);
|
|
803
|
+
this.revealed.length = 0;
|
|
804
|
+
this.arrivals.length = 0;
|
|
805
|
+
this.finished.length = 0;
|
|
806
|
+
this.mergedParts.length = 0;
|
|
807
|
+
this.singles.length = 0;
|
|
808
|
+
this.outlineMesh = null;
|
|
809
|
+
this.outlinePart = null;
|
|
810
|
+
for (const texture of this.textureSet?.all() ?? [])
|
|
811
|
+
this.renderer.disposeSurfaceTexture(texture);
|
|
812
|
+
this.textureSet = null;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* The coarse whole model, on screen until the real one is complete.
|
|
817
|
+
*
|
|
818
|
+
* **Drawn as a part rather than through a second path**, so a scene that already iterates
|
|
819
|
+
* `parts` needs no change and cannot forget it. It wears no image and no reflection, because
|
|
820
|
+
* a level of detail carries the four mandatory attributes only — see `buildCoarseLevel`, and
|
|
821
|
+
* §4.6 for why a silhouette does not pay for the roughness of a surface nobody looks at.
|
|
822
|
+
*
|
|
823
|
+
* **The replacement is atomic**, which §4.6 asks for by name: a better level takes the worse
|
|
824
|
+
* one's place in the draw list in the same call it is uploaded in, so no frame draws both and
|
|
825
|
+
* none draws neither. It keeps the fade it had, or a finer level arriving would restart it and
|
|
826
|
+
* the model would appear to flash.
|
|
827
|
+
*/
|
|
828
|
+
private uploadOutline(mesh: MeshData, level: number): boolean {
|
|
829
|
+
const fit = this.fit;
|
|
830
|
+
/*
|
|
831
|
+
* `merged` is the one case worth guarding: the model itself is on screen by then, and an
|
|
832
|
+
* outline added after it would draw a second, coarser copy of the same object inside it.
|
|
833
|
+
*/
|
|
834
|
+
if (fit === null || this.merged || level <= this.outlineLevel) return false;
|
|
835
|
+
const single = new MeshBuilder();
|
|
836
|
+
single.addMesh(mesh, fit.x, fit.y, fit.z, fit.scale);
|
|
837
|
+
let uploaded: MeshHandle;
|
|
838
|
+
try {
|
|
839
|
+
uploaded = this.renderer.createMesh(single.build());
|
|
840
|
+
} catch (error) {
|
|
841
|
+
/* An outline that will not upload costs the outline. The model is still coming. */
|
|
842
|
+
console.warn(
|
|
843
|
+
'DrftLoader: the coarse level would not upload; the load goes on without it',
|
|
844
|
+
error,
|
|
845
|
+
);
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const previous = this.outlineMesh;
|
|
850
|
+
const part: DrftPart = {
|
|
851
|
+
mesh: uploaded,
|
|
852
|
+
albedo: -1,
|
|
853
|
+
orm: -1,
|
|
854
|
+
normal: -1,
|
|
855
|
+
emissive: -1,
|
|
856
|
+
opacity: 1,
|
|
857
|
+
reflectivity: 0,
|
|
858
|
+
roughnessScale: 1,
|
|
859
|
+
metallicScale: 1,
|
|
860
|
+
occlusionStrength: 0,
|
|
861
|
+
cutout: 0,
|
|
862
|
+
reveal: this.outlinePart?.reveal ?? 0,
|
|
863
|
+
};
|
|
864
|
+
const at = this.outlinePart === null ? -1 : this.revealed.indexOf(this.outlinePart);
|
|
865
|
+
/* First in the list, so it is drawn before the parts that stand in front of it. */
|
|
866
|
+
if (at >= 0) this.revealed[at] = part;
|
|
867
|
+
else this.revealed.unshift(part);
|
|
868
|
+
this.outlinePart = part;
|
|
869
|
+
this.outlineMesh = uploaded;
|
|
870
|
+
this.outlineLevel = level;
|
|
871
|
+
if (previous !== null) this.renderer.disposeMesh(previous);
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** One part, transformed, dressed and uploaded. Returns whether it landed. */
|
|
876
|
+
private uploadOne(mesh: MeshData, material: DrftMaterial | undefined): boolean {
|
|
877
|
+
const fit = this.fit;
|
|
878
|
+
if (fit === null) return false;
|
|
879
|
+
const dressed = this.options.transform?.(mesh, material) ?? mesh;
|
|
880
|
+
const single = new MeshBuilder();
|
|
881
|
+
single.addMesh(dressed, fit.x, fit.y, fit.z, fit.scale);
|
|
882
|
+
const override = this.options.surface?.(material);
|
|
883
|
+
/*
|
|
884
|
+
* **The resolution and the key both live in `drawKey.ts`, and that is the point of them.**
|
|
885
|
+
*
|
|
886
|
+
* Four texture indices, the two fields an override may replace, and four scalars. What each
|
|
887
|
+
* default is and why the override wins for two of them is written there, at the resolution,
|
|
888
|
+
* because a consumer counting the draws a bake will cost has to answer the same question and
|
|
889
|
+
* was answering it with a copy of this code that went stale when `cutout` joined the key.
|
|
890
|
+
*/
|
|
891
|
+
const g = resolveDrawGrouping(material, override);
|
|
892
|
+
const {
|
|
893
|
+
albedo,
|
|
894
|
+
orm,
|
|
895
|
+
normal,
|
|
896
|
+
emissive,
|
|
897
|
+
opacity,
|
|
898
|
+
reflectivity,
|
|
899
|
+
roughnessScale,
|
|
900
|
+
metallicScale,
|
|
901
|
+
occlusionStrength,
|
|
902
|
+
cutout,
|
|
903
|
+
} = g;
|
|
904
|
+
|
|
905
|
+
/*
|
|
906
|
+
* Added to its merged group here, while there is a budget governing how much of this happens
|
|
907
|
+
* in one frame.
|
|
908
|
+
*/
|
|
909
|
+
const key = drawKeyOf(material, override);
|
|
910
|
+
let group = this.groups.get(key);
|
|
911
|
+
if (group === undefined) {
|
|
912
|
+
group = {
|
|
913
|
+
albedo,
|
|
914
|
+
orm,
|
|
915
|
+
normal,
|
|
916
|
+
emissive,
|
|
917
|
+
opacity,
|
|
918
|
+
reflectivity,
|
|
919
|
+
roughnessScale,
|
|
920
|
+
metallicScale,
|
|
921
|
+
occlusionStrength,
|
|
922
|
+
cutout,
|
|
923
|
+
builder: new MeshBuilder(),
|
|
924
|
+
};
|
|
925
|
+
this.groups.set(key, group);
|
|
926
|
+
}
|
|
927
|
+
/* Not merged, for the reason above: a group's deltas would deform its neighbours. */
|
|
928
|
+
if (dressed.morphTargets === undefined) {
|
|
929
|
+
group.builder.addMesh(dressed, fit.x, fit.y, fit.z, fit.scale);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
try {
|
|
933
|
+
/*
|
|
934
|
+
* **A morphed part bypasses the builder, and is never merged.**
|
|
935
|
+
*
|
|
936
|
+
* `MeshBuilder` carries the attributes it knows and morph deltas are not among them, so a
|
|
937
|
+
* part built through it arrives on the GPU with its targets dropped. Merging is worse than
|
|
938
|
+
* that rather than merely lossy: a merged group concatenates vertices from several parts,
|
|
939
|
+
* and one part's deltas applied across that buffer would deform whatever geometry happened
|
|
940
|
+
* to follow it.
|
|
941
|
+
*
|
|
942
|
+
* So the deltas are scaled by the fit and handed straight to `createMesh`. **Scaled and not
|
|
943
|
+
* translated** — a delta is a displacement, and moving it by the fit's offset would drag
|
|
944
|
+
* every vertex toward the origin of the model rather than deform it in place.
|
|
945
|
+
*/
|
|
946
|
+
const built = single.build();
|
|
947
|
+
const morphable =
|
|
948
|
+
dressed.morphTargets !== undefined && dressed.morphTargetCount !== undefined
|
|
949
|
+
? {
|
|
950
|
+
...built,
|
|
951
|
+
morphTargets: dressed.morphTargets.map((delta) => delta * fit.scale),
|
|
952
|
+
morphTargetCount: dressed.morphTargetCount,
|
|
953
|
+
}
|
|
954
|
+
: built;
|
|
955
|
+
const uploaded = this.renderer.createMesh(morphable);
|
|
956
|
+
this.singles.push(uploaded);
|
|
957
|
+
const part: DrftPart = {
|
|
958
|
+
mesh: uploaded,
|
|
959
|
+
albedo,
|
|
960
|
+
orm,
|
|
961
|
+
normal,
|
|
962
|
+
emissive,
|
|
963
|
+
opacity,
|
|
964
|
+
reflectivity,
|
|
965
|
+
roughnessScale,
|
|
966
|
+
metallicScale,
|
|
967
|
+
occlusionStrength,
|
|
968
|
+
cutout,
|
|
969
|
+
reveal: 0,
|
|
970
|
+
};
|
|
971
|
+
this.revealed.push(part);
|
|
972
|
+
if (this.options.keepReveal === true) this.arrivals.push(part);
|
|
973
|
+
return true;
|
|
974
|
+
} catch (error) {
|
|
975
|
+
/* One bad part costs one surface rather than the model. Uploading is where a malformed
|
|
976
|
+
asset reaches the driver, and a throw here would leak every buffer already made. */
|
|
977
|
+
console.warn('DrftLoader: a part would not upload and was skipped', error);
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Replace the per-part meshes with one per material group.
|
|
984
|
+
*
|
|
985
|
+
* Grouped by opacity and reflectivity as well as by image, because two surfaces can share a map
|
|
986
|
+
* and differ in blend: merging on the image alone means one cannot be made translucent without
|
|
987
|
+
* taking the other with it.
|
|
988
|
+
*/
|
|
989
|
+
private buildGroup(key: string): void {
|
|
990
|
+
const group = this.groups.get(key);
|
|
991
|
+
if (group === undefined) return;
|
|
992
|
+
try {
|
|
993
|
+
this.mergedParts.push({
|
|
994
|
+
mesh: this.renderer.createMesh(group.builder.build()),
|
|
995
|
+
albedo: group.albedo,
|
|
996
|
+
orm: group.orm,
|
|
997
|
+
normal: group.normal,
|
|
998
|
+
emissive: group.emissive,
|
|
999
|
+
opacity: group.opacity,
|
|
1000
|
+
reflectivity: group.reflectivity,
|
|
1001
|
+
roughnessScale: group.roughnessScale,
|
|
1002
|
+
metallicScale: group.metallicScale,
|
|
1003
|
+
occlusionStrength: group.occlusionStrength,
|
|
1004
|
+
cutout: group.cutout,
|
|
1005
|
+
reveal: 1,
|
|
1006
|
+
});
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
/* One bad group costs one surface rather than the model. */
|
|
1009
|
+
console.warn('DrftLoader: a merged group would not upload and was skipped', error);
|
|
1010
|
+
}
|
|
1011
|
+
this.groups.delete(key);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Put the merged groups on screen and throw the per-part meshes away.
|
|
1016
|
+
*
|
|
1017
|
+
* **In one step, once every group exists.** A frame drawn with both sets present would draw the
|
|
1018
|
+
* model twice and blend its translucent surfaces against themselves, and a frame drawn with the
|
|
1019
|
+
* singles already gone would show a model with holes in it. So the groups are built over as many
|
|
1020
|
+
* frames as it takes and swapped in together.
|
|
1021
|
+
*/
|
|
1022
|
+
private swapInMerged(): void {
|
|
1023
|
+
/*
|
|
1024
|
+
* Nothing merged means every group failed to upload, and then the outline is the only thing
|
|
1025
|
+
* standing between a viewer and an empty room. Keeping it is the honest state: something
|
|
1026
|
+
* incomplete rather than nothing at all.
|
|
1027
|
+
*/
|
|
1028
|
+
if (this.mergedParts.length === 0) return;
|
|
1029
|
+
/*
|
|
1030
|
+
* The outline leaves in the same frame the model arrives. Two representations of one object
|
|
1031
|
+
* is worse than either of them, and it is what a fade would otherwise cross-dissolve.
|
|
1032
|
+
*
|
|
1033
|
+
* Kept rather than disposed under `keepReveal`, along with the singles below: a reveal that
|
|
1034
|
+
* can be replayed is one whose pieces still exist.
|
|
1035
|
+
*/
|
|
1036
|
+
const keep = this.options.keepReveal === true;
|
|
1037
|
+
if (this.outlineMesh !== null && !keep) {
|
|
1038
|
+
this.renderer.disposeMesh(this.outlineMesh);
|
|
1039
|
+
this.outlineMesh = null;
|
|
1040
|
+
this.outlinePart = null;
|
|
1041
|
+
}
|
|
1042
|
+
this.revealed.length = 0;
|
|
1043
|
+
for (const part of this.mergedParts) this.revealed.push(part);
|
|
1044
|
+
this.mergedParts.length = 0;
|
|
1045
|
+
/* Solid first, blended last, which is the whole of the sort: a translucent surface needs the
|
|
1046
|
+
world behind it already drawn to blend against. */
|
|
1047
|
+
this.revealed.sort((a, b) => b.opacity - a.opacity);
|
|
1048
|
+
if (keep) {
|
|
1049
|
+
/* Copied after the sort, so a replay of the finished state draws it in the order the
|
|
1050
|
+
finished state is actually drawn in. */
|
|
1051
|
+
this.finished.length = 0;
|
|
1052
|
+
for (const part of this.revealed) this.finished.push(part);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
for (const mesh of this.singles) this.renderer.disposeMesh(mesh);
|
|
1056
|
+
this.singles.length = 0;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Rebuild the name-addressed texture set as images decode.
|
|
1061
|
+
*
|
|
1062
|
+
* Rebuilt rather than mutated because `TextureSet` is immutable once made, and this happens
|
|
1063
|
+
* once per image rather than once per frame. Addressing by name is what stops a regroup
|
|
1064
|
+
* silently reassigning an image to a different surface.
|
|
1065
|
+
*/
|
|
1066
|
+
private rebuildTextures(ordinal: number): void {
|
|
1067
|
+
if (this.disposed) return;
|
|
1068
|
+
const previous = this.textureSet;
|
|
1069
|
+
/*
|
|
1070
|
+
* An ordinal that already has a texture and a better image is **re-uploaded into the GPU
|
|
1071
|
+
* object it already has**, rather than given a new one. That is what makes a preview
|
|
1072
|
+
* replaceable at all: every draw loop is holding the handle from the frame the preview landed
|
|
1073
|
+
* in, so handing out a second handle would leave the sharp image on a texture nothing draws.
|
|
1074
|
+
* `updateSurfaceTexture` exists for exactly this and is what `replaceTexture` is built on.
|
|
1075
|
+
*/
|
|
1076
|
+
const existing = previous?.at(ordinal) ?? null;
|
|
1077
|
+
const arrived = this.images[ordinal] ?? null;
|
|
1078
|
+
if (existing !== null && arrived !== null) {
|
|
1079
|
+
this.renderer.updateSurfaceTexture(existing, arrived);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/*
|
|
1084
|
+
* Which images are pictures and which are data, so each is sampled the way its role needs.
|
|
1085
|
+
* `textureColorSpaces` owns that question and says at length why it has two answers.
|
|
1086
|
+
*/
|
|
1087
|
+
const spaces = textureColorSpaces(this.materials, this.chunks.length);
|
|
1088
|
+
|
|
1089
|
+
this.textureSet = TextureSet.from(this.chunks, (_, at) => {
|
|
1090
|
+
const held = previous?.at(at) ?? null;
|
|
1091
|
+
if (held !== null) return held;
|
|
1092
|
+
const bitmap = this.images[at] ?? null;
|
|
1093
|
+
if (bitmap === null) return null;
|
|
1094
|
+
const anisotropy = this.options.anisotropy;
|
|
1095
|
+
const wrap = this.options.textureWrap;
|
|
1096
|
+
return this.renderer.createSurfaceTexture(bitmap, {
|
|
1097
|
+
...(anisotropy === undefined ? {} : { anisotropy }),
|
|
1098
|
+
...(wrap === undefined ? {} : { wrap }),
|
|
1099
|
+
colorSpace: spaces[at] ?? 'linear',
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** One place that writes the progress, so every field stays consistent with the phase. */
|
|
1105
|
+
private set(patch: Partial<DrftLoadProgress>): void {
|
|
1106
|
+
const next = { ...this.state, ...patch };
|
|
1107
|
+
const parts = next.partsTotal > 0 ? next.partsDone / next.partsTotal : 0;
|
|
1108
|
+
const images = next.imagesTotal > 0 ? next.imagesDone / next.imagesTotal : 1;
|
|
1109
|
+
this.state = {
|
|
1110
|
+
...next,
|
|
1111
|
+
fraction: patch.fraction ?? Math.min(1, parts * this.meshShare + images * this.imageShare),
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* The MIME type a codec's bytes carry, or `null` for one the browser has no decoder for.
|
|
1118
|
+
*
|
|
1119
|
+
* **Exported because the fall-through was the bug.** This was written inline as two ternaries
|
|
1120
|
+
* ending in `'image/jpeg'`, so every codec the chain did not name became a JPEG — and `CODEC_RAW`,
|
|
1121
|
+
* which the baker writes for every image it could not find, was handed four bytes of pixel and a
|
|
1122
|
+
* decoder that had no chance with them. Naming each codec and returning `null` for the one that is
|
|
1123
|
+
* not an encoded image makes the omission a value rather than a default, and lets a test say so.
|
|
1124
|
+
*/
|
|
1125
|
+
export function imageTypeFor(codec: number): string {
|
|
1126
|
+
if (codec === CODEC_PNG) return 'image/png';
|
|
1127
|
+
if (codec === CODEC_WEBP) return 'image/webp';
|
|
1128
|
+
return 'image/jpeg';
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Whether a codec is decoded from its own bytes rather than through an image decoder.
|
|
1133
|
+
*
|
|
1134
|
+
* Raw is the only one, and it is not an encoded image at all: the bytes *are* the pixels.
|
|
1135
|
+
*/
|
|
1136
|
+
export function isRawCodec(codec: number): boolean {
|
|
1137
|
+
return codec === CODEC_RAW;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* An embedded image as a bitmap, through the browser's own decoders.
|
|
1142
|
+
*
|
|
1143
|
+
* The `.slice()` matters: the bytes are a view into the streamed buffer, and a `Blob` built on
|
|
1144
|
+
* the view would keep the whole file alive for as long as the image does.
|
|
1145
|
+
*/
|
|
1146
|
+
async function decodeImage(texture: DrftTexture, longestSide?: number): Promise<ImageBitmap> {
|
|
1147
|
+
/*
|
|
1148
|
+
* **A raw texture has no decoder, because it is already decoded.**
|
|
1149
|
+
*
|
|
1150
|
+
* The baker writes one wherever an image was named and could not be found: a 1x1 white
|
|
1151
|
+
* `CODEC_RAW` stand-in, so that every material's `albedo` ordinal keeps meaning what it meant
|
|
1152
|
+
* rather than renumbering the whole table. Handing those four bytes to `createImageBitmap` as a
|
|
1153
|
+
* JPEG fails, as it should — and the result was that every model with a missing map logged one
|
|
1154
|
+
* `did not decode` per stand-in and the surface it stood in for went untextured, which is the
|
|
1155
|
+
* outcome the stand-in exists to avoid. `blankTextures` in a consumer is written expecting these
|
|
1156
|
+
* to arrive as 1x1 white; they never did.
|
|
1157
|
+
*
|
|
1158
|
+
* `ImageData` takes the bytes as they are, which is what raw means, and needs no round trip
|
|
1159
|
+
* through an encoder. Anything not four bytes a pixel is not something this format can describe,
|
|
1160
|
+
* so it is refused with a message that says so rather than by a decoder's generic one.
|
|
1161
|
+
*/
|
|
1162
|
+
if (isRawCodec(texture.codec)) {
|
|
1163
|
+
const pixels = texture.width * texture.height;
|
|
1164
|
+
if (pixels <= 0 || texture.bytes.length < pixels * 4) {
|
|
1165
|
+
throw new DrftError(
|
|
1166
|
+
`a raw texture needs ${pixels * 4} bytes for ${texture.width}x${texture.height} and carries ${texture.bytes.length}`,
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
return await createImageBitmap(
|
|
1170
|
+
new ImageData(
|
|
1171
|
+
new Uint8ClampedArray(texture.bytes.slice(0, pixels * 4)),
|
|
1172
|
+
texture.width,
|
|
1173
|
+
texture.height,
|
|
1174
|
+
),
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
const blob = new Blob([texture.bytes.slice()], { type: imageTypeFor(texture.codec) });
|
|
1178
|
+
if (longestSide === undefined) return await createImageBitmap(blob);
|
|
1179
|
+
/*
|
|
1180
|
+
* Resized while it decodes, which is the point: asking for a 256 pixel version of a 2048
|
|
1181
|
+
* square is a fraction of the work of decoding one and throwing most of it away, and the
|
|
1182
|
+
* browser is the only party here that can do either.
|
|
1183
|
+
*
|
|
1184
|
+
* Both sides are given so the aspect ratio survives; `width` and `height` come from the
|
|
1185
|
+
* chunk, which read them out of the image's own header at bake time. A file that predates
|
|
1186
|
+
* those fields reports zero, and then there is nothing to scale against and the full decode
|
|
1187
|
+
* is the honest answer.
|
|
1188
|
+
*/
|
|
1189
|
+
const longest = Math.max(texture.width, texture.height);
|
|
1190
|
+
if (longest <= 0 || longest <= longestSide) return await createImageBitmap(blob);
|
|
1191
|
+
const scale = longestSide / longest;
|
|
1192
|
+
return await createImageBitmap(blob, {
|
|
1193
|
+
resizeWidth: Math.max(1, Math.round(texture.width * scale)),
|
|
1194
|
+
resizeHeight: Math.max(1, Math.round(texture.height * scale)),
|
|
1195
|
+
resizeQuality: 'medium',
|
|
1196
|
+
});
|
|
1197
|
+
}
|