lecodes-assets 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,9 +31,12 @@
31
31
  // elevation 0 at the top of the image). A point p lands at uv ((i + a) / C, (k + 1 - b) / R),
32
32
  // a = 0.5 + 0.5·dot(p-c, right)/r, b = the same on up (a right, b UP).
33
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):
34
+ // --max-size (all three carry the texture extras `lecodes.impostor = true`; a 12 × 256 = 3072 px strip stays):
35
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
36
+ // (FRACTIONAL: covered samples / S²)
37
+ // metallicRoughnessTexture r, g = the shading normal in OBJECT space (no frame basis): a card sample's
38
+ // the crown's HULL normal, a bark sample's the MESH normal (the interpolated
39
+ // vertex NORMAL, sign-corrected, turned toward the frame's viewer); packed with the
37
40
  // FULL-sphere octahedral map normalEncode / normalDecode below (the far side's
38
41
  // and the crown's underside normals included) - not the frames' hemi octEncode;
39
42
  // the map's seam pole (its four corners) is object -Y, a direction no frame looks
@@ -43,8 +46,18 @@
43
46
  // point nearest this frame's viewer, 0.5 = the quad plane through c, 0 = the
44
47
  // farthest) - the shader self-shadows the quad from the frame nearest the sun
45
48
  // 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
49
+ // occlusionTexture r = the BARK fraction (bark samples / covered samples: an OPAQUE slot is bark,
50
+ // a MASK slot a card), g = 0, b = the BARK's OCCLUSION (the mean over the texel's
51
+ // bark samples of what foliage.mat darkens the bark's ambient by beyond the sky
52
+ // visibility: the baked cavity COLOR_0.g x the material's occlusion map at its
53
+ // strength x the normal map's own cavity ½ + ½·clamp(n.z); never 0 - 1/255 at
54
+ // least - so b = 0 says "an atlas before this channel" and the shader reads 1),
55
+ // a = 1 - linear data, not occlusion. g = 0 marks the map as present: the
56
+ // engine's default for a missing map is white (g = 1) and the shader reads
57
+ // bark = r·(1 - g), so an older atlas reads no bark.
58
+ // S×S supersamples a texel (S = `samples`, default 8: coverage = covered / S², data = mean of the covered
59
+ // ones: the normal averaged as a vector then encoded, the depth averaged), then the colour, data and bark
60
+ // are DILATED into the empty
48
61
  // texels of the same frame so bilinear / mips never pull black in (a depth read just outside a
49
62
  // silhouette continues the surface; the albedo's alpha there says "no occluder").
50
63
  // quad node `<level-0 root name>_LOD<k>`, k = min(2, highest existing level + 1); every `_LOD<n>` n ≥ k goes
@@ -76,16 +89,19 @@ export type ImpostorOptions = {
76
89
  px?: number
77
90
  /** The LOD level the quad takes (default min(2, highest existing + 1)). */
78
91
  level?: number
92
+ /** Supersamples a texel's SIDE (S: S×S samples, default IMPOSTOR_SAMPLES = 8): the coverage is covered / S² — a porous
93
+ * crown keeps its holes as a fraction (foliage-impostor.mat cuts it at the mask; alphaToCoverage takes it under MSAA). */
94
+ samples?: number
79
95
  log?: (s: string) => void
80
96
  }
81
97
  export type ImpostorResult = {
82
98
  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
99
+ radius: number, center: [number, number, number], level: number, triangles: number, ms: number, node: string, samples: number
84
100
  }
85
101
 
86
102
  /** "12×3 frames · 256 px (3072×768)" / "oct 8×8 frames · 128 px (1024×1024)" — the import's and the doctor's log. */
87
103
  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})`
104
+ `${r.rows ? `${r.cols}×${r.rows}` : `oct ${r.cols}×${r.cols}`} frames · ${r.px} px (${r.width}×${r.height}) · ${r.samples}×${r.samples} samples`
89
105
 
90
106
  const LOD_RE = /_LOD([1-9]\d*)$/i
91
107
  const REPEAT = 10497, CLAMP = 33071, MIRROR = 33648
@@ -131,6 +147,45 @@ export const normalDecode = (u: number, v: number): Vec3 => {
131
147
  return [ x / l, z / l, y / l ]
132
148
  }
133
149
 
150
+ /** THE DATA MAP'S MIPS (transcodeKtx2.ts hands them to the encoder as explicit levels): a box mip of the ENCODED
151
+ * octahedral normal is wrong - the map's seam pole is -Y at the corners and +Y its centre, so averaging two
152
+ * downward normals' coordinates lands near the centre, i.e. UP. Measured on a Chernobyl aspen (2026-11-04): the
153
+ * lower crown's normals read n.y -0.40 at level 0, -0.20 after a 4x4 box of the map, -0.11 after 8x8 - and the
154
+ * quad, reading that mip under an overcast sky, lit its crown 22 % brighter than the cards. Here every level's r, g
155
+ * is the encoded mean of the DECODED normals of its source texels (normalised), b (depth) and a (AO) the plain mean.
156
+ * Level 0 is the source; the levels halve to 1x1 (an odd edge takes its last texel into the previous block). */
157
+ export type DataLevel = { data: Uint8Array, width: number, height: number }
158
+ export const impostorDataMipChain = (src: Uint8Array, sw: number, sh: number): DataLevel[] => {
159
+ const levels: DataLevel[] = [ { data: src, width: sw, height: sh } ]
160
+ let cur = levels[0]
161
+ while (cur.width > 1 || cur.height > 1) {
162
+ const w = Math.max(1, cur.width >> 1), h = Math.max(1, cur.height >> 1)
163
+ const out = new Uint8Array(w * h * 4)
164
+ const fx = cur.width / w, fy = cur.height / h
165
+ for (let y = 0; y < h; y++) {
166
+ const y0 = Math.floor(y * fy), y1 = y === h - 1 ? cur.height : Math.floor((y + 1) * fy)
167
+ for (let x = 0; x < w; x++) {
168
+ const x0 = Math.floor(x * fx), x1 = x === w - 1 ? cur.width : Math.floor((x + 1) * fx)
169
+ let nx = 0, ny = 0, nz = 0, b = 0, a = 0, n = 0
170
+ for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) {
171
+ const o = (yy * cur.width + xx) * 4
172
+ const v = normalDecode(cur.data[o] / 255, cur.data[o + 1] / 255)
173
+ nx += v[0]; ny += v[1]; nz += v[2]; b += cur.data[o + 2]; a += cur.data[o + 3]; n++
174
+ }
175
+ const l = Math.hypot(nx, ny, nz)
176
+ const [ eu, ev ] = l > 1e-9 ? normalEncode([ nx / l, ny / l, nz / l ]) : [ 0.5, 0.5 ]
177
+ const o = (y * w + x) * 4
178
+ out[o] = Math.round(Math.min(Math.max(eu, 0), 1) * 255)
179
+ out[o + 1] = Math.round(Math.min(Math.max(ev, 0), 1) * 255)
180
+ out[o + 2] = Math.round(b / n); out[o + 3] = Math.round(a / n)
181
+ }
182
+ }
183
+ cur = { data: out, width: w, height: h }
184
+ levels.push(cur)
185
+ }
186
+ return levels
187
+ }
188
+
134
189
  /** Screen right / up of the frame that looks along -d. */
135
190
  export const frameBasis = (d: Vec3): { right: Vec3, up: Vec3 } => {
136
191
  const ref: Vec3 = Math.abs(d[1]) < 0.999 ? [ 0, 1, 0 ] : [ 0, 0, -1 ]
@@ -178,9 +233,12 @@ export const octLayout = (N: number, P: number): ImpostorLayout => {
178
233
 
179
234
  // ---- level-0 geometry ------------------------------------------------------------------------------------
180
235
 
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 }
236
+ type Sampler = { w: number, h: number, wrapS: number, wrapT: number }
237
+ /** A bark slot's extra maps (RGBA8, sampled through `taps`): the glTF occlusion map (r, linear) at its strength, the normal map (b = n.z). */
238
+ type Tex = Sampler & { img: Uint8Array, set: number }
239
+ 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, occ: Tex | null, occStrength: number, nmap: Tex | null }
182
240
  type Level0 = {
183
- pos: Float32Array, nrm: Float32Array, uv: Float32Array, ao: Float32Array
241
+ pos: Float32Array, nrm: Float32Array, uv: Float32Array, ao: Float32Array, cav: Float32Array
184
242
  tri: Uint32Array, triSlot: Uint16Array, face: Float32Array, slots: Slot[]
185
243
  min: Vec3, max: Vec3, root: GltfNode
186
244
  }
@@ -208,17 +266,8 @@ export const impostorBounds = (doc: Document): { min: Vec3, max: Vec3 } | null =
208
266
  return min[0] <= max[0] ? { min, max } : null
209
267
  }
210
268
 
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
269
+ type Images = Map<Texture, Promise<{ img: Uint8Array, w: number, h: number } | null>>
270
+ const decodeTex = (tex: Texture, images: Images): Promise<{ img: Uint8Array, w: number, h: number } | null> => {
222
271
  let job = images.get(tex)
223
272
  if (!job) {
224
273
  job = (async () => {
@@ -228,17 +277,45 @@ const makeSlot = async (mat: Material | null, images: Map<Texture, Promise<{ img
228
277
  })()
229
278
  images.set(tex, job)
230
279
  }
231
- const img = await job
280
+ return job
281
+ }
282
+
283
+ const makeSlot = async (mat: Material | null, images: Images, log: (s: string) => void): Promise<Slot> => {
284
+ const f = (mat?.getBaseColorFactor() ?? [ 1, 1, 1, 1 ]) as [number, number, number, number]
285
+ const mask = mat?.getAlphaMode() === "MASK"
286
+ 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, occ: null, occStrength: 1, nmap: null }
287
+ if (!mat) return slot
288
+ // the BARK's extra maps (an opaque slot): what foliage.mat darkens the bark's ambient by - its occlusion map at the
289
+ // material's strength and its normal map's cavity - go into the bark map's b (the header). A KTX2 one is skipped
290
+ // with a line (the base colour below is the one that must be source bytes).
291
+ if (!mask) {
292
+ for (const [ what, tex, info ] of [ [ "occ", mat.getOcclusionTexture(), mat.getOcclusionTextureInfo() ], [ "nmap", mat.getNormalTexture(), mat.getNormalTextureInfo() ] ] as const) {
293
+ if (!tex) continue
294
+ if ((tex.getMimeType() || "").includes("ktx2")) { log(` ⚠ impostor: the ${what === "occ" ? "occlusion" : "normal"} map of "${mat.getName()}" is KTX2 - the bark's atlas occlusion goes without it`); continue }
295
+ const im = await decodeTex(tex, images)
296
+ if (!im) continue
297
+ const t: Tex = { img: im.img, w: im.w, h: im.h, wrapS: info?.getWrapS() ?? REPEAT, wrapT: info?.getWrapT() ?? REPEAT, set: info?.getTexCoord() ?? 0 }
298
+ if (what === "occ") { slot.occ = t; slot.occStrength = mat.getOcclusionStrength() } else slot.nmap = t
299
+ }
300
+ }
301
+ const tex = mat.getBaseColorTexture()
302
+ if (!tex) return slot
303
+ 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)`)
304
+ const info = mat.getBaseColorTextureInfo()
305
+ slot.set = info?.getTexCoord() ?? 0
306
+ slot.wrapS = info?.getWrapS() ?? REPEAT
307
+ slot.wrapT = info?.getWrapT() ?? REPEAT
308
+ const img = await decodeTex(tex, images)
232
309
  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
310
  slot.img = img.img; slot.w = img.w; slot.h = img.h
234
311
  return slot
235
312
  }
236
313
 
237
314
  const collectLevel0 = async (doc: Document, log: (s: string) => void): Promise<Level0 | null> => {
238
- const pos: number[] = [], nrm: number[] = [], uv: number[] = [], ao: number[] = []
315
+ const pos: number[] = [], nrm: number[] = [], uv: number[] = [], ao: number[] = [], cav: number[] = []
239
316
  const tri: number[] = [], triSlot: number[] = [], face: number[] = []
240
317
  const slots: Slot[] = [], slotOf = new Map<Material | null, number>()
241
- const images = new Map<Texture, Promise<{ img: Uint8Array, w: number, h: number } | null>>()
318
+ const images: Images = new Map()
242
319
  const min: Vec3 = [ Infinity, Infinity, Infinity ], max: Vec3 = [ -Infinity, -Infinity, -Infinity ]
243
320
  const trisOfNode = new Map<GltfNode, number>()
244
321
  const v: number[] = []
@@ -277,7 +354,7 @@ const collectLevel0 = async (doc: Document, log: (s: string) => void): Promise<L
277
354
  nrm.push(nx / l, ny / l, nz / l)
278
355
  } else nrm.push(0, 0, 0) // no normals: the face normal stands in at shading
279
356
  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)
357
+ if (C) { C.getElement(i, v); ao.push(v[0]); cav.push(v[1]) } else { ao.push(1); cav.push(1) }
281
358
  }
282
359
  const idx = p.getIndices()
283
360
  const n = idx ? idx.getCount() : count
@@ -303,7 +380,7 @@ const collectLevel0 = async (doc: Document, log: (s: string) => void): Promise<L
303
380
  if (!nested && n > best) { best = n; root = node }
304
381
  }
305
382
  return {
306
- pos: Float32Array.from(pos), nrm: Float32Array.from(nrm), uv: Float32Array.from(uv), ao: Float32Array.from(ao),
383
+ pos: Float32Array.from(pos), nrm: Float32Array.from(nrm), uv: Float32Array.from(uv), ao: Float32Array.from(ao), cav: Float32Array.from(cav),
307
384
  tri: Uint32Array.from(tri), triSlot: Uint16Array.from(triSlot), face: Float32Array.from(face), slots, min, max, root: root!,
308
385
  }
309
386
  }
@@ -322,7 +399,7 @@ const wrapI = (i: number, n: number, mode: number): number => {
322
399
  }
323
400
  // bilinear taps of the last `taps` call: byte offsets + weights (module scratch: the bake is synchronous per sample)
324
401
  const tapI = new Int32Array(4), tapW = new Float32Array(4)
325
- const taps = (s: Slot, u: number, v: number): void => {
402
+ const taps = (s: Sampler, u: number, v: number): void => {
326
403
  const fx = u * s.w - 0.5, fy = v * s.h - 0.5
327
404
  const x0 = Math.floor(fx), y0 = Math.floor(fy), tx = fx - x0, ty = fy - y0
328
405
  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)
@@ -335,6 +412,20 @@ const alphaAt = (s: Slot, u: number, v: number): number => {
335
412
  const d = s.img
336
413
  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
414
  }
415
+ // one channel of a bark map at (u, v), bilinear, 0..1
416
+ const chanAt = (t: Tex, ch: number, u: number, v: number): number => {
417
+ taps(t, u, v)
418
+ const d = t.img
419
+ return (d[tapI[0] + ch] * tapW[0] + d[tapI[1] + ch] * tapW[1] + d[tapI[2] + ch] * tapW[2] + d[tapI[3] + ch] * tapW[3]) / 255
420
+ }
421
+ // what foliage.mat darkens the bark's ambient by beyond the sky visibility: the material's occlusion map at its strength
422
+ // x the normal map's cavity (½ + ½·clamp(n.z, 0, 1): a texel whose normal leans hard off the surface sits in a groove)
423
+ const barkOccAt = (s: Slot, u: number, v: number): number => {
424
+ let o = 1
425
+ if (s.occ) o *= 1 + s.occStrength * (chanAt(s.occ, 0, u, v) - 1)
426
+ if (s.nmap) o *= 0.5 + 0.5 * Math.min(Math.max(chanAt(s.nmap, 2, u, v) * 2 - 1, 0), 1)
427
+ return o
428
+ }
338
429
 
339
430
  // ---- dilation ------------------------------------------------------------------------------------------------
340
431
 
@@ -342,7 +433,7 @@ const DILATE_PASSES = 16
342
433
 
343
434
  /** Fill the empty texels' colour (base rgb) and data (all four) from their filled 8-neighbours in the same frame,
344
435
  * 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 => {
436
+ const dilate = (base: Uint8Array, data: Uint8Array, bark: Uint8Array, covered: Uint8Array, W: number, H: number, frameOf: Int32Array, nFrames: number): void => {
346
437
  let cur = covered.slice()
347
438
  for (let pass = 0; pass < DILATE_PASSES; pass++) {
348
439
  const next = cur.slice()
@@ -352,7 +443,7 @@ const dilate = (base: Uint8Array, data: Uint8Array, covered: Uint8Array, W: numb
352
443
  const i = y * W + x
353
444
  if (cur[i]) continue
354
445
  const f = frameOf[i]
355
- let n = 0, r = 0, g = 0, b = 0, d0 = 0, d1 = 0, d2 = 0, d3 = 0
446
+ let n = 0, r = 0, g = 0, b = 0, d0 = 0, d1 = 0, d2 = 0, d3 = 0, k0 = 0, k2 = 0
356
447
  for (let oy = -1; oy <= 1; oy++) {
357
448
  const yy = y + oy
358
449
  if (yy < 0 || yy >= H) continue
@@ -364,6 +455,7 @@ const dilate = (base: Uint8Array, data: Uint8Array, covered: Uint8Array, W: numb
364
455
  const o = k * 4
365
456
  r += base[o]; g += base[o + 1]; b += base[o + 2]
366
457
  d0 += data[o]; d1 += data[o + 1]; d2 += data[o + 2]; d3 += data[o + 3]
458
+ k0 += bark[o]; k2 += bark[o + 2]
367
459
  n++
368
460
  }
369
461
  }
@@ -371,6 +463,7 @@ const dilate = (base: Uint8Array, data: Uint8Array, covered: Uint8Array, W: numb
371
463
  const o = i * 4
372
464
  base[o] = Math.round(r / n); base[o + 1] = Math.round(g / n); base[o + 2] = Math.round(b / n)
373
465
  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)
466
+ bark[o] = Math.round(k0 / n); bark[o + 2] = Math.round(k2 / n)
374
467
  next[i] = 1
375
468
  changed++
376
469
  }
@@ -380,24 +473,26 @@ const dilate = (base: Uint8Array, data: Uint8Array, covered: Uint8Array, W: numb
380
473
  }
381
474
  // the rest: the frame's mean over its covered texels (the whole atlas' when the frame saw nothing)
382
475
  // (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
476
+ const sums = new Float64Array((nFrames + 1) * 10)
477
+ const all = nFrames * 10
385
478
  for (let i = 0; i < W * H; i++) {
386
479
  if (!covered[i]) continue
387
480
  const o = i * 4
388
- for (const s of frameOf[i] >= 0 ? [ frameOf[i] * 8, all ] : [ all ]) {
481
+ for (const s of frameOf[i] >= 0 ? [ frameOf[i] * 10, all ] : [ all ]) {
389
482
  sums[s] += base[o]; sums[s + 1] += base[o + 1]; sums[s + 2] += base[o + 2]
390
483
  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]++
484
+ sums[s + 8] += bark[o]; sums[s + 9] += bark[o + 2]
391
485
  }
392
486
  }
393
487
  for (let i = 0; i < W * H; i++) {
394
488
  if (cur[i]) continue
395
- let s = frameOf[i] >= 0 ? frameOf[i] * 8 : all
489
+ let s = frameOf[i] >= 0 ? frameOf[i] * 10 : all
396
490
  if (!sums[s + 7]) s = all
397
491
  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 }
492
+ if (!n) { base[o] = base[o + 1] = base[o + 2] = 128; data[o] = data[o + 1] = data[o + 2] = 128; data[o + 3] = 255; bark[o] = 0; bark[o + 2] = 0; continue }
399
493
  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
494
  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)
495
+ bark[o] = Math.round(sums[s + 8] / n); bark[o + 2] = Math.round(sums[s + 9] / n)
401
496
  }
402
497
  }
403
498
 
@@ -419,6 +514,9 @@ const invert4 = (m: ArrayLike<number>): number[] | null => {
419
514
  ]
420
515
  }
421
516
 
517
+ /** The default supersampling (S×S a texel) — `--impostor-samples` overrides it. */
518
+ export const IMPOSTOR_SAMPLES = 8
519
+
422
520
  const encodePng = async (rgba: Uint8Array, w: number, h: number): Promise<Uint8Array> => {
423
521
  const sharp = (await import("sharp")).default
424
522
  return new Uint8Array(await sharp(Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength), { raw: { width: w, height: h, channels: 4 } }).png().toBuffer())
@@ -434,7 +532,7 @@ export const bakeImpostor = async (doc: Document, opts: ImpostorOptions): Promis
434
532
  const log = opts.log ?? ((s: string) => console.log(s))
435
533
  const g = await collectLevel0(doc, log)
436
534
  if (!g) return null
437
- const { pos, nrm, uv, ao, tri, triSlot, face, slots } = g
535
+ const { pos, nrm, uv, ao, cav, tri, triSlot, face, slots } = g
438
536
  const nv = pos.length / 3, nt = tri.length / 3
439
537
  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
538
  let r2 = 0
@@ -461,8 +559,11 @@ export const bakeImpostor = async (doc: Document, opts: ImpostorOptions): Promis
461
559
  for (let y = f.y0; y < f.y1; y++) frameOf.fill(fi, y * AW + f.x0, y * AW + f.x1)
462
560
  })
463
561
 
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
562
+ // the BARK map (the material's occlusionTexture): r = the texel's bark fraction, g = b = 0, a = 255 (the header)
563
+ const base = new Uint8Array(AW * AH * 4), data = new Uint8Array(AW * AH * 4), bark = new Uint8Array(AW * AH * 4), covered = new Uint8Array(AW * AH)
564
+ const S = Math.max(1, Math.min(16, Math.round(opts.samples ?? IMPOSTOR_SAMPLES))), S2 = S * S
565
+ for (let i = 3; i < bark.length; i += 4) bark[i] = 255
566
+ const samples = span * span * S2
466
567
  const zb = new Float32Array(samples), tb = new Int32Array(samples), l1b = new Float32Array(samples), l2b = new Float32Array(samples)
467
568
  const qx = new Float32Array(nv), qy = new Float32Array(nv), qz = new Float32Array(nv)
468
569
 
@@ -470,7 +571,7 @@ export const bakeImpostor = async (doc: Document, opts: ImpostorOptions): Promis
470
571
  const d = fr.d
471
572
  const { right, up } = frameBasis(d)
472
573
  const X0 = fr.x0, X1 = fr.x1, Y0 = fr.y0, Y1 = fr.y1
473
- const W = (X1 - X0) * 2, H = (Y1 - Y0) * 2
574
+ const W = (X1 - X0) * S, H = (Y1 - Y0) * S
474
575
  // every vertex into this frame's sample grid (sample k centred at k + 0.5), qz = toward the camera; the frame's
475
576
  // local (a, b) spans its rect: x = x0 + a·(x1 - x0), y = y0 + (1 - b)·(y1 - y0)
476
577
  const k = 0.5 / r
@@ -515,24 +616,39 @@ export const bakeImpostor = async (doc: Document, opts: ImpostorOptions): Promis
515
616
  }
516
617
  }
517
618
  }
518
- // resolve: each texel's four samples shaded from their triangle + barycentrics
619
+ // resolve: each texel's S×S samples shaded from their triangle + barycentrics
519
620
  for (let y = Y0; y < Y1; y++) {
520
621
  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)
622
+ let n = 0, nBark = 0, cr = 0, cg = 0, cb = 0, nx = 0, ny = 0, nz = 0, aoSum = 0, zSum = 0, occSum = 0
623
+ for (let q = 0; q < S2; q++) {
624
+ const s = ((y - Y0) * S + Math.floor(q / S)) * W + (x - X0) * S + (q % S)
524
625
  const t = tb[s]
525
626
  if (t < 0) continue
526
627
  zSum += zb[s] // dot(p - c, d) of the surviving sample's surface point
527
628
  const l1 = l1b[s], l2 = l2b[s], l0 = 1 - l1 - l2
528
629
  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
630
  const slot = slots[triSlot[t]]
631
+ if (!slot.mask) {
632
+ // BARK (an opaque slot): the MESH normal - the vertices' interpolated NORMAL (the face's where the mesh has
633
+ // none) - turned toward this frame's viewer: what the bark is lit by (foliage.mat), not the crown's hull
634
+ let mx = nrm[ia * 3] * l0 + nrm[ib * 3] * l1 + nrm[ic * 3] * l2
635
+ let my = nrm[ia * 3 + 1] * l0 + nrm[ib * 3 + 1] * l1 + nrm[ic * 3 + 1] * l2
636
+ let mz = nrm[ia * 3 + 2] * l0 + nrm[ib * 3 + 2] * l1 + nrm[ic * 3 + 2] * l2
637
+ let ml = Math.hypot(mx, my, mz)
638
+ if (ml < 1e-6) { mx = face[t * 3]; my = face[t * 3 + 1]; mz = face[t * 3 + 2]; ml = 1 }
639
+ const flip = mx * d[0] + my * d[1] + mz * d[2] < 0 ? -1 : 1
640
+ nx += flip * mx / ml; ny += flip * my / ml; nz += flip * mz / ml
641
+ nBark++
642
+ // the bark's occlusion (the header): the baked cavity x the material's maps at the sample's uv
643
+ occSum += (cav[ia] * l0 + cav[ib] * l1 + cav[ic] * l2) * (slot.occ || slot.nmap ? barkOccAt(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) : 1)
644
+ } else {
645
+ // a card: the hull normal at the surface point (the cards' shading normal, see the header), never flipped
646
+ const mx = pos[ia * 3] * l0 + pos[ib * 3] * l1 + pos[ic * 3] * l2 - crown[0]
647
+ const my = pos[ia * 3 + 1] * l0 + pos[ib * 3 + 1] * l1 + pos[ic * 3 + 1] * l2 - crown[1]
648
+ const mz = pos[ia * 3 + 2] * l0 + pos[ib * 3 + 2] * l1 + pos[ic * 3 + 2] * l2 - crown[2]
649
+ const ml = Math.hypot(mx, my, mz) || 1
650
+ nx += mx / ml; ny += my / ml; nz += mz / ml
651
+ }
536
652
  if (slot.img) {
537
653
  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
654
  const im = slot.img
@@ -546,7 +662,9 @@ export const bakeImpostor = async (doc: Document, opts: ImpostorOptions): Promis
546
662
  if (!n) continue
547
663
  const o = (y * AW + x) * 4
548
664
  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)
665
+ base[o] = toSrgb8(cr / n); base[o + 1] = toSrgb8(cg / n); base[o + 2] = toSrgb8(cb / n); base[o + 3] = Math.round(n / S2 * 255)
666
+ bark[o] = Math.round(nBark / n * 255)
667
+ bark[o + 2] = nBark ? Math.max(1, Math.round(Math.min(Math.max(occSum / nBark, 0), 1) * 255)) : 0 // 0 = no bark here (and an older atlas)
550
668
  // r, g = the mean hull normal in OBJECT space, full-sphere octahedral; b = the mean depth dot(p - c, d) / r
551
669
  const nl = Math.hypot(nx, ny, nz) || 1
552
670
  const [ eu, ev ] = normalEncode([ nx / nl, ny / nl, nz / nl ])
@@ -558,7 +676,7 @@ export const bakeImpostor = async (doc: Document, opts: ImpostorOptions): Promis
558
676
  }
559
677
  }
560
678
  }
561
- dilate(base, data, covered, AW, AH, frameOf, frames.length)
679
+ dilate(base, data, bark, covered, AW, AH, frameOf, frames.length)
562
680
 
563
681
  // ---- the quad: the levels it replaces go, then the node --------------------------------------------------
564
682
  const root = g.root
@@ -568,20 +686,20 @@ export const bakeImpostor = async (doc: Document, opts: ImpostorOptions): Promis
568
686
  // `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
687
  // shows at - never past the first gap: the pass hides every level at an index nothing fills
570
688
  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) }
689
+ for (const n of doc.getRoot().listNodes()) { const m = LOD_RE.exec(n.getName()); if (m && Number(m[1]) >= level && !n.isDisposed()) dropNode(n) }
573
690
 
574
691
  const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer()
575
692
  // tagged: transcodeKtx2.ts planKtx2 never resizes them by --max-size (a 3072 px strip squashed is a broken layout)
576
693
  const tag = () => ({ lecodes: { impostor: true } })
577
694
  const albedo = doc.createTexture(`${rootName}_impostor_albedo`).setMimeType("image/png").setImage(await encodePng(base, AW, AH)).setExtras(tag())
578
695
  const normalDepth = doc.createTexture(`${rootName}_impostor_data`).setMimeType("image/png").setImage(await encodePng(data, AW, AH)).setExtras(tag())
696
+ const barkMap = doc.createTexture(`${rootName}_impostor_bark`).setMimeType("image/png").setImage(await encodePng(bark, AW, AH)).setExtras(tag())
579
697
  const material = doc.createMaterial(`${rootName}_impostor`)
580
698
  .setAlphaMode("MASK").setAlphaCutoff(0.5).setDoubleSided(true).setMetallicFactor(0).setRoughnessFactor(1).setBaseColorFactor([ 1, 1, 1, 1 ])
581
- .setBaseColorTexture(albedo).setMetallicRoughnessTexture(normalDepth)
699
+ .setBaseColorTexture(albedo).setMetallicRoughnessTexture(normalDepth).setOcclusionTexture(barkMap)
582
700
  .setExtras({ lecodes: { impostor: [ layout.cols, c[0], c[1], c[2], r, layout.rows ] } })
583
701
  // 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)
702
+ for (const info of [ material.getBaseColorTextureInfo(), material.getMetallicRoughnessTextureInfo(), material.getOcclusionTextureInfo() ]) info?.setWrapS(CLAMP).setWrapT(CLAMP)
585
703
  const acc = (type: "VEC2" | "VEC3" | "VEC4" | "SCALAR", arr: Float32Array<ArrayBuffer> | Uint16Array<ArrayBuffer>) => doc.createAccessor().setType(type).setArray(arr).setBuffer(buffer)
586
704
  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
705
  const prim = doc.createPrimitive().setMaterial(material)
@@ -602,7 +720,14 @@ export const bakeImpostor = async (doc: Document, opts: ImpostorOptions): Promis
602
720
  } else {
603
721
  for (const s of doc.getRoot().listScenes()) if (s.listChildren().includes(root)) s.addChild(node)
604
722
  }
605
- // quiet: prune logs every removal at INFO, once per kind of an import
723
+ await pruneQuiet(doc)
724
+ 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(), samples: S }
725
+ }
726
+
727
+ const dropNode = (n: GltfNode): void => { for (const ch of n.listChildren()) dropNode(ch); n.dispose() }
728
+
729
+ /** prune what the dropped nodes used — quietly: prune logs every removal at INFO, once per kind of an import */
730
+ const pruneQuiet = async (doc: Document): Promise<void> => {
606
731
  const logger = doc.getLogger()
607
732
  doc.setLogger(new Logger(Logger.Verbosity.SILENT))
608
733
  await doc.transform(prune({
@@ -610,5 +735,25 @@ export const bakeImpostor = async (doc: Document, opts: ImpostorOptions): Promis
610
735
  keepLeaves: true, keepAttributes: true, keepIndices: true, keepSolidTextures: true, keepExtras: false,
611
736
  }))
612
737
  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() }
738
+ }
739
+
740
+ /**
741
+ * Drop the pack's own BILLBOARD level: Unity's tree exporter writes the last level as `Billboard_<Tree>` — two crossed
742
+ * cards over a pre-rendered sheet under a Standard-shader material, which the importer keeps as `<Tree>_LOD<n>`
743
+ * (kindGlb.ts) and which, drawn by the crown cards' shader, is a flat pale cross (Chernobyl, 2026-11-04: every far
744
+ * tree). Under `--impostor` the quad IS the billboard, so the level goes for every vegetation kind — the ones too small
745
+ * for a quad simply end at their last mesh level. A node whose every primitive wears a `Billboard_*` material. Returns
746
+ * how many nodes went.
747
+ */
748
+ export const dropBillboards = async (doc: Document): Promise<number> => {
749
+ let n = 0
750
+ for (const node of doc.getRoot().listNodes()) {
751
+ if (node.isDisposed()) continue
752
+ const prims = node.getMesh()?.listPrimitives() ?? []
753
+ if (!prims.length || !prims.every((p) => /^Billboard_/i.test(p.getMaterial()?.getName() ?? ""))) continue
754
+ dropNode(node)
755
+ n++
756
+ }
757
+ if (n) await pruneQuiet(doc)
758
+ return n
614
759
  }