lecodes-assets 2.4.10 → 2.5.1

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.
@@ -4,6 +4,7 @@
4
4
  // Codec by texture role:
5
5
  // - colour (base/emissive) -> ETC1S, sRGB (small, lossy)
6
6
  // - data (normal/metalRough/occ.) -> UASTC + zstd, linear (precise; ETC1S steps on a smooth channel)
7
+ // - an impostor atlas (either map) -> UASTC + zstd (its colour is the far crown's hue; ETC1S shifts it)
7
8
  // …unless `codecPolicy: 'uastc'` asks for UASTC everywhere. That is for HERO assets — a
8
9
  // first-person weapon or gloves fill a third of the screen at 30 cm, where ETC1S's block artifacts
9
10
  // on the base colour are plainly visible. World props stay on the default: ETC1S is the right
@@ -16,7 +17,7 @@ import { existsSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'no
16
17
  import { cpus, tmpdir } from 'node:os'
17
18
  import { join } from 'node:path'
18
19
  import type { Root, Texture } from '@gltf-transform/core'
19
- import { getTextureRoles, DEFAULT_MAX_SIZE } from './transcodeTextures'
20
+ import { getTextureRoles, isImpostorAtlas, DEFAULT_MAX_SIZE } from './transcodeTextures'
20
21
 
21
22
  /**
22
23
  * The KTX2 encoder: KTX-Software's legacy `toktx`, or — when there is none — its unified `ktx`
@@ -143,12 +144,15 @@ export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_S
143
144
  // reflection probes, 2026-09-17) and it wrecks normals; colour stays on ETC1S (small, its artefacts hide in the
144
145
  // detail) unless 'uastc' asks for UASTC everywhere (hero assets). 'hq' is the same as the default now.
145
146
  // oetf: linear for any non-colour data.
147
+ // An impostor atlas (both maps) takes UASTC too: at the distance the quad shows, one texel is the whole screen pixel
148
+ // of a crown and ETC1S's palette quantisation shifts the crown's hue against the cards it replaces.
146
149
  const codec: 'etc1s' | 'uastc' =
147
150
  policy === 'uastc' ? 'uastc'
148
- : isNormal || isData ? 'uastc'
151
+ : isNormal || isData || isImpostorAtlas(tex) ? 'uastc'
149
152
  : 'etc1s'
150
153
  const oetf: 'srgb' | 'linear' = isNormal || isData ? 'linear' : 'srgb'
151
- const resize = Math.max(width, height) > maxSize
154
+ // an impostor atlas keeps its size whatever the cap (its frames are fixed pixel rects); still ×4 and mipmapped
155
+ const resize = !isImpostorAtlas(tex) && Math.max(width, height) > maxSize
152
156
  return {
153
157
  tex, name: tex.getName() || '?', roles, fromFmt, codec, oetf, resize, width, height, hasAlpha,
154
158
  beforeBytes: img.length, category: 'lossy',
@@ -250,7 +254,7 @@ async function encodeOne(plan: Ktx2Plan, enc: KtxEncoder, maxSize: number): Prom
250
254
  const snap4 = (n: number) => Math.max(4, Math.round(n / 4) * 4)
251
255
  let tw = plan.width, th = plan.height
252
256
  const m = Math.max(tw, th)
253
- if (m > maxSize) { const s = maxSize / m; tw = tw * s; th = th * s }
257
+ if (plan.resize && m > maxSize) { const s = maxSize / m; tw = tw * s; th = th * s }
254
258
  tw = snap4(tw); th = snap4(th)
255
259
 
256
260
  let pipe = sharp(Buffer.from(plan.tex.getImage()!))
@@ -15,6 +15,11 @@ import type { Root, Texture } from '@gltf-transform/core'
15
15
  export const DEFAULT_MAX_SIZE = 2048
16
16
  const HEAVY_PNG_MIN_DIM = 1024
17
17
 
18
+ /** An IMPOSTOR ATLAS (doctor/impostor.ts tags both with extras `lecodes.impostor`): never resized by the size cap — its
19
+ * frames sit at fixed pixel rects, and a 12 × 256 = 3072 px strip squashed under a 2048 cap is a different layout. */
20
+ export const isImpostorAtlas = (tex: Texture): boolean =>
21
+ !!(tex.getExtras() as { lecodes?: { impostor?: unknown } } | undefined)?.lecodes?.impostor
22
+
18
23
  export interface TexturePlan {
19
24
  tex: Texture
20
25
  name: string
@@ -63,7 +68,7 @@ export async function planTexture(tex: Texture, root: Root, maxSize = DEFAULT_MA
63
68
  return null // undecodable by sharp — leave it
64
69
  }
65
70
 
66
- const resize = Math.max(width, height) > maxSize
71
+ const resize = !isImpostorAtlas(tex) && Math.max(width, height) > maxSize
67
72
  let desired: 'jpeg' | 'png' = forceFmt ?? (hasAlpha ? 'png' : 'jpeg') // PNG only when alpha is needed
68
73
  if (desired === 'jpeg' && hasAlpha) desired = 'png' // never drop a real alpha channel
69
74
 
@@ -7,8 +7,9 @@
7
7
  // COLOR_0 = (ao, cavity, 1, wind) ao = sky visibility 0..1 (cosine-weighted upper hemisphere, WORLD up)
8
8
  // cavity = the bark's short-range occlusion 0..1 (the hemisphere of its own normal,
9
9
  // hits within `cavity` metres: limb joints, the trunk under a limb); cards 1
10
- // wind = the per-vertex wind weight — the pack's own (COLOR_0.a as it came)
11
- // or, for a mesh without one, the height over the plant squared
10
+ // wind = the per-vertex wind weight — the pack's own (COLOR_0.a as it came) when
11
+ // its material carries a wind profile (`lecodes.wind`), else the height
12
+ // over the plant squared
12
13
  // An exact white (1, 1, 1, 1) is what gltfio hands a mesh WITHOUT a colour set, and the shader takes that
13
14
  // as "no data" (ao 1, height weight) — a vertex whose ao AND wind both round to 1 lands there too, and it
14
15
  // is exactly the vertex nothing changes for.
@@ -136,6 +137,13 @@ export type VertexAoOptions = {
136
137
 
137
138
  const LOD_RE = /_LOD([1-9]\d*)$/i
138
139
 
140
+ /** True when the primitive's material carries the pack's wind profile (glTF extras `lecodes.wind`, kindGlb.ts
141
+ * windProfile): the one case a vertex alpha is an authored wind weight. */
142
+ const windProfiled = (p: Primitive): boolean => {
143
+ const extras = p.getMaterial()?.getExtras() as { lecodes?: { wind?: unknown } } | null | undefined
144
+ return Array.isArray(extras?.lecodes?.wind)
145
+ }
146
+
139
147
  /** World-space triangles of every level-0 mesh node. */
140
148
  const collectOccluders = (doc: Document): { tris: Tri[], min: [number, number, number], max: [number, number, number] } => {
141
149
  const tris: Tri[] = []
@@ -247,6 +255,7 @@ export const bakeVertexAo = (doc: Document, opts: VertexAoOptions = {}): { verti
247
255
  }
248
256
  const old = p.getAttribute("COLOR_0")
249
257
  const oldEl = old ? old.getElementSize() : 0
258
+ const authoredWind = windProfiled(p)
250
259
  const out = new Float32Array(new ArrayBuffer(n * 16))
251
260
  const v: number[] = [], c: number[] = [], q: number[] = []
252
261
  // the cavity term: the bark (opaque) with a normal, rays in the hemisphere of the vertex' world normal
@@ -281,9 +290,12 @@ export const bakeVertexAo = (doc: Document, opts: VertexAoOptions = {}): { verti
281
290
  }
282
291
  cav = 1 - occ / cavRays.length
283
292
  }
284
- // the wind weight: the pack's own (its alpha, as it came) or the height over the plant, squared
293
+ // the wind weight: the pack's own alpha when its shader AUTHORED one there (the material carries a wind profile,
294
+ // `lecodes.wind` - NatureManufacture paints the weight into the vertex alpha), else the height over the plant,
295
+ // squared. A pack whose wind mask lives in a texture (Chernobyl's Leaves.shader) ships the alpha at 1 everywhere,
296
+ // and taken as the weight that shivered every leaf by the field's full amplitude (2026-11-03).
285
297
  let wind: number
286
- if (old && oldEl === 4) { old.getElement(i, c); wind = c[3] } else { const h = Math.min(Math.max((y - min[1]) / height, 0), 1); wind = h * h }
298
+ if (old && oldEl === 4 && authoredWind) { old.getElement(i, c); wind = c[3] } else { const h = Math.min(Math.max((y - min[1]) / height, 0), 1); wind = h * h }
287
299
  out[i * 4] = ao; out[i * 4 + 1] = cav; out[i * 4 + 2] = 1; out[i * 4 + 3] = wind
288
300
  sum += ao; vertices++
289
301
  }
@@ -55,9 +55,12 @@ export const localTRS = (go: UGO): TRS => {
55
55
  }
56
56
  export const enabled = (c: UComp): boolean => num(c.body.m_Enabled, 1) !== 0
57
57
 
58
- /** LOD level per renderer component (from every LODGroup in the tree): renderer → level. */
59
- export const lodLevels = (roots: UGO[], ids?: Map<string, UGO | UComp>): Map<UComp, number> => {
60
- const out = new Map<UComp, number>()
58
+ /** The LOD levels each renderer component is drawn at (from every LODGroup in the tree): renderer → its levels,
59
+ * ascending. Unity lists a renderer under every level it serves (a door's LOD1 mesh standing in at LOD2 too), and a
60
+ * renderer whose group has ONE level (a cull-only group: the walls of a composite building, each in a LODGroup of
61
+ * its own) is drawn at every level — `[]` says so. A renderer in no LODGroup is not in the map. */
62
+ export const lodLevels = (roots: UGO[], ids?: Map<string, UGO | UComp>): Map<UComp, number[]> => {
63
+ const out = new Map<UComp, number[]>()
61
64
  const walk = (g: UGO): void => {
62
65
  for (const c of g.comps) {
63
66
  if (c.cls !== "LODGroup" || !Array.isArray(c.body.m_LODs)) continue
@@ -65,17 +68,23 @@ export const lodLevels = (roots: UGO[], ids?: Map<string, UGO | UComp>): Map<UCo
65
68
  const byId = new Map<string, UComp>()
66
69
  const collect = (x: UGO): void => { for (const k of x.comps) byId.set(k.id, k); x.children.forEach(collect) }
67
70
  collect(g)
71
+ const levels = c.body.m_LODs.filter((lod) => isMap(lod) && Array.isArray(lod.renderers)).length
68
72
  c.body.m_LODs.forEach((lod, level) => {
69
73
  if (!isMap(lod) || !Array.isArray(lod.renderers)) return
70
74
  for (const r of lod.renderers) {
71
75
  const id = ref(isMap(r) ? r.renderer : undefined).fileID
72
76
  const rc = byId.get(id) ?? (ids?.get(id) as UComp | undefined)
73
- if (rc && "go" in rc && !out.has(rc)) out.set(rc, level)
77
+ if (!rc || !("go" in rc)) continue
78
+ if (levels <= 1) { if (!out.has(rc)) out.set(rc, []); continue }
79
+ const have = out.get(rc)
80
+ if (!have) out.set(rc, [ level ])
81
+ else if (have.length > 0 && !have.includes(level)) have.push(level)
74
82
  }
75
83
  })
76
84
  }
77
85
  g.children.forEach(walk)
78
86
  }
79
87
  roots.forEach(walk)
88
+ for (const v of out.values()) v.sort((a, b) => a - b)
80
89
  return out
81
90
  }
@@ -29,11 +29,14 @@ export type WriteLevelOptions = FoliageOptions & {
29
29
  force?: boolean
30
30
  maxSize?: number
31
31
  ktx2?: boolean
32
+ /** The heightfield's largest resolution (samples a side): a finer Unity heightmap is read every k-th sample
33
+ * (levelTerrain.ts). Default 1025: half-metre cells over a 500 m map, the size every level so far ran on. */
34
+ terrainRes?: number
32
35
  log?: (s: string) => void
33
36
  /** Leave the pack's directional light out of the scene file: the level owns its sun — filament renders the
34
37
  * brightest directional light of a scene and the bake follows it, so a second one only confuses. */
35
38
  noSun?: boolean
36
- /** Kinds (by name) that take `lightmap: false` — no atlas rect, lit in real time. The case it was
39
+ /** Kinds (by name) that take `static: false` — no atlas rect, lit in real time. The case it was
37
40
  * written for is a LAMP FIXTURE: the pack models the bulb inside its housing, so the fixture's own
38
41
  * faces are the brightest texels in the level (the range's `SM_Door_lamp_a` read 18 000 lx against
39
42
  * a 1 700 lx floor) and the bake spends a rect on each. Also the right switch for anything the game
@@ -63,6 +66,11 @@ export type WriteLevelOptions = FoliageOptions & {
63
66
  /** Triangles a kind may have before the import simplifies it (triBudget.ts; default 50 000, 0 = no cap):
64
67
  * what keeps a photogrammetry scan from arriving as a million-triangle prop and mesh collider. */
65
68
  maxTris?: number
69
+ /** IMPOSTORS (doctor/impostor.ts): every foliage kind whose level-0 height is ≥ 4 m gets its far LOD level as one
70
+ * camera-facing quad over an atlas of views — `cols` azimuths × `rows` elevation rows, or the N×N hemi-octahedron
71
+ * of N = `cols` when `rows` is 0 — `px` pixels a frame (default 256 for a plant ≥ 8 m, 128 under), at LOD slot
72
+ * `level` (default 2: the engine shows it under 0.12 of the viewport height; 1 = under 0.30, the pack's own LOD1 goes). */
73
+ impostor?: { cols: number, rows: number, px?: number, level?: number }
66
74
  /** The command that produced this import, written into the scene file's header. A re-import that
67
75
  * forgets a flag silently undoes a decision — the dropped kinds come back, the lamps go into the
68
76
  * atlas again — and nothing in the project records it, so the line IS the record. */
@@ -87,7 +95,17 @@ export type WriteLevelOptions = FoliageOptions & {
87
95
  // `_ShiverDirectionality`) in the glTF material extras `lecodes.wind` (kindGlb.ts windProfile) — creator-gl's
88
96
  // foliage tier bends and shivers each material as Unity HDRP's Wind.hlsl does.
89
97
  // 11: the bark's CAVITY occlusion in COLOR_0.g (vertexAo.ts `cavity`: short rays in the normal's hemisphere — limb joints).
90
- const BUILDER_VERSION = 12 // 12 (2026-10-18): a material without a metallic map or `_Metallic` is metallic 0, not glTF's 1 (the LOD3 crosses were black)
98
+ // 12 (2026-10-18): a material without a metallic map or `_Metallic` is metallic 0, not glTF's 1 (the LOD3 crosses were black)
99
+ // 13 (2026-11-03): an Amplify leaf graph's additive noise tint folds into the base colour factor (materialWriter.ts: Chernobyl's
100
+ // leaves drew at two thirds); the vertex alpha is the wind weight only under a `lecodes.wind` profile (vertexAo.ts)
101
+ // 14 (2026-11-03): every MASK material of a vegetation kind is double-sided (a pack's leaf shader culls off in its source)
102
+ // 15 (2026-11-03): `--impostor`: foliage kinds ≥ 4 m take an octahedral impostor quad as their last LOD level (doctor/impostor.ts)
103
+ // 16 (2026-11-03): `--impostor-level`: the LOD slot the quad takes is the bake's choice (the engine's thresholds decide the distance)
104
+ // 17 (2026-11-03): the impostor's data map carries the crown's HULL normal (xyz, the cards' shading normal), no depth
105
+ // 18 (2026-11-03): `--impostor <A>x<E>[@px]`: azimuths × elevation rows (default 12x3), `oct<N>` keeps the octahedron; the atlases skip --max-size
106
+ // 19 (2026-11-03): the impostor's albedo atlas is UASTC like its data map (ETC1S shifted the far crown's hue)
107
+ // 20 (2026-11-03): the impostor's data map = octahedral hull normal (rg, object space) + depth (b) + AO (a): the quad self-shadows from its own atlas
108
+ const BUILDER_VERSION = 20
91
109
  const v3 = (v: number[]): string => `[${v.map(r3).join(", ")}]`
92
110
  const ident = (s: string): string => { const x = s.replace(/[^\w$]/g, "_"); return /^\d/.test(x) ? `_${x}` : x }
93
111
 
@@ -171,7 +189,7 @@ const kindSceneFile = (k: SceneKind, glbRel: string, source: string, foliage: bo
171
189
  // the SDK's vegetation tier: wind, touch bending, per-copy tint (Foliage); a level may add `{ fade }` per kind
172
190
  if (foliage) model.push("foliage: true")
173
191
  // out of the bake entirely (`--no-lightmap <re>`): no atlas rect, lit in real time
174
- if (noLightmap) model.push("lightmap: false")
192
+ if (noLightmap) model.push("static: false")
175
193
  if (modelAspects.length) model.push(`aspects: [${modelAspects.join(", ")}]`)
176
194
  return `// Generated by \`lecodes assets scene\` from ${source}${k.prefabPath ? ` (${k.prefabPath.replace(/\\/g, "/").replace(/^.*\/Assets\//, "Assets/")})` : ""}.
177
195
  // Regenerated on every re-import — edit the level that instances it, not this file.
@@ -220,13 +238,16 @@ const buildKindGlbs = async (kinds: SceneKind[], builder: KindGlbBuilder, assetD
220
238
  // painted every broken branch pure red (2026-09-23). Non-foliage kinds lose COLOR_0; the vegetation
221
239
  // tier keeps it — its shader ignores the tint, and the alpha IS the wind weight it will read.
222
240
  const foliage = decideFoliage(k, builder.materialsOf(k), opts)
223
- const hash = createHash("sha1").update(JSON.stringify([ BUILDER_VERSION, k.key, opts.maxSize, opts.ktx2, maxTris, ...(foliage ? [] : [ "nocolor" ]) ])).digest("hex").slice(0, 16)
241
+ const hash = createHash("sha1").update(JSON.stringify([ BUILDER_VERSION, k.key, opts.maxSize, opts.ktx2, maxTris, ...(foliage ? [] : [ "nocolor" ]), ...(foliage && opts.impostor ? [ "impostor", opts.impostor.cols, opts.impostor.rows, opts.impostor.px ?? 0, opts.impostor.level ?? 2 ] : []) ])).digest("hex").slice(0, 16)
224
242
  if (!opts.force && existsSync(glb) && manifest[k.name] === hash) { reused++; continue }
225
243
  const { doc, parts, missing } = await builder.build(k)
226
244
  if (!foliage) for (const m of doc.getRoot().listMeshes()) for (const p of m.listPrimitives()) { const c = p.getAttribute("COLOR_0"); if (c) { p.setAttribute("COLOR_0", null); if (c.listParents().length <= 1) c.dispose() } }
227
245
  // and the vegetation tier gets its VERTEX AO: the sky's visibility from every vertex baked into COLOR_0.r
228
246
  // (doctor/vertexAo.ts), the wind weight kept in the alpha — the one thing that makes a crown read as a volume
229
247
  if (foliage) {
248
+ // the cards of a plant are two-sided by nature; a pack's leaf shader may cull off in its SOURCE (Chernobyl's
249
+ // Leaves.shader: `Cull Off`), which no .mat property shows - the glTF flag says so for every reader (2026-11-03)
250
+ for (const m of doc.getRoot().listMaterials()) if (m.getAlphaMode() === "MASK") m.setDoubleSided(true)
230
251
  const { bakeVertexAo } = await import("../doctor/vertexAo")
231
252
  const ao = bakeVertexAo(doc)
232
253
  if (ao.vertices) log(` 🌿 ${k.name}: vertex AO on ${ao.vertices} vertices (mean ${ao.meanAo.toFixed(2)})`)
@@ -235,6 +256,18 @@ const buildKindGlbs = async (kinds: SceneKind[], builder: KindGlbBuilder, assetD
235
256
  if (!parts) { log(` ⚠ ${k.name}: no mesh could be built — skipped`); continue }
236
257
  const cut = await fitTriangleBudget(doc, maxTris)
237
258
  if (cut) log(` ✂ ${k.name}: ${cut.before.toLocaleString("en")} → ${cut.after.toLocaleString("en")} triangles (--max-tris ${maxTris})`)
259
+ // a TREE's far level as one quad over an atlas of views (doctor/impostor.ts) — before the doctor: the bake reads the
260
+ // PNG / JPEG base colours the KTX2 encode takes away, and the atlases go through that encode like any map
261
+ if (foliage && opts.impostor) {
262
+ const { bakeImpostor, impostorBounds, impostorLabel } = await import("../doctor/impostor")
263
+ const box = impostorBounds(doc)
264
+ const height = box ? box.max[1] - box.min[1] : 0
265
+ if (height >= 4) {
266
+ // px unset: the bake's own default by the same level-0 height (256 for ≥ 8 m, 128 under)
267
+ const imp = await bakeImpostor(doc, { ...opts.impostor, log })
268
+ if (imp) log(` 🌲 ${k.name}: impostor ${impostorLabel(imp)} · ${imp.node} · r ${imp.radius.toFixed(2)} m · ${imp.triangles} triangles in ${imp.ms} ms`)
269
+ }
270
+ }
238
271
  await applyPlan(doc, await analyzeDoc(doc, doctor), doctor)
239
272
  writeFileSync(glb, await makeIO().writeBinary(doc))
240
273
  manifest[k.name] = hash
@@ -287,7 +320,7 @@ const writeKindScenes = (kinds: SceneKind[], builder: KindGlbBuilder, dirs: { as
287
320
  log(` 🧱 collider fit (${r3(tol * 100)} cm): ${meshKinds.length} of ${boxKinds} kind(s) with box colliders take a MESH collider instead${meshKinds.length ? `: ${meshKinds.slice(0, 6).join(", ")}${meshKinds.length > 6 ? ` … +${meshKinds.length - 6}` : ""}` : ""}`)
288
321
  }
289
322
  log(` ${describeFoliage(foliageKinds, opts)}`)
290
- if (opts.noLightmap) log(` 🔦 lightmap: false on ${unbakedKinds.length} kind(s)${unbakedKinds.length ? `: ${unbakedKinds.slice(0, 8).join(", ")}${unbakedKinds.length > 8 ? " …" : ""}` : ` (nothing matched /${opts.noLightmap.source}/)`}`)
323
+ if (opts.noLightmap) log(` 🔦 static: false on ${unbakedKinds.length} kind(s)${unbakedKinds.length ? `: ${unbakedKinds.slice(0, 8).join(", ")}${unbakedKinds.length > 8 ? " …" : ""}` : ` (nothing matched /${opts.noLightmap.source}/)`}`)
291
324
  }
292
325
 
293
326
  // ---- the map scene -----------------------------------------------------------------------------------------
@@ -67,26 +67,35 @@ const writeTerrainFile = (file: string, sizeX: number, sizeZ: number, cellSize:
67
67
  /** The `.terrain` file, the packed albedo file per kept layer, and the packed normal textures. */
68
68
  export type WrittenTerrain = { file: string, layers: string[], normals: string[] }
69
69
 
70
- export const writeTerrain = async (r: SceneImportFull, dir: string, name: string, opts: { ktx2?: boolean }, log: (s: string) => void): Promise<WrittenTerrain | null> => {
70
+ export const writeTerrain = async (r: SceneImportFull, dir: string, name: string, opts: { ktx2?: boolean, terrainRes?: number }, log: (s: string) => void): Promise<WrittenTerrain | null> => {
71
71
  const t = r.terrainData
72
72
  if (!t || !r.terrain) return null
73
73
  mkdirSync(dir, { recursive: true })
74
- const res = t.resolution
74
+ // THE HEIGHTFIELD'S RESOLUTION IS A COST EVERYWHERE: the file (7 bytes a sample), Jolt's height field, the navmesh's
75
+ // input (two triangles a cell: Chernobyl's 4097² = 33 M triangles, past what Recast's wasm can hold) and the render
76
+ // chunks. A pack's terrain is authored at Unity's 4097 over 500 m (12 cm cells) where half-metre cells draw the same
77
+ // ground; `--terrain-res` caps it: every k-th sample along both axes (the samples are exact, no filtering), the
78
+ // largest k whose result is at most the cap — a Unity heightmap is 2^n + 1, so every power of two divides it.
79
+ const src = t.resolution
80
+ let step = 1
81
+ if (opts.terrainRes && opts.terrainRes > 1) while ((src - 1) / step + 1 > opts.terrainRes && (src - 1) % (step * 2) === 0) step *= 2
82
+ const res = (src - 1) / step + 1
83
+ if (step > 1) log(` 🏔 terrain heightfield ${src}² → ${res}² (every ${step}th sample, --terrain-res ${opts.terrainRes})`)
75
84
  // mirror the columns: LeCodes sample (0,0) is the Unity terrain's +X far corner
76
85
  const heights = new Float32Array(res * res)
77
- for (let z = 0; z < res; z++) for (let x = 0; x < res; x++) heights[z * res + x] = t.heights[z * res + (res - 1 - x)]
86
+ for (let z = 0; z < res; z++) for (let x = 0; x < res; x++) heights[z * res + x] = t.heights[(z * step) * src + (src - 1 - x * step)]
78
87
  const plan = await planLayers(t)
79
88
  const slot = new Map<number, number>()
80
89
  plan.keep.forEach((li, s) => slot.set(li, s))
81
90
  for (const [ lose, win ] of plan.mergeInto) slot.set(lose, slot.get(win)!)
82
91
  // control: area-average the (finer) alphamaps onto the samples
83
92
  const ar = t.alphaResolution, control = new Uint8Array(res * res * 4)
84
- const f = ar / res
93
+ const f = ar / src
85
94
  for (let z = 0; z < res; z++) for (let x = 0; x < res; x++) {
86
- const ux = res - 1 - x // mirrored column
95
+ const ux = (res - 1 - x) * step, uz = z * step // mirrored column, in source samples
87
96
  const acc = [ 0, 0, 0, 0 ]
88
- const ax0 = Math.max(0, Math.floor((ux - 0.5) * f)), ax1 = Math.min(ar - 1, Math.ceil((ux + 0.5) * f))
89
- const az0 = Math.max(0, Math.floor((z - 0.5) * f)), az1 = Math.min(ar - 1, Math.ceil((z + 0.5) * f))
97
+ const ax0 = Math.max(0, Math.floor((ux - step / 2) * f)), ax1 = Math.min(ar - 1, Math.ceil((ux + step / 2) * f))
98
+ const az0 = Math.max(0, Math.floor((uz - step / 2) * f)), az1 = Math.min(ar - 1, Math.ceil((uz + step / 2) * f))
90
99
  for (let li = 0; li < t.weights.length; li++) {
91
100
  const w = t.weights[li]
92
101
  let s = 0
@@ -96,7 +96,17 @@ export const unityMaterialWriter = (doc: Document, resolver: GuidResolver, warn:
96
96
  // tree-billboard shader's `_BaseColor` names a deleted file, its atlas sits in `_MainTex1`)
97
97
  const baseG = SLOTS.base.map((n) => um.textures[n]).find((g) => g && (g.startsWith(PATH_GUID) || resolver.resolve(g))) ?? null
98
98
  if (baseG) { const t = await texFromGuid(baseG); if (t) { gm.setBaseColorTexture(t); slots.push(`base=${nameOf(baseG)}`) } }
99
- const baseColor = um.colors._BaseColor ?? um.colors._Color
99
+ let baseColor = um.colors._BaseColor ?? um.colors._Color
100
+ // An Amplify leaf graph's albedo is `_Color × tex + _Color_Tilling × tex × noise` (Chernobyl's Leaves.shader: a
101
+ // world-noise patchiness, noise 0..1): the mean of that is `_Color + _Color_Tilling / 2` on the texture, and
102
+ // `_Color` alone drew every leaf of the pack at two thirds of its brightness (2026-11-03). The patchiness itself
103
+ // is the vegetation shader's own (foliage.mat's leaf tint noise).
104
+ const tilling = um.colors._Color_Tilling
105
+ if (tilling && um.floats._Tilling_Color !== undefined) {
106
+ const c = baseColor ?? [ 1, 1, 1, 1 ]
107
+ baseColor = [ c[0] + 0.5 * tilling[0], c[1] + 0.5 * tilling[1], c[2] + 0.5 * tilling[2], c[3] ]
108
+ slots.push(`base×(_Color + _Color_Tilling/2)`)
109
+ }
100
110
  if (baseColor) gm.setBaseColorFactor([ baseColor[0], baseColor[1], baseColor[2], gm.getBaseColorFactor()[3] ])
101
111
  // EVERY slot is picked the way the base is: the first candidate whose texture EXISTS. A `.mat` keeps the
102
112
  // property block of every shader it ever wore, so a NatureManufacture bark that moved from HDRP Lit to
@@ -32,7 +32,10 @@ export type KindPart = {
32
32
  mesh: { guid: string, fileID: string }
33
33
  /** `.mat` GUIDs, one per submesh (Unity's order). */
34
34
  materials: string[]
35
- /** LODGroup level (0 when the renderer is in no LODGroup). */
35
+ /** The level this part is drawn at. THE ENGINE'S LOD GROUP IS THE WHOLE GLB (creator-gl lod.cpp): at level n it draws
36
+ * the `_LOD<n>` nodes and nothing else, so a renderer Unity draws at several levels (listed under each, or in a
37
+ * cull-only LODGroup, or in none while the kind has levels) is written once per level — the same mesh under a
38
+ * node per level. One part here is one node. */
36
39
  lod: number
37
40
  castShadows: boolean
38
41
  }
@@ -218,6 +221,8 @@ export const importUnityScene = async (scenePath: string, opts: ImportSceneOptio
218
221
  const lodSuffix = (n: string): number => { const m = /_LOD(\d+)$/i.exec(n); return m ? +m[1] : 0 }
219
222
  const kindOf = (root: UGO, prefab: string | null, fallbackName: string, nested: { g: UGO, local: Mat4 }[]): SceneKind | null => {
220
223
  const parts: KindPart[] = [], colliders: KindCollider[] = [], klights: KindLight[] = []
224
+ // a renderer and the levels it is drawn at: `[]` = every level the kind has (expanded once the kind's levels are known)
225
+ const drawn: { part: Omit<KindPart, "lod">, levels: number[] }[] = []
221
226
  let particles = 0
222
227
  const lods = lodLevels([ root ])
223
228
  const walk = (g: UGO, parentM: Mat4, path: string, top: boolean): void => {
@@ -232,7 +237,10 @@ export const importUnityScene = async (scenePath: string, opts: ImportSceneOptio
232
237
  const mesh = ref(mf.body.m_Mesh)
233
238
  if (mesh.guid && mesh.guid !== "0000000000000000e000000000000000") {
234
239
  const mats = Array.isArray(mr.body.m_Materials) ? mr.body.m_Materials.map((x) => ref(x).guid ?? "") : []
235
- parts.push({ path: p, matrix: M, mesh: { guid: mesh.guid, fileID: mesh.fileID }, materials: mats, lod: lods.get(mr) ?? lodSuffix(goName(g)), castShadows: num(mr.body.m_CastShadows, 1) !== 0 })
240
+ // in no LODGroup: a loose renderer named like its node (`…_LOD1`) keeps that level; a plain one is drawn at every level
241
+ const suffix = lodSuffix(goName(g))
242
+ const levels = lods.get(mr) ?? (suffix > 0 ? [ suffix ] : [])
243
+ drawn.push({ part: { path: p, matrix: M, mesh: { guid: mesh.guid, fileID: mesh.fileID }, materials: mats, castShadows: num(mr.body.m_CastShadows, 1) !== 0 }, levels })
236
244
  }
237
245
  }
238
246
  for (const c of g.comps) {
@@ -245,6 +253,12 @@ export const importUnityScene = async (scenePath: string, opts: ImportSceneOptio
245
253
  for (const ch of g.children) walk(ch, m, p, false)
246
254
  }
247
255
  walk(root, I4(), "", true)
256
+ // the kind's levels: a part drawn at every level gets a node per level (the same mesh, `<name>_LOD<n>` beside `<name>`)
257
+ const maxLevel = drawn.reduce((m, d) => Math.max(m, ...d.levels), 0)
258
+ for (const d of drawn) {
259
+ const levels = d.levels.length ? d.levels : Array.from({ length: maxLevel + 1 }, (_, i) => i)
260
+ for (const lod of levels) parts.push({ ...d.part, lod })
261
+ }
248
262
  stats.particles += particles
249
263
  stats.prefabLights += klights.length
250
264
  if (!parts.length && !colliders.length) return null