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
|
@@ -3,40 +3,106 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Codec by texture role:
|
|
5
5
|
// - colour (base/emissive) -> ETC1S, sRGB (small, lossy)
|
|
6
|
-
// - data (normal/metalRough/occ.) -> UASTC + zstd, linear (precise)
|
|
6
|
+
// - data (normal/metalRough/occ.) -> UASTC + zstd, linear (precise; ETC1S steps on a smooth channel)
|
|
7
|
+
// - an impostor atlas (either map) -> UASTC + zstd (its colour is the far crown's hue; ETC1S shifts it)
|
|
7
8
|
// …unless `codecPolicy: 'uastc'` asks for UASTC everywhere. That is for HERO assets — a
|
|
8
9
|
// first-person weapon or gloves fill a third of the screen at 30 cm, where ETC1S's block artifacts
|
|
9
10
|
// on the base colour are plainly visible. World props stay on the default: ETC1S is the right
|
|
10
11
|
// trade there and the size difference is roughly 8x.
|
|
11
12
|
// Mipmaps are generated; alpha is preserved (ETC1S/UASTC both encode it).
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
12
14
|
import sharp from 'sharp'
|
|
13
|
-
import { spawnSync } from 'node:child_process'
|
|
15
|
+
import { spawn, spawnSync } from 'node:child_process' // spawnSync only for the one-shot --version probe
|
|
14
16
|
import { existsSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
|
15
|
-
import { tmpdir } from 'node:os'
|
|
17
|
+
import { cpus, tmpdir } from 'node:os'
|
|
16
18
|
import { join } from 'node:path'
|
|
17
19
|
import type { Root, Texture } from '@gltf-transform/core'
|
|
18
|
-
import { getTextureRoles, DEFAULT_MAX_SIZE } from './transcodeTextures'
|
|
20
|
+
import { getTextureRoles, isImpostorAtlas, DEFAULT_MAX_SIZE } from './transcodeTextures'
|
|
19
21
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The KTX2 encoder: KTX-Software's legacy `toktx`, or — when there is none — its unified `ktx`
|
|
24
|
+
* tool's `create` subcommand. KTX-Software 5 dropped `toktx` (the macOS installer ships only `ktx`),
|
|
25
|
+
* 4.4 still has both; the two write the same Basis files from the same inputs, only the flags differ
|
|
26
|
+
* (`ktxEncodeArgs`).
|
|
27
|
+
*/
|
|
28
|
+
export interface KtxEncoder { kind: 'toktx' | 'ktx'; path: string; version: string }
|
|
29
|
+
|
|
30
|
+
export const KTX_ENCODER_MISSING =
|
|
31
|
+
'KTX-Software not found — neither `toktx` nor `ktx` (looked at $KTX_TOKTX, $KTX_TOOL, the installer\'s folders, PATH).\n' +
|
|
32
|
+
' Install it from https://github.com/KhronosGroup/KTX-Software/releases, or set KTX_TOKTX / KTX_TOOL=<path to the binary>.'
|
|
33
|
+
|
|
34
|
+
/** Where KTX-Software's installers put their binaries, per platform. */
|
|
35
|
+
function installDirs(): string[] {
|
|
36
|
+
if (process.platform === 'win32') {
|
|
37
|
+
return [ process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'Programs', 'KTX-Software', 'bin'),
|
|
38
|
+
process.env.ProgramFiles && join(process.env.ProgramFiles, 'KTX-Software', 'bin') ].filter((d): d is string => !!d)
|
|
28
39
|
}
|
|
29
|
-
return '
|
|
40
|
+
return [ '/usr/local/bin', '/opt/homebrew/bin' ]
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
|
|
43
|
+
/** `name` resolved: the env override, then the installer's folders, then PATH (the bare name). */
|
|
44
|
+
function locate(name: 'toktx' | 'ktx', env: string | undefined): string {
|
|
45
|
+
if (env && existsSync(env)) return env
|
|
46
|
+
const exe = process.platform === 'win32' ? `${name}.exe` : name
|
|
47
|
+
return installDirs().map((d) => join(d, exe)).find((p) => existsSync(p)) ?? exe
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Locate the toktx binary: $KTX_TOKTX → the installer's folders → PATH. */
|
|
51
|
+
export const findToktx = (): string => locate('toktx', process.env.KTX_TOKTX)
|
|
52
|
+
|
|
53
|
+
/** Locate KTX-Software's unified `ktx` tool: $KTX_TOOL → next to $KTX_TOKTX → the installer's folders → PATH. */
|
|
54
|
+
export const findKtxCli = (): string =>
|
|
55
|
+
locate('ktx', process.env.KTX_TOOL ?? process.env.KTX_TOKTX?.replace(/toktx(\.exe)?$/i, process.platform === 'win32' ? 'ktx.exe' : 'ktx'))
|
|
56
|
+
|
|
57
|
+
/** The binary's `--version` line, or null when it does not run. */
|
|
58
|
+
export function checkToktx(tool: string): string | null {
|
|
33
59
|
try {
|
|
34
|
-
const p = spawnSync(
|
|
35
|
-
if (p.status === 0) return (p.stdout ?? '').toString().trim() ||
|
|
60
|
+
const p = spawnSync(tool, ['--version'], { encoding: 'utf8' })
|
|
61
|
+
if (p.status === 0) return (p.stdout ?? '').toString().trim() || tool
|
|
36
62
|
} catch { /* not found */ }
|
|
37
63
|
return null
|
|
38
64
|
}
|
|
39
65
|
|
|
66
|
+
/** toktx when it runs, else `ktx` (create), else null. */
|
|
67
|
+
export function findKtxEncoder(): KtxEncoder | null {
|
|
68
|
+
const toktx = findToktx()
|
|
69
|
+
const tv = checkToktx(toktx)
|
|
70
|
+
if (tv) return { kind: 'toktx', path: toktx, version: tv }
|
|
71
|
+
const ktx = findKtxCli()
|
|
72
|
+
const kv = checkToktx(ktx)
|
|
73
|
+
return kv ? { kind: 'ktx', path: ktx, version: kv } : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface KtxEncodeJob {
|
|
77
|
+
input: string
|
|
78
|
+
output: string
|
|
79
|
+
codec: 'etc1s' | 'uastc'
|
|
80
|
+
oetf: 'srgb' | 'linear'
|
|
81
|
+
/** Channels of the input PNG — `ktx create` wants the format spelled out. */
|
|
82
|
+
channels: 3 | 4
|
|
83
|
+
/** UASTC quality 0–4. */
|
|
84
|
+
uastcQuality: number
|
|
85
|
+
/** Zstandard level for UASTC (ETC1S is Basis-LZ, never supercompressed on top). */
|
|
86
|
+
zstd: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The encoder's command line for one PNG → KTX2 (always 2D, mipmapped, the transfer function assigned). */
|
|
90
|
+
export function ktxEncodeArgs(enc: KtxEncoder, j: KtxEncodeJob): string[] {
|
|
91
|
+
if (enc.kind === 'toktx') {
|
|
92
|
+
return [ '--t2', ...(j.codec === 'etc1s'
|
|
93
|
+
? [ '--encode', 'etc1s', '--clevel', '2', '--qlevel', '128' ]
|
|
94
|
+
: [ '--encode', 'uastc', '--uastc_quality', String(j.uastcQuality), '--zcmp', String(j.zstd) ]),
|
|
95
|
+
'--genmipmap', '--assign_oetf', j.oetf, j.output, j.input ]
|
|
96
|
+
}
|
|
97
|
+
const format = `${j.channels === 4 ? 'R8G8B8A8' : 'R8G8B8'}_${j.oetf === 'srgb' ? 'SRGB' : 'UNORM'}`
|
|
98
|
+
return [ 'create', '--format', format, ...(j.codec === 'etc1s'
|
|
99
|
+
? [ '--encode', 'basis-lz', '--clevel', '2', '--qlevel', '128' ]
|
|
100
|
+
: [ '--encode', 'uastc', '--uastc-quality', String(j.uastcQuality), '--zstd', String(j.zstd) ]),
|
|
101
|
+
// --assign-tf: sharp's PNGs carry no colour chunk, which `ktx` reads as sRGB and then refuses to
|
|
102
|
+
// put into a *_UNORM format without a conversion — the data maps ARE linear, so say so.
|
|
103
|
+
'--generate-mipmap', '--assign-tf', j.oetf, j.input, j.output ]
|
|
104
|
+
}
|
|
105
|
+
|
|
40
106
|
export interface Ktx2Plan {
|
|
41
107
|
tex: Texture
|
|
42
108
|
name: string
|
|
@@ -73,15 +139,20 @@ export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_S
|
|
|
73
139
|
return null
|
|
74
140
|
}
|
|
75
141
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
142
|
+
// DATA maps (normal, metal / rough, occlusion) always take UASTC: ETC1S is a palette codec whose blocks show
|
|
143
|
+
// as steps on a smooth metallic or roughness channel (the range's metal props read pixelated under the
|
|
144
|
+
// reflection probes, 2026-09-17) and it wrecks normals; colour stays on ETC1S (small, its artefacts hide in the
|
|
145
|
+
// detail) unless 'uastc' asks for UASTC everywhere (hero assets). 'hq' is the same as the default now.
|
|
78
146
|
// oetf: linear for any non-colour data.
|
|
147
|
+
// An impostor atlas (both maps) takes UASTC too: at the distance the quad shows, one texel is the whole screen pixel
|
|
148
|
+
// of a crown and ETC1S's palette quantisation shifts the crown's hue against the cards it replaces.
|
|
79
149
|
const codec: 'etc1s' | 'uastc' =
|
|
80
150
|
policy === 'uastc' ? 'uastc'
|
|
81
|
-
: isNormal
|
|
151
|
+
: isNormal || isData || isImpostorAtlas(tex) ? 'uastc'
|
|
82
152
|
: 'etc1s'
|
|
83
153
|
const oetf: 'srgb' | 'linear' = isNormal || isData ? 'linear' : 'srgb'
|
|
84
|
-
|
|
154
|
+
// an impostor atlas keeps its size whatever the cap (its frames are fixed pixel rects); still ×4 and mipmapped
|
|
155
|
+
const resize = !isImpostorAtlas(tex) && Math.max(width, height) > maxSize
|
|
85
156
|
return {
|
|
86
157
|
tex, name: tex.getName() || '?', roles, fromFmt, codec, oetf, resize, width, height, hasAlpha,
|
|
87
158
|
beforeBytes: img.length, category: 'lossy',
|
|
@@ -89,7 +160,90 @@ export async function planKtx2(tex: Texture, root: Root, maxSize = DEFAULT_MAX_S
|
|
|
89
160
|
}
|
|
90
161
|
}
|
|
91
162
|
|
|
92
|
-
|
|
163
|
+
/**
|
|
164
|
+
* ENCODED KTX2 BY CONTENT, for the life of the process.
|
|
165
|
+
*
|
|
166
|
+
* Encoding is pure: the same pixels at the same cap, codec and transfer function give the same file.
|
|
167
|
+
* And a LEVEL import asks for the same pixels over and over — every kind is its own self-contained
|
|
168
|
+
* GLB, so a wall texture shared by a hundred buildings is embedded, and therefore encoded, a hundred
|
|
169
|
+
* times. Measured on the Chernobyl import: **3554 embedded textures of which 314 are distinct**, i.e.
|
|
170
|
+
* 11.3 encodes of each on average and 144 of the most-shared ones. Each of those is a temp dir, a
|
|
171
|
+
* sharp decode + PNG re-encode, and a SYNCHRONOUS `toktx` process — which is also why the importer
|
|
172
|
+
* sat at 1.4 of this machine's 24 cores while it ran.
|
|
173
|
+
*
|
|
174
|
+
* Keyed on everything that changes the output and nothing else.
|
|
175
|
+
*/
|
|
176
|
+
const encoded = new Map<string, Uint8Array>()
|
|
177
|
+
let hits = 0
|
|
178
|
+
/** Encodes RUNNING right now, by cache key: a second caller for the same image waits on the first
|
|
179
|
+
* instead of starting its own toktx. Without it the concurrency below would turn the cache's whole
|
|
180
|
+
* point inside out — a texture on a hundred props would encode a hundred times, in parallel. */
|
|
181
|
+
const inFlight = new Map<string, Promise<Uint8Array>>()
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* How many toktx processes may run at once. The encode is an external process per texture, so the
|
|
185
|
+
* work is ENTIRELY outside this thread and the only reason to cap it is not to swamp the machine;
|
|
186
|
+
* one per core, minus one for us, is the usual shape. `LECODES_KTX_JOBS` overrides it (1 = the old
|
|
187
|
+
* strictly-sequential behaviour, for a bisect).
|
|
188
|
+
*/
|
|
189
|
+
const JOBS = Math.max(1, Number(process.env.LECODES_KTX_JOBS) || Math.max(1, cpus().length - 1))
|
|
190
|
+
let running = 0
|
|
191
|
+
const waiting: (() => void)[] = []
|
|
192
|
+
const acquire = async (): Promise<void> => {
|
|
193
|
+
if (running < JOBS) { running++; return }
|
|
194
|
+
await new Promise<void>((res) => waiting.push(res))
|
|
195
|
+
running++
|
|
196
|
+
}
|
|
197
|
+
const release = (): void => { running--; waiting.shift()?.() }
|
|
198
|
+
|
|
199
|
+
export const ktx2Jobs = (): number => JOBS
|
|
200
|
+
|
|
201
|
+
/** The encoder as a promise. `spawnSync` blocked the event loop for the whole encode, which made every
|
|
202
|
+
* texture in a project strictly sequential no matter how many cores were idle. */
|
|
203
|
+
const runEncoder = (enc: KtxEncoder, args: string[], name: string): Promise<void> =>
|
|
204
|
+
new Promise((resolve, reject) => {
|
|
205
|
+
const proc = spawn(enc.path, args, { windowsHide: true })
|
|
206
|
+
let err = ''
|
|
207
|
+
proc.stderr?.on('data', (d) => { err += String(d) })
|
|
208
|
+
proc.on('error', reject)
|
|
209
|
+
proc.on('close', (code) => code === 0
|
|
210
|
+
? resolve()
|
|
211
|
+
: reject(new Error(`${enc.kind === 'ktx' ? 'ktx create' : 'toktx'} failed (exit ${code}) for "${name}": ${err.slice(0, 300)}`)))
|
|
212
|
+
})
|
|
213
|
+
/** How many encodes this process served from the cache (for a caller that wants to report it). */
|
|
214
|
+
export const ktx2CacheHits = (): number => hits
|
|
215
|
+
|
|
216
|
+
export async function applyKtx2(plan: Ktx2Plan, enc: KtxEncoder, maxSize = DEFAULT_MAX_SIZE): Promise<void> {
|
|
217
|
+
const src = plan.tex.getImage()
|
|
218
|
+
const key = src
|
|
219
|
+
? `${createHash('sha1').update(src).digest('hex')}:${maxSize}:${plan.codec}:${plan.oetf}:${plan.hasAlpha ? 1 : 0}`
|
|
220
|
+
: null
|
|
221
|
+
if (key) {
|
|
222
|
+
const hit = encoded.get(key)
|
|
223
|
+
if (hit) { hits++; plan.tex.setImage(hit).setMimeType('image/ktx2'); plan.afterBytes = hit.length; return }
|
|
224
|
+
// the same image, already being encoded by another caller: wait on that one
|
|
225
|
+
const busy = inFlight.get(key)
|
|
226
|
+
if (busy) {
|
|
227
|
+
hits++
|
|
228
|
+
const bytes = await busy
|
|
229
|
+
plan.tex.setImage(bytes).setMimeType('image/ktx2')
|
|
230
|
+
plan.afterBytes = bytes.length
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const work = encodeOne(plan, enc, maxSize)
|
|
235
|
+
if (key) {
|
|
236
|
+
inFlight.set(key, work)
|
|
237
|
+
try { encoded.set(key, await work) } finally { inFlight.delete(key) }
|
|
238
|
+
}
|
|
239
|
+
const bytes = await work
|
|
240
|
+
plan.tex.setImage(bytes).setMimeType('image/ktx2')
|
|
241
|
+
plan.afterBytes = bytes.length
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** One texture through sharp + the encoder, returning the encoded bytes. Holds a pool slot only around
|
|
245
|
+
* the external process, so the sharp resize of the next texture overlaps with this one's encode. */
|
|
246
|
+
async function encodeOne(plan: Ktx2Plan, enc: KtxEncoder, maxSize: number): Promise<Uint8Array> {
|
|
93
247
|
const dir = mkdtempSync(join(tmpdir(), 'glbktx-'))
|
|
94
248
|
const inPng = join(dir, 'in.png')
|
|
95
249
|
const outKtx = join(dir, 'out.ktx2')
|
|
@@ -100,24 +254,25 @@ export async function applyKtx2(plan: Ktx2Plan, toktx: string, maxSize = DEFAULT
|
|
|
100
254
|
const snap4 = (n: number) => Math.max(4, Math.round(n / 4) * 4)
|
|
101
255
|
let tw = plan.width, th = plan.height
|
|
102
256
|
const m = Math.max(tw, th)
|
|
103
|
-
if (m > maxSize) { const s = maxSize / m; tw = tw * s; th = th * s }
|
|
257
|
+
if (plan.resize && m > maxSize) { const s = maxSize / m; tw = tw * s; th = th * s }
|
|
104
258
|
tw = snap4(tw); th = snap4(th)
|
|
105
259
|
|
|
106
260
|
let pipe = sharp(Buffer.from(plan.tex.getImage()!))
|
|
107
261
|
if (tw !== plan.width || th !== plan.height) pipe = pipe.resize({ width: tw, height: th, fit: 'fill' })
|
|
108
|
-
|
|
262
|
+
// Always RGB / RGBA (a grey or palette source too): `ktx create` declares the channel count up front.
|
|
263
|
+
pipe = pipe.toColourspace('srgb')
|
|
264
|
+
pipe = plan.hasAlpha ? pipe.ensureAlpha() : pipe.removeAlpha() // RGB → no wasted alpha slice
|
|
109
265
|
writeFileSync(inPng, await pipe.png().toBuffer())
|
|
110
266
|
|
|
111
|
-
const args =
|
|
112
|
-
|
|
113
|
-
:
|
|
267
|
+
const args = ktxEncodeArgs(enc, {
|
|
268
|
+
input: inPng, output: outKtx, codec: plan.codec, oetf: plan.oetf,
|
|
269
|
+
channels: plan.hasAlpha ? 4 : 3, uastcQuality: 2, zstd: 18,
|
|
270
|
+
})
|
|
114
271
|
|
|
115
|
-
|
|
116
|
-
|
|
272
|
+
await acquire()
|
|
273
|
+
try { await runEncoder(enc, args, plan.name) } finally { release() }
|
|
117
274
|
|
|
118
|
-
|
|
119
|
-
plan.tex.setImage(new Uint8Array(ktx)).setMimeType('image/ktx2')
|
|
120
|
-
plan.afterBytes = ktx.length
|
|
275
|
+
return new Uint8Array(readFileSync(outKtx))
|
|
121
276
|
} finally {
|
|
122
277
|
rmSync(dir, { recursive: true, force: true })
|
|
123
278
|
}
|
|
@@ -15,6 +15,11 @@ import type { Root, Texture } from '@gltf-transform/core'
|
|
|
15
15
|
export const DEFAULT_MAX_SIZE = 2048
|
|
16
16
|
const HEAVY_PNG_MIN_DIM = 1024
|
|
17
17
|
|
|
18
|
+
/** An IMPOSTOR ATLAS (doctor/impostor.ts tags both with extras `lecodes.impostor`): never resized by the size cap — its
|
|
19
|
+
* frames sit at fixed pixel rects, and a 12 × 256 = 3072 px strip squashed under a 2048 cap is a different layout. */
|
|
20
|
+
export const isImpostorAtlas = (tex: Texture): boolean =>
|
|
21
|
+
!!(tex.getExtras() as { lecodes?: { impostor?: unknown } } | undefined)?.lecodes?.impostor
|
|
22
|
+
|
|
18
23
|
export interface TexturePlan {
|
|
19
24
|
tex: Texture
|
|
20
25
|
name: string
|
|
@@ -63,7 +68,7 @@ export async function planTexture(tex: Texture, root: Root, maxSize = DEFAULT_MA
|
|
|
63
68
|
return null // undecodable by sharp — leave it
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
const resize = Math.max(width, height) > maxSize
|
|
71
|
+
const resize = !isImpostorAtlas(tex) && Math.max(width, height) > maxSize
|
|
67
72
|
let desired: 'jpeg' | 'png' = forceFmt ?? (hasAlpha ? 'png' : 'jpeg') // PNG only when alpha is needed
|
|
68
73
|
if (desired === 'jpeg' && hasAlpha) desired = 'png' // never drop a real alpha channel
|
|
69
74
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// MIRROR of
|
|
1
|
+
// MIRROR of runtime/web/src/viewer/validateGlb.ts (kept in sync by hand — the viewer copy is the source of truth;
|
|
2
2
|
// duplicated so lecodes-assets stays publishable without a cross-package import).
|
|
3
3
|
// Pre-flight GLB validator — runs in the browser and in Bun (no Node/DOM APIs, no deps).
|
|
4
4
|
//
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
export const MAX_BONE_COUNT = 256
|
|
18
18
|
export const MAX_MORPH_TARGET_COUNT = 256
|
|
19
19
|
|
|
20
|
-
// Texture formats with a provider registered in creator-gl (see
|
|
20
|
+
// Texture formats with a provider registered in creator-gl (see engines/gl/src/glb.cpp).
|
|
21
21
|
export const SUPPORTED_IMAGE_FORMATS = ['png', 'jpeg', 'ktx2'] as const
|
|
22
22
|
export type ImageFormat = (typeof SUPPORTED_IMAGE_FORMATS)[number] | 'webp' | 'unknown'
|
|
23
23
|
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// VERTEX AMBIENT OCCLUSION FOR VEGETATION — the sky's visibility from every vertex of a plant, baked into
|
|
2
|
+
// COLOR_0.r at import (what SpeedTree bakes for its trees, and the one thing that makes a crown read as
|
|
3
|
+
// a volume instead of a heap of green cards: the leaves deep inside take less sky than the ones on the
|
|
4
|
+
// outside, and the trunk under the crown stands in its shade).
|
|
5
|
+
//
|
|
6
|
+
// The convention the vegetation tier reads (creator-gl materials/src/foliage.mat):
|
|
7
|
+
// COLOR_0 = (ao, cavity, 1, wind) ao = sky visibility 0..1 (cosine-weighted upper hemisphere, WORLD up)
|
|
8
|
+
// cavity = the bark's short-range occlusion 0..1 (the hemisphere of its own normal,
|
|
9
|
+
// hits within `cavity` metres: limb joints, the trunk under a limb); cards 1
|
|
10
|
+
// wind = the per-vertex wind weight — the pack's own (COLOR_0.a as it came) when
|
|
11
|
+
// its material carries a wind profile (`lecodes.wind`), else the height
|
|
12
|
+
// over the plant squared
|
|
13
|
+
// An exact white (1, 1, 1, 1) is what gltfio hands a mesh WITHOUT a colour set, and the shader takes that
|
|
14
|
+
// as "no data" (ao 1, height weight) — a vertex whose ao AND wind both round to 1 lands there too, and it
|
|
15
|
+
// is exactly the vertex nothing changes for.
|
|
16
|
+
//
|
|
17
|
+
// Occluders = every triangle of the asset's level-0 meshes (`_LOD<n>` n ≥ 1 are copies of the same
|
|
18
|
+
// plant), cards counted opaque — a coarse hit for an alpha-cutout leaf, which over-darkens a little and
|
|
19
|
+
// is what the shader's strength knob is for. A BVH over the triangles, `rays` cosine-weighted rays per
|
|
20
|
+
// vertex from a fixed sequence rotated per vertex, rays start `bias` metres off the surface.
|
|
21
|
+
|
|
22
|
+
import type { Document, Primitive } from "@gltf-transform/core"
|
|
23
|
+
|
|
24
|
+
type Tri = { ax: number, ay: number, az: number, bx: number, by: number, bz: number, cx: number, cy: number, cz: number }
|
|
25
|
+
type BvhNode = { min: [number, number, number], max: [number, number, number], left: number, right: number, first: number, count: number }
|
|
26
|
+
|
|
27
|
+
const buildBvh = (tris: Tri[]): { nodes: BvhNode[], order: number[] } => {
|
|
28
|
+
const order = tris.map((_, i) => i)
|
|
29
|
+
const nodes: BvhNode[] = []
|
|
30
|
+
const cx = (t: Tri): number => (t.ax + t.bx + t.cx) / 3, cy = (t: Tri): number => (t.ay + t.by + t.cy) / 3, cz = (t: Tri): number => (t.az + t.bz + t.cz) / 3
|
|
31
|
+
const build = (first: number, count: number): number => {
|
|
32
|
+
const min: [number, number, number] = [ Infinity, Infinity, Infinity ], max: [number, number, number] = [ -Infinity, -Infinity, -Infinity ]
|
|
33
|
+
for (let i = first; i < first + count; i++) {
|
|
34
|
+
const t = tris[order[i]]
|
|
35
|
+
min[0] = Math.min(min[0], t.ax, t.bx, t.cx); min[1] = Math.min(min[1], t.ay, t.by, t.cy); min[2] = Math.min(min[2], t.az, t.bz, t.cz)
|
|
36
|
+
max[0] = Math.max(max[0], t.ax, t.bx, t.cx); max[1] = Math.max(max[1], t.ay, t.by, t.cy); max[2] = Math.max(max[2], t.az, t.bz, t.cz)
|
|
37
|
+
}
|
|
38
|
+
const id = nodes.length
|
|
39
|
+
nodes.push({ min, max, left: -1, right: -1, first, count })
|
|
40
|
+
if (count <= 8) return id
|
|
41
|
+
const ext = [ max[0] - min[0], max[1] - min[1], max[2] - min[2] ]
|
|
42
|
+
const axis = ext[0] >= ext[1] && ext[0] >= ext[2] ? 0 : ext[1] >= ext[2] ? 1 : 2
|
|
43
|
+
const key = axis === 0 ? cx : axis === 1 ? cy : cz
|
|
44
|
+
const slice = order.slice(first, first + count).sort((a, b) => key(tris[a]) - key(tris[b]))
|
|
45
|
+
for (let i = 0; i < count; i++) order[first + i] = slice[i]
|
|
46
|
+
const half = count >> 1
|
|
47
|
+
const n = nodes[id]
|
|
48
|
+
n.count = 0
|
|
49
|
+
n.left = build(first, half)
|
|
50
|
+
n.right = build(first + half, count - half)
|
|
51
|
+
return id
|
|
52
|
+
}
|
|
53
|
+
if (tris.length) build(0, tris.length)
|
|
54
|
+
return { nodes, order }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Möller–Trumbore, both sides: the hit distance in (tmin, tmax), or -1. */
|
|
58
|
+
const triDist = (t: Tri, ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, tmin: number, tmax: number): number => {
|
|
59
|
+
const e1x = t.bx - t.ax, e1y = t.by - t.ay, e1z = t.bz - t.az
|
|
60
|
+
const e2x = t.cx - t.ax, e2y = t.cy - t.ay, e2z = t.cz - t.az
|
|
61
|
+
const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x
|
|
62
|
+
const det = e1x * px + e1y * py + e1z * pz
|
|
63
|
+
if (Math.abs(det) < 1e-9) return -1
|
|
64
|
+
const inv = 1 / det
|
|
65
|
+
const tx = ox - t.ax, ty = oy - t.ay, tz = oz - t.az
|
|
66
|
+
const u = (tx * px + ty * py + tz * pz) * inv
|
|
67
|
+
if (u < 0 || u > 1) return -1
|
|
68
|
+
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x
|
|
69
|
+
const v = (dx * qx + dy * qy + dz * qz) * inv
|
|
70
|
+
if (v < 0 || u + v > 1) return -1
|
|
71
|
+
const d = (e2x * qx + e2y * qy + e2z * qz) * inv
|
|
72
|
+
return d > tmin && d < tmax ? d : -1
|
|
73
|
+
}
|
|
74
|
+
const hitTri = (t: Tri, ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, tmin: number, tmax: number): boolean =>
|
|
75
|
+
triDist(t, ox, oy, oz, dx, dy, dz, tmin, tmax) >= 0
|
|
76
|
+
|
|
77
|
+
const hitBox = (n: BvhNode, ox: number, oy: number, oz: number, ix: number, iy: number, iz: number, tmax: number): boolean => {
|
|
78
|
+
let t0 = 0, t1 = tmax
|
|
79
|
+
const a = (n.min[0] - ox) * ix, b = (n.max[0] - ox) * ix; t0 = Math.max(t0, Math.min(a, b)); t1 = Math.min(t1, Math.max(a, b))
|
|
80
|
+
const c = (n.min[1] - oy) * iy, d = (n.max[1] - oy) * iy; t0 = Math.max(t0, Math.min(c, d)); t1 = Math.min(t1, Math.max(c, d))
|
|
81
|
+
const e = (n.min[2] - oz) * iz, f = (n.max[2] - oz) * iz; t0 = Math.max(t0, Math.min(e, f)); t1 = Math.min(t1, Math.max(e, f))
|
|
82
|
+
return t1 >= t0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const occluded = (bvh: { nodes: BvhNode[], order: number[] }, tris: Tri[], ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, tmin: number, tmax: number): boolean => {
|
|
86
|
+
if (!bvh.nodes.length) return false
|
|
87
|
+
const ix = 1 / (dx || 1e-12), iy = 1 / (dy || 1e-12), iz = 1 / (dz || 1e-12)
|
|
88
|
+
const stack = [ 0 ]
|
|
89
|
+
while (stack.length) {
|
|
90
|
+
const n = bvh.nodes[stack.pop()!]
|
|
91
|
+
if (!hitBox(n, ox, oy, oz, ix, iy, iz, tmax)) continue
|
|
92
|
+
if (n.left < 0) {
|
|
93
|
+
for (let i = n.first; i < n.first + n.count; i++) if (hitTri(tris[bvh.order[i]], ox, oy, oz, dx, dy, dz, tmin, tmax)) return true
|
|
94
|
+
} else { stack.push(n.left, n.right) }
|
|
95
|
+
}
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The nearest hit along the ray in (tmin, tmax), or Infinity. */
|
|
100
|
+
const nearestHit = (bvh: { nodes: BvhNode[], order: number[] }, tris: Tri[], ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, tmin: number, tmax: number): number => {
|
|
101
|
+
let best = Infinity
|
|
102
|
+
if (!bvh.nodes.length) return best
|
|
103
|
+
const ix = 1 / (dx || 1e-12), iy = 1 / (dy || 1e-12), iz = 1 / (dz || 1e-12)
|
|
104
|
+
const stack = [ 0 ]
|
|
105
|
+
while (stack.length) {
|
|
106
|
+
const n = bvh.nodes[stack.pop()!]
|
|
107
|
+
if (!hitBox(n, ox, oy, oz, ix, iy, iz, Math.min(tmax, best))) continue
|
|
108
|
+
if (n.left < 0) {
|
|
109
|
+
for (let i = n.first; i < n.first + n.count; i++) {
|
|
110
|
+
const d = triDist(tris[bvh.order[i]], ox, oy, oz, dx, dy, dz, tmin, Math.min(tmax, best))
|
|
111
|
+
if (d >= 0 && d < best) best = d
|
|
112
|
+
}
|
|
113
|
+
} else { stack.push(n.left, n.right) }
|
|
114
|
+
}
|
|
115
|
+
return best
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type VertexAoOptions = {
|
|
119
|
+
/** Rays per vertex (default 48). */
|
|
120
|
+
rays?: number
|
|
121
|
+
/** Metres a ray starts off its vertex (default 0.02). */
|
|
122
|
+
bias?: number
|
|
123
|
+
/** Metres a ray travels (default: the asset's extent). */
|
|
124
|
+
maxDistance?: number
|
|
125
|
+
/** CAVITY (junction) occlusion for the OPAQUE primitives — the bark: `cavityRays` cosine-weighted rays in the
|
|
126
|
+
* hemisphere of the vertex' own normal, a hit within `cavity` metres counts by (1 - distance / cavity) → COLOR_0.g,
|
|
127
|
+
* 1 = open. The base of a limb and the trunk around it darken, the joint reads as grown, not laid on. Leaf cards
|
|
128
|
+
* keep 1 (their own neighbours would darken every crown). Default 0.8 m, 0 = off. */
|
|
129
|
+
cavity?: number
|
|
130
|
+
/** Rays per vertex for the cavity term (default 24). */
|
|
131
|
+
cavityRays?: number
|
|
132
|
+
/** CANOPY NORMALS: how far the normals of the alpha-MASK primitives (the leaf cards) are bent toward the
|
|
133
|
+
* direction from the crown's centre — 0 keeps the cards' own (each card lit as a flat plane: a heap of
|
|
134
|
+
* cut-outs), 1 makes the crown shade like one sphere. Default 0.6 — what a tree tool bakes. */
|
|
135
|
+
canopy?: number
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const LOD_RE = /_LOD([1-9]\d*)$/i
|
|
139
|
+
|
|
140
|
+
/** True when the primitive's material carries the pack's wind profile (glTF extras `lecodes.wind`, kindGlb.ts
|
|
141
|
+
* windProfile): the one case a vertex alpha is an authored wind weight. */
|
|
142
|
+
const windProfiled = (p: Primitive): boolean => {
|
|
143
|
+
const extras = p.getMaterial()?.getExtras() as { lecodes?: { wind?: unknown } } | null | undefined
|
|
144
|
+
return Array.isArray(extras?.lecodes?.wind)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** World-space triangles of every level-0 mesh node. */
|
|
148
|
+
const collectOccluders = (doc: Document): { tris: Tri[], min: [number, number, number], max: [number, number, number] } => {
|
|
149
|
+
const tris: Tri[] = []
|
|
150
|
+
const min: [number, number, number] = [ Infinity, Infinity, Infinity ], max: [number, number, number] = [ -Infinity, -Infinity, -Infinity ]
|
|
151
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
152
|
+
const mesh = node.getMesh()
|
|
153
|
+
if (!mesh || LOD_RE.test(node.getName())) continue
|
|
154
|
+
const m = node.getWorldMatrix()
|
|
155
|
+
for (const p of mesh.listPrimitives()) {
|
|
156
|
+
const pos = p.getAttribute("POSITION"), idx = p.getIndices()
|
|
157
|
+
if (!pos || p.getMode() !== 4) continue
|
|
158
|
+
const n = idx ? idx.getCount() : pos.getCount()
|
|
159
|
+
const v: number[] = []
|
|
160
|
+
const at = (k: number): [number, number, number] => {
|
|
161
|
+
pos.getElement(idx ? idx.getScalar(k) : k, v)
|
|
162
|
+
const x = m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], y = m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13], z = m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]
|
|
163
|
+
min[0] = Math.min(min[0], x); min[1] = Math.min(min[1], y); min[2] = Math.min(min[2], z)
|
|
164
|
+
max[0] = Math.max(max[0], x); max[1] = Math.max(max[1], y); max[2] = Math.max(max[2], z)
|
|
165
|
+
return [ x, y, z ]
|
|
166
|
+
}
|
|
167
|
+
for (let k = 0; k + 2 < n; k += 3) {
|
|
168
|
+
const a = at(k), b = at(k + 1), c = at(k + 2)
|
|
169
|
+
tris.push({ ax: a[0], ay: a[1], az: a[2], bx: b[0], by: b[1], bz: b[2], cx: c[0], cy: c[1], cz: c[2] })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { tris, min, max }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Cosine-weighted directions on the upper hemisphere (world +Y), a fixed Fibonacci set. */
|
|
177
|
+
const skyRays = (count: number): [number, number, number][] => {
|
|
178
|
+
const out: [number, number, number][] = []
|
|
179
|
+
const golden = Math.PI * (3 - Math.sqrt(5))
|
|
180
|
+
for (let i = 0; i < count; i++) {
|
|
181
|
+
const u = (i + 0.5) / count // cosine-weighted: y = sqrt(1 - u) puts more rays near the zenith
|
|
182
|
+
const y = Math.sqrt(1 - u), r = Math.sqrt(u), phi = i * golden
|
|
183
|
+
out.push([ r * Math.cos(phi), y, r * Math.sin(phi) ])
|
|
184
|
+
}
|
|
185
|
+
return out
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Bake the sky visibility of every vertex of the document's meshes into COLOR_0 as (ao, 1, 1, wind) — see the
|
|
190
|
+
* header. Returns the number of vertices written and the mean ao (a plant reads ~0.5–0.7).
|
|
191
|
+
*/
|
|
192
|
+
export const bakeVertexAo = (doc: Document, opts: VertexAoOptions = {}): { vertices: number, meanAo: number } => {
|
|
193
|
+
const { tris, min, max } = collectOccluders(doc)
|
|
194
|
+
if (!tris.length) return { vertices: 0, meanAo: 1 }
|
|
195
|
+
const bvh = buildBvh(tris)
|
|
196
|
+
const rays = skyRays(opts.rays ?? 48)
|
|
197
|
+
const bias = opts.bias ?? 0.02
|
|
198
|
+
const reach = opts.maxDistance ?? Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2])
|
|
199
|
+
const cavReach = Math.max(opts.cavity ?? 0.8, 0)
|
|
200
|
+
const cavRays = skyRays(opts.cavityRays ?? 24)
|
|
201
|
+
const height = Math.max(max[1] - min[1], 0.05)
|
|
202
|
+
let vertices = 0, sum = 0
|
|
203
|
+
const done = new Set<Primitive>()
|
|
204
|
+
// the crown: the centre of every leaf card's vertices (world), what the canopy normals point away from
|
|
205
|
+
const canopy = Math.min(Math.max(opts.canopy ?? 0.6, 0), 1)
|
|
206
|
+
const crown = [ 0, 0, 0 ]
|
|
207
|
+
let crownN = 0
|
|
208
|
+
if (canopy > 0) {
|
|
209
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
210
|
+
const mesh = node.getMesh()
|
|
211
|
+
if (!mesh || LOD_RE.test(node.getName())) continue
|
|
212
|
+
const m = node.getWorldMatrix()
|
|
213
|
+
for (const p of mesh.listPrimitives()) {
|
|
214
|
+
if (p.getMaterial()?.getAlphaMode() !== "MASK") continue
|
|
215
|
+
const pos = p.getAttribute("POSITION"); if (!pos) continue
|
|
216
|
+
const v: number[] = []
|
|
217
|
+
for (let i = 0; i < pos.getCount(); i++) { pos.getElement(i, v); crown[0] += m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12]; crown[1] += m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13]; crown[2] += m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]; crownN++ }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (crownN) { crown[0] /= crownN; crown[1] /= crownN; crown[2] /= crownN }
|
|
221
|
+
}
|
|
222
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
223
|
+
const mesh = node.getMesh()
|
|
224
|
+
if (!mesh) continue
|
|
225
|
+
const m = node.getWorldMatrix()
|
|
226
|
+
for (const p of mesh.listPrimitives()) {
|
|
227
|
+
if (done.has(p)) continue
|
|
228
|
+
done.add(p)
|
|
229
|
+
const pos = p.getAttribute("POSITION")
|
|
230
|
+
if (!pos) continue
|
|
231
|
+
const n = pos.getCount()
|
|
232
|
+
// canopy normals on the leaf cards: the card's normal blended toward "away from the crown's centre" (in the
|
|
233
|
+
// node's frame: the crown centre pulled back through the inverse world matrix, rotation-only for the normal)
|
|
234
|
+
const nrm = p.getAttribute("NORMAL")
|
|
235
|
+
if (canopy > 0 && crownN && nrm && p.getMaterial()?.getAlphaMode() === "MASK") {
|
|
236
|
+
const inv = invert4(m)
|
|
237
|
+
const cl = inv ? [ inv[0] * crown[0] + inv[4] * crown[1] + inv[8] * crown[2] + inv[12], inv[1] * crown[0] + inv[5] * crown[1] + inv[9] * crown[2] + inv[13], inv[2] * crown[0] + inv[6] * crown[1] + inv[10] * crown[2] + inv[14] ] : null
|
|
238
|
+
if (cl) {
|
|
239
|
+
const out = new Float32Array(new ArrayBuffer(n * 12)), v: number[] = [], q: number[] = []
|
|
240
|
+
for (let i = 0; i < n; i++) {
|
|
241
|
+
pos.getElement(i, v); nrm.getElement(i, q)
|
|
242
|
+
let dx = v[0] - cl[0], dy = v[1] - cl[1], dz = v[2] - cl[2]
|
|
243
|
+
const l = Math.hypot(dx, dy, dz) || 1
|
|
244
|
+
dx /= l; dy /= l; dz /= l
|
|
245
|
+
// a card facing the wrong way is flipped into the hemisphere of its radial first (double-sided cards
|
|
246
|
+
// have no side of their own), then blended
|
|
247
|
+
const flip = q[0] * dx + q[1] * dy + q[2] * dz < 0 ? -1 : 1
|
|
248
|
+
let nx = q[0] * flip * (1 - canopy) + dx * canopy, ny = q[1] * flip * (1 - canopy) + dy * canopy, nz = q[2] * flip * (1 - canopy) + dz * canopy
|
|
249
|
+
const nl = Math.hypot(nx, ny, nz) || 1
|
|
250
|
+
out[i * 3] = nx / nl; out[i * 3 + 1] = ny / nl; out[i * 3 + 2] = nz / nl
|
|
251
|
+
}
|
|
252
|
+
p.setAttribute("NORMAL", doc.createAccessor().setType("VEC3").setArray(out).setBuffer(doc.getRoot().listBuffers()[0] ?? doc.createBuffer()))
|
|
253
|
+
if (nrm.listParents().length <= 1) nrm.dispose()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const old = p.getAttribute("COLOR_0")
|
|
257
|
+
const oldEl = old ? old.getElementSize() : 0
|
|
258
|
+
const authoredWind = windProfiled(p)
|
|
259
|
+
const out = new Float32Array(new ArrayBuffer(n * 16))
|
|
260
|
+
const v: number[] = [], c: number[] = [], q: number[] = []
|
|
261
|
+
// the cavity term: the bark (opaque) with a normal, rays in the hemisphere of the vertex' world normal
|
|
262
|
+
const cavNrm = cavReach > 0 && p.getMaterial()?.getAlphaMode() !== "MASK" ? p.getAttribute("NORMAL") : null
|
|
263
|
+
for (let i = 0; i < n; i++) {
|
|
264
|
+
pos.getElement(i, v)
|
|
265
|
+
const x = m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12], y = m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13], z = m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14]
|
|
266
|
+
// a per-vertex rotation of the fixed set about +Y, so neighbours never share a banding pattern
|
|
267
|
+
const rot = ((x * 12.9898 + z * 78.233 + y * 37.719) % 1 + 1) % 1 * Math.PI * 2, cr = Math.cos(rot), sr = Math.sin(rot)
|
|
268
|
+
let open = 0
|
|
269
|
+
for (const d of rays) {
|
|
270
|
+
const dx = d[0] * cr - d[2] * sr, dz = d[0] * sr + d[2] * cr
|
|
271
|
+
if (!occluded(bvh, tris, x + dx * bias, y + d[1] * bias, z + dz * bias, dx, d[1], dz, 0, reach)) open++
|
|
272
|
+
}
|
|
273
|
+
const ao = open / rays.length
|
|
274
|
+
let cav = 1
|
|
275
|
+
if (cavNrm) {
|
|
276
|
+
nrmOf(cavNrm, i, m, q)
|
|
277
|
+
// a tangent frame about the normal: the +Y ray set turned into it, and the per-vertex rotation kept
|
|
278
|
+
const ax = Math.abs(q[1]) < 0.9 ? 0 : 1, ay = ax ? 0 : 1
|
|
279
|
+
let tx = ay * q[2] - 0, ty = 0 - ax * q[2], tz = ax * q[1] - ay * q[0] // cross(n, a) for a = (ax, ay, 0)
|
|
280
|
+
const tl = Math.hypot(tx, ty, tz) || 1; tx /= tl; ty /= tl; tz /= tl
|
|
281
|
+
const bx = q[1] * tz - q[2] * ty, by = q[2] * tx - q[0] * tz, bz = q[0] * ty - q[1] * tx // cross(n, t)
|
|
282
|
+
let occ = 0
|
|
283
|
+
for (const d of cavRays) {
|
|
284
|
+
const rx = d[0] * cr - d[2] * sr, rz = d[0] * sr + d[2] * cr
|
|
285
|
+
const dx = tx * rx + q[0] * d[1] + bx * rz, dy = ty * rx + q[1] * d[1] + by * rz, dz = tz * rx + q[2] * d[1] + bz * rz
|
|
286
|
+
// the origin is lifted off the vertex' OWN surface along its normal only, and a hit at distance 0 counts: a limb's
|
|
287
|
+
// base vertex lies ON the trunk, and the rays into the trunk meet its surface right there - that IS the joint
|
|
288
|
+
const h = nearestHit(bvh, tris, x + q[0] * bias, y + q[1] * bias, z + q[2] * bias, dx, dy, dz, -1e-6, cavReach)
|
|
289
|
+
if (h < cavReach) occ += 1 - h / cavReach
|
|
290
|
+
}
|
|
291
|
+
cav = 1 - occ / cavRays.length
|
|
292
|
+
}
|
|
293
|
+
// the wind weight: the pack's own alpha when its shader AUTHORED one there (the material carries a wind profile,
|
|
294
|
+
// `lecodes.wind` - NatureManufacture paints the weight into the vertex alpha), else the height over the plant,
|
|
295
|
+
// squared. A pack whose wind mask lives in a texture (Chernobyl's Leaves.shader) ships the alpha at 1 everywhere,
|
|
296
|
+
// and taken as the weight that shivered every leaf by the field's full amplitude (2026-11-03).
|
|
297
|
+
let wind: number
|
|
298
|
+
if (old && oldEl === 4 && authoredWind) { old.getElement(i, c); wind = c[3] } else { const h = Math.min(Math.max((y - min[1]) / height, 0), 1); wind = h * h }
|
|
299
|
+
out[i * 4] = ao; out[i * 4 + 1] = cav; out[i * 4 + 2] = 1; out[i * 4 + 3] = wind
|
|
300
|
+
sum += ao; vertices++
|
|
301
|
+
}
|
|
302
|
+
const acc = doc.createAccessor().setType("VEC4").setArray(out).setBuffer(doc.getRoot().listBuffers()[0] ?? doc.createBuffer())
|
|
303
|
+
p.setAttribute("COLOR_0", acc)
|
|
304
|
+
if (old && old.listParents().length <= 1) old.dispose()
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return { vertices, meanAo: vertices ? sum / vertices : 1 }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The vertex' normal in world space (the node's rotation part, then normalised), into `q`. */
|
|
311
|
+
const nrmOf = (nrm: { getElement(i: number, out: number[]): number[] }, i: number, m: ArrayLike<number>, q: number[]): void => {
|
|
312
|
+
nrm.getElement(i, q)
|
|
313
|
+
const x = m[0] * q[0] + m[4] * q[1] + m[8] * q[2], y = m[1] * q[0] + m[5] * q[1] + m[9] * q[2], z = m[2] * q[0] + m[6] * q[1] + m[10] * q[2]
|
|
314
|
+
const l = Math.hypot(x, y, z) || 1
|
|
315
|
+
q[0] = x / l; q[1] = y / l; q[2] = z / l
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 4×4 column-major inverse (null when singular). */
|
|
319
|
+
const invert4 = (m: ArrayLike<number>): number[] | null => {
|
|
320
|
+
const a = Array.from(m), r = new Array<number>(16)
|
|
321
|
+
const [ a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33 ] = a
|
|
322
|
+
const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10, b03 = a01 * a12 - a02 * a11
|
|
323
|
+
const b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12, b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30
|
|
324
|
+
const b08 = a20 * a33 - a23 * a30, b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32
|
|
325
|
+
let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06
|
|
326
|
+
if (Math.abs(det) < 1e-12) return null
|
|
327
|
+
det = 1 / det
|
|
328
|
+
r[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det; r[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det; r[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det; r[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det
|
|
329
|
+
r[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det; r[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det; r[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det; r[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det
|
|
330
|
+
r[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det; r[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det; r[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det; r[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det
|
|
331
|
+
r[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det; r[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det; r[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det; r[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det
|
|
332
|
+
return r
|
|
333
|
+
}
|