@driftengine/assets 4.8.3 → 4.8.5

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.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/dist/bc7Decode.d.ts +33 -0
  3. package/dist/bc7Decode.js +197 -0
  4. package/dist/bcAnswer.d.ts +28 -0
  5. package/dist/bcAnswer.js +15 -0
  6. package/dist/bcDecode.d.ts +28 -0
  7. package/dist/bcDecode.js +175 -0
  8. package/dist/bcFlip.d.ts +21 -0
  9. package/dist/bcFlip.js +52 -0
  10. package/dist/bcImage.d.ts +15 -0
  11. package/dist/bcImage.js +38 -0
  12. package/dist/bcLoad.d.ts +26 -0
  13. package/dist/bcLoad.js +113 -0
  14. package/dist/bcWorker.d.ts +1 -0
  15. package/dist/bcWorker.js +14 -0
  16. package/dist/dds.d.ts +16 -18
  17. package/dist/dds.js +20 -293
  18. package/dist/ddsBlocks.d.ts +3 -0
  19. package/dist/ddsBlocks.js +54 -0
  20. package/dist/ddsHeader.d.ts +32 -0
  21. package/dist/ddsHeader.js +119 -0
  22. package/dist/drftLoader.d.ts +18 -0
  23. package/dist/drftLoader.js +98 -8
  24. package/dist/fbx.js +32 -13
  25. package/dist/fbxRig.d.ts +0 -18
  26. package/dist/fbxRig.js +0 -37
  27. package/dist/gltf.js +74 -12
  28. package/dist/index.d.ts +4 -0
  29. package/dist/index.js +7 -2
  30. package/dist/influences.d.ts +40 -0
  31. package/dist/influences.js +53 -0
  32. package/dist/weld.js +3 -0
  33. package/package.json +4 -4
  34. package/src/bc7Decode.ts +237 -0
  35. package/src/bcAnswer.ts +45 -0
  36. package/src/bcDecode.ts +218 -0
  37. package/src/bcFlip.ts +55 -0
  38. package/src/bcImage.ts +58 -0
  39. package/src/bcLoad.ts +154 -0
  40. package/src/bcWorker.ts +20 -0
  41. package/src/dds.ts +21 -337
  42. package/src/ddsBlocks.ts +63 -0
  43. package/src/ddsHeader.ts +160 -0
  44. package/src/drftLoader.ts +115 -9
  45. package/src/fbx.ts +34 -18
  46. package/src/fbxRig.ts +0 -41
  47. package/src/gltf.ts +75 -13
  48. package/src/index.ts +7 -2
  49. package/src/influences.ts +77 -0
  50. package/src/weld.ts +3 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A block-compressed DDS kept as it was authored: its blocks, untouched, and every mip level its
3
+ * author stored, ready for a `CODEC_BC` payload.
4
+ *
5
+ * **Why keep them rather than decode them.** A BC7 texture is a byte a texel on the GPU where RGBA
6
+ * is four, and its chain is the one its author built — often filtered with care a box filter does
7
+ * not take, and for a normal map renormalised at every level. Decoding to RGBA and letting the
8
+ * engine generate mips throws both away, and costs a phone's memory and a desktop's bandwidth for
9
+ * the privilege. Where a device cannot sample a format, the loader decodes the same blocks there.
10
+ *
11
+ * **What it refuses, so the baker can fall back to decoding level 0:** a chain longer than the
12
+ * image has levels for, a chain whose bytes stop before its last level, a volume texture (each of
13
+ * whose levels holds several slices, so its layout is not a chain of images), and an uncompressed
14
+ * surface, which has no blocks to keep. Bytes after the last level are left where they are; a
15
+ * classic cube map's other five faces are such bytes, so its first face is what is kept.
16
+ */
17
+ import { bcChainLength, bcLevelBytes, DrftError } from '@driftengine/drft';
18
+ import type { BcImage } from '@driftengine/drft';
19
+
20
+ import { DDPF_FOURCC, HEADER_BYTES, isDds, readDdsSurface } from './ddsHeader.ts';
21
+
22
+ /** `DDSCAPS2_VOLUME`: each level is a stack of slices rather than one image. */
23
+ const DDSCAPS2_VOLUME = 0x200000;
24
+
25
+ /** The surface's blocks and stored levels, as views over `bytes`. */
26
+ export function ddsBlocks(bytes: Uint8Array): BcImage {
27
+ if (!isDds(bytes)) throw new DrftError('dds: these bytes do not begin "DDS "');
28
+ if (bytes.length < HEADER_BYTES) {
29
+ throw new DrftError('dds: the file is shorter than its own header');
30
+ }
31
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
32
+ if ((view.getUint32(80, true) & DDPF_FOURCC) === 0) {
33
+ throw new DrftError('dds: this surface is uncompressed, so it has no blocks to keep');
34
+ }
35
+ if ((view.getUint32(112, true) & DDSCAPS2_VOLUME) !== 0) {
36
+ throw new DrftError(
37
+ 'dds: this is a volume texture, whose levels are stacks of slices rather than a chain',
38
+ );
39
+ }
40
+ const surface = readDdsSurface(bytes, view);
41
+ const { format, width, height, mipCount } = surface;
42
+ const most = bcChainLength(width, height);
43
+ if (mipCount > most) {
44
+ throw new DrftError(
45
+ `dds: ${surface.named} declares ${mipCount} levels for a ${width}x${height} image, which ` +
46
+ `has at most ${most}`,
47
+ );
48
+ }
49
+ const levels: Uint8Array[] = [];
50
+ let at = surface.dataAt;
51
+ for (let i = 0; i < mipCount; i++) {
52
+ const size = bcLevelBytes(format, width, height, i);
53
+ if (at + size > bytes.length) {
54
+ throw new DrftError(
55
+ `dds: level ${i} of ${mipCount} needs ${size} bytes and ${Math.max(0, bytes.length - at)} ` +
56
+ 'are left',
57
+ );
58
+ }
59
+ levels.push(bytes.subarray(at, at + size));
60
+ at += size;
61
+ }
62
+ return { format, srgb: surface.srgb, width, height, levels };
63
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * A DDS header, read as far as which blocks follow it: the block format and the colour space its
3
+ * author declared, in either header spelling, the size, the stored mip count, and where the surface
4
+ * starts. Shared by `dds.ts`, which decodes the blocks, and `ddsBlocks.ts`, which keeps them.
5
+ *
6
+ * **Two spellings of one thing.** A classic header names the format in the four characters at byte
7
+ * 84 and the surface follows at 128; a `DX10` header puts `DX10` there instead and states the real
8
+ * format in a 20-byte extension, so the surface starts at 148. Nothing else differs — the blocks are
9
+ * the same blocks — which is why both resolve to one name and one offset.
10
+ *
11
+ * What it refuses, by name: BC6H, signed BC4 and BC5, the premultiplied DXT2 and DXT4, and from a
12
+ * `DX10` header a cube map, an array or anything but a plain 2D texture. A format identified and
13
+ * declined is worth more than one half-decoded, which is `recognise.ts`'s principle applied one
14
+ * level down.
15
+ */
16
+ import { DrftError } from '@driftengine/drft';
17
+ import type { BcFormat } from '@driftengine/drft';
18
+
19
+ export const HEADER_BYTES = 128;
20
+ /** `DDS_HEADER_DXT10`: the extension a `DX10` FourCC puts between the header and the surface. */
21
+ const DX10_EXTENSION_BYTES = 20;
22
+ /** `DDPF_FOURCC`: the pixel format names a compression rather than describing channels. */
23
+ export const DDPF_FOURCC = 0x4;
24
+
25
+ /** Whether these bytes are a DDS at all. The magic, and nothing inferred from a file name. */
26
+ export function isDds(bytes: Uint8Array): boolean {
27
+ return (
28
+ bytes.length >= 4 &&
29
+ bytes[0] === 0x44 &&
30
+ bytes[1] === 0x44 &&
31
+ bytes[2] === 0x53 &&
32
+ bytes[3] === 0x20
33
+ );
34
+ }
35
+
36
+ /**
37
+ * The DXGI block formats, and which of them declare sRGB.
38
+ *
39
+ * **sRGB is a statement about how the values are *read* rather than about how they are stored.**
40
+ * BC3 announced as `DXGI_FORMAT_BC3_UNORM_SRGB` is bit for bit the BC3 announced as `DXT5`: the
41
+ * same blocks and the same mip chain. Measured from outside on one bundle's paint texture —
42
+ * 5,592,580 bytes against 5,592,560 for the classic spelling of the same 2048² chain, which is
43
+ * 148 + 5,592,432 against 128 + 5,592,432. The typeless spellings read as linear.
44
+ *
45
+ * **What is deliberately absent.** `BC4_SNORM` (81) and `BC5_SNORM` (84), whose values are signed,
46
+ * and BC6H (94–96), whose values are half floats — mapping any of them would decode to wrong numbers
47
+ * rather than to an error, which is the one failure this reader is written to avoid.
48
+ */
49
+ const DXGI_BLOCK: Readonly<Record<number, readonly [BcFormat, boolean]>> = {
50
+ 70: ['bc1', false],
51
+ 71: ['bc1', false],
52
+ 72: ['bc1', true],
53
+ 73: ['bc2', false],
54
+ 74: ['bc2', false],
55
+ 75: ['bc2', true],
56
+ 76: ['bc3', false],
57
+ 77: ['bc3', false],
58
+ 78: ['bc3', true],
59
+ 79: ['bc4', false],
60
+ 80: ['bc4', false],
61
+ 82: ['bc5', false],
62
+ 83: ['bc5', false],
63
+ 97: ['bc7', false],
64
+ 98: ['bc7', false],
65
+ 99: ['bc7', true],
66
+ };
67
+
68
+ /** The classic spellings. BC7 has none: only a `DX10` header can name it. */
69
+ const FOURCC_BLOCK: Readonly<Record<string, BcFormat>> = {
70
+ DXT1: 'bc1',
71
+ DXT3: 'bc2',
72
+ DXT5: 'bc3',
73
+ ATI1: 'bc4',
74
+ BC4U: 'bc4',
75
+ ATI2: 'bc5',
76
+ BC5U: 'bc5',
77
+ };
78
+
79
+ /** What a block-compressed DDS holds, read from its header alone. */
80
+ export interface DdsSurface {
81
+ readonly format: BcFormat;
82
+ readonly srgb: boolean;
83
+ readonly width: number;
84
+ readonly height: number;
85
+ /**
86
+ * The levels the header says follow. Zero is read as one, which is what Microsoft's own loader
87
+ * does: many writers leave the count zero, and its flag unset, on a surface with no chain.
88
+ */
89
+ readonly mipCount: number;
90
+ /** Where level 0's first block starts. */
91
+ readonly dataAt: number;
92
+ /** How the header named the format, for a message: a FourCC, or `DXGI <n>`. */
93
+ readonly named: string;
94
+ }
95
+
96
+ /**
97
+ * The surface a block-compressed DDS declares. The caller has checked the magic, the length of the
98
+ * classic header and that the pixel format names a compression.
99
+ *
100
+ * **What a `DX10` header can say that a reader must refuse.** `resourceDimension` other than 3
101
+ * (`TEXTURE2D`), `miscFlag` bit 2 (a cube map) or an `arraySize` above 1. Taking one would hand the
102
+ * caller six faces or twenty slices claiming to be a single surface — a picture rather than an
103
+ * error.
104
+ */
105
+ export function readDdsSurface(bytes: Uint8Array, view: DataView): DdsSurface {
106
+ const height = view.getUint32(12, true);
107
+ const width = view.getUint32(16, true);
108
+ const mipCount = Math.max(1, view.getUint32(28, true));
109
+ const fourcc = String.fromCharCode(
110
+ bytes[84] as number,
111
+ bytes[85] as number,
112
+ bytes[86] as number,
113
+ bytes[87] as number,
114
+ );
115
+ if (fourcc !== 'DX10') {
116
+ const format = FOURCC_BLOCK[fourcc];
117
+ if (format === undefined) {
118
+ throw new DrftError(
119
+ `dds: "${fourcc}" is not a format this reader decodes. It reads BC1 (DXT1), BC2 (DXT3), ` +
120
+ 'BC3 (DXT5), BC4 (ATI1) and BC5 (ATI2), and BC7 behind a DX10 header. Convert the ' +
121
+ 'texture to PNG, or re-export it as one of those.',
122
+ );
123
+ }
124
+ return { format, srgb: false, width, height, mipCount, dataAt: HEADER_BYTES, named: fourcc };
125
+ }
126
+
127
+ const dataAt = HEADER_BYTES + DX10_EXTENSION_BYTES;
128
+ if (bytes.length < dataAt) {
129
+ throw new DrftError(
130
+ 'dds: a DX10 header is declared and the twenty-byte extension is not there',
131
+ );
132
+ }
133
+ const dxgi = view.getUint32(HEADER_BYTES, true);
134
+ const dimension = view.getUint32(HEADER_BYTES + 4, true);
135
+ const miscFlag = view.getUint32(HEADER_BYTES + 8, true);
136
+ const arraySize = view.getUint32(HEADER_BYTES + 12, true);
137
+ if (dimension !== 3) {
138
+ throw new DrftError(
139
+ `dds: this DX10 surface declares resourceDimension ${dimension}, and only 3 (a plain 2D ` +
140
+ 'texture) is read here. A 1D or volume texture is a different thing wearing the same header.',
141
+ );
142
+ }
143
+ if ((miscFlag & 0x4) !== 0) {
144
+ throw new DrftError('dds: this is a cube map, and only a plain 2D texture is read here.');
145
+ }
146
+ if (arraySize > 1) {
147
+ throw new DrftError(
148
+ `dds: this is a texture array of ${arraySize}, and only a plain 2D texture is read here.`,
149
+ );
150
+ }
151
+ const entry = DXGI_BLOCK[dxgi];
152
+ if (entry === undefined) {
153
+ throw new DrftError(
154
+ `dds: DXGI format ${dxgi} is not one this reader decodes. It reads BC1 to BC5 and BC7, ` +
155
+ 'unsigned. Convert the texture to PNG, or re-export it as one of those.',
156
+ );
157
+ }
158
+ const [format, srgb] = entry;
159
+ return { format, srgb, width, height, mipCount, dataAt, named: `DXGI ${dxgi}` };
160
+ }
package/src/drftLoader.ts CHANGED
@@ -10,6 +10,12 @@ import { placeFields } from './fieldPlacement.ts';
10
10
  import type { DrftFieldPlacement } from './fieldPlacement.ts';
11
11
  import type { DrftTexture } from '@driftengine/drft';
12
12
  import { TextureSet, textureColorSpaces } from './drftTextures.ts';
13
+ import { bcPlan, createBcDecoder } from './bcLoad.ts';
14
+ import type { BcDecoder } from './bcLoad.ts';
15
+ import { decodeBc } from './bcDecode.ts';
16
+ import { isCompressedSource, uploadsCompressed } from '@driftengine/core';
17
+ import { CODEC_BC, codecName, readBcPayload } from '@driftengine/drft';
18
+ import type { BcImage } from '@driftengine/drft';
13
19
  import { drawKeyOf, resolveDrawGrouping } from './drawKey.ts';
14
20
  import type { DrawGrouping, DrawSurfaceOverride } from './drawKey.ts';
15
21
  import {
@@ -323,7 +329,10 @@ export class DrftLoader {
323
329
  private readonly bound: boolean[] = [];
324
330
  private boundCount = 0;
325
331
  private readonly singles: MeshHandle[] = [];
326
- private readonly images: (ImageBitmap | null)[] = [];
332
+ private readonly images: (LoadedImage | null)[] = [];
333
+ /** Started at the first BC texture that has to be decoded, and only then. See `bcLoad.ts`. */
334
+ private bcDecoder: BcDecoder | null = null;
335
+ private warnedBc = false;
327
336
  private readonly chunks: DrftTexture[] = [];
328
337
  private materials: readonly DrftMaterial[] = [];
329
338
  /**
@@ -812,6 +821,10 @@ export class DrftLoader {
812
821
  * visibly wrong, which a consumer's loading bar reported to a person as finished.
813
822
  */
814
823
  this.set({ phase: 'textures' });
824
+ if (texture.codec === CODEC_BC) {
825
+ this.acceptBc(texture, ordinal);
826
+ return;
827
+ }
815
828
  /*
816
829
  * Decoded through the browser's own image path, so the engine ships no decoder, and
817
830
  * **each one fails on its own**: a texture is the part of an asset most likely to be
@@ -969,7 +982,7 @@ export class DrftLoader {
969
982
  if (ordinal !== undefined) {
970
983
  const take = this.options.onImage;
971
984
  if (take === undefined) this.rebuildTextures(ordinal);
972
- else take(this.chunks[ordinal]?.name ?? '', this.images[ordinal] ?? null);
985
+ else take(this.chunks[ordinal]?.name ?? '', bitmapOf(this.images[ordinal] ?? null));
973
986
  /*
974
987
  * Counted here, once, and only for the image itself.
975
988
  *
@@ -1047,6 +1060,8 @@ export class DrftLoader {
1047
1060
  if (this.disposed) return;
1048
1061
  this.disposed = true;
1049
1062
  this.regionStore.dispose();
1063
+ this.bcDecoder?.dispose();
1064
+ this.bcDecoder = null;
1050
1065
  /*
1051
1066
  * Everything this class ever made, each disposed once.
1052
1067
  *
@@ -1444,7 +1459,8 @@ export class DrftLoader {
1444
1459
  */
1445
1460
  const existing = previous?.at(ordinal) ?? null;
1446
1461
  const arrived = this.images[ordinal] ?? null;
1447
- if (existing !== null && arrived !== null) {
1462
+ /* Blocks never arrive as a replacement: a BC texture has no preview to replace. */
1463
+ if (existing !== null && arrived !== null && !isCompressedSource(arrived)) {
1448
1464
  this.renderer.updateSurfaceTexture(existing, arrived);
1449
1465
  return;
1450
1466
  }
@@ -1458,18 +1474,90 @@ export class DrftLoader {
1458
1474
  this.textureSet = TextureSet.from(this.chunks, (_, at) => {
1459
1475
  const held = previous?.at(at) ?? null;
1460
1476
  if (held !== null) return held;
1461
- const bitmap = this.images[at] ?? null;
1462
- if (bitmap === null) return null;
1477
+ const image = this.images[at] ?? null;
1478
+ if (image === null) return null;
1463
1479
  const anisotropy = this.options.anisotropy;
1464
1480
  const wrap = this.options.textureWrap;
1465
- return this.renderer.createSurfaceTexture(bitmap, {
1481
+ const colorSpace = spaces[at] ?? 'linear';
1482
+ return this.renderer.createSurfaceTexture(this.uploadable(image, colorSpace === 'srgb'), {
1466
1483
  ...(anisotropy === undefined ? {} : { anisotropy }),
1467
1484
  ...(wrap === undefined ? {} : { wrap }),
1468
- colorSpace: spaces[at] ?? 'linear',
1485
+ colorSpace,
1469
1486
  });
1470
1487
  });
1471
1488
  }
1472
1489
 
1490
+ /**
1491
+ * A BC texture: its blocks kept, to go up as they are where this device samples them, or decoded
1492
+ * off the main thread where it does not — which on a phone is every one. See `bcLoad.ts`.
1493
+ *
1494
+ * **A consumer taking images through `onImage` gets a decoded image either way**, as an ordinary
1495
+ * picture: what it builds from them is its own, and a texture array of bitmaps cannot hold blocks.
1496
+ * Like every image, a BC texture fails on its own and its surfaces draw untextured.
1497
+ */
1498
+ private acceptBc(texture: DrftTexture, ordinal: number): void {
1499
+ const done = (image: LoadedImage | null): void => {
1500
+ if (this.disposed) return;
1501
+ this.images[ordinal] = image;
1502
+ this.sharp[ordinal] = true;
1503
+ this.imageQueue.push(ordinal);
1504
+ };
1505
+ const failed = (error: unknown): void => {
1506
+ console.warn(
1507
+ `DrftLoader: image ${ordinal} did not decode; its surfaces stay untextured` +
1508
+ ` (codec BC, ${texture.bytes.length} bytes, ${texture.width}x${texture.height})`,
1509
+ error,
1510
+ );
1511
+ done(null);
1512
+ };
1513
+ let image: BcImage;
1514
+ try {
1515
+ image = readBcPayload(texture.width, texture.height, texture.bytes);
1516
+ } catch (error) {
1517
+ failed(error);
1518
+ return;
1519
+ }
1520
+ const asImage = this.options.onImage !== undefined;
1521
+ const srgb = textureColorSpaces(this.materials, ordinal + 1)[ordinal] === 'srgb';
1522
+ if (!asImage && bcPlan(image, srgb, this.renderer.compressedFormats) === 'blocks') {
1523
+ done(image);
1524
+ return;
1525
+ }
1526
+ if (this.bcDecoder === null) this.bcDecoder = createBcDecoder();
1527
+ const decoder = this.bcDecoder;
1528
+ decoder
1529
+ .decode(image, asImage)
1530
+ .then(async (rgba) => {
1531
+ /* Said once, where a worker could not be had or failed: a slower load nobody could
1532
+ otherwise attribute. */
1533
+ if (decoder.reason !== '' && !this.warnedBc) {
1534
+ this.warnedBc = true;
1535
+ console.warn(`DrftLoader: ${decoder.reason}`);
1536
+ }
1537
+ const pixels = imageDataOf(rgba, image.width, image.height);
1538
+ done(asImage ? await createImageBitmap(pixels, AS_AUTHORED) : pixels);
1539
+ })
1540
+ .catch(failed);
1541
+ }
1542
+
1543
+ /**
1544
+ * What goes to `createSurfaceTexture`: the image as it is, unless it is blocks this device cannot
1545
+ * take in the colour space the slot reads — a material read after its texture arrived can change
1546
+ * that — in which case they are decoded here, on the spot.
1547
+ */
1548
+ private uploadable(image: LoadedImage, srgb: boolean): ImageBitmap | ImageData | BcImage {
1549
+ if (!isCompressedSource(image)) return image;
1550
+ const { format, width, height } = image;
1551
+ if (uploadsCompressed(format, srgb, width, height, this.renderer.compressedFormats)) {
1552
+ return image;
1553
+ }
1554
+ return imageDataOf(
1555
+ decodeBc(format, width, height, image.levels[0] as Uint8Array),
1556
+ width,
1557
+ height,
1558
+ );
1559
+ }
1560
+
1473
1561
  /** One place that writes the progress, so every field stays consistent with the phase. */
1474
1562
  private set(patch: Partial<DrftLoadProgress>): void {
1475
1563
  const next = { ...this.state, ...patch };
@@ -1554,6 +1642,23 @@ const AS_AUTHORED = {
1554
1642
  colorSpaceConversion: 'none',
1555
1643
  } as const satisfies ImageBitmapOptions;
1556
1644
 
1645
+ /** What the loader holds for an image: decoded, or blocks to upload as they are. */
1646
+ type LoadedImage = ImageBitmap | ImageData | BcImage;
1647
+
1648
+ /** RGBA as an `ImageData`, which every upload path takes as it takes a bitmap. */
1649
+ function imageDataOf(rgba: Uint8Array, width: number, height: number): ImageData {
1650
+ return new ImageData(
1651
+ new Uint8ClampedArray(rgba.buffer as ArrayBuffer, rgba.byteOffset, rgba.length),
1652
+ width,
1653
+ height,
1654
+ );
1655
+ }
1656
+
1657
+ /** The bitmap `onImage` is handed. Only bitmaps reach that path, BC decoded to one on the way. */
1658
+ function bitmapOf(image: LoadedImage | null): ImageBitmap | null {
1659
+ return image === null || isCompressedSource(image) || 'data' in image ? null : image;
1660
+ }
1661
+
1557
1662
  async function decodeImage(texture: DrftTexture, longestSide?: number): Promise<ImageBitmap> {
1558
1663
  /*
1559
1664
  * **A raw texture has no decoder, because it is already decoded.**
@@ -1589,8 +1694,9 @@ async function decodeImage(texture: DrftTexture, longestSide?: number): Promise<
1589
1694
  const type = imageTypeFor(texture.codec);
1590
1695
  if (type === null) {
1591
1696
  throw new DrftError(
1592
- `texture codec ${texture.codec} is not one this loader can decode; bake it again with a ` +
1593
- 'current baker',
1697
+ `texture codec ${codecName(texture.codec)} is not one this loader reads: a newer baker ` +
1698
+ 'wrote it, or the file is damaged. Update @driftengine/assets, or bake with the baker ' +
1699
+ 'that matches it',
1594
1700
  );
1595
1701
  }
1596
1702
  const blob = new Blob([texture.bytes.slice()], { type });
package/src/fbx.ts CHANGED
@@ -32,7 +32,8 @@ import type { UpAxis } from './orient.ts';
32
32
  import { FALLBACK_MATERIAL, readMaterials } from './fbxMaterial.ts';
33
33
  import type { DrftMaterial } from '@driftengine/drft';
34
34
  import type { AssetReference } from './assetPath.ts';
35
- import { bareName, indexObjects, readClips, readSkin, writeInfluences } from './fbxRig.ts';
35
+ import { bareName, indexObjects, readClips, readSkin } from './fbxRig.ts';
36
+ import { writeInfluences } from './influences.ts';
36
37
  import type { AnimationClip, DrftSkin } from '@driftengine/drft';
37
38
 
38
39
  /** One value repeated per vertex, for a material constant this format has nowhere else to put. */
@@ -688,6 +689,16 @@ export function fbxToMeshes(
688
689
  const outUvs: number[] | null = uvLayer === null ? null : [];
689
690
  const outJoints: number[] | null = skin === null ? null : [];
690
691
  const outWeights: number[] | null = skin === null ? null : [];
692
+ /* The fifth to eighth influences, kept only if some vertex needed them. See influences.ts. */
693
+ const outJoints2: number[] | null = skin === null ? null : [];
694
+ const outWeights2: number[] | null = skin === null ? null : [];
695
+ let secondInfluences = false;
696
+ const scratch = {
697
+ joints: new Float32Array(4),
698
+ weights: new Float32Array(4),
699
+ joints2: new Float32Array(4),
700
+ weights2: new Float32Array(4),
701
+ };
691
702
  const indices: number[] = [];
692
703
  /*
693
704
  * A mesh with no normals is not deduplicated, and that is the geometry speaking rather than
@@ -805,23 +816,25 @@ export function fbxToMeshes(
805
816
  outColors.push(r, g, b);
806
817
  if (outUvs !== null) outUvs.push(u, v);
807
818
 
808
- if (outJoints !== null && outWeights !== null && skin !== null) {
809
- outJoints.push(0, 0, 0, 0);
810
- outWeights.push(0, 0, 0, 0);
811
- const scratchJoints = new Float32Array(4);
812
- const scratchWeights = new Float32Array(4);
813
- const report = writeInfluences(
814
- skin.influences.get(vertexIndex),
815
- scratchJoints,
816
- scratchWeights,
817
- 0,
818
- );
819
- for (let k = 0; k < 4; k++) {
820
- outJoints[emitted * 4 + k] = scratchJoints[k] as number;
821
- outWeights[emitted * 4 + k] = scratchWeights[k] as number;
822
- }
819
+ if (
820
+ outJoints !== null &&
821
+ outWeights !== null &&
822
+ outJoints2 !== null &&
823
+ outWeights2 !== null &&
824
+ skin !== null
825
+ ) {
826
+ scratch.joints.fill(0);
827
+ scratch.weights.fill(0);
828
+ scratch.joints2.fill(0);
829
+ scratch.weights2.fill(0);
830
+ const report = writeInfluences(skin.influences.get(vertexIndex), scratch, 0);
831
+ outJoints.push(...scratch.joints);
832
+ outWeights.push(...scratch.weights);
833
+ outJoints2.push(...scratch.joints2);
834
+ outWeights2.push(...scratch.weights2);
823
835
  droppedInfluences ||= report.dropped;
824
836
  rescaledInfluences ||= report.rescaled;
837
+ secondInfluences ||= report.second;
825
838
  }
826
839
  }
827
840
 
@@ -834,8 +847,8 @@ export function fbxToMeshes(
834
847
  }
835
848
  if (droppedInfluences) {
836
849
  warnings.push(
837
- `${name}: a vertex is held by more than four joints; the four largest are kept and ` +
838
- `renormalised, because four is what the skinning palette reads.`,
850
+ `${name}: a vertex is held by more than eight joints; the eight largest are kept and ` +
851
+ `renormalised, because eight is what the skinning palette reads.`,
839
852
  );
840
853
  }
841
854
  if (rescaledInfluences) {
@@ -861,6 +874,9 @@ export function fbxToMeshes(
861
874
  ...(outJoints === null || outWeights === null
862
875
  ? {}
863
876
  : { joints: Float32Array.from(outJoints), weights: Float32Array.from(outWeights) }),
877
+ ...(outJoints2 === null || outWeights2 === null || !secondInfluences
878
+ ? {}
879
+ : { joints2: Float32Array.from(outJoints2), weights2: Float32Array.from(outWeights2) }),
864
880
  });
865
881
  perMesh.push(material);
866
882
  }
package/src/fbxRig.ts CHANGED
@@ -30,9 +30,6 @@ import type { FbxNode } from './fbx.ts';
30
30
  /** One second, in the integer unit `KeyTime` counts. FBX's own constant. */
31
31
  const KTIME_PER_SECOND = 46186158000;
32
32
 
33
- /** The most influences a vertex may carry, which is what the palette shader reads. */
34
- const MAX_INFLUENCES = 4;
35
-
36
33
  const IDENTITY: readonly number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
37
34
 
38
35
  function findChild(node: FbxNode, name: string): FbxNode | undefined {
@@ -332,44 +329,6 @@ export function readSkin(
332
329
  return { joints, inverseBind, influences, modelOfJoint: order };
333
330
  }
334
331
 
335
- /**
336
- * One control point's four influences, largest first, normalised, written into a caller's arrays.
337
- *
338
- * **Four is the shader's number and the file's is unbounded.** A rig authored with six influences
339
- * on a shoulder is ordinary, so the four largest are kept and renormalised rather than the first
340
- * four taken: dropping by *order* would discard whichever the exporter happened to write last,
341
- * which on a shoulder is as likely to be the dominant one as not.
342
- *
343
- * Returns true where something was dropped or rescaled, so the caller can say so once for the mesh
344
- * rather than once per vertex.
345
- */
346
- export function writeInfluences(
347
- influences: readonly { joint: number; weight: number }[] | undefined,
348
- joints: Float32Array,
349
- weights: Float32Array,
350
- at: number,
351
- ): { dropped: boolean; rescaled: boolean } {
352
- if (influences === undefined || influences.length === 0)
353
- return { dropped: false, rescaled: false };
354
- const sorted = [...influences].sort((a, b) => b.weight - a.weight);
355
- const kept = sorted.slice(0, MAX_INFLUENCES);
356
- let sum = 0;
357
- for (const influence of kept) sum += influence.weight;
358
- if (sum <= 0) return { dropped: false, rescaled: false };
359
- for (let i = 0; i < kept.length; i++) {
360
- const influence = kept[i] as { joint: number; weight: number };
361
- joints[at * MAX_INFLUENCES + i] = influence.joint;
362
- weights[at * MAX_INFLUENCES + i] = influence.weight / sum;
363
- }
364
- let total = 0;
365
- for (const influence of sorted) total += influence.weight;
366
- return {
367
- dropped: sorted.length > MAX_INFLUENCES,
368
- /* A tenth of a per cent, which is wider than float noise and narrower than an authoring slip. */
369
- rescaled: Math.abs(total - 1) > 1e-3,
370
- };
371
- }
372
-
373
332
  /** Euler degrees in the file's default XYZ order, as a quaternion. */
374
333
  function eulerToQuaternion(x: number, y: number, z: number): [number, number, number, number] {
375
334
  const half = Math.PI / 360;
package/src/gltf.ts CHANGED
@@ -26,6 +26,7 @@ import { MAX_MORPH_TARGETS } from '@driftengine/core';
26
26
  import { accessorFloats } from './gltfAccessor.ts';
27
27
  import type { GltfSkin } from './gltfSkin.ts';
28
28
  import { readGltfSkins } from './gltfSkin.ts';
29
+ import { writeInfluences } from './influences.ts';
29
30
 
30
31
  /* The subset of the schema this reads. Everything else is ignored or refused by name. */
31
32
  interface GltfAccessor {
@@ -1283,22 +1284,82 @@ function buildPrimitive(
1283
1284
 
1284
1285
  let joints: Float32Array | undefined;
1285
1286
  let weights: Float32Array | undefined;
1286
- const jointIndex = primitive.attributes['JOINTS_0'];
1287
- const weightIndex = primitive.attributes['WEIGHTS_0'];
1288
- if (skin !== undefined && jointIndex !== undefined && weightIndex !== undefined) {
1289
- joints = accessorFloats(doc, buffers, jointIndex, `${label} JOINTS_0`);
1290
- weights = accessorFloats(doc, buffers, weightIndex, `${label} WEIGHTS_0`);
1291
- for (let i = 0; i < joints.length; i++) {
1292
- const old = joints[i] as number;
1293
- const moved = skin.remap[old];
1294
- if (moved === undefined) {
1295
- throw new DrftError(
1296
- `gltf: ${label} weights a vertex to joint ${old}, and its skin has ${skin.remap.length}`,
1287
+ let joints2: Float32Array | undefined;
1288
+ let weights2: Float32Array | undefined;
1289
+ if (skin !== undefined) {
1290
+ /*
1291
+ * Every `JOINTS_n`/`WEIGHTS_n` pair the primitive carries, remapped onto the sorted joints. One
1292
+ * pair is what nearly every export writes and keeps the path it always had; more go through
1293
+ * `writeInfluences`, which keeps the eight heaviest, heaviest first, across the two sets a mesh
1294
+ * carries. See influences.ts.
1295
+ */
1296
+ const sets: { joints: Float32Array; weights: Float32Array }[] = [];
1297
+ for (let n = 0; ; n++) {
1298
+ const jointIndex = primitive.attributes[`JOINTS_${n}`];
1299
+ const weightIndex = primitive.attributes[`WEIGHTS_${n}`];
1300
+ if (jointIndex === undefined || weightIndex === undefined) break;
1301
+ const set = {
1302
+ joints: accessorFloats(doc, buffers, jointIndex, `${label} JOINTS_${n}`),
1303
+ weights: accessorFloats(doc, buffers, weightIndex, `${label} WEIGHTS_${n}`),
1304
+ };
1305
+ for (let i = 0; i < set.joints.length; i++) {
1306
+ const old = set.joints[i] as number;
1307
+ const moved = skin.remap[old];
1308
+ if (moved === undefined) {
1309
+ throw new DrftError(
1310
+ `gltf: ${label} weights a vertex to joint ${old}, and its skin has ${skin.remap.length}`,
1311
+ );
1312
+ }
1313
+ set.joints[i] = moved;
1314
+ }
1315
+ sets.push(set);
1316
+ }
1317
+ const only = sets.length === 1 ? sets[0] : undefined;
1318
+ if (only !== undefined) {
1319
+ joints = only.joints;
1320
+ weights = only.weights;
1321
+ normaliseWeights(weights, label, warnings);
1322
+ } else if (sets.length > 1) {
1323
+ const vertices = (sets[0] as { joints: Float32Array }).joints.length / 4;
1324
+ const out = {
1325
+ joints: new Float32Array(vertices * 4),
1326
+ weights: new Float32Array(vertices * 4),
1327
+ joints2: new Float32Array(vertices * 4),
1328
+ weights2: new Float32Array(vertices * 4),
1329
+ };
1330
+ let dropped = false;
1331
+ let rescaled = false;
1332
+ let second = false;
1333
+ for (let v = 0; v < vertices; v++) {
1334
+ const influences: { joint: number; weight: number }[] = [];
1335
+ for (const set of sets) {
1336
+ for (let k = 0; k < 4; k++) {
1337
+ influences.push({
1338
+ joint: set.joints[v * 4 + k] as number,
1339
+ weight: set.weights[v * 4 + k] as number,
1340
+ });
1341
+ }
1342
+ }
1343
+ const report = writeInfluences(influences, out, v);
1344
+ dropped ||= report.dropped;
1345
+ rescaled ||= report.rescaled;
1346
+ second ||= report.second;
1347
+ }
1348
+ joints = out.joints;
1349
+ weights = out.weights;
1350
+ if (second) {
1351
+ joints2 = out.joints2;
1352
+ weights2 = out.weights2;
1353
+ }
1354
+ if (dropped) {
1355
+ warnings.push(
1356
+ `${label}: a vertex is held by more than eight joints; the eight largest are kept`,
1297
1357
  );
1298
1358
  }
1299
- joints[i] = moved;
1359
+ if (rescaled) {
1360
+ warnings.push(`${label}: vertex weight sets did not sum to one and were rescaled`);
1361
+ }
1300
1362
  }
1301
- normaliseWeights(weights, label, warnings);
1302
1363
  }
1303
1364
 
1304
1365
  return {
@@ -1314,6 +1375,7 @@ function buildPrimitive(
1314
1375
  ...(uvs === undefined ? {} : { uvs }),
1315
1376
  ...(tangents === undefined ? {} : { tangents }),
1316
1377
  ...(joints === undefined || weights === undefined ? {} : { joints, weights }),
1378
+ ...(joints2 === undefined || weights2 === undefined ? {} : { joints2, weights2 }),
1317
1379
  ...(morphTargets === undefined || morphTargetCount === undefined
1318
1380
  ? {}
1319
1381
  : { morphTargets, morphTargetCount }),