lecodes-assets 0.2.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -26
- package/dist/cli.js +18157 -11441
- package/dist/index.js +16603 -9878
- package/package.json +3 -1
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -1039
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +79 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +108 -0
- package/src/commands/shared.ts +140 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +288 -0
- package/src/doctor/doctor.ts +94 -30
- package/src/doctor/impostor.ts +614 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -0
- package/src/doctor/transcodeKtx2.ts +186 -31
- package/src/doctor/transcodeTextures.ts +6 -1
- package/src/doctor/validateGlb.ts +2 -2
- package/src/doctor/vertexAo.ts +333 -0
- package/src/fbx/read.ts +2 -0
- package/src/fbx/toDocument.ts +227 -209
- package/src/geometry.ts +222 -0
- package/src/index.ts +2 -0
- package/src/retarget/heading.ts +238 -171
- package/src/retarget/humanoid.ts +572 -561
- package/src/retarget/retarget.ts +2375 -2272
- package/src/retarget/rig.ts +4 -1
- package/src/retarget/writeMarks.ts +134 -132
- package/src/sky/cmgen.ts +3 -3
- package/src/sky/index.ts +57 -8
- package/src/terrainPack.ts +61 -22
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +90 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +414 -0
- package/src/unity/levelTerrain.ts +125 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +316 -0
- package/src/unity/materials.ts +34 -429
- package/src/unity/math.ts +82 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +10 -36
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +339 -304
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +24 -5
- package/src/unity/yaml.ts +131 -0
- package/src/unreal/index.ts +6 -5
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +117 -9
- package/src/unreal/package.ts +30 -12
- package/src/unreal/resolve.ts +6 -4
- package/src/unreal/sound.ts +128 -0
- package/src/unreal/texture.ts +70 -3
- package/src/unreal/toDocument.ts +7 -0
- package/src/unreal/versions.ts +8 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +1 -1
- package/src/wasm/lightmapUv.js +1 -1
- package/src/doctor/lightmapUv.ts +0 -452
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// A Unity scene import (scene.ts) → files in a LeCodes project:
|
|
2
|
+
//
|
|
3
|
+
// assets/<map>/<Kind>.glb one per kind (kindGlb.ts), texture-capped + KTX2 by the doctor
|
|
4
|
+
// assets/<map>/terrain/… <map>.terrain (heights, control) + the packed layer textures
|
|
5
|
+
// src/<map>/<Kind>.scene.ts a prefab scene per kind: the model + its colliders
|
|
6
|
+
// src/<map>.scene.ts sun, terrain, every placement (grouped like the Unity scene)
|
|
7
|
+
//
|
|
8
|
+
// Everything here is REGENERATED by the next import — the files say so at the top. A game level
|
|
9
|
+
// instances `src/<map>.scene.ts` as a prefab in a scene file of its own and adds its gameplay
|
|
10
|
+
// nodes there. GLBs are rebuilt only when their kind changed (assets/<map>/kinds.json) or --force.
|
|
11
|
+
|
|
12
|
+
import { createHash } from "node:crypto"
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
14
|
+
import { dirname, join, relative, resolve } from "node:path"
|
|
15
|
+
import { makeIO } from "../fbx/convert"
|
|
16
|
+
import { decideFoliage, describeFoliage, type FoliageOptions } from "./foliage"
|
|
17
|
+
import { findUnityIndex, resolverFor } from "./guid"
|
|
18
|
+
import { fitVerdict, readGlbTriangles, type FitBox, type FitTriangles } from "./colliderFit"
|
|
19
|
+
import { DEFAULT_MAX_TRIS, fitTriangleBudget } from "./triBudget"
|
|
20
|
+
import { KindGlbBuilder } from "./kindGlb"
|
|
21
|
+
import { writeTerrain, type WrittenTerrain } from "./levelTerrain"
|
|
22
|
+
import { decompose, eulerYXZ, qmul, r2, r3 } from "./math"
|
|
23
|
+
import type { KindCollider, SceneImportFull, SceneKind } from "./scene"
|
|
24
|
+
|
|
25
|
+
export type WriteLevelOptions = FoliageOptions & {
|
|
26
|
+
project: string
|
|
27
|
+
/** Map name: folder + scene file stem (default: the Unity scene's file name, lower-cased). */
|
|
28
|
+
name?: string
|
|
29
|
+
force?: boolean
|
|
30
|
+
maxSize?: number
|
|
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
|
|
35
|
+
log?: (s: string) => void
|
|
36
|
+
/** Leave the pack's directional light out of the scene file: the level owns its sun — filament renders the
|
|
37
|
+
* brightest directional light of a scene and the bake follows it, so a second one only confuses. */
|
|
38
|
+
noSun?: boolean
|
|
39
|
+
/** Kinds (by name) that take `static: false` — no atlas rect, lit in real time. The case it was
|
|
40
|
+
* written for is a LAMP FIXTURE: the pack models the bulb inside its housing, so the fixture's own
|
|
41
|
+
* faces are the brightest texels in the level (the range's `SM_Door_lamp_a` read 18 000 lx against
|
|
42
|
+
* a 1 700 lx floor) and the bake spends a rect on each. Also the right switch for anything the game
|
|
43
|
+
* moves or swaps. */
|
|
44
|
+
noLightmap?: RegExp
|
|
45
|
+
/** METRES A BOX COLLIDER'S FACE MAY STAND OFF THE GEOMETRY under it before the kind takes a MESH
|
|
46
|
+
* collider instead (default 0.015; colliderFit.ts measures it, a huge value keeps every box).
|
|
47
|
+
* A pack's box is an approximation, and where it is a loose one everything that lands on the hit
|
|
48
|
+
* point lands in mid-air: the shooting range's wall module is flat at ±0.100 m while its box was
|
|
49
|
+
* fitted to the ±0.138 m of its edge trim, so bullets stopped 3.8 cm in front of the wall, their
|
|
50
|
+
* impact particles hung there and the bullet-hole decals (a ±3 cm projection box) never reached
|
|
51
|
+
* the wall at all. */
|
|
52
|
+
colliderFit?: number
|
|
53
|
+
/** Write NO colliders at all: every kind is pure visuals, no node carries a Physics body. A viewer
|
|
54
|
+
* wants that, and so does any level whose placements outnumber what Jolt is configured for
|
|
55
|
+
* (the Mountain scene: 78 000 placements, ~20 000 of them with a collider — the body cap fired
|
|
56
|
+
* before the level finished loading). */
|
|
57
|
+
noColliders?: boolean
|
|
58
|
+
/** Kinds whose name matches take `castShadows: false`. A pack's BACKGROUND MOUNTAINS are 2 km meshes a
|
|
59
|
+
* kilometre out that Unity's shadow distance never reaches; here they are casters like any other, and
|
|
60
|
+
* their shadow lay across the whole map. */
|
|
61
|
+
noShadows?: RegExp
|
|
62
|
+
/** Skip the alpha-card trim (`doctor --trim-cards`), which the import otherwise always runs. It
|
|
63
|
+
* DELETES a quad with no opaque texel in its UV rect, and a pack whose decal atlas is 94 % clear
|
|
64
|
+
* can lose a whole kind that way — silently, since the GLB keeps its nodes and textures. */
|
|
65
|
+
noTrimCards?: boolean
|
|
66
|
+
/** Triangles a kind may have before the import simplifies it (triBudget.ts; default 50 000, 0 = no cap):
|
|
67
|
+
* what keeps a photogrammetry scan from arriving as a million-triangle prop and mesh collider. */
|
|
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 }
|
|
74
|
+
/** The command that produced this import, written into the scene file's header. A re-import that
|
|
75
|
+
* forgets a flag silently undoes a decision — the dropped kinds come back, the lamps go into the
|
|
76
|
+
* atlas again — and nothing in the project records it, so the line IS the record. */
|
|
77
|
+
command?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Bump when a kind GLB built from the same kind comes out different — the cache keys on it.
|
|
81
|
+
// 3: the mesh lookup finds `Book (17)` as `Book` and the sole mesh under a parent node.
|
|
82
|
+
// 4: triplanar Shader Graph materials — `_Diffuse_Tex` & co. read, the projection baked into TEXCOORD_0;
|
|
83
|
+
// materials found past a nested `assets/` folder (guid.ts).
|
|
84
|
+
// 5: overrides on unaddressable model children matched by position (prefabTree.ts TransformGuess).
|
|
85
|
+
// 6: Unity mesh assets as part sources (unityMesh.ts — the tree LOD3 cross); every texture slot picked
|
|
86
|
+
// by the first candidate whose file EXISTS (a stale `_MaskMap` no longer shadows the live pack).
|
|
87
|
+
// (same version, hash element "nocolor": non-foliage kinds lose COLOR_0 — a Unity pack's vertex colour is
|
|
88
|
+
// shader data, never a tint; NatureManufacture's branches came out pure red.)
|
|
89
|
+
// 7: a Unity DETAIL LAYER on another UV set (kindGlb.ts detailLayer: `_DETALUSEUV3_ON` + `_DetailAlbedoMap` under a
|
|
90
|
+
// white `_DetailMask`) becomes the primitive's base/normal/ORM on TEXCOORD_0 = that set × tiling — the pines'
|
|
91
|
+
// bark, which UV0 never covered (the trunks were flat brown above the roots); FBX UV sets 1..3 now read.
|
|
92
|
+
// 8: vegetation kinds carry VERTEX AO — COLOR_0 = (sky visibility, 1, 1, wind weight), doctor/vertexAo.ts.
|
|
93
|
+
// 9: canopy normals on the leaf cards (vertexAo.ts `canopy`, 0.6 toward the crown centre).
|
|
94
|
+
// 10: the pack's per-material WIND PROFILE (`_Drag` / `_Stiffness` / `_InitialBend` / `_ShiverDrag` /
|
|
95
|
+
// `_ShiverDirectionality`) in the glTF material extras `lecodes.wind` (kindGlb.ts windProfile) — creator-gl's
|
|
96
|
+
// foliage tier bends and shivers each material as Unity HDRP's Wind.hlsl does.
|
|
97
|
+
// 11: the bark's CAVITY occlusion in COLOR_0.g (vertexAo.ts `cavity`: short rays in the normal's hemisphere — limb joints).
|
|
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
|
|
109
|
+
const v3 = (v: number[]): string => `[${v.map(r3).join(", ")}]`
|
|
110
|
+
const ident = (s: string): string => { const x = s.replace(/[^\w$]/g, "_"); return /^\d/.test(x) ? `_${x}` : x }
|
|
111
|
+
|
|
112
|
+
const transformFields = (pos: number[], rot: number[], scale?: number[]): string => {
|
|
113
|
+
const e = eulerYXZ(rot).map(r2)
|
|
114
|
+
const parts = [ `position: ${v3(pos)}` ]
|
|
115
|
+
if (e.some((v) => v !== 0)) parts.push(`eulerAngles: [${e.join(", ")}]`)
|
|
116
|
+
if (scale && scale.some((v) => Math.abs(v - 1) > 1e-3)) parts.push(Math.abs(scale[0] - scale[1]) < 1e-3 && Math.abs(scale[0] - scale[2]) < 1e-3 ? `scale: ${r3(scale[0])}` : `scale: ${v3(scale)}`)
|
|
117
|
+
return parts.join(", ")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- colliders -------------------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
type ColliderSpec = { line: (i: number) => string, box: FitBox | null }
|
|
123
|
+
|
|
124
|
+
const STATIC_ASPECT = "use(Physics, { motion: 'static' })"
|
|
125
|
+
|
|
126
|
+
/** The pack's solid colliders, each as the prefab file will spell it — plus the box's own frame, for
|
|
127
|
+
* the fit test (colliderFit.ts) that decides whether the boxes may be trusted at all. */
|
|
128
|
+
const colliderSpecs = (k: SceneKind): { mesh: (KindCollider & { shape: "mesh" }) | undefined, specs: ColliderSpec[] } => {
|
|
129
|
+
const solid = k.colliders.filter((c) => !c.trigger)
|
|
130
|
+
const mesh = solid.find((c) => c.shape === "mesh") as (KindCollider & { shape: "mesh" }) | undefined
|
|
131
|
+
const specs: ColliderSpec[] = []
|
|
132
|
+
for (const c of solid) {
|
|
133
|
+
if (c.shape === "mesh") continue
|
|
134
|
+
const d = decompose(c.matrix)
|
|
135
|
+
const s = d.s.map(Math.abs)
|
|
136
|
+
const center = c.center
|
|
137
|
+
const origin = [ -center[0] * s[0], center[1] * s[1], center[2] * s[2] ] // Unity local → mirrored
|
|
138
|
+
let shape: string
|
|
139
|
+
let rot = d.r
|
|
140
|
+
let half: [number, number, number] | null = null
|
|
141
|
+
if (c.shape === "box") {
|
|
142
|
+
half = [ c.size[0] * s[0] / 2, c.size[1] * s[1] / 2, c.size[2] * s[2] / 2 ]
|
|
143
|
+
shape = `box: ${v3(half)}`
|
|
144
|
+
} else if (c.shape === "sphere") shape = `sphere: ${r3(c.radius * Math.max(...s))}`
|
|
145
|
+
else {
|
|
146
|
+
const ax = c.direction
|
|
147
|
+
const perp = Math.max(...s.filter((_, j) => j !== ax))
|
|
148
|
+
const radius = c.radius * perp
|
|
149
|
+
const halfH = Math.max(0, c.height * s[ax] / 2 - radius)
|
|
150
|
+
shape = `capsule: { halfHeight: ${r3(halfH)}, radius: ${r3(radius)} }`
|
|
151
|
+
// LeCodes capsules stand on Y: an X / Z capsule turns its node (origin stays in the collider's frame)
|
|
152
|
+
if (ax !== 1) {
|
|
153
|
+
const h = Math.SQRT1_2
|
|
154
|
+
const turn = ax === 0 ? [ 0, 0, h, h ] : [ h, 0, 0, h ]
|
|
155
|
+
rot = qmul(rot, turn)
|
|
156
|
+
// the origin was expressed in the unturned frame — rotate it into the turned one
|
|
157
|
+
const o = origin
|
|
158
|
+
if (ax === 0) { const [ ox, oy ] = o; o[0] = oy; o[1] = -ox } else { const [ , oy, oz ] = o; o[1] = oz; o[2] = -oy }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const box: FitBox | null = half
|
|
162
|
+
? { half, origin: [ origin[0], origin[1], origin[2] ], t: [ d.t[0], d.t[1], d.t[2] ], rot: [ rot[0], rot[1], rot[2], rot[3] ] }
|
|
163
|
+
: null
|
|
164
|
+
specs.push({
|
|
165
|
+
line: (i: number) => ` collider_${i}: { ${transformFields(d.t, rot)}, aspects: [use(Shape, { ${shape}, origin: ${v3(origin)} }), ${STATIC_ASPECT}] },`,
|
|
166
|
+
box,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
return { mesh, specs }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `forceMesh` = the fit test found a box that floats off the art: the whole kind takes the mesh
|
|
173
|
+
* instead, since keeping the good boxes beside it would collide twice. */
|
|
174
|
+
const colliderNodes = (k: SceneKind, forceMesh = false): { modelAspects: string[], children: string[] } => {
|
|
175
|
+
const modelAspects: string[] = []
|
|
176
|
+
const children: string[] = []
|
|
177
|
+
const { mesh, specs } = colliderSpecs(k)
|
|
178
|
+
// the colliders stay PICKABLE: a game's hitscan is Physics.raycast, and the physics raycast skips a body whose
|
|
179
|
+
// Shape has raycast: false (creator-physics PickableBodyFilter) - the range shot through its own walls (2026-09-18)
|
|
180
|
+
if (mesh || forceMesh) modelAspects.push(`use(Shape, { mesh: ${mesh?.convex ? "'convex'" : "true"} })`, STATIC_ASPECT)
|
|
181
|
+
if (!forceMesh) { let i = 0; for (const spec of specs) children.push(spec.line(++i)) }
|
|
182
|
+
return { modelAspects, children }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const kindSceneFile = (k: SceneKind, glbRel: string, source: string, foliage: boolean, noLightmap = false, forceMesh = false, noColliders = false, noShadows = false): string => {
|
|
186
|
+
const { modelAspects, children } = noColliders ? { modelAspects: [] as string[], children: [] as string[] } : colliderNodes(k, forceMesh)
|
|
187
|
+
const shadows = !noShadows && k.parts.some((p) => p.castShadows)
|
|
188
|
+
const model = [ `model: asset('${glbRel}')`, shadows ? "castShadows: true" : "castShadows: false" ]
|
|
189
|
+
// the SDK's vegetation tier: wind, touch bending, per-copy tint (Foliage); a level may add `{ fade }` per kind
|
|
190
|
+
if (foliage) model.push("foliage: true")
|
|
191
|
+
// out of the bake entirely (`--no-lightmap <re>`): no atlas rect, lit in real time
|
|
192
|
+
if (noLightmap) model.push("static: false")
|
|
193
|
+
if (modelAspects.length) model.push(`aspects: [${modelAspects.join(", ")}]`)
|
|
194
|
+
return `// Generated by \`lecodes assets scene\` from ${source}${k.prefabPath ? ` (${k.prefabPath.replace(/\\/g, "/").replace(/^.*\/Assets\//, "Assets/")})` : ""}.
|
|
195
|
+
// Regenerated on every re-import — edit the level that instances it, not this file.
|
|
196
|
+
|
|
197
|
+
export default defineScene({
|
|
198
|
+
nodes: {
|
|
199
|
+
${[ ` model: { ${model.join(", ")} },`, ...children ].join("\n")}
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- kinds: GLB + prefab scene ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
const rel = (from: string, to: string): string => { const x = relative(from, to).replace(/\\/g, "/"); return x.startsWith(".") ? x : `./${x}` }
|
|
208
|
+
|
|
209
|
+
/** WHERE `name:<material>` REFS ARE ANSWERED FROM. A renderer's material list names a GUID only
|
|
210
|
+
* when something REMAPPED it (the model importer's `externalObjects`, or a prefab / scene
|
|
211
|
+
* override); left alone, it carries the FBX's own material NAME and Unity resolves that by
|
|
212
|
+
* SEARCHING THE PROJECT (`materialSearch` in the importer). So the folder to walk is the pack
|
|
213
|
+
* ROOT — the directory holding unity-index.txt — and not the prefab's grandparent: a pack that
|
|
214
|
+
* keeps `Prefabs/` and `Materials/` as SIBLINGS, which is the ordinary layout, has no .mat file
|
|
215
|
+
* anywhere under that grandparent and every one of those refs then falls back to the FBX's
|
|
216
|
+
* placeholder. That failure is silent AND plausible-looking: an FBX placeholder still carries a
|
|
217
|
+
* diffuse COLOUR, so a glass pane comes out flat grey rather than obviously missing, and the
|
|
218
|
+
* pack's own transparency, textures and smoothness are simply gone. */
|
|
219
|
+
const packRootOf = (prefabPath: string): string => dirname(findUnityIndex(dirname(prefabPath)) ?? join(dirname(prefabPath), ".."))
|
|
220
|
+
|
|
221
|
+
/** One GLB per kind under `assetDir`, rebuilt only when the kind changed (kinds.json) or `force`. */
|
|
222
|
+
const buildKindGlbs = async (kinds: SceneKind[], builder: KindGlbBuilder, assetDir: string, opts: WriteLevelOptions, log: (s: string) => void): Promise<{ converted: number, reused: number }> => {
|
|
223
|
+
const manifestPath = join(assetDir, "kinds.json")
|
|
224
|
+
const manifest: Record<string, string> = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : {}
|
|
225
|
+
const { analyzeDoc, applyPlan } = await import("../doctor/doctor")
|
|
226
|
+
const { ensureCodecs } = await import("../doctor/reduceGlbBones")
|
|
227
|
+
await ensureCodecs()
|
|
228
|
+
// cards trimmed to their opaque texels BEFORE the KTX2 encode (doctor/cardTrim.ts): a foliage pack's
|
|
229
|
+
// quads are mostly transparent, and that discarded fill was the frame on Twig & Twine's map
|
|
230
|
+
const doctor = { ktx2: opts.ktx2 !== false, maxSize: opts.maxSize ?? 1024, fixEmissive: true, trimCards: !opts.noTrimCards }
|
|
231
|
+
let converted = 0, reused = 0
|
|
232
|
+
for (const [ i, k ] of kinds.entries()) {
|
|
233
|
+
const glb = join(assetDir, `${k.name}.glb`)
|
|
234
|
+
const maxTris = opts.maxTris ?? DEFAULT_MAX_TRIS
|
|
235
|
+
// VERTEX COLOURS ARE NOT COLOURS in a Unity pack: the Standard / HDRP Lit shaders never read them, and
|
|
236
|
+
// a custom shader reads them as DATA — NatureManufacture bakes its wind weight there, (1, 0, 0, 1) on
|
|
237
|
+
// every branch vertex. glTF's rule is the opposite (COLOR_0 tints the base colour), so the ubershader
|
|
238
|
+
// painted every broken branch pure red (2026-09-23). Non-foliage kinds lose COLOR_0; the vegetation
|
|
239
|
+
// tier keeps it — its shader ignores the tint, and the alpha IS the wind weight it will read.
|
|
240
|
+
const foliage = decideFoliage(k, builder.materialsOf(k), opts)
|
|
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)
|
|
242
|
+
if (!opts.force && existsSync(glb) && manifest[k.name] === hash) { reused++; continue }
|
|
243
|
+
const { doc, parts, missing } = await builder.build(k)
|
|
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() } }
|
|
245
|
+
// and the vegetation tier gets its VERTEX AO: the sky's visibility from every vertex baked into COLOR_0.r
|
|
246
|
+
// (doctor/vertexAo.ts), the wind weight kept in the alpha — the one thing that makes a crown read as a volume
|
|
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)
|
|
251
|
+
const { bakeVertexAo } = await import("../doctor/vertexAo")
|
|
252
|
+
const ao = bakeVertexAo(doc)
|
|
253
|
+
if (ao.vertices) log(` 🌿 ${k.name}: vertex AO on ${ao.vertices} vertices (mean ${ao.meanAo.toFixed(2)})`)
|
|
254
|
+
}
|
|
255
|
+
for (const m of missing) log(` ⚠ ${k.name}: ${m}`)
|
|
256
|
+
if (!parts) { log(` ⚠ ${k.name}: no mesh could be built — skipped`); continue }
|
|
257
|
+
const cut = await fitTriangleBudget(doc, maxTris)
|
|
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
|
+
}
|
|
271
|
+
await applyPlan(doc, await analyzeDoc(doc, doctor), doctor)
|
|
272
|
+
writeFileSync(glb, await makeIO().writeBinary(doc))
|
|
273
|
+
manifest[k.name] = hash
|
|
274
|
+
converted++
|
|
275
|
+
log(` [${i + 1}/${kinds.length}] ${k.name}.glb · ${parts} part(s) · ${Math.round(readFileSync(glb).length / 1024)} KB`)
|
|
276
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 1) + "\n")
|
|
277
|
+
}
|
|
278
|
+
return { converted, reused }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The LOD0 triangles of a kind's GLB, read once (the fit test below asks for the same file per kind). */
|
|
282
|
+
const triCache = new Map<string, FitTriangles | null>()
|
|
283
|
+
const trianglesOf = (glb: string): FitTriangles | null => {
|
|
284
|
+
const hit = triCache.get(glb)
|
|
285
|
+
if (hit !== undefined) return hit
|
|
286
|
+
let tris: FitTriangles | null = null
|
|
287
|
+
try { tris = readGlbTriangles(readFileSync(glb)) } catch { tris = null }
|
|
288
|
+
triCache.set(glb, tris)
|
|
289
|
+
return tris
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** `src/<map>/<Kind>.scene.ts` for every kind that has its GLB, with the foliage / no-lightmap / collider verdicts. */
|
|
293
|
+
const writeKindScenes = (kinds: SceneKind[], builder: KindGlbBuilder, dirs: { assets: string, src: string }, source: string, opts: WriteLevelOptions, log: (s: string) => void): void => {
|
|
294
|
+
const foliageKinds: string[] = []
|
|
295
|
+
const unbakedKinds: string[] = []
|
|
296
|
+
const tol = opts.colliderFit ?? 0.015
|
|
297
|
+
const meshKinds: string[] = []
|
|
298
|
+
let boxKinds = 0
|
|
299
|
+
for (const k of kinds) {
|
|
300
|
+
const foliage = decideFoliage(k, builder.materialsOf(k), opts)
|
|
301
|
+
if (foliage) foliageKinds.push(k.name)
|
|
302
|
+
const noLightmap = !!opts.noLightmap?.test(k.name)
|
|
303
|
+
if (noLightmap) unbakedKinds.push(k.name)
|
|
304
|
+
// DOES THE PACK'S BOX SIT ON THE ART? (colliderFit.ts, `--collider-fit`) — measured against the
|
|
305
|
+
// kind's own GLB, which is on disk by now whether it was rebuilt or reused
|
|
306
|
+
const glb = join(dirs.assets, `${k.name}.glb`)
|
|
307
|
+
const boxes = colliderSpecs(k).specs.map((x) => x.box).filter((b): b is FitBox => b !== null)
|
|
308
|
+
let forceMesh = false
|
|
309
|
+
if (boxes.length > 0 && existsSync(glb) && !opts.noColliders) {
|
|
310
|
+
boxKinds++
|
|
311
|
+
const v = fitVerdict(trianglesOf(glb), boxes, { tolerance: tol })
|
|
312
|
+
if (v.mesh) {
|
|
313
|
+
forceMesh = true
|
|
314
|
+
meshKinds.push(`${k.name} (${v.worst && v.worst.covered >= 0.25 ? `${Math.round(v.worst.gap * 100)} cm off` : "over a void"})`)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
writeFileSync(join(dirs.src, `${k.name}.scene.ts`), kindSceneFile(k, rel(dirs.src, join(dirs.assets, `${k.name}.glb`)), source, foliage, noLightmap, forceMesh, !!opts.noColliders, !!opts.noShadows?.test(k.name)))
|
|
318
|
+
}
|
|
319
|
+
if (boxKinds > 0) {
|
|
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}` : ""}` : ""}`)
|
|
321
|
+
}
|
|
322
|
+
log(` ${describeFoliage(foliageKinds, opts)}`)
|
|
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}/)`}`)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- the map scene -----------------------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
const terrainNode = (r: SceneImportFull, terrain: WrittenTerrain, sdir: string): string[] => {
|
|
329
|
+
const data = r.terrainData!
|
|
330
|
+
const layers = terrain.layers.map((f, i) => {
|
|
331
|
+
const l = data.layers.find((x) => x.name.replace(/^LAYER_/i, "").toLowerCase() === f.replace(/^.*[\\/]/, "").replace(/\.(ktx2|png)$/, "")) ?? data.layers[i]
|
|
332
|
+
return `{ albedo: asset('${rel(sdir, f)}'), tiling: ${r3(l.tileSize[0])}, roughness: ${l.mask ? r2(1 - l.smoothness) : 1}${l.normal ? `, normalScale: ${r2(l.normalScale)}` : ""} }`
|
|
333
|
+
})
|
|
334
|
+
const normals = terrain.normals.map((f) => `asset('${rel(sdir, f)}')`)
|
|
335
|
+
return [
|
|
336
|
+
` terrain: {`,
|
|
337
|
+
` terrain: { data: asset('${rel(sdir, terrain.file)}'), layers: [${layers.join(", ")}]${normals.length ? `, normals: [${normals.join(", ")}]` : ""} },`,
|
|
338
|
+
` position: ${v3(r.terrain!.position)}, receiveShadows: true,`,
|
|
339
|
+
` aspects: [use(Shape, { heightfield: true }), use(Physics, { motion: 'static' })],`,
|
|
340
|
+
` },`,
|
|
341
|
+
]
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Placements grouped like the Unity scene: its top-level group objects (+ Trees / Details for the terrain's). */
|
|
345
|
+
const placementGroups = (r: SceneImportFull, usable: Set<string>): string[] => {
|
|
346
|
+
const groups = new Map<string, string[]>()
|
|
347
|
+
const used = new Map<string, number>()
|
|
348
|
+
for (const inst of r.instances) {
|
|
349
|
+
if (!usable.has(inst.kind)) continue
|
|
350
|
+
const g = inst.group.startsWith("Terrain/") ? inst.group.slice(8) : (inst.group.split("/")[0] || "Objects")
|
|
351
|
+
const key = ident(g.replace(/^_+/, "")) || "Objects"
|
|
352
|
+
const n = (used.get(`${key}/${inst.kind}`) ?? 0) + 1
|
|
353
|
+
used.set(`${key}/${inst.kind}`, n)
|
|
354
|
+
const list = groups.get(key) ?? []
|
|
355
|
+
list.push(` ${ident(inst.kind)}_${n}: { prefab: ${ident(inst.kind)}, ${transformFields(inst.position, inst.rotation, inst.scale)} },`)
|
|
356
|
+
groups.set(key, list)
|
|
357
|
+
}
|
|
358
|
+
return [ ...groups ].flatMap(([ g, list ]) => [ ` ${g}: {`, ` children: {`, ...list, ` },`, ` },` ])
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const mapSceneFile = (r: SceneImportFull, name: string, usable: SceneKind[], terrain: WrittenTerrain | null, sdir: string, opts: WriteLevelOptions): string => {
|
|
362
|
+
const lines: string[] = []
|
|
363
|
+
lines.push(`// Generated by \`lecodes assets scene\` from ${r.source} — ${r.instances.length} placements of ${usable.length} kinds.`)
|
|
364
|
+
lines.push("// Regenerated on every re-import: instance this file as a prefab in a level of your own and put the")
|
|
365
|
+
lines.push("// gameplay there. Frame: Unity mirrored on X, metres, Y up.")
|
|
366
|
+
if (opts.command) {
|
|
367
|
+
lines.push("//")
|
|
368
|
+
lines.push("// RE-IMPORT WITH THE SAME LINE — the flags carry decisions this file cannot show (kinds dropped,")
|
|
369
|
+
lines.push("// placements dropped at a point, kinds kept out of the lightmap); drop one and it silently undoes itself:")
|
|
370
|
+
lines.push(`// ${opts.command}`)
|
|
371
|
+
}
|
|
372
|
+
lines.push("")
|
|
373
|
+
for (const k of usable) lines.push(`import ${ident(k.name)} from './${name}/${k.name}.scene'`)
|
|
374
|
+
lines.push("")
|
|
375
|
+
lines.push("export default defineScene({")
|
|
376
|
+
const cam = r.cameras[0]
|
|
377
|
+
if (cam) {
|
|
378
|
+
const target = cam.position.map((v, i) => v + cam.forward[i] * 30)
|
|
379
|
+
lines.push(` camera: { position: ${v3(cam.position)}, target: ${v3(target)}, fov: ${r2(cam.fov)} },`)
|
|
380
|
+
}
|
|
381
|
+
lines.push(" nodes: {")
|
|
382
|
+
const sun = opts.noSun ? undefined : r.lights.find((l) => l.type === "directional")
|
|
383
|
+
if (sun) lines.push(` sun: { light: { kind: 'sun', direction: ${v3(sun.direction)}, intensity: ${Math.round(Math.min(120000, Math.max(20000, sun.intensity)))}, color: '#${sun.color.map((c) => Math.round(Math.min(1, c) * 255).toString(16).padStart(2, "0")).join("")}', shadowsQuality: 1, shadowDistance: 30 } },`)
|
|
384
|
+
if (terrain && r.terrain) lines.push(...terrainNode(r, terrain, sdir))
|
|
385
|
+
lines.push(...placementGroups(r, new Set(usable.map((k) => k.name))))
|
|
386
|
+
lines.push(" },", "})", "")
|
|
387
|
+
return lines.join("\n")
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---- the whole level ---------------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
export const writeLeCodesLevel = async (r: SceneImportFull, opts: WriteLevelOptions): Promise<{ scene: string, kinds: number, converted: number, reused: number }> => {
|
|
393
|
+
const log = opts.log ?? console.log
|
|
394
|
+
const project = resolve(opts.project)
|
|
395
|
+
const name = (opts.name ?? r.source.replace(/\.unity$/i, "")).toLowerCase().replace(/[^\w-]+/g, "_")
|
|
396
|
+
const dirs = { assets: join(project, "assets", name), src: join(project, "src", name) }
|
|
397
|
+
mkdirSync(dirs.assets, { recursive: true }); mkdirSync(dirs.src, { recursive: true })
|
|
398
|
+
|
|
399
|
+
const kinds = Object.values(r.kinds)
|
|
400
|
+
const anyPrefab = kinds.find((k) => k.prefabPath)?.prefabPath
|
|
401
|
+
const builder = new KindGlbBuilder({
|
|
402
|
+
resolver: resolverFor(anyPrefab ?? project),
|
|
403
|
+
materialDirs: anyPrefab ? [ packRootOf(anyPrefab) ] : [],
|
|
404
|
+
warn: (m) => log(` ⚠ ${m}`),
|
|
405
|
+
})
|
|
406
|
+
const { converted, reused } = await buildKindGlbs(kinds, builder, dirs.assets, opts, log)
|
|
407
|
+
const usable = kinds.filter((k) => existsSync(join(dirs.assets, `${k.name}.glb`)))
|
|
408
|
+
writeKindScenes(usable, builder, dirs, r.source, opts, log)
|
|
409
|
+
const terrain = await writeTerrain(r, join(dirs.assets, "terrain"), name, opts, log)
|
|
410
|
+
|
|
411
|
+
const sceneFile = join(project, "src", `${name}.scene.ts`)
|
|
412
|
+
writeFileSync(sceneFile, mapSceneFile(r, name, usable, terrain, dirname(sceneFile), opts))
|
|
413
|
+
return { scene: sceneFile, kinds: usable.length, converted, reused }
|
|
414
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// A Unity terrain → the `.terrain` file the SDK's Terrain.parse reads (heights + control) and the
|
|
2
|
+
// packed layer textures. LeCodes' terrain material blends FOUR layers, so a terrain with more has
|
|
3
|
+
// its closest-coloured ones merged; the grid is mirrored on X like everything else in the import.
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
import { decodeImage } from "./image"
|
|
8
|
+
import { r3 } from "./math"
|
|
9
|
+
import type { SceneImportFull } from "./scene"
|
|
10
|
+
import type { UnityTerrain } from "./terrain"
|
|
11
|
+
|
|
12
|
+
type LayerPlan = { keep: number[], mergeInto: Map<number, number> }
|
|
13
|
+
|
|
14
|
+
/** Four layers is what the LeCodes terrain material blends: merge the closest-coloured pairs (by
|
|
15
|
+
* mean albedo) until four remain, the lesser-covered layer folding into the other. */
|
|
16
|
+
const planLayers = async (t: UnityTerrain): Promise<LayerPlan> => {
|
|
17
|
+
const n = t.layers.length
|
|
18
|
+
const cover = t.weights.map((w) => { let s = 0; for (let i = 0; i < w.length; i += 7) s += w[i]; return s })
|
|
19
|
+
const mean: number[][] = []
|
|
20
|
+
for (const l of t.layers) {
|
|
21
|
+
const img = l.diffuse ? await decodeImage(new Uint8Array(readFileSync(l.diffuse)), l.diffuse) : null
|
|
22
|
+
if (!img) { mean.push([ 0.5, 0.5, 0.5 ]); continue }
|
|
23
|
+
let r = 0, g = 0, b = 0, c = 0
|
|
24
|
+
for (let i = 0; i < img.data.length; i += 4 * 97) { r += img.data[i]; g += img.data[i + 1]; b += img.data[i + 2]; c++ }
|
|
25
|
+
mean.push([ r / c / 255, g / c / 255, b / c / 255 ])
|
|
26
|
+
}
|
|
27
|
+
const alive = new Set(Array.from({ length: n }, (_, i) => i))
|
|
28
|
+
const mergeInto = new Map<number, number>()
|
|
29
|
+
while (alive.size > 4) {
|
|
30
|
+
let best: [number, number] = [ -1, -1 ], bd = Infinity
|
|
31
|
+
for (const a of alive) for (const b of alive) {
|
|
32
|
+
if (a >= b) continue
|
|
33
|
+
const d = Math.hypot(mean[a][0] - mean[b][0], mean[a][1] - mean[b][1], mean[a][2] - mean[b][2])
|
|
34
|
+
if (d < bd) { bd = d; best = [ a, b ] }
|
|
35
|
+
}
|
|
36
|
+
const [ a, b ] = best
|
|
37
|
+
const [ win, lose ] = cover[a] >= cover[b] ? [ a, b ] : [ b, a ]
|
|
38
|
+
alive.delete(lose)
|
|
39
|
+
mergeInto.set(lose, win)
|
|
40
|
+
cover[win] += cover[lose]
|
|
41
|
+
for (const [ k, v ] of mergeInto) if (v === lose) mergeInto.set(k, win)
|
|
42
|
+
}
|
|
43
|
+
return { keep: [ ...alive ].sort((a, b) => a - b), mergeInto }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const LTER_MAGIC = 0x5245544c, LTER_VERSION = 1
|
|
47
|
+
|
|
48
|
+
/** Heights (metres) + control (RGBA per sample) → the `.terrain` file the SDK's Terrain.parse reads. */
|
|
49
|
+
const writeTerrainFile = (file: string, sizeX: number, sizeZ: number, cellSize: number, heights: Float32Array, control: Uint8Array, layers: object[]): void => {
|
|
50
|
+
let lo = Infinity, hi = -Infinity
|
|
51
|
+
for (const h of heights) { if (h < lo) lo = h; if (h > hi) hi = h }
|
|
52
|
+
const range = hi - lo > 1e-6 ? hi - lo : 1
|
|
53
|
+
const n = sizeX * sizeZ
|
|
54
|
+
const meta = { sizeX, sizeZ, cellSize, chunk: 64, heightMin: lo, heightMax: hi, layers, blobs: { heights: { fmt: "u16", off: 0, len: n * 2 }, holes: { off: n * 2, len: n }, control: { off: n * 3, len: n * 4 } } }
|
|
55
|
+
const json = new TextEncoder().encode(JSON.stringify(meta))
|
|
56
|
+
const blobStart = (12 + json.length + 3) & ~3
|
|
57
|
+
const out = new ArrayBuffer(blobStart + n * 7)
|
|
58
|
+
const dv = new DataView(out)
|
|
59
|
+
dv.setUint32(0, LTER_MAGIC, true); dv.setUint32(4, LTER_VERSION, true); dv.setUint32(8, json.length, true)
|
|
60
|
+
new Uint8Array(out, 12, json.length).set(json)
|
|
61
|
+
const u16 = new Uint16Array(out, blobStart, n)
|
|
62
|
+
for (let i = 0; i < n; i++) u16[i] = Math.round(((heights[i] - lo) / range) * 65535)
|
|
63
|
+
new Uint8Array(out, blobStart + n * 3, n * 4).set(control)
|
|
64
|
+
writeFileSync(file, new Uint8Array(out))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The `.terrain` file, the packed albedo file per kept layer, and the packed normal textures. */
|
|
68
|
+
export type WrittenTerrain = { file: string, layers: string[], normals: string[] }
|
|
69
|
+
|
|
70
|
+
export const writeTerrain = async (r: SceneImportFull, dir: string, name: string, opts: { ktx2?: boolean, terrainRes?: number }, log: (s: string) => void): Promise<WrittenTerrain | null> => {
|
|
71
|
+
const t = r.terrainData
|
|
72
|
+
if (!t || !r.terrain) return null
|
|
73
|
+
mkdirSync(dir, { recursive: true })
|
|
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})`)
|
|
84
|
+
// mirror the columns: LeCodes sample (0,0) is the Unity terrain's +X far corner
|
|
85
|
+
const heights = new Float32Array(res * res)
|
|
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)]
|
|
87
|
+
const plan = await planLayers(t)
|
|
88
|
+
const slot = new Map<number, number>()
|
|
89
|
+
plan.keep.forEach((li, s) => slot.set(li, s))
|
|
90
|
+
for (const [ lose, win ] of plan.mergeInto) slot.set(lose, slot.get(win)!)
|
|
91
|
+
// control: area-average the (finer) alphamaps onto the samples
|
|
92
|
+
const ar = t.alphaResolution, control = new Uint8Array(res * res * 4)
|
|
93
|
+
const f = ar / src
|
|
94
|
+
for (let z = 0; z < res; z++) for (let x = 0; x < res; x++) {
|
|
95
|
+
const ux = (res - 1 - x) * step, uz = z * step // mirrored column, in source samples
|
|
96
|
+
const acc = [ 0, 0, 0, 0 ]
|
|
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))
|
|
99
|
+
for (let li = 0; li < t.weights.length; li++) {
|
|
100
|
+
const w = t.weights[li]
|
|
101
|
+
let s = 0
|
|
102
|
+
for (let az = az0; az <= az1; az++) for (let ax = ax0; ax <= ax1; ax++) s += w[az * ar + ax]
|
|
103
|
+
acc[slot.get(li)!] += s
|
|
104
|
+
}
|
|
105
|
+
const tot = acc[0] + acc[1] + acc[2] + acc[3] || 1
|
|
106
|
+
for (let c = 0; c < 4; c++) control[(z * res + x) * 4 + c] = Math.round(acc[c] / tot * 255)
|
|
107
|
+
}
|
|
108
|
+
const kept = plan.keep.map((li) => t.layers[li])
|
|
109
|
+
const file = join(dir, `${name}.terrain`)
|
|
110
|
+
// ROUGHNESS (terrain.mat: a layer's roughness = its factor × the ALPHA of its albedo, per pixel). An HDRP
|
|
111
|
+
// layer with a mask map keeps its smoothness in the mask's ALPHA and `m_Smoothness` is a MULTIPLIER on
|
|
112
|
+
// it (1 in every NatureManufacture layer) — read as the smoothness itself it made the whole terrain a
|
|
113
|
+
// mirror (factor 0: the ground sparkled under the sun, 2026-09-23). Now the mask rides the albedo's
|
|
114
|
+
// alpha as 1 − smoothness (terrainPack.ts `smoothnessAlpha`) and the factor is 1 — the multiplier is
|
|
115
|
+
// not expressible per pixel and is dropped (logged). No mask = the number IS the
|
|
116
|
+
// smoothness (Unity's built-in terrain), so the factor is 1 − it.
|
|
117
|
+
const layers = kept.map((l) => ({ tiling: l.tileSize[0], roughness: l.mask ? 1 : Math.min(1, Math.max(0, 1 - l.smoothness)), normalScale: l.normal ? l.normalScale : 0, triplanar: false }))
|
|
118
|
+
log(` 🪨 terrain roughness: ${kept.map((l, i) => `${l.name} ${l.mask ? "mask alpha" : "flat"} ×${layers[i].roughness.toFixed(2)}`).join(", ")}`)
|
|
119
|
+
writeTerrainFile(file, res, res, t.size[0] / (res - 1), heights, control, layers)
|
|
120
|
+
log(`🏔 terrain ${res}² · ${r3(t.size[0] / (res - 1))} m cells · layers ${kept.map((l) => l.name).join(", ")}${plan.mergeInto.size ? ` (merged: ${[ ...plan.mergeInto ].map(([ a, b ]) => `${t.layers[a].name} → ${t.layers[b].name}`).join(", ")})` : ""}`)
|
|
121
|
+
const { packTerrainTextures } = await import("../terrainPack")
|
|
122
|
+
const packed = await packTerrainTextures(kept.map((l) => ({ name: l.name.replace(/^LAYER_/i, "").toLowerCase(), albedo: l.diffuse!, normal: l.normal ?? undefined, smoothnessAlpha: l.mask ?? undefined, albedoScale: l.albedoScale })), { outDir: dir, size: 1024, png: opts.ktx2 === false, log: () => {} })
|
|
123
|
+
return { file, layers: packed.layers.map((l) => l.file), normals: packed.normals }
|
|
124
|
+
}
|
|
125
|
+
|