@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/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
- export let orbitActions = { rotate: "vec2", zoom: "axis", pan: "vec2" } as const
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 rotates, the triggers
38
- * zoom, the left stick pans; the arrow keys rotate and minus/equals zoom.
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, gamepad, keyboard } = devices
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
- if (gamepad) {
54
- out.push({ action: "rotate", source: invert(gamepad.rightStick) }, { action: "zoom", source: gamepad.triggers }, { action: "pan", source: invert(gamepad.leftStick) })
55
- }
56
- if (keyboard) {
57
- out.push({ action: "rotate", source: invert(keyboard.arrows) }, { action: "zoom", source: keyboard.axis("Minus", "Equal") })
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
- export let firstPersonActions = { look: "vec2", move: "vec2", rise: "axis" } as const
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; WASD and the arrows
70
- * walk, Q/E rise and sink.
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) out.push({ action: "look", source: gamepad.rightStick }, { action: "move", source: gamepad.leftStick }, { action: "rise", source: gamepad.shoulders })
77
- if (keyboard) out.push({ action: "move", source: keyboard.wasd }, { action: "move", source: keyboard.arrows }, { action: "rise", source: keyboard.axis("KeyQ", "KeyE") })
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
- * the scene draws this material's meshes after every opaque one, sorted
91
- * back-to-front by mesh origin, and re-sorts them when the camera moves. */
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
- * depth-written, not sorted, unlike `transparent`. Foliage cards and
182
- * fences want it with `cull: "none"`; a mapped cutout casts its cutout
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). A color-only
546
- // alphaTest is a constant over the mesh - all or nothing, and nothing
547
- // needs no program - so it keeps the plain cull-only variant.
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!, opts.triplanar, opts.map!, mapTransform ? params.uMapTransform as number[] : undefined)
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!, opts.triplanar, opts.map!, mapTransform ? params.uMapTransform as number[] : undefined)
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, uAlphaTest }
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 reads the normal here: everywhere else the
763
- // linker drops vNormal and uNormal reflects inactive, so writing it per
764
- // move would warn every time. The value cannot matter, skip the write.
765
- if (!flags.triplanar) material.normalMatrix = false
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 key = [map, transparent, blend, fixedY, fog].join("|")
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({ map, transparent, fog }),
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
- return cls.instance({ params: { uColor }, textures: map ? { uMap: opts.map! } : undefined })
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, Vec3 } from "./math.ts"
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 - like _transparent, a snapshot,
54
- * because setGeometry swaps mesh.geometry before the rebuild. */
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. Absent, an
392
- * instanced mesh culls by the union of its live instances' boxes (the
393
- * core follows them) and sorts by its node position; a record mesh is
394
- * never culled. */
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). Its record slot is fixed for its life (a
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
- for (let p: SceneNode | null = parent; p !== mesh; p = p.parent) {
658
- if (p === null) throw new Error("addInstance: parent must be the mesh or a node inside its subtree")
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
  /**