lecodes-assets 2.4.10 → 2.5.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/dist/cli.js +712 -33
- package/dist/index.js +712 -33
- package/package.json +1 -1
- package/src/commands/doctor.ts +15 -3
- package/src/commands/scene.ts +6 -1
- package/src/commands/shared.ts +20 -1
- package/src/doctor/cardTrim.ts +2 -0
- package/src/doctor/impostor.ts +614 -0
- package/src/doctor/transcodeKtx2.ts +8 -4
- package/src/doctor/transcodeTextures.ts +6 -1
- package/src/doctor/vertexAo.ts +16 -4
- package/src/unity/gameObjects.ts +13 -4
- package/src/unity/lecodesLevel.ts +38 -5
- package/src/unity/levelTerrain.ts +16 -7
- package/src/unity/materialWriter.ts +11 -1
- package/src/unity/scene.ts +16 -2
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
// IMPOSTORS FOR TREES: a far tree drawn as ONE camera-facing quad textured from an atlas of views of the tree
|
|
2
|
+
// (a GRID of A azimuths × E elevation rows, or an N×N hemi-OCTAHEDRON), baked here into the tree's own GLB as its
|
|
3
|
+
// last LOD level (`<root>_LOD<k>`, picked by creator-gl's LOD pass like any level). The atlas holds
|
|
4
|
+
// light-independent data only; the engine's foliage-impostor.mat lights the quad with the crown cards' model.
|
|
5
|
+
// What the GLB carries (the engine reads the same text):
|
|
6
|
+
//
|
|
7
|
+
// level 0 every mesh node without a `_LOD<n>` suffix (n ≥ 1) — the only geometry rendered. OPAQUE (bark) and
|
|
8
|
+
// MASK (cards, cut at the material's alphaCutoff against base texture × baseColorFactor); MASK and
|
|
9
|
+
// double-sided triangles are drawn whatever their facing. The normal stored is NOT the mesh's: it is the
|
|
10
|
+
// crown's HULL normal the cards shade by (foliage-wind.vs f.hull) - from the point (0, 0.6 × height, 0)
|
|
11
|
+
// above the plant's origin to the surface point - so the quad is lit exactly as the cards it replaces
|
|
12
|
+
// (a leaf deep in the crown on the far side keeps a normal that looks away: the crown's self-shading).
|
|
13
|
+
// sphere c = centre of the level-0 AABB in the GLB's frame (node world matrices applied), r = the largest
|
|
14
|
+
// distance from c to a level-0 vertex. Every frame is an orthographic view of [-r, r]² around c.
|
|
15
|
+
// frames Two layouts, both over the upper hemisphere (+Y up, a tree is never seen from below). P = pixels a
|
|
16
|
+
// frame's side (default 256 for a plant ≥ 8 m tall, 128 under; always a multiple of 4).
|
|
17
|
+
// GRID A × E (A ≥ 3 azimuths, E ≥ 1 rows; the default 12 × 3): frame (i, k), i ∈ [0, A), k ∈ [0, E),
|
|
18
|
+
// az = i·2π/A, el = k·(π/2)/E (E = 3 → 0°, 30°, 60°; 90° is never a row - the top row serves above it),
|
|
19
|
+
// d = gridDirection(i, k) = (cos el·sin az, sin el, cos el·cos az): az 0 looks from +Z, az 90° from +X.
|
|
20
|
+
// gridEncode(d) = (fa, fe): az = atan2(d.x, d.z) wrapped into [0, 2π), el = asin(clamp(d.y, 0, 1)),
|
|
21
|
+
// fa = az/(2π)·A (frame A ≡ frame 0), fe = clamp(el·E/(π/2), 0, E-1). The shader's pick: i = floor(fa),
|
|
22
|
+
// k = floor(fe), t = fract; t.x + t.y < 1 → (i,k) (i+1,k) (i,k+1) weighted (1-t.x-t.y, t.x, t.y), else
|
|
23
|
+
// (i+1,k+1) (i,k+1) (i+1,k) weighted (t.x+t.y-1, 1-t.x, 1-t.y); i+1 mod A, k+1 → min(k+1, E-1);
|
|
24
|
+
// packed i·E + k.
|
|
25
|
+
// OCTAHEDRON N × N (N ≥ 2): frame (i, j) looks from d = octDecode(i / (N-1), j / (N-1)) — octDecode /
|
|
26
|
+
// octEncode below, identical in the shader. Zenith → uv (0.5, 0.5), +X horizon → (1, 1), +Z horizon
|
|
27
|
+
// → (1, 0); the map's border is the horizon. Packed i·N + j.
|
|
28
|
+
// basis ref = |d.y| < 0.999 ? +Y : -Z; right = normalize(ref × d); up = d × right (d = +Z: right +X, up +Y).
|
|
29
|
+
// atlas C columns × R rows of P×P frames, C·P × R·P pixels (grid: C = A, R = E; octahedron: C = R = N, square).
|
|
30
|
+
// Frame (column i, row k) at pixels [i·P, (i+1)·P) × [k·P, (k+1)·P), image row 0 at the TOP (grid: ROW 0 =
|
|
31
|
+
// elevation 0 at the top of the image). A point p lands at uv ((i + a) / C, (k + 1 - b) / R),
|
|
32
|
+
// a = 0.5 + 0.5·dot(p-c, right)/r, b = the same on up (a right, b UP).
|
|
33
|
+
// textures PNG RGBA8, the doctor's KTX2 pass encodes them afterwards like any map - but never resized by its
|
|
34
|
+
// --max-size (both carry the texture extras `lecodes.impostor = true`; a 12 × 256 = 3072 px strip stays):
|
|
35
|
+
// baseColorTexture rgb = albedo (sRGB; source × baseColorFactor, averaged in LINEAR), a = coverage
|
|
36
|
+
// metallicRoughnessTexture r, g = the hull normal in OBJECT space (no frame basis), packed with the
|
|
37
|
+
// FULL-sphere octahedral map normalEncode / normalDecode below (the far side's
|
|
38
|
+
// and the crown's underside normals included) - not the frames' hemi octEncode;
|
|
39
|
+
// the map's seam pole (its four corners) is object -Y, a direction no frame looks
|
|
40
|
+
// from (at -Z the az-180° frame's near-side normals all sat on the corners,
|
|
41
|
+
// and bilinear / mips averaged them into garbage) - +Y is the map's centre;
|
|
42
|
+
// b = depth along the frame's view axis, dot(p - c, d) / r ·0.5+0.5 (1 = the
|
|
43
|
+
// point nearest this frame's viewer, 0.5 = the quad plane through c, 0 = the
|
|
44
|
+
// farthest) - the shader self-shadows the quad from the frame nearest the sun
|
|
45
|
+
// with it (and parallax may read it later); a = AO (COLOR_0.r, else 1).
|
|
46
|
+
// 2×2 supersamples a texel (coverage = covered / 4, data = mean of the covered ones: the normal averaged
|
|
47
|
+
// as a vector then encoded, the depth averaged), then the colour and data are DILATED into the empty
|
|
48
|
+
// texels of the same frame so bilinear / mips never pull black in (a depth read just outside a
|
|
49
|
+
// silhouette continues the surface; the albedo's alpha there says "no occluder").
|
|
50
|
+
// quad node `<level-0 root name>_LOD<k>`, k = min(2, highest existing level + 1); every `_LOD<n>` n ≥ k goes
|
|
51
|
+
// (the pack's cross-card billboard is worse) and `prune` drops what they used. 4 vertices: POSITION =
|
|
52
|
+
// the corners c + r·(∓1, ∓1, ∓1) so the accessor's min/max is the sphere's box (the shader places the
|
|
53
|
+
// corners itself), TEXCOORD_0 = the corner id, COLOR_0 white, NORMAL +Z. Material `<root>_impostor`,
|
|
54
|
+
// MASK 0.5, double-sided, metallic 0 / roughness 1, extras `lecodes.impostor = [A, cx, cy, cz, r, E]`
|
|
55
|
+
// (the 6th number = the ROW count; E = 0 or missing = the octahedron, the first number then = N);
|
|
56
|
+
// node extras `lecodes.impostor = { cols, rows, px }` (rows 0 = the octahedron, cols = N).
|
|
57
|
+
//
|
|
58
|
+
// The quad's OBJECT space must be the frame the bake ran in (+Y up), so it is a sibling of the level-0 root node but
|
|
59
|
+
// carries the inverse of their parent's world matrix as its local transform (identity under an identity parent) —
|
|
60
|
+
// not the level-0 node's own local transform, which in a Unity pack is the FBX's -90° X turn.
|
|
61
|
+
//
|
|
62
|
+
// The renderer is a plain software rasterizer: per frame every vertex is projected once, each triangle whose box
|
|
63
|
+
// meets the frame is scanned over its sample box with edge functions and a depth test, the alpha test taken at the
|
|
64
|
+
// sample; the surviving triangle id + barycentrics per sample are shaded once afterwards.
|
|
65
|
+
|
|
66
|
+
import { Logger, PropertyType, type Document, type Material, type Node as GltfNode, type Texture } from "@gltf-transform/core"
|
|
67
|
+
import { prune } from "@gltf-transform/functions"
|
|
68
|
+
import { decodeImage } from "../unity/image"
|
|
69
|
+
|
|
70
|
+
export type ImpostorOptions = {
|
|
71
|
+
/** Grid: the azimuths (A ≥ 3). Octahedron (rows 0): the frames a side (N ≥ 2). */
|
|
72
|
+
cols: number
|
|
73
|
+
/** Grid: the elevation rows (E ≥ 1); 0 = the octahedron of N = cols. */
|
|
74
|
+
rows: number
|
|
75
|
+
/** Pixels a frame's side (P, default 256 for a plant ≥ 8 m tall, 128 under), rounded to a multiple of 4. */
|
|
76
|
+
px?: number
|
|
77
|
+
/** The LOD level the quad takes (default min(2, highest existing + 1)). */
|
|
78
|
+
level?: number
|
|
79
|
+
log?: (s: string) => void
|
|
80
|
+
}
|
|
81
|
+
export type ImpostorResult = {
|
|
82
|
+
cols: number, rows: number, px: number, width: number, height: number
|
|
83
|
+
radius: number, center: [number, number, number], level: number, triangles: number, ms: number, node: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** "12×3 frames · 256 px (3072×768)" / "oct 8×8 frames · 128 px (1024×1024)" — the import's and the doctor's log. */
|
|
87
|
+
export const impostorLabel = (r: ImpostorResult): string =>
|
|
88
|
+
`${r.rows ? `${r.cols}×${r.rows}` : `oct ${r.cols}×${r.cols}`} frames · ${r.px} px (${r.width}×${r.height})`
|
|
89
|
+
|
|
90
|
+
const LOD_RE = /_LOD([1-9]\d*)$/i
|
|
91
|
+
const REPEAT = 10497, CLAMP = 33071, MIRROR = 33648
|
|
92
|
+
type Vec3 = [number, number, number]
|
|
93
|
+
|
|
94
|
+
/** uv in [0,1]² → a unit direction with y ≥ 0 (the shader's decode, line for line). */
|
|
95
|
+
export const octDecode = (u: number, v: number): Vec3 => {
|
|
96
|
+
const a = u * 2 - 1, b = v * 2 - 1
|
|
97
|
+
const x = (a + b) / 2, z = (a - b) / 2, y = 1 - Math.abs(x) - Math.abs(z)
|
|
98
|
+
const l = Math.hypot(x, y, z) || 1
|
|
99
|
+
return [ x / l, y / l, z / l ]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A direction (y ≥ 0) → uv in [0,1]². */
|
|
103
|
+
export const octEncode = (d: Vec3): [number, number] => {
|
|
104
|
+
const s = Math.abs(d[0]) + Math.abs(d[1]) + Math.abs(d[2]) || 1
|
|
105
|
+
const px = d[0] / s, pz = d[2] / s
|
|
106
|
+
return [ (px + pz) * 0.5 + 0.5, (px - pz) * 0.5 + 0.5 ]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A unit normal (any direction) → [0,1]² with the FULL-sphere octahedral mapping of the SWAPPED vector (x, z, y):
|
|
110
|
+
* the map's pole / seam is object -Y (the corners), +Y its centre — the data map's r, g (the shader's decode is
|
|
111
|
+
* `normalDecode`, line for line). sign(0) = +1. Not the frames' hemi `octEncode`. */
|
|
112
|
+
export const normalEncode = (n: Vec3): [number, number] => {
|
|
113
|
+
const s = Math.abs(n[0]) + Math.abs(n[1]) + Math.abs(n[2]) || 1
|
|
114
|
+
let px = n[0] / s, py = n[2] / s
|
|
115
|
+
if (n[1] / s < 0) {
|
|
116
|
+
const sx = px >= 0 ? 1 : -1, sy = py >= 0 ? 1 : -1
|
|
117
|
+
const ox = (1 - Math.abs(py)) * sx, oy = (1 - Math.abs(px)) * sy
|
|
118
|
+
px = ox; py = oy
|
|
119
|
+
}
|
|
120
|
+
return [ px * 0.5 + 0.5, py * 0.5 + 0.5 ]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** [0,1]² → the unit normal `normalEncode` stored (the mapping's decode, then swapped back: (p.x, p.z, p.y)). */
|
|
124
|
+
export const normalDecode = (u: number, v: number): Vec3 => {
|
|
125
|
+
let x = u * 2 - 1, y = v * 2 - 1
|
|
126
|
+
const z = 1 - Math.abs(x) - Math.abs(y)
|
|
127
|
+
const t = Math.min(Math.max(-z, 0), 1)
|
|
128
|
+
x += x >= 0 ? -t : t
|
|
129
|
+
y += y >= 0 ? -t : t
|
|
130
|
+
const l = Math.hypot(x, y, z) || 1
|
|
131
|
+
return [ x / l, z / l, y / l ]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Screen right / up of the frame that looks along -d. */
|
|
135
|
+
export const frameBasis = (d: Vec3): { right: Vec3, up: Vec3 } => {
|
|
136
|
+
const ref: Vec3 = Math.abs(d[1]) < 0.999 ? [ 0, 1, 0 ] : [ 0, 0, -1 ]
|
|
137
|
+
let rx = ref[1] * d[2] - ref[2] * d[1], ry = ref[2] * d[0] - ref[0] * d[2], rz = ref[0] * d[1] - ref[1] * d[0]
|
|
138
|
+
const l = Math.hypot(rx, ry, rz) || 1
|
|
139
|
+
rx /= l; ry /= l; rz /= l
|
|
140
|
+
return { right: [ rx, ry, rz ], up: [ d[1] * rz - d[2] * ry, d[2] * rx - d[0] * rz, d[0] * ry - d[1] * rx ] }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Grid frame (i, k) of A azimuths × E rows → its unit direction (az 0 = from +Z, az 90° = from +X, row 0 = the horizon). */
|
|
144
|
+
export const gridDirection = (i: number, k: number, A: number, E: number): Vec3 => {
|
|
145
|
+
const az = i * 2 * Math.PI / A, el = k * (Math.PI / 2) / E
|
|
146
|
+
return [ Math.cos(el) * Math.sin(az), Math.sin(el), Math.cos(el) * Math.cos(az) ]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A direction → the grid's continuous frame coordinates (fa ∈ [0, A), fe ∈ [0, E-1]); the shader's encode, line for line. */
|
|
150
|
+
export const gridEncode = (d: Vec3, A: number, E: number): [number, number] => {
|
|
151
|
+
let az = Math.atan2(d[0], d[2])
|
|
152
|
+
if (az < 0) az += 2 * Math.PI
|
|
153
|
+
const el = Math.asin(Math.min(Math.max(d[1], 0), 1))
|
|
154
|
+
let fa = az / (2 * Math.PI) * A
|
|
155
|
+
if (fa >= A) fa -= A
|
|
156
|
+
return [ fa, Math.min(Math.max(el * E / (Math.PI / 2), 0), E - 1) ]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** One frame of the atlas: column i, row k, the direction it looks from, its pixel rect [x0, x1) × [y0, y1). */
|
|
160
|
+
export type ImpostorFrame = { i: number, k: number, d: Vec3, x0: number, x1: number, y0: number, y1: number }
|
|
161
|
+
export type ImpostorLayout = { cols: number, rows: number, px: number, width: number, height: number, frames: ImpostorFrame[] }
|
|
162
|
+
|
|
163
|
+
const frame = (i: number, k: number, d: Vec3, P: number): ImpostorFrame => ({ i, k, d, x0: i * P, x1: (i + 1) * P, y0: k * P, y1: (k + 1) * P })
|
|
164
|
+
|
|
165
|
+
/** The grid layout: A azimuths × E elevation rows of P×P frames, an A·P × E·P atlas, row 0 = elevation 0 at the top. */
|
|
166
|
+
export const gridLayout = (A: number, E: number, P: number): ImpostorLayout => {
|
|
167
|
+
const frames: ImpostorFrame[] = []
|
|
168
|
+
for (let k = 0; k < E; k++) for (let i = 0; i < A; i++) frames.push(frame(i, k, gridDirection(i, k, A, E), P))
|
|
169
|
+
return { cols: A, rows: E, px: P, width: A * P, height: E * P, frames }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The hemi-octahedral layout: N×N frames of P×P, an N·P square atlas (rows 0 in the extras). */
|
|
173
|
+
export const octLayout = (N: number, P: number): ImpostorLayout => {
|
|
174
|
+
const frames: ImpostorFrame[] = []
|
|
175
|
+
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) frames.push(frame(i, j, octDecode(i / (N - 1), j / (N - 1)), P))
|
|
176
|
+
return { cols: N, rows: 0, px: P, width: N * P, height: N * P, frames }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- level-0 geometry ------------------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
type Slot = { mask: boolean, cull: boolean, cutoff: number, f: [number, number, number, number], img: Uint8Array | null, w: number, h: number, wrapS: number, wrapT: number, set: number }
|
|
182
|
+
type Level0 = {
|
|
183
|
+
pos: Float32Array, nrm: Float32Array, uv: Float32Array, ao: Float32Array
|
|
184
|
+
tri: Uint32Array, triSlot: Uint16Array, face: Float32Array, slots: Slot[]
|
|
185
|
+
min: Vec3, max: Vec3, root: GltfNode
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const isLevel0 = (n: GltfNode): boolean => !!n.getMesh() && !LOD_RE.test(n.getName())
|
|
189
|
+
|
|
190
|
+
/** The level-0 AABB in the GLB's frame (null when there is no level-0 geometry) — what the import gates on. */
|
|
191
|
+
export const impostorBounds = (doc: Document): { min: Vec3, max: Vec3 } | null => {
|
|
192
|
+
const min: Vec3 = [ Infinity, Infinity, Infinity ], max: Vec3 = [ -Infinity, -Infinity, -Infinity ]
|
|
193
|
+
const v: number[] = []
|
|
194
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
195
|
+
if (!isLevel0(node)) continue
|
|
196
|
+
const m = node.getWorldMatrix()
|
|
197
|
+
for (const p of node.getMesh()!.listPrimitives()) {
|
|
198
|
+
const pos = p.getAttribute("POSITION")
|
|
199
|
+
if (!pos || p.getMode() !== 4) continue
|
|
200
|
+
for (let i = 0; i < pos.getCount(); i++) {
|
|
201
|
+
pos.getElement(i, v)
|
|
202
|
+
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]
|
|
203
|
+
if (x < min[0]) min[0] = x; if (y < min[1]) min[1] = y; if (z < min[2]) min[2] = z
|
|
204
|
+
if (x > max[0]) max[0] = x; if (y > max[1]) max[1] = y; if (z > max[2]) max[2] = z
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return min[0] <= max[0] ? { min, max } : null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const makeSlot = async (mat: Material | null, images: Map<Texture, Promise<{ img: Uint8Array, w: number, h: number } | null>>, log: (s: string) => void): Promise<Slot> => {
|
|
212
|
+
const f = (mat?.getBaseColorFactor() ?? [ 1, 1, 1, 1 ]) as [number, number, number, number]
|
|
213
|
+
const mask = mat?.getAlphaMode() === "MASK"
|
|
214
|
+
const slot: Slot = { mask, cull: !mask && !mat?.getDoubleSided(), cutoff: mat ? mat.getAlphaCutoff() : 0.5, f: [ f[0], f[1], f[2], f[3] ], img: null, w: 0, h: 0, wrapS: REPEAT, wrapT: REPEAT, set: 0 }
|
|
215
|
+
const tex = mat?.getBaseColorTexture()
|
|
216
|
+
if (!mat || !tex) return slot
|
|
217
|
+
if ((tex.getMimeType() || "").includes("ktx2")) throw new Error(`impostor: the base colour of material "${mat.getName()}" is already KTX2 — bake impostors before the KTX2 encode (from the source textures)`)
|
|
218
|
+
const info = mat.getBaseColorTextureInfo()
|
|
219
|
+
slot.set = info?.getTexCoord() ?? 0
|
|
220
|
+
slot.wrapS = info?.getWrapS() ?? REPEAT
|
|
221
|
+
slot.wrapT = info?.getWrapT() ?? REPEAT
|
|
222
|
+
let job = images.get(tex)
|
|
223
|
+
if (!job) {
|
|
224
|
+
job = (async () => {
|
|
225
|
+
const bytes = tex.getImage()
|
|
226
|
+
const img = bytes ? await decodeImage(bytes, tex.getURI() || tex.getMimeType() || "texture.png") : null
|
|
227
|
+
return img ? { img: img.data, w: img.width, h: img.height } : null
|
|
228
|
+
})()
|
|
229
|
+
images.set(tex, job)
|
|
230
|
+
}
|
|
231
|
+
const img = await job
|
|
232
|
+
if (!img) { log(` ⚠ impostor: the base colour of "${mat.getName()}" could not be decoded — drawn as its factor, opaque`); slot.mask = false; return slot }
|
|
233
|
+
slot.img = img.img; slot.w = img.w; slot.h = img.h
|
|
234
|
+
return slot
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const collectLevel0 = async (doc: Document, log: (s: string) => void): Promise<Level0 | null> => {
|
|
238
|
+
const pos: number[] = [], nrm: number[] = [], uv: number[] = [], ao: number[] = []
|
|
239
|
+
const tri: number[] = [], triSlot: number[] = [], face: number[] = []
|
|
240
|
+
const slots: Slot[] = [], slotOf = new Map<Material | null, number>()
|
|
241
|
+
const images = new Map<Texture, Promise<{ img: Uint8Array, w: number, h: number } | null>>()
|
|
242
|
+
const min: Vec3 = [ Infinity, Infinity, Infinity ], max: Vec3 = [ -Infinity, -Infinity, -Infinity ]
|
|
243
|
+
const trisOfNode = new Map<GltfNode, number>()
|
|
244
|
+
const v: number[] = []
|
|
245
|
+
for (const node of doc.getRoot().listNodes()) {
|
|
246
|
+
if (!isLevel0(node)) continue
|
|
247
|
+
const m = node.getWorldMatrix()
|
|
248
|
+
// normals through the cofactor of the 3×3 (det · inverse transpose: columns b×c, c×a, a×b), sign-corrected so a
|
|
249
|
+
// mirrored node keeps its normals' side; the same sign corrects the winding-derived face normal
|
|
250
|
+
const ax = m[0], ay = m[1], az = m[2], bx = m[4], by = m[5], bz = m[6], cx = m[8], cy = m[9], cz = m[10]
|
|
251
|
+
const det = ax * (by * cz - bz * cy) - bx * (ay * cz - az * cy) + cx * (ay * bz - az * by)
|
|
252
|
+
const sg = det < 0 ? -1 : 1
|
|
253
|
+
const n0 = [ (by * cz - bz * cy) * sg, (bz * cx - bx * cz) * sg, (bx * cy - by * cx) * sg ]
|
|
254
|
+
const n1 = [ (cy * az - cz * ay) * sg, (cz * ax - cx * az) * sg, (cx * ay - cy * ax) * sg ]
|
|
255
|
+
const n2 = [ (ay * bz - az * by) * sg, (az * bx - ax * bz) * sg, (ax * by - ay * bx) * sg ]
|
|
256
|
+
let nodeTris = 0
|
|
257
|
+
for (const p of node.getMesh()!.listPrimitives()) {
|
|
258
|
+
const P = p.getAttribute("POSITION")
|
|
259
|
+
if (!P || p.getMode() !== 4) continue
|
|
260
|
+
const mat = p.getMaterial()
|
|
261
|
+
let si = slotOf.get(mat)
|
|
262
|
+
if (si === undefined) { si = slots.length; slots.push(await makeSlot(mat, images, log)); slotOf.set(mat, si) }
|
|
263
|
+
const slot = slots[si]
|
|
264
|
+
const N = p.getAttribute("NORMAL"), T = p.getAttribute(`TEXCOORD_${slot.set}`), C = p.getAttribute("COLOR_0")
|
|
265
|
+
const base = pos.length / 3
|
|
266
|
+
const count = P.getCount()
|
|
267
|
+
for (let i = 0; i < count; i++) {
|
|
268
|
+
P.getElement(i, v)
|
|
269
|
+
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]
|
|
270
|
+
pos.push(x, y, z)
|
|
271
|
+
if (x < min[0]) min[0] = x; if (y < min[1]) min[1] = y; if (z < min[2]) min[2] = z
|
|
272
|
+
if (x > max[0]) max[0] = x; if (y > max[1]) max[1] = y; if (z > max[2]) max[2] = z
|
|
273
|
+
if (N) {
|
|
274
|
+
N.getElement(i, v)
|
|
275
|
+
const nx = n0[0] * v[0] + n1[0] * v[1] + n2[0] * v[2], ny = n0[1] * v[0] + n1[1] * v[1] + n2[1] * v[2], nz = n0[2] * v[0] + n1[2] * v[1] + n2[2] * v[2]
|
|
276
|
+
const l = Math.hypot(nx, ny, nz) || 1
|
|
277
|
+
nrm.push(nx / l, ny / l, nz / l)
|
|
278
|
+
} else nrm.push(0, 0, 0) // no normals: the face normal stands in at shading
|
|
279
|
+
if (T) { T.getElement(i, v); uv.push(v[0], v[1]) } else uv.push(0, 0)
|
|
280
|
+
if (C) { C.getElement(i, v); ao.push(v[0]) } else ao.push(1)
|
|
281
|
+
}
|
|
282
|
+
const idx = p.getIndices()
|
|
283
|
+
const n = idx ? idx.getCount() : count
|
|
284
|
+
for (let k = 0; k + 2 < n; k += 3) {
|
|
285
|
+
const a = base + (idx ? idx.getScalar(k) : k), b = base + (idx ? idx.getScalar(k + 1) : k + 1), c = base + (idx ? idx.getScalar(k + 2) : k + 2)
|
|
286
|
+
tri.push(a, b, c); triSlot.push(si)
|
|
287
|
+
const e1x = pos[b * 3] - pos[a * 3], e1y = pos[b * 3 + 1] - pos[a * 3 + 1], e1z = pos[b * 3 + 2] - pos[a * 3 + 2]
|
|
288
|
+
const e2x = pos[c * 3] - pos[a * 3], e2y = pos[c * 3 + 1] - pos[a * 3 + 1], e2z = pos[c * 3 + 2] - pos[a * 3 + 2]
|
|
289
|
+
const fx = (e1y * e2z - e1z * e2y) * sg, fy = (e1z * e2x - e1x * e2z) * sg, fz = (e1x * e2y - e1y * e2x) * sg
|
|
290
|
+
const fl = Math.hypot(fx, fy, fz) || 1
|
|
291
|
+
face.push(fx / fl, fy / fl, fz / fl)
|
|
292
|
+
nodeTris++
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
trisOfNode.set(node, nodeTris)
|
|
296
|
+
}
|
|
297
|
+
if (!tri.length) return null
|
|
298
|
+
// the root the quad is named after: a level-0 node with no level-0 ancestor, the one with the most triangles
|
|
299
|
+
let root: GltfNode | null = null, best = -1
|
|
300
|
+
for (const [ node, n ] of trisOfNode) {
|
|
301
|
+
let nested = false
|
|
302
|
+
for (let a = node.getParentNode(); a; a = a.getParentNode()) if (trisOfNode.has(a)) { nested = true; break }
|
|
303
|
+
if (!nested && n > best) { best = n; root = node }
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
pos: Float32Array.from(pos), nrm: Float32Array.from(nrm), uv: Float32Array.from(uv), ao: Float32Array.from(ao),
|
|
307
|
+
tri: Uint32Array.from(tri), triSlot: Uint16Array.from(triSlot), face: Float32Array.from(face), slots, min, max, root: root!,
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---- texture sampling -------------------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
const SRGB_TO_LINEAR = Float32Array.from({ length: 256 }, (_, i) => { const c = i / 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) })
|
|
314
|
+
const toSrgb8 = (x: number): number => {
|
|
315
|
+
const c = x <= 0 ? 0 : x >= 1 ? 1 : x <= 0.0031308 ? x * 12.92 : 1.055 * Math.pow(x, 1 / 2.4) - 0.055
|
|
316
|
+
return Math.round(c * 255)
|
|
317
|
+
}
|
|
318
|
+
const wrapI = (i: number, n: number, mode: number): number => {
|
|
319
|
+
if (mode === CLAMP) return i < 0 ? 0 : i >= n ? n - 1 : i
|
|
320
|
+
if (mode === MIRROR) { const p = 2 * n, k = ((i % p) + p) % p; return k < n ? k : p - 1 - k }
|
|
321
|
+
return ((i % n) + n) % n
|
|
322
|
+
}
|
|
323
|
+
// bilinear taps of the last `taps` call: byte offsets + weights (module scratch: the bake is synchronous per sample)
|
|
324
|
+
const tapI = new Int32Array(4), tapW = new Float32Array(4)
|
|
325
|
+
const taps = (s: Slot, u: number, v: number): void => {
|
|
326
|
+
const fx = u * s.w - 0.5, fy = v * s.h - 0.5
|
|
327
|
+
const x0 = Math.floor(fx), y0 = Math.floor(fy), tx = fx - x0, ty = fy - y0
|
|
328
|
+
const xa = wrapI(x0, s.w, s.wrapS), xb = wrapI(x0 + 1, s.w, s.wrapS), ya = wrapI(y0, s.h, s.wrapT), yb = wrapI(y0 + 1, s.h, s.wrapT)
|
|
329
|
+
tapI[0] = (ya * s.w + xa) * 4; tapI[1] = (ya * s.w + xb) * 4; tapI[2] = (yb * s.w + xa) * 4; tapI[3] = (yb * s.w + xb) * 4
|
|
330
|
+
tapW[0] = (1 - tx) * (1 - ty); tapW[1] = tx * (1 - ty); tapW[2] = (1 - tx) * ty; tapW[3] = tx * ty
|
|
331
|
+
}
|
|
332
|
+
const alphaAt = (s: Slot, u: number, v: number): number => {
|
|
333
|
+
if (!s.img) return s.f[3]
|
|
334
|
+
taps(s, u, v)
|
|
335
|
+
const d = s.img
|
|
336
|
+
return (d[tapI[0] + 3] * tapW[0] + d[tapI[1] + 3] * tapW[1] + d[tapI[2] + 3] * tapW[2] + d[tapI[3] + 3] * tapW[3]) / 255 * s.f[3]
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---- dilation ------------------------------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
const DILATE_PASSES = 16
|
|
342
|
+
|
|
343
|
+
/** Fill the empty texels' colour (base rgb) and data (all four) from their filled 8-neighbours in the same frame,
|
|
344
|
+
* pass by pass; whatever is still empty after the passes takes its frame's mean (the mips' last levels). */
|
|
345
|
+
const dilate = (base: Uint8Array, data: Uint8Array, covered: Uint8Array, W: number, H: number, frameOf: Int32Array, nFrames: number): void => {
|
|
346
|
+
let cur = covered.slice()
|
|
347
|
+
for (let pass = 0; pass < DILATE_PASSES; pass++) {
|
|
348
|
+
const next = cur.slice()
|
|
349
|
+
let changed = 0
|
|
350
|
+
for (let y = 0; y < H; y++) {
|
|
351
|
+
for (let x = 0; x < W; x++) {
|
|
352
|
+
const i = y * W + x
|
|
353
|
+
if (cur[i]) continue
|
|
354
|
+
const f = frameOf[i]
|
|
355
|
+
let n = 0, r = 0, g = 0, b = 0, d0 = 0, d1 = 0, d2 = 0, d3 = 0
|
|
356
|
+
for (let oy = -1; oy <= 1; oy++) {
|
|
357
|
+
const yy = y + oy
|
|
358
|
+
if (yy < 0 || yy >= H) continue
|
|
359
|
+
for (let ox = -1; ox <= 1; ox++) {
|
|
360
|
+
const xx = x + ox
|
|
361
|
+
if (xx < 0 || xx >= W) continue
|
|
362
|
+
const k = yy * W + xx
|
|
363
|
+
if (!cur[k] || frameOf[k] !== f) continue
|
|
364
|
+
const o = k * 4
|
|
365
|
+
r += base[o]; g += base[o + 1]; b += base[o + 2]
|
|
366
|
+
d0 += data[o]; d1 += data[o + 1]; d2 += data[o + 2]; d3 += data[o + 3]
|
|
367
|
+
n++
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!n) continue
|
|
371
|
+
const o = i * 4
|
|
372
|
+
base[o] = Math.round(r / n); base[o + 1] = Math.round(g / n); base[o + 2] = Math.round(b / n)
|
|
373
|
+
data[o] = Math.round(d0 / n); data[o + 1] = Math.round(d1 / n); data[o + 2] = Math.round(d2 / n); data[o + 3] = Math.round(d3 / n)
|
|
374
|
+
next[i] = 1
|
|
375
|
+
changed++
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
cur = next
|
|
379
|
+
if (!changed) break
|
|
380
|
+
}
|
|
381
|
+
// the rest: the frame's mean over its covered texels (the whole atlas' when the frame saw nothing)
|
|
382
|
+
// (slot nFrames = the whole atlas; a texel outside every frame - none in today's layouts - takes the atlas' mean)
|
|
383
|
+
const sums = new Float64Array((nFrames + 1) * 8)
|
|
384
|
+
const all = nFrames * 8
|
|
385
|
+
for (let i = 0; i < W * H; i++) {
|
|
386
|
+
if (!covered[i]) continue
|
|
387
|
+
const o = i * 4
|
|
388
|
+
for (const s of frameOf[i] >= 0 ? [ frameOf[i] * 8, all ] : [ all ]) {
|
|
389
|
+
sums[s] += base[o]; sums[s + 1] += base[o + 1]; sums[s + 2] += base[o + 2]
|
|
390
|
+
sums[s + 3] += data[o]; sums[s + 4] += data[o + 1]; sums[s + 5] += data[o + 2]; sums[s + 6] += data[o + 3]; sums[s + 7]++
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
for (let i = 0; i < W * H; i++) {
|
|
394
|
+
if (cur[i]) continue
|
|
395
|
+
let s = frameOf[i] >= 0 ? frameOf[i] * 8 : all
|
|
396
|
+
if (!sums[s + 7]) s = all
|
|
397
|
+
const n = sums[s + 7], o = i * 4
|
|
398
|
+
if (!n) { base[o] = base[o + 1] = base[o + 2] = 128; data[o] = data[o + 1] = data[o + 2] = 128; data[o + 3] = 255; continue }
|
|
399
|
+
base[o] = Math.round(sums[s] / n); base[o + 1] = Math.round(sums[s + 1] / n); base[o + 2] = Math.round(sums[s + 2] / n)
|
|
400
|
+
data[o] = Math.round(sums[s + 3] / n); data[o + 1] = Math.round(sums[s + 4] / n); data[o + 2] = Math.round(sums[s + 5] / n); data[o + 3] = Math.round(sums[s + 6] / n)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---- the bake ------------------------------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
const invert4 = (m: ArrayLike<number>): number[] | null => {
|
|
407
|
+
const [ a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33 ] = Array.from(m)
|
|
408
|
+
const b00 = a00 * a11 - a01 * a10, b01 = a00 * a12 - a02 * a10, b02 = a00 * a13 - a03 * a10, b03 = a01 * a12 - a02 * a11
|
|
409
|
+
const b04 = a01 * a13 - a03 * a11, b05 = a02 * a13 - a03 * a12, b06 = a20 * a31 - a21 * a30, b07 = a20 * a32 - a22 * a30
|
|
410
|
+
const b08 = a20 * a33 - a23 * a30, b09 = a21 * a32 - a22 * a31, b10 = a21 * a33 - a23 * a31, b11 = a22 * a33 - a23 * a32
|
|
411
|
+
let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06
|
|
412
|
+
if (Math.abs(det) < 1e-12) return null
|
|
413
|
+
det = 1 / det
|
|
414
|
+
return [
|
|
415
|
+
(a11 * b11 - a12 * b10 + a13 * b09) * det, (a02 * b10 - a01 * b11 - a03 * b09) * det, (a31 * b05 - a32 * b04 + a33 * b03) * det, (a22 * b04 - a21 * b05 - a23 * b03) * det,
|
|
416
|
+
(a12 * b08 - a10 * b11 - a13 * b07) * det, (a00 * b11 - a02 * b08 + a03 * b07) * det, (a32 * b02 - a30 * b05 - a33 * b01) * det, (a20 * b05 - a22 * b02 + a23 * b01) * det,
|
|
417
|
+
(a10 * b10 - a11 * b08 + a13 * b06) * det, (a01 * b08 - a00 * b10 - a03 * b06) * det, (a30 * b04 - a31 * b02 + a33 * b00) * det, (a21 * b02 - a20 * b04 - a23 * b00) * det,
|
|
418
|
+
(a11 * b07 - a10 * b09 - a12 * b06) * det, (a00 * b09 - a01 * b07 + a02 * b06) * det, (a31 * b01 - a30 * b03 - a32 * b00) * det, (a20 * b03 - a21 * b01 + a22 * b00) * det,
|
|
419
|
+
]
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const encodePng = async (rgba: Uint8Array, w: number, h: number): Promise<Uint8Array> => {
|
|
423
|
+
const sharp = (await import("sharp")).default
|
|
424
|
+
return new Uint8Array(await sharp(Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength), { raw: { width: w, height: h, channels: 4 } }).png().toBuffer())
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Bake the document's level-0 geometry into an impostor atlas (the grid of `cols` × `rows`, or the octahedron of
|
|
429
|
+
* N = `cols` when `rows` is 0) and add the quad as its last LOD level (see the header). Null when there is no
|
|
430
|
+
* level-0 triangle. Throws when a level-0 base texture is already KTX2.
|
|
431
|
+
*/
|
|
432
|
+
export const bakeImpostor = async (doc: Document, opts: ImpostorOptions): Promise<ImpostorResult | null> => {
|
|
433
|
+
const t0 = performance.now()
|
|
434
|
+
const log = opts.log ?? ((s: string) => console.log(s))
|
|
435
|
+
const g = await collectLevel0(doc, log)
|
|
436
|
+
if (!g) return null
|
|
437
|
+
const { pos, nrm, uv, ao, tri, triSlot, face, slots } = g
|
|
438
|
+
const nv = pos.length / 3, nt = tri.length / 3
|
|
439
|
+
const c: Vec3 = [ (g.min[0] + g.max[0]) / 2, (g.min[1] + g.max[1]) / 2, (g.min[2] + g.max[2]) / 2 ]
|
|
440
|
+
let r2 = 0
|
|
441
|
+
for (let v = 0; v < nv; v++) {
|
|
442
|
+
const dx = pos[v * 3] - c[0], dy = pos[v * 3 + 1] - c[1], dz = pos[v * 3 + 2] - c[2]
|
|
443
|
+
const d = dx * dx + dy * dy + dz * dz
|
|
444
|
+
if (d > r2) r2 = d
|
|
445
|
+
}
|
|
446
|
+
const r = Math.sqrt(r2) || 1e-3
|
|
447
|
+
const height = g.max[1] - g.min[1]
|
|
448
|
+
const P = Math.max(4, Math.round((opts.px ?? (height >= 8 ? 256 : 128)) / 4) * 4)
|
|
449
|
+
const layout = opts.rows > 0
|
|
450
|
+
? gridLayout(Math.max(3, Math.round(opts.cols)), Math.max(1, Math.round(opts.rows)), P)
|
|
451
|
+
: octLayout(Math.max(2, Math.round(opts.cols)), P)
|
|
452
|
+
const { width: AW, height: AH, frames } = layout
|
|
453
|
+
// the crown's centre the cards' hull normal radiates from: 0.6 of the plant's height over its ORIGIN (foliage-wind.vs
|
|
454
|
+
// `crown = origin + (0, 0.6 × plant.x, 0)`, plant.x = the asset box's height - glb.cpp foliageBindAsset)
|
|
455
|
+
const crown: Vec3 = [ 0, 0.6 * height, 0 ]
|
|
456
|
+
// texel → its frame's index in the list (-1 = none): the dilation never crosses a frame's edge
|
|
457
|
+
const frameOf = new Int32Array(AW * AH).fill(-1)
|
|
458
|
+
let span = 0
|
|
459
|
+
frames.forEach((f, fi) => {
|
|
460
|
+
span = Math.max(span, f.x1 - f.x0, f.y1 - f.y0)
|
|
461
|
+
for (let y = f.y0; y < f.y1; y++) frameOf.fill(fi, y * AW + f.x0, y * AW + f.x1)
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
const base = new Uint8Array(AW * AH * 4), data = new Uint8Array(AW * AH * 4), covered = new Uint8Array(AW * AH)
|
|
465
|
+
const samples = span * span * 4
|
|
466
|
+
const zb = new Float32Array(samples), tb = new Int32Array(samples), l1b = new Float32Array(samples), l2b = new Float32Array(samples)
|
|
467
|
+
const qx = new Float32Array(nv), qy = new Float32Array(nv), qz = new Float32Array(nv)
|
|
468
|
+
|
|
469
|
+
for (const fr of frames) {
|
|
470
|
+
const d = fr.d
|
|
471
|
+
const { right, up } = frameBasis(d)
|
|
472
|
+
const X0 = fr.x0, X1 = fr.x1, Y0 = fr.y0, Y1 = fr.y1
|
|
473
|
+
const W = (X1 - X0) * 2, H = (Y1 - Y0) * 2
|
|
474
|
+
// every vertex into this frame's sample grid (sample k centred at k + 0.5), qz = toward the camera; the frame's
|
|
475
|
+
// local (a, b) spans its rect: x = x0 + a·(x1 - x0), y = y0 + (1 - b)·(y1 - y0)
|
|
476
|
+
const k = 0.5 / r
|
|
477
|
+
for (let v = 0; v < nv; v++) {
|
|
478
|
+
const dx = pos[v * 3] - c[0], dy = pos[v * 3 + 1] - c[1], dz = pos[v * 3 + 2] - c[2]
|
|
479
|
+
const a = 0.5 + k * (dx * right[0] + dy * right[1] + dz * right[2])
|
|
480
|
+
const b = 0.5 + k * (dx * up[0] + dy * up[1] + dz * up[2])
|
|
481
|
+
qx[v] = a * W
|
|
482
|
+
qy[v] = (1 - b) * H
|
|
483
|
+
qz[v] = dx * d[0] + dy * d[1] + dz * d[2]
|
|
484
|
+
}
|
|
485
|
+
zb.fill(-Infinity, 0, W * H); tb.fill(-1, 0, W * H)
|
|
486
|
+
for (let t = 0; t < nt; t++) {
|
|
487
|
+
const ia = tri[t * 3], ib = tri[t * 3 + 1], ic = tri[t * 3 + 2]
|
|
488
|
+
const x0 = qx[ia], y0 = qy[ia], x1 = qx[ib], y1 = qy[ib], x2 = qx[ic], y2 = qy[ic]
|
|
489
|
+
const minX = Math.max(0, Math.ceil(Math.min(x0, x1, x2) - 0.5)), maxX = Math.min(W - 1, Math.floor(Math.max(x0, x1, x2) - 0.5))
|
|
490
|
+
if (minX > maxX) continue
|
|
491
|
+
const minY = Math.max(0, Math.ceil(Math.min(y0, y1, y2) - 0.5)), maxY = Math.min(H - 1, Math.floor(Math.max(y0, y1, y2) - 0.5))
|
|
492
|
+
if (minY > maxY) continue
|
|
493
|
+
const area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0)
|
|
494
|
+
if (Math.abs(area) < 1e-12) continue
|
|
495
|
+
const slot = slots[triSlot[t]]
|
|
496
|
+
if (slot.cull && face[t * 3] * d[0] + face[t * 3 + 1] * d[1] + face[t * 3 + 2] * d[2] < 0) continue
|
|
497
|
+
const inv = 1 / area
|
|
498
|
+
const z0 = qz[ia], dz1 = qz[ib] - z0, dz2 = qz[ic] - z0
|
|
499
|
+
const testAlpha = slot.mask
|
|
500
|
+
const u0 = uv[ia * 2], v0 = uv[ia * 2 + 1], du1 = uv[ib * 2] - u0, dv1 = uv[ib * 2 + 1] - v0, du2 = uv[ic * 2] - u0, dv2 = uv[ic * 2 + 1] - v0
|
|
501
|
+
// barycentrics of v1 / v2 (l1, l2) step linearly along a row
|
|
502
|
+
const s1x = (y2 - y0) * inv, s2x = -(y1 - y0) * inv
|
|
503
|
+
for (let sy = minY; sy <= maxY; sy++) {
|
|
504
|
+
const py = sy + 0.5 - y0, px = minX + 0.5 - x0
|
|
505
|
+
let l1 = (px * (y2 - y0) - py * (x2 - x0)) * inv
|
|
506
|
+
let l2 = ((x1 - x0) * py - (y1 - y0) * px) * inv
|
|
507
|
+
const row = sy * W
|
|
508
|
+
for (let sx = minX; sx <= maxX; sx++, l1 += s1x, l2 += s2x) {
|
|
509
|
+
if (l1 < -1e-7 || l2 < -1e-7 || l1 + l2 > 1 + 1e-7) continue
|
|
510
|
+
const z = z0 + l1 * dz1 + l2 * dz2
|
|
511
|
+
const s = row + sx
|
|
512
|
+
if (z <= zb[s]) continue
|
|
513
|
+
if (testAlpha && alphaAt(slot, u0 + l1 * du1 + l2 * du2, v0 + l1 * dv1 + l2 * dv2) < slot.cutoff) continue
|
|
514
|
+
zb[s] = z; tb[s] = t; l1b[s] = l1; l2b[s] = l2
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// resolve: each texel's four samples shaded from their triangle + barycentrics
|
|
519
|
+
for (let y = Y0; y < Y1; y++) {
|
|
520
|
+
for (let x = X0; x < X1; x++) {
|
|
521
|
+
let n = 0, cr = 0, cg = 0, cb = 0, nx = 0, ny = 0, nz = 0, aoSum = 0, zSum = 0
|
|
522
|
+
for (let q = 0; q < 4; q++) {
|
|
523
|
+
const s = ((y - Y0) * 2 + (q >> 1)) * W + (x - X0) * 2 + (q & 1)
|
|
524
|
+
const t = tb[s]
|
|
525
|
+
if (t < 0) continue
|
|
526
|
+
zSum += zb[s] // dot(p - c, d) of the surviving sample's surface point
|
|
527
|
+
const l1 = l1b[s], l2 = l2b[s], l0 = 1 - l1 - l2
|
|
528
|
+
const ia = tri[t * 3], ib = tri[t * 3 + 1], ic = tri[t * 3 + 2]
|
|
529
|
+
// the hull normal at the surface point (the cards' shading normal, see the header), never flipped
|
|
530
|
+
const mx = pos[ia * 3] * l0 + pos[ib * 3] * l1 + pos[ic * 3] * l2 - crown[0]
|
|
531
|
+
const my = pos[ia * 3 + 1] * l0 + pos[ib * 3 + 1] * l1 + pos[ic * 3 + 1] * l2 - crown[1]
|
|
532
|
+
const mz = pos[ia * 3 + 2] * l0 + pos[ib * 3 + 2] * l1 + pos[ic * 3 + 2] * l2 - crown[2]
|
|
533
|
+
const ml = Math.hypot(mx, my, mz) || 1
|
|
534
|
+
nx += mx / ml; ny += my / ml; nz += mz / ml
|
|
535
|
+
const slot = slots[triSlot[t]]
|
|
536
|
+
if (slot.img) {
|
|
537
|
+
taps(slot, uv[ia * 2] * l0 + uv[ib * 2] * l1 + uv[ic * 2] * l2, uv[ia * 2 + 1] * l0 + uv[ib * 2 + 1] * l1 + uv[ic * 2 + 1] * l2)
|
|
538
|
+
const im = slot.img
|
|
539
|
+
let r0 = 0, g0 = 0, b0 = 0
|
|
540
|
+
for (let k4 = 0; k4 < 4; k4++) { const o = tapI[k4], w = tapW[k4]; r0 += SRGB_TO_LINEAR[im[o]] * w; g0 += SRGB_TO_LINEAR[im[o + 1]] * w; b0 += SRGB_TO_LINEAR[im[o + 2]] * w }
|
|
541
|
+
cr += r0 * slot.f[0]; cg += g0 * slot.f[1]; cb += b0 * slot.f[2]
|
|
542
|
+
} else { cr += slot.f[0]; cg += slot.f[1]; cb += slot.f[2] }
|
|
543
|
+
aoSum += ao[ia] * l0 + ao[ib] * l1 + ao[ic] * l2
|
|
544
|
+
n++
|
|
545
|
+
}
|
|
546
|
+
if (!n) continue
|
|
547
|
+
const o = (y * AW + x) * 4
|
|
548
|
+
covered[y * AW + x] = 1
|
|
549
|
+
base[o] = toSrgb8(cr / n); base[o + 1] = toSrgb8(cg / n); base[o + 2] = toSrgb8(cb / n); base[o + 3] = Math.round(n / 4 * 255)
|
|
550
|
+
// r, g = the mean hull normal in OBJECT space, full-sphere octahedral; b = the mean depth dot(p - c, d) / r
|
|
551
|
+
const nl = Math.hypot(nx, ny, nz) || 1
|
|
552
|
+
const [ eu, ev ] = normalEncode([ nx / nl, ny / nl, nz / nl ])
|
|
553
|
+
const depth = zSum / n / r
|
|
554
|
+
data[o] = Math.round(Math.min(Math.max(eu, 0), 1) * 255)
|
|
555
|
+
data[o + 1] = Math.round(Math.min(Math.max(ev, 0), 1) * 255)
|
|
556
|
+
data[o + 2] = Math.round(Math.min(Math.max(depth * 0.5 + 0.5, 0), 1) * 255)
|
|
557
|
+
data[o + 3] = Math.round(Math.min(Math.max(aoSum / n, 0), 1) * 255)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
dilate(base, data, covered, AW, AH, frameOf, frames.length)
|
|
562
|
+
|
|
563
|
+
// ---- the quad: the levels it replaces go, then the node --------------------------------------------------
|
|
564
|
+
const root = g.root
|
|
565
|
+
const rootName = root.getName() || root.getMesh()?.getName() || "impostor"
|
|
566
|
+
let highest = 0
|
|
567
|
+
for (const n of doc.getRoot().listNodes()) { const m = LOD_RE.exec(n.getName()); if (m) highest = Math.max(highest, Number(m[1])) }
|
|
568
|
+
// `level` (--impostor-level) picks the slot - and so the LOD pass's threshold (0.30 / 0.12 / 0.05 for 1 / 2 / 3) the quad
|
|
569
|
+
// shows at - never past the first gap: the pass hides every level at an index nothing fills
|
|
570
|
+
const level = Math.min(Math.max(1, Math.round(opts.level ?? 2)), highest + 1)
|
|
571
|
+
const drop = (n: GltfNode): void => { for (const ch of n.listChildren()) drop(ch); n.dispose() }
|
|
572
|
+
for (const n of doc.getRoot().listNodes()) { const m = LOD_RE.exec(n.getName()); if (m && Number(m[1]) >= level && !n.isDisposed()) drop(n) }
|
|
573
|
+
|
|
574
|
+
const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer()
|
|
575
|
+
// tagged: transcodeKtx2.ts planKtx2 never resizes them by --max-size (a 3072 px strip squashed is a broken layout)
|
|
576
|
+
const tag = () => ({ lecodes: { impostor: true } })
|
|
577
|
+
const albedo = doc.createTexture(`${rootName}_impostor_albedo`).setMimeType("image/png").setImage(await encodePng(base, AW, AH)).setExtras(tag())
|
|
578
|
+
const normalDepth = doc.createTexture(`${rootName}_impostor_data`).setMimeType("image/png").setImage(await encodePng(data, AW, AH)).setExtras(tag())
|
|
579
|
+
const material = doc.createMaterial(`${rootName}_impostor`)
|
|
580
|
+
.setAlphaMode("MASK").setAlphaCutoff(0.5).setDoubleSided(true).setMetallicFactor(0).setRoughnessFactor(1).setBaseColorFactor([ 1, 1, 1, 1 ])
|
|
581
|
+
.setBaseColorTexture(albedo).setMetallicRoughnessTexture(normalDepth)
|
|
582
|
+
.setExtras({ lecodes: { impostor: [ layout.cols, c[0], c[1], c[2], r, layout.rows ] } })
|
|
583
|
+
// clamped: a frame on the atlas border must not wrap into the opposite one
|
|
584
|
+
for (const info of [ material.getBaseColorTextureInfo(), material.getMetallicRoughnessTextureInfo() ]) info?.setWrapS(CLAMP).setWrapT(CLAMP)
|
|
585
|
+
const acc = (type: "VEC2" | "VEC3" | "VEC4" | "SCALAR", arr: Float32Array<ArrayBuffer> | Uint16Array<ArrayBuffer>) => doc.createAccessor().setType(type).setArray(arr).setBuffer(buffer)
|
|
586
|
+
const corners = new Float32Array([ -1, -1, -1, 1, -1, 1, 1, 1, -1, -1, 1, 1 ]).map((s, k) => c[k % 3] + s * r)
|
|
587
|
+
const prim = doc.createPrimitive().setMaterial(material)
|
|
588
|
+
.setAttribute("POSITION", acc("VEC3", corners))
|
|
589
|
+
.setAttribute("NORMAL", acc("VEC3", new Float32Array([ 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1 ])))
|
|
590
|
+
.setAttribute("TEXCOORD_0", acc("VEC2", new Float32Array([ 0, 0, 1, 0, 1, 1, 0, 1 ])))
|
|
591
|
+
.setAttribute("COLOR_0", acc("VEC4", new Float32Array(16).fill(1)))
|
|
592
|
+
.setIndices(acc("SCALAR", new Uint16Array([ 0, 1, 2, 0, 2, 3 ])))
|
|
593
|
+
const node = doc.createNode(`${rootName}_LOD${level}`).setMesh(doc.createMesh(`${rootName}_impostor`).addPrimitive(prim))
|
|
594
|
+
.setExtras({ lecodes: { impostor: { cols: layout.cols, rows: layout.rows, px: P } } })
|
|
595
|
+
const parent = root.getParentNode()
|
|
596
|
+
if (parent) {
|
|
597
|
+
parent.addChild(node)
|
|
598
|
+
const pw = parent.getWorldMatrix()
|
|
599
|
+
const identity = pw.every((x, k) => Math.abs(x - (k % 5 === 0 ? 1 : 0)) < 1e-6)
|
|
600
|
+
const inv = identity ? null : invert4(pw)
|
|
601
|
+
if (inv) node.setMatrix(inv as unknown as Parameters<GltfNode["setMatrix"]>[0])
|
|
602
|
+
} else {
|
|
603
|
+
for (const s of doc.getRoot().listScenes()) if (s.listChildren().includes(root)) s.addChild(node)
|
|
604
|
+
}
|
|
605
|
+
// quiet: prune logs every removal at INFO, once per kind of an import
|
|
606
|
+
const logger = doc.getLogger()
|
|
607
|
+
doc.setLogger(new Logger(Logger.Verbosity.SILENT))
|
|
608
|
+
await doc.transform(prune({
|
|
609
|
+
propertyTypes: [ PropertyType.MESH, PropertyType.PRIMITIVE, PropertyType.MATERIAL, PropertyType.TEXTURE, PropertyType.ACCESSOR ],
|
|
610
|
+
keepLeaves: true, keepAttributes: true, keepIndices: true, keepSolidTextures: true, keepExtras: false,
|
|
611
|
+
}))
|
|
612
|
+
doc.setLogger(logger)
|
|
613
|
+
return { cols: layout.cols, rows: layout.rows, px: P, width: AW, height: AH, radius: r, center: c, level, triangles: nt, ms: Math.round(performance.now() - t0), node: node.getName() }
|
|
614
|
+
}
|