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
package/src/retarget/rig.ts
CHANGED
|
@@ -268,7 +268,10 @@ export const bindVsRest = (rig: Rig): { worst: number; bone: string; skinned: nu
|
|
|
268
268
|
export const checkUniformScale = (rig: Rig): void => {
|
|
269
269
|
for (let i = 0; i < rig.names.length; i++) {
|
|
270
270
|
const [ a, b, c ] = rig.rest.s[i]
|
|
271
|
-
|
|
271
|
+
// RELATIVE: a Blender 0.01 armature under a 100× export leaves [99.99986, 99.99999, 100.00001] — float
|
|
272
|
+
// noise, not a squash (an absolute 1e-4 refused every rig-studio cloak bone)
|
|
273
|
+
const tol = 1e-5 * Math.max(1, Math.abs(a), Math.abs(b), Math.abs(c))
|
|
274
|
+
if (Math.abs(a - b) > tol || Math.abs(b - c) > tol) {
|
|
272
275
|
throw new Error(`${rig.label}: bone "${rig.names[i]}" has a non-uniform rest scale [${rig.rest.s[i]}] — retargeting needs uniform scales`)
|
|
273
276
|
}
|
|
274
277
|
}
|
|
@@ -1,132 +1,134 @@
|
|
|
1
|
-
// Foot marks for a GLB that was NOT retargeted (a pack whose clips were captured on the model's own
|
|
2
|
-
// rig): every clip is posed on the document's skeleton, the feet found by name (the same vocabulary
|
|
3
|
-
// the runtime uses: a `foot`/`ankle` bone per side, its `toe`/`ball` child as the contact), the marks
|
|
4
|
-
// measured (marks.ts) and written to the animation's `extras.lecodes.contacts` — the block
|
|
5
|
-
// `retarget()` writes for the clips it produces.
|
|
6
|
-
|
|
7
|
-
import type { Document } from '@gltf-transform/core'
|
|
8
|
-
import { rigFromDocument, worldOf, type Vec3, type Quat } from './rig'
|
|
9
|
-
import { headingCurve } from './heading'
|
|
10
|
-
import { detectMarks, type ClipMarks, type FootTrack, type LevelKind } from './marks'
|
|
11
|
-
|
|
12
|
-
export interface WriteMarksOptions {
|
|
13
|
-
/** the baked heading curve must END here (degrees, + = left) — a stop that turns; heading.ts `endAt` */
|
|
14
|
-
headingEnd?: number
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
return
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (!
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (f.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (!
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
if (animated.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (!
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (f.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
1
|
+
// Foot marks for a GLB that was NOT retargeted (a pack whose clips were captured on the model's own
|
|
2
|
+
// rig): every clip is posed on the document's skeleton, the feet found by name (the same vocabulary
|
|
3
|
+
// the runtime uses: a `foot`/`ankle` bone per side, its `toe`/`ball` child as the contact), the marks
|
|
4
|
+
// measured (marks.ts) and written to the animation's `extras.lecodes.contacts` — the block
|
|
5
|
+
// `retarget()` writes for the clips it produces.
|
|
6
|
+
|
|
7
|
+
import type { Document } from '@gltf-transform/core'
|
|
8
|
+
import { rigFromDocument, worldOf, type Vec3, type Quat } from './rig'
|
|
9
|
+
import { headingCurve } from './heading'
|
|
10
|
+
import { detectMarks, type ClipMarks, type FootTrack, type LevelKind } from './marks'
|
|
11
|
+
|
|
12
|
+
export interface WriteMarksOptions {
|
|
13
|
+
/** the baked heading curve must END here (degrees, + = left) — a stop that turns; heading.ts `endAt` */
|
|
14
|
+
headingEnd?: number
|
|
15
|
+
/** these clips are LOOPS (heading.ts `loop`: the travel direction smoothed periodically) */
|
|
16
|
+
loops?: string[]
|
|
17
|
+
/** which clips carry LEVELS: a staircase or a platform (see `stairsMatcher`) */
|
|
18
|
+
stairs?: (clip: string) => LevelKind
|
|
19
|
+
/** sampling rate of the measurement (default 60) */
|
|
20
|
+
fps?: number
|
|
21
|
+
/** only these clips (default: all) */
|
|
22
|
+
clips?: string[]
|
|
23
|
+
warn?: (message: string) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const lower = (s: string): string => s.toLowerCase()
|
|
27
|
+
const shortName = (s: string): string => { const i = s.lastIndexOf(':'); return i >= 0 ? s.slice(i + 1) : s }
|
|
28
|
+
/** 1 left / 2 right / 0 unknown — the runtime's `sideOf` (curves.cpp). */
|
|
29
|
+
const sideOf = (full: string): 0 | 1 | 2 => {
|
|
30
|
+
const n = lower(shortName(full))
|
|
31
|
+
const has = (s: string): boolean => n.includes(s)
|
|
32
|
+
const starts = (s: string): boolean => n.startsWith(s)
|
|
33
|
+
const ends = (s: string): boolean => n.endsWith(s)
|
|
34
|
+
if (has('left') || starts('l_') || starts('l.') || starts('l ') || ends('_l') || ends('.l') || ends(' l') || has('lft')) return 1
|
|
35
|
+
if (has('right') || starts('r_') || starts('r.') || starts('r ') || ends('_r') || ends('.r') || ends(' r') || has('rgt')) return 2
|
|
36
|
+
const raw = shortName(full)
|
|
37
|
+
if (raw.length > 1 && raw[0] === 'l' && raw[1] === raw[1]!.toUpperCase() && raw[1] !== raw[1]!.toLowerCase()) return 1
|
|
38
|
+
if (raw.length > 1 && raw[0] === 'r' && raw[1] === raw[1]!.toUpperCase() && raw[1] !== raw[1]!.toLowerCase()) return 2
|
|
39
|
+
return 0
|
|
40
|
+
}
|
|
41
|
+
const isFootName = (s: string): boolean => { const n = lower(shortName(s)); return n.includes('foot') || n.includes('ankle') }
|
|
42
|
+
const isToeName = (s: string): boolean => { const n = lower(shortName(s)); return n.includes('toe') || n.includes('ball') }
|
|
43
|
+
const isEndName = (s: string): boolean => { const n = lower(shortName(s)); return n.endsWith('_end') || n.endsWith('end') && n.includes('toe') }
|
|
44
|
+
const headingOf = (q: Quat): number => Math.atan2(2 * (q[0] * q[2] + q[3] * q[1]), 1 - 2 * (q[0] * q[0] + q[1] * q[1]))
|
|
45
|
+
|
|
46
|
+
/** Measure and write every clip's marks. Returns one row per clip written. */
|
|
47
|
+
export const writeMarks = (doc: Document, opts: WriteMarksOptions = {}): { clip: string; marks: ClipMarks; travel: [ number, number ] }[] => {
|
|
48
|
+
const rig = rigFromDocument(doc, 'marks')
|
|
49
|
+
const fps = opts.fps ?? 60
|
|
50
|
+
const depth = (i: number): number => { let d = 0; for (let p = rig.parents[i]!; p >= 0; p = rig.parents[p]!) d++; return d }
|
|
51
|
+
const isDesc = (j: number, anc: number): boolean => { for (let p = rig.parents[j]!; p >= 0; p = rig.parents[p]!) if (p === anc) return true; return false }
|
|
52
|
+
// the feet: per side the shallowest foot-named joint, its contact = the first toe-named descendant that is not a leaf end
|
|
53
|
+
const packFeet: { foot: number; contact: number }[] = [ { foot: -1, contact: -1 }, { foot: -1, contact: -1 } ]
|
|
54
|
+
const feet = packFeet
|
|
55
|
+
rig.names.forEach((n, i) => {
|
|
56
|
+
if (!n || !isFootName(n) || isEndName(n)) return
|
|
57
|
+
const s = sideOf(n)
|
|
58
|
+
if (!s) return
|
|
59
|
+
const f = feet[s - 1]!
|
|
60
|
+
if (f.foot < 0 || depth(i) < depth(f.foot)) f.foot = i
|
|
61
|
+
})
|
|
62
|
+
for (const f of feet) {
|
|
63
|
+
if (f.foot < 0) continue
|
|
64
|
+
for (let j = 0; j < rig.names.length; j++) if (isToeName(rig.names[j]!) && !isEndName(rig.names[j]!) && isDesc(j, f.foot)) { f.contact = j; break }
|
|
65
|
+
if (f.contact < 0) f.contact = f.foot
|
|
66
|
+
}
|
|
67
|
+
if (feet[0]!.foot < 0 && feet[1]!.foot < 0) throw new Error('writeMarks: no foot bones found (names with "foot"/"ankle" per side)')
|
|
68
|
+
const anims = doc.getRoot().listAnimations()
|
|
69
|
+
const nodes = doc.getRoot().listNodes()
|
|
70
|
+
const rows: { clip: string; marks: ClipMarks; travel: [ number, number ] }[] = []
|
|
71
|
+
for (const clip of rig.clips) {
|
|
72
|
+
if (opts.clips && !opts.clips.includes(clip.name)) continue
|
|
73
|
+
const anim = anims.find((a) => a.getName() === clip.name)
|
|
74
|
+
if (!anim) continue
|
|
75
|
+
// A GLB that merged several rigs (a donor built from many clip files) has several joints per
|
|
76
|
+
// name: use the feet THIS clip animates — the ones under a node with a channel — else the
|
|
77
|
+
// pack's feet found above (a native pack has one rig)
|
|
78
|
+
const animated = new Set<number>()
|
|
79
|
+
for (const ch of anim.listChannels()) { const i = nodes.indexOf(ch.getTargetNode()!); if (i >= 0) animated.add(i) }
|
|
80
|
+
const feet = packFeet.map((f) => ({ ...f }))
|
|
81
|
+
if (animated.size) {
|
|
82
|
+
const under = (j: number): boolean => { for (let p = j; p >= 0; p = rig.parents[p]!) if (animated.has(p)) return true; return false }
|
|
83
|
+
rig.names.forEach((n, i) => {
|
|
84
|
+
if (!n || !isFootName(n) || isEndName(n) || !under(i)) return
|
|
85
|
+
const sd = sideOf(n)
|
|
86
|
+
if (!sd) return
|
|
87
|
+
const f = feet[sd - 1]!
|
|
88
|
+
if (f.foot < 0 || !under(f.foot) || depth(i) < depth(f.foot)) { f.foot = i; f.contact = -1 }
|
|
89
|
+
})
|
|
90
|
+
for (const f of feet) {
|
|
91
|
+
if (f.foot < 0 || f.contact >= 0) continue
|
|
92
|
+
for (let j = 0; j < rig.names.length; j++) if (isToeName(rig.names[j]!) && !isEndName(rig.names[j]!) && isDesc(j, f.foot)) { f.contact = j; break }
|
|
93
|
+
if (f.contact < 0) f.contact = f.foot
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// the clip's root: the shallowest joint it translates among the feet's ancestors (the hips when
|
|
97
|
+
// nothing above them moves) — travel and the `at` frame are measured against it
|
|
98
|
+
let rootIdx = -1
|
|
99
|
+
for (const ch of anim.listChannels()) {
|
|
100
|
+
if (ch.getTargetPath() !== 'translation') continue
|
|
101
|
+
const i = nodes.indexOf(ch.getTargetNode()!)
|
|
102
|
+
if (i < 0) continue
|
|
103
|
+
const above = feet.some((f) => f.foot >= 0 && isDesc(f.foot, i))
|
|
104
|
+
if (above && (rootIdx < 0 || depth(i) < depth(rootIdx))) rootIdx = i
|
|
105
|
+
}
|
|
106
|
+
if (rootIdx < 0) { const f = feet.find((x) => x.foot >= 0)!; rootIdx = rig.parents[f.foot]!; while (rig.parents[rootIdx]! >= 0 && !/hips|pelvis|root/i.test(rig.names[rootIdx]!)) rootIdx = rig.parents[rootIdx]! }
|
|
107
|
+
const n = Math.max(2, Math.round(clip.duration * fps) + 1)
|
|
108
|
+
const dt = clip.duration / (n - 1)
|
|
109
|
+
const root: Vec3[] = [], heading: number[] = []
|
|
110
|
+
const tracks: (FootTrack | null)[] = feet.map((f) => (f.foot >= 0 ? { contact: [], ankle: [] } : null))
|
|
111
|
+
for (let k = 0; k < n; k++) {
|
|
112
|
+
const w = worldOf(rig, clip.sample(k * dt))
|
|
113
|
+
root.push(w.pos[rootIdx]!)
|
|
114
|
+
heading.push(headingOf(w.rot[rootIdx]!))
|
|
115
|
+
feet.forEach((f, s) => { const t = tracks[s]; if (t) { t.contact.push(w.pos[f.contact]!); t.ankle.push(w.pos[f.foot]!) } })
|
|
116
|
+
// (the standalone writer measures feet only — a native pack's hands are the retargeter's concern)
|
|
117
|
+
}
|
|
118
|
+
const kind = opts.stairs?.(clip.name) ?? false
|
|
119
|
+
const floor = Math.min(...feet.filter((f) => f.foot >= 0).map((f) => rig.bind.worldPos[f.contact]![1]))
|
|
120
|
+
const res = detectMarks({ dt, root, heading, feet: { l: tracks[0] ?? undefined, r: tracks[1] ?? undefined }, stairs: kind === 'stairs' ? true : kind, floor: Number.isFinite(floor) ? floor : undefined })
|
|
121
|
+
for (const w of res.warnings) opts.warn?.(`${clip.name}: ${w}`)
|
|
122
|
+
const marks: ClipMarks = {
|
|
123
|
+
feet: { l: tracks[0] ? rig.names[feet[0]!.contact]! : null, r: tracks[1] ? rig.names[feet[1]!.contact]! : null },
|
|
124
|
+
l: res.l, r: res.r, first: res.first, ...(res.stairs ? { stairs: res.stairs } : {}),
|
|
125
|
+
}
|
|
126
|
+
const travel: [ number, number ] = [ root[n - 1]![0] - root[0]![0], root[n - 1]![2] - root[0]![2] ]
|
|
127
|
+
const extras = (anim.getExtras() ?? {}) as Record<string, unknown>
|
|
128
|
+
// the HEADING curve (heading.ts): the smoothed direction of the root's travel — the node's turn under root rotation
|
|
129
|
+
const headingBaked = headingCurve(root, heading, dt, { endAt: opts.headingEnd, loop: opts.loops?.includes(clip.name) })
|
|
130
|
+
anim.setExtras({ ...extras, lecodes: { ...((extras.lecodes as Record<string, unknown>) ?? {}), contacts: marks, travel, heading: headingBaked } })
|
|
131
|
+
rows.push({ clip: clip.name, marks, travel })
|
|
132
|
+
}
|
|
133
|
+
return rows
|
|
134
|
+
}
|
package/src/sky/cmgen.ts
CHANGED
|
@@ -36,7 +36,7 @@ const findIn = (dir: string, depth = 3): string | null => {
|
|
|
36
36
|
const fromBuildTree = (): string | null => {
|
|
37
37
|
let dir = resolve(process.cwd())
|
|
38
38
|
for (let i = 0; i < 6; i++) {
|
|
39
|
-
const hit = join(dir, "
|
|
39
|
+
const hit = join(dir, "hosts", "desktop", "build", "creator-gl", "filament", "tools", "cmgen", exeName())
|
|
40
40
|
if (existsSync(hit)) return hit
|
|
41
41
|
const up = dirname(dir)
|
|
42
42
|
if (up === dir) break
|
|
@@ -67,5 +67,5 @@ export const checkCmgen = (cmgen: string): string | null => {
|
|
|
67
67
|
|
|
68
68
|
export const CMGEN_HELP =
|
|
69
69
|
`cmgen not found (looked at $LECODES_CMGEN, ~/.lecodes/cmgen, a sibling desktop build, PATH).\n` +
|
|
70
|
-
` It ships with filament: build the desktop host once (
|
|
71
|
-
`
|
|
70
|
+
` It ships with filament: build the desktop host once (hosts/desktop/build.ps1) and it lands at\n` +
|
|
71
|
+
` hosts/desktop/build/creator-gl/filament/tools/cmgen/, or set LECODES_CMGEN=<path to cmgen>.`
|
package/src/sky/index.ts
CHANGED
|
@@ -31,22 +31,40 @@ export type SkyOptions = {
|
|
|
31
31
|
iblName?: string
|
|
32
32
|
/** Skip the sky, emit only the lighting probe. */
|
|
33
33
|
iblOnly?: boolean
|
|
34
|
+
/** Take the SUN out of the lighting probe (cmgen --clamp-sun, the LeCodes fork's): for a level that has a directional
|
|
35
|
+
* sun. The sun in the probe is the same light a second time - from everywhere, without shadows: it flattens every
|
|
36
|
+
* object and halves the contrast of the baked shade. The drawn sky keeps its sun. `true` = 8 degrees. */
|
|
37
|
+
noSun?: boolean | number
|
|
34
38
|
log?: (msg: string) => void
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
/** What `noSun` measured: the disc taken out of the probe, and what the scene should be given for it. */
|
|
42
|
+
export type SunCalibration = {
|
|
43
|
+
/** The disc's illuminance on a surface facing it, in the picture's own units. */
|
|
44
|
+
discUnits: number
|
|
45
|
+
/** Degrees above the horizon (from the unclamped picture). */
|
|
46
|
+
elevation: number
|
|
47
|
+
/** A clear day's direct sun at that elevation, lux: 127 500 x exp(-0.21 / sin(elevation)). */
|
|
48
|
+
sunLux: number
|
|
49
|
+
/** environmentIntensity that makes the picture's own disc that bright: sunLux / discUnits - pixel 1.0 in nits. */
|
|
50
|
+
environmentIntensity: number
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
export type SkyResult = {
|
|
54
|
+
sun?: SunCalibration
|
|
38
55
|
skyPath: string | null
|
|
39
56
|
iblPath: string | null
|
|
40
57
|
analysis: SkyAnalysis | null
|
|
41
58
|
}
|
|
42
59
|
|
|
43
|
-
const runCmgen = (cmgen: string, args: string[]):
|
|
60
|
+
const runCmgen = (cmgen: string, args: string[]): string => {
|
|
44
61
|
const p = spawnSync(cmgen, args, { encoding: "utf8" })
|
|
45
62
|
if (p.error) throw new Error(`cmgen failed to start: ${p.error.message}`)
|
|
46
63
|
if (p.status !== 0) {
|
|
47
64
|
const out = `${p.stdout ?? ""}${p.stderr ?? ""}`.trim()
|
|
48
65
|
throw new Error(`cmgen exited with ${p.status}${out ? `:\n${out}` : ""}`)
|
|
49
66
|
}
|
|
67
|
+
return `${p.stdout ?? ""}${p.stderr ?? ""}`
|
|
50
68
|
}
|
|
51
69
|
|
|
52
70
|
/** The file cmgen's --deploy wrote for a given role, whatever it chose to call the folder. */
|
|
@@ -91,19 +109,42 @@ export const buildSky = async (input: string, opts: SkyOptions = {}): Promise<Sk
|
|
|
91
109
|
|
|
92
110
|
log(` cubemap ${iblSize}… (lighting probe + SH)`)
|
|
93
111
|
const d2 = join(stage, "ibl")
|
|
94
|
-
|
|
112
|
+
let discUnits = 0
|
|
113
|
+
const sunArgs = opts.noSun ? [typeof opts.noSun === "number" ? `--clamp-sun=${opts.noSun}` : "--clamp-sun"] : []
|
|
114
|
+
const iblOut = runCmgen(cmgen, ["-q", "--format=ktx", `--size=${iblSize}`, ...sunArgs, `--deploy=${d2}`, input])
|
|
115
|
+
if (opts.noSun) {
|
|
116
|
+
const line = /Sun clamped:[^\r\n]*/.exec(iblOut)
|
|
117
|
+
if (!line) throw new Error("this cmgen has no --clamp-sun (it is the LeCodes filament fork's): point LECODES_CMGEN at hosts/desktop/build/creator-gl/filament/tools/cmgen")
|
|
118
|
+
log(` ${line[0]}`)
|
|
119
|
+
const disc = /disc's illuminance ([0-9.eE+-]+)/.exec(iblOut)
|
|
120
|
+
if (disc) discUnits = parseFloat(disc[1])
|
|
121
|
+
}
|
|
95
122
|
const iblSrc = deployed(d2, "_ibl")
|
|
96
123
|
if (!iblSrc) throw new Error("cmgen produced no _ibl.ktx")
|
|
97
124
|
result.iblPath = join(outDir, `${iblName}.ktx`)
|
|
98
125
|
copyFileSync(iblSrc, result.iblPath)
|
|
99
126
|
|
|
100
|
-
// Analyse whichever cubemap is sharpest — the sky when we made one, else the probe.
|
|
101
|
-
|
|
127
|
+
// Analyse whichever cubemap is sharpest — the sky when we made one, else the probe. A probe WITHOUT its sun says
|
|
128
|
+
// nothing about where the sun is: with `noSun` and no drawn sky, one more small pass keeps the disc for the reading.
|
|
129
|
+
let toScan = result.skyPath ?? result.iblPath
|
|
130
|
+
if (opts.noSun && !result.skyPath) {
|
|
131
|
+
const d3 = join(stage, "scan")
|
|
132
|
+
runCmgen(cmgen, ["-q", "--format=ktx", `--size=${iblSize}`, `--deploy=${d3}`, input])
|
|
133
|
+
toScan = deployed(d3, "_skybox") ?? deployed(d3, "_ibl") ?? toScan
|
|
134
|
+
}
|
|
102
135
|
try {
|
|
103
136
|
result.analysis = analyzeSkyKtx(new Uint8Array(readFileSync(toScan)))
|
|
104
137
|
} catch (e) {
|
|
105
138
|
log(` (could not analyse the environment: ${(e as Error).message})`)
|
|
106
139
|
}
|
|
140
|
+
// THE CALIBRATION: the picture's units are relative, the sun's illuminance is not. A clear day's direct sun at
|
|
141
|
+
// elevation h is 127 500 x exp(-0.21 / sin h) lux (the extraterrestrial constant through a clear atmosphere's
|
|
142
|
+
// optical depth); environmentIntensity = that / the disc's illuminance in the picture's units - pixel 1.0 in nits.
|
|
143
|
+
const elevation = result.analysis?.elevation ?? 0
|
|
144
|
+
if (discUnits > 0 && elevation > 2 && (result.analysis?.peakLuminance ?? 0) >= 100) {
|
|
145
|
+
const sunLux = 127500 * Math.exp(-0.21 / Math.sin(elevation * Math.PI / 180))
|
|
146
|
+
result.sun = { discUnits, elevation, sunLux, environmentIntensity: sunLux / discUnits }
|
|
147
|
+
}
|
|
107
148
|
} finally {
|
|
108
149
|
rmSync(stage, { recursive: true, force: true })
|
|
109
150
|
}
|
|
@@ -142,10 +183,18 @@ export const formatSkyReport = (r: SkyResult, name = "SKY"): string => {
|
|
|
142
183
|
` warm white (#fff2e2 or so) and tint to taste.`,
|
|
143
184
|
)
|
|
144
185
|
}
|
|
145
|
-
|
|
146
|
-
|
|
186
|
+
const round2 = (v: number): number => { const m = Math.pow(10, Math.max(0, Math.floor(Math.log10(v)) - 1)); return Math.round(v / m) * m }
|
|
187
|
+
const c = r.sun
|
|
188
|
+
if (c) {
|
|
189
|
+
out.push(` CALIBRATED BY THE SUN: its disc is ${c.discUnits.toFixed(2)} of the picture's units of illuminance; a clear day's sun`)
|
|
190
|
+
out.push(` at ${c.elevation}° is ~${round2(c.sunLux)} lux, so pixel 1.0 = ${round2(c.environmentIntensity)} nits. The probe has the disc taken out:`)
|
|
191
|
+
out.push(` the directional light below IS the sun - give it that illuminance, do not tune the two by eye.`)
|
|
192
|
+
} else {
|
|
193
|
+
out.push(` mean luminance ${a.meanLuminance} (upper hemisphere ${a.upperMeanLuminance}) — the brighter this is,`)
|
|
194
|
+
out.push(` the lower environmentIntensity wants to be.${a.peakLuminance >= 100 ? " --no-sun measures the disc and gives the number." : ""}`)
|
|
195
|
+
}
|
|
147
196
|
out.push("")
|
|
148
|
-
out.push(` env: { skybox: { texture: ${name} }, environmentIntensity: 16000 }`)
|
|
149
|
-
out.push(` light: { kind: 'sun', direction: [${dx}, ${dy}, ${dz}], color: '${blueSun ? "#fff2e2" : a.sunTint}' }`)
|
|
197
|
+
out.push(` env: { skybox: { texture: ${name} }, environmentIntensity: ${c ? round2(c.environmentIntensity) : 16000} }`)
|
|
198
|
+
out.push(` light: { kind: 'sun', direction: [${dx}, ${dy}, ${dz}], color: '${blueSun ? "#fff2e2" : a.sunTint}'${c ? `, intensity: ${round2(c.sunLux)}` : ""} }`)
|
|
150
199
|
return out.join("\n")
|
|
151
200
|
}
|
package/src/terrainPack.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// `lecodes assets terrain-pack` — a terrain's splat layers (up to four) → what `Terrain.create`'s
|
|
2
2
|
// `layers`/`normals` read (docs/terrain-plan.md §1.5):
|
|
3
3
|
//
|
|
4
|
-
// <name>.ktx2 one per layer: the albedo, sRGB, square, mipmapped
|
|
4
|
+
// <name>.ktx2 one per layer: the albedo, sRGB, square, mipmapped — with the layer's ROUGHNESS in its ALPHA when
|
|
5
|
+
// the layer has a roughness source (terrain.mat: roughness = the layer's factor x albedo alpha; the
|
|
6
|
+
// one channel the sampler budget leaves free). No source = no alpha = the factor alone.
|
|
5
7
|
// normals01.ktx2 layer 0's normal XY in RG + layer 1's in BA (linear)
|
|
6
8
|
// normals23.ktx2 layers 2 + 3 likewise (only when there are more than two layers)
|
|
7
9
|
//
|
|
@@ -12,30 +14,42 @@
|
|
|
12
14
|
// Inputs: an albedo file per layer (PNG/JPEG/WebP/TIFF via sharp, TGA/BMP decoded here) with the
|
|
13
15
|
// normal map given by `--normals`, guessed from the albedo's name (`<x>_Albedo.png` → `<x>_Normal.png`),
|
|
14
16
|
// or — the Unity-pack shape — a FOLDER per layer holding `*_Albedo.*` + `*_Normal.*`.
|
|
17
|
+
// Roughness: `--roughness` per layer, guessed from the name (`<x>_Roughness.*`), or a folder's `*Roughness*` /
|
|
18
|
+
// `*AlbedoSmoothness*` (Unity: smoothness in the ALPHA, inverted here). A roughness image is read from its GREEN channel:
|
|
19
|
+
// a grey map has it in every channel, a glTF metallicRoughness / ORM map has roughness there.
|
|
15
20
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"
|
|
16
21
|
import { basename, extname, join, resolve } from "node:path"
|
|
17
22
|
import { spawnSync } from "node:child_process"
|
|
18
23
|
import { decodeBmp, decodeTga, encodePng, sniffMime } from "./textures/resolveImage"
|
|
19
|
-
import {
|
|
24
|
+
import { findKtxEncoder, ktxEncodeArgs, KTX_ENCODER_MISSING, type KtxEncoder } from "./doctor/transcodeKtx2"
|
|
20
25
|
|
|
21
|
-
export type TerrainPackLayer = {
|
|
26
|
+
export type TerrainPackLayer = {
|
|
27
|
+
name: string, albedo: string, normal?: string
|
|
28
|
+
/** a roughness image (read from its green channel) … */
|
|
29
|
+
roughness?: string
|
|
30
|
+
/** … or a Unity `*_AlbedoSmoothness` image: roughness = 1 − its alpha */
|
|
31
|
+
smoothnessAlpha?: string
|
|
32
|
+
/** A per-channel scale on the albedo, applied in LINEAR light (Unity's TerrainLayer `m_DiffuseRemapMax`: a tint
|
|
33
|
+
* the artist dialled on the layer, which the packed texture must carry since a layer has no tint of its own). */
|
|
34
|
+
albedoScale?: [number, number, number]
|
|
35
|
+
}
|
|
22
36
|
|
|
23
37
|
export type TerrainPackOptions = {
|
|
24
38
|
/** Where the files land. */
|
|
25
39
|
outDir: string
|
|
26
40
|
/** Output edge in pixels — every texture is resampled square (default 1024). */
|
|
27
41
|
size?: number
|
|
28
|
-
/** Write PNGs instead of KTX2 (no
|
|
42
|
+
/** Write PNGs instead of KTX2 (no KTX-Software needed; larger, uncompressed on the GPU). */
|
|
29
43
|
png?: boolean
|
|
30
44
|
/** Invert the normal maps' green channel (DirectX-style sources; Unity's are OpenGL-style, the default). */
|
|
31
45
|
flipY?: boolean
|
|
32
|
-
/**
|
|
46
|
+
/** UASTC quality 0–4 (default 1). */
|
|
33
47
|
quality?: number
|
|
34
48
|
log?: (msg: string) => void
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
export type TerrainPackResult = {
|
|
38
|
-
layers: { name: string, file: string, albedo: string, normal: string | null, sourceSize: [number, number] }[]
|
|
52
|
+
layers: { name: string, file: string, albedo: string, normal: string | null, roughness: string | null, sourceSize: [number, number] }[]
|
|
39
53
|
normals: string[]
|
|
40
54
|
/** The matching `Terrain.create` fragment. */
|
|
41
55
|
snippet: string
|
|
@@ -44,7 +58,7 @@ export type TerrainPackResult = {
|
|
|
44
58
|
const IMAGE_EXT = /\.(png|jpe?g|webp|tiff?|tga|bmp)$/i
|
|
45
59
|
|
|
46
60
|
/** A layer from a file (+ optional normal) or a folder (`*Albedo*` + `*Normal*` inside). */
|
|
47
|
-
export const resolveTerrainLayer = (input: string, normal?: string, name?: string): TerrainPackLayer => {
|
|
61
|
+
export const resolveTerrainLayer = (input: string, normal?: string, name?: string, roughness?: string): TerrainPackLayer => {
|
|
48
62
|
const p = resolve(input)
|
|
49
63
|
if (!existsSync(p)) throw new Error(`not found: ${input}`)
|
|
50
64
|
if (statSync(p).isDirectory()) {
|
|
@@ -53,7 +67,12 @@ export const resolveTerrainLayer = (input: string, normal?: string, name?: strin
|
|
|
53
67
|
const albedo = files.find((f) => /albedo/i.test(f) && !/smooth/i.test(f)) ?? files.find((f) => /albedo|basecolor|base_color|diffuse|_d\./i.test(f))
|
|
54
68
|
if (!albedo) throw new Error(`${input}: no albedo image (a file named *Albedo* / *BaseColor* / *Diffuse*)`)
|
|
55
69
|
const nrm = normal ?? files.find((f) => /normal|_n\./i.test(f) && !/normalmap_?dx/i.test(f))
|
|
56
|
-
|
|
70
|
+
const rough = roughness ? resolve(roughness) : files.find((f) => /rough/i.test(f))
|
|
71
|
+
const smooth = rough ? undefined : files.find((f) => /albedo.*smooth|smooth.*albedo/i.test(f))
|
|
72
|
+
return {
|
|
73
|
+
name: name ?? cleanName(basename(p)), albedo: join(p, albedo), normal: nrm ? (normal ? resolve(normal) : join(p, nrm)) : undefined,
|
|
74
|
+
roughness: rough ? (roughness ? rough : join(p, rough)) : undefined, smoothnessAlpha: smooth ? join(p, smooth) : undefined,
|
|
75
|
+
}
|
|
57
76
|
}
|
|
58
77
|
if (!IMAGE_EXT.test(p)) throw new Error(`${input}: not an image (png/jpg/webp/tiff/tga/bmp)`)
|
|
59
78
|
let nrm: string | undefined = normal ? resolve(normal) : undefined
|
|
@@ -65,7 +84,16 @@ export const resolveTerrainLayer = (input: string, normal?: string, name?: strin
|
|
|
65
84
|
if (hit) { nrm = hit; break }
|
|
66
85
|
}
|
|
67
86
|
}
|
|
68
|
-
|
|
87
|
+
let rough: string | undefined = roughness ? resolve(roughness) : undefined
|
|
88
|
+
if (!rough) {
|
|
89
|
+
const stem = p.slice(0, -extname(p).length)
|
|
90
|
+
for (const cand of [ stem.replace(/albedo/i, "Roughness"), stem.replace(/albedo/i, "roughness"), `${stem}_Roughness`, `${stem}_roughness`, stem.replace(/_?(basecolor|diffuse|_d)$/i, "_Roughness") ]) {
|
|
91
|
+
if (cand === stem) continue
|
|
92
|
+
const hit = [ ".png", ".jpg", ".jpeg", ".tga", ".webp", ".tif", ".tiff", ".bmp" ].map((e) => cand + e).find((f) => existsSync(f))
|
|
93
|
+
if (hit) { rough = hit; break }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { name: name ?? cleanName(basename(p, extname(p)).replace(/[_-]?(albedo|basecolor|base_color|diffuse)$/i, "")), albedo: p, normal: nrm, roughness: rough }
|
|
69
97
|
}
|
|
70
98
|
|
|
71
99
|
const cleanName = (s: string): string => s.trim().replace(/\s+/g, "_").replace(/[^A-Za-z0-9_-]/g, "").toLowerCase() || "layer"
|
|
@@ -88,10 +116,10 @@ const loadRgba = async (path: string, size: number): Promise<Raw & { sourceSize:
|
|
|
88
116
|
return { data, width: info.width, height: info.height, sourceSize: [ meta.width ?? 0, meta.height ?? 0 ] }
|
|
89
117
|
}
|
|
90
118
|
|
|
91
|
-
const encodeKtx2 = (
|
|
92
|
-
const args =
|
|
93
|
-
const p = spawnSync(
|
|
94
|
-
if (p.status !== 0) throw new Error(
|
|
119
|
+
const encodeKtx2 = (enc: KtxEncoder, png: string, ktx2: string, channels: 3 | 4, srgb: boolean, quality: number): void => {
|
|
120
|
+
const args = ktxEncodeArgs(enc, { input: png, output: ktx2, codec: "uastc", oetf: srgb ? "srgb" : "linear", channels, uastcQuality: quality, zstd: 18 })
|
|
121
|
+
const p = spawnSync(enc.path, args, { encoding: "utf8" })
|
|
122
|
+
if (p.status !== 0) throw new Error(`${enc.kind === "ktx" ? "ktx create" : "toktx"} failed (${p.status ?? p.error?.message ?? "?"}) on ${basename(png)}:\n${(p.stderr || p.stdout || "").trim()}`)
|
|
95
123
|
}
|
|
96
124
|
|
|
97
125
|
export const packTerrainTextures = async (layers: TerrainPackLayer[], opts: TerrainPackOptions): Promise<TerrainPackResult> => {
|
|
@@ -101,10 +129,8 @@ export const packTerrainTextures = async (layers: TerrainPackLayer[], opts: Terr
|
|
|
101
129
|
if (!(size >= 16 && size <= 8192) || (size & (size - 1)) !== 0) throw new Error(`--size wants a power of two in 16..8192, got ${size}`)
|
|
102
130
|
const log = opts.log ?? (() => {})
|
|
103
131
|
const sharp = (await import("sharp")).default
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
106
|
-
throw new Error("KTX-Software's toktx is needed for KTX2 output — install it (https://github.com/KhronosGroup/KTX-Software/releases, on PATH or KTX_TOKTX=<path>) or pass --png")
|
|
107
|
-
}
|
|
132
|
+
const enc = opts.png ? null : findKtxEncoder()
|
|
133
|
+
if (!opts.png && !enc) throw new Error(`${KTX_ENCODER_MISSING}\n Or pass --png.`)
|
|
108
134
|
const seen = new Set<string>()
|
|
109
135
|
for (const l of layers) {
|
|
110
136
|
if (seen.has(l.name)) throw new Error(`two layers named "${l.name}" — pass --names`)
|
|
@@ -118,8 +144,8 @@ export const packTerrainTextures = async (layers: TerrainPackLayer[], opts: Terr
|
|
|
118
144
|
if (channels === 3) pipe = pipe.removeAlpha()
|
|
119
145
|
const png = opts.png ? out : out.slice(0, -ext.length) + ".png"
|
|
120
146
|
await pipe.png({ compressionLevel: opts.png ? 9 : 1 }).toFile(png)
|
|
121
|
-
if (
|
|
122
|
-
encodeKtx2(
|
|
147
|
+
if (enc) {
|
|
148
|
+
encodeKtx2(enc, png, out, channels, srgb, opts.quality ?? 1)
|
|
123
149
|
unlinkSync(png)
|
|
124
150
|
}
|
|
125
151
|
}
|
|
@@ -128,16 +154,29 @@ export const packTerrainTextures = async (layers: TerrainPackLayer[], opts: Terr
|
|
|
128
154
|
const normalRaws: (Raw | null)[] = []
|
|
129
155
|
for (const l of layers) {
|
|
130
156
|
const alb = await loadRgba(l.albedo, size)
|
|
157
|
+
if (l.albedoScale && l.albedoScale.some((s) => Math.abs(s - 1) > 1e-3)) {
|
|
158
|
+
const toLin = (v: number): number => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4))
|
|
159
|
+
const toSrgb = (v: number): number => (v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055)
|
|
160
|
+
const lut = [ 0, 1, 2 ].map((c) => { const t = new Uint8Array(256); for (let i = 0; i < 256; i++) t[i] = Math.round(Math.min(1, toSrgb(toLin(i / 255) * l.albedoScale![c])) * 255); return t })
|
|
161
|
+
for (let i = 0; i < size * size; i++) for (let c = 0; c < 3; c++) alb.data[i * 4 + c] = lut[c][alb.data[i * 4 + c]]
|
|
162
|
+
}
|
|
131
163
|
const file = join(opts.outDir, l.name + ext)
|
|
132
|
-
|
|
164
|
+
// the layer's roughness rides the albedo's alpha (the header); without a source the albedo ships opaque
|
|
165
|
+
const roughSrc = l.roughness ?? l.smoothnessAlpha
|
|
166
|
+
if (roughSrc) {
|
|
167
|
+
if (!existsSync(roughSrc)) throw new Error(`${l.name}: roughness map not found: ${roughSrc}`)
|
|
168
|
+
const r = await loadRgba(roughSrc, size)
|
|
169
|
+
for (let i = 0; i < size * size; i++) alb.data[i * 4 + 3] = l.roughness ? r.data[i * 4 + 1] : 255 - r.data[i * 4 + 3]
|
|
170
|
+
}
|
|
171
|
+
await writeImage(alb, roughSrc ? 4 : 3, file, true)
|
|
133
172
|
let nrm: (Raw & { sourceSize: [number, number] }) | null = null
|
|
134
173
|
if (l.normal) {
|
|
135
174
|
if (!existsSync(l.normal)) throw new Error(`${l.name}: normal map not found: ${l.normal}`)
|
|
136
175
|
nrm = await loadRgba(l.normal, size)
|
|
137
176
|
}
|
|
138
177
|
normalRaws.push(nrm)
|
|
139
|
-
result.layers.push({ name: l.name, file, albedo: l.albedo, normal: l.normal ?? null, sourceSize: alb.sourceSize })
|
|
140
|
-
log(` ${l.name.padEnd(16)} ${alb.sourceSize[0]}×${alb.sourceSize[1]} → ${size}² ${nrm ? `+ normal ${nrm.sourceSize[0]}×${nrm.sourceSize[1]}` : "(flat normal)"}`)
|
|
178
|
+
result.layers.push({ name: l.name, file, albedo: l.albedo, normal: l.normal ?? null, roughness: roughSrc ?? null, sourceSize: alb.sourceSize })
|
|
179
|
+
log(` ${l.name.padEnd(16)} ${alb.sourceSize[0]}×${alb.sourceSize[1]} → ${size}² ${nrm ? `+ normal ${nrm.sourceSize[0]}×${nrm.sourceSize[1]}` : "(flat normal)"}${roughSrc ? ` + roughness in the alpha (${l.roughness ? basename(roughSrc) : "1 − " + basename(roughSrc) + "'s alpha"})` : ""}`)
|
|
141
180
|
}
|
|
142
181
|
|
|
143
182
|
// the packs: RG = layer 2k's XY, BA = layer 2k+1's; a missing map is flat
|