lecodes-assets 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +240 -5
- package/dist/cli.js +7757 -683
- package/dist/index.js +7866 -675
- package/package.json +13 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli.ts +1039 -0
- package/src/doctor/doctor.ts +241 -0
- package/src/doctor/lightmapUv.ts +452 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +124 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +109 -0
- package/src/fbx/toDocument.ts +209 -0
- package/src/filmstrip.ts +263 -0
- package/src/index.ts +17 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +171 -0
- package/src/retarget/humanoid.ts +561 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2272 -0
- package/src/retarget/rig.ts +542 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +132 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +151 -0
- package/src/terrainPack.ts +169 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/materials.ts +593 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabs.ts +80 -0
- package/src/unity/scene.ts +363 -0
- package/src/unity/unpack.ts +130 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +114 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materials.ts +291 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +659 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +51 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/texture.ts +202 -0
- package/src/unreal/toDocument.ts +360 -0
- package/src/unreal/versions.ts +100 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Unity `.anim` (AnimationClip) reader — the clips that never made it into an FBX.
|
|
2
|
+
//
|
|
3
|
+
// Asset packs routinely ship a rig as FBX but its animations as Unity's own AnimationClip assets:
|
|
4
|
+
// the polyperfect cat and all three dogs have EMPTY FBX files and eight `.anim` clips each (Sit,
|
|
5
|
+
// Sleep, StandUp, …) that no glTF tool can see. When the package was serialised as text those are
|
|
6
|
+
// plain YAML, so the curves are readable without Unity.
|
|
7
|
+
//
|
|
8
|
+
// Only what a retarget needs is parsed — rotation, position and scale curves keyed by transform
|
|
9
|
+
// PATH — with a hand-rolled line scanner rather than a YAML library: these files run to tens of
|
|
10
|
+
// thousands of lines (a 2.6 MB Sleep clip) and the shape is fixed and shallow.
|
|
11
|
+
//
|
|
12
|
+
// Two traps in the format:
|
|
13
|
+
// * `m_EditorCurves` / `m_EulerEditorCurves` repeat every curve component-by-component for the
|
|
14
|
+
// editor's graph view, and make up most of the file. Parsing past them double-counts everything,
|
|
15
|
+
// so the scan stops at the first section that is not one of the three curve lists.
|
|
16
|
+
// * `path` is the transform's FULL hierarchy path from the animator root
|
|
17
|
+
// (`actor:skin_Root_M/actor:skin_Spine1_M/…`), which only matches a rig by its LAST segment.
|
|
18
|
+
import type { Clip, Pose, Quat, Rig, Vec3 } from './rig'
|
|
19
|
+
import { qnorm, qslerp } from './rig'
|
|
20
|
+
|
|
21
|
+
export interface UnityCurve { path: string; bone: string; times: number[]; values: number[][] }
|
|
22
|
+
export interface UnityClip {
|
|
23
|
+
name: string
|
|
24
|
+
duration: number
|
|
25
|
+
sampleRate: number
|
|
26
|
+
rotation: UnityCurve[]
|
|
27
|
+
position: UnityCurve[]
|
|
28
|
+
scale: UnityCurve[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SECTIONS: Record<string, keyof Pick<UnityClip, 'rotation' | 'position' | 'scale'>> = {
|
|
32
|
+
m_RotationCurves: 'rotation',
|
|
33
|
+
m_PositionCurves: 'position',
|
|
34
|
+
m_ScaleCurves: 'scale',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const numbersIn = (s: string): number[] => {
|
|
38
|
+
const out: number[] = []
|
|
39
|
+
for (const m of s.matchAll(/-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g)) out.push(Number(m[0]))
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse one `.anim` file's text. Throws when it is Unity's binary form rather than YAML. */
|
|
44
|
+
export function parseUnityAnim(text: string, fallbackName = 'clip'): UnityClip {
|
|
45
|
+
if (!text.startsWith('%YAML')) {
|
|
46
|
+
throw new Error('not a text-serialised Unity asset — re-export the project with Asset Serialization = Force Text')
|
|
47
|
+
}
|
|
48
|
+
const clip: UnityClip = { name: fallbackName, duration: 0, sampleRate: 30, rotation: [], position: [], scale: [] }
|
|
49
|
+
const lines = text.split('\n')
|
|
50
|
+
|
|
51
|
+
let section: UnityCurve[] | null = null
|
|
52
|
+
let cur: UnityCurve | null = null
|
|
53
|
+
let pending: number | null = null // time of a keyframe whose value line has not arrived
|
|
54
|
+
let stopTime: number | null = null
|
|
55
|
+
let inSettings = false
|
|
56
|
+
|
|
57
|
+
const flush = (): void => {
|
|
58
|
+
if (cur && section && cur.times.length) section.push(cur)
|
|
59
|
+
cur = null
|
|
60
|
+
pending = null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const raw of lines) {
|
|
64
|
+
const line = raw.replace(/\r$/, '')
|
|
65
|
+
// Top-level key (two-space indent): switches or ends a section.
|
|
66
|
+
const top = /^ (m_[A-Za-z]+):(.*)$/.exec(line)
|
|
67
|
+
if (top) {
|
|
68
|
+
flush()
|
|
69
|
+
const key = top[1]
|
|
70
|
+
inSettings = key === 'm_AnimationClipSettings'
|
|
71
|
+
if (key === 'm_Name') { clip.name = top[2].trim() || fallbackName; section = null; continue }
|
|
72
|
+
if (key === 'm_SampleRate') { clip.sampleRate = Number(top[2].trim()) || 30; section = null; continue }
|
|
73
|
+
const which = SECTIONS[key]
|
|
74
|
+
if (which) {
|
|
75
|
+
// `m_RotationCurves: []` — present but empty.
|
|
76
|
+
section = top[2].trim() === '[]' ? null : clip[which]
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
// Anything else ends curve parsing; the editor mirrors below must not be read.
|
|
80
|
+
section = null
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (inSettings) {
|
|
84
|
+
const st = /^ m_StopTime:\s*(\S+)/.exec(line)
|
|
85
|
+
if (st) stopTime = Number(st[1])
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (!section) continue
|
|
89
|
+
|
|
90
|
+
if (/^ - curve:/.test(line)) { flush(); cur = { path: '', bone: '', times: [], values: [] }; continue }
|
|
91
|
+
if (!cur) continue
|
|
92
|
+
|
|
93
|
+
const p = /^ path:\s*(.*)$/.exec(line)
|
|
94
|
+
if (p) {
|
|
95
|
+
cur.path = p[1].trim()
|
|
96
|
+
cur.bone = cur.path.split('/').pop() ?? cur.path
|
|
97
|
+
flush()
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
const t = /^\s+time:\s*(\S+)\s*$/.exec(line)
|
|
101
|
+
if (t) { pending = Number(t[1]); continue }
|
|
102
|
+
const v = /^\s+value:\s*\{(.*)\}\s*$/.exec(line)
|
|
103
|
+
if (v && pending !== null) {
|
|
104
|
+
cur.times.push(pending)
|
|
105
|
+
cur.values.push(numbersIn(v[1]))
|
|
106
|
+
pending = null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
flush()
|
|
110
|
+
|
|
111
|
+
let maxT = 0
|
|
112
|
+
for (const list of [ clip.rotation, clip.position, clip.scale ]) {
|
|
113
|
+
for (const c of list) maxT = Math.max(maxT, c.times[c.times.length - 1] ?? 0)
|
|
114
|
+
}
|
|
115
|
+
clip.duration = stopTime !== null && stopTime > 0 ? stopTime : maxT
|
|
116
|
+
return clip
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const sample = (c: UnityCurve, time: number, n: number): number[] => {
|
|
120
|
+
const last = c.times.length - 1
|
|
121
|
+
if (time <= c.times[0]) return c.values[0].slice(0, n)
|
|
122
|
+
if (time >= c.times[last]) return c.values[last].slice(0, n)
|
|
123
|
+
let i = 0
|
|
124
|
+
while (i < last && c.times[i + 1] < time) i++
|
|
125
|
+
const u = (time - c.times[i]) / (c.times[i + 1] - c.times[i] || 1)
|
|
126
|
+
const a = c.values[i], b = c.values[i + 1]
|
|
127
|
+
// Unity writes Hermite tangents too; linear/slerp between keys is what a 24-30 fps baked curve
|
|
128
|
+
// reproduces to well under a degree, and a retarget resamples at its own rate anyway.
|
|
129
|
+
if (n === 4) return qslerp(a as unknown as Quat, b as unknown as Quat, u)
|
|
130
|
+
return [ 0, 1, 2 ].map((k) => a[k] + (b[k] - a[k]) * u)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface AttachReport { name: string; bound: number; total: number; unbound: string[] }
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Turn parsed `.anim` clips into `Clip`s driving `rig`, binding each curve's LAST path segment to a
|
|
137
|
+
* bone name. Curves that bind nothing (a mesh group, a control rig) are counted and reported.
|
|
138
|
+
*/
|
|
139
|
+
export function attachUnityClips(rig: Rig, clips: UnityClip[]): { clips: Clip[]; report: AttachReport[] } {
|
|
140
|
+
const out: Clip[] = []
|
|
141
|
+
const report: AttachReport[] = []
|
|
142
|
+
for (const uc of clips) {
|
|
143
|
+
const bind = (list: UnityCurve[], n: number): { node: number; c: UnityCurve; n: number }[] => {
|
|
144
|
+
const res: { node: number; c: UnityCurve; n: number }[] = []
|
|
145
|
+
for (const c of list) {
|
|
146
|
+
const node = rig.index.get(c.bone)
|
|
147
|
+
if (node !== undefined) res.push({ node, c, n })
|
|
148
|
+
}
|
|
149
|
+
return res
|
|
150
|
+
}
|
|
151
|
+
const rot = bind(uc.rotation, 4), pos = bind(uc.position, 3), scl = bind(uc.scale, 3)
|
|
152
|
+
const total = uc.rotation.length + uc.position.length + uc.scale.length
|
|
153
|
+
const bound = rot.length + pos.length + scl.length
|
|
154
|
+
const unbound = [ ...uc.rotation, ...uc.position, ...uc.scale ]
|
|
155
|
+
.filter((c) => !rig.index.has(c.bone)).map((c) => c.bone)
|
|
156
|
+
report.push({ name: uc.name, bound, total, unbound: [ ...new Set(unbound) ] })
|
|
157
|
+
const base = rig.bind.local
|
|
158
|
+
out.push({
|
|
159
|
+
name: uc.name,
|
|
160
|
+
duration: uc.duration,
|
|
161
|
+
sample: (time: number): Pose => {
|
|
162
|
+
const pose: Pose = { t: base.t.map((v) => [ ...v ] as Vec3), r: base.r.map((v) => [ ...v ] as Quat), s: base.s.map((v) => [ ...v ] as Vec3) }
|
|
163
|
+
for (const { node, c } of rot) pose.r[node] = qnorm(sample(c, time, 4) as Quat)
|
|
164
|
+
for (const { node, c } of pos) pose.t[node] = sample(c, time, 3) as Vec3
|
|
165
|
+
for (const { node, c } of scl) pose.s[node] = sample(c, time, 3) as Vec3
|
|
166
|
+
return pose
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
return { clips: out, report }
|
|
171
|
+
}
|
|
@@ -0,0 +1,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
|
+
/** which clips carry LEVELS: a staircase or a platform (see `stairsMatcher`) */
|
|
16
|
+
stairs?: (clip: string) => LevelKind
|
|
17
|
+
/** sampling rate of the measurement (default 60) */
|
|
18
|
+
fps?: number
|
|
19
|
+
/** only these clips (default: all) */
|
|
20
|
+
clips?: string[]
|
|
21
|
+
warn?: (message: string) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const lower = (s: string): string => s.toLowerCase()
|
|
25
|
+
const shortName = (s: string): string => { const i = s.lastIndexOf(':'); return i >= 0 ? s.slice(i + 1) : s }
|
|
26
|
+
/** 1 left / 2 right / 0 unknown — the runtime's `sideOf` (curves.cpp). */
|
|
27
|
+
const sideOf = (full: string): 0 | 1 | 2 => {
|
|
28
|
+
const n = lower(shortName(full))
|
|
29
|
+
const has = (s: string): boolean => n.includes(s)
|
|
30
|
+
const starts = (s: string): boolean => n.startsWith(s)
|
|
31
|
+
const ends = (s: string): boolean => n.endsWith(s)
|
|
32
|
+
if (has('left') || starts('l_') || starts('l.') || starts('l ') || ends('_l') || ends('.l') || ends(' l') || has('lft')) return 1
|
|
33
|
+
if (has('right') || starts('r_') || starts('r.') || starts('r ') || ends('_r') || ends('.r') || ends(' r') || has('rgt')) return 2
|
|
34
|
+
const raw = shortName(full)
|
|
35
|
+
if (raw.length > 1 && raw[0] === 'l' && raw[1] === raw[1]!.toUpperCase() && raw[1] !== raw[1]!.toLowerCase()) return 1
|
|
36
|
+
if (raw.length > 1 && raw[0] === 'r' && raw[1] === raw[1]!.toUpperCase() && raw[1] !== raw[1]!.toLowerCase()) return 2
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
const isFootName = (s: string): boolean => { const n = lower(shortName(s)); return n.includes('foot') || n.includes('ankle') }
|
|
40
|
+
const isToeName = (s: string): boolean => { const n = lower(shortName(s)); return n.includes('toe') || n.includes('ball') }
|
|
41
|
+
const isEndName = (s: string): boolean => { const n = lower(shortName(s)); return n.endsWith('_end') || n.endsWith('end') && n.includes('toe') }
|
|
42
|
+
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]))
|
|
43
|
+
|
|
44
|
+
/** Measure and write every clip's marks. Returns one row per clip written. */
|
|
45
|
+
export const writeMarks = (doc: Document, opts: WriteMarksOptions = {}): { clip: string; marks: ClipMarks; travel: [ number, number ] }[] => {
|
|
46
|
+
const rig = rigFromDocument(doc, 'marks')
|
|
47
|
+
const fps = opts.fps ?? 60
|
|
48
|
+
const depth = (i: number): number => { let d = 0; for (let p = rig.parents[i]!; p >= 0; p = rig.parents[p]!) d++; return d }
|
|
49
|
+
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 }
|
|
50
|
+
// the feet: per side the shallowest foot-named joint, its contact = the first toe-named descendant that is not a leaf end
|
|
51
|
+
const packFeet: { foot: number; contact: number }[] = [ { foot: -1, contact: -1 }, { foot: -1, contact: -1 } ]
|
|
52
|
+
const feet = packFeet
|
|
53
|
+
rig.names.forEach((n, i) => {
|
|
54
|
+
if (!n || !isFootName(n) || isEndName(n)) return
|
|
55
|
+
const s = sideOf(n)
|
|
56
|
+
if (!s) return
|
|
57
|
+
const f = feet[s - 1]!
|
|
58
|
+
if (f.foot < 0 || depth(i) < depth(f.foot)) f.foot = i
|
|
59
|
+
})
|
|
60
|
+
for (const f of feet) {
|
|
61
|
+
if (f.foot < 0) continue
|
|
62
|
+
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 }
|
|
63
|
+
if (f.contact < 0) f.contact = f.foot
|
|
64
|
+
}
|
|
65
|
+
if (feet[0]!.foot < 0 && feet[1]!.foot < 0) throw new Error('writeMarks: no foot bones found (names with "foot"/"ankle" per side)')
|
|
66
|
+
const anims = doc.getRoot().listAnimations()
|
|
67
|
+
const nodes = doc.getRoot().listNodes()
|
|
68
|
+
const rows: { clip: string; marks: ClipMarks; travel: [ number, number ] }[] = []
|
|
69
|
+
for (const clip of rig.clips) {
|
|
70
|
+
if (opts.clips && !opts.clips.includes(clip.name)) continue
|
|
71
|
+
const anim = anims.find((a) => a.getName() === clip.name)
|
|
72
|
+
if (!anim) continue
|
|
73
|
+
// A GLB that merged several rigs (a donor built from many clip files) has several joints per
|
|
74
|
+
// name: use the feet THIS clip animates — the ones under a node with a channel — else the
|
|
75
|
+
// pack's feet found above (a native pack has one rig)
|
|
76
|
+
const animated = new Set<number>()
|
|
77
|
+
for (const ch of anim.listChannels()) { const i = nodes.indexOf(ch.getTargetNode()!); if (i >= 0) animated.add(i) }
|
|
78
|
+
const feet = packFeet.map((f) => ({ ...f }))
|
|
79
|
+
if (animated.size) {
|
|
80
|
+
const under = (j: number): boolean => { for (let p = j; p >= 0; p = rig.parents[p]!) if (animated.has(p)) return true; return false }
|
|
81
|
+
rig.names.forEach((n, i) => {
|
|
82
|
+
if (!n || !isFootName(n) || isEndName(n) || !under(i)) return
|
|
83
|
+
const sd = sideOf(n)
|
|
84
|
+
if (!sd) return
|
|
85
|
+
const f = feet[sd - 1]!
|
|
86
|
+
if (f.foot < 0 || !under(f.foot) || depth(i) < depth(f.foot)) { f.foot = i; f.contact = -1 }
|
|
87
|
+
})
|
|
88
|
+
for (const f of feet) {
|
|
89
|
+
if (f.foot < 0 || f.contact >= 0) continue
|
|
90
|
+
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 }
|
|
91
|
+
if (f.contact < 0) f.contact = f.foot
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// the clip's root: the shallowest joint it translates among the feet's ancestors (the hips when
|
|
95
|
+
// nothing above them moves) — travel and the `at` frame are measured against it
|
|
96
|
+
let rootIdx = -1
|
|
97
|
+
for (const ch of anim.listChannels()) {
|
|
98
|
+
if (ch.getTargetPath() !== 'translation') continue
|
|
99
|
+
const i = nodes.indexOf(ch.getTargetNode()!)
|
|
100
|
+
if (i < 0) continue
|
|
101
|
+
const above = feet.some((f) => f.foot >= 0 && isDesc(f.foot, i))
|
|
102
|
+
if (above && (rootIdx < 0 || depth(i) < depth(rootIdx))) rootIdx = i
|
|
103
|
+
}
|
|
104
|
+
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]! }
|
|
105
|
+
const n = Math.max(2, Math.round(clip.duration * fps) + 1)
|
|
106
|
+
const dt = clip.duration / (n - 1)
|
|
107
|
+
const root: Vec3[] = [], heading: number[] = []
|
|
108
|
+
const tracks: (FootTrack | null)[] = feet.map((f) => (f.foot >= 0 ? { contact: [], ankle: [] } : null))
|
|
109
|
+
for (let k = 0; k < n; k++) {
|
|
110
|
+
const w = worldOf(rig, clip.sample(k * dt))
|
|
111
|
+
root.push(w.pos[rootIdx]!)
|
|
112
|
+
heading.push(headingOf(w.rot[rootIdx]!))
|
|
113
|
+
feet.forEach((f, s) => { const t = tracks[s]; if (t) { t.contact.push(w.pos[f.contact]!); t.ankle.push(w.pos[f.foot]!) } })
|
|
114
|
+
// (the standalone writer measures feet only — a native pack's hands are the retargeter's concern)
|
|
115
|
+
}
|
|
116
|
+
const kind = opts.stairs?.(clip.name) ?? false
|
|
117
|
+
const floor = Math.min(...feet.filter((f) => f.foot >= 0).map((f) => rig.bind.worldPos[f.contact]![1]))
|
|
118
|
+
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 })
|
|
119
|
+
for (const w of res.warnings) opts.warn?.(`${clip.name}: ${w}`)
|
|
120
|
+
const marks: ClipMarks = {
|
|
121
|
+
feet: { l: tracks[0] ? rig.names[feet[0]!.contact]! : null, r: tracks[1] ? rig.names[feet[1]!.contact]! : null },
|
|
122
|
+
l: res.l, r: res.r, first: res.first, ...(res.stairs ? { stairs: res.stairs } : {}),
|
|
123
|
+
}
|
|
124
|
+
const travel: [ number, number ] = [ root[n - 1]![0] - root[0]![0], root[n - 1]![2] - root[0]![2] ]
|
|
125
|
+
const extras = (anim.getExtras() ?? {}) as Record<string, unknown>
|
|
126
|
+
// the HEADING curve (heading.ts): the smoothed direction of the root's travel — the node's turn under root rotation
|
|
127
|
+
const headingBaked = headingCurve(root, heading, dt, { endAt: opts.headingEnd })
|
|
128
|
+
anim.setExtras({ ...extras, lecodes: { ...((extras.lecodes as Record<string, unknown>) ?? {}), contacts: marks, travel, heading: headingBaked } })
|
|
129
|
+
rows.push({ clip: clip.name, marks, travel })
|
|
130
|
+
}
|
|
131
|
+
return rows
|
|
132
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Reading back a KTX1 cubemap so `sky` can report what the environment actually contains — above
|
|
2
|
+
// all WHERE ITS SUN IS. A scene lights itself with a directional light that the artist aims by hand;
|
|
3
|
+
// if that direction disagrees with the sun visible in the sky, every shadow in the level points
|
|
4
|
+
// somewhere the picture says it shouldn't. Measuring it beats guessing.
|
|
5
|
+
//
|
|
6
|
+
// cmgen writes KTX1 with GL_R11F_G11F_B10F (0x8C3A): one 32-bit word per texel, 6 faces in the
|
|
7
|
+
// order +X -X +Y -Y +Z -Z, mip levels back to back, each level prefixed by its per-face byte size.
|
|
8
|
+
|
|
9
|
+
export type SkyAnalysis = {
|
|
10
|
+
size: number
|
|
11
|
+
faces: number
|
|
12
|
+
mips: number
|
|
13
|
+
/** Unit vector pointing TOWARD the brightest texel — where the sun is in the sky. */
|
|
14
|
+
toSun: [number, number, number]
|
|
15
|
+
/** What a `light.direction` must be to match it: the negated `toSun`. */
|
|
16
|
+
lightDirection: [number, number, number]
|
|
17
|
+
/** Degrees above the horizon; negative means the brightest point is below it. */
|
|
18
|
+
elevation: number
|
|
19
|
+
/** Colour of the bright core (every texel within a decade of the peak), normalized so the
|
|
20
|
+
* brightest channel is 1 — the light source's tint, not one noisy texel's. */
|
|
21
|
+
sunTint: string
|
|
22
|
+
/** Peak luminance. A real sun disc reads in the thousands; a few units means an overcast or
|
|
23
|
+
* post-sunset sky with no disc at all, where a strong directional light would be wrong. */
|
|
24
|
+
peakLuminance: number
|
|
25
|
+
/** Mean luminance over the whole sphere, and over the upper hemisphere alone. */
|
|
26
|
+
meanLuminance: number
|
|
27
|
+
upperMeanLuminance: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const readU32 = (b: Uint8Array, o: number): number =>
|
|
31
|
+
(b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0
|
|
32
|
+
|
|
33
|
+
/** One 11/11/10 packed float triple → linear RGB. */
|
|
34
|
+
const unpackR11G11B10 = (w: number): [number, number, number] => {
|
|
35
|
+
const part = (exp: number, mant: number, mbits: number): number => {
|
|
36
|
+
if (exp === 0) return (mant / (1 << mbits)) * 2 ** -14
|
|
37
|
+
if (exp === 31) return 65504
|
|
38
|
+
return (1 + mant / (1 << mbits)) * 2 ** (exp - 15)
|
|
39
|
+
}
|
|
40
|
+
return [
|
|
41
|
+
part((w >>> 6) & 0x1f, w & 0x3f, 6),
|
|
42
|
+
part((w >>> 17) & 0x1f, (w >>> 11) & 0x3f, 6),
|
|
43
|
+
part((w >>> 27) & 0x1f, (w >>> 22) & 0x1f, 5),
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Cubemap face + in-face uv → direction, matching filament's face order and orientation. */
|
|
48
|
+
const dirFor = (face: number, s: number, t: number): [number, number, number] => {
|
|
49
|
+
const a = 2 * s - 1
|
|
50
|
+
const b = 1 - 2 * t
|
|
51
|
+
switch (face) {
|
|
52
|
+
case 0: return [1, b, -a]
|
|
53
|
+
case 1: return [-1, b, a]
|
|
54
|
+
case 2: return [a, 1, -b]
|
|
55
|
+
case 3: return [a, -1, b]
|
|
56
|
+
case 4: return [a, b, 1]
|
|
57
|
+
default: return [-a, b, -1]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const hex2 = (v: number): string => Math.max(0, Math.min(255, Math.round(v * 255))).toString(16).padStart(2, "0")
|
|
62
|
+
|
|
63
|
+
/** Scan mip 0 of a cmgen KTX1 cubemap. `step` subsamples — 4 keeps a 512 cube under a few ms. */
|
|
64
|
+
export const analyzeSkyKtx = (buf: Uint8Array, step = 4): SkyAnalysis => {
|
|
65
|
+
if (buf.length < 64 || buf[1] !== 0x4b || buf[2] !== 0x54 || buf[3] !== 0x58) {
|
|
66
|
+
throw new Error("not a KTX1 file")
|
|
67
|
+
}
|
|
68
|
+
const width = readU32(buf, 12 + 6 * 4)
|
|
69
|
+
const height = readU32(buf, 12 + 7 * 4)
|
|
70
|
+
const faces = readU32(buf, 12 + 10 * 4)
|
|
71
|
+
const mips = readU32(buf, 12 + 11 * 4)
|
|
72
|
+
const kvLen = readU32(buf, 12 + 12 * 4)
|
|
73
|
+
if (faces !== 6) throw new Error(`expected a cubemap (6 faces), got ${faces}`)
|
|
74
|
+
|
|
75
|
+
const base = 64 + kvLen
|
|
76
|
+
const faceBytes = readU32(buf, base)
|
|
77
|
+
const data = base + 4
|
|
78
|
+
|
|
79
|
+
let peak = -1
|
|
80
|
+
let peakDir: [number, number, number] = [0, 1, 0]
|
|
81
|
+
let total = 0
|
|
82
|
+
let count = 0
|
|
83
|
+
let upper = 0
|
|
84
|
+
let upperCount = 0
|
|
85
|
+
|
|
86
|
+
for (let face = 0; face < 6; face++) {
|
|
87
|
+
const start = data + face * faceBytes
|
|
88
|
+
for (let y = 0; y < height; y += step) {
|
|
89
|
+
for (let x = 0; x < width; x += step) {
|
|
90
|
+
const [r, g, b] = unpackR11G11B10(readU32(buf, start + (y * width + x) * 4))
|
|
91
|
+
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
92
|
+
total += lum
|
|
93
|
+
count++
|
|
94
|
+
const d = dirFor(face, (x + 0.5) / width, (y + 0.5) / height)
|
|
95
|
+
if (d[1] > 0) { upper += lum; upperCount++ }
|
|
96
|
+
if (lum > peak) { peak = lum; peakDir = d }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Second pass for the TINT. One texel's colour is a bad answer: the single brightest sample can
|
|
102
|
+
// sit on the rim of a clipped sun where the sky's blue still dominates, which reported a blue sun
|
|
103
|
+
// for a clear-day HDRI. Average the whole bright core instead — every texel within a decade of
|
|
104
|
+
// the peak — so the tint describes the light source rather than one pixel of it.
|
|
105
|
+
const cutoff = peak * 0.1
|
|
106
|
+
let sr = 0, sg = 0, sb = 0, sn = 0
|
|
107
|
+
for (let face = 0; face < 6 && cutoff > 0; face++) {
|
|
108
|
+
const start = data + face * faceBytes
|
|
109
|
+
for (let y = 0; y < height; y += step) {
|
|
110
|
+
for (let x = 0; x < width; x += step) {
|
|
111
|
+
const [r, g, b] = unpackR11G11B10(readU32(buf, start + (y * width + x) * 4))
|
|
112
|
+
if (0.2126 * r + 0.7152 * g + 0.0722 * b < cutoff) continue
|
|
113
|
+
sr += r; sg += g; sb += b; sn++
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const peakRgb: [number, number, number] = sn > 0 ? [sr / sn, sg / sn, sb / sn] : [1, 1, 1]
|
|
118
|
+
|
|
119
|
+
const len = Math.hypot(peakDir[0], peakDir[1], peakDir[2]) || 1
|
|
120
|
+
const toSun: [number, number, number] = [peakDir[0] / len, peakDir[1] / len, peakDir[2] / len]
|
|
121
|
+
const mx = Math.max(peakRgb[0], peakRgb[1], peakRgb[2]) || 1
|
|
122
|
+
const r2 = (v: number): number => Math.round(v * 100) / 100
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
size: width,
|
|
126
|
+
faces,
|
|
127
|
+
mips,
|
|
128
|
+
toSun: [r2(toSun[0]), r2(toSun[1]), r2(toSun[2])],
|
|
129
|
+
lightDirection: [r2(-toSun[0]), r2(-toSun[1]), r2(-toSun[2])],
|
|
130
|
+
elevation: Math.round(Math.asin(Math.max(-1, Math.min(1, toSun[1]))) * (180 / Math.PI) * 10) / 10,
|
|
131
|
+
sunTint: `#${hex2(peakRgb[0] / mx)}${hex2(peakRgb[1] / mx)}${hex2(peakRgb[2] / mx)}`,
|
|
132
|
+
peakLuminance: Math.round(peak * 10) / 10,
|
|
133
|
+
meanLuminance: Math.round((total / Math.max(1, count)) * 1000) / 1000,
|
|
134
|
+
upperMeanLuminance: Math.round((upper / Math.max(1, upperCount)) * 1000) / 1000,
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/sky/cmgen.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Locating filament's cmgen — the tool that turns an equirectangular HDR into the cubemaps the
|
|
2
|
+
// engine wants. Same shape as findToktx (doctor/transcodeKtx2.ts): env override, then the places it
|
|
3
|
+
// realistically sits, then PATH.
|
|
4
|
+
//
|
|
5
|
+
// cmgen is not bundled: most projects never build an environment, and the binary is ~600 KB per
|
|
6
|
+
// platform. Resolution order:
|
|
7
|
+
// 1. LECODES_CMGEN — explicit path (CI, or a local filament build)
|
|
8
|
+
// 2. ~/.lecodes/cmgen/**/cmgen[.exe] — the cache `lecodes` downloads into, newest first
|
|
9
|
+
// 3. a sibling desktop build tree — working in this monorepo, cmgen is already built
|
|
10
|
+
// 4. PATH
|
|
11
|
+
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
12
|
+
import { homedir } from "node:os"
|
|
13
|
+
import { dirname, join, resolve } from "node:path"
|
|
14
|
+
import { spawnSync } from "node:child_process"
|
|
15
|
+
|
|
16
|
+
const exeName = (): string => (process.platform === "win32" ? "cmgen.exe" : "cmgen")
|
|
17
|
+
|
|
18
|
+
/** Depth-limited hunt for the binary inside a directory (a release archive may nest a folder). */
|
|
19
|
+
const findIn = (dir: string, depth = 3): string | null => {
|
|
20
|
+
if (depth < 0 || !existsSync(dir)) return null
|
|
21
|
+
let dirs: string[] = []
|
|
22
|
+
try {
|
|
23
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
24
|
+
const p = join(dir, e.name)
|
|
25
|
+
if (e.isFile() && e.name === exeName()) return p
|
|
26
|
+
if (e.isDirectory()) dirs.push(p)
|
|
27
|
+
}
|
|
28
|
+
} catch { return null }
|
|
29
|
+
// newest first: a cache holds cmgen-<version> folders and the freshest should win
|
|
30
|
+
dirs.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
|
|
31
|
+
for (const d of dirs) { const hit = findIn(d, depth - 1); if (hit) return hit }
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Walk up from here looking for the monorepo's desktop build, where filament's host tools land. */
|
|
36
|
+
const fromBuildTree = (): string | null => {
|
|
37
|
+
let dir = resolve(process.cwd())
|
|
38
|
+
for (let i = 0; i < 6; i++) {
|
|
39
|
+
const hit = join(dir, "packages", "desktop", "build", "creator-gl", "filament", "tools", "cmgen", exeName())
|
|
40
|
+
if (existsSync(hit)) return hit
|
|
41
|
+
const up = dirname(dir)
|
|
42
|
+
if (up === dir) break
|
|
43
|
+
dir = up
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const findCmgen = (): string => {
|
|
49
|
+
const env = process.env.LECODES_CMGEN
|
|
50
|
+
if (env) {
|
|
51
|
+
if (!existsSync(env)) throw new Error(`LECODES_CMGEN points at "${env}" which doesn't exist.`)
|
|
52
|
+
return env
|
|
53
|
+
}
|
|
54
|
+
return findIn(join(homedir(), ".lecodes", "cmgen")) ?? fromBuildTree() ?? exeName()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Version string when the binary runs, else null — cmgen has no --version, so -h is the probe. */
|
|
58
|
+
export const checkCmgen = (cmgen: string): string | null => {
|
|
59
|
+
try {
|
|
60
|
+
const p = spawnSync(cmgen, ["-h"], { encoding: "utf8" })
|
|
61
|
+
// cmgen exits non-zero on -h; what matters is that it ran and printed its own banner
|
|
62
|
+
const out = `${p.stdout ?? ""}${p.stderr ?? ""}`
|
|
63
|
+
if (p.error) return null
|
|
64
|
+
return /cmgen/i.test(out) ? "cmgen" : null
|
|
65
|
+
} catch { return null }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const CMGEN_HELP =
|
|
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 (packages/desktop/build.ps1) and it lands at\n` +
|
|
71
|
+
` packages/desktop/build/creator-gl/filament/tools/cmgen/, or set LECODES_CMGEN=<path to cmgen>.`
|
package/src/sky/index.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Equirectangular HDR (.exr / .hdr) → the two cubemaps a LeCodes scene needs.
|
|
2
|
+
//
|
|
3
|
+
// The thing everyone gets wrong, so it is worth stating plainly: an environment is TWO files and
|
|
4
|
+
// they are NOT interchangeable.
|
|
5
|
+
//
|
|
6
|
+
// sky.ktx — sharp, single mip. What is DRAWN behind the scene (`skybox: { texture }`).
|
|
7
|
+
// ibl.ktx — prefiltered per roughness + spherical harmonics. What LIGHTS the scene.
|
|
8
|
+
//
|
|
9
|
+
// Handing the IBL to the skybox is the tempting shortcut (one file, and it "works") but that file
|
|
10
|
+
// has been blurred on purpose, so the sky comes out visibly soft. cmgen emits both for this reason;
|
|
11
|
+
// this command just makes the pair reproducible and names them the way the runtime expects.
|
|
12
|
+
//
|
|
13
|
+
// The IBL is deliberately smaller: it is only ever sampled as low-frequency lighting, so 256 is
|
|
14
|
+
// plenty, while the sky is looked at directly and wants 512+.
|
|
15
|
+
import { spawnSync } from "node:child_process"
|
|
16
|
+
import { existsSync, mkdirSync, mkdtempSync, copyFileSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"
|
|
17
|
+
import { tmpdir } from "node:os"
|
|
18
|
+
import { basename, extname, join, resolve } from "node:path"
|
|
19
|
+
import { findCmgen, checkCmgen, CMGEN_HELP } from "./cmgen"
|
|
20
|
+
import { analyzeSkyKtx, type SkyAnalysis } from "./analyze"
|
|
21
|
+
|
|
22
|
+
export type SkyOptions = {
|
|
23
|
+
/** Where the pair lands (default: beside the input). */
|
|
24
|
+
outDir?: string
|
|
25
|
+
/** Cubemap edge for the drawn sky (default 512). */
|
|
26
|
+
size?: number
|
|
27
|
+
/** Cubemap edge for the lighting probe (default 256). */
|
|
28
|
+
iblSize?: number
|
|
29
|
+
/** File names, without extension (default "sky" and "ibl" — what the runtime looks for). */
|
|
30
|
+
skyName?: string
|
|
31
|
+
iblName?: string
|
|
32
|
+
/** Skip the sky, emit only the lighting probe. */
|
|
33
|
+
iblOnly?: boolean
|
|
34
|
+
log?: (msg: string) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type SkyResult = {
|
|
38
|
+
skyPath: string | null
|
|
39
|
+
iblPath: string | null
|
|
40
|
+
analysis: SkyAnalysis | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const runCmgen = (cmgen: string, args: string[]): void => {
|
|
44
|
+
const p = spawnSync(cmgen, args, { encoding: "utf8" })
|
|
45
|
+
if (p.error) throw new Error(`cmgen failed to start: ${p.error.message}`)
|
|
46
|
+
if (p.status !== 0) {
|
|
47
|
+
const out = `${p.stdout ?? ""}${p.stderr ?? ""}`.trim()
|
|
48
|
+
throw new Error(`cmgen exited with ${p.status}${out ? `:\n${out}` : ""}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The file cmgen's --deploy wrote for a given role, whatever it chose to call the folder. */
|
|
53
|
+
const deployed = (dir: string, suffix: string): string | null => {
|
|
54
|
+
if (!existsSync(dir)) return null
|
|
55
|
+
const hit = readdirSync(dir).find((f) => f.endsWith(`${suffix}.ktx`))
|
|
56
|
+
return hit ? join(dir, hit) : null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const buildSky = async (input: string, opts: SkyOptions = {}): Promise<SkyResult> => {
|
|
60
|
+
const log = opts.log ?? (() => {})
|
|
61
|
+
if (!existsSync(input)) throw new Error(`not found: ${input}`)
|
|
62
|
+
|
|
63
|
+
const ext = extname(input).toLowerCase()
|
|
64
|
+
if (![".exr", ".hdr", ".png", ".psd"].includes(ext)) {
|
|
65
|
+
throw new Error(`unsupported input "${ext}" — cmgen reads .exr, .hdr, .png or .psd (equirectangular).`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const cmgen = findCmgen()
|
|
69
|
+
if (!checkCmgen(cmgen)) throw new Error(CMGEN_HELP)
|
|
70
|
+
|
|
71
|
+
const outDir = resolve(opts.outDir ?? resolve(input, ".."))
|
|
72
|
+
mkdirSync(outDir, { recursive: true })
|
|
73
|
+
const size = opts.size ?? 512
|
|
74
|
+
const iblSize = opts.iblSize ?? 256
|
|
75
|
+
const skyName = opts.skyName ?? "sky"
|
|
76
|
+
const iblName = opts.iblName ?? "ibl"
|
|
77
|
+
|
|
78
|
+
const stage = mkdtempSync(join(tmpdir(), "lecodes-sky-"))
|
|
79
|
+
const result: SkyResult = { skyPath: null, iblPath: null, analysis: null }
|
|
80
|
+
try {
|
|
81
|
+
// Two passes: the sky and the probe want different resolutions, and --deploy sizes both at once.
|
|
82
|
+
if (!opts.iblOnly) {
|
|
83
|
+
log(` cubemap ${size}… (sky)`)
|
|
84
|
+
const d = join(stage, "sky")
|
|
85
|
+
runCmgen(cmgen, ["-q", "--format=ktx", `--size=${size}`, `--deploy=${d}`, input])
|
|
86
|
+
const src = deployed(d, "_skybox")
|
|
87
|
+
if (!src) throw new Error("cmgen produced no _skybox.ktx")
|
|
88
|
+
result.skyPath = join(outDir, `${skyName}.ktx`)
|
|
89
|
+
copyFileSync(src, result.skyPath)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
log(` cubemap ${iblSize}… (lighting probe + SH)`)
|
|
93
|
+
const d2 = join(stage, "ibl")
|
|
94
|
+
runCmgen(cmgen, ["-q", "--format=ktx", `--size=${iblSize}`, `--deploy=${d2}`, input])
|
|
95
|
+
const iblSrc = deployed(d2, "_ibl")
|
|
96
|
+
if (!iblSrc) throw new Error("cmgen produced no _ibl.ktx")
|
|
97
|
+
result.iblPath = join(outDir, `${iblName}.ktx`)
|
|
98
|
+
copyFileSync(iblSrc, result.iblPath)
|
|
99
|
+
|
|
100
|
+
// Analyse whichever cubemap is sharpest — the sky when we made one, else the probe.
|
|
101
|
+
const toScan = result.skyPath ?? result.iblPath
|
|
102
|
+
try {
|
|
103
|
+
result.analysis = analyzeSkyKtx(new Uint8Array(readFileSync(toScan)))
|
|
104
|
+
} catch (e) {
|
|
105
|
+
log(` (could not analyse the environment: ${(e as Error).message})`)
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
rmSync(stage, { recursive: true, force: true })
|
|
109
|
+
}
|
|
110
|
+
return result
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The report + the scene snippet to paste. The analysis is the point of the command: it turns
|
|
114
|
+
* "aim the sun by eye until the shadows look right" into a number read off the environment. */
|
|
115
|
+
export const formatSkyReport = (r: SkyResult, name = "SKY"): string => {
|
|
116
|
+
const out: string[] = []
|
|
117
|
+
const mb = (p: string): string => `${(statSync(p).size / 1048576).toFixed(1)} MB`
|
|
118
|
+
if (r.skyPath) out.push(`💾 ${r.skyPath} (${mb(r.skyPath)}) — drawn as the sky`)
|
|
119
|
+
if (r.iblPath) out.push(`💾 ${r.iblPath} (${mb(r.iblPath)}) — lights the scene`)
|
|
120
|
+
const a = r.analysis
|
|
121
|
+
if (!a) return out.join("\n")
|
|
122
|
+
|
|
123
|
+
const [dx, dy, dz] = a.lightDirection
|
|
124
|
+
out.push("")
|
|
125
|
+
out.push(`🔆 sun at ${a.elevation}° above the horizon · tint ${a.sunTint} · peak ${a.peakLuminance}`)
|
|
126
|
+
if (a.peakLuminance < 100) {
|
|
127
|
+
out.push(
|
|
128
|
+
` NOTE: that peak is low — this environment has no sun DISC (overcast, or the sun is below\n` +
|
|
129
|
+
` the horizon). The direction below is just the brightest part of the sky glow, and a strong\n` +
|
|
130
|
+
` white directional light under it will read as wrong as one pointing the wrong way.`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
// Sunlight is never blue. A blue-dominant core means the disc is clipped in this HDRI and the
|
|
134
|
+
// reading has picked up the intensely blue sky glow around it — common in the 1k/2k versions.
|
|
135
|
+
// The DIRECTION is still trustworthy; the colour is not, so say so instead of handing over a
|
|
136
|
+
// number that would light the level with blue "sunlight".
|
|
137
|
+
const blueSun = parseInt(a.sunTint.slice(5, 7), 16) > parseInt(a.sunTint.slice(1, 3), 16) + 8
|
|
138
|
+
if (blueSun && a.peakLuminance >= 100) {
|
|
139
|
+
out.push(
|
|
140
|
+
` NOTE: that tint is BLUE-dominant, which no sun is — the disc is clipped in this file and the\n` +
|
|
141
|
+
` reading caught the sky glow around it. Trust the direction, not the colour: use a neutral\n` +
|
|
142
|
+
` warm white (#fff2e2 or so) and tint to taste.`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
out.push(` mean luminance ${a.meanLuminance} (upper hemisphere ${a.upperMeanLuminance}) — the brighter this is,`)
|
|
146
|
+
out.push(` the lower environmentIntensity wants to be.`)
|
|
147
|
+
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}' }`)
|
|
150
|
+
return out.join("\n")
|
|
151
|
+
}
|