@solidrt/3d 0.0.51 → 0.0.52

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/src/glsl.ts CHANGED
@@ -16,6 +16,13 @@
16
16
 
17
17
  import { glsl } from "@solidrt/core/gpu"
18
18
 
19
+ /** The directional-light cap of the scene's light list (DirectionalLight nodes)
20
+ * and of the `lit` fragment; a custom fragment declares
21
+ * `uniform vec3 uLightDir[MAX_LIGHTS]` / `uLightColor[MAX_LIGHTS]` and
22
+ * loops to `uLightCount`. A shader-source constant, so it is fixed for
23
+ * the app (see okf/backlog/app-runtime-config.md). */
24
+ export const MAX_LIGHTS = 4
25
+
19
26
  /**
20
27
  * The standard lit vertex stage: clip position via uViewProj * uModel,
21
28
  * with world position, world normal (via `mat3(uNormal)`, correct under
@@ -50,9 +57,9 @@ export const LIT_VERTEX = glsl`
50
57
  * LIT_VERTEX for "colored"-layout geometry: the same interface plus the
51
58
  * per-vertex aColor vec4 forwarded raw as `in vec4 vColor` - what it means
52
59
  * (a tint, baked AO in one channel, anything) is the fragment's business.
53
- * Using this constant opts the material into the colored layout (its
54
- * meshes need withColors() geometry), because shaderMaterial detects
55
- * aColor in the vertex source.
60
+ * Using this constant makes the material read aColor (shaderMaterial
61
+ * collects the vertex stage's `in` declarations), so its meshes need
62
+ * geometry carrying that channel - withColors() - or add() throws.
56
63
  */
57
64
  export const LIT_VERTEX_COLORED = glsl`
58
65
  in vec3 aPos;
@@ -110,3 +117,76 @@ export const FRESNEL = glsl`
110
117
  return pow(1.0 - max(dot(n, v), 0.0), power);
111
118
  }
112
119
  `
120
+
121
+ /**
122
+ * The scene's shadow set as a receiving program declares it, one slot per
123
+ * directional light index: `uShadowMap0..N-1` (each light's depth map, a
124
+ * white texel when it does not cast), `uShadowMatrix[N]` (its light-space
125
+ * viewProj), `uShadowCast[N]` (1 when light i casts), `uShadowBias[N]` and
126
+ * `uShadowNormalBias[N]`. The scene binds and writes all of it on every
127
+ * target a receiving material can draw into; a custom material composes
128
+ * this, then SHADOW, then SHADOW_LOOKUP (in that order) and multiplies
129
+ * light i's term by `lightShadow(i, worldPos, n)` - `lit` is the shape.
130
+ * A material that does not receive composes none of it, so it declares
131
+ * no samplers for nothing.
132
+ */
133
+ export const SHADOW_SLOTS = glsl`
134
+ ${Array.from({ length: MAX_LIGHTS }, (_, i) => `uniform sampler2D uShadowMap${i};`).join("\n ")}
135
+ uniform mat4 uShadowMatrix[${MAX_LIGHTS}];
136
+ uniform int uShadowCast[${MAX_LIGHTS}];
137
+ uniform float uShadowBias[${MAX_LIGHTS}];
138
+ uniform float uShadowNormalBias[${MAX_LIGHTS}];
139
+ `
140
+
141
+ /**
142
+ * `float shadow(sampler2D map, vec4 coord, float bias)` - the directional
143
+ * shadow factor (1 lit, 0 shadowed) for a world point carried into a
144
+ * casting light's clip space by its `uShadowMatrix[i]`:
145
+ * `shadow(uShadowMap0, uShadowMatrix[0] * vec4(vWorldPos, 1.0), uShadowBias[0])`.
146
+ * Perspective divide, 0..1 remap, out-of-frustum returns 1 (lit), then a
147
+ * 3x3 PCF over texel neighbours comparing the map's `.r` (a stage-1 depth
148
+ * texture samples nearest, so the softness is this loop, not the sampler).
149
+ * `bias` is subtracted from the point's depth against acne; SHADOW_LOOKUP
150
+ * also offsets the point along its normal by `uShadowNormalBias[i]`
151
+ * before the transform.
152
+ */
153
+ export const SHADOW = glsl`
154
+ float shadow(sampler2D map, vec4 coord, float bias) {
155
+ vec3 p = coord.xyz / coord.w * 0.5 + 0.5;
156
+ if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) return 1.0;
157
+ vec2 texel = 1.0 / vec2(textureSize(map, 0));
158
+ float lit = 0.0;
159
+ for (int y = -1; y <= 1; y++) {
160
+ for (int x = -1; x <= 1; x++) {
161
+ float d = texture(map, p.xy + vec2(float(x), float(y)) * texel).r;
162
+ lit += p.z - bias <= d ? 1.0 : 0.0;
163
+ }
164
+ }
165
+ return lit / 9.0;
166
+ }
167
+ `
168
+
169
+ /**
170
+ * The step from a light index to its shadow factor, over SHADOW_SLOTS and
171
+ * SHADOW (compose both first). `float shadowAt(int i, vec4 coord, float
172
+ * bias)` picks light i's map - an if-chain over the slots, because GLSL
173
+ * ES 3.00 only indexes a sampler array by a constant - and samples it
174
+ * with `shadow`. `float lightShadow(int i, vec3 worldPos, vec3 n)` is
175
+ * the one to call per light: 1 for a light that does not cast, else the
176
+ * factor for `worldPos` pushed along its normal `n` by
177
+ * `uShadowNormalBias[i]` (the acne knob to reach for first) and carried
178
+ * through `uShadowMatrix[i]` with `uShadowBias[i]`. Position and normal
179
+ * are arguments, so no varying name is pinned and a custom vertex stage
180
+ * composes freely.
181
+ */
182
+ export const SHADOW_LOOKUP = glsl`
183
+ float shadowAt(int i, vec4 coord, float bias) {
184
+ ${Array.from({ length: MAX_LIGHTS }, (_, i) => `if (i == ${i}) return shadow(uShadowMap${i}, coord, bias);`).join("\n ")}
185
+ return 1.0;
186
+ }
187
+
188
+ float lightShadow(int i, vec3 worldPos, vec3 n) {
189
+ if (uShadowCast[i] != 1) return 1.0;
190
+ return shadowAt(i, uShadowMatrix[i] * vec4(worldPos + n * uShadowNormalBias[i], 1.0), uShadowBias[i]);
191
+ }
192
+ `
package/src/gltf.ts ADDED
@@ -0,0 +1,437 @@
1
+ // glTF 2.0, the subset an app needs to show authored models: the scene's
2
+ // node tree with world transforms BAKED into the vertices (one part per
3
+ // mesh node, its name kept), triangles with positions, normals (flat ones
4
+ // generated when absent, per the spec), one UV set and indices, and
5
+ // materials reduced to what lit()/unlit() draw - base color factor and
6
+ // texture, double-sidedness, alpha blending. Both containers: .gltf JSON
7
+ // with external or data: buffers and images, and single-file .glb.
8
+ //
9
+ // Pure module by design - a parse is JSON plus typed-array views plus one
10
+ // interleave loop per primitive, so it runs the same under bun (the bake
11
+ // tool in tools/model.ts, the check rig) and on flux (loadGltf in
12
+ // model.ts). It never decodes images: material.map indexes the encoded
13
+ // bytes in `images`, and uploading is the engine side's job.
14
+ //
15
+ // Outside the subset: Draco/meshopt-compressed meshes and any other
16
+ // required extension throw naming it; non-triangle primitives, sparse
17
+ // accessors and morph/skin data are skipped or ignored; vertex colors,
18
+ // tangents and further UV sets are dropped (the standard layout has no
19
+ // slot for them yet).
20
+
21
+ import { compose, mat4, multiply, normalMatrix } from "./math.ts"
22
+ import type { Mat4, Quat, Vec3 } from "./math.ts"
23
+ import { packGeometry, STANDARD_FLOATS } from "./geometry.ts"
24
+ import type { Geometry } from "./geometry.ts"
25
+
26
+ /** What lit()/unlit() take from a glTF material. */
27
+ export type ModelMaterial = {
28
+ name: string
29
+ /** Straight [r, g, b, a] 0..1 (glTF baseColorFactor). */
30
+ color: [number, number, number, number]
31
+ /** Index into ModelData.images (the base color texture), or null. */
32
+ map: number | null
33
+ /** glTF doubleSided. Reported, not applied: the standard materials cull
34
+ * back faces. */
35
+ doubleSided: boolean
36
+ /** alphaMode BLEND. MASK draws opaque (no alpha test). */
37
+ transparent: boolean
38
+ }
39
+
40
+ /** One drawable: a mesh node's primitive, vertices in WORLD space. */
41
+ export type ModelPart = {
42
+ /** The glTF node's name (or the mesh's, or `node<i>`); a node with
43
+ * several primitives numbers them `name#<k>`. */
44
+ name: string
45
+ geometry: Geometry
46
+ /** Index into ModelData.materials. */
47
+ material: number
48
+ }
49
+
50
+ /** A parsed model: plain data, no GPU resources. What parseGltf and
51
+ * decodeModel produce and createModel consumes. */
52
+ export type ModelData = {
53
+ parts: ModelPart[]
54
+ materials: ModelMaterial[]
55
+ /** Encoded image files (PNG/JPEG bytes) the materials' `map` index. */
56
+ images: Uint8Array[]
57
+ /** World-space [minX, minY, minZ, maxX, maxY, maxZ] over every part. */
58
+ bounds: Float32Array
59
+ }
60
+
61
+ /** Resolves a relative uri of a .gltf (its .bin buffers, image files) to
62
+ * bytes. Not needed for .glb or data: uris. */
63
+ export type UriResolver = (uri: string) => Uint8Array
64
+
65
+ const GLB_MAGIC = 0x46546c67
66
+ const CHUNK_JSON = 0x4e4f534a
67
+ const CHUNK_BIN = 0x004e4942
68
+ const MODE_TRIANGLES = 4
69
+
70
+ const COMPONENT_BYTES: Record<number, number> = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }
71
+ const TYPE_ELEMENTS: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }
72
+
73
+ const DEFAULT_MATERIAL: ModelMaterial = { name: "default", color: [1, 1, 1, 1], map: null, doubleSided: false, transparent: false }
74
+
75
+ /** True when the bytes are a .glb container (the "glTF" magic). */
76
+ export function isGlb(bytes: Uint8Array): boolean {
77
+ return bytes.length >= 12 && new DataView(bytes.buffer, bytes.byteOffset, 12).getUint32(0, true) === GLB_MAGIC
78
+ }
79
+
80
+ /** The external uris a .gltf document references (buffers and images), so
81
+ * an async caller can fetch them before parseGltf. Empty for .glb and
82
+ * data: uris. */
83
+ export function gltfExternalUris(bytes: Uint8Array): string[] {
84
+ if (isGlb(bytes)) return []
85
+ let gltf = JSON.parse(new TextDecoder().decode(bytes))
86
+ let uris: string[] = []
87
+ for (let item of [...(gltf.buffers ?? []), ...(gltf.images ?? [])]) {
88
+ if (typeof item.uri === "string" && !item.uri.startsWith("data:")) uris.push(item.uri)
89
+ }
90
+ return uris
91
+ }
92
+
93
+ /**
94
+ * Parse a .glb or .gltf into ModelData. `resolve` supplies the bytes of a
95
+ * .gltf's external files by their uri as written in the document (still
96
+ * percent-encoded); omit it for .glb.
97
+ */
98
+ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
99
+ let gltf: any
100
+ let bin: Uint8Array | null = null
101
+ if (isGlb(bytes)) {
102
+ let glb = readGlb(bytes)
103
+ gltf = glb.json
104
+ bin = glb.bin
105
+ } else {
106
+ gltf = JSON.parse(new TextDecoder().decode(bytes))
107
+ }
108
+ if (gltf.asset?.version !== undefined && !String(gltf.asset.version).startsWith("2")) {
109
+ throw new Error("parseGltf: glTF version " + gltf.asset.version + " (only 2.x is supported)")
110
+ }
111
+ for (let ext of gltf.extensionsRequired ?? []) {
112
+ if (ext === "KHR_draco_mesh_compression" || ext === "EXT_meshopt_compression") {
113
+ throw new Error("parseGltf: the file's meshes are compressed (" + ext + "), which is not supported: re-export without mesh compression")
114
+ }
115
+ // Quantized attributes read through the normalized-integer path; every
116
+ // other required extension changes what the file means.
117
+ if (ext !== "KHR_mesh_quantization") {
118
+ throw new Error("parseGltf: the file requires the " + ext + " extension, which is not supported")
119
+ }
120
+ }
121
+
122
+ let external = (uri: string, what: string): Uint8Array => {
123
+ if (uri.startsWith("data:")) return decodeDataUri(uri)
124
+ if (resolve === undefined) throw new Error("parseGltf: " + what + " references the external file " + uri + " and no resolver was given")
125
+ return resolve(uri)
126
+ }
127
+
128
+ let buffers: Uint8Array[] = (gltf.buffers ?? []).map((b: any, i: number): Uint8Array => {
129
+ if (b.uri === undefined) {
130
+ if (bin === null) throw new Error("parseGltf: buffer " + i + " has no uri and the file has no binary chunk")
131
+ return bin
132
+ }
133
+ return external(b.uri, "buffer " + i)
134
+ })
135
+
136
+ let bufferViewBytes = (index: number): Uint8Array => {
137
+ let view = gltf.bufferViews[index]
138
+ let buffer = buffers[view.buffer]
139
+ if (buffer === undefined) throw new Error("parseGltf: bufferView " + index + " names a missing buffer")
140
+ return buffer.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength)
141
+ }
142
+
143
+ // Images are pulled in only when a material samples them, in first-use
144
+ // order, so `map` indexes a compact list.
145
+ let images: Uint8Array[] = []
146
+ let imageSlots = new Map<number, number>()
147
+ let imageSlot = (index: number): number => {
148
+ let slot = imageSlots.get(index)
149
+ if (slot === undefined) {
150
+ let image = gltf.images?.[index]
151
+ if (image === undefined) throw new Error("parseGltf: texture names a missing image " + index)
152
+ let bytes = image.uri !== undefined ? external(image.uri, "image " + index) : bufferViewBytes(image.bufferView)
153
+ slot = images.length
154
+ images.push(bytes)
155
+ imageSlots.set(index, slot)
156
+ }
157
+ return slot
158
+ }
159
+
160
+ let materials: ModelMaterial[] = (gltf.materials ?? []).map((m: any, i: number): ModelMaterial => {
161
+ let pbr = m.pbrMetallicRoughness ?? {}
162
+ let factor = pbr.baseColorFactor ?? [1, 1, 1, 1]
163
+ let map: number | null = null
164
+ if (pbr.baseColorTexture !== undefined) {
165
+ let texture = gltf.textures?.[pbr.baseColorTexture.index]
166
+ if (texture?.source !== undefined) map = imageSlot(texture.source)
167
+ }
168
+ return {
169
+ name: m.name ?? "material" + i,
170
+ color: [factor[0], factor[1], factor[2], factor[3] ?? 1],
171
+ map,
172
+ doubleSided: m.doubleSided === true,
173
+ transparent: m.alphaMode === "BLEND",
174
+ }
175
+ })
176
+ // Primitives without a material draw the spec's default; it is appended
177
+ // only when something uses it.
178
+ let defaultMaterial = -1
179
+
180
+ let accessorFloats = (index: number, what: string): { data: Float32Array; elements: number; count: number } => {
181
+ let acc = gltf.accessors[index]
182
+ if (acc === undefined) throw new Error("parseGltf: " + what + " names a missing accessor " + index)
183
+ if (acc.sparse !== undefined) throw new Error("parseGltf: " + what + " uses a sparse accessor, which is not supported")
184
+ let elements = TYPE_ELEMENTS[acc.type]
185
+ let compBytes = COMPONENT_BYTES[acc.componentType]
186
+ if (elements === undefined || compBytes === undefined) throw new Error("parseGltf: " + what + " has an unknown accessor type")
187
+ let out = new Float32Array(acc.count * elements)
188
+ if (acc.bufferView === undefined) return { data: out, elements, count: acc.count }
189
+ let view = gltf.bufferViews[acc.bufferView]
190
+ let bytes = bufferViewBytes(acc.bufferView)
191
+ let stride = view.byteStride ?? compBytes * elements
192
+ let dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
193
+ let base = acc.byteOffset ?? 0
194
+ let normalized = acc.normalized === true
195
+ let read = readerFor(acc.componentType, normalized)
196
+ for (let i = 0; i < acc.count; i++) {
197
+ let at = base + i * stride
198
+ for (let e = 0; e < elements; e++) out[i * elements + e] = read(dv, at + e * compBytes)
199
+ }
200
+ return { data: out, elements, count: acc.count }
201
+ }
202
+
203
+ let parts: ModelPart[] = []
204
+ let bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
205
+ let normal = mat4()
206
+
207
+ let emit = (prim: any, name: string, world: Mat4): void => {
208
+ if ((prim.mode ?? MODE_TRIANGLES) !== MODE_TRIANGLES) return
209
+ if (prim.attributes?.POSITION === undefined) return
210
+ let pos = accessorFloats(prim.attributes.POSITION, name + " POSITION")
211
+ if (pos.elements !== 3) throw new Error("parseGltf: " + name + " POSITION is not VEC3")
212
+ let count = pos.count
213
+ let nrm = prim.attributes.NORMAL !== undefined ? accessorFloats(prim.attributes.NORMAL, name + " NORMAL").data : null
214
+ let uv = prim.attributes.TEXCOORD_0 !== undefined ? accessorFloats(prim.attributes.TEXCOORD_0, name + " TEXCOORD_0").data : null
215
+ let indices: ArrayLike<number> =
216
+ prim.indices !== undefined ? accessorFloats(prim.indices, name + " indices").data : Array.from({ length: count }, (_, i) => i)
217
+ if (indices.length % 3 !== 0) throw new Error("parseGltf: " + name + " index count is not a multiple of 3")
218
+
219
+ normalMatrix(normal, world)
220
+ let flip = det3(world) < 0
221
+
222
+ let vertices: Float32Array
223
+ let packedIndices: number[]
224
+ if (nrm !== null) {
225
+ vertices = new Float32Array(count * STANDARD_FLOATS)
226
+ for (let i = 0; i < count; i++) writeVertex(vertices, i, pos.data, nrm, uv, i, world, normal)
227
+ packedIndices = new Array(indices.length)
228
+ for (let i = 0; i < indices.length; i += 3) {
229
+ packedIndices[i] = indices[i]!
230
+ packedIndices[i + 1] = flip ? indices[i + 2]! : indices[i + 1]!
231
+ packedIndices[i + 2] = flip ? indices[i + 1]! : indices[i + 2]!
232
+ }
233
+ } else {
234
+ // No normals: the spec asks for flat shading, which needs one vertex
235
+ // per triangle corner, so the primitive is un-indexed here and each
236
+ // corner takes its face normal (computed in world space, after the
237
+ // bake, so a mirroring transform is already accounted for).
238
+ let triangles = indices.length / 3
239
+ vertices = new Float32Array(triangles * 3 * STANDARD_FLOATS)
240
+ packedIndices = new Array(triangles * 3)
241
+ let face: Vec3 = [0, 0, 0]
242
+ for (let t = 0; t < triangles; t++) {
243
+ let a = indices[t * 3]!, b = indices[t * 3 + 1]!, c = indices[t * 3 + 2]!
244
+ if (flip) [b, c] = [c, b]
245
+ let out = t * 3
246
+ writeVertex(vertices, out, pos.data, null, uv, a, world, normal)
247
+ writeVertex(vertices, out + 1, pos.data, null, uv, b, world, normal)
248
+ writeVertex(vertices, out + 2, pos.data, null, uv, c, world, normal)
249
+ faceNormal(face, vertices, out)
250
+ for (let k = 0; k < 3; k++) {
251
+ let at = (out + k) * STANDARD_FLOATS + 3
252
+ vertices[at] = face[0]
253
+ vertices[at + 1] = face[1]
254
+ vertices[at + 2] = face[2]
255
+ packedIndices[out + k] = out + k
256
+ }
257
+ }
258
+ count = triangles * 3
259
+ }
260
+
261
+ for (let i = 0; i < count; i++) {
262
+ let at = i * STANDARD_FLOATS
263
+ let x = vertices[at]!, y = vertices[at + 1]!, z = vertices[at + 2]!
264
+ if (x < bounds[0]!) bounds[0] = x
265
+ if (y < bounds[1]!) bounds[1] = y
266
+ if (z < bounds[2]!) bounds[2] = z
267
+ if (x > bounds[3]!) bounds[3] = x
268
+ if (y > bounds[4]!) bounds[4] = y
269
+ if (z > bounds[5]!) bounds[5] = z
270
+ }
271
+
272
+ let material = prim.material
273
+ if (material === undefined) {
274
+ if (defaultMaterial < 0) {
275
+ defaultMaterial = materials.length
276
+ materials.push({ ...DEFAULT_MATERIAL })
277
+ }
278
+ material = defaultMaterial
279
+ }
280
+ parts.push({ name, geometry: packGeometry(vertices, packedIndices, { label: name }), material })
281
+ }
282
+
283
+ let local = mat4()
284
+ let walk = (index: number, parent: Mat4): void => {
285
+ let node = gltf.nodes[index]
286
+ if (node === undefined) throw new Error("parseGltf: scene names a missing node " + index)
287
+ let world = mat4()
288
+ multiply(world, parent, nodeMatrix(local, node))
289
+ if (node.mesh !== undefined) {
290
+ let mesh = gltf.meshes[node.mesh]
291
+ if (mesh === undefined) throw new Error("parseGltf: node " + index + " names a missing mesh " + node.mesh)
292
+ let name = node.name ?? mesh.name ?? "node" + index
293
+ let prims: any[] = mesh.primitives ?? []
294
+ for (let k = 0; k < prims.length; k++) emit(prims[k], prims.length > 1 ? name + "#" + k : name, world)
295
+ }
296
+ for (let child of node.children ?? []) walk(child, world)
297
+ }
298
+
299
+ let scene = gltf.scenes?.[gltf.scene ?? 0]
300
+ let roots: number[] = scene?.nodes ?? (gltf.nodes ?? []).map((_: unknown, i: number) => i)
301
+ let root = mat4()
302
+ for (let index of roots) walk(index, root)
303
+
304
+ if (parts.length === 0) bounds.fill(0)
305
+ return { parts, materials, images, bounds }
306
+ }
307
+
308
+ function readGlb(bytes: Uint8Array): { json: any; bin: Uint8Array | null } {
309
+ let dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
310
+ let version = dv.getUint32(4, true)
311
+ if (version !== 2) throw new Error("parseGltf: glb version " + version + " (only 2 is supported)")
312
+ let length = Math.min(dv.getUint32(8, true), bytes.byteLength)
313
+ let json: any = null
314
+ let bin: Uint8Array | null = null
315
+ let at = 12
316
+ while (at + 8 <= length) {
317
+ let chunkLength = dv.getUint32(at, true)
318
+ let chunkType = dv.getUint32(at + 4, true)
319
+ let chunk = bytes.subarray(at + 8, at + 8 + chunkLength)
320
+ if (chunkType === CHUNK_JSON) json = JSON.parse(new TextDecoder().decode(chunk))
321
+ else if (chunkType === CHUNK_BIN && bin === null) bin = chunk
322
+ at += 8 + chunkLength
323
+ }
324
+ if (json === null) throw new Error("parseGltf: glb has no JSON chunk")
325
+ return { json, bin }
326
+ }
327
+
328
+ function decodeDataUri(uri: string): Uint8Array {
329
+ let comma = uri.indexOf(",")
330
+ if (comma < 0) throw new Error("parseGltf: malformed data: uri")
331
+ let meta = uri.slice(0, comma)
332
+ let payload = uri.slice(comma + 1)
333
+ if (!meta.endsWith(";base64")) return new TextEncoder().encode(decodeURIComponent(payload))
334
+ let text = atob(payload)
335
+ let out = new Uint8Array(text.length)
336
+ for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i)
337
+ return out
338
+ }
339
+
340
+ function readerFor(componentType: number, normalized: boolean): (dv: DataView, at: number) => number {
341
+ switch (componentType) {
342
+ case 5126:
343
+ return (dv, at) => dv.getFloat32(at, true)
344
+ case 5125:
345
+ return (dv, at) => dv.getUint32(at, true)
346
+ case 5123:
347
+ return normalized ? (dv, at) => dv.getUint16(at, true) / 65535 : (dv, at) => dv.getUint16(at, true)
348
+ case 5122:
349
+ return normalized ? (dv, at) => Math.max(dv.getInt16(at, true) / 32767, -1) : (dv, at) => dv.getInt16(at, true)
350
+ case 5121:
351
+ return normalized ? (dv, at) => dv.getUint8(at) / 255 : (dv, at) => dv.getUint8(at)
352
+ default:
353
+ return normalized ? (dv, at) => Math.max(dv.getInt8(at) / 127, -1) : (dv, at) => dv.getInt8(at)
354
+ }
355
+ }
356
+
357
+ const NO_TRANSLATION: Vec3 = [0, 0, 0]
358
+ const NO_ROTATION: Quat = [0, 0, 0, 1]
359
+ const NO_SCALE: Vec3 = [1, 1, 1]
360
+
361
+ function nodeMatrix(out: Mat4, node: any): Mat4 {
362
+ if (node.matrix !== undefined) {
363
+ for (let i = 0; i < 16; i++) out[i] = node.matrix[i]
364
+ return out
365
+ }
366
+ return compose(out, node.translation ?? NO_TRANSLATION, node.rotation ?? NO_ROTATION, node.scale ?? NO_SCALE)
367
+ }
368
+
369
+ function det3(m: Mat4): number {
370
+ return (
371
+ m[0] * (m[5] * m[10] - m[6] * m[9]) -
372
+ m[4] * (m[1] * m[10] - m[2] * m[9]) +
373
+ m[8] * (m[1] * m[6] - m[2] * m[5])
374
+ )
375
+ }
376
+
377
+ // One interleaved vertex: position through the world matrix, normal (when
378
+ // given) through the normal matrix and re-normalized, uv copied or zero.
379
+ function writeVertex(
380
+ out: Float32Array,
381
+ slot: number,
382
+ pos: Float32Array,
383
+ nrm: Float32Array | null,
384
+ uv: Float32Array | null,
385
+ src: number,
386
+ world: Mat4,
387
+ normal: Mat4,
388
+ ): void {
389
+ let at = slot * STANDARD_FLOATS
390
+ let x = pos[src * 3]!, y = pos[src * 3 + 1]!, z = pos[src * 3 + 2]!
391
+ out[at] = world[0] * x + world[4] * y + world[8] * z + world[12]
392
+ out[at + 1] = world[1] * x + world[5] * y + world[9] * z + world[13]
393
+ out[at + 2] = world[2] * x + world[6] * y + world[10] * z + world[14]
394
+ if (nrm !== null) {
395
+ let nx = nrm[src * 3]!, ny = nrm[src * 3 + 1]!, nz = nrm[src * 3 + 2]!
396
+ let wx = normal[0] * nx + normal[4] * ny + normal[8] * nz
397
+ let wy = normal[1] * nx + normal[5] * ny + normal[9] * nz
398
+ let wz = normal[2] * nx + normal[6] * ny + normal[10] * nz
399
+ let len = Math.hypot(wx, wy, wz)
400
+ if (len > 1e-12) {
401
+ wx /= len
402
+ wy /= len
403
+ wz /= len
404
+ } else {
405
+ wx = 0
406
+ wy = 1
407
+ wz = 0
408
+ }
409
+ out[at + 3] = wx
410
+ out[at + 4] = wy
411
+ out[at + 5] = wz
412
+ }
413
+ if (uv !== null) {
414
+ out[at + 6] = uv[src * 2]!
415
+ out[at + 7] = uv[src * 2 + 1]!
416
+ }
417
+ }
418
+
419
+ // The unit normal of the triangle at three consecutive vertex slots.
420
+ function faceNormal(out: Vec3, v: Float32Array, first: number): void {
421
+ let a = first * STANDARD_FLOATS, b = (first + 1) * STANDARD_FLOATS, c = (first + 2) * STANDARD_FLOATS
422
+ let abx = v[b]! - v[a]!, aby = v[b + 1]! - v[a + 1]!, abz = v[b + 2]! - v[a + 2]!
423
+ let acx = v[c]! - v[a]!, acy = v[c + 1]! - v[a + 1]!, acz = v[c + 2]! - v[a + 2]!
424
+ let nx = aby * acz - abz * acy
425
+ let ny = abz * acx - abx * acz
426
+ let nz = abx * acy - aby * acx
427
+ let len = Math.hypot(nx, ny, nz)
428
+ if (len > 1e-12) {
429
+ out[0] = nx / len
430
+ out[1] = ny / len
431
+ out[2] = nz / len
432
+ } else {
433
+ out[0] = 0
434
+ out[1] = 1
435
+ out[2] = 0
436
+ }
437
+ }
package/src/index.ts CHANGED
@@ -5,23 +5,28 @@
5
5
  // without Solid components) and the component face (Scene/Mesh/Group/
6
6
  // PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
7
7
 
8
- export { add, createGroup, createInstancedMesh, createMesh, createScene, disposeInstances, getRotation, lookAt, remove, setGeometry, setInstanceCount, setInstances, setMaterial, setMeshParams, setRenderOrder, setTransform, setVisible, worldPosition } from "./scene.ts"
9
- export type { CameraUpdate, Hit, InstancedMesh as InstancedMeshNode, InstancedMeshOptions, Mesh as MeshNode, MeshInstances, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, TransformUpdate } from "./scene.ts"
8
+ export { add, createDirectionalLight, createGroup, createHemisphereLight, createInstancedMesh, createMesh, createScene, createSprite, setLight, disposeInstances, getRotation, lookAt, remove, setCastShadow, setGeometry, setInstanceCount, setInstances, setMaterial, setMeshParams, setRenderOrder, setTransform, setTransition, setVisible, worldPosition, MAX_SHADOWS } from "./scene.ts"
9
+ export type { CameraUpdate, DirectionalLight as DirectionalLightNode, DirectionalLightOptions, HemisphereLight as HemisphereLightNode, HemisphereLightOptions, Hit, Light, InstancedMesh as InstancedMeshNode, InstancedMeshOptions, Mesh as MeshNode, MeshInstances, OrthoExtent, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, ShadowCamera, ShadowOptions, TransformUpdate, TransitionEndEvent, View, ViewOptions } from "./scene.ts"
10
+ export type { NodeTransition, NodeTransitionSpec } from "flux:spatial"
10
11
  export { disposeGeometry } from "./geometry-gpu.ts"
11
- export { box, circle, cone, cylinder, fillColors, geometryBounds, mergeGeometries, plane, ring, sphere, torus, torusKnot, transformGeometry, withColors, FLOATS_PER_VERTEX, VERTEX_LAYOUTS } from "./geometry.ts"
12
- export type { ColorFill, Geometry, VertexLayout } from "./geometry.ts"
13
- export { rayBoxDistance } from "./bvh.ts"
12
+ export { box, circle, cone, cylinder, fillAttribute, fillColors, geometryBounds, layoutAttributes, layoutKey, layoutSlot, layoutStride, mergeGeometries, packGeometry, plane, ring, sphere, torus, torusKnot, transformGeometry, validateGeometry, withAttribute, withColors, STANDARD_FLOATS, VERTEX_LAYOUTS } from "./geometry.ts"
13
+ export type { AttributeFill, BoxOptions, CircleOptions, ColorFill, ConeOptions, CylinderOptions, Geometry, GeometryOptions, PlaneOptions, RingOptions, SphereOptions, TorusKnotOptions, TorusOptions, VertexLayout } from "./geometry.ts"
14
14
  export { fillet, roundRect, shape, triangulate } from "./profile.ts"
15
15
  export type { Profile, ProfilePoint } from "./profile.ts"
16
16
  export { extrude, lathe, pathFrames, sweep, tube } from "./sweep.ts"
17
- export type { PathFrames, PathPoint, SweepPath } from "./sweep.ts"
18
- export { shaderMaterial, shaderMaterialClass, unlit } from "./material.ts"
19
- export type { Material, ShaderMaterialClass, ShaderMaterialClassOptions, ShaderMaterialInstanceOptions, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
20
- export { Group, InstancedMesh, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
21
- export type { InstancedMeshProps, MeshProps, PerspectiveCameraProps, PointerEventProps, SceneProps, TransformProps } from "./components.tsx"
17
+ export type { ExtrudeOptions, LatheOptions, PathFrames, PathPoint, SweepPath, TubeOptions } from "./sweep.ts"
18
+ export { lit, shaderMaterial, shaderMaterialClass, sprite, unlit } from "./material.ts"
19
+ export type { LitOptions, Material, ShaderMaterialClass, ShaderMaterialClassOptions, ShaderMaterialInstanceOptions, ShaderMaterialOptions, SpriteOptions, UnlitOptions } from "./material.ts"
20
+ export { DirectionalLight, Group, HemisphereLight, InstancedMesh, Mesh, PerspectiveCamera, Scene, Sprite, useScene } from "./components.tsx"
21
+ export type { DirectionalLightProps, HemisphereLightProps, InstancedMeshProps, MeshProps, PerspectiveCameraProps, PointerEventProps, SceneProps, SpriteProps, TransformProps } from "./components.tsx"
22
+ export { gltfExternalUris, isGlb, parseGltf } from "./gltf.ts"
23
+ export type { ModelData, ModelMaterial, ModelPart, UriResolver } from "./gltf.ts"
24
+ export { decodeModel, encodeModel } from "./model-file.ts"
25
+ export { createModel, loadGltf, loadModel } from "./model.ts"
26
+ export type { Model, ModelOptions } from "./model.ts"
22
27
  export { createOrbitCamera } from "./orbit.ts"
23
- export type { OrbitCamera, OrbitCameraOptions, OrbitPose } from "./orbit.ts"
28
+ export type { OrbitCamera, OrbitCameraOptions, OrbitPose, OrbitTarget } from "./orbit.ts"
24
29
  // math's lookAt (the camera view matrix) stays on the /math subpath: the
25
30
  // root's lookAt is the scene verb, the same split as `add`.
26
- export { compose, copy, eulerFromQuat, identity, mat4, multiply, normalMatrix, perspective, quat, quatFromAxisAngle, quatFromEuler, quatFromFrame, quatFromTo, quatMultiply, quatNormalize, quatSlerp } from "./math.ts"
31
+ export { rayBoxDistance, compose, copy, eulerFromQuat, identity, mat4, multiply, normalMatrix, orthographic, perspective, quat, quatFromAxisAngle, quatFromEuler, quatFromFrame, quatFromTo, quatMultiply, quatNormalize, quatSlerp } from "./math.ts"
27
32
  export type { Mat4, Quat, Vec2, Vec3 } from "./math.ts"