@solidrt/3d 0.0.60 → 0.0.61
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 +436 -139
- package/examples/cascades.tsx +10 -4
- package/examples/pick.tsx +6 -4
- package/examples/sprites.tsx +7 -1
- package/package.json +4 -3
- package/src/components/context.tsx +13 -2
- package/src/components/first-person-camera.tsx +1 -1
- package/src/components/index.ts +2 -0
- package/src/components/instanced-mesh.tsx +25 -6
- package/src/components/lod.tsx +3 -1
- package/src/components/orbit-camera.tsx +105 -20
- package/src/components/shots.tsx +93 -0
- package/src/first-person.ts +48 -16
- package/src/gltf.ts +316 -62
- package/src/index.ts +8 -3
- package/src/input.ts +61 -17
- package/src/light.ts +25 -2
- package/src/material.ts +101 -25
- package/src/math.ts +11 -0
- package/src/mesh.ts +68 -21
- package/src/model-data.ts +27 -0
- package/src/model-file.ts +112 -37
- package/src/model.ts +90 -29
- package/src/node.ts +1 -1
- package/src/orbit.ts +526 -108
- package/src/scene-shadows.ts +4 -3
- package/src/scene.ts +201 -189
- package/src/shots.ts +63 -0
- package/src/skeleton.ts +39 -7
- package/tools/environment.ts +1 -0
- package/tools/model.ts +4 -3
- package/tools/node-env.d.ts +25 -0
- package/src/motion.ts +0 -17
- package/src/order.ts +0 -58
package/src/input.ts
CHANGED
|
@@ -27,19 +27,35 @@ export type CameraDevices = {
|
|
|
27
27
|
keyboard?: KeyboardDevice
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** The orbit control's actions: `rotate`, `zoom`, `pan
|
|
31
|
-
|
|
30
|
+
/** The orbit control's actions: `rotate`, `zoom`, `pan`, `focus` (an
|
|
31
|
+
* axis: a bound double tap nudges it with the tap's focal). */
|
|
32
|
+
export let orbitActions = { rotate: "vec2", zoom: "axis", pan: "vec2", focus: "axis" } as const
|
|
33
|
+
|
|
34
|
+
// The device half orbit and map presets share: the right stick rotates,
|
|
35
|
+
// the triggers zoom, the left stick pans; the arrows rotate and
|
|
36
|
+
// minus/equals zoom.
|
|
37
|
+
function orbitDeviceBindings(devices: CameraDevices, out: Binding[]): Binding[] {
|
|
38
|
+
let { gamepad, keyboard } = devices
|
|
39
|
+
if (gamepad) {
|
|
40
|
+
out.push({ action: "rotate", source: invert(gamepad.rightStick) }, { action: "zoom", source: gamepad.triggers }, { action: "pan", source: invert(gamepad.leftStick) })
|
|
41
|
+
}
|
|
42
|
+
if (keyboard) {
|
|
43
|
+
out.push({ action: "rotate", source: invert(keyboard.arrows) }, { action: "zoom", source: keyboard.axis("Minus", "Equal") })
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
32
47
|
|
|
33
48
|
/**
|
|
34
49
|
* The orbit camera's standard bindings: a drag rotates, a Ctrl-drag or a
|
|
35
50
|
* right-drag pans (the desktop pans, Three's and Blender's; a chorded or
|
|
36
51
|
* right-button drag feeds only `pan`, never `rotate`), a pinch and the
|
|
37
|
-
* wheel zoom, two fingers pan; the right stick
|
|
38
|
-
* zoom, the left stick pans; the arrow keys rotate
|
|
52
|
+
* wheel zoom, two fingers pan, a double tap focuses; the right stick
|
|
53
|
+
* rotates, the triggers zoom, the left stick pans; the arrow keys rotate
|
|
54
|
+
* and minus/equals zoom.
|
|
39
55
|
*/
|
|
40
56
|
export function orbitBindings(devices: CameraDevices): Binding[] {
|
|
41
57
|
let out: Binding[] = []
|
|
42
|
-
let { pointer
|
|
58
|
+
let { pointer } = devices
|
|
43
59
|
if (pointer) {
|
|
44
60
|
out.push(
|
|
45
61
|
{ action: "rotate", source: pointer.drag },
|
|
@@ -48,32 +64,60 @@ export function orbitBindings(devices: CameraDevices): Binding[] {
|
|
|
48
64
|
{ action: "pan", source: pointer.pan },
|
|
49
65
|
{ action: "zoom", source: pointer.pinch },
|
|
50
66
|
{ action: "zoom", source: pointer.wheel },
|
|
67
|
+
{ action: "focus", source: pointer.doubleTap },
|
|
51
68
|
)
|
|
52
69
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
return orbitDeviceBindings(devices, out)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The map preset over the same orbit control (Three's MapControls): a
|
|
75
|
+
* drag PANS, a right-drag or a Ctrl-drag rotates, two fingers rotate, a
|
|
76
|
+
* pinch and the wheel zoom, a double tap focuses; the pad and keys as
|
|
77
|
+
* orbitBindings. Pair it with `panPlane: "ground"` on the control so a
|
|
78
|
+
* pan slides over the map, not across the view.
|
|
79
|
+
*/
|
|
80
|
+
export function mapBindings(devices: CameraDevices): Binding[] {
|
|
81
|
+
let out: Binding[] = []
|
|
82
|
+
let { pointer } = devices
|
|
83
|
+
if (pointer) {
|
|
84
|
+
out.push(
|
|
85
|
+
{ action: "pan", source: pointer.drag },
|
|
86
|
+
{ action: "rotate", source: pointer.drag("Ctrl") },
|
|
87
|
+
{ action: "rotate", source: pointer.drag("Right") },
|
|
88
|
+
{ action: "rotate", source: pointer.pan },
|
|
89
|
+
{ action: "zoom", source: pointer.pinch },
|
|
90
|
+
{ action: "zoom", source: pointer.wheel },
|
|
91
|
+
{ action: "focus", source: pointer.doubleTap },
|
|
92
|
+
)
|
|
58
93
|
}
|
|
59
|
-
return out
|
|
94
|
+
return orbitDeviceBindings(devices, out)
|
|
60
95
|
}
|
|
61
96
|
|
|
62
|
-
/** The first-person control's actions: `look`, `move`, `rise
|
|
63
|
-
|
|
97
|
+
/** The first-person control's actions: `look`, `move`, `rise`, `boost`
|
|
98
|
+
* (an axis, so a button binding reads 1 while held - the control has
|
|
99
|
+
* no button kind). */
|
|
100
|
+
export let firstPersonActions = { look: "vec2", move: "vec2", rise: "axis", boost: "axis" } as const
|
|
64
101
|
|
|
65
102
|
/**
|
|
66
103
|
* The first-person camera's standard bindings: a drag looks around and so
|
|
67
104
|
* does mouse motion while the pointer is locked (the app locks it: see
|
|
68
105
|
* examples/first-person.tsx); the right stick looks, the left stick
|
|
69
|
-
* walks, the shoulders rise and sink in fly mode
|
|
70
|
-
* walk, Q/E rise and
|
|
106
|
+
* walks, the shoulders rise and sink in fly mode, the left stick's press
|
|
107
|
+
* boosts (the usual pad sprint); WASD and the arrows walk, Q/E rise and
|
|
108
|
+
* sink, Shift boosts.
|
|
71
109
|
*/
|
|
72
110
|
export function firstPersonBindings(devices: CameraDevices): Binding[] {
|
|
73
111
|
let out: Binding[] = []
|
|
74
112
|
let { pointer, gamepad, keyboard } = devices
|
|
75
113
|
if (pointer) out.push({ action: "look", source: pointer.drag }, { action: "look", source: pointer.mouseDelta })
|
|
76
|
-
if (gamepad)
|
|
77
|
-
|
|
114
|
+
if (gamepad) {
|
|
115
|
+
out.push({ action: "look", source: gamepad.rightStick }, { action: "move", source: gamepad.leftStick }, { action: "rise", source: gamepad.shoulders })
|
|
116
|
+
out.push({ action: "boost", source: gamepad.button("leftStick") })
|
|
117
|
+
}
|
|
118
|
+
if (keyboard) {
|
|
119
|
+
out.push({ action: "move", source: keyboard.wasd }, { action: "move", source: keyboard.arrows }, { action: "rise", source: keyboard.axis("KeyQ", "KeyE") })
|
|
120
|
+
out.push({ action: "boost", source: keyboard.key("ShiftLeft") }, { action: "boost", source: keyboard.key("ShiftRight") })
|
|
121
|
+
}
|
|
78
122
|
return out
|
|
79
123
|
}
|
package/src/light.ts
CHANGED
|
@@ -44,6 +44,14 @@ export type ShadowOptions = {
|
|
|
44
44
|
* every cascade; it also bounds the maps' depth range, which is what
|
|
45
45
|
* `bias` is measured against. A box light ignores it. */
|
|
46
46
|
distance?: number | null
|
|
47
|
+
/** Where the cascades split: `cascades - 1` fractions of the
|
|
48
|
+
* near..distance range, strictly ascending in 0..1, each the far bound
|
|
49
|
+
* of one slice (Godot's shadow_split_1..3, Unity's cascade splits).
|
|
50
|
+
* Absent or null (the default) the practical split places them,
|
|
51
|
+
* halfway between uniform and logarithmic; give them to push
|
|
52
|
+
* resolution outward (a high viewpoint over distant detail) or pull it
|
|
53
|
+
* in (close quarters). A box light ignores it. */
|
|
54
|
+
splits?: number[] | null
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/** A directional light node: parallel rays travelling along `direction`
|
|
@@ -66,7 +74,7 @@ export type DirectionalLight = SceneNode & {
|
|
|
66
74
|
* `receiveShadow: false`). */
|
|
67
75
|
castShadow: boolean
|
|
68
76
|
/** The resolved shadow options (read; write through setLight). */
|
|
69
|
-
shadow: { mapSize: number; bias: number; normalBias: number; radius: number; camera: ShadowCamera; cascades: number; distance: number | null }
|
|
77
|
+
shadow: { mapSize: number; bias: number; normalBias: number; radius: number; camera: ShadowCamera; cascades: number; distance: number | null; splits: number[] | null }
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
/** A spot light node: a cone of light from the node's WORLD position
|
|
@@ -251,6 +259,21 @@ function mergeShadow(into: DirectionalLight["shadow"], update: ShadowOptions): v
|
|
|
251
259
|
if (d !== null && !(d > 0)) throw new Error("shadow.distance must be a positive number or null")
|
|
252
260
|
into.distance = d
|
|
253
261
|
}
|
|
262
|
+
if (update.splits !== undefined) {
|
|
263
|
+
let s = update.splits
|
|
264
|
+
if (s !== null) {
|
|
265
|
+
if (!Array.isArray(s)) throw new Error("shadow.splits must be an array of fractions or null")
|
|
266
|
+
s.forEach((t, i) => {
|
|
267
|
+
if (!(t > 0 && t < 1)) throw new Error("shadow.splits[" + i + "] must be a fraction strictly between 0 and 1")
|
|
268
|
+
if (i > 0 && !(t > s[i - 1]!)) throw new Error("shadow.splits must be strictly ascending")
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
into.splits = s === null ? null : [...s]
|
|
272
|
+
}
|
|
273
|
+
// Checked after both merge: cascades and splits may arrive in one update.
|
|
274
|
+
if (into.splits !== null && into.splits.length !== into.cascades - 1) {
|
|
275
|
+
throw new Error("shadow.splits must hold cascades - 1 fractions (" + (into.cascades - 1) + " for " + into.cascades + " cascades), got " + into.splits.length)
|
|
276
|
+
}
|
|
254
277
|
}
|
|
255
278
|
|
|
256
279
|
export function createDirectionalLight(opts: DirectionalLightOptions = {}): DirectionalLight {
|
|
@@ -260,7 +283,7 @@ export function createDirectionalLight(opts: DirectionalLightOptions = {}): Dire
|
|
|
260
283
|
light.color = [...(opts.color ?? [1, 1, 1])] as Vec3
|
|
261
284
|
light.intensity = opts.intensity ?? 1
|
|
262
285
|
light.castShadow = opts.castShadow === true
|
|
263
|
-
light.shadow = { mapSize: 1024, bias: 0, normalBias: 0, radius: 1, camera: { left: -5, right: 5, top: 5, bottom: -5, near: 0.5, far: 500 }, cascades: 1, distance: null }
|
|
286
|
+
light.shadow = { mapSize: 1024, bias: 0, normalBias: 0, radius: 1, camera: { left: -5, right: 5, top: 5, bottom: -5, near: 0.5, far: 500 }, cascades: 1, distance: null, splits: null }
|
|
264
287
|
if (opts.shadow !== undefined) mergeShadow(light.shadow, opts.shadow)
|
|
265
288
|
return light
|
|
266
289
|
}
|
package/src/material.ts
CHANGED
|
@@ -86,10 +86,16 @@ export type Material = {
|
|
|
86
86
|
* stride). */
|
|
87
87
|
attributes(): VertexAttribute[]
|
|
88
88
|
/** True when the pipeline blends over (its `blend`, "alpha" by default,
|
|
89
|
-
* with depthWrite off):
|
|
90
|
-
*
|
|
91
|
-
*
|
|
89
|
+
* with depthWrite off): the scene draws this material's meshes after
|
|
90
|
+
* every opaque and cutout one, sorted back-to-front by the center of
|
|
91
|
+
* their world bounds and re-sorted as the camera moves. */
|
|
92
92
|
transparent?: boolean
|
|
93
|
+
/** True when the fragment discards (a stock material's `alphaTest`, a
|
|
94
|
+
* shaderMaterialClass told `cutout`): the scene draws this material's
|
|
95
|
+
* meshes in the cutout queue, after the solid opaques and front-to-back
|
|
96
|
+
* like them, so the solids fill the depth before the fragments that
|
|
97
|
+
* cannot early-z. */
|
|
98
|
+
cutout?: boolean
|
|
93
99
|
/** The instance buffers, when the material's pipeline declares them
|
|
94
100
|
* (shaderMaterialClass's `instanceBuffers`; the stock materials'
|
|
95
101
|
* `instanced`), one layout per buffer after the geometry's. Such a
|
|
@@ -177,10 +183,10 @@ export type UnlitOptions = {
|
|
|
177
183
|
cull?: CullMode
|
|
178
184
|
/** Cutout: drop a fragment whose final alpha (color x map, and the
|
|
179
185
|
* vertex color under vertexColors) is below this, 0..1 (Three's alphaTest, glTF
|
|
180
|
-
* alphaMode MASK with its alphaCutoff). Opaque otherwise:
|
|
181
|
-
*
|
|
182
|
-
* fences want it with `cull:
|
|
183
|
-
* (Material.shadow). */
|
|
186
|
+
* alphaMode MASK with its alphaCutoff). Opaque otherwise: depth-written
|
|
187
|
+
* and drawn after the solid opaques (Material.cutout), never blended
|
|
188
|
+
* like `transparent`. Foliage cards and fences want it with `cull:
|
|
189
|
+
* "none"`; a mapped cutout casts its cutout (Material.shadow). */
|
|
184
190
|
alphaTest?: number
|
|
185
191
|
/** Take the scene's fog (default true, Three's `material.fog`): the
|
|
186
192
|
* fragment fades toward the fog color with its distance from the
|
|
@@ -290,6 +296,7 @@ export function unlit(opts: UnlitOptions = {}): Material {
|
|
|
290
296
|
instanceBuffers: stockInstanceBuffers(instanced, instanceColors),
|
|
291
297
|
instanceStyle: instanceColors ? INSTANCE_COLOR_DEFAULT : undefined,
|
|
292
298
|
transparent,
|
|
299
|
+
cutout: alphaTest,
|
|
293
300
|
blend,
|
|
294
301
|
cull,
|
|
295
302
|
label: "scene-unlit-" + key,
|
|
@@ -369,6 +376,19 @@ export type LitOptions = UnlitOptions & {
|
|
|
369
376
|
* scene's shadow set (see SHADOW's doc) and composing `shadow` per light.
|
|
370
377
|
*/
|
|
371
378
|
receiveShadow?: boolean
|
|
379
|
+
/** GLSL at file scope for `surface`: uniforms (each an ordinary
|
|
380
|
+
* per-entry param, `params` on the mesh or setMeshParams) and helper
|
|
381
|
+
* functions. See the tier-2 look in AGENTS.md "Custom looks". */
|
|
382
|
+
prelude?: string
|
|
383
|
+
/** GLSL declaring `void surface(inout Surface s)`, called once the
|
|
384
|
+
* program has filled the Surface from the options (base from `color`,
|
|
385
|
+
* the map and the vertex color; the normal, bent by the normal map;
|
|
386
|
+
* emissive, the light model's fields) and before it shades: rewrite any
|
|
387
|
+
* field or `discard`. Runs in the shadow twin too, so what it discards
|
|
388
|
+
* casts no shadow (the twin sees a `prelude` uniform at its zero value:
|
|
389
|
+
* mesh params reach the main entry only). One program per distinct
|
|
390
|
+
* source, like every other option. */
|
|
391
|
+
surface?: string
|
|
372
392
|
}
|
|
373
393
|
|
|
374
394
|
/** `phong`'s options: the lit set plus the Blinn-Phong highlight and the
|
|
@@ -440,6 +460,9 @@ type LitClass = {
|
|
|
440
460
|
instanced: boolean
|
|
441
461
|
instanceColors: boolean
|
|
442
462
|
morph: boolean
|
|
463
|
+
// The tier-2 sources, "" for none: a source string is a fine key part.
|
|
464
|
+
prelude: string
|
|
465
|
+
surface: string
|
|
443
466
|
}
|
|
444
467
|
|
|
445
468
|
function litClassKey(c: LitClass): string {
|
|
@@ -500,6 +523,8 @@ export function phong(opts: PhongOptions = {}): Material {
|
|
|
500
523
|
instanced: opts.instanced === true || opts.instanceColors === true,
|
|
501
524
|
instanceColors: opts.instanceColors === true,
|
|
502
525
|
morph: opts.morph === true,
|
|
526
|
+
prelude: opts.prelude ?? "",
|
|
527
|
+
surface: opts.surface ?? "",
|
|
503
528
|
}
|
|
504
529
|
let key = litClassKey(flags)
|
|
505
530
|
let cls = phongClasses.get(key)
|
|
@@ -511,6 +536,7 @@ export function phong(opts: PhongOptions = {}): Material {
|
|
|
511
536
|
instanceBuffers: stockInstanceBuffers(flags.instanced, flags.instanceColors),
|
|
512
537
|
instanceStyle: flags.instanceColors ? INSTANCE_COLOR_DEFAULT : undefined,
|
|
513
538
|
transparent: flags.transparent,
|
|
539
|
+
cutout: flags.alphaTest,
|
|
514
540
|
blend: flags.blend,
|
|
515
541
|
cull,
|
|
516
542
|
label: "scene-phong-" + key,
|
|
@@ -542,15 +568,16 @@ export function phong(opts: PhongOptions = {}): Material {
|
|
|
542
568
|
if (lightMap) textures.uLightMap = opts.lightMap!
|
|
543
569
|
}
|
|
544
570
|
// A mapped cutout casts its cutout, triplanar included (the shadow
|
|
545
|
-
// source resolves the base exactly as the main one does)
|
|
546
|
-
//
|
|
547
|
-
//
|
|
571
|
+
// source resolves the base exactly as the main one does), and a
|
|
572
|
+
// surface function casts its discards. A color-only alphaTest is a
|
|
573
|
+
// constant over the mesh - all or nothing, and nothing needs no
|
|
574
|
+
// program - so it keeps the plain cull-only variant.
|
|
548
575
|
let material = cls.instance({
|
|
549
576
|
params,
|
|
550
577
|
textures,
|
|
551
578
|
shadow:
|
|
552
|
-
alphaTest && map
|
|
553
|
-
? litShadowMaterial(flags, uColor, opts.alphaTest
|
|
579
|
+
(alphaTest && map) || flags.surface !== ""
|
|
580
|
+
? litShadowMaterial(flags, uColor, opts.alphaTest, opts.triplanar, opts.map, mapTransform ? params.uMapTransform as number[] : undefined)
|
|
554
581
|
: undefined,
|
|
555
582
|
})
|
|
556
583
|
return material
|
|
@@ -609,6 +636,8 @@ function standardShadowFlags(c: StandardClass): LitClass {
|
|
|
609
636
|
instanced: c.instanced,
|
|
610
637
|
instanceColors: c.instanceColors,
|
|
611
638
|
morph: c.morph,
|
|
639
|
+
prelude: c.prelude,
|
|
640
|
+
surface: c.surface,
|
|
612
641
|
}
|
|
613
642
|
}
|
|
614
643
|
|
|
@@ -673,6 +702,8 @@ export function standard(opts: StandardOptions = {}): Material {
|
|
|
673
702
|
instanced: opts.instanced === true || opts.instanceColors === true,
|
|
674
703
|
instanceColors: opts.instanceColors === true,
|
|
675
704
|
morph: opts.morph === true,
|
|
705
|
+
prelude: opts.prelude ?? "",
|
|
706
|
+
surface: opts.surface ?? "",
|
|
676
707
|
metalnessMap,
|
|
677
708
|
roughnessMap,
|
|
678
709
|
}
|
|
@@ -686,6 +717,7 @@ export function standard(opts: StandardOptions = {}): Material {
|
|
|
686
717
|
instanceBuffers: stockInstanceBuffers(flags.instanced, flags.instanceColors),
|
|
687
718
|
instanceStyle: flags.instanceColors ? INSTANCE_COLOR_DEFAULT : undefined,
|
|
688
719
|
transparent: flags.transparent,
|
|
720
|
+
cutout: flags.alphaTest,
|
|
689
721
|
blend: flags.blend,
|
|
690
722
|
cull,
|
|
691
723
|
label: "scene-standard-" + key,
|
|
@@ -716,8 +748,8 @@ export function standard(opts: StandardOptions = {}): Material {
|
|
|
716
748
|
params,
|
|
717
749
|
textures,
|
|
718
750
|
shadow:
|
|
719
|
-
alphaTest && map
|
|
720
|
-
? litShadowMaterial(standardShadowFlags(flags), uColor, opts.alphaTest
|
|
751
|
+
(alphaTest && map) || flags.surface !== ""
|
|
752
|
+
? litShadowMaterial(standardShadowFlags(flags), uColor, opts.alphaTest, opts.triplanar, opts.map, mapTransform ? params.uMapTransform as number[] : undefined)
|
|
721
753
|
: undefined,
|
|
722
754
|
})
|
|
723
755
|
}
|
|
@@ -733,9 +765,9 @@ let litShadowClasses = new Map<string, ShaderMaterialClass>()
|
|
|
733
765
|
function litShadowMaterial(
|
|
734
766
|
flags: LitClass,
|
|
735
767
|
uColor: number[],
|
|
736
|
-
uAlphaTest: number,
|
|
768
|
+
uAlphaTest: number | undefined,
|
|
737
769
|
triplanar: number | undefined,
|
|
738
|
-
uMap: TextureId,
|
|
770
|
+
uMap: TextureId | undefined,
|
|
739
771
|
uMapTransform?: number[],
|
|
740
772
|
): Material {
|
|
741
773
|
// A skinned cutout keeps its skinning (flags.skinned rides into the
|
|
@@ -755,14 +787,16 @@ function litShadowMaterial(
|
|
|
755
787
|
})
|
|
756
788
|
litShadowClasses.set(key, cls)
|
|
757
789
|
}
|
|
758
|
-
let params: ShaderParams = { uColor
|
|
790
|
+
let params: ShaderParams = { uColor }
|
|
791
|
+
if (flags.alphaTest) params.uAlphaTest = uAlphaTest!
|
|
759
792
|
if (flags.triplanar) params.uTriplanar = triplanar!
|
|
760
793
|
if (uMapTransform !== undefined) params.uMapTransform = uMapTransform
|
|
761
|
-
let material = cls.instance({ params, textures: { uMap } })
|
|
762
|
-
// Only triplanar sampling
|
|
763
|
-
// linker drops vNormal and uNormal reflects
|
|
764
|
-
// move would warn every time. The value
|
|
765
|
-
|
|
794
|
+
let material = cls.instance({ params, textures: flags.map ? { uMap: uMap! } : undefined })
|
|
795
|
+
// Only triplanar sampling and a surface function read the normal here:
|
|
796
|
+
// everywhere else the linker drops vNormal and uNormal reflects
|
|
797
|
+
// inactive, so writing it per move would warn every time. The value
|
|
798
|
+
// cannot matter, skip the write.
|
|
799
|
+
if (!flags.triplanar && flags.surface === "") material.normalMatrix = false
|
|
766
800
|
return material
|
|
767
801
|
}
|
|
768
802
|
|
|
@@ -886,8 +920,31 @@ export type SpriteOptions = Omit<UnlitOptions, "instanced" | "instanceColors" |
|
|
|
886
920
|
* follows the camera, the quad stays upright on world y - trees and
|
|
887
921
|
* standing characters, the classic sprite. */
|
|
888
922
|
billboard?: "full" | "fixed-y"
|
|
923
|
+
/** A procedural falloff over the quad, so a glow, flare or puff needs
|
|
924
|
+
* no texture: `"radial"` multiplies the base (color and alpha, the
|
|
925
|
+
* premultiplied rule) by `(1 - 2 * |uv - 0.5|)^falloff` clamped over
|
|
926
|
+
* the inscribed disc, composing with a `map` (the map's alpha times the
|
|
927
|
+
* disc). Absent, a mapless sprite is a hard square. */
|
|
928
|
+
shape?: "radial"
|
|
929
|
+
/** The radial falloff's exponent (default 1: linear to the rim; 2 a
|
|
930
|
+
* soft puff, 0.5 a hard-edged disc), the per-entry `uFalloff`. Needs
|
|
931
|
+
* `shape`. */
|
|
932
|
+
falloff?: number
|
|
889
933
|
}
|
|
890
934
|
|
|
935
|
+
// The radial shape as a tier-2 surface on the unlit fragment: the
|
|
936
|
+
// vertex stages always write vUv, which the fragment declares only with
|
|
937
|
+
// a map, so the mapless prelude declares it itself.
|
|
938
|
+
const RADIAL_PRELUDE = glsl`
|
|
939
|
+
uniform float uFalloff;
|
|
940
|
+
`
|
|
941
|
+
const RADIAL_SURFACE = glsl`
|
|
942
|
+
void surface(inout Surface s) {
|
|
943
|
+
float disc = clamp(1.0 - 2.0 * length(vUv - 0.5), 0.0, 1.0);
|
|
944
|
+
s.base *= pow(disc, uFalloff);
|
|
945
|
+
}
|
|
946
|
+
`
|
|
947
|
+
|
|
891
948
|
// The billboard vertex stages: the unit quad's corners placed along the
|
|
892
949
|
// camera axes at the mesh's world position, with the quad's size read
|
|
893
950
|
// off uModel's column lengths so `scale` sizes the sprite like any mesh.
|
|
@@ -963,12 +1020,22 @@ export function sprite(opts: SpriteOptions = {}): Material {
|
|
|
963
1020
|
let transparent = opts.transparent !== false
|
|
964
1021
|
let fixedY = opts.billboard === "fixed-y"
|
|
965
1022
|
let fog = fogForm(opts.fog, blend)
|
|
966
|
-
let
|
|
1023
|
+
let radial = opts.shape === "radial"
|
|
1024
|
+
if (opts.shape !== undefined && !radial) throw new Error('sprite: shape must be "radial", got ' + opts.shape)
|
|
1025
|
+
if (opts.falloff !== undefined && !radial) throw new Error("sprite: falloff needs shape")
|
|
1026
|
+
if (opts.falloff !== undefined && !(Number.isFinite(opts.falloff) && opts.falloff > 0)) throw new Error("sprite: falloff must be a positive number, got " + opts.falloff)
|
|
1027
|
+
let key = [map, transparent, blend, fixedY, fog, radial].join("|")
|
|
967
1028
|
let cls = spriteClasses.get(key)
|
|
968
1029
|
if (cls === undefined) {
|
|
969
1030
|
cls = shaderMaterialClass({
|
|
970
1031
|
vertex: fixedY ? SPRITE_FIXED_Y_VERTEX_SRC : SPRITE_VERTEX_SRC,
|
|
971
|
-
fragment: unlitFragment({
|
|
1032
|
+
fragment: unlitFragment({
|
|
1033
|
+
map,
|
|
1034
|
+
transparent,
|
|
1035
|
+
fog,
|
|
1036
|
+
prelude: radial ? (map ? "" : "in vec2 vUv;\n") + RADIAL_PRELUDE : undefined,
|
|
1037
|
+
surface: radial ? RADIAL_SURFACE : undefined,
|
|
1038
|
+
}),
|
|
972
1039
|
transparent,
|
|
973
1040
|
blend,
|
|
974
1041
|
cull: "none",
|
|
@@ -976,7 +1043,9 @@ export function sprite(opts: SpriteOptions = {}): Material {
|
|
|
976
1043
|
})
|
|
977
1044
|
spriteClasses.set(key, cls)
|
|
978
1045
|
}
|
|
979
|
-
|
|
1046
|
+
let params: ShaderParams = { uColor }
|
|
1047
|
+
if (radial) params.uFalloff = opts.falloff ?? 1
|
|
1048
|
+
return cls.instance({ params, textures: map ? { uMap: opts.map! } : undefined })
|
|
980
1049
|
}
|
|
981
1050
|
|
|
982
1051
|
/** The attributes `material` reads that `layout` does not carry (name and
|
|
@@ -1173,6 +1242,11 @@ export type ShaderMaterialClassOptions = {
|
|
|
1173
1242
|
* draw belongs after the opaques so it depth-tests against them, and
|
|
1174
1243
|
* back-to-front is harmless for the order-independent modes. */
|
|
1175
1244
|
transparent?: boolean
|
|
1245
|
+
/** The fragment discards (an alpha test of its own): the scene draws
|
|
1246
|
+
* this material's meshes in the cutout queue, after the solid opaques
|
|
1247
|
+
* (see Material.cutout). Ignored on a transparent material, which sorts
|
|
1248
|
+
* as one. */
|
|
1249
|
+
cutout?: boolean
|
|
1176
1250
|
/** Pipeline state; defaults match unlit: depth: true, cull: "back",
|
|
1177
1251
|
* and for transparent materials blend "alpha", depthWrite: false. */
|
|
1178
1252
|
depth?: boolean
|
|
@@ -1251,6 +1325,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
1251
1325
|
// The LOD cross-fade is a fragment-stage discard (Material.lodFade).
|
|
1252
1326
|
let lodFade = /\buLodFade\b/.test(opts.fragment)
|
|
1253
1327
|
let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
|
|
1328
|
+
let cutout = opts.cutout === true
|
|
1254
1329
|
let depth = opts.depth ?? true
|
|
1255
1330
|
let cull = opts.cull ?? "back"
|
|
1256
1331
|
// An unknown format has no record stride: it would leave here intact,
|
|
@@ -1351,6 +1426,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
1351
1426
|
lodFade,
|
|
1352
1427
|
attributes,
|
|
1353
1428
|
transparent,
|
|
1429
|
+
cutout,
|
|
1354
1430
|
instanceBuffers,
|
|
1355
1431
|
instanceStyle: opts.instanceStyle,
|
|
1356
1432
|
pipeline: pipelineFor,
|
package/src/math.ts
CHANGED
|
@@ -775,6 +775,17 @@ export function cascadeSplit(near: number, far: number, index: number, count: nu
|
|
|
775
775
|
return uniform + (log - uniform) * lambda
|
|
776
776
|
}
|
|
777
777
|
|
|
778
|
+
/**
|
|
779
|
+
* The far bound of cascade `index` of `count`: `splits` given (the
|
|
780
|
+
* light's explicit fractions of near..far, `count - 1` of them) places
|
|
781
|
+
* it at that fraction of the range, else cascadeSplit's `lambda` does.
|
|
782
|
+
* The last slice ends at `far` either way.
|
|
783
|
+
*/
|
|
784
|
+
export function cascadeBoundary(near: number, far: number, index: number, count: number, lambda: number, splits: number[] | null): number {
|
|
785
|
+
if (splits === null || index >= count - 1) return cascadeSplit(near, far, index, count, lambda)
|
|
786
|
+
return near + (far - near) * splits[index]!
|
|
787
|
+
}
|
|
788
|
+
|
|
778
789
|
/** The camera facts a frustum slice depends on: its view matrix (rows are
|
|
779
790
|
* its right, up and back axes), eye, vertical fov in degrees and, for an
|
|
780
791
|
* orthographic camera, the extents (fov ignored then). */
|
package/src/mesh.ts
CHANGED
|
@@ -10,9 +10,9 @@ import type { AttributeAccess, Geometry } from "./geometry.ts"
|
|
|
10
10
|
import { INSTANCE_MATRIX_ATTRIBUTES } from "./glsl.ts"
|
|
11
11
|
import type { GeometryBuffers } from "./geometry-gpu.ts"
|
|
12
12
|
import type { Material } from "./material.ts"
|
|
13
|
-
import type { TransformUpdate
|
|
13
|
+
import type { TransformUpdate } from "./math.ts"
|
|
14
14
|
import * as spatial from "flux:spatial"
|
|
15
|
-
import { activateMorph, afterFree, createWeightsTexture, enterScene, makeNode, rebindMorph, remove, resetMorph, setTransform } from "./node.ts"
|
|
15
|
+
import { activateMorph, afterFree, createWeightsTexture, enterScene, leaveScene, makeNode, rebindMorph, remove, resetMorph, setTransform } from "./node.ts"
|
|
16
16
|
import type { SceneNode } from "./node.ts"
|
|
17
17
|
|
|
18
18
|
export type Mesh = SceneNode & {
|
|
@@ -50,16 +50,9 @@ export type Mesh = SceneNode & {
|
|
|
50
50
|
* Set with setDrawRange; reset by setGeometry. */
|
|
51
51
|
_range: { first: number; count: number } | null
|
|
52
52
|
/** The geometry-buffer reference the entry was built from, acquired at
|
|
53
|
-
* attach and what _detach releases -
|
|
54
|
-
*
|
|
53
|
+
* attach and what _detach releases - a snapshot, because setGeometry
|
|
54
|
+
* swaps mesh.geometry before the rebuild. */
|
|
55
55
|
_buffers: GeometryBuffers | null
|
|
56
|
-
/** material.transparent as of the last attach - the entry's actual
|
|
57
|
-
* pipeline state, and what _detach counts against (setMaterial swaps
|
|
58
|
-
* mesh.material before the rebuild). */
|
|
59
|
-
_transparent: boolean
|
|
60
|
-
/** World-space center of the local bounds, refreshed at sort time: the
|
|
61
|
-
* transparent sort key. */
|
|
62
|
-
_center: Vec3
|
|
63
56
|
_params: ShaderParams | null
|
|
64
57
|
/** Per-mesh sampler bindings merged over the material's at attach (a
|
|
65
58
|
* skin's uBones palette texture): create-time state, set before the
|
|
@@ -118,6 +111,11 @@ export type MeshInstances = {
|
|
|
118
111
|
/** The node-backed population (createInstancedMesh): handles by slot
|
|
119
112
|
* (null = free) and the free list; null on a record mesh. */
|
|
120
113
|
nodes: InstanceSlots | null
|
|
114
|
+
/** The node the instance records are relative to when it is not the
|
|
115
|
+
* mesh itself (InstancedMeshOptions.anchor): an ancestor the mesh sits
|
|
116
|
+
* under at identity, whose subtree the instances may live anywhere in.
|
|
117
|
+
* null on a plain population and on a record mesh. */
|
|
118
|
+
anchor: SceneNode | null
|
|
121
119
|
/** The level meshes of an instanced LOD (createInstancedLod), nearest
|
|
122
120
|
* first - the first is the population itself, the rest its children -
|
|
123
121
|
* sharing this population's instance slots, each with a matrix buffer
|
|
@@ -221,8 +219,6 @@ export function createMesh(geometry: Geometry, material: Material): Mesh {
|
|
|
221
219
|
mesh._entry = null
|
|
222
220
|
mesh._range = null
|
|
223
221
|
mesh._buffers = null
|
|
224
|
-
mesh._transparent = false
|
|
225
|
-
mesh._center = [0, 0, 0]
|
|
226
222
|
mesh._params = null
|
|
227
223
|
mesh._textures = null
|
|
228
224
|
mesh._morphOwner = geometry.morphs === undefined ? null : mesh
|
|
@@ -388,10 +384,13 @@ function markRecords(mesh: Mesh, stream: InstanceStream, lo: number, hi: number)
|
|
|
388
384
|
type PopulationOptions = {
|
|
389
385
|
/** LOCAL bounds covering the population ([minX, minY, minZ, maxX, maxY,
|
|
390
386
|
* maxZ] - geometryBounds' shape), copied in: the mesh node's own box,
|
|
391
|
-
* what the frustum test and the transparent sort use.
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
387
|
+
* what the frustum test and the transparent sort use. On an instanced
|
|
388
|
+
* mesh it is a cull box ONLY - the instances are what picks, never the
|
|
389
|
+
* mesh's box; on a record mesh (records opaque to picking) it is also
|
|
390
|
+
* the box the queries test, twelve triangles. Absent, an instanced
|
|
391
|
+
* mesh culls by the union of its live instances' boxes (the core
|
|
392
|
+
* follows them) and sorts by its node position; a record mesh is never
|
|
393
|
+
* culled and never picked. */
|
|
395
394
|
bounds?: ArrayLike<number>
|
|
396
395
|
/** Debug label for the record buffer. */
|
|
397
396
|
label?: string
|
|
@@ -403,6 +402,20 @@ export type InstancedMeshOptions = PopulationOptions & {
|
|
|
403
402
|
* in one call and republishes at the next flush - amortized like a
|
|
404
403
|
* dynamic array; reserve realistically to skip the copies. */
|
|
405
404
|
capacity?: number
|
|
405
|
+
/** The node the instance records are relative to (default: the mesh
|
|
406
|
+
* itself). Set it to an ANCESTOR the mesh sits under at identity and
|
|
407
|
+
* instances may be placed under any node of that ancestor's subtree,
|
|
408
|
+
* not only under the mesh - a population whose copies ride a hierarchy
|
|
409
|
+
* the mesh is not the root of (createModel's shared parts: the mesh
|
|
410
|
+
* hangs at identity under the model root, the anchor, and each
|
|
411
|
+
* placement node in the model's tree carries an instance). The core
|
|
412
|
+
* writes each record as the instance's world relative to the anchor,
|
|
413
|
+
* so the draw's `uModel * instanceMatrix()` places it correctly only
|
|
414
|
+
* while the mesh's world equals the anchor's: keep the chain between
|
|
415
|
+
* them identity. Checked at scene enter (the anchor must be an
|
|
416
|
+
* ancestor of the mesh, or add throws); the identity is the caller's
|
|
417
|
+
* contract, like an instanced LOD's levels. */
|
|
418
|
+
anchor?: SceneNode
|
|
406
419
|
}
|
|
407
420
|
|
|
408
421
|
/** A record mesh's bounds are also its ONLY picking leaf: records are
|
|
@@ -506,6 +519,7 @@ export function createInstancedMesh(geometry: Geometry, material: Material, opts
|
|
|
506
519
|
count: 0,
|
|
507
520
|
bounds: copyBounds(opts?.bounds, "createInstancedMesh"),
|
|
508
521
|
nodes: { slots: [], free: [] },
|
|
522
|
+
anchor: opts?.anchor ?? null,
|
|
509
523
|
levels: null,
|
|
510
524
|
morph: populationMorph(geometry, capacity, opts?.label),
|
|
511
525
|
}
|
|
@@ -642,7 +656,9 @@ export function publishRecords(mesh: Mesh): void {
|
|
|
642
656
|
* (position/rotation/quaternion/scale; absent keys at their identity)
|
|
643
657
|
* under `parent` - the mesh itself by default, or a node inside the
|
|
644
658
|
* mesh's subtree (a squad group within a fleet: the record stays
|
|
645
|
-
* mesh-relative through it)
|
|
659
|
+
* mesh-relative through it) - inside the ANCHOR's subtree when the mesh
|
|
660
|
+
* was created with one (see InstancedMeshOptions.anchor). Its record
|
|
661
|
+
* slot is fixed for its life (a
|
|
646
662
|
* removed instance's slot recycles to the next add); past the
|
|
647
663
|
* reservation the buffers double. From here on it is a node like any
|
|
648
664
|
* other: setTransform/setTransition/setVisible, lookAt, worldPosition,
|
|
@@ -654,8 +670,11 @@ export function publishRecords(mesh: Mesh): void {
|
|
|
654
670
|
export function addInstance(mesh: InstancedMesh, update?: TransformUpdate, parent: SceneNode = mesh): InstanceNode {
|
|
655
671
|
let inst: (MeshInstances & { nodes: InstanceSlots }) | null = mesh._instances
|
|
656
672
|
if (inst === null) throw new Error("addInstance: the mesh's instances are disposed")
|
|
657
|
-
|
|
658
|
-
|
|
673
|
+
let root: SceneNode = inst.anchor ?? mesh
|
|
674
|
+
for (let p: SceneNode | null = parent; p !== root; p = p.parent) {
|
|
675
|
+
if (p === null) {
|
|
676
|
+
throw new Error(inst.anchor === null ? "addInstance: parent must be the mesh or a node inside its subtree" : "addInstance: parent must be the mesh's anchor or a node inside its subtree")
|
|
677
|
+
}
|
|
659
678
|
}
|
|
660
679
|
let slot = inst.nodes.free.pop() ?? inst.nodes.slots.length
|
|
661
680
|
// The levels of an instanced LOD grow, count and blank together: one
|
|
@@ -685,6 +704,33 @@ export function addInstance(mesh: InstancedMesh, update?: TransformUpdate, paren
|
|
|
685
704
|
return instance
|
|
686
705
|
}
|
|
687
706
|
|
|
707
|
+
/**
|
|
708
|
+
* @internal Move a live instance under another node of its population's
|
|
709
|
+
* frame (the mesh, or the anchor's subtree): its slot and record are
|
|
710
|
+
* kept, its local pose is now read against the new parent, and in a
|
|
711
|
+
* scene it re-enters there so the core rebinds the record against the
|
|
712
|
+
* current anchor. The generic add/remove refuse instances (slot-bound);
|
|
713
|
+
* this is the one reparent, for bindSkeleton's grafts.
|
|
714
|
+
*/
|
|
715
|
+
export function reparentInstance(instance: InstanceNode, parent: SceneNode): void {
|
|
716
|
+
let mesh = instance.mesh
|
|
717
|
+
if (mesh === null || instance._destroyed) throw new Error("reparentInstance: the instance is gone")
|
|
718
|
+
if (parent._destroyed) throw new Error("reparentInstance: the parent was destroyed")
|
|
719
|
+
let root: SceneNode = mesh._instances?.anchor ?? mesh
|
|
720
|
+
for (let p: SceneNode | null = parent; p !== root; p = p.parent) {
|
|
721
|
+
if (p === null) throw new Error("reparentInstance: parent must be inside the population's anchor subtree")
|
|
722
|
+
}
|
|
723
|
+
if (instance._scene) leaveScene(instance)
|
|
724
|
+
let old = instance.parent
|
|
725
|
+
if (old !== null) {
|
|
726
|
+
let at = old.children.indexOf(instance)
|
|
727
|
+
if (at >= 0) old.children.splice(at, 1)
|
|
728
|
+
}
|
|
729
|
+
instance.parent = parent
|
|
730
|
+
parent.children.push(instance)
|
|
731
|
+
if (parent._scene) enterScene(instance, parent._scene)
|
|
732
|
+
}
|
|
733
|
+
|
|
688
734
|
// Grow the population's buffers to `next` records: replacements (never a
|
|
689
735
|
// resize). An instanced mesh's live matrix records move to the new buffer
|
|
690
736
|
// in one core call and republish at the next flush; every stream's mirror
|
|
@@ -749,6 +795,7 @@ export function createRecordMesh(
|
|
|
749
795
|
count: Math.max(0, Math.min(Math.floor(count ?? capacity), capacity)),
|
|
750
796
|
bounds: copyBounds(opts?.bounds, "createRecordMesh"),
|
|
751
797
|
nodes: null,
|
|
798
|
+
anchor: null,
|
|
752
799
|
levels: null,
|
|
753
800
|
morph: null,
|
|
754
801
|
}
|
|
@@ -842,7 +889,7 @@ export function disposeInstances(mesh: InstancedMesh | RecordMesh): void {
|
|
|
842
889
|
export function setRenderOrder(mesh: Mesh, order: number): void {
|
|
843
890
|
if (mesh.renderOrder === order) return
|
|
844
891
|
mesh.renderOrder = order
|
|
845
|
-
mesh._scene?._reorder()
|
|
892
|
+
mesh._scene?._reorder(mesh)
|
|
846
893
|
}
|
|
847
894
|
|
|
848
895
|
/**
|