@driftengine/assets 4.2.1 → 4.4.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.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/dist/bake/collapseHeap.d.ts +24 -0
  3. package/dist/bake/collapseHeap.js +80 -0
  4. package/dist/bake/collapseMeasures.d.ts +31 -0
  5. package/dist/bake/collapseMeasures.js +183 -0
  6. package/dist/bake/sceneField.d.ts +6 -0
  7. package/dist/bake/sceneField.js +48 -0
  8. package/dist/bake/sdf.d.ts +5 -5
  9. package/dist/bake/sdf.js +15 -95
  10. package/dist/bake/simplify.d.ts +42 -0
  11. package/dist/bake/simplify.js +350 -0
  12. package/dist/bake/surfaceDepth.d.ts +29 -0
  13. package/dist/bake/surfaceDepth.js +101 -0
  14. package/dist/downscale.d.ts +25 -0
  15. package/dist/downscale.js +50 -0
  16. package/dist/drawKey.d.ts +13 -1
  17. package/dist/drawKey.js +5 -3
  18. package/dist/drftLoader.d.ts +38 -7
  19. package/dist/drftLoader.js +186 -30
  20. package/dist/fieldPlacement.d.ts +22 -0
  21. package/dist/fieldPlacement.js +49 -0
  22. package/dist/gltf.d.ts +46 -1
  23. package/dist/gltf.js +101 -2
  24. package/dist/index.d.ts +9 -1
  25. package/dist/index.js +15 -1
  26. package/dist/loadProgress.d.ts +22 -1
  27. package/dist/orient.d.ts +9 -1
  28. package/dist/orient.js +60 -2
  29. package/dist/partMaterial.d.ts +19 -0
  30. package/dist/partMaterial.js +16 -0
  31. package/dist/readModel.d.ts +3 -1
  32. package/dist/repeats.d.ts +39 -0
  33. package/dist/repeats.js +391 -0
  34. package/dist/weld.d.ts +8 -0
  35. package/dist/weld.js +155 -34
  36. package/package.json +4 -4
  37. package/src/bake/collapseHeap.ts +81 -0
  38. package/src/bake/collapseMeasures.ts +215 -0
  39. package/src/bake/sceneField.ts +50 -0
  40. package/src/bake/sdf.ts +14 -99
  41. package/src/bake/simplify.ts +395 -0
  42. package/src/bake/surfaceDepth.ts +102 -0
  43. package/src/downscale.ts +61 -0
  44. package/src/drawKey.ts +17 -3
  45. package/src/drftLoader.ts +224 -38
  46. package/src/fieldPlacement.ts +74 -0
  47. package/src/gltf.ts +139 -3
  48. package/src/index.ts +24 -1
  49. package/src/loadProgress.ts +23 -1
  50. package/src/orient.ts +62 -3
  51. package/src/partMaterial.ts +42 -0
  52. package/src/readModel.ts +3 -1
  53. package/src/repeats.ts +442 -0
  54. package/src/weld.ts +164 -34
package/src/gltf.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * world matrix on the way through, and the graph does not survive into the engine.
18
18
  */
19
19
 
20
- import type { DrftNode, MeshData } from '@driftengine/drft';
20
+ import type { DrftLight, DrftNode, MeshData } from '@driftengine/drft';
21
21
  import { DrftError } from '@driftengine/drft';
22
22
  import type { DrftMaterial } from '@driftengine/drft';
23
23
  import { generateTangents } from '@driftengine/core';
@@ -93,6 +93,7 @@ interface GltfMaterial {
93
93
  emissiveFactor?: number[];
94
94
  /** `OPAQUE`, `MASK` or `BLEND`. Absent means opaque, which the specification states. */
95
95
  alphaMode?: string;
96
+ doubleSided?: boolean;
96
97
  alphaCutoff?: number;
97
98
  name?: string;
98
99
  /**
@@ -118,6 +119,16 @@ interface GltfImage {
118
119
  interface GltfTexture {
119
120
  source?: number;
120
121
  sampler?: number;
122
+ /**
123
+ * Where an image in a newer codec is named. glTF's own `source` is the fallback, and a file that
124
+ * *requires* the extension usually carries none, so the extension's index is the only route to
125
+ * the picture.
126
+ */
127
+ extensions?: {
128
+ EXT_texture_webp?: { source?: number };
129
+ EXT_texture_avif?: { source?: number };
130
+ KHR_texture_basisu?: { source?: number };
131
+ };
121
132
  }
122
133
  interface GltfNode {
123
134
  mesh?: number;
@@ -129,6 +140,17 @@ interface GltfNode {
129
140
  rotation?: number[];
130
141
  scale?: number[];
131
142
  name?: string;
143
+ /** Which of the document's punctual lights hangs on this node. */
144
+ extensions?: { KHR_lights_punctual?: { light?: number } };
145
+ }
146
+ /** One `KHR_lights_punctual` light, as the extension states it. */
147
+ interface GltfPunctualLight {
148
+ type?: string;
149
+ name?: string;
150
+ color?: number[];
151
+ intensity?: number;
152
+ range?: number;
153
+ spot?: { innerConeAngle?: number; outerConeAngle?: number };
132
154
  }
133
155
  export interface GltfDocument {
134
156
  asset?: { version?: string; generator?: string };
@@ -142,9 +164,14 @@ export interface GltfDocument {
142
164
  materials?: GltfMaterial[];
143
165
  images?: GltfImage[];
144
166
  textures?: GltfTexture[];
167
+ /** Extensions the file states it cannot be read correctly without. */
168
+ extensionsRequired?: string[];
169
+ extensionsUsed?: string[];
145
170
  /* Read by `gltfSkin.ts`; ignored entirely by the mesh path, which flattens. */
146
171
  skins?: GltfSkinDef[];
147
172
  animations?: GltfAnimationDef[];
173
+ /** The document's own lights, which nodes name by index. */
174
+ extensions?: { KHR_lights_punctual?: { lights?: GltfPunctualLight[] } };
148
175
  }
149
176
 
150
177
  /** A skin, as glTF declares one. `skeleton` is a hint this reader does not need and ignores. */
@@ -323,6 +350,34 @@ function normalMatrix(m: readonly number[]): number[] {
323
350
  * textures may share one image with different samplers. Following the indirection here keeps
324
351
  * the material's index meaning what the file said it meant.
325
352
  */
353
+ /**
354
+ * Which image a texture shows: glTF's `source` when there is one, otherwise an image-codec
355
+ * extension's.
356
+ *
357
+ * **The fallback wins when both exist**, because every reader can decode it and the baker
358
+ * identifies whatever bytes it gets. What that gives up is nothing a bake can use today: a WebP
359
+ * extension's image is smaller, but nothing here knows the two are the same picture. What would
360
+ * make it wrong is a baker that decodes AVIF, where the extension's image may be the better source.
361
+ *
362
+ * Basis is refused by name: it is a GPU transcoding format, and this engine uploads decoded images.
363
+ */
364
+ function textureSource(
365
+ texture: GltfTexture | undefined,
366
+ index: number,
367
+ warnings: string[],
368
+ ): number | undefined {
369
+ if (texture?.source !== undefined) return texture.source;
370
+ const ext = texture?.extensions;
371
+ if (ext?.KHR_texture_basisu !== undefined) {
372
+ warnings.push(
373
+ `texture ${index} is KHR_texture_basisu, which this reader does not transcode; export ` +
374
+ 'the model with PNG, JPEG or WebP images',
375
+ );
376
+ return undefined;
377
+ }
378
+ return ext?.EXT_texture_webp?.source ?? ext?.EXT_texture_avif?.source;
379
+ }
380
+
326
381
  function readImages(
327
382
  doc: GltfDocument,
328
383
  buffers: readonly Uint8Array[],
@@ -330,7 +385,7 @@ function readImages(
330
385
  ): AssetReference[] {
331
386
  const out: AssetReference[] = [];
332
387
  for (let i = 0; i < (doc.textures ?? []).length; i++) {
333
- const source = doc.textures?.[i]?.source;
388
+ const source = textureSource(doc.textures?.[i], i, warnings);
334
389
  const image = source === undefined ? undefined : doc.images?.[source];
335
390
  if (image === undefined) {
336
391
  warnings.push(`texture ${i} names no image this reader can find; it will draw untextured`);
@@ -563,6 +618,17 @@ function materialOf(material: GltfMaterial | undefined): DrftMaterial {
563
618
  const modulated = ormMap >= 0;
564
619
  const metallic = modulated ? 0 : (pbr?.metallicFactor ?? 1);
565
620
  const roughness = modulated ? DEFAULT_ROUGHNESS : (pbr?.roughnessFactor ?? 1);
621
+ /*
622
+ * **The same trap reached by the other door: a metal whose colour is an image.** `specular` is
623
+ * this engine's dielectric highlight and it is white, which is a metal's highlight only where the
624
+ * metal is white. Where the base colour is a texture that colour is per texel, the lane cannot
625
+ * say it, and the factor alone blew a dirt decal to white: its file names no metallic and no
626
+ * roughness, so the specification reads a rough metal of dark grime, and every wall it covered
627
+ * drew white under a low sun with the grime dark between. So the lane keeps the dielectric's
628
+ * zero there. What it gives up is the sun's glint on a textured metal with no map, which then
629
+ * shades as the grime or the paint its image says; what would change it is a metallic lane.
630
+ */
631
+ const highlight = pbr?.baseColorTexture === undefined ? metallic : 0;
566
632
 
567
633
  /*
568
634
  * **glTF leaves `metallicRoughnessTexture`'s R channel undefined.** The specification assigns G
@@ -583,7 +649,7 @@ function materialOf(material: GltfMaterial | undefined): DrftMaterial {
583
649
  return {
584
650
  name: material?.name ?? '',
585
651
  color: [base[0] ?? 1, base[1] ?? 1, base[2] ?? 1],
586
- specular: metallic,
652
+ specular: highlight,
587
653
  roughness,
588
654
  emissive,
589
655
  emissiveColor:
@@ -591,6 +657,14 @@ function materialOf(material: GltfMaterial | undefined): DrftMaterial {
591
657
  ? [emissiveFactor[0] ?? 0, emissiveFactor[1] ?? 0, emissiveFactor[2] ?? 0]
592
658
  : [-1, -1, -1],
593
659
  opacity: blends ? Math.min(1, Math.max(0, base[3] ?? 1)) : 1,
660
+ /*
661
+ * glTF's `BLEND` says alpha is the factor *times the texture*. With a factor of 1 `opacity` alone
662
+ * cannot say the material blends, and a leaf whose shape is all in its texture's alpha drew as a
663
+ * solid card. So it is said.
664
+ */
665
+ blend: blends,
666
+ /* Seen from both faces: a curtain, a leaf card. The specification's default is one face. */
667
+ doubleSided: material?.doubleSided === true,
594
668
  /* Only in MASK mode: the specification says `alphaCutoff` has no meaning in the other two. */
595
669
  cutout: masked ? Math.min(1, Math.max(0, material?.alphaCutoff ?? 0.5)) : 0,
596
670
  /* Metalness is glTF's statement about reflection: a metal mirrors its surroundings and
@@ -627,6 +701,14 @@ export interface GltfReadOptions {
627
701
  readonly deriveTangents?: boolean;
628
702
  }
629
703
 
704
+ /** The required extensions this reader implements, or follows far enough to fail loudly. */
705
+ const KNOWN_REQUIRED = new Set([
706
+ 'KHR_materials_pbrSpecularGlossiness',
707
+ 'EXT_texture_webp',
708
+ 'EXT_texture_avif',
709
+ 'KHR_lights_punctual',
710
+ ]);
711
+
630
712
  /**
631
713
  * Turn a document into meshes in world space, one per primitive.
632
714
  *
@@ -644,6 +726,7 @@ export function gltfToMeshes(
644
726
  materials: DrftMaterial[];
645
727
  textures: AssetReference[];
646
728
  nodes: DrftNode[];
729
+ lights: DrftLight[];
647
730
  } {
648
731
  const version = doc.asset?.version ?? '';
649
732
  if (!version.startsWith('2')) {
@@ -655,6 +738,15 @@ export function gltfToMeshes(
655
738
  /* One per labelled material, by the ordinal `materials` is about to give it. */
656
739
  const substances: { material: number; substance: string }[] = [];
657
740
  const warnings: string[] = [];
741
+ /*
742
+ * A required extension is the file saying it cannot be read correctly without it. One this
743
+ * reader does not know is said by name, rather than drawing whatever falls out.
744
+ */
745
+ for (const name of doc.extensionsRequired ?? []) {
746
+ if (!KNOWN_REQUIRED.has(name)) {
747
+ warnings.push(`the file requires ${name}, which this reader does not implement`);
748
+ }
749
+ }
658
750
  const textures = readImages(doc, buffers, warnings);
659
751
  /*
660
752
  * Said once per material rather than once per primitive that uses it.
@@ -696,6 +788,9 @@ export function gltfToMeshes(
696
788
  * traversal.
697
789
  */
698
790
  const nodes: DrftNode[] = [];
791
+ /* The lights, placed by the same world matrix the walk computes for the geometry. */
792
+ const lights: DrftLight[] = [];
793
+ const punctual = doc.extensions?.KHR_lights_punctual?.lights ?? [];
699
794
 
700
795
  const walk = (nodeIndex: number, parent: readonly number[], parentNode: number): void => {
701
796
  if (seen.has(nodeIndex)) {
@@ -708,6 +803,18 @@ export function gltfToMeshes(
708
803
  if (node === undefined)
709
804
  throw new DrftError(`gltf: a scene names node ${nodeIndex}, which is absent`);
710
805
  const world = multiply(parent, localMatrix(node));
806
+ const hung = node.extensions?.KHR_lights_punctual?.light;
807
+ if (hung !== undefined) {
808
+ const light = punctual[hung];
809
+ if (light === undefined) {
810
+ warnings.push(`node ${nodeIndex} names light ${hung}, and the file has ${punctual.length}`);
811
+ } else {
812
+ const placed = placeLight(light, world, node.name ?? light.name ?? '');
813
+ if (placed === null)
814
+ warnings.push(`light ${hung}: a type "${light.type}" is not a punctual light`);
815
+ else lights.push(placed);
816
+ }
817
+ }
711
818
 
712
819
  const entry = nodes.length;
713
820
  nodes.push({
@@ -795,10 +902,39 @@ export function gltfToMeshes(
795
902
  materials,
796
903
  textures,
797
904
  nodes,
905
+ lights,
798
906
  ...(substances.length === 0 ? {} : { substances }),
799
907
  };
800
908
  }
801
909
 
910
+ /**
911
+ * One punctual light where its node puts it: the node's origin, and its local −z for the way it
912
+ * points, both through the world matrix. The extension's defaults stand where the file is silent.
913
+ */
914
+ function placeLight(
915
+ light: GltfPunctualLight,
916
+ world: readonly number[],
917
+ name: string,
918
+ ): DrftLight | null {
919
+ const kind = light.type;
920
+ if (kind !== 'point' && kind !== 'spot' && kind !== 'directional') return null;
921
+ const dx = -(world[8] ?? 0);
922
+ const dy = -(world[9] ?? 0);
923
+ const dz = -(world[10] ?? 1);
924
+ const length = Math.hypot(dx, dy, dz) || 1;
925
+ return {
926
+ kind,
927
+ name,
928
+ position: [world[12] ?? 0, world[13] ?? 0, world[14] ?? 0],
929
+ direction: [dx / length, dy / length, dz / length],
930
+ color: [light.color?.[0] ?? 1, light.color?.[1] ?? 1, light.color?.[2] ?? 1],
931
+ intensity: light.intensity ?? 1,
932
+ range: light.range ?? 0,
933
+ innerConeRad: light.spot?.innerConeAngle ?? 0,
934
+ outerConeRad: light.spot?.outerConeAngle ?? Math.PI / 4,
935
+ };
936
+ }
937
+
802
938
  function buildPrimitive(
803
939
  doc: GltfDocument,
804
940
  buffers: readonly Uint8Array[],
package/src/index.ts CHANGED
@@ -13,6 +13,11 @@
13
13
  * consumer writes the words, because what to call a stage is a decision about an audience.
14
14
  */
15
15
  export { DrftLoader } from './drftLoader.ts';
16
+ /*
17
+ * A part's whole material in the renderer's shape, so a consumer drawing parts cannot copy half of
18
+ * it: a copy that left out the occlusion strength drew a bought model black.
19
+ */
20
+ export { writePartMaterial } from './partMaterial.ts';
16
21
  /*
17
22
  * **A `DTEX` chunk's tile table as the grid residency asks about.** The other constructor of a
18
23
  * `MaterialTileGrid` — `latentTileGrid` cuts one from a latent a baker holds, and this reads one a
@@ -28,6 +33,7 @@ export type { DrftFit, DrftLoaderOptions } from './drftLoader.ts';
28
33
  export { drawKeyOf, resolveDrawGrouping } from './drawKey.ts';
29
34
  export type { DrawGrouping, DrawSurfaceOverride } from './drawKey.ts';
30
35
  export type { DrftLoadPhase, DrftLoadProgress, DrftPart } from './loadProgress.ts';
36
+ export type { DrftFieldPlacement } from './fieldPlacement.ts';
31
37
  /* Addressing a texture by name, so a consumer never holds an ordinal. */
32
38
  export { TextureSet, textureColorSpaces } from './drftTextures.ts';
33
39
  export { assetCandidates, basenameOf } from './assetPath.ts';
@@ -105,11 +111,21 @@ export type { AsyncInflate } from './fbxInflate.ts';
105
111
  * on a real asset is roughly six times more than the model has.
106
112
  */
107
113
  export { weldMesh, dropDefaultAttributes } from './weld.ts';
114
+ export { simplifyMesh } from './bake/simplify.ts';
115
+ export type { SimplifyOptions } from './bake/simplify.ts';
116
+ /*
117
+ * Copies an export merged into one mesh, found again as a prototype and a placement each. The
118
+ * baker writes them as `INST`; see `repeats.ts` for how strict a copy has to be to count.
119
+ */
120
+ export { componentCount, findRepeats } from './repeats.ts';
121
+ export type { Repeats } from './repeats.ts';
108
122
  /*
109
123
  * The frame is derived after the weld and not inside a reader, for the reason `tangentOrder.ts`
110
124
  * measures: a soup carrying a frame per corner has nothing left to merge.
111
125
  */
112
126
  export { deriveTangentsFor } from './tangentOrder.ts';
127
+ /* A decoded image shrunk to a texture cap, which is what the baker's `--max-texture` runs. */
128
+ export { cappedSize, downscaleRgba } from './downscale.ts';
113
129
  /*
114
130
  * One coarse mesh standing in for a whole asset, so a load can open on an outline rather than on
115
131
  * nothing. Baked, because a coarse model has to exist as geometry before it can be drawn, and
@@ -122,7 +138,13 @@ export type { CoarseLevelOptions } from './coarseLevel.ts';
122
138
  * than guessed: every rule for measuring it from the geometry is wrong on something ordinary,
123
139
  * and guessing is what put a car upside down and reported it as verified.
124
140
  */
125
- export { orientMeshes, parseUpAxis, describeUpAxis } from './orient.ts';
141
+ export {
142
+ orientMeshes,
143
+ orientTransforms,
144
+ orientLights,
145
+ parseUpAxis,
146
+ describeUpAxis,
147
+ } from './orient.ts';
126
148
  export type { UpAxis } from './orient.ts';
127
149
 
128
150
  /* Skins and clips out of a glTF document. See the module for why it is not part of `gltf.ts`. */
@@ -199,6 +221,7 @@ export type { ClusterDag, ClusterLevel } from './bake/clusterLod.ts';
199
221
  * carries, and a field a consumer cannot bake is a pipeline with no input.
200
222
  */
201
223
  export { bakeObjectSdf } from './bake/sdf.ts';
224
+ export { bakeSceneField } from './bake/sceneField.ts';
202
225
  /*
203
226
  * **The third time, and the one that got furthest before anybody noticed.** `bake/hlod.ts` builds
204
227
  * the proxies and impostors `docs/CAPABILITIES.md` lists as shipping, it is tested, it is sized and
@@ -1,4 +1,4 @@
1
- import type { MeshHandle } from '@driftengine/core';
1
+ import type { MeshHandle, InstancedHandle, MeshInstances } from '@driftengine/core';
2
2
 
3
3
  /**
4
4
  * The vocabulary of a load in flight: which phase it is in, how far it has got, and what a
@@ -73,6 +73,12 @@ export interface DrftLoadProgress {
73
73
  }
74
74
 
75
75
  /** One drawable piece of a loaded model. */
76
+ /** A part's copies: the instanced batch over its mesh, and the placements already uploaded to it. */
77
+ export interface DrftPartInstances {
78
+ readonly batch: InstancedHandle;
79
+ readonly data: MeshInstances;
80
+ }
81
+
76
82
  export interface DrftPart {
77
83
  readonly mesh: MeshHandle;
78
84
  /** Which of the asset's images this surface wears, or -1 for none. */
@@ -121,6 +127,22 @@ export interface DrftPart {
121
127
  * nothing at all.
122
128
  */
123
129
  readonly cutout: number;
130
+ /**
131
+ * Whether the part blends: its texture's alpha is coverage, so it is drawn translucent whatever its
132
+ * `opacity` says. glTF's `BLEND` with a factor of 1 is the case: a leaf's shape is in its alpha.
133
+ */
134
+ readonly blend: boolean;
135
+ /** Whether both faces are seen: glTF's `doubleSided`. Drawn without culling, each face lit as the front. */
136
+ readonly doubleSided: boolean;
137
+ /**
138
+ * The copies this part is drawn as, or null for a part drawn once.
139
+ *
140
+ * A mesh the file places many times (`INST`, 1.18) is uploaded once and drawn through the
141
+ * engine's instanced path: `drawInstanced(batch, data)`, and `sink.instanced` for a shadow. The
142
+ * placements are uploaded when the part is made and carry the fit, so a caller passes them as
143
+ * they are. Null for every part of a file with no instances, which is every file before 1.18.
144
+ */
145
+ readonly instances: DrftPartInstances | null;
124
146
  /**
125
147
  * 0 to 1 as this part arrives, for a caller that would rather it faded in than appeared.
126
148
  *
package/src/orient.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  * untouched.
27
27
  */
28
28
 
29
- import type { DrftNode, MeshData } from '@driftengine/drft';
29
+ import type { DrftLight, DrftNode, MeshData } from '@driftengine/drft';
30
30
  import { DrftError } from '@driftengine/drft';
31
31
 
32
32
  /** Which way is up in the asset, as a signed axis of the engine's own frame. */
@@ -71,7 +71,11 @@ export function describeUpAxis(up: UpAxis): string {
71
71
  return (TURNS[up] as { says: string }).says;
72
72
  }
73
73
 
74
- function turnTriples(source: Float32Array, m: readonly number[]): Float32Array {
74
+ /**
75
+ * Turn the first three of every `stride` values, and copy the rest. Three for a position or a
76
+ * normal; four for a tangent, whose fourth is the bitangent's handedness and is not a direction.
77
+ */
78
+ function turnTriples(source: Float32Array, m: readonly number[], stride = 3): Float32Array {
75
79
  const out = new Float32Array(source.length);
76
80
  const m0 = m[0] as number;
77
81
  const m1 = m[1] as number;
@@ -82,13 +86,14 @@ function turnTriples(source: Float32Array, m: readonly number[]): Float32Array {
82
86
  const m6 = m[6] as number;
83
87
  const m7 = m[7] as number;
84
88
  const m8 = m[8] as number;
85
- for (let i = 0; i + 2 < source.length; i += 3) {
89
+ for (let i = 0; i + 2 < source.length; i += stride) {
86
90
  const x = source[i] as number;
87
91
  const y = source[i + 1] as number;
88
92
  const z = source[i + 2] as number;
89
93
  out[i] = m0 * x + m1 * y + m2 * z;
90
94
  out[i + 1] = m3 * x + m4 * y + m5 * z;
91
95
  out[i + 2] = m6 * x + m7 * y + m8 * z;
96
+ for (let k = 3; k < stride; k++) out[i + k] = source[i + k] as number;
92
97
  }
93
98
  return out;
94
99
  }
@@ -106,6 +111,60 @@ export function orientMeshes(meshes: readonly MeshData[], assetUp: UpAxis): Mesh
106
111
  ...mesh,
107
112
  positions: turnTriples(mesh.positions, m),
108
113
  normals: turnTriples(mesh.normals, m),
114
+ /*
115
+ * **The tangent frame turns with the normals**, and it did not until 2026-09-25: the baker
116
+ * derives tangents before it orients, so a normal-mapped model baked with `--up` kept its
117
+ * tangents in the file's axes and lit its normal maps from the wrong side.
118
+ */
119
+ ...(mesh.tangents === undefined ? {} : { tangents: turnTriples(mesh.tangents, m, 4) }),
120
+ }));
121
+ }
122
+
123
+ /**
124
+ * Column-major 4×4 placements, turned into the engine's frame: `O · M · Oᵀ` for each, with `O` the
125
+ * same turn `orientMeshes` gives the geometry. A copy the file moved along its own +z is then moved
126
+ * along the engine's +y, and a placement that was a rotation stays one of the same angle.
127
+ */
128
+ export function orientTransforms(transforms: Float32Array, assetUp: UpAxis): Float32Array {
129
+ const o = TURNS[assetUp].m;
130
+ const out = new Float32Array(transforms.length);
131
+ for (let at = 0; at + 15 < transforms.length; at += 16) {
132
+ /* Row-major 3×3 of the placement, read out of its column-major block. */
133
+ for (let r = 0; r < 3; r++) {
134
+ for (let c = 0; c < 3; c++) {
135
+ let sum = 0;
136
+ for (let i = 0; i < 3; i++) {
137
+ for (let j = 0; j < 3; j++) {
138
+ /* (O M Oᵀ)[r][c] = Σ O[r][i] M[i][j] O[c][j] */
139
+ sum +=
140
+ (o[r * 3 + i] as number) *
141
+ (transforms[at + j * 4 + i] as number) *
142
+ (o[c * 3 + j] as number);
143
+ }
144
+ }
145
+ out[at + c * 4 + r] = sum;
146
+ }
147
+ let t = 0;
148
+ for (let i = 0; i < 3; i++)
149
+ t += (o[r * 3 + i] as number) * (transforms[at + 12 + i] as number);
150
+ out[at + 12 + r] = t;
151
+ }
152
+ out[at + 15] = 1;
153
+ }
154
+ return out;
155
+ }
156
+
157
+ /** Authored lights, turned into the engine's frame by the turn `orientMeshes` gives the geometry. */
158
+ export function orientLights(lights: readonly DrftLight[], assetUp: UpAxis): DrftLight[] {
159
+ const { m } = TURNS[assetUp];
160
+ const turn = (v: readonly [number, number, number]): [number, number, number] => {
161
+ const t = turnTriples(new Float32Array(v), m);
162
+ return [t[0] as number, t[1] as number, t[2] as number];
163
+ };
164
+ return lights.map((light) => ({
165
+ ...light,
166
+ position: turn(light.position),
167
+ direction: turn(light.direction),
109
168
  }));
110
169
  }
111
170
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A loaded part's material, written into the shape the renderer's `setMaterial` takes.
3
+ *
4
+ * **Every field or none**, because a partial copy fails in the one way nobody notices: silently and
5
+ * dark. `SurfaceMaterial` defaults `occlusionStrength` to 1, which is right for a caller who binds
6
+ * an ORM map and says nothing. A glTF material with no occlusion texture is carried as 0, and glTF
7
+ * lets its ORM map's red channel be anything — zero, in a bought scene measured here. A consumer
8
+ * who copied the maps and not the strength drew every surface multiplied by that zero. This is the
9
+ * one place the correspondence is written, so a field added to one side has one place to arrive.
10
+ *
11
+ * Writes into a caller-owned object and allocates nothing, so a scene rewrites one material per
12
+ * part in its frame loop. Opacity and reflectivity are not written: the first is a choice of draw
13
+ * call and the second a separate setter, and both stay the caller's to apply.
14
+ */
15
+ import type { SurfaceMaterial, SurfaceTextureHandle } from '@driftengine/core';
16
+ import type { TextureSet } from './drftTextures.ts';
17
+ import type { DrftPart } from './loadProgress.ts';
18
+
19
+ function at(
20
+ textures: TextureSet<SurfaceTextureHandle> | null,
21
+ index: number,
22
+ ): SurfaceTextureHandle | null {
23
+ return index >= 0 ? (textures?.at(index) ?? null) : null;
24
+ }
25
+
26
+ /** `part`'s maps and factors into `out`, resolving each map through `textures`. */
27
+ export function writePartMaterial(
28
+ part: DrftPart,
29
+ textures: TextureSet<SurfaceTextureHandle> | null,
30
+ out: SurfaceMaterial<SurfaceTextureHandle>,
31
+ ): SurfaceMaterial<SurfaceTextureHandle> {
32
+ out.albedo = at(textures, part.albedo);
33
+ out.orm = at(textures, part.orm);
34
+ out.normal = at(textures, part.normal);
35
+ out.emissive = at(textures, part.emissive);
36
+ out.roughnessScale = part.roughnessScale;
37
+ out.metallicScale = part.metallicScale;
38
+ out.occlusionStrength = part.occlusionStrength;
39
+ out.cutout = part.cutout;
40
+ out.doubleSided = part.doubleSided;
41
+ return out;
42
+ }
package/src/readModel.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import type { MeshData } from '@driftengine/drft';
25
- import type { AnimationClip, DrftMaterial, DrftNode, DrftSkin } from '@driftengine/drft';
25
+ import type { AnimationClip, DrftLight, DrftMaterial, DrftNode, DrftSkin } from '@driftengine/drft';
26
26
  import { DrftError } from '@driftengine/drft';
27
27
  import type { AssetReference } from './assetPath.ts';
28
28
  import { assetCandidates } from './assetPath.ts';
@@ -150,6 +150,8 @@ export interface ModelImport {
150
150
  * reads it calls `localiseNodes` to get parts it can turn.
151
151
  */
152
152
  nodes?: readonly DrftNode[];
153
+ /** The lights the file was authored with, in world space. glTF carries them; nothing else here. */
154
+ lights?: readonly DrftLight[];
153
155
  /** Which way round the source's coordinate system was. `orient.ts` is what acts on it. */
154
156
  declaredHand?: Handedness;
155
157
  }