lecodes-assets 2.4.10 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +712 -33
- package/dist/index.js +712 -33
- package/package.json +1 -1
- package/src/commands/doctor.ts +15 -3
- package/src/commands/scene.ts +6 -1
- package/src/commands/shared.ts +20 -1
- package/src/doctor/cardTrim.ts +2 -0
- package/src/doctor/impostor.ts +614 -0
- package/src/doctor/transcodeKtx2.ts +8 -4
- package/src/doctor/transcodeTextures.ts +6 -1
- package/src/doctor/vertexAo.ts +16 -4
- package/src/unity/gameObjects.ts +13 -4
- package/src/unity/lecodesLevel.ts +38 -5
- package/src/unity/levelTerrain.ts +16 -7
- package/src/unity/materialWriter.ts +11 -1
- package/src/unity/scene.ts +16 -2
|
@@ -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
|
-
|
|
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
|
|
package/src/doctor/vertexAo.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
}
|
package/src/unity/gameObjects.ts
CHANGED
|
@@ -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
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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 `
|
|
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
|
-
|
|
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("
|
|
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(` 🔦
|
|
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
|
-
|
|
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 *
|
|
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 /
|
|
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
|
|
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 -
|
|
89
|
-
const az0 = Math.max(0, Math.floor((
|
|
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
|
-
|
|
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
|
package/src/unity/scene.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|