@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/AGENTS.md +332 -91
- package/README.md +13 -9
- package/demos/README.md +15 -0
- package/demos/assets/icon.svg +23 -0
- package/demos/package.json +9 -0
- package/demos/src/the-third-dimension.tsx +866 -0
- package/demos/tsconfig.json +15 -0
- package/examples/README.md +29 -2
- package/examples/aim.tsx +5 -5
- package/examples/instanced.tsx +4 -4
- package/examples/lit.tsx +66 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +51 -0
- package/examples/pick.tsx +5 -5
- package/examples/scene-background.tsx +3 -3
- package/examples/scene-basic.tsx +3 -3
- package/examples/scene-post-effect.tsx +3 -3
- package/examples/scene-views.tsx +95 -0
- package/examples/shadows.tsx +86 -0
- package/examples/sprites.tsx +95 -0
- package/examples/sweep-paths.tsx +11 -27
- package/package.json +4 -3
- package/src/components.tsx +110 -14
- package/src/geometry-gpu.ts +13 -2
- package/src/geometry.ts +331 -127
- package/src/glsl.ts +83 -3
- package/src/gltf.ts +437 -0
- package/src/index.ts +17 -12
- package/src/material.ts +352 -47
- package/src/math.ts +69 -0
- package/src/model-file.ts +122 -0
- package/src/model.ts +105 -0
- package/src/orbit.ts +13 -9
- package/src/order.ts +12 -5
- package/src/profile.ts +4 -8
- package/src/scene.ts +1075 -282
- package/src/sweep.ts +21 -36
- package/src/bvh.ts +0 -258
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
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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,
|
|
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"
|