@solidrt/3d 0.0.57 → 0.0.58
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 +859 -528
- package/examples/README.md +6 -0
- package/examples/wireframe.tsx +107 -0
- package/package.json +2 -2
- package/src/geometry-gpu.ts +45 -13
- package/src/geometry.ts +186 -5
- package/src/gltf.ts +97 -16
- package/src/index.ts +2 -1
- package/src/input.ts +11 -2
- package/src/material.ts +26 -8
- package/src/mesh.ts +5 -6
- package/src/model.ts +65 -33
- package/src/node.ts +2 -1
- package/src/scene.ts +6 -3
- package/src/skeleton.ts +211 -0
package/src/gltf.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// glTF 2.0, the subset an app needs to show authored models: the scene's
|
|
2
2
|
// node tree RETAINED - a node table of local TRS with parent links (matrix
|
|
3
3
|
// nodes decomposed; shear dropped), vertices in NODE-LOCAL space, one part
|
|
4
|
-
// per mesh primitive referencing its node -
|
|
5
|
-
//
|
|
6
|
-
// indices
|
|
4
|
+
// per mesh primitive referencing its node - positions, normals (flat ones
|
|
5
|
+
// generated when absent, per the spec, for triangles), one UV set and
|
|
6
|
+
// indices in the primitive's topology (strips and fans unrolled to
|
|
7
|
+
// triangle lists, a line loop closed into a strip; lines and points keep
|
|
8
|
+
// theirs on the geometry), and materials reduced to what lit()/unlit()
|
|
9
|
+
// draw - base color
|
|
7
10
|
// factor and texture, normal map (with scale), emissive factor and map
|
|
8
11
|
// (KHR_materials_emissive_strength as the intensity), double-sidedness, alpha
|
|
9
12
|
// blending and masking - plus the file's animations as baked clips
|
|
@@ -19,8 +22,8 @@
|
|
|
19
22
|
// bytes in `images`, and uploading is the engine side's job.
|
|
20
23
|
//
|
|
21
24
|
// Outside the subset: Draco/meshopt-compressed meshes and any other
|
|
22
|
-
// required extension throw naming it;
|
|
23
|
-
//
|
|
25
|
+
// required extension throw naming it; sparse accessors and morph
|
|
26
|
+
// targets (the "weights" channel path) are skipped
|
|
24
27
|
// or ignored; vertex colors, tangents and further UV sets are dropped
|
|
25
28
|
// (the standard layout has no slot for them yet). Skins ARE parsed:
|
|
26
29
|
// joints, inverse binds, and the "skinned" vertex layout.
|
|
@@ -30,6 +33,7 @@ import { linearToSrgb } from "./color.ts"
|
|
|
30
33
|
import type { Mat4, Quat, Vec3 } from "./math.ts"
|
|
31
34
|
import { layoutStride, packGeometry, packIndices, STANDARD_FLOATS } from "./geometry.ts"
|
|
32
35
|
import type { Geometry } from "./geometry.ts"
|
|
36
|
+
import type { Topology } from "@solidrt/core/gpu"
|
|
33
37
|
|
|
34
38
|
/** What lit()/unlit() take from a glTF material. */
|
|
35
39
|
export type ModelMaterial = {
|
|
@@ -182,7 +186,39 @@ export type UriResolver = (uri: string) => Uint8Array
|
|
|
182
186
|
const GLB_MAGIC = 0x46546c67
|
|
183
187
|
const CHUNK_JSON = 0x4e4f534a
|
|
184
188
|
const CHUNK_BIN = 0x004e4942
|
|
189
|
+
// glTF primitive modes (the GL enum values).
|
|
190
|
+
const MODE_POINTS = 0
|
|
191
|
+
const MODE_LINES = 1
|
|
192
|
+
const MODE_LINE_LOOP = 2
|
|
193
|
+
const MODE_LINE_STRIP = 3
|
|
185
194
|
const MODE_TRIANGLES = 4
|
|
195
|
+
const MODE_TRIANGLE_STRIP = 5
|
|
196
|
+
const MODE_TRIANGLE_FAN = 6
|
|
197
|
+
|
|
198
|
+
/** A triangle strip unrolled to a list: triangle t is (t, t+1, t+2),
|
|
199
|
+
* every odd one with its last two swapped so the winding stays put. */
|
|
200
|
+
function stripTriangles(strip: ArrayLike<number>): number[] {
|
|
201
|
+
let out: number[] = []
|
|
202
|
+
for (let t = 0; t + 2 < strip.length; t++) {
|
|
203
|
+
if (t % 2 === 0) out.push(strip[t]!, strip[t + 1]!, strip[t + 2]!)
|
|
204
|
+
else out.push(strip[t]!, strip[t + 2]!, strip[t + 1]!)
|
|
205
|
+
}
|
|
206
|
+
return out
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A triangle fan unrolled to a list: every triangle shares the hub. */
|
|
210
|
+
function fanTriangles(fan: ArrayLike<number>): number[] {
|
|
211
|
+
let out: number[] = []
|
|
212
|
+
for (let t = 1; t + 1 < fan.length; t++) out.push(fan[0]!, fan[t]!, fan[t + 1]!)
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A line loop as a strip that returns to its first vertex. */
|
|
217
|
+
function closeLoop(loop: ArrayLike<number>): number[] {
|
|
218
|
+
let out = Array.from(loop)
|
|
219
|
+
if (loop.length > 0) out.push(loop[0]!)
|
|
220
|
+
return out
|
|
221
|
+
}
|
|
186
222
|
|
|
187
223
|
// The spec's alphaCutoff when a MASK material leaves it out.
|
|
188
224
|
const GLTF_ALPHA_CUTOFF = 0.5
|
|
@@ -383,8 +419,8 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
|
|
|
383
419
|
let bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
|
|
384
420
|
|
|
385
421
|
let emit = (prim: any, name: string, node: number, world: Mat4, skin: number | null): void => {
|
|
386
|
-
if ((prim.mode ?? MODE_TRIANGLES) !== MODE_TRIANGLES) return
|
|
387
422
|
if (prim.attributes?.POSITION === undefined) return
|
|
423
|
+
let mode: number = prim.mode ?? MODE_TRIANGLES
|
|
388
424
|
let pos = accessorFloats(prim.attributes.POSITION, name + " POSITION")
|
|
389
425
|
if (pos.elements !== 3) throw new Error("parseGltf: " + name + " POSITION is not VEC3")
|
|
390
426
|
let count = pos.count
|
|
@@ -408,17 +444,57 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
|
|
|
408
444
|
weights = w.data
|
|
409
445
|
}
|
|
410
446
|
let stride = skinned ? SKINNED_FLOATS : STANDARD_FLOATS
|
|
411
|
-
let
|
|
447
|
+
let raw: ArrayLike<number> =
|
|
412
448
|
prim.indices !== undefined ? accessorFloats(prim.indices, name + " indices").data : Array.from({ length: count }, (_, i) => i)
|
|
413
|
-
|
|
449
|
+
// The primitive's topology, carried on the geometry. Triangle strips
|
|
450
|
+
// and fans are unrolled to lists (what the winding flip, the
|
|
451
|
+
// flat-shading un-index and the picking shape expect) and a line
|
|
452
|
+
// loop, which has no GPU topology, is closed into a strip.
|
|
453
|
+
let topology: Topology
|
|
454
|
+
let indices: ArrayLike<number>
|
|
455
|
+
switch (mode) {
|
|
456
|
+
case MODE_TRIANGLES:
|
|
457
|
+
topology = "triangles"
|
|
458
|
+
indices = raw
|
|
459
|
+
break
|
|
460
|
+
case MODE_TRIANGLE_STRIP:
|
|
461
|
+
topology = "triangles"
|
|
462
|
+
indices = stripTriangles(raw)
|
|
463
|
+
break
|
|
464
|
+
case MODE_TRIANGLE_FAN:
|
|
465
|
+
topology = "triangles"
|
|
466
|
+
indices = fanTriangles(raw)
|
|
467
|
+
break
|
|
468
|
+
case MODE_LINES:
|
|
469
|
+
topology = "lines"
|
|
470
|
+
indices = raw
|
|
471
|
+
break
|
|
472
|
+
case MODE_LINE_STRIP:
|
|
473
|
+
topology = "line-strip"
|
|
474
|
+
indices = raw
|
|
475
|
+
break
|
|
476
|
+
case MODE_LINE_LOOP:
|
|
477
|
+
topology = "line-strip"
|
|
478
|
+
indices = closeLoop(raw)
|
|
479
|
+
break
|
|
480
|
+
case MODE_POINTS:
|
|
481
|
+
topology = "points"
|
|
482
|
+
indices = raw
|
|
483
|
+
break
|
|
484
|
+
default:
|
|
485
|
+
throw new Error("parseGltf: " + name + " has an unknown primitive mode " + mode)
|
|
486
|
+
}
|
|
487
|
+
let triangles = topology === "triangles"
|
|
488
|
+
if (triangles && indices.length % 3 !== 0) throw new Error("parseGltf: " + name + " index count is not a multiple of 3")
|
|
414
489
|
|
|
415
490
|
// A mirroring chain (negative world determinant at the rest pose)
|
|
416
491
|
// flips the displayed winding, so the index order is flipped here to
|
|
417
492
|
// compensate - cull: "back" keeps the outside. Stored normals stay the
|
|
418
493
|
// authored ones: the runtime's inverse-transpose maps them outward for
|
|
419
494
|
// the flipped winding. Baked from the REST pose; a scale animated
|
|
420
|
-
// across zero would unbake it, which is pathological.
|
|
421
|
-
|
|
495
|
+
// across zero would unbake it, which is pathological. Lines and
|
|
496
|
+
// points have no winding.
|
|
497
|
+
let flip = triangles && !skinned && det3(world) < 0
|
|
422
498
|
// Joint boxes need the skin's inverse binds, and skins are built after
|
|
423
499
|
// the walk (their joints are ordinary nodes the walk registers), so
|
|
424
500
|
// the arrays are parked under the FILE's skin index and grown then.
|
|
@@ -426,14 +502,18 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
|
|
|
426
502
|
|
|
427
503
|
let vertices: Float32Array
|
|
428
504
|
let packedIndices: number[]
|
|
429
|
-
if (nrm !== null) {
|
|
505
|
+
if (nrm !== null || !triangles) {
|
|
506
|
+
// Indexed as authored. Lines and points without normals get zero
|
|
507
|
+
// ones (there is no face to take one from; nothing lights them).
|
|
430
508
|
vertices = new Float32Array(count * stride)
|
|
431
509
|
for (let i = 0; i < count; i++) writeVertex(vertices, i, pos.data, nrm, uv, i, stride, joints, weights)
|
|
432
|
-
packedIndices =
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
510
|
+
packedIndices = Array.from(indices)
|
|
511
|
+
if (flip) {
|
|
512
|
+
for (let i = 0; i < packedIndices.length; i += 3) {
|
|
513
|
+
let b = packedIndices[i + 1]!
|
|
514
|
+
packedIndices[i + 1] = packedIndices[i + 2]!
|
|
515
|
+
packedIndices[i + 2] = b
|
|
516
|
+
}
|
|
437
517
|
}
|
|
438
518
|
} else {
|
|
439
519
|
// No normals: the spec asks for flat shading, which needs one vertex
|
|
@@ -508,6 +588,7 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
|
|
|
508
588
|
let geometry: Geometry = skinned
|
|
509
589
|
? { vertices, indices: packIndices(packedIndices, count), layout: "skinned", label: name }
|
|
510
590
|
: packGeometry(vertices, packedIndices, { label: name })
|
|
591
|
+
if (!triangles) geometry.topology = topology
|
|
511
592
|
parts.push({ name, node, skin, geometry, material })
|
|
512
593
|
}
|
|
513
594
|
|
package/src/index.ts
CHANGED
|
@@ -35,7 +35,7 @@ export { linearColor, linearToSrgb, srgbToLinear } from "./color.ts"
|
|
|
35
35
|
export type { CameraState, CameraUpdate, OrthoExtent } from "./camera.ts"
|
|
36
36
|
export type { NodeTransition, NodeTransitionSpec } from "flux:spatial"
|
|
37
37
|
export { disposeGeometry } from "./geometry-gpu.ts"
|
|
38
|
-
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"
|
|
38
|
+
export { box, circle, cone, cylinder, fillAttribute, fillColors, edgesGeometry, geometryBounds, geometryTopology, layoutAttributes, layoutKey, layoutSlot, layoutStride, mergeGeometries, packGeometry, plane, ring, sphere, torus, torusKnot, transformGeometry, validateGeometry, wireframeGeometry, withAttribute, withColors, STANDARD_FLOATS, VERTEX_LAYOUTS } from "./geometry.ts"
|
|
39
39
|
export type { AttributeFill, BoxOptions, CircleOptions, ColorFill, ConeOptions, CylinderOptions, Geometry, GeometryOptions, PlaneOptions, RingOptions, SphereOptions, TorusKnotOptions, TorusOptions, VertexLayout } from "./geometry.ts"
|
|
40
40
|
export { fillet, roundRect, shape, triangulate } from "./profile.ts"
|
|
41
41
|
export type { Profile, ProfilePoint } from "./profile.ts"
|
|
@@ -69,6 +69,7 @@ export type { ModelChannel, ModelClip, ModelData, ModelMaterial, ModelNode, Mode
|
|
|
69
69
|
export { decodeModel, encodeModel } from "./model-file.ts"
|
|
70
70
|
export { createModel, loadGltf, loadModel } from "./model.ts"
|
|
71
71
|
export type { Model, ModelMaps, ModelOptions } from "./model.ts"
|
|
72
|
+
export { bindSkeleton } from "./skeleton.ts"
|
|
72
73
|
export { createMixer } from "./mixer.ts"
|
|
73
74
|
export type { Mixer, MixerOptions, MixerPlayOptions } from "./mixer.ts"
|
|
74
75
|
export { sampleChannel } from "./clip.ts"
|
package/src/input.ts
CHANGED
|
@@ -31,7 +31,9 @@ export type CameraDevices = {
|
|
|
31
31
|
export let orbitActions = { rotate: "vec2", zoom: "axis", pan: "vec2" } as const
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* The orbit camera's standard bindings: a drag rotates, a
|
|
34
|
+
* The orbit camera's standard bindings: a drag rotates, a Ctrl-drag or a
|
|
35
|
+
* right-drag pans (the desktop pans, Three's and Blender's; a chorded or
|
|
36
|
+
* right-button drag feeds only `pan`, never `rotate`), a pinch and the
|
|
35
37
|
* wheel zoom, two fingers pan; the right stick rotates, the triggers
|
|
36
38
|
* zoom, the left stick pans; the arrow keys rotate and minus/equals zoom.
|
|
37
39
|
*/
|
|
@@ -39,7 +41,14 @@ export function orbitBindings(devices: CameraDevices): Binding[] {
|
|
|
39
41
|
let out: Binding[] = []
|
|
40
42
|
let { pointer, gamepad, keyboard } = devices
|
|
41
43
|
if (pointer) {
|
|
42
|
-
out.push(
|
|
44
|
+
out.push(
|
|
45
|
+
{ action: "rotate", source: pointer.drag },
|
|
46
|
+
{ action: "pan", source: pointer.drag("Ctrl") },
|
|
47
|
+
{ action: "pan", source: pointer.drag("Right") },
|
|
48
|
+
{ action: "pan", source: pointer.pan },
|
|
49
|
+
{ action: "zoom", source: pointer.pinch },
|
|
50
|
+
{ action: "zoom", source: pointer.wheel },
|
|
51
|
+
)
|
|
43
52
|
}
|
|
44
53
|
if (gamepad) {
|
|
45
54
|
out.push({ action: "rotate", source: invert(gamepad.rightStick) }, { action: "zoom", source: gamepad.triggers }, { action: "pan", source: invert(gamepad.leftStick) })
|
package/src/material.ts
CHANGED
|
@@ -41,7 +41,7 @@ import type {
|
|
|
41
41
|
Topology,
|
|
42
42
|
VertexAttribute,
|
|
43
43
|
} from "@solidrt/core/gpu"
|
|
44
|
-
import { layoutAttributes, layoutKey, layoutSlot } from "./geometry.ts"
|
|
44
|
+
import { FORMAT_FLOATS, layoutAttributes, layoutKey, layoutSlot } from "./geometry.ts"
|
|
45
45
|
import type { VertexLayout } from "./geometry.ts"
|
|
46
46
|
import { linearColor } from "./color.ts"
|
|
47
47
|
import {
|
|
@@ -63,9 +63,10 @@ import {
|
|
|
63
63
|
} from "./glsl.ts"
|
|
64
64
|
|
|
65
65
|
export type Material = {
|
|
66
|
-
/** The pipeline this material draws with for geometry of `layout`
|
|
67
|
-
* (lazily created, one per
|
|
68
|
-
|
|
66
|
+
/** The pipeline this material draws with for geometry of `layout` and
|
|
67
|
+
* `topology` (lazily created, one per pair met): the geometry says what
|
|
68
|
+
* its indices are, the material draws them that way. */
|
|
69
|
+
pipeline(layout: VertexLayout | undefined, topology: Topology): RenderPipelineId
|
|
69
70
|
/** Per-entry uniform values this material contributes at addDraw. */
|
|
70
71
|
params: ShaderParams
|
|
71
72
|
/** Per-entry sampler bindings, when the material samples textures. */
|
|
@@ -1072,7 +1073,6 @@ export type ShaderMaterialClassOptions = {
|
|
|
1072
1073
|
depthWrite?: boolean
|
|
1073
1074
|
blend?: BlendMode
|
|
1074
1075
|
cull?: CullMode
|
|
1075
|
-
topology?: Topology
|
|
1076
1076
|
label?: string
|
|
1077
1077
|
}
|
|
1078
1078
|
|
|
@@ -1142,6 +1142,24 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
1142
1142
|
let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
|
|
1143
1143
|
let depth = opts.depth ?? true
|
|
1144
1144
|
let cull = opts.cull ?? "back"
|
|
1145
|
+
// Formats are the vertex vocabulary (f32/vec2/vec3/vec4), not WebGPU's
|
|
1146
|
+
// spelling, and an unknown one has no float count: it would leave here
|
|
1147
|
+
// intact, make a NaN record stride at the mesh, and fail as an arithmetic
|
|
1148
|
+
// complaint from createRecordMesh naming neither the attribute nor the
|
|
1149
|
+
// format. Checked where the name is written instead.
|
|
1150
|
+
for (let attr of opts.instanceAttributes ?? []) {
|
|
1151
|
+
if (!(attr.format in FORMAT_FLOATS)) {
|
|
1152
|
+
throw new Error(
|
|
1153
|
+
"shaderMaterial unknown instance attribute format '" +
|
|
1154
|
+
String(attr.format) +
|
|
1155
|
+
"' for " +
|
|
1156
|
+
attr.name +
|
|
1157
|
+
" (expected " +
|
|
1158
|
+
Object.keys(FORMAT_FLOATS).join(", ") +
|
|
1159
|
+
")",
|
|
1160
|
+
)
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1145
1163
|
// An empty list declares nothing - same as absent (the engine requires an
|
|
1146
1164
|
// instance buffer exactly when attributes are declared).
|
|
1147
1165
|
let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
|
|
@@ -1192,8 +1210,8 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
1192
1210
|
// record buffer, declared on the pipeline beside the layout).
|
|
1193
1211
|
let attributes = (): VertexAttribute[] =>
|
|
1194
1212
|
programAttributes(programFor()).filter(a => !instanceAttributes?.some(i => i.name === a.name))
|
|
1195
|
-
let pipelineFor = (layout: VertexLayout | undefined): RenderPipelineId => {
|
|
1196
|
-
let key = layoutKey(layout)
|
|
1213
|
+
let pipelineFor = (layout: VertexLayout | undefined, topology: Topology): RenderPipelineId => {
|
|
1214
|
+
let key = layoutKey(layout) + "|" + topology
|
|
1197
1215
|
let pipeline = pipelines.get(key)
|
|
1198
1216
|
if (pipeline === undefined) {
|
|
1199
1217
|
pipeline = createRenderPipeline(programFor(), {
|
|
@@ -1205,7 +1223,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
1205
1223
|
depthWrite: opts.depthWrite ?? (transparent && depth ? false : undefined),
|
|
1206
1224
|
blend: opts.blend ?? (transparent ? "alpha" : undefined),
|
|
1207
1225
|
cull,
|
|
1208
|
-
topology
|
|
1226
|
+
topology,
|
|
1209
1227
|
label: opts.label,
|
|
1210
1228
|
})
|
|
1211
1229
|
pipelines.set(key, pipeline)
|
package/src/mesh.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// scene side is reached through the node's SceneHooks (node.ts).
|
|
5
5
|
|
|
6
6
|
import { createBuffer, destroyBuffer, writeBuffer } from "@solidrt/core/gpu"
|
|
7
|
-
import type { BufferId, DrawId, InstanceAttribute, ShaderParams, TextureBindings
|
|
8
|
-
import { geometryBounds, plane } from "./geometry.ts"
|
|
7
|
+
import type { BufferId, DrawId, InstanceAttribute, ShaderParams, TextureBindings } from "@solidrt/core/gpu"
|
|
8
|
+
import { FORMAT_FLOATS, geometryBounds, plane } from "./geometry.ts"
|
|
9
9
|
import type { Geometry } from "./geometry.ts"
|
|
10
10
|
import type { GeometryBuffers } from "./geometry-gpu.ts"
|
|
11
11
|
import type { Material } from "./material.ts"
|
|
@@ -24,7 +24,8 @@ export type Mesh = SceneNode & {
|
|
|
24
24
|
renderOrder: number
|
|
25
25
|
/** Draw into the scene's shadow map (default false, Three's default).
|
|
26
26
|
* Set with setCastShadow. A casting instanced mesh is skipped (the
|
|
27
|
-
* depth pass cannot know its record layout)
|
|
27
|
+
* depth pass cannot know its record layout), and so is any geometry
|
|
28
|
+
* that is not a triangle list: lines and points cast nothing. */
|
|
28
29
|
castShadow: boolean
|
|
29
30
|
/** Layer membership bitmask (default 1, Three's `object.layers`): a
|
|
30
31
|
* target draws the mesh when its mask intersects this. Not inherited
|
|
@@ -225,13 +226,11 @@ export function localBounds(mesh: Mesh): Float32Array | null {
|
|
|
225
226
|
return mesh._sprite ? SPRITE_BOUNDS : geometryBounds(mesh.geometry)
|
|
226
227
|
}
|
|
227
228
|
|
|
228
|
-
const ATTRIBUTE_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
|
|
229
|
-
|
|
230
229
|
/** Floats per record of one instance slot of an attribute list (slot 0
|
|
231
230
|
* by default; the list's `slot` keys pick the others). */
|
|
232
231
|
export function instanceStride(attributes: InstanceAttribute[], slot = 0): number {
|
|
233
232
|
let stride = 0
|
|
234
|
-
for (let a of attributes) if ((a.slot ?? 0) === slot) stride +=
|
|
233
|
+
for (let a of attributes) if ((a.slot ?? 0) === slot) stride += FORMAT_FLOATS[a.format]
|
|
235
234
|
return stride
|
|
236
235
|
}
|
|
237
236
|
|
package/src/model.ts
CHANGED
|
@@ -24,6 +24,8 @@ import { add, createGroup, remove, setTransform } from "./node.ts"
|
|
|
24
24
|
import type { SceneNode } from "./node.ts"
|
|
25
25
|
import { createMesh } from "./mesh.ts"
|
|
26
26
|
import type { Mesh } from "./mesh.ts"
|
|
27
|
+
import { refreshJointBounds, unbindSkeleton } from "./skeleton.ts"
|
|
28
|
+
import type { WornPiece } from "./skeleton.ts"
|
|
27
29
|
|
|
28
30
|
/** Anisotropic filtering level for a model's textures: the engines' usual
|
|
29
31
|
* default (Godot ships 2x, Unity's quality presets 2-8x) - enough to keep a
|
|
@@ -80,8 +82,30 @@ export type Model = SceneNode & {
|
|
|
80
82
|
/** Local rest-pose [minX, minY, minZ, maxX, maxY, maxZ] over every part
|
|
81
83
|
* (conservative for parts under rotated nodes). */
|
|
82
84
|
bounds: Float32Array
|
|
83
|
-
/** Detach the model and free its geometry buffers and textures.
|
|
85
|
+
/** Detach the model and free its geometry buffers and textures. A
|
|
86
|
+
* worn piece (bindSkeleton) comes off its body; a body takes the
|
|
87
|
+
* pieces it wears with it. */
|
|
84
88
|
dispose(): void
|
|
89
|
+
/** @internal The skins the parts draw, in file order: the palette
|
|
90
|
+
* texture (shared by identical skins), the joint nodes in row order,
|
|
91
|
+
* their inverse binds and joint-space influence boxes. */
|
|
92
|
+
_skins: ModelSkinNodes[]
|
|
93
|
+
/** @internal The pieces bound onto this model's skeleton. */
|
|
94
|
+
_worn: WornPiece[]
|
|
95
|
+
/** @internal The body this model is bound onto, or null. */
|
|
96
|
+
_body: Model | null
|
|
97
|
+
/** @internal Subtrees bindSkeleton moved under the body's joints (no
|
|
98
|
+
* longer under this model); detached again by dispose. */
|
|
99
|
+
_grafts: SceneNode[]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @internal One skin of a built model: ModelSkin with its joints
|
|
103
|
+
* resolved to the model's nodes and its palette texture. */
|
|
104
|
+
export type ModelSkinNodes = {
|
|
105
|
+
texture: TextureId
|
|
106
|
+
joints: SceneNode[]
|
|
107
|
+
inverseBind: Float32Array
|
|
108
|
+
jointBounds: Float32Array
|
|
85
109
|
}
|
|
86
110
|
|
|
87
111
|
/**
|
|
@@ -162,6 +186,10 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
162
186
|
let materials = data.materials.map((_, i) => materialFor(i, false))
|
|
163
187
|
|
|
164
188
|
let model = createGroup() as Model
|
|
189
|
+
model._skins = []
|
|
190
|
+
model._worn = []
|
|
191
|
+
model._body = null
|
|
192
|
+
model._grafts = []
|
|
165
193
|
// The node table is pre-order, so every parent group exists before its
|
|
166
194
|
// children reference it.
|
|
167
195
|
let groups: SceneNode[] = data.nodes.map((n) => {
|
|
@@ -183,39 +211,38 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
183
211
|
// joints move - there is no JS palette walk, and posing joints with
|
|
184
212
|
// setTransform is always enough. Identical skins (same joints, same
|
|
185
213
|
// inverse binds - the body/legs and LOD splits exporters produce) share
|
|
186
|
-
// one texture, so their palette is computed and uploaded once.
|
|
187
|
-
|
|
214
|
+
// one texture, so their palette is computed and uploaded once. A worn
|
|
215
|
+
// piece's rows move onto another model's joints (bindSkeleton).
|
|
188
216
|
data.skins.forEach((skin, i) => {
|
|
189
|
-
let joints = skin.joints.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
other.joints.join(",") === joints &&
|
|
194
|
-
other.inverseBind.length === skin.inverseBind.length &&
|
|
195
|
-
other.inverseBind.every((v, k) => v === skin.inverseBind[k])
|
|
196
|
-
) {
|
|
197
|
-
skinTextures.push(skinTextures[j]!)
|
|
198
|
-
return
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
let texture = createMutableTexture(new Float32Array(skin.joints.length * 16), 4, skin.joints.length, {
|
|
202
|
-
format: "rgba32f",
|
|
203
|
-
autoFree: false,
|
|
204
|
-
label: label ? label + "-skin" + i : "skin" + i,
|
|
217
|
+
let joints = skin.joints.map((n) => {
|
|
218
|
+
let joint = groups[n]
|
|
219
|
+
if (joint === undefined) throw new Error("createModel: skin " + i + " names a missing node " + n)
|
|
220
|
+
return joint
|
|
205
221
|
})
|
|
222
|
+
let key = skin.joints.join(",")
|
|
223
|
+
let same = model._skins.find(
|
|
224
|
+
(other, j) =>
|
|
225
|
+
data.skins[j]!.joints.join(",") === key &&
|
|
226
|
+
other.inverseBind.length === skin.inverseBind.length &&
|
|
227
|
+
other.inverseBind.every((v, k) => v === skin.inverseBind[k]),
|
|
228
|
+
)
|
|
229
|
+
let texture =
|
|
230
|
+
same?.texture ??
|
|
231
|
+
createMutableTexture(new Float32Array(skin.joints.length * 16), 4, skin.joints.length, {
|
|
232
|
+
format: "rgba32f",
|
|
233
|
+
autoFree: false,
|
|
234
|
+
label: label ? label + "-skin" + i : "skin" + i,
|
|
235
|
+
})
|
|
236
|
+
model._skins.push({ texture, joints, inverseBind: skin.inverseBind, jointBounds: skin.jointBounds })
|
|
237
|
+
if (same !== undefined) return
|
|
206
238
|
textures.push(texture)
|
|
207
|
-
|
|
208
|
-
for (let j = 0; j < skin.joints.length; j++) {
|
|
209
|
-
let joint = groups[skin.joints[j]!]
|
|
210
|
-
if (joint === undefined) throw new Error("createModel: skin " + i + " names a missing node " + skin.joints[j])
|
|
239
|
+
joints.forEach((joint, j) => {
|
|
211
240
|
;(joint._palettes ??= []).push({ texture, row: j, post: skin.inverseBind.slice(j * 16, j * 16 + 16), anchor: model })
|
|
212
|
-
|
|
213
|
-
// flush carries through the pose. A joint influencing nothing (an
|
|
214
|
-
// inverted box) gets none and contributes nothing to the union.
|
|
215
|
-
let jb = skin.jointBounds.subarray(j * 6, j * 6 + 6)
|
|
216
|
-
if (jb[0]! <= jb[3]! && jb[1]! <= jb[4]! && jb[2]! <= jb[5]!) joint._cullBounds = Float32Array.from(jb)
|
|
217
|
-
}
|
|
241
|
+
})
|
|
218
242
|
})
|
|
243
|
+
// Each joint's influence box, in joint space: culling-only bounds the
|
|
244
|
+
// flush carries through the pose, united over every skin reaching it.
|
|
245
|
+
refreshJointBounds(model)
|
|
219
246
|
model.parts = data.parts.map((part) => {
|
|
220
247
|
let skinned = part.skin !== null
|
|
221
248
|
let material = materialFor(part.material, skinned)
|
|
@@ -225,11 +252,11 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
225
252
|
// matrices place them, so its mesh hangs off the model root (the
|
|
226
253
|
// spec ignores the node's transform for skinned meshes) and uModel
|
|
227
254
|
// stays the model's own placement.
|
|
228
|
-
let
|
|
229
|
-
if (
|
|
230
|
-
mesh._textures = { uBones: texture }
|
|
255
|
+
let skin = model._skins[part.skin!]
|
|
256
|
+
if (skin === undefined) throw new Error("createModel: part '" + part.name + "' names a missing skin " + part.skin)
|
|
257
|
+
mesh._textures = { uBones: skin.texture }
|
|
231
258
|
// Culled by its joints' boxes, so the box follows the animation.
|
|
232
|
-
mesh._cullJoints =
|
|
259
|
+
mesh._cullJoints = skin.joints.slice()
|
|
233
260
|
add(model, mesh)
|
|
234
261
|
} else {
|
|
235
262
|
let node = groups[part.node]
|
|
@@ -242,6 +269,11 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
|
|
|
242
269
|
model.clips = data.clips
|
|
243
270
|
model.bounds = data.bounds
|
|
244
271
|
model.dispose = () => {
|
|
272
|
+
// A worn piece comes off first (its rows sit on the body's joints);
|
|
273
|
+
// a body takes its pieces with it (their skins read joints that are
|
|
274
|
+
// about to go).
|
|
275
|
+
if (model._body !== null) unbindSkeleton(model)
|
|
276
|
+
for (let worn of model._worn.slice()) worn.piece.dispose()
|
|
245
277
|
if (model.parent !== null) remove(model)
|
|
246
278
|
for (let part of model.parts) disposeGeometry(part.mesh.geometry)
|
|
247
279
|
for (let id of textures) destroyTexture(id)
|
package/src/node.ts
CHANGED
|
@@ -143,7 +143,8 @@ export type SceneNode = {
|
|
|
143
143
|
* skin): bound to the core at every scene enter, so the flush writes
|
|
144
144
|
* `inverse(anchorWorld) * world * post` - the model-local bone matrix -
|
|
145
145
|
* to the palette texture's row whenever the node moves. `anchor` is the
|
|
146
|
-
* model root
|
|
146
|
+
* model root - the body's, for a piece bound onto its skeleton - and
|
|
147
|
+
* null for non-joints (the common case pays one null check). */
|
|
147
148
|
_palettes: { texture: TextureId; row: number; post: Float32Array; anchor: SceneNode }[] | null
|
|
148
149
|
/** A culling-only local box (a model joint's influence region, in joint
|
|
149
150
|
* space): bound at every scene enter so the joint's world box follows
|
package/src/scene.ts
CHANGED
|
@@ -50,7 +50,7 @@ import { cameraParams, cameraState, ensureCamera, makeCamera, updateCamera } fro
|
|
|
50
50
|
import type { Camera, CameraState, CameraUpdate } from "./camera.ts"
|
|
51
51
|
import { makeShadowSystem } from "./scene-shadows.ts"
|
|
52
52
|
import { makePointerInput } from "./scene-pointer.ts"
|
|
53
|
-
import { geometryBounds, layoutKey, validateGeometry } from "./geometry.ts"
|
|
53
|
+
import { geometryBounds, geometryTopology, layoutKey, validateGeometry } from "./geometry.ts"
|
|
54
54
|
import type { Geometry } from "./geometry.ts"
|
|
55
55
|
import { acquireGeometryBuffers, releaseGeometryBuffers } from "./geometry-gpu.ts"
|
|
56
56
|
import { backgroundPipeline, missingAttributes, SKYBOX_FRAGMENT } from "./material.ts"
|
|
@@ -1289,7 +1289,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
1289
1289
|
// so added live it would draw at the seeded identity until then. The
|
|
1290
1290
|
// mismatch branch in sync() turns it on in the same pass that writes
|
|
1291
1291
|
// uModel.
|
|
1292
|
-
mesh._entry = addDraw(texture, mesh.material.pipeline(mesh.geometry.layout), entrySeed(mesh.material, mesh._params), {
|
|
1292
|
+
mesh._entry = addDraw(texture, mesh.material.pipeline(mesh.geometry.layout, geometryTopology(mesh.geometry)), entrySeed(mesh.material, mesh._params), {
|
|
1293
1293
|
buffer: bufs.buffer,
|
|
1294
1294
|
indexBuffer: bufs.index,
|
|
1295
1295
|
indexFormat: bufs.indexFormat,
|
|
@@ -1319,6 +1319,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
1319
1319
|
if (v.entries.has(mesh)) return
|
|
1320
1320
|
let inst = mesh._instances
|
|
1321
1321
|
if (v.shadowFilter !== null && !v.shadowFilter(mesh)) return
|
|
1322
|
+
// Lines and points cast nothing: a one-pixel wireframe in a shadow
|
|
1323
|
+
// map is speckle on the floor, never a shadow anyone wanted.
|
|
1324
|
+
if (v.shadowFilter !== null && geometryTopology(mesh.geometry) !== "triangles") return
|
|
1322
1325
|
if ((mesh.layers & v.mask) === 0) return
|
|
1323
1326
|
let material = viewMaterial(v, mesh)
|
|
1324
1327
|
// An override pipeline cannot know a populated mesh's record layout,
|
|
@@ -1331,7 +1334,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
1331
1334
|
checkInstancePairing(material, inst, "Shadow material")
|
|
1332
1335
|
}
|
|
1333
1336
|
let bufs = mesh._buffers!
|
|
1334
|
-
let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout), entrySeed(material, v.override !== null ? null : mesh._params), {
|
|
1337
|
+
let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout, geometryTopology(mesh.geometry)), entrySeed(material, v.override !== null ? null : mesh._params), {
|
|
1335
1338
|
buffer: bufs.buffer,
|
|
1336
1339
|
indexBuffer: bufs.index,
|
|
1337
1340
|
indexFormat: bufs.indexFormat,
|