lecodes-assets 2.5.1 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +3970 -3400
- package/dist/index.js +9126 -8556
- package/package.json +1 -1
- package/src/commands/doctor.ts +2 -1
- package/src/commands/scene.ts +2 -1
- package/src/commands/shared.ts +3 -2
- package/src/doctor/cardTrim.ts +19 -13
- package/src/doctor/coverageMips.ts +132 -0
- package/src/doctor/doctor.ts +8 -1
- package/src/doctor/imageFacts.ts +44 -0
- package/src/doctor/impostor.ts +199 -54
- package/src/doctor/transcodeKtx2.ts +71 -24
- package/src/doctor/transcodeTextures.ts +5 -9
- package/src/textures/byteLru.ts +38 -0
- package/src/textures/resolveImage.ts +30 -4
- package/src/unity/image.ts +23 -12
- package/src/unity/kindGlb.ts +47 -7
- package/src/unity/lecodesLevel.ts +37 -6
- package/src/unity/materialWriter.ts +182 -96
- package/src/unity/prefabTree.ts +3 -0
- package/src/unity/scene.ts +6 -2
- package/src/unity/triBudget.ts +41 -9
package/src/doctor/impostor.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
//
|
|
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
|
-
//
|
|
47
|
-
//
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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) *
|
|
384
|
-
const all = nFrames *
|
|
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] *
|
|
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] *
|
|
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
|
-
|
|
465
|
-
const
|
|
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) *
|
|
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
|
|
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 <
|
|
523
|
-
const s = ((y - Y0) *
|
|
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 /
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|