lecodes-assets 0.2.1 → 2.5.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 (99) hide show
  1. package/README.md +203 -26
  2. package/dist/cli.js +18157 -11441
  3. package/dist/index.js +16603 -9878
  4. package/package.json +3 -1
  5. package/src/cli-kit/command.ts +100 -0
  6. package/src/cli-kit/help.ts +72 -0
  7. package/src/cli-kit/index.ts +4 -0
  8. package/src/cli-kit/parse.ts +72 -0
  9. package/src/cli-kit/run.ts +52 -0
  10. package/src/cli.ts +24 -1039
  11. package/src/commands/avatar.ts +77 -0
  12. package/src/commands/bvh.ts +26 -0
  13. package/src/commands/convert.ts +156 -0
  14. package/src/commands/doctor.ts +79 -0
  15. package/src/commands/geometry.ts +45 -0
  16. package/src/commands/index.ts +24 -0
  17. package/src/commands/marks.ts +38 -0
  18. package/src/commands/mirror.ts +35 -0
  19. package/src/commands/probe.ts +43 -0
  20. package/src/commands/retarget/donor.ts +63 -0
  21. package/src/commands/retarget/index.ts +169 -0
  22. package/src/commands/retarget/map.ts +81 -0
  23. package/src/commands/retarget/report.ts +77 -0
  24. package/src/commands/scene.ts +108 -0
  25. package/src/commands/shared.ts +140 -0
  26. package/src/commands/sky.ts +35 -0
  27. package/src/commands/terrainPack.ts +40 -0
  28. package/src/commands/unpack.ts +43 -0
  29. package/src/commands/unreal.ts +109 -0
  30. package/src/commands/unwrap.ts +56 -0
  31. package/src/doctor/cardTrim.ts +288 -0
  32. package/src/doctor/doctor.ts +94 -30
  33. package/src/doctor/impostor.ts +614 -0
  34. package/src/doctor/merge/accessors.ts +30 -0
  35. package/src/doctor/merge/atlasLayout.ts +94 -0
  36. package/src/doctor/merge/atlasPixels.ts +114 -0
  37. package/src/doctor/merge/bindSpace.ts +59 -0
  38. package/src/doctor/merge/classify.ts +136 -0
  39. package/src/doctor/merge/geometry.ts +167 -0
  40. package/src/doctor/merge/index.ts +98 -0
  41. package/src/doctor/merge/matrix.ts +60 -0
  42. package/src/doctor/merge/plan.ts +88 -0
  43. package/src/doctor/merge/textures.ts +60 -0
  44. package/src/doctor/merge/types.ts +77 -0
  45. package/src/doctor/transcodeKtx2.ts +186 -31
  46. package/src/doctor/transcodeTextures.ts +6 -1
  47. package/src/doctor/validateGlb.ts +2 -2
  48. package/src/doctor/vertexAo.ts +333 -0
  49. package/src/fbx/read.ts +2 -0
  50. package/src/fbx/toDocument.ts +227 -209
  51. package/src/geometry.ts +222 -0
  52. package/src/index.ts +2 -0
  53. package/src/retarget/heading.ts +238 -171
  54. package/src/retarget/humanoid.ts +572 -561
  55. package/src/retarget/retarget.ts +2375 -2272
  56. package/src/retarget/rig.ts +4 -1
  57. package/src/retarget/writeMarks.ts +134 -132
  58. package/src/sky/cmgen.ts +3 -3
  59. package/src/sky/index.ts +57 -8
  60. package/src/terrainPack.ts +61 -22
  61. package/src/unity/colliderFit.ts +287 -0
  62. package/src/unity/fbxModel.ts +194 -0
  63. package/src/unity/foliage.ts +63 -0
  64. package/src/unity/gameObjects.ts +90 -0
  65. package/src/unity/guid.ts +104 -0
  66. package/src/unity/image.ts +58 -0
  67. package/src/unity/kindGlb.ts +364 -0
  68. package/src/unity/lecodesLevel.ts +414 -0
  69. package/src/unity/levelTerrain.ts +125 -0
  70. package/src/unity/matFile.ts +179 -0
  71. package/src/unity/materialSlots.ts +53 -0
  72. package/src/unity/materialWriter.ts +316 -0
  73. package/src/unity/materials.ts +34 -429
  74. package/src/unity/math.ts +82 -0
  75. package/src/unity/prefabTree.ts +285 -0
  76. package/src/unity/prefabs.ts +10 -36
  77. package/src/unity/propertyPath.ts +43 -0
  78. package/src/unity/scene.ts +339 -304
  79. package/src/unity/serialized.ts +242 -0
  80. package/src/unity/terrain.ts +221 -0
  81. package/src/unity/triBudget.ts +77 -0
  82. package/src/unity/triplanar.ts +122 -0
  83. package/src/unity/unityMesh.ts +151 -0
  84. package/src/unity/unpack.ts +24 -5
  85. package/src/unity/yaml.ts +131 -0
  86. package/src/unreal/index.ts +6 -5
  87. package/src/unreal/materialGraph.ts +224 -0
  88. package/src/unreal/materials.ts +117 -9
  89. package/src/unreal/package.ts +30 -12
  90. package/src/unreal/resolve.ts +6 -4
  91. package/src/unreal/sound.ts +128 -0
  92. package/src/unreal/texture.ts +70 -3
  93. package/src/unreal/toDocument.ts +7 -0
  94. package/src/unreal/versions.ts +8 -0
  95. package/src/unwrap/index.ts +167 -0
  96. package/src/unwrap/lightmapUv.ts +761 -0
  97. package/src/wasm/fbxReader.js +1 -1
  98. package/src/wasm/lightmapUv.js +1 -1
  99. package/src/doctor/lightmapUv.ts +0 -452
@@ -0,0 +1,614 @@
1
+ // IMPOSTORS FOR TREES: a far tree drawn as ONE camera-facing quad textured from an atlas of views of the tree
2
+ // (a GRID of A azimuths × E elevation rows, or an N×N hemi-OCTAHEDRON), baked here into the tree's own GLB as its
3
+ // last LOD level (`<root>_LOD<k>`, picked by creator-gl's LOD pass like any level). The atlas holds
4
+ // light-independent data only; the engine's foliage-impostor.mat lights the quad with the crown cards' model.
5
+ // What the GLB carries (the engine reads the same text):
6
+ //
7
+ // level 0 every mesh node without a `_LOD<n>` suffix (n ≥ 1) — the only geometry rendered. OPAQUE (bark) and
8
+ // MASK (cards, cut at the material's alphaCutoff against base texture × baseColorFactor); MASK and
9
+ // double-sided triangles are drawn whatever their facing. The normal stored is NOT the mesh's: it is the
10
+ // crown's HULL normal the cards shade by (foliage-wind.vs f.hull) - from the point (0, 0.6 × height, 0)
11
+ // above the plant's origin to the surface point - so the quad is lit exactly as the cards it replaces
12
+ // (a leaf deep in the crown on the far side keeps a normal that looks away: the crown's self-shading).
13
+ // sphere c = centre of the level-0 AABB in the GLB's frame (node world matrices applied), r = the largest
14
+ // distance from c to a level-0 vertex. Every frame is an orthographic view of [-r, r]² around c.
15
+ // frames Two layouts, both over the upper hemisphere (+Y up, a tree is never seen from below). P = pixels a
16
+ // frame's side (default 256 for a plant ≥ 8 m tall, 128 under; always a multiple of 4).
17
+ // GRID A × E (A ≥ 3 azimuths, E ≥ 1 rows; the default 12 × 3): frame (i, k), i ∈ [0, A), k ∈ [0, E),
18
+ // az = i·2π/A, el = k·(π/2)/E (E = 3 → 0°, 30°, 60°; 90° is never a row - the top row serves above it),
19
+ // d = gridDirection(i, k) = (cos el·sin az, sin el, cos el·cos az): az 0 looks from +Z, az 90° from +X.
20
+ // gridEncode(d) = (fa, fe): az = atan2(d.x, d.z) wrapped into [0, 2π), el = asin(clamp(d.y, 0, 1)),
21
+ // fa = az/(2π)·A (frame A ≡ frame 0), fe = clamp(el·E/(π/2), 0, E-1). The shader's pick: i = floor(fa),
22
+ // k = floor(fe), t = fract; t.x + t.y < 1 → (i,k) (i+1,k) (i,k+1) weighted (1-t.x-t.y, t.x, t.y), else
23
+ // (i+1,k+1) (i,k+1) (i+1,k) weighted (t.x+t.y-1, 1-t.x, 1-t.y); i+1 mod A, k+1 → min(k+1, E-1);
24
+ // packed i·E + k.
25
+ // OCTAHEDRON N × N (N ≥ 2): frame (i, j) looks from d = octDecode(i / (N-1), j / (N-1)) — octDecode /
26
+ // octEncode below, identical in the shader. Zenith → uv (0.5, 0.5), +X horizon → (1, 1), +Z horizon
27
+ // → (1, 0); the map's border is the horizon. Packed i·N + j.
28
+ // basis ref = |d.y| < 0.999 ? +Y : -Z; right = normalize(ref × d); up = d × right (d = +Z: right +X, up +Y).
29
+ // atlas C columns × R rows of P×P frames, C·P × R·P pixels (grid: C = A, R = E; octahedron: C = R = N, square).
30
+ // Frame (column i, row k) at pixels [i·P, (i+1)·P) × [k·P, (k+1)·P), image row 0 at the TOP (grid: ROW 0 =
31
+ // elevation 0 at the top of the image). A point p lands at uv ((i + a) / C, (k + 1 - b) / R),
32
+ // a = 0.5 + 0.5·dot(p-c, right)/r, b = the same on up (a right, b UP).
33
+ // textures PNG RGBA8, the doctor's KTX2 pass encodes them afterwards like any map - but never resized by its
34
+ // --max-size (both carry the texture extras `lecodes.impostor = true`; a 12 × 256 = 3072 px strip stays):
35
+ // baseColorTexture rgb = albedo (sRGB; source × baseColorFactor, averaged in LINEAR), a = coverage
36
+ // metallicRoughnessTexture r, g = the hull normal in OBJECT space (no frame basis), packed with the
37
+ // FULL-sphere octahedral map normalEncode / normalDecode below (the far side's
38
+ // and the crown's underside normals included) - not the frames' hemi octEncode;
39
+ // the map's seam pole (its four corners) is object -Y, a direction no frame looks
40
+ // from (at -Z the az-180° frame's near-side normals all sat on the corners,
41
+ // and bilinear / mips averaged them into garbage) - +Y is the map's centre;
42
+ // b = depth along the frame's view axis, dot(p - c, d) / r ·0.5+0.5 (1 = the
43
+ // point nearest this frame's viewer, 0.5 = the quad plane through c, 0 = the
44
+ // farthest) - the shader self-shadows the quad from the frame nearest the sun
45
+ // with it (and parallax may read it later); a = AO (COLOR_0.r, else 1).
46
+ // 2×2 supersamples a texel (coverage = covered / 4, data = mean of the covered ones: the normal averaged
47
+ // as a vector then encoded, the depth averaged), then the colour and data are DILATED into the empty
48
+ // texels of the same frame so bilinear / mips never pull black in (a depth read just outside a
49
+ // silhouette continues the surface; the albedo's alpha there says "no occluder").
50
+ // quad node `<level-0 root name>_LOD<k>`, k = min(2, highest existing level + 1); every `_LOD<n>` n ≥ k goes
51
+ // (the pack's cross-card billboard is worse) and `prune` drops what they used. 4 vertices: POSITION =
52
+ // the corners c + r·(∓1, ∓1, ∓1) so the accessor's min/max is the sphere's box (the shader places the
53
+ // corners itself), TEXCOORD_0 = the corner id, COLOR_0 white, NORMAL +Z. Material `<root>_impostor`,
54
+ // MASK 0.5, double-sided, metallic 0 / roughness 1, extras `lecodes.impostor = [A, cx, cy, cz, r, E]`
55
+ // (the 6th number = the ROW count; E = 0 or missing = the octahedron, the first number then = N);
56
+ // node extras `lecodes.impostor = { cols, rows, px }` (rows 0 = the octahedron, cols = N).
57
+ //
58
+ // The quad's OBJECT space must be the frame the bake ran in (+Y up), so it is a sibling of the level-0 root node but
59
+ // carries the inverse of their parent's world matrix as its local transform (identity under an identity parent) —
60
+ // not the level-0 node's own local transform, which in a Unity pack is the FBX's -90° X turn.
61
+ //
62
+ // The renderer is a plain software rasterizer: per frame every vertex is projected once, each triangle whose box
63
+ // meets the frame is scanned over its sample box with edge functions and a depth test, the alpha test taken at the
64
+ // sample; the surviving triangle id + barycentrics per sample are shaded once afterwards.
65
+
66
+ import { Logger, PropertyType, type Document, type Material, type Node as GltfNode, type Texture } from "@gltf-transform/core"
67
+ import { prune } from "@gltf-transform/functions"
68
+ import { decodeImage } from "../unity/image"
69
+
70
+ export type ImpostorOptions = {
71
+ /** Grid: the azimuths (A ≥ 3). Octahedron (rows 0): the frames a side (N ≥ 2). */
72
+ cols: number
73
+ /** Grid: the elevation rows (E ≥ 1); 0 = the octahedron of N = cols. */
74
+ rows: number
75
+ /** Pixels a frame's side (P, default 256 for a plant ≥ 8 m tall, 128 under), rounded to a multiple of 4. */
76
+ px?: number
77
+ /** The LOD level the quad takes (default min(2, highest existing + 1)). */
78
+ level?: number
79
+ log?: (s: string) => void
80
+ }
81
+ export type ImpostorResult = {
82
+ cols: number, rows: number, px: number, width: number, height: number
83
+ radius: number, center: [number, number, number], level: number, triangles: number, ms: number, node: string
84
+ }
85
+
86
+ /** "12×3 frames · 256 px (3072×768)" / "oct 8×8 frames · 128 px (1024×1024)" — the import's and the doctor's log. */
87
+ export const impostorLabel = (r: ImpostorResult): string =>
88
+ `${r.rows ? `${r.cols}×${r.rows}` : `oct ${r.cols}×${r.cols}`} frames · ${r.px} px (${r.width}×${r.height})`
89
+
90
+ const LOD_RE = /_LOD([1-9]\d*)$/i
91
+ const REPEAT = 10497, CLAMP = 33071, MIRROR = 33648
92
+ type Vec3 = [number, number, number]
93
+
94
+ /** uv in [0,1]² → a unit direction with y ≥ 0 (the shader's decode, line for line). */
95
+ export const octDecode = (u: number, v: number): Vec3 => {
96
+ const a = u * 2 - 1, b = v * 2 - 1
97
+ const x = (a + b) / 2, z = (a - b) / 2, y = 1 - Math.abs(x) - Math.abs(z)
98
+ const l = Math.hypot(x, y, z) || 1
99
+ return [ x / l, y / l, z / l ]
100
+ }
101
+
102
+ /** A direction (y ≥ 0) → uv in [0,1]². */
103
+ export const octEncode = (d: Vec3): [number, number] => {
104
+ const s = Math.abs(d[0]) + Math.abs(d[1]) + Math.abs(d[2]) || 1
105
+ const px = d[0] / s, pz = d[2] / s
106
+ return [ (px + pz) * 0.5 + 0.5, (px - pz) * 0.5 + 0.5 ]
107
+ }
108
+
109
+ /** A unit normal (any direction) → [0,1]² with the FULL-sphere octahedral mapping of the SWAPPED vector (x, z, y):
110
+ * the map's pole / seam is object -Y (the corners), +Y its centre — the data map's r, g (the shader's decode is
111
+ * `normalDecode`, line for line). sign(0) = +1. Not the frames' hemi `octEncode`. */
112
+ export const normalEncode = (n: Vec3): [number, number] => {
113
+ const s = Math.abs(n[0]) + Math.abs(n[1]) + Math.abs(n[2]) || 1
114
+ let px = n[0] / s, py = n[2] / s
115
+ if (n[1] / s < 0) {
116
+ const sx = px >= 0 ? 1 : -1, sy = py >= 0 ? 1 : -1
117
+ const ox = (1 - Math.abs(py)) * sx, oy = (1 - Math.abs(px)) * sy
118
+ px = ox; py = oy
119
+ }
120
+ return [ px * 0.5 + 0.5, py * 0.5 + 0.5 ]
121
+ }
122
+
123
+ /** [0,1]² → the unit normal `normalEncode` stored (the mapping's decode, then swapped back: (p.x, p.z, p.y)). */
124
+ export const normalDecode = (u: number, v: number): Vec3 => {
125
+ let x = u * 2 - 1, y = v * 2 - 1
126
+ const z = 1 - Math.abs(x) - Math.abs(y)
127
+ const t = Math.min(Math.max(-z, 0), 1)
128
+ x += x >= 0 ? -t : t
129
+ y += y >= 0 ? -t : t
130
+ const l = Math.hypot(x, y, z) || 1
131
+ return [ x / l, z / l, y / l ]
132
+ }
133
+
134
+ /** Screen right / up of the frame that looks along -d. */
135
+ export const frameBasis = (d: Vec3): { right: Vec3, up: Vec3 } => {
136
+ const ref: Vec3 = Math.abs(d[1]) < 0.999 ? [ 0, 1, 0 ] : [ 0, 0, -1 ]
137
+ let rx = ref[1] * d[2] - ref[2] * d[1], ry = ref[2] * d[0] - ref[0] * d[2], rz = ref[0] * d[1] - ref[1] * d[0]
138
+ const l = Math.hypot(rx, ry, rz) || 1
139
+ rx /= l; ry /= l; rz /= l
140
+ return { right: [ rx, ry, rz ], up: [ d[1] * rz - d[2] * ry, d[2] * rx - d[0] * rz, d[0] * ry - d[1] * rx ] }
141
+ }
142
+
143
+ /** Grid frame (i, k) of A azimuths × E rows → its unit direction (az 0 = from +Z, az 90° = from +X, row 0 = the horizon). */
144
+ export const gridDirection = (i: number, k: number, A: number, E: number): Vec3 => {
145
+ const az = i * 2 * Math.PI / A, el = k * (Math.PI / 2) / E
146
+ return [ Math.cos(el) * Math.sin(az), Math.sin(el), Math.cos(el) * Math.cos(az) ]
147
+ }
148
+
149
+ /** A direction → the grid's continuous frame coordinates (fa ∈ [0, A), fe ∈ [0, E-1]); the shader's encode, line for line. */
150
+ export const gridEncode = (d: Vec3, A: number, E: number): [number, number] => {
151
+ let az = Math.atan2(d[0], d[2])
152
+ if (az < 0) az += 2 * Math.PI
153
+ const el = Math.asin(Math.min(Math.max(d[1], 0), 1))
154
+ let fa = az / (2 * Math.PI) * A
155
+ if (fa >= A) fa -= A
156
+ return [ fa, Math.min(Math.max(el * E / (Math.PI / 2), 0), E - 1) ]
157
+ }
158
+
159
+ /** One frame of the atlas: column i, row k, the direction it looks from, its pixel rect [x0, x1) × [y0, y1). */
160
+ export type ImpostorFrame = { i: number, k: number, d: Vec3, x0: number, x1: number, y0: number, y1: number }
161
+ export type ImpostorLayout = { cols: number, rows: number, px: number, width: number, height: number, frames: ImpostorFrame[] }
162
+
163
+ const frame = (i: number, k: number, d: Vec3, P: number): ImpostorFrame => ({ i, k, d, x0: i * P, x1: (i + 1) * P, y0: k * P, y1: (k + 1) * P })
164
+
165
+ /** The grid layout: A azimuths × E elevation rows of P×P frames, an A·P × E·P atlas, row 0 = elevation 0 at the top. */
166
+ export const gridLayout = (A: number, E: number, P: number): ImpostorLayout => {
167
+ const frames: ImpostorFrame[] = []
168
+ for (let k = 0; k < E; k++) for (let i = 0; i < A; i++) frames.push(frame(i, k, gridDirection(i, k, A, E), P))
169
+ return { cols: A, rows: E, px: P, width: A * P, height: E * P, frames }
170
+ }
171
+
172
+ /** The hemi-octahedral layout: N×N frames of P×P, an N·P square atlas (rows 0 in the extras). */
173
+ export const octLayout = (N: number, P: number): ImpostorLayout => {
174
+ const frames: ImpostorFrame[] = []
175
+ for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) frames.push(frame(i, j, octDecode(i / (N - 1), j / (N - 1)), P))
176
+ return { cols: N, rows: 0, px: P, width: N * P, height: N * P, frames }
177
+ }
178
+
179
+ // ---- level-0 geometry ------------------------------------------------------------------------------------
180
+
181
+ type Slot = { mask: boolean, cull: boolean, cutoff: number, f: [number, number, number, number], img: Uint8Array | null, w: number, h: number, wrapS: number, wrapT: number, set: number }
182
+ type Level0 = {
183
+ pos: Float32Array, nrm: Float32Array, uv: Float32Array, ao: Float32Array
184
+ tri: Uint32Array, triSlot: Uint16Array, face: Float32Array, slots: Slot[]
185
+ min: Vec3, max: Vec3, root: GltfNode
186
+ }
187
+
188
+ const isLevel0 = (n: GltfNode): boolean => !!n.getMesh() && !LOD_RE.test(n.getName())
189
+
190
+ /** The level-0 AABB in the GLB's frame (null when there is no level-0 geometry) — what the import gates on. */
191
+ export const impostorBounds = (doc: Document): { min: Vec3, max: Vec3 } | null => {
192
+ const min: Vec3 = [ Infinity, Infinity, Infinity ], max: Vec3 = [ -Infinity, -Infinity, -Infinity ]
193
+ const v: number[] = []
194
+ for (const node of doc.getRoot().listNodes()) {
195
+ if (!isLevel0(node)) continue
196
+ const m = node.getWorldMatrix()
197
+ for (const p of node.getMesh()!.listPrimitives()) {
198
+ const pos = p.getAttribute("POSITION")
199
+ if (!pos || p.getMode() !== 4) continue
200
+ for (let i = 0; i < pos.getCount(); i++) {
201
+ pos.getElement(i, v)
202
+ const x = m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], y = m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13], z = m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]
203
+ if (x < min[0]) min[0] = x; if (y < min[1]) min[1] = y; if (z < min[2]) min[2] = z
204
+ if (x > max[0]) max[0] = x; if (y > max[1]) max[1] = y; if (z > max[2]) max[2] = z
205
+ }
206
+ }
207
+ }
208
+ return min[0] <= max[0] ? { min, max } : null
209
+ }
210
+
211
+ const makeSlot = async (mat: Material | null, images: Map<Texture, Promise<{ img: Uint8Array, w: number, h: number } | null>>, log: (s: string) => void): Promise<Slot> => {
212
+ const f = (mat?.getBaseColorFactor() ?? [ 1, 1, 1, 1 ]) as [number, number, number, number]
213
+ const mask = mat?.getAlphaMode() === "MASK"
214
+ const slot: Slot = { mask, cull: !mask && !mat?.getDoubleSided(), cutoff: mat ? mat.getAlphaCutoff() : 0.5, f: [ f[0], f[1], f[2], f[3] ], img: null, w: 0, h: 0, wrapS: REPEAT, wrapT: REPEAT, set: 0 }
215
+ const tex = mat?.getBaseColorTexture()
216
+ if (!mat || !tex) return slot
217
+ if ((tex.getMimeType() || "").includes("ktx2")) throw new Error(`impostor: the base colour of material "${mat.getName()}" is already KTX2 — bake impostors before the KTX2 encode (from the source textures)`)
218
+ const info = mat.getBaseColorTextureInfo()
219
+ slot.set = info?.getTexCoord() ?? 0
220
+ slot.wrapS = info?.getWrapS() ?? REPEAT
221
+ slot.wrapT = info?.getWrapT() ?? REPEAT
222
+ let job = images.get(tex)
223
+ if (!job) {
224
+ job = (async () => {
225
+ const bytes = tex.getImage()
226
+ const img = bytes ? await decodeImage(bytes, tex.getURI() || tex.getMimeType() || "texture.png") : null
227
+ return img ? { img: img.data, w: img.width, h: img.height } : null
228
+ })()
229
+ images.set(tex, job)
230
+ }
231
+ const img = await job
232
+ if (!img) { log(` ⚠ impostor: the base colour of "${mat.getName()}" could not be decoded — drawn as its factor, opaque`); slot.mask = false; return slot }
233
+ slot.img = img.img; slot.w = img.w; slot.h = img.h
234
+ return slot
235
+ }
236
+
237
+ const collectLevel0 = async (doc: Document, log: (s: string) => void): Promise<Level0 | null> => {
238
+ const pos: number[] = [], nrm: number[] = [], uv: number[] = [], ao: number[] = []
239
+ const tri: number[] = [], triSlot: number[] = [], face: number[] = []
240
+ const slots: Slot[] = [], slotOf = new Map<Material | null, number>()
241
+ const images = new Map<Texture, Promise<{ img: Uint8Array, w: number, h: number } | null>>()
242
+ const min: Vec3 = [ Infinity, Infinity, Infinity ], max: Vec3 = [ -Infinity, -Infinity, -Infinity ]
243
+ const trisOfNode = new Map<GltfNode, number>()
244
+ const v: number[] = []
245
+ for (const node of doc.getRoot().listNodes()) {
246
+ if (!isLevel0(node)) continue
247
+ const m = node.getWorldMatrix()
248
+ // normals through the cofactor of the 3×3 (det · inverse transpose: columns b×c, c×a, a×b), sign-corrected so a
249
+ // mirrored node keeps its normals' side; the same sign corrects the winding-derived face normal
250
+ const ax = m[0], ay = m[1], az = m[2], bx = m[4], by = m[5], bz = m[6], cx = m[8], cy = m[9], cz = m[10]
251
+ const det = ax * (by * cz - bz * cy) - bx * (ay * cz - az * cy) + cx * (ay * bz - az * by)
252
+ const sg = det < 0 ? -1 : 1
253
+ const n0 = [ (by * cz - bz * cy) * sg, (bz * cx - bx * cz) * sg, (bx * cy - by * cx) * sg ]
254
+ const n1 = [ (cy * az - cz * ay) * sg, (cz * ax - cx * az) * sg, (cx * ay - cy * ax) * sg ]
255
+ const n2 = [ (ay * bz - az * by) * sg, (az * bx - ax * bz) * sg, (ax * by - ay * bx) * sg ]
256
+ let nodeTris = 0
257
+ for (const p of node.getMesh()!.listPrimitives()) {
258
+ const P = p.getAttribute("POSITION")
259
+ if (!P || p.getMode() !== 4) continue
260
+ const mat = p.getMaterial()
261
+ let si = slotOf.get(mat)
262
+ if (si === undefined) { si = slots.length; slots.push(await makeSlot(mat, images, log)); slotOf.set(mat, si) }
263
+ const slot = slots[si]
264
+ const N = p.getAttribute("NORMAL"), T = p.getAttribute(`TEXCOORD_${slot.set}`), C = p.getAttribute("COLOR_0")
265
+ const base = pos.length / 3
266
+ const count = P.getCount()
267
+ for (let i = 0; i < count; i++) {
268
+ P.getElement(i, v)
269
+ const x = m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], y = m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13], z = m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]
270
+ pos.push(x, y, z)
271
+ if (x < min[0]) min[0] = x; if (y < min[1]) min[1] = y; if (z < min[2]) min[2] = z
272
+ if (x > max[0]) max[0] = x; if (y > max[1]) max[1] = y; if (z > max[2]) max[2] = z
273
+ if (N) {
274
+ N.getElement(i, v)
275
+ const nx = n0[0] * v[0] + n1[0] * v[1] + n2[0] * v[2], ny = n0[1] * v[0] + n1[1] * v[1] + n2[1] * v[2], nz = n0[2] * v[0] + n1[2] * v[1] + n2[2] * v[2]
276
+ const l = Math.hypot(nx, ny, nz) || 1
277
+ nrm.push(nx / l, ny / l, nz / l)
278
+ } else nrm.push(0, 0, 0) // no normals: the face normal stands in at shading
279
+ if (T) { T.getElement(i, v); uv.push(v[0], v[1]) } else uv.push(0, 0)
280
+ if (C) { C.getElement(i, v); ao.push(v[0]) } else ao.push(1)
281
+ }
282
+ const idx = p.getIndices()
283
+ const n = idx ? idx.getCount() : count
284
+ for (let k = 0; k + 2 < n; k += 3) {
285
+ const a = base + (idx ? idx.getScalar(k) : k), b = base + (idx ? idx.getScalar(k + 1) : k + 1), c = base + (idx ? idx.getScalar(k + 2) : k + 2)
286
+ tri.push(a, b, c); triSlot.push(si)
287
+ const e1x = pos[b * 3] - pos[a * 3], e1y = pos[b * 3 + 1] - pos[a * 3 + 1], e1z = pos[b * 3 + 2] - pos[a * 3 + 2]
288
+ const e2x = pos[c * 3] - pos[a * 3], e2y = pos[c * 3 + 1] - pos[a * 3 + 1], e2z = pos[c * 3 + 2] - pos[a * 3 + 2]
289
+ const fx = (e1y * e2z - e1z * e2y) * sg, fy = (e1z * e2x - e1x * e2z) * sg, fz = (e1x * e2y - e1y * e2x) * sg
290
+ const fl = Math.hypot(fx, fy, fz) || 1
291
+ face.push(fx / fl, fy / fl, fz / fl)
292
+ nodeTris++
293
+ }
294
+ }
295
+ trisOfNode.set(node, nodeTris)
296
+ }
297
+ if (!tri.length) return null
298
+ // the root the quad is named after: a level-0 node with no level-0 ancestor, the one with the most triangles
299
+ let root: GltfNode | null = null, best = -1
300
+ for (const [ node, n ] of trisOfNode) {
301
+ let nested = false
302
+ for (let a = node.getParentNode(); a; a = a.getParentNode()) if (trisOfNode.has(a)) { nested = true; break }
303
+ if (!nested && n > best) { best = n; root = node }
304
+ }
305
+ return {
306
+ pos: Float32Array.from(pos), nrm: Float32Array.from(nrm), uv: Float32Array.from(uv), ao: Float32Array.from(ao),
307
+ tri: Uint32Array.from(tri), triSlot: Uint16Array.from(triSlot), face: Float32Array.from(face), slots, min, max, root: root!,
308
+ }
309
+ }
310
+
311
+ // ---- texture sampling -------------------------------------------------------------------------------------
312
+
313
+ const SRGB_TO_LINEAR = Float32Array.from({ length: 256 }, (_, i) => { const c = i / 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) })
314
+ const toSrgb8 = (x: number): number => {
315
+ const c = x <= 0 ? 0 : x >= 1 ? 1 : x <= 0.0031308 ? x * 12.92 : 1.055 * Math.pow(x, 1 / 2.4) - 0.055
316
+ return Math.round(c * 255)
317
+ }
318
+ const wrapI = (i: number, n: number, mode: number): number => {
319
+ if (mode === CLAMP) return i < 0 ? 0 : i >= n ? n - 1 : i
320
+ if (mode === MIRROR) { const p = 2 * n, k = ((i % p) + p) % p; return k < n ? k : p - 1 - k }
321
+ return ((i % n) + n) % n
322
+ }
323
+ // bilinear taps of the last `taps` call: byte offsets + weights (module scratch: the bake is synchronous per sample)
324
+ const tapI = new Int32Array(4), tapW = new Float32Array(4)
325
+ const taps = (s: Slot, u: number, v: number): void => {
326
+ const fx = u * s.w - 0.5, fy = v * s.h - 0.5
327
+ const x0 = Math.floor(fx), y0 = Math.floor(fy), tx = fx - x0, ty = fy - y0
328
+ const xa = wrapI(x0, s.w, s.wrapS), xb = wrapI(x0 + 1, s.w, s.wrapS), ya = wrapI(y0, s.h, s.wrapT), yb = wrapI(y0 + 1, s.h, s.wrapT)
329
+ tapI[0] = (ya * s.w + xa) * 4; tapI[1] = (ya * s.w + xb) * 4; tapI[2] = (yb * s.w + xa) * 4; tapI[3] = (yb * s.w + xb) * 4
330
+ tapW[0] = (1 - tx) * (1 - ty); tapW[1] = tx * (1 - ty); tapW[2] = (1 - tx) * ty; tapW[3] = tx * ty
331
+ }
332
+ const alphaAt = (s: Slot, u: number, v: number): number => {
333
+ if (!s.img) return s.f[3]
334
+ taps(s, u, v)
335
+ const d = s.img
336
+ return (d[tapI[0] + 3] * tapW[0] + d[tapI[1] + 3] * tapW[1] + d[tapI[2] + 3] * tapW[2] + d[tapI[3] + 3] * tapW[3]) / 255 * s.f[3]
337
+ }
338
+
339
+ // ---- dilation ------------------------------------------------------------------------------------------------
340
+
341
+ const DILATE_PASSES = 16
342
+
343
+ /** Fill the empty texels' colour (base rgb) and data (all four) from their filled 8-neighbours in the same frame,
344
+ * pass by pass; whatever is still empty after the passes takes its frame's mean (the mips' last levels). */
345
+ const dilate = (base: Uint8Array, data: Uint8Array, covered: Uint8Array, W: number, H: number, frameOf: Int32Array, nFrames: number): void => {
346
+ let cur = covered.slice()
347
+ for (let pass = 0; pass < DILATE_PASSES; pass++) {
348
+ const next = cur.slice()
349
+ let changed = 0
350
+ for (let y = 0; y < H; y++) {
351
+ for (let x = 0; x < W; x++) {
352
+ const i = y * W + x
353
+ if (cur[i]) continue
354
+ const f = frameOf[i]
355
+ let n = 0, r = 0, g = 0, b = 0, d0 = 0, d1 = 0, d2 = 0, d3 = 0
356
+ for (let oy = -1; oy <= 1; oy++) {
357
+ const yy = y + oy
358
+ if (yy < 0 || yy >= H) continue
359
+ for (let ox = -1; ox <= 1; ox++) {
360
+ const xx = x + ox
361
+ if (xx < 0 || xx >= W) continue
362
+ const k = yy * W + xx
363
+ if (!cur[k] || frameOf[k] !== f) continue
364
+ const o = k * 4
365
+ r += base[o]; g += base[o + 1]; b += base[o + 2]
366
+ d0 += data[o]; d1 += data[o + 1]; d2 += data[o + 2]; d3 += data[o + 3]
367
+ n++
368
+ }
369
+ }
370
+ if (!n) continue
371
+ const o = i * 4
372
+ base[o] = Math.round(r / n); base[o + 1] = Math.round(g / n); base[o + 2] = Math.round(b / n)
373
+ data[o] = Math.round(d0 / n); data[o + 1] = Math.round(d1 / n); data[o + 2] = Math.round(d2 / n); data[o + 3] = Math.round(d3 / n)
374
+ next[i] = 1
375
+ changed++
376
+ }
377
+ }
378
+ cur = next
379
+ if (!changed) break
380
+ }
381
+ // the rest: the frame's mean over its covered texels (the whole atlas' when the frame saw nothing)
382
+ // (slot nFrames = the whole atlas; a texel outside every frame - none in today's layouts - takes the atlas' mean)
383
+ const sums = new Float64Array((nFrames + 1) * 8)
384
+ const all = nFrames * 8
385
+ for (let i = 0; i < W * H; i++) {
386
+ if (!covered[i]) continue
387
+ const o = i * 4
388
+ for (const s of frameOf[i] >= 0 ? [ frameOf[i] * 8, all ] : [ all ]) {
389
+ sums[s] += base[o]; sums[s + 1] += base[o + 1]; sums[s + 2] += base[o + 2]
390
+ sums[s + 3] += data[o]; sums[s + 4] += data[o + 1]; sums[s + 5] += data[o + 2]; sums[s + 6] += data[o + 3]; sums[s + 7]++
391
+ }
392
+ }
393
+ for (let i = 0; i < W * H; i++) {
394
+ if (cur[i]) continue
395
+ let s = frameOf[i] >= 0 ? frameOf[i] * 8 : all
396
+ if (!sums[s + 7]) s = all
397
+ const n = sums[s + 7], o = i * 4
398
+ if (!n) { base[o] = base[o + 1] = base[o + 2] = 128; data[o] = data[o + 1] = data[o + 2] = 128; data[o + 3] = 255; continue }
399
+ base[o] = Math.round(sums[s] / n); base[o + 1] = Math.round(sums[s + 1] / n); base[o + 2] = Math.round(sums[s + 2] / n)
400
+ data[o] = Math.round(sums[s + 3] / n); data[o + 1] = Math.round(sums[s + 4] / n); data[o + 2] = Math.round(sums[s + 5] / n); data[o + 3] = Math.round(sums[s + 6] / n)
401
+ }
402
+ }
403
+
404
+ // ---- the bake ------------------------------------------------------------------------------------------------
405
+
406
+ const invert4 = (m: ArrayLike<number>): number[] | null => {
407
+ const [ a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33 ] = Array.from(m)
408
+ const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10, b03 = a01 * a12 - a02 * a11
409
+ const b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12, b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30
410
+ const b08 = a20 * a33 - a23 * a30, b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32
411
+ let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06
412
+ if (Math.abs(det) < 1e-12) return null
413
+ det = 1 / det
414
+ return [
415
+ (a11 * b11 - a12 * b10 + a13 * b09) * det, (a02 * b10 - a01 * b11 - a03 * b09) * det, (a31 * b05 - a32 * b04 + a33 * b03) * det, (a22 * b04 - a21 * b05 - a23 * b03) * det,
416
+ (a12 * b08 - a10 * b11 - a13 * b07) * det, (a00 * b11 - a02 * b08 + a03 * b07) * det, (a32 * b02 - a30 * b05 - a33 * b01) * det, (a20 * b05 - a22 * b02 + a23 * b01) * det,
417
+ (a10 * b10 - a11 * b08 + a13 * b06) * det, (a01 * b08 - a00 * b10 - a03 * b06) * det, (a30 * b04 - a31 * b02 + a33 * b00) * det, (a21 * b02 - a20 * b04 - a23 * b00) * det,
418
+ (a11 * b07 - a10 * b09 - a12 * b06) * det, (a00 * b09 - a01 * b07 + a02 * b06) * det, (a31 * b01 - a30 * b03 - a32 * b00) * det, (a20 * b03 - a21 * b01 + a22 * b00) * det,
419
+ ]
420
+ }
421
+
422
+ const encodePng = async (rgba: Uint8Array, w: number, h: number): Promise<Uint8Array> => {
423
+ const sharp = (await import("sharp")).default
424
+ return new Uint8Array(await sharp(Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength), { raw: { width: w, height: h, channels: 4 } }).png().toBuffer())
425
+ }
426
+
427
+ /**
428
+ * Bake the document's level-0 geometry into an impostor atlas (the grid of `cols` × `rows`, or the octahedron of
429
+ * N = `cols` when `rows` is 0) and add the quad as its last LOD level (see the header). Null when there is no
430
+ * level-0 triangle. Throws when a level-0 base texture is already KTX2.
431
+ */
432
+ export const bakeImpostor = async (doc: Document, opts: ImpostorOptions): Promise<ImpostorResult | null> => {
433
+ const t0 = performance.now()
434
+ const log = opts.log ?? ((s: string) => console.log(s))
435
+ const g = await collectLevel0(doc, log)
436
+ if (!g) return null
437
+ const { pos, nrm, uv, ao, tri, triSlot, face, slots } = g
438
+ const nv = pos.length / 3, nt = tri.length / 3
439
+ const c: Vec3 = [ (g.min[0] + g.max[0]) / 2, (g.min[1] + g.max[1]) / 2, (g.min[2] + g.max[2]) / 2 ]
440
+ let r2 = 0
441
+ for (let v = 0; v < nv; v++) {
442
+ const dx = pos[v * 3] - c[0], dy = pos[v * 3 + 1] - c[1], dz = pos[v * 3 + 2] - c[2]
443
+ const d = dx * dx + dy * dy + dz * dz
444
+ if (d > r2) r2 = d
445
+ }
446
+ const r = Math.sqrt(r2) || 1e-3
447
+ const height = g.max[1] - g.min[1]
448
+ const P = Math.max(4, Math.round((opts.px ?? (height >= 8 ? 256 : 128)) / 4) * 4)
449
+ const layout = opts.rows > 0
450
+ ? gridLayout(Math.max(3, Math.round(opts.cols)), Math.max(1, Math.round(opts.rows)), P)
451
+ : octLayout(Math.max(2, Math.round(opts.cols)), P)
452
+ const { width: AW, height: AH, frames } = layout
453
+ // the crown's centre the cards' hull normal radiates from: 0.6 of the plant's height over its ORIGIN (foliage-wind.vs
454
+ // `crown = origin + (0, 0.6 × plant.x, 0)`, plant.x = the asset box's height - glb.cpp foliageBindAsset)
455
+ const crown: Vec3 = [ 0, 0.6 * height, 0 ]
456
+ // texel → its frame's index in the list (-1 = none): the dilation never crosses a frame's edge
457
+ const frameOf = new Int32Array(AW * AH).fill(-1)
458
+ let span = 0
459
+ frames.forEach((f, fi) => {
460
+ span = Math.max(span, f.x1 - f.x0, f.y1 - f.y0)
461
+ for (let y = f.y0; y < f.y1; y++) frameOf.fill(fi, y * AW + f.x0, y * AW + f.x1)
462
+ })
463
+
464
+ const base = new Uint8Array(AW * AH * 4), data = new Uint8Array(AW * AH * 4), covered = new Uint8Array(AW * AH)
465
+ const samples = span * span * 4
466
+ const zb = new Float32Array(samples), tb = new Int32Array(samples), l1b = new Float32Array(samples), l2b = new Float32Array(samples)
467
+ const qx = new Float32Array(nv), qy = new Float32Array(nv), qz = new Float32Array(nv)
468
+
469
+ for (const fr of frames) {
470
+ const d = fr.d
471
+ const { right, up } = frameBasis(d)
472
+ const X0 = fr.x0, X1 = fr.x1, Y0 = fr.y0, Y1 = fr.y1
473
+ const W = (X1 - X0) * 2, H = (Y1 - Y0) * 2
474
+ // every vertex into this frame's sample grid (sample k centred at k + 0.5), qz = toward the camera; the frame's
475
+ // local (a, b) spans its rect: x = x0 + a·(x1 - x0), y = y0 + (1 - b)·(y1 - y0)
476
+ const k = 0.5 / r
477
+ for (let v = 0; v < nv; v++) {
478
+ const dx = pos[v * 3] - c[0], dy = pos[v * 3 + 1] - c[1], dz = pos[v * 3 + 2] - c[2]
479
+ const a = 0.5 + k * (dx * right[0] + dy * right[1] + dz * right[2])
480
+ const b = 0.5 + k * (dx * up[0] + dy * up[1] + dz * up[2])
481
+ qx[v] = a * W
482
+ qy[v] = (1 - b) * H
483
+ qz[v] = dx * d[0] + dy * d[1] + dz * d[2]
484
+ }
485
+ zb.fill(-Infinity, 0, W * H); tb.fill(-1, 0, W * H)
486
+ for (let t = 0; t < nt; t++) {
487
+ const ia = tri[t * 3], ib = tri[t * 3 + 1], ic = tri[t * 3 + 2]
488
+ const x0 = qx[ia], y0 = qy[ia], x1 = qx[ib], y1 = qy[ib], x2 = qx[ic], y2 = qy[ic]
489
+ const minX = Math.max(0, Math.ceil(Math.min(x0, x1, x2) - 0.5)), maxX = Math.min(W - 1, Math.floor(Math.max(x0, x1, x2) - 0.5))
490
+ if (minX > maxX) continue
491
+ const minY = Math.max(0, Math.ceil(Math.min(y0, y1, y2) - 0.5)), maxY = Math.min(H - 1, Math.floor(Math.max(y0, y1, y2) - 0.5))
492
+ if (minY > maxY) continue
493
+ const area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0)
494
+ if (Math.abs(area) < 1e-12) continue
495
+ const slot = slots[triSlot[t]]
496
+ if (slot.cull && face[t * 3] * d[0] + face[t * 3 + 1] * d[1] + face[t * 3 + 2] * d[2] < 0) continue
497
+ const inv = 1 / area
498
+ const z0 = qz[ia], dz1 = qz[ib] - z0, dz2 = qz[ic] - z0
499
+ const testAlpha = slot.mask
500
+ const u0 = uv[ia * 2], v0 = uv[ia * 2 + 1], du1 = uv[ib * 2] - u0, dv1 = uv[ib * 2 + 1] - v0, du2 = uv[ic * 2] - u0, dv2 = uv[ic * 2 + 1] - v0
501
+ // barycentrics of v1 / v2 (l1, l2) step linearly along a row
502
+ const s1x = (y2 - y0) * inv, s2x = -(y1 - y0) * inv
503
+ for (let sy = minY; sy <= maxY; sy++) {
504
+ const py = sy + 0.5 - y0, px = minX + 0.5 - x0
505
+ let l1 = (px * (y2 - y0) - py * (x2 - x0)) * inv
506
+ let l2 = ((x1 - x0) * py - (y1 - y0) * px) * inv
507
+ const row = sy * W
508
+ for (let sx = minX; sx <= maxX; sx++, l1 += s1x, l2 += s2x) {
509
+ if (l1 < -1e-7 || l2 < -1e-7 || l1 + l2 > 1 + 1e-7) continue
510
+ const z = z0 + l1 * dz1 + l2 * dz2
511
+ const s = row + sx
512
+ if (z <= zb[s]) continue
513
+ if (testAlpha && alphaAt(slot, u0 + l1 * du1 + l2 * du2, v0 + l1 * dv1 + l2 * dv2) < slot.cutoff) continue
514
+ zb[s] = z; tb[s] = t; l1b[s] = l1; l2b[s] = l2
515
+ }
516
+ }
517
+ }
518
+ // resolve: each texel's four samples shaded from their triangle + barycentrics
519
+ for (let y = Y0; y < Y1; y++) {
520
+ for (let x = X0; x < X1; x++) {
521
+ let n = 0, cr = 0, cg = 0, cb = 0, nx = 0, ny = 0, nz = 0, aoSum = 0, zSum = 0
522
+ for (let q = 0; q < 4; q++) {
523
+ const s = ((y - Y0) * 2 + (q >> 1)) * W + (x - X0) * 2 + (q & 1)
524
+ const t = tb[s]
525
+ if (t < 0) continue
526
+ zSum += zb[s] // dot(p - c, d) of the surviving sample's surface point
527
+ const l1 = l1b[s], l2 = l2b[s], l0 = 1 - l1 - l2
528
+ const ia = tri[t * 3], ib = tri[t * 3 + 1], ic = tri[t * 3 + 2]
529
+ // the hull normal at the surface point (the cards' shading normal, see the header), never flipped
530
+ const mx = pos[ia * 3] * l0 + pos[ib * 3] * l1 + pos[ic * 3] * l2 - crown[0]
531
+ const my = pos[ia * 3 + 1] * l0 + pos[ib * 3 + 1] * l1 + pos[ic * 3 + 1] * l2 - crown[1]
532
+ const mz = pos[ia * 3 + 2] * l0 + pos[ib * 3 + 2] * l1 + pos[ic * 3 + 2] * l2 - crown[2]
533
+ const ml = Math.hypot(mx, my, mz) || 1
534
+ nx += mx / ml; ny += my / ml; nz += mz / ml
535
+ const slot = slots[triSlot[t]]
536
+ if (slot.img) {
537
+ taps(slot, uv[ia * 2] * l0 + uv[ib * 2] * l1 + uv[ic * 2] * l2, uv[ia * 2 + 1] * l0 + uv[ib * 2 + 1] * l1 + uv[ic * 2 + 1] * l2)
538
+ const im = slot.img
539
+ let r0 = 0, g0 = 0, b0 = 0
540
+ for (let k4 = 0; k4 < 4; k4++) { const o = tapI[k4], w = tapW[k4]; r0 += SRGB_TO_LINEAR[im[o]] * w; g0 += SRGB_TO_LINEAR[im[o + 1]] * w; b0 += SRGB_TO_LINEAR[im[o + 2]] * w }
541
+ cr += r0 * slot.f[0]; cg += g0 * slot.f[1]; cb += b0 * slot.f[2]
542
+ } else { cr += slot.f[0]; cg += slot.f[1]; cb += slot.f[2] }
543
+ aoSum += ao[ia] * l0 + ao[ib] * l1 + ao[ic] * l2
544
+ n++
545
+ }
546
+ if (!n) continue
547
+ const o = (y * AW + x) * 4
548
+ covered[y * AW + x] = 1
549
+ base[o] = toSrgb8(cr / n); base[o + 1] = toSrgb8(cg / n); base[o + 2] = toSrgb8(cb / n); base[o + 3] = Math.round(n / 4 * 255)
550
+ // r, g = the mean hull normal in OBJECT space, full-sphere octahedral; b = the mean depth dot(p - c, d) / r
551
+ const nl = Math.hypot(nx, ny, nz) || 1
552
+ const [ eu, ev ] = normalEncode([ nx / nl, ny / nl, nz / nl ])
553
+ const depth = zSum / n / r
554
+ data[o] = Math.round(Math.min(Math.max(eu, 0), 1) * 255)
555
+ data[o + 1] = Math.round(Math.min(Math.max(ev, 0), 1) * 255)
556
+ data[o + 2] = Math.round(Math.min(Math.max(depth * 0.5 + 0.5, 0), 1) * 255)
557
+ data[o + 3] = Math.round(Math.min(Math.max(aoSum / n, 0), 1) * 255)
558
+ }
559
+ }
560
+ }
561
+ dilate(base, data, covered, AW, AH, frameOf, frames.length)
562
+
563
+ // ---- the quad: the levels it replaces go, then the node --------------------------------------------------
564
+ const root = g.root
565
+ const rootName = root.getName() || root.getMesh()?.getName() || "impostor"
566
+ let highest = 0
567
+ for (const n of doc.getRoot().listNodes()) { const m = LOD_RE.exec(n.getName()); if (m) highest = Math.max(highest, Number(m[1])) }
568
+ // `level` (--impostor-level) picks the slot - and so the LOD pass's threshold (0.30 / 0.12 / 0.05 for 1 / 2 / 3) the quad
569
+ // shows at - never past the first gap: the pass hides every level at an index nothing fills
570
+ const level = Math.min(Math.max(1, Math.round(opts.level ?? 2)), highest + 1)
571
+ const drop = (n: GltfNode): void => { for (const ch of n.listChildren()) drop(ch); n.dispose() }
572
+ for (const n of doc.getRoot().listNodes()) { const m = LOD_RE.exec(n.getName()); if (m && Number(m[1]) >= level && !n.isDisposed()) drop(n) }
573
+
574
+ const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer()
575
+ // tagged: transcodeKtx2.ts planKtx2 never resizes them by --max-size (a 3072 px strip squashed is a broken layout)
576
+ const tag = () => ({ lecodes: { impostor: true } })
577
+ const albedo = doc.createTexture(`${rootName}_impostor_albedo`).setMimeType("image/png").setImage(await encodePng(base, AW, AH)).setExtras(tag())
578
+ const normalDepth = doc.createTexture(`${rootName}_impostor_data`).setMimeType("image/png").setImage(await encodePng(data, AW, AH)).setExtras(tag())
579
+ const material = doc.createMaterial(`${rootName}_impostor`)
580
+ .setAlphaMode("MASK").setAlphaCutoff(0.5).setDoubleSided(true).setMetallicFactor(0).setRoughnessFactor(1).setBaseColorFactor([ 1, 1, 1, 1 ])
581
+ .setBaseColorTexture(albedo).setMetallicRoughnessTexture(normalDepth)
582
+ .setExtras({ lecodes: { impostor: [ layout.cols, c[0], c[1], c[2], r, layout.rows ] } })
583
+ // clamped: a frame on the atlas border must not wrap into the opposite one
584
+ for (const info of [ material.getBaseColorTextureInfo(), material.getMetallicRoughnessTextureInfo() ]) info?.setWrapS(CLAMP).setWrapT(CLAMP)
585
+ const acc = (type: "VEC2" | "VEC3" | "VEC4" | "SCALAR", arr: Float32Array<ArrayBuffer> | Uint16Array<ArrayBuffer>) => doc.createAccessor().setType(type).setArray(arr).setBuffer(buffer)
586
+ const corners = new Float32Array([ -1, -1, -1, 1, -1, 1, 1, 1, -1, -1, 1, 1 ]).map((s, k) => c[k % 3] + s * r)
587
+ const prim = doc.createPrimitive().setMaterial(material)
588
+ .setAttribute("POSITION", acc("VEC3", corners))
589
+ .setAttribute("NORMAL", acc("VEC3", new Float32Array([ 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1 ])))
590
+ .setAttribute("TEXCOORD_0", acc("VEC2", new Float32Array([ 0, 0, 1, 0, 1, 1, 0, 1 ])))
591
+ .setAttribute("COLOR_0", acc("VEC4", new Float32Array(16).fill(1)))
592
+ .setIndices(acc("SCALAR", new Uint16Array([ 0, 1, 2, 0, 2, 3 ])))
593
+ const node = doc.createNode(`${rootName}_LOD${level}`).setMesh(doc.createMesh(`${rootName}_impostor`).addPrimitive(prim))
594
+ .setExtras({ lecodes: { impostor: { cols: layout.cols, rows: layout.rows, px: P } } })
595
+ const parent = root.getParentNode()
596
+ if (parent) {
597
+ parent.addChild(node)
598
+ const pw = parent.getWorldMatrix()
599
+ const identity = pw.every((x, k) => Math.abs(x - (k % 5 === 0 ? 1 : 0)) < 1e-6)
600
+ const inv = identity ? null : invert4(pw)
601
+ if (inv) node.setMatrix(inv as unknown as Parameters<GltfNode["setMatrix"]>[0])
602
+ } else {
603
+ for (const s of doc.getRoot().listScenes()) if (s.listChildren().includes(root)) s.addChild(node)
604
+ }
605
+ // quiet: prune logs every removal at INFO, once per kind of an import
606
+ const logger = doc.getLogger()
607
+ doc.setLogger(new Logger(Logger.Verbosity.SILENT))
608
+ await doc.transform(prune({
609
+ propertyTypes: [ PropertyType.MESH, PropertyType.PRIMITIVE, PropertyType.MATERIAL, PropertyType.TEXTURE, PropertyType.ACCESSOR ],
610
+ keepLeaves: true, keepAttributes: true, keepIndices: true, keepSolidTextures: true, keepExtras: false,
611
+ }))
612
+ doc.setLogger(logger)
613
+ return { cols: layout.cols, rows: layout.rows, px: P, width: AW, height: AH, radius: r, center: c, level, triangles: nt, ms: Math.round(performance.now() - t0), node: node.getName() }
614
+ }
@@ -0,0 +1,30 @@
1
+ // Accessor reads as flat typed arrays.
2
+
3
+ import type { Accessor, Primitive } from '@gltf-transform/core'
4
+
5
+ export const readAttr = (acc: Accessor): { size: number; data: Float32Array } => {
6
+ const size = acc.getElementSize(), n = acc.getCount()
7
+ const data = new Float32Array(n * size)
8
+ const el = new Array<number>(size)
9
+ for (let i = 0; i < n; i++) { acc.getElement(i, el); for (let k = 0; k < size; k++) data[i * size + k] = el[k] }
10
+ return { size, data }
11
+ }
12
+ export const readIndices = (p: Primitive): Uint32Array => {
13
+ const idx = p.getIndices()
14
+ if (idx) return Uint32Array.from(idx.getArray() as ArrayLike<number>)
15
+ const n = p.getAttribute('POSITION')!.getCount()
16
+ return Uint32Array.from({ length: n }, (_, i) => i)
17
+ }
18
+ /** Area-weighted vertex normals for a primitive that has none. */
19
+ export function flatNormals(pos: Float32Array, idx: Uint32Array): Float32Array {
20
+ const n = new Float32Array(pos.length)
21
+ for (let t = 0; t + 2 < idx.length; t += 3) {
22
+ const a = idx[t] * 3, b = idx[t + 1] * 3, c = idx[t + 2] * 3
23
+ const ux = pos[b] - pos[a], uy = pos[b + 1] - pos[a + 1], uz = pos[b + 2] - pos[a + 2]
24
+ const vx = pos[c] - pos[a], vy = pos[c + 1] - pos[a + 1], vz = pos[c + 2] - pos[a + 2]
25
+ const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
26
+ for (const i of [ a, b, c ]) { n[i] += nx; n[i + 1] += ny; n[i + 2] += nz }
27
+ }
28
+ for (let i = 0; i < n.length; i += 3) { const l = Math.hypot(n[i], n[i + 1], n[i + 2]) || 1; n[i] /= l; n[i + 1] /= l; n[i + 2] /= l }
29
+ return n
30
+ }