lecodes-assets 0.2.0 → 2.4.10
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 +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- 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 -0
- 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 +67 -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 +103 -0
- package/src/commands/shared.ts +121 -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 +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -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/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -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 +238 -0
- package/src/retarget/humanoid.ts +572 -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 +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- 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 +81 -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 +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- 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 +149 -0
- package/src/unity/yaml.ts +131 -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 +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -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/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// MIRROR of runtime/web/src/viewer/validateGlb.ts (kept in sync by hand — the viewer copy is the source of truth;
|
|
2
|
+
// duplicated so lecodes-assets stays publishable without a cross-package import).
|
|
3
|
+
// Pre-flight GLB validator — runs in the browser and in Bun (no Node/DOM APIs, no deps).
|
|
4
|
+
//
|
|
5
|
+
// Why this exists: the creator-gl wasm is shipped WITHOUT emscripten assertions, so when a
|
|
6
|
+
// model violates a filament hard-limit the C++ code calls abort() with no usable message
|
|
7
|
+
// (e.g. "bone count > 256", or a missing texture-decoder for webp). Validate the bytes here,
|
|
8
|
+
// BEFORE handing them to module._loadGlb(), and reject with a clear, actionable error instead.
|
|
9
|
+
//
|
|
10
|
+
// Mirrors the exact constraints filament/gltfio enforce at load time:
|
|
11
|
+
// - filament CONFIG_MAX_BONE_COUNT = 256 (skin.joints per renderable)
|
|
12
|
+
// - filament CONFIG_MAX_MORPH_TARGET_COUNT = 256 (morph targets per primitive)
|
|
13
|
+
// - gltfio texture providers registered in glb.cpp: image/png, image/jpeg, image/ktx2 only
|
|
14
|
+
// (webp / EXT_texture_webp has no provider -> load fails).
|
|
15
|
+
|
|
16
|
+
// ---- filament limits (keep in sync with EngineEnums.h) ----------------------------------------
|
|
17
|
+
export const MAX_BONE_COUNT = 256
|
|
18
|
+
export const MAX_MORPH_TARGET_COUNT = 256
|
|
19
|
+
|
|
20
|
+
// Texture formats with a provider registered in creator-gl (see engines/gl/src/glb.cpp).
|
|
21
|
+
export const SUPPORTED_IMAGE_FORMATS = ['png', 'jpeg', 'ktx2'] as const
|
|
22
|
+
export type ImageFormat = (typeof SUPPORTED_IMAGE_FORMATS)[number] | 'webp' | 'unknown'
|
|
23
|
+
|
|
24
|
+
export interface GlbIssue {
|
|
25
|
+
level: 'error' | 'warning'
|
|
26
|
+
code: 'CONTAINER' | 'BONE_COUNT' | 'MORPH_COUNT' | 'TEXTURE_FORMAT'
|
|
27
|
+
message: string
|
|
28
|
+
detail?: Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GlbValidationResult {
|
|
32
|
+
ok: boolean
|
|
33
|
+
errors: GlbIssue[]
|
|
34
|
+
warnings: GlbIssue[]
|
|
35
|
+
summary: {
|
|
36
|
+
skins: number
|
|
37
|
+
maxJointCount: number
|
|
38
|
+
images: number
|
|
39
|
+
formats: Record<string, number>
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const GLB_MAGIC = 0x46546c67 // "glTF"
|
|
44
|
+
const CHUNK_JSON = 0x4e4f534a // "JSON"
|
|
45
|
+
const CHUNK_BIN = 0x004e4942 // "BIN\0"
|
|
46
|
+
|
|
47
|
+
class GlbParseError extends Error {}
|
|
48
|
+
|
|
49
|
+
export interface ParsedGlb {
|
|
50
|
+
json: any
|
|
51
|
+
bin: Uint8Array | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toBytes(data: ArrayBuffer | Uint8Array): Uint8Array {
|
|
55
|
+
return data instanceof Uint8Array ? data : new Uint8Array(data)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Container parse only (JSON chunk + BIN chunk); also accepts raw .gltf JSON bytes.
|
|
59
|
+
* Exported for reuse (backend asset-metadata extraction) — throws GlbParseError on bad input. */
|
|
60
|
+
export function parseGlb(bytes: Uint8Array): ParsedGlb {
|
|
61
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
62
|
+
|
|
63
|
+
// Accept a raw .gltf (JSON) too — useful for editor/CI paths. No BIN chunk in that case,
|
|
64
|
+
// so byte-sniffing of embedded images is skipped (uri/mimeType still validated).
|
|
65
|
+
if (bytes.byteLength < 12 || dv.getUint32(0, true) !== GLB_MAGIC) {
|
|
66
|
+
try {
|
|
67
|
+
return { json: JSON.parse(new TextDecoder().decode(bytes)), bin: null }
|
|
68
|
+
} catch {
|
|
69
|
+
throw new GlbParseError('Not a GLB (bad magic) and not parseable as glTF JSON')
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const version = dv.getUint32(4, true)
|
|
74
|
+
if (version !== 2) throw new GlbParseError(`Unsupported GLB container version ${version} (expected 2)`)
|
|
75
|
+
|
|
76
|
+
let offset = 12
|
|
77
|
+
let json: any = null
|
|
78
|
+
let bin: Uint8Array | null = null
|
|
79
|
+
while (offset + 8 <= bytes.byteLength) {
|
|
80
|
+
const chunkLen = dv.getUint32(offset, true)
|
|
81
|
+
const chunkType = dv.getUint32(offset + 4, true)
|
|
82
|
+
const start = offset + 8
|
|
83
|
+
const end = start + chunkLen
|
|
84
|
+
if (end > bytes.byteLength) throw new GlbParseError(`Chunk at ${offset} overruns the file`)
|
|
85
|
+
if (chunkType === CHUNK_JSON) {
|
|
86
|
+
json = JSON.parse(new TextDecoder().decode(bytes.subarray(start, end)))
|
|
87
|
+
} else if (chunkType === CHUNK_BIN) {
|
|
88
|
+
bin = bytes.subarray(start, end)
|
|
89
|
+
}
|
|
90
|
+
offset = end // chunkLength already includes the 4-byte alignment padding (glTF spec)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!json) throw new GlbParseError('GLB has no JSON chunk')
|
|
94
|
+
return { json, bin }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- image format detection -------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function mimeToFormat(mime: string): ImageFormat {
|
|
100
|
+
switch (mime) {
|
|
101
|
+
case 'image/png': return 'png'
|
|
102
|
+
case 'image/jpeg': return 'jpeg'
|
|
103
|
+
case 'image/ktx2': return 'ktx2'
|
|
104
|
+
case 'image/webp': return 'webp'
|
|
105
|
+
default: return 'unknown'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function extToFormat(ext: string): ImageFormat {
|
|
110
|
+
switch (ext) {
|
|
111
|
+
case 'png': return 'png'
|
|
112
|
+
case 'jpg':
|
|
113
|
+
case 'jpeg': return 'jpeg'
|
|
114
|
+
case 'ktx2': return 'ktx2'
|
|
115
|
+
case 'webp': return 'webp'
|
|
116
|
+
default: return 'unknown'
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sniffMagic(b: Uint8Array): ImageFormat {
|
|
121
|
+
if (b.length >= 4 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'png'
|
|
122
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg'
|
|
123
|
+
// RIFF....WEBP
|
|
124
|
+
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
|
|
125
|
+
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'webp'
|
|
126
|
+
// KTX2: 0xAB 'KTX 20' 0xBB 0x0D 0x0A 0x1A 0x0A
|
|
127
|
+
if (b.length >= 8 && b[0] === 0xab && b[1] === 0x4b && b[2] === 0x54 && b[3] === 0x58 &&
|
|
128
|
+
b[4] === 0x20 && b[5] === 0x32 && b[6] === 0x30 && b[7] === 0xbb) return 'ktx2'
|
|
129
|
+
return 'unknown'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function detectFormat(image: any, json: any, bin: Uint8Array | null): { format: ImageFormat; via: string } {
|
|
133
|
+
if (typeof image?.uri === 'string') {
|
|
134
|
+
const uri: string = image.uri
|
|
135
|
+
if (uri.startsWith('data:')) {
|
|
136
|
+
const m = /^data:([^;,]+)[;,]/.exec(uri)
|
|
137
|
+
const mime = (m?.[1] ?? '').toLowerCase()
|
|
138
|
+
return { format: mimeToFormat(mime), via: `data-uri "${mime || '?'}"` }
|
|
139
|
+
}
|
|
140
|
+
const ext = (uri.split('?')[0].split('#')[0].split('.').pop() ?? '').toLowerCase()
|
|
141
|
+
return { format: extToFormat(ext), via: `uri ".${ext}"` }
|
|
142
|
+
}
|
|
143
|
+
if (typeof image?.mimeType === 'string') {
|
|
144
|
+
const mime = image.mimeType.toLowerCase()
|
|
145
|
+
return { format: mimeToFormat(mime), via: `mimeType "${image.mimeType}"` }
|
|
146
|
+
}
|
|
147
|
+
if (typeof image?.bufferView === 'number' && bin) {
|
|
148
|
+
const bv = json.bufferViews?.[image.bufferView]
|
|
149
|
+
// embedded images live in buffer 0 (the BIN chunk) for GLB
|
|
150
|
+
if (bv && (bv.buffer ?? 0) === 0) {
|
|
151
|
+
const start = bv.byteOffset ?? 0
|
|
152
|
+
return { format: sniffMagic(bin.subarray(start, start + 16)), via: 'magic-bytes' }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { format: 'unknown', via: 'undetermined' }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Image indices actually referenced by a texture (standard source + ktx2/webp extension sources).
|
|
159
|
+
function collectUsedImages(json: any): Set<number> {
|
|
160
|
+
const used = new Set<number>()
|
|
161
|
+
for (const t of json.textures ?? []) {
|
|
162
|
+
if (typeof t.source === 'number') used.add(t.source)
|
|
163
|
+
const basisu = t.extensions?.KHR_texture_basisu
|
|
164
|
+
if (typeof basisu?.source === 'number') used.add(basisu.source)
|
|
165
|
+
const webp = t.extensions?.EXT_texture_webp
|
|
166
|
+
if (typeof webp?.source === 'number') used.add(webp.source)
|
|
167
|
+
}
|
|
168
|
+
return used
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- main entry -------------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
export function validateGlb(data: ArrayBuffer | Uint8Array): GlbValidationResult {
|
|
174
|
+
const errors: GlbIssue[] = []
|
|
175
|
+
const warnings: GlbIssue[] = []
|
|
176
|
+
const formats: Record<string, number> = {}
|
|
177
|
+
|
|
178
|
+
let parsed: ParsedGlb
|
|
179
|
+
try {
|
|
180
|
+
parsed = parseGlb(toBytes(data))
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
errors: [{ level: 'error', code: 'CONTAINER', message: (e as Error).message }],
|
|
185
|
+
warnings: [],
|
|
186
|
+
summary: { skins: 0, maxJointCount: 0, images: 0, formats: {} },
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const { json, bin } = parsed
|
|
190
|
+
|
|
191
|
+
// --- skins: joints per skin == filament mSkinningBoneCount, capped at CONFIG_MAX_BONE_COUNT ---
|
|
192
|
+
const skins: any[] = json.skins ?? []
|
|
193
|
+
let maxJointCount = 0
|
|
194
|
+
for (let i = 0; i < skins.length; i++) {
|
|
195
|
+
const n = skins[i]?.joints?.length ?? 0
|
|
196
|
+
maxJointCount = Math.max(maxJointCount, n)
|
|
197
|
+
if (n > MAX_BONE_COUNT) {
|
|
198
|
+
errors.push({
|
|
199
|
+
level: 'error',
|
|
200
|
+
code: 'BONE_COUNT',
|
|
201
|
+
message: `skin[${i}]${skins[i].name ? ` "${skins[i].name}"` : ''} has ${n} joints, exceeds filament limit of ${MAX_BONE_COUNT}`,
|
|
202
|
+
detail: { skin: i, name: skins[i].name, joints: n, limit: MAX_BONE_COUNT },
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- morph targets per primitive, capped at CONFIG_MAX_MORPH_TARGET_COUNT --------------------
|
|
208
|
+
for (let mi = 0; mi < (json.meshes?.length ?? 0); mi++) {
|
|
209
|
+
const mesh = json.meshes[mi]
|
|
210
|
+
for (let pi = 0; pi < (mesh.primitives?.length ?? 0); pi++) {
|
|
211
|
+
const n = mesh.primitives[pi]?.targets?.length ?? 0
|
|
212
|
+
if (n > MAX_MORPH_TARGET_COUNT) {
|
|
213
|
+
errors.push({
|
|
214
|
+
level: 'error',
|
|
215
|
+
code: 'MORPH_COUNT',
|
|
216
|
+
message: `mesh[${mi}]${mesh.name ? ` "${mesh.name}"` : ''} primitive ${pi} has ${n} morph targets, exceeds filament limit of ${MAX_MORPH_TARGET_COUNT}`,
|
|
217
|
+
detail: { mesh: mi, primitive: pi, targets: n, limit: MAX_MORPH_TARGET_COUNT },
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- textures: every image must decode with a registered provider (png/jpeg/ktx2) -----------
|
|
224
|
+
const images: any[] = json.images ?? []
|
|
225
|
+
const used = collectUsedImages(json)
|
|
226
|
+
for (let i = 0; i < images.length; i++) {
|
|
227
|
+
const { format, via } = detectFormat(images[i], json, bin)
|
|
228
|
+
formats[format] = (formats[format] ?? 0) + 1
|
|
229
|
+
const supported = (SUPPORTED_IMAGE_FORMATS as readonly string[]).includes(format)
|
|
230
|
+
if (supported) continue
|
|
231
|
+
|
|
232
|
+
const referenced = used.has(i)
|
|
233
|
+
const issue: GlbIssue = {
|
|
234
|
+
level: referenced ? 'error' : 'warning',
|
|
235
|
+
code: 'TEXTURE_FORMAT',
|
|
236
|
+
message:
|
|
237
|
+
`image[${i}]${images[i].name ? ` "${images[i].name}"` : ''} is ${format.toUpperCase()} ` +
|
|
238
|
+
`(${via})${referenced ? '' : ' [unreferenced]'} — only ${SUPPORTED_IMAGE_FORMATS.join('/')} are supported`,
|
|
239
|
+
detail: { image: i, format, via, referenced },
|
|
240
|
+
}
|
|
241
|
+
;(referenced ? errors : warnings).push(issue)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
ok: errors.length === 0,
|
|
246
|
+
errors,
|
|
247
|
+
warnings,
|
|
248
|
+
summary: { skins: skins.length, maxJointCount, images: images.length, formats },
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Human-readable report (string, so it works identically in browser console and Bun stdout).
|
|
253
|
+
export function formatReport(result: GlbValidationResult, label = 'GLB'): string {
|
|
254
|
+
const lines: string[] = []
|
|
255
|
+
const s = result.summary
|
|
256
|
+
lines.push(`${result.ok ? '✅ VALID' : '❌ INVALID'} ${label}`)
|
|
257
|
+
lines.push(
|
|
258
|
+
` skins=${s.skins} (max joints=${s.maxJointCount}/${MAX_BONE_COUNT}) ` +
|
|
259
|
+
`images=${s.images} [${Object.entries(s.formats).map(([k, v]) => `${k}:${v}`).join(', ') || 'none'}]`,
|
|
260
|
+
)
|
|
261
|
+
for (const e of result.errors) lines.push(` ✗ ${e.code}: ${e.message}`)
|
|
262
|
+
for (const w of result.warnings) lines.push(` ⚠ ${w.code}: ${w.message}`)
|
|
263
|
+
return lines.join('\n')
|
|
264
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
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)
|
|
11
|
+
// or, for a mesh without one, the height over the plant squared
|
|
12
|
+
// An exact white (1, 1, 1, 1) is what gltfio hands a mesh WITHOUT a colour set, and the shader takes that
|
|
13
|
+
// as "no data" (ao 1, height weight) — a vertex whose ao AND wind both round to 1 lands there too, and it
|
|
14
|
+
// is exactly the vertex nothing changes for.
|
|
15
|
+
//
|
|
16
|
+
// Occluders = every triangle of the asset's level-0 meshes (`_LOD<n>` n ≥ 1 are copies of the same
|
|
17
|
+
// plant), cards counted opaque — a coarse hit for an alpha-cutout leaf, which over-darkens a little and
|
|
18
|
+
// is what the shader's strength knob is for. A BVH over the triangles, `rays` cosine-weighted rays per
|
|
19
|
+
// vertex from a fixed sequence rotated per vertex, rays start `bias` metres off the surface.
|
|
20
|
+
|
|
21
|
+
import type { Document, Primitive } from "@gltf-transform/core"
|
|
22
|
+
|
|
23
|
+
type Tri = { ax: number, ay: number, az: number, bx: number, by: number, bz: number, cx: number, cy: number, cz: number }
|
|
24
|
+
type BvhNode = { min: [number, number, number], max: [number, number, number], left: number, right: number, first: number, count: number }
|
|
25
|
+
|
|
26
|
+
const buildBvh = (tris: Tri[]): { nodes: BvhNode[], order: number[] } => {
|
|
27
|
+
const order = tris.map((_, i) => i)
|
|
28
|
+
const nodes: BvhNode[] = []
|
|
29
|
+
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
|
|
30
|
+
const build = (first: number, count: number): number => {
|
|
31
|
+
const min: [number, number, number] = [ Infinity, Infinity, Infinity ], max: [number, number, number] = [ -Infinity, -Infinity, -Infinity ]
|
|
32
|
+
for (let i = first; i < first + count; i++) {
|
|
33
|
+
const t = tris[order[i]]
|
|
34
|
+
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)
|
|
35
|
+
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)
|
|
36
|
+
}
|
|
37
|
+
const id = nodes.length
|
|
38
|
+
nodes.push({ min, max, left: -1, right: -1, first, count })
|
|
39
|
+
if (count <= 8) return id
|
|
40
|
+
const ext = [ max[0] - min[0], max[1] - min[1], max[2] - min[2] ]
|
|
41
|
+
const axis = ext[0] >= ext[1] && ext[0] >= ext[2] ? 0 : ext[1] >= ext[2] ? 1 : 2
|
|
42
|
+
const key = axis === 0 ? cx : axis === 1 ? cy : cz
|
|
43
|
+
const slice = order.slice(first, first + count).sort((a, b) => key(tris[a]) - key(tris[b]))
|
|
44
|
+
for (let i = 0; i < count; i++) order[first + i] = slice[i]
|
|
45
|
+
const half = count >> 1
|
|
46
|
+
const n = nodes[id]
|
|
47
|
+
n.count = 0
|
|
48
|
+
n.left = build(first, half)
|
|
49
|
+
n.right = build(first + half, count - half)
|
|
50
|
+
return id
|
|
51
|
+
}
|
|
52
|
+
if (tris.length) build(0, tris.length)
|
|
53
|
+
return { nodes, order }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Möller–Trumbore, both sides: the hit distance in (tmin, tmax), or -1. */
|
|
57
|
+
const triDist = (t: Tri, ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, tmin: number, tmax: number): number => {
|
|
58
|
+
const e1x = t.bx - t.ax, e1y = t.by - t.ay, e1z = t.bz - t.az
|
|
59
|
+
const e2x = t.cx - t.ax, e2y = t.cy - t.ay, e2z = t.cz - t.az
|
|
60
|
+
const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x
|
|
61
|
+
const det = e1x * px + e1y * py + e1z * pz
|
|
62
|
+
if (Math.abs(det) < 1e-9) return -1
|
|
63
|
+
const inv = 1 / det
|
|
64
|
+
const tx = ox - t.ax, ty = oy - t.ay, tz = oz - t.az
|
|
65
|
+
const u = (tx * px + ty * py + tz * pz) * inv
|
|
66
|
+
if (u < 0 || u > 1) return -1
|
|
67
|
+
const qx = ty * e1z - tz * e1y, qy = tz * e1x - tx * e1z, qz = tx * e1y - ty * e1x
|
|
68
|
+
const v = (dx * qx + dy * qy + dz * qz) * inv
|
|
69
|
+
if (v < 0 || u + v > 1) return -1
|
|
70
|
+
const d = (e2x * qx + e2y * qy + e2z * qz) * inv
|
|
71
|
+
return d > tmin && d < tmax ? d : -1
|
|
72
|
+
}
|
|
73
|
+
const hitTri = (t: Tri, ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, tmin: number, tmax: number): boolean =>
|
|
74
|
+
triDist(t, ox, oy, oz, dx, dy, dz, tmin, tmax) >= 0
|
|
75
|
+
|
|
76
|
+
const hitBox = (n: BvhNode, ox: number, oy: number, oz: number, ix: number, iy: number, iz: number, tmax: number): boolean => {
|
|
77
|
+
let t0 = 0, t1 = tmax
|
|
78
|
+
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))
|
|
79
|
+
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))
|
|
80
|
+
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))
|
|
81
|
+
return t1 >= t0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
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 => {
|
|
85
|
+
if (!bvh.nodes.length) return false
|
|
86
|
+
const ix = 1 / (dx || 1e-12), iy = 1 / (dy || 1e-12), iz = 1 / (dz || 1e-12)
|
|
87
|
+
const stack = [ 0 ]
|
|
88
|
+
while (stack.length) {
|
|
89
|
+
const n = bvh.nodes[stack.pop()!]
|
|
90
|
+
if (!hitBox(n, ox, oy, oz, ix, iy, iz, tmax)) continue
|
|
91
|
+
if (n.left < 0) {
|
|
92
|
+
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
|
|
93
|
+
} else { stack.push(n.left, n.right) }
|
|
94
|
+
}
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The nearest hit along the ray in (tmin, tmax), or Infinity. */
|
|
99
|
+
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 => {
|
|
100
|
+
let best = Infinity
|
|
101
|
+
if (!bvh.nodes.length) return best
|
|
102
|
+
const ix = 1 / (dx || 1e-12), iy = 1 / (dy || 1e-12), iz = 1 / (dz || 1e-12)
|
|
103
|
+
const stack = [ 0 ]
|
|
104
|
+
while (stack.length) {
|
|
105
|
+
const n = bvh.nodes[stack.pop()!]
|
|
106
|
+
if (!hitBox(n, ox, oy, oz, ix, iy, iz, Math.min(tmax, best))) continue
|
|
107
|
+
if (n.left < 0) {
|
|
108
|
+
for (let i = n.first; i < n.first + n.count; i++) {
|
|
109
|
+
const d = triDist(tris[bvh.order[i]], ox, oy, oz, dx, dy, dz, tmin, Math.min(tmax, best))
|
|
110
|
+
if (d >= 0 && d < best) best = d
|
|
111
|
+
}
|
|
112
|
+
} else { stack.push(n.left, n.right) }
|
|
113
|
+
}
|
|
114
|
+
return best
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type VertexAoOptions = {
|
|
118
|
+
/** Rays per vertex (default 48). */
|
|
119
|
+
rays?: number
|
|
120
|
+
/** Metres a ray starts off its vertex (default 0.02). */
|
|
121
|
+
bias?: number
|
|
122
|
+
/** Metres a ray travels (default: the asset's extent). */
|
|
123
|
+
maxDistance?: number
|
|
124
|
+
/** CAVITY (junction) occlusion for the OPAQUE primitives — the bark: `cavityRays` cosine-weighted rays in the
|
|
125
|
+
* hemisphere of the vertex' own normal, a hit within `cavity` metres counts by (1 - distance / cavity) → COLOR_0.g,
|
|
126
|
+
* 1 = open. The base of a limb and the trunk around it darken, the joint reads as grown, not laid on. Leaf cards
|
|
127
|
+
* keep 1 (their own neighbours would darken every crown). Default 0.8 m, 0 = off. */
|
|
128
|
+
cavity?: number
|
|
129
|
+
/** Rays per vertex for the cavity term (default 24). */
|
|
130
|
+
cavityRays?: number
|
|
131
|
+
/** CANOPY NORMALS: how far the normals of the alpha-MASK primitives (the leaf cards) are bent toward the
|
|
132
|
+
* direction from the crown's centre — 0 keeps the cards' own (each card lit as a flat plane: a heap of
|
|
133
|
+
* cut-outs), 1 makes the crown shade like one sphere. Default 0.6 — what a tree tool bakes. */
|
|
134
|
+
canopy?: number
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const LOD_RE = /_LOD([1-9]\d*)$/i
|
|
138
|
+
|
|
139
|
+
/** World-space triangles of every level-0 mesh node. */
|
|
140
|
+
const collectOccluders = (doc: Document): { tris: Tri[], min: [number, number, number], max: [number, number, number] } => {
|
|
141
|
+
const tris: Tri[] = []
|
|
142
|
+
const min: [number, number, number] = [ Infinity, Infinity, Infinity ], max: [number, number, number] = [ -Infinity, -Infinity, -Infinity ]
|
|
143
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
144
|
+
const mesh = node.getMesh()
|
|
145
|
+
if (!mesh || LOD_RE.test(node.getName())) continue
|
|
146
|
+
const m = node.getWorldMatrix()
|
|
147
|
+
for (const p of mesh.listPrimitives()) {
|
|
148
|
+
const pos = p.getAttribute("POSITION"), idx = p.getIndices()
|
|
149
|
+
if (!pos || p.getMode() !== 4) continue
|
|
150
|
+
const n = idx ? idx.getCount() : pos.getCount()
|
|
151
|
+
const v: number[] = []
|
|
152
|
+
const at = (k: number): [number, number, number] => {
|
|
153
|
+
pos.getElement(idx ? idx.getScalar(k) : k, v)
|
|
154
|
+
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]
|
|
155
|
+
min[0] = Math.min(min[0], x); min[1] = Math.min(min[1], y); min[2] = Math.min(min[2], z)
|
|
156
|
+
max[0] = Math.max(max[0], x); max[1] = Math.max(max[1], y); max[2] = Math.max(max[2], z)
|
|
157
|
+
return [ x, y, z ]
|
|
158
|
+
}
|
|
159
|
+
for (let k = 0; k + 2 < n; k += 3) {
|
|
160
|
+
const a = at(k), b = at(k + 1), c = at(k + 2)
|
|
161
|
+
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] })
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { tris, min, max }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Cosine-weighted directions on the upper hemisphere (world +Y), a fixed Fibonacci set. */
|
|
169
|
+
const skyRays = (count: number): [number, number, number][] => {
|
|
170
|
+
const out: [number, number, number][] = []
|
|
171
|
+
const golden = Math.PI * (3 - Math.sqrt(5))
|
|
172
|
+
for (let i = 0; i < count; i++) {
|
|
173
|
+
const u = (i + 0.5) / count // cosine-weighted: y = sqrt(1 - u) puts more rays near the zenith
|
|
174
|
+
const y = Math.sqrt(1 - u), r = Math.sqrt(u), phi = i * golden
|
|
175
|
+
out.push([ r * Math.cos(phi), y, r * Math.sin(phi) ])
|
|
176
|
+
}
|
|
177
|
+
return out
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Bake the sky visibility of every vertex of the document's meshes into COLOR_0 as (ao, 1, 1, wind) — see the
|
|
182
|
+
* header. Returns the number of vertices written and the mean ao (a plant reads ~0.5–0.7).
|
|
183
|
+
*/
|
|
184
|
+
export const bakeVertexAo = (doc: Document, opts: VertexAoOptions = {}): { vertices: number, meanAo: number } => {
|
|
185
|
+
const { tris, min, max } = collectOccluders(doc)
|
|
186
|
+
if (!tris.length) return { vertices: 0, meanAo: 1 }
|
|
187
|
+
const bvh = buildBvh(tris)
|
|
188
|
+
const rays = skyRays(opts.rays ?? 48)
|
|
189
|
+
const bias = opts.bias ?? 0.02
|
|
190
|
+
const reach = opts.maxDistance ?? Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2])
|
|
191
|
+
const cavReach = Math.max(opts.cavity ?? 0.8, 0)
|
|
192
|
+
const cavRays = skyRays(opts.cavityRays ?? 24)
|
|
193
|
+
const height = Math.max(max[1] - min[1], 0.05)
|
|
194
|
+
let vertices = 0, sum = 0
|
|
195
|
+
const done = new Set<Primitive>()
|
|
196
|
+
// the crown: the centre of every leaf card's vertices (world), what the canopy normals point away from
|
|
197
|
+
const canopy = Math.min(Math.max(opts.canopy ?? 0.6, 0), 1)
|
|
198
|
+
const crown = [ 0, 0, 0 ]
|
|
199
|
+
let crownN = 0
|
|
200
|
+
if (canopy > 0) {
|
|
201
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
202
|
+
const mesh = node.getMesh()
|
|
203
|
+
if (!mesh || LOD_RE.test(node.getName())) continue
|
|
204
|
+
const m = node.getWorldMatrix()
|
|
205
|
+
for (const p of mesh.listPrimitives()) {
|
|
206
|
+
if (p.getMaterial()?.getAlphaMode() !== "MASK") continue
|
|
207
|
+
const pos = p.getAttribute("POSITION"); if (!pos) continue
|
|
208
|
+
const v: number[] = []
|
|
209
|
+
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++ }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (crownN) { crown[0] /= crownN; crown[1] /= crownN; crown[2] /= crownN }
|
|
213
|
+
}
|
|
214
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
215
|
+
const mesh = node.getMesh()
|
|
216
|
+
if (!mesh) continue
|
|
217
|
+
const m = node.getWorldMatrix()
|
|
218
|
+
for (const p of mesh.listPrimitives()) {
|
|
219
|
+
if (done.has(p)) continue
|
|
220
|
+
done.add(p)
|
|
221
|
+
const pos = p.getAttribute("POSITION")
|
|
222
|
+
if (!pos) continue
|
|
223
|
+
const n = pos.getCount()
|
|
224
|
+
// canopy normals on the leaf cards: the card's normal blended toward "away from the crown's centre" (in the
|
|
225
|
+
// node's frame: the crown centre pulled back through the inverse world matrix, rotation-only for the normal)
|
|
226
|
+
const nrm = p.getAttribute("NORMAL")
|
|
227
|
+
if (canopy > 0 && crownN && nrm && p.getMaterial()?.getAlphaMode() === "MASK") {
|
|
228
|
+
const inv = invert4(m)
|
|
229
|
+
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
|
|
230
|
+
if (cl) {
|
|
231
|
+
const out = new Float32Array(new ArrayBuffer(n * 12)), v: number[] = [], q: number[] = []
|
|
232
|
+
for (let i = 0; i < n; i++) {
|
|
233
|
+
pos.getElement(i, v); nrm.getElement(i, q)
|
|
234
|
+
let dx = v[0] - cl[0], dy = v[1] - cl[1], dz = v[2] - cl[2]
|
|
235
|
+
const l = Math.hypot(dx, dy, dz) || 1
|
|
236
|
+
dx /= l; dy /= l; dz /= l
|
|
237
|
+
// a card facing the wrong way is flipped into the hemisphere of its radial first (double-sided cards
|
|
238
|
+
// have no side of their own), then blended
|
|
239
|
+
const flip = q[0] * dx + q[1] * dy + q[2] * dz < 0 ? -1 : 1
|
|
240
|
+
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
|
|
241
|
+
const nl = Math.hypot(nx, ny, nz) || 1
|
|
242
|
+
out[i * 3] = nx / nl; out[i * 3 + 1] = ny / nl; out[i * 3 + 2] = nz / nl
|
|
243
|
+
}
|
|
244
|
+
p.setAttribute("NORMAL", doc.createAccessor().setType("VEC3").setArray(out).setBuffer(doc.getRoot().listBuffers()[0] ?? doc.createBuffer()))
|
|
245
|
+
if (nrm.listParents().length <= 1) nrm.dispose()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const old = p.getAttribute("COLOR_0")
|
|
249
|
+
const oldEl = old ? old.getElementSize() : 0
|
|
250
|
+
const out = new Float32Array(new ArrayBuffer(n * 16))
|
|
251
|
+
const v: number[] = [], c: number[] = [], q: number[] = []
|
|
252
|
+
// the cavity term: the bark (opaque) with a normal, rays in the hemisphere of the vertex' world normal
|
|
253
|
+
const cavNrm = cavReach > 0 && p.getMaterial()?.getAlphaMode() !== "MASK" ? p.getAttribute("NORMAL") : null
|
|
254
|
+
for (let i = 0; i < n; i++) {
|
|
255
|
+
pos.getElement(i, v)
|
|
256
|
+
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]
|
|
257
|
+
// a per-vertex rotation of the fixed set about +Y, so neighbours never share a banding pattern
|
|
258
|
+
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)
|
|
259
|
+
let open = 0
|
|
260
|
+
for (const d of rays) {
|
|
261
|
+
const dx = d[0] * cr - d[2] * sr, dz = d[0] * sr + d[2] * cr
|
|
262
|
+
if (!occluded(bvh, tris, x + dx * bias, y + d[1] * bias, z + dz * bias, dx, d[1], dz, 0, reach)) open++
|
|
263
|
+
}
|
|
264
|
+
const ao = open / rays.length
|
|
265
|
+
let cav = 1
|
|
266
|
+
if (cavNrm) {
|
|
267
|
+
nrmOf(cavNrm, i, m, q)
|
|
268
|
+
// a tangent frame about the normal: the +Y ray set turned into it, and the per-vertex rotation kept
|
|
269
|
+
const ax = Math.abs(q[1]) < 0.9 ? 0 : 1, ay = ax ? 0 : 1
|
|
270
|
+
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)
|
|
271
|
+
const tl = Math.hypot(tx, ty, tz) || 1; tx /= tl; ty /= tl; tz /= tl
|
|
272
|
+
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)
|
|
273
|
+
let occ = 0
|
|
274
|
+
for (const d of cavRays) {
|
|
275
|
+
const rx = d[0] * cr - d[2] * sr, rz = d[0] * sr + d[2] * cr
|
|
276
|
+
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
|
|
277
|
+
// the origin is lifted off the vertex' OWN surface along its normal only, and a hit at distance 0 counts: a limb's
|
|
278
|
+
// base vertex lies ON the trunk, and the rays into the trunk meet its surface right there - that IS the joint
|
|
279
|
+
const h = nearestHit(bvh, tris, x + q[0] * bias, y + q[1] * bias, z + q[2] * bias, dx, dy, dz, -1e-6, cavReach)
|
|
280
|
+
if (h < cavReach) occ += 1 - h / cavReach
|
|
281
|
+
}
|
|
282
|
+
cav = 1 - occ / cavRays.length
|
|
283
|
+
}
|
|
284
|
+
// the wind weight: the pack's own (its alpha, as it came) or the height over the plant, squared
|
|
285
|
+
let wind: number
|
|
286
|
+
if (old && oldEl === 4) { old.getElement(i, c); wind = c[3] } else { const h = Math.min(Math.max((y - min[1]) / height, 0), 1); wind = h * h }
|
|
287
|
+
out[i * 4] = ao; out[i * 4 + 1] = cav; out[i * 4 + 2] = 1; out[i * 4 + 3] = wind
|
|
288
|
+
sum += ao; vertices++
|
|
289
|
+
}
|
|
290
|
+
const acc = doc.createAccessor().setType("VEC4").setArray(out).setBuffer(doc.getRoot().listBuffers()[0] ?? doc.createBuffer())
|
|
291
|
+
p.setAttribute("COLOR_0", acc)
|
|
292
|
+
if (old && old.listParents().length <= 1) old.dispose()
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return { vertices, meanAo: vertices ? sum / vertices : 1 }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The vertex' normal in world space (the node's rotation part, then normalised), into `q`. */
|
|
299
|
+
const nrmOf = (nrm: { getElement(i: number, out: number[]): number[] }, i: number, m: ArrayLike<number>, q: number[]): void => {
|
|
300
|
+
nrm.getElement(i, q)
|
|
301
|
+
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]
|
|
302
|
+
const l = Math.hypot(x, y, z) || 1
|
|
303
|
+
q[0] = x / l; q[1] = y / l; q[2] = z / l
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 4×4 column-major inverse (null when singular). */
|
|
307
|
+
const invert4 = (m: ArrayLike<number>): number[] | null => {
|
|
308
|
+
const a = Array.from(m), r = new Array<number>(16)
|
|
309
|
+
const [ a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33 ] = a
|
|
310
|
+
const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10, b03 = a01 * a12 - a02 * a11
|
|
311
|
+
const b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12, b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30
|
|
312
|
+
const b08 = a20 * a33 - a23 * a30, b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32
|
|
313
|
+
let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06
|
|
314
|
+
if (Math.abs(det) < 1e-12) return null
|
|
315
|
+
det = 1 / det
|
|
316
|
+
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
|
|
317
|
+
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
|
|
318
|
+
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
|
|
319
|
+
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
|
|
320
|
+
return r
|
|
321
|
+
}
|