@opsydyn/effect-three 0.1.0 → 0.3.0

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +3 -4
  3. package/dist/{index-ef8pbv9g.js → index-8cf8t6by.js} +328 -25
  4. package/dist/index-8cf8t6by.js.map +41 -0
  5. package/dist/index.js +61 -8
  6. package/dist/index.js.map +3 -3
  7. package/dist/kernel/behaviours.d.ts.map +1 -1
  8. package/dist/kernel/build.d.ts +56 -1
  9. package/dist/kernel/build.d.ts.map +1 -1
  10. package/dist/kernel/cameraMotion.d.ts +3 -1
  11. package/dist/kernel/cameraMotion.d.ts.map +1 -1
  12. package/dist/kernel/cameraShake.d.ts +33 -0
  13. package/dist/kernel/cameraShake.d.ts.map +1 -0
  14. package/dist/kernel/description.d.ts +85 -1
  15. package/dist/kernel/description.d.ts.map +1 -1
  16. package/dist/kernel/diff.d.ts +3 -1
  17. package/dist/kernel/diff.d.ts.map +1 -1
  18. package/dist/kernel/equality.d.ts +5 -1
  19. package/dist/kernel/equality.d.ts.map +1 -1
  20. package/dist/kernel/index.d.ts +1 -1
  21. package/dist/kernel/index.d.ts.map +1 -1
  22. package/dist/kernel/index.js +12 -2
  23. package/dist/kernel/index.js.map +1 -1
  24. package/dist/kernel/labels.d.ts +6 -0
  25. package/dist/kernel/labels.d.ts.map +1 -1
  26. package/dist/kernel/pointer.d.ts.map +1 -1
  27. package/dist/kernel/renderKit.d.ts +5 -4
  28. package/dist/kernel/renderKit.d.ts.map +1 -1
  29. package/dist/kernel/resources.d.ts.map +1 -1
  30. package/dist/kernel/sceneGraph.d.ts +0 -1
  31. package/dist/kernel/sceneGraph.d.ts.map +1 -1
  32. package/dist/runtime/SceneSchema.d.ts +296 -5
  33. package/dist/runtime/SceneSchema.d.ts.map +1 -1
  34. package/dist/webgl/index.js +2 -2
  35. package/dist/webgl/index.js.map +3 -3
  36. package/dist/webgpu/index.js +2 -2
  37. package/dist/webgpu/index.js.map +3 -3
  38. package/package.json +1 -1
  39. package/src/kernel/behaviours.ts +5 -0
  40. package/src/kernel/bodySources.ts +1 -1
  41. package/src/kernel/build.ts +98 -0
  42. package/src/kernel/cameraMotion.ts +3 -0
  43. package/src/kernel/cameraShake.ts +97 -0
  44. package/src/kernel/description.ts +91 -1
  45. package/src/kernel/diff.ts +7 -1
  46. package/src/kernel/equality.ts +47 -0
  47. package/src/kernel/index.ts +5 -0
  48. package/src/kernel/labels.ts +6 -0
  49. package/src/kernel/pointer.ts +8 -0
  50. package/src/kernel/renderKit.ts +5 -4
  51. package/src/kernel/resources.ts +69 -0
  52. package/src/kernel/sceneGraph.ts +146 -3
  53. package/src/runtime/SceneSchema.ts +58 -4
  54. package/src/webgl/index.ts +1 -1
  55. package/src/webgpu/index.ts +2 -2
  56. package/dist/index-ef8pbv9g.js.map +0 -40
@@ -359,6 +359,12 @@ const pathOf = (graph: SceneGraph, object: Object3D): ReadonlyArray<string> => {
359
359
  return path;
360
360
  };
361
361
 
362
+ /** Whether an object and every ancestor are visible. */
363
+ const shown = (object: Object3D): boolean => {
364
+ for (let at: Object3D | null = object; at !== null; at = at.parent) if (!at.visible) return false;
365
+ return true;
366
+ };
367
+
362
368
  /** Creates a raycasting picker over a graph's interactive meshes and groups. */
363
369
  export const createPicker = (graph: SceneGraph): Picker => {
364
370
  const raycaster = new Raycaster();
@@ -403,6 +409,8 @@ export const createPicker = (graph: SceneGraph): Picker => {
403
409
  const nearestHit = (view: string | undefined): Hit | undefined => {
404
410
  // Recursive, so an interactive group makes every mesh inside it pickable.
405
411
  for (const intersection of raycaster.intersectObjects(graph.interactiveObjects(), true)) {
412
+ // Hidden things are not hit: a level of detail not showing, as three's `LOD.raycast`.
413
+ if (!shown(intersection.object)) continue;
406
414
  const owner = keyedOwner(graph, intersection.object);
407
415
  const key = owner === undefined ? undefined : graph.keyOf(owner);
408
416
  if (owner !== undefined && key !== undefined) {
@@ -42,11 +42,12 @@ export type ShadowedScene = {
42
42
  /** A renderer's shaded parts. */
43
43
  export type RenderKit = {
44
44
  /**
45
- * Whether the renderer can draw a label engine's labels. troika's are WebGL materials:
46
- * the WebGPU renderer cannot (on real WebGPU they break the frame), so its kit says no
47
- * and labels stay empty.
45
+ * The kind of material the renderer draws: `'webgl'`, WebGL's own materials, for
46
+ * `WebGLRenderer`; `'tsl'`, three's node materials, for `WebGPURenderer`. A label engine
47
+ * draws with its implementation for it (`LabelEngine.create` or `createTsl`); WebGL
48
+ * materials break a WebGPU frame, so without a TSL implementation labels stay empty.
48
49
  */
49
- readonly drawsLabels: boolean;
50
+ readonly shading: 'webgl' | 'tsl';
50
51
  readonly sky: () => HostedSky;
51
52
  readonly shaderGrid: (node: ShaderGridNode) => HostedShaderGrid;
52
53
  readonly contactShadows: (graph: ShadowedScene, renderer: SceneRenderer) => ContactShadowPasses;
@@ -22,17 +22,21 @@ import {
22
22
  CircleGeometry,
23
23
  ConeGeometry,
24
24
  CylinderGeometry,
25
+ Euler,
25
26
  ExtrudeGeometry,
26
27
  Material,
27
28
  type Object3D,
28
29
  type Texture,
29
30
  PlaneGeometry,
31
+ Quaternion,
30
32
  RingGeometry,
33
+ Shape,
31
34
  ShapeGeometry as ShapeGeometry3,
32
35
  SphereGeometry,
33
36
  TorusGeometry,
34
37
  } from 'three';
35
38
  import type { Font } from 'three/addons/loaders/FontLoader.js';
39
+ import { toCreasedNormals } from 'three/addons/utils/BufferGeometryUtils.js';
36
40
 
37
41
  import type {
38
42
  GeometryRef,
@@ -191,6 +195,67 @@ const buildText = (shape: TextGeometry, font: Font | undefined): BufferGeometry
191
195
  return geometry;
192
196
  };
193
197
 
198
+ /** Drei's `RoundedBox` nudge, keeping its corner arcs from being zero length. */
199
+ const roundedEpsilon = 0.00001;
200
+
201
+ /**
202
+ * Drei's `RoundedBox` (`bf6f4ad`): the corners' arcs drawn tiny and the bevel making the
203
+ * rounding, extruded through the depth less the bevels, centred, and its normals creased
204
+ * (three's `toCreasedNormals`, which Drei takes from three-stdlib).
205
+ */
206
+ const buildRoundedBox = (shape: Extract<ShapeGeometry, { readonly _tag: 'RoundedBox' }>) => {
207
+ const { width, height, depth, radius } = shape;
208
+ const outline = new Shape();
209
+ const inset = radius - roundedEpsilon;
210
+ const e = roundedEpsilon;
211
+ outline.absarc(e, e, e, -Math.PI / 2, -Math.PI, true);
212
+ outline.absarc(e, height - inset * 2, e, Math.PI, Math.PI / 2, true);
213
+ outline.absarc(width - inset * 2, height - inset * 2, e, Math.PI / 2, 0, true);
214
+ outline.absarc(width - inset * 2, e, e, 0, -Math.PI / 2, true);
215
+ const geometry = new ExtrudeGeometry(outline, {
216
+ depth: depth - radius * 2,
217
+ bevelEnabled: true,
218
+ bevelSegments: shape.bevelSegments * 2,
219
+ steps: shape.steps,
220
+ bevelSize: radius - roundedEpsilon,
221
+ bevelThickness: radius,
222
+ curveSegments: shape.smoothness,
223
+ });
224
+ geometry.center();
225
+ return toCreasedNormals(geometry, shape.creaseAngle);
226
+ };
227
+
228
+ /** Drei's `Backdrop` mesh turn, built into the geometry: the floor flat, the wall behind. */
229
+ const backdropTurn = new Quaternion().setFromEuler(new Euler(-Math.PI / 2, 0, Math.PI / 2));
230
+
231
+ /** Drei's `Backdrop` easing: the floor stays flat, then sweeps up. */
232
+ const easeInExpo = (x: number): number => (x === 0 ? 0 : 2 ** (10 * x - 10));
233
+
234
+ /**
235
+ * Drei's `Backdrop` (`bf6f4ad`): a unit plane whose rows rise by an exponential ease, its
236
+ * first row pulled forward by `floor`, then turned as Drei turns its mesh.
237
+ */
238
+ const buildBackdrop = (shape: Extract<ShapeGeometry, { readonly _tag: 'Backdrop' }>) => {
239
+ const { segments, floor } = shape;
240
+ const geometry = new PlaneGeometry(1, 1, segments, segments);
241
+ const position = geometry.getAttribute('position');
242
+ const offset = 0.5;
243
+ let vertex = 0;
244
+ for (let x = 0; x < segments + 1; x++) {
245
+ for (let y = 0; y < segments + 1; y++) {
246
+ position.setXYZ(
247
+ vertex++,
248
+ x / segments - offset + (x === 0 ? -floor : 0),
249
+ y / segments - offset,
250
+ easeInExpo(x / segments),
251
+ );
252
+ }
253
+ }
254
+ position.needsUpdate = true;
255
+ geometry.computeVertexNormals();
256
+ return geometry.applyQuaternion(backdropTurn);
257
+ };
258
+
194
259
  /** Builds the three.js geometry for a shape description. */
195
260
  const buildShape = (
196
261
  shape: ShapeGeometry,
@@ -232,6 +297,10 @@ const buildShape = (
232
297
  return new RingGeometry(shape.innerRadius, shape.outerRadius, shape.thetaSegments);
233
298
  case 'Circle':
234
299
  return new CircleGeometry(shape.radius, shape.segments);
300
+ case 'RoundedBox':
301
+ return buildRoundedBox(shape);
302
+ case 'Backdrop':
303
+ return buildBackdrop(shape);
235
304
  }
236
305
  };
237
306
 
@@ -21,10 +21,12 @@ import {
21
21
  OrthographicCamera,
22
22
  PerspectiveCamera,
23
23
  PointLight,
24
+ Quaternion,
24
25
  Scene,
25
26
  SpotLight,
26
27
  Vector3,
27
28
  } from 'three';
29
+ import { clone as cloneSkinned } from 'three/addons/utils/SkeletonUtils.js';
28
30
 
29
31
  import { clipRunning, clipTime } from './animation.ts';
30
32
  import { writeRotation } from './behaviours.ts';
@@ -32,6 +34,7 @@ import { type BodyCollision, type BodyContactForce, createBodyHost } from './bod
32
34
  import { type BodySource, instanceSources, nodeSource, placeObject } from './bodySources.ts';
33
35
  import { renderSettings, scene as describeScene } from './build.ts';
34
36
  import { type CameraMotion, createCameraMotion } from './cameraMotion.ts';
37
+ import { type CameraShaker, createCameraShaker } from './cameraShake.ts';
35
38
  import type {
36
39
  AmbientLightNode,
37
40
  BorrowedNode,
@@ -426,6 +429,14 @@ const noInstances: ReadonlyArray<Instance> = [];
426
429
  const emptyScene = describeScene({});
427
430
 
428
431
  /** Creates an empty scene graph. */
432
+ /**
433
+ * A copy of a lent object for a second place to show it, sharing its geometry and
434
+ * materials, as Drei's `Clone` (`bf6f4ad`, `src/core/Clone.tsx`): a skinned model through
435
+ * three's `SkeletonUtils.clone`, so that the copy has bones of its own to animate.
436
+ */
437
+ const cloneLoan = (lent: Object3D): Object3D =>
438
+ lent.getObjectByProperty('isSkinnedMesh', true) === undefined ? lent.clone() : cloneSkinned(lent);
439
+
429
440
  export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph => {
430
441
  const store = options.store ?? createResourceStore();
431
442
  const ownsStore = options.store === undefined;
@@ -470,8 +481,22 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
470
481
  hostedSky = undefined;
471
482
  }
472
483
  const cameraMotion = createCameraMotion();
484
+ const cameraShaker = createCameraShaker();
473
485
  /** Each view's camera motion, by the view's key. */
474
486
  const viewMotions = new Map<string, CameraMotion>();
487
+ /** Each view's camera shake, by the view's key. */
488
+ const viewShakers = new Map<string, CameraShaker>();
489
+ const shaking = (): boolean => {
490
+ if (cameraShaker.shaking(elapsed)) return true;
491
+ for (const shaker of viewShakers.values()) if (shaker.shaking(elapsed)) return true;
492
+ return false;
493
+ };
494
+ /** Shakes a view's camera on top of where its motion aims it. */
495
+ const shakeView = (view: HostedView): void => {
496
+ const motion = viewMotions.get(view.key);
497
+ if (motion === undefined) return;
498
+ viewShakers.get(view.key)?.apply(view.camera, motion.target(), elapsed, animating);
499
+ };
475
500
  const viewsMoving = (): boolean => {
476
501
  for (const motion of viewMotions.values()) if (motion.moving()) return true;
477
502
  return false;
@@ -505,6 +530,9 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
505
530
  applyLens(hostedCamera, description.camera);
506
531
  const motion = viewMotions.get(description.key) ?? createCameraMotion();
507
532
  viewMotions.set(description.key, motion);
533
+ const shaker = viewShakers.get(description.key) ?? createCameraShaker();
534
+ viewShakers.set(description.key, shaker);
535
+ shaker.describe(description.camera.shake, elapsed);
508
536
  // As the scene's camera: a new kind of camera has nothing on screen to travel from.
509
537
  motion.aim(hostedCamera, description.camera, elapsed, hostedCamera !== previous || !animating);
510
538
  const area = areaOf(description.rect);
@@ -520,10 +548,21 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
520
548
  for (const key of viewMotions.keys()) {
521
549
  if (!described.some((description) => description.key === key)) viewMotions.delete(key);
522
550
  }
551
+ for (const key of viewShakers.keys()) {
552
+ if (!described.some((description) => description.key === key)) viewShakers.delete(key);
553
+ }
523
554
  };
524
555
  const entries = new Map<string, Entry>();
525
556
  const keys = new WeakMap<Object3D, string>();
526
557
  const spinning = new Set<Entry>();
558
+ // Billboards: turned towards the camera each frame, after everything else has moved.
559
+ const facing = new Set<Entry>();
560
+ const cameraTurn = new Quaternion();
561
+ const parentTurn = new Quaternion();
562
+ // Groups with levels of detail: one child shown at a time, by the camera's distance.
563
+ const detailing = new Set<Entry>();
564
+ const cameraPlace = new Vector3();
565
+ const groupPlace = new Vector3();
527
566
  const playing = new Set<Borrowing>();
528
567
  const registry: Readonly<Record<string, Behaviour>> = {
529
568
  ...builtinBehaviours,
@@ -580,6 +619,77 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
580
619
  writeRotation(entry.object.rotation, node.rotation, elapsed);
581
620
  if (node.rotation._tag === 'Spin') spinning.add(entry);
582
621
  else spinning.delete(entry);
622
+ if (node.rotation._tag === 'Billboard') facing.add(entry);
623
+ else facing.delete(entry);
624
+ };
625
+
626
+ /**
627
+ * Drei's `Billboard`: the node's rotation is its parent's world rotation undone, then the
628
+ * camera's, so its world rotation is the camera's; locked axes go back to 0.
629
+ */
630
+ const faceCamera = (entry: Entry): void => {
631
+ if (!isPlaced(entry) || entry.node.rotation._tag !== 'Billboard') return;
632
+ const { lockX, lockY, lockZ } = entry.node.rotation;
633
+ const object = entry.object;
634
+ parentTurn.identity();
635
+ object.parent?.getWorldQuaternion(parentTurn);
636
+ object.quaternion.copy(cameraTurn).premultiply(parentTurn.invert());
637
+ if (lockX) object.rotation.x = 0;
638
+ if (lockY) object.rotation.y = 0;
639
+ if (lockZ) object.rotation.z = 0;
640
+ };
641
+
642
+ /** The object of a group's `index`th child, as described. */
643
+ const childObject = (node: GroupNode, index: number): Object3D | undefined => {
644
+ const child = node.children[index];
645
+ return child === undefined ? undefined : entries.get(child.key)?.object;
646
+ };
647
+
648
+ /**
649
+ * three's `LOD.update`, as Drei's `Detailed` runs it each frame, over the group's children
650
+ * in order: the first level shows, each later one takes over once the camera is at least
651
+ * its distance away (less its hysteresis while it shows), and the rest hide.
652
+ */
653
+ const showLevel = (entry: Entry): void => {
654
+ if (entry._tag !== 'Group') return;
655
+ const { node } = entry;
656
+ const { detail } = node;
657
+ if (detail === null) return;
658
+ const { distances, hysteresis } = detail;
659
+ const count = node.children.length;
660
+ if (count < 2) return;
661
+ entry.object.getWorldPosition(groupPlace);
662
+ const distance = cameraPlace.distanceTo(groupPlace) / camera.zoom;
663
+ const first = childObject(node, 0);
664
+ if (first !== undefined) first.visible = true;
665
+ let level = 1;
666
+ for (; level < count; level++) {
667
+ const object = childObject(node, level);
668
+ const from = distances[level] ?? Number.NaN;
669
+ const reach = object?.visible === true ? from - from * hysteresis : from;
670
+ if (!(distance >= reach)) break;
671
+ const previous = childObject(node, level - 1);
672
+ if (previous !== undefined) previous.visible = false;
673
+ if (object !== undefined) object.visible = true;
674
+ }
675
+ for (; level < count; level++) {
676
+ const object = childObject(node, level);
677
+ if (object !== undefined) object.visible = false;
678
+ }
679
+ };
680
+
681
+ const trackDetail = (entry: Entry, node: GroupNode): void => {
682
+ if (node.detail !== null) {
683
+ detailing.add(entry);
684
+ return;
685
+ }
686
+ // No longer levels: every child shows again.
687
+ if (detailing.delete(entry)) {
688
+ for (let index = 0; index < node.children.length; index++) {
689
+ const object = childObject(node, index);
690
+ if (object !== undefined) object.visible = true;
691
+ }
692
+ }
583
693
  };
584
694
 
585
695
  const build = (node: SceneNode): Entry => {
@@ -593,6 +703,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
593
703
  trackInteractive(entry.object, node.interactive);
594
704
  // A group following a tracked space shows only once the space is found.
595
705
  if (node.follow !== null) entry.object.visible = false;
706
+ trackDetail(entry, node);
596
707
  return entry;
597
708
  }
598
709
  case 'Mesh': {
@@ -644,9 +755,11 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
644
755
  return entry;
645
756
  }
646
757
  case 'Label': {
647
- // A renderer that cannot draw the engine's labels (WebGPU and troika) gets none.
758
+ // The engine's implementation for the renderer's kind of material, if it has one.
648
759
  const hosted =
649
- options.kit?.drawsLabels === false ? undefined : options.labels?.create(node, invalidate);
760
+ options.kit?.shading === 'tsl'
761
+ ? options.labels?.createTsl?.(node, invalidate)
762
+ : options.labels?.create(node, invalidate);
650
763
  const material = node.material === null ? undefined : store.acquireMaterial(node.material);
651
764
  hosted?.setMaterial(material);
652
765
  const entry: Entry = { _tag: 'Label', node, object: new Group(), hosted, material };
@@ -704,12 +817,22 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
704
817
  entry.shown = undefined;
705
818
  const lent = store.lent(entry.node.id);
706
819
  if (lent === undefined) return;
707
- const shown = lent.parent === null ? lent : lent.clone();
820
+ const shown = lent.parent === null ? lent : cloneLoan(lent);
708
821
  entry.object.add(shown);
709
822
  entry.shown = shown;
823
+ castShadows(shown, entry.node);
710
824
  startPlayer(entry);
711
825
  };
712
826
 
827
+ /** Drei's `Clone` shadow shorthands: each mesh casts or receives where asked. */
828
+ const castShadows = (shown: Object3D, node: BorrowedNode): void => {
829
+ if (!node.castShadow && !node.receiveShadow) return;
830
+ shown.traverse((part) => {
831
+ if (part instanceof Mesh && node.castShadow) part.castShadow = true;
832
+ if (part instanceof Mesh && node.receiveShadow) part.receiveShadow = true;
833
+ });
834
+ };
835
+
713
836
  const stopPlayer = (entry: Borrowing): void => {
714
837
  if (entry.player === undefined) return;
715
838
  entry.player.mixer.stopAllAction();
@@ -848,6 +971,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
848
971
  const release = (entry: Entry): void => {
849
972
  if (isPlaced(entry)) behaving.delete(entry);
850
973
  spinning.delete(entry);
974
+ facing.delete(entry);
975
+ detailing.delete(entry);
851
976
  moving.delete(entry);
852
977
  reparented.delete(entry);
853
978
  trackInteractive(entry.object, false);
@@ -1003,6 +1128,9 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1003
1128
  if (fields.includes('interactive')) trackInteractive(entry.object, next.interactive);
1004
1129
  if (fields.includes('id')) showLoan(entry);
1005
1130
  else if (fields.includes('animation')) startPlayer(entry);
1131
+ if (fields.includes('castShadow') || fields.includes('receiveShadow')) {
1132
+ if (entry.shown !== undefined) castShadows(entry.shown, next);
1133
+ }
1006
1134
  return;
1007
1135
  }
1008
1136
  if (entry._tag === 'Label' && next._tag === 'Label') {
@@ -1021,6 +1149,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1021
1149
  if (fields.includes('rotation')) trackRotation(entry, next);
1022
1150
  if (fields.includes('behaviour')) trackBehaviour(entry, next);
1023
1151
  if (fields.includes('interactive')) trackInteractive(entry.object, next.interactive);
1152
+ if (fields.includes('detail')) trackDetail(entry, next);
1024
1153
  if (fields.includes('follow')) {
1025
1154
  // Back to its own transform, or hidden until the new space is found.
1026
1155
  entry.object.visible = next.follow === null;
@@ -1096,6 +1225,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1096
1225
  applyLens(camera, op.camera);
1097
1226
  // A new kind of camera has nothing on screen to travel from.
1098
1227
  cameraMotion.aim(camera, op.camera, elapsed, camera !== previous || !animating);
1228
+ cameraShaker.describe(op.camera.shake, elapsed);
1099
1229
  fitCamera(camera, viewport);
1100
1230
  return;
1101
1231
  }
@@ -1315,8 +1445,20 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1315
1445
  behaving.forEach(applyBehaviour);
1316
1446
  cameraMotion.step(camera, elapsed);
1317
1447
  for (const view of views) viewMotions.get(view.key)?.step(view.camera, elapsed);
1448
+ // Shake on top of wherever the cameras are aimed now.
1449
+ cameraShaker.apply(camera, cameraMotion.target(), elapsed, animating);
1450
+ views.forEach(shakeView);
1318
1451
  // Last: a dynamic body's pose is the simulation's, whatever else moved the mesh.
1319
1452
  bodyHost.tick(elapsed, animating);
1453
+ // After the camera and everything a billboard may sit on have moved.
1454
+ if (facing.size > 0) {
1455
+ camera.getWorldQuaternion(cameraTurn);
1456
+ facing.forEach(faceCamera);
1457
+ }
1458
+ if (detailing.size > 0) {
1459
+ camera.getWorldPosition(cameraPlace);
1460
+ detailing.forEach(showLevel);
1461
+ }
1320
1462
  };
1321
1463
 
1322
1464
  const finishTween = (tween: Tween, entry: Entry): void => {
@@ -1406,6 +1548,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1406
1548
  clipsRunning() ||
1407
1549
  cameraMotion.moving() ||
1408
1550
  viewsMoving() ||
1551
+ shaking() ||
1409
1552
  bodyHost.needsFrame())),
1410
1553
  drawn: () => {
1411
1554
  dirty = false;
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @module
9
9
  */
10
- import { Schema } from 'effect';
10
+ import { Effect, Schema } from 'effect';
11
11
 
12
12
  import { handJoints, type SceneNode as KernelSceneNode } from '../kernel/description.ts';
13
13
 
@@ -21,10 +21,18 @@ export const Vec3 = Schema.Tuple([Schema.Finite, Schema.Finite, Schema.Finite]);
21
21
  /** A rotation axis. */
22
22
  export const Axis = Schema.Literals(['x', 'y', 'z']);
23
23
 
24
+ /**
25
+ * Fields added after 0.2.0 decode as their builder's default when absent, so descriptions
26
+ * stored before them (a camera kept in a Model, say) still decode.
27
+ */
28
+ const absentAsNull = <S extends Schema.Top>(schema: S) =>
29
+ Schema.NullOr(schema).pipe(Schema.withDecodingDefaultKey(Effect.succeed(null)));
30
+ const absentAsFalse = Schema.Boolean.pipe(Schema.withDecodingDefaultKey(Effect.succeed(false)));
31
+
24
32
  /** A 24-bit RGB colour, for example `0x3366ff`. */
25
33
  export const Rgb = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 0xffffff }));
26
34
 
27
- /** Model-owned Euler angles, or a spin behaviour that owns the rotation. */
35
+ /** Model-owned Euler angles, a spin behaviour, or facing the camera (a billboard). */
28
36
  export const Rotation = Schema.Union([
29
37
  Schema.TaggedStruct('Fixed', { euler: Vec3 }),
30
38
  Schema.TaggedStruct('Spin', {
@@ -32,6 +40,11 @@ export const Rotation = Schema.Union([
32
40
  radiansPerSecond: Schema.Finite,
33
41
  phase: Schema.Finite,
34
42
  }),
43
+ Schema.TaggedStruct('Billboard', {
44
+ lockX: Schema.Boolean,
45
+ lockY: Schema.Boolean,
46
+ lockZ: Schema.Boolean,
47
+ }),
35
48
  ]);
36
49
 
37
50
  /** Box geometry parameters. */
@@ -84,6 +97,24 @@ export const CapsuleGeometry = Schema.TaggedStruct('Capsule', {
84
97
  radialSegments: Segments,
85
98
  });
86
99
 
100
+ /** Rounded box geometry parameters (Drei's `RoundedBox`). */
101
+ export const RoundedBoxGeometry = Schema.TaggedStruct('RoundedBox', {
102
+ width: Positive,
103
+ height: Positive,
104
+ depth: Positive,
105
+ radius: NonNegative,
106
+ smoothness: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
107
+ bevelSegments: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
108
+ steps: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
109
+ creaseAngle: NonNegative,
110
+ });
111
+
112
+ /** Backdrop geometry parameters (Drei's `Backdrop`). */
113
+ export const BackdropGeometry = Schema.TaggedStruct('Backdrop', {
114
+ floor: NonNegative,
115
+ segments: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
116
+ });
117
+
87
118
  /** Ring geometry parameters. */
88
119
  export const RingGeometry = Schema.TaggedStruct('Ring', {
89
120
  innerRadius: NonNegative,
@@ -132,6 +163,8 @@ export const ShapeGeometry = Schema.Union([
132
163
  CapsuleGeometry,
133
164
  RingGeometry,
134
165
  CircleGeometry,
166
+ RoundedBoxGeometry,
167
+ BackdropGeometry,
135
168
  ]);
136
169
 
137
170
  /** A registered behaviour, by name, with numeric parameters. */
@@ -424,6 +457,8 @@ export const BorrowedNode = Schema.TaggedStruct('Borrowed', {
424
457
  behaviour: Schema.NullOr(Behave),
425
458
  interactive: Schema.Boolean,
426
459
  body: Schema.NullOr(Body),
460
+ castShadow: absentAsFalse,
461
+ receiveShadow: absentAsFalse,
427
462
  });
428
463
 
429
464
  /** Distance-field text. */
@@ -559,7 +594,7 @@ export const HemisphereLightNode = Schema.TaggedStruct('HemisphereLight', {
559
594
  });
560
595
 
561
596
  /** Any scene node. Groups nest recursively. */
562
- export const SceneNode: Schema.Codec<KernelSceneNode> = Schema.Union([
597
+ export const SceneNode: Schema.Codec<KernelSceneNode, unknown> = Schema.Union([
563
598
  Schema.TaggedStruct('Group', {
564
599
  key: Schema.String,
565
600
  position: Vec3,
@@ -568,9 +603,12 @@ export const SceneNode: Schema.Codec<KernelSceneNode> = Schema.Union([
568
603
  transition: Transition,
569
604
  behaviour: Schema.NullOr(Behave),
570
605
  interactive: Schema.Boolean,
571
- children: Schema.Array(Schema.suspend((): Schema.Codec<KernelSceneNode> => SceneNode)),
606
+ children: Schema.Array(Schema.suspend((): Schema.Codec<KernelSceneNode, unknown> => SceneNode)),
572
607
  body: Schema.NullOr(Body),
573
608
  follow: Schema.NullOr(XrSpace),
609
+ detail: absentAsNull(
610
+ Schema.Struct({ distances: Schema.Array(NonNegative), hysteresis: NonNegative }),
611
+ ),
574
612
  }),
575
613
  MeshNode,
576
614
  InstancesNode,
@@ -588,6 +626,20 @@ export const SceneNode: Schema.Codec<KernelSceneNode> = Schema.Union([
588
626
  /** The fraction of the way a camera closes every sixtieth of a second: 0 jumps. */
589
627
  const CameraDamping = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThan(1));
590
628
 
629
+ /** Camera shake (Drei's `CameraShake`). */
630
+ export const CameraShake = Schema.Struct({
631
+ intensity: NonNegative,
632
+ decay: Schema.Boolean,
633
+ decayRate: NonNegative,
634
+ maxYaw: NonNegative,
635
+ maxPitch: NonNegative,
636
+ maxRoll: NonNegative,
637
+ yawFrequency: NonNegative,
638
+ pitchFrequency: NonNegative,
639
+ rollFrequency: NonNegative,
640
+ trigger: Schema.Finite,
641
+ });
642
+
591
643
  /** A perspective camera. */
592
644
  export const PerspectiveCamera = Schema.TaggedStruct('Perspective', {
593
645
  fov: Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 180, exclusiveMinimum: true })),
@@ -597,6 +649,7 @@ export const PerspectiveCamera = Schema.TaggedStruct('Perspective', {
597
649
  lookAt: Vec3,
598
650
  damping: CameraDamping,
599
651
  autoRotate: Schema.Finite,
652
+ shake: absentAsNull(CameraShake),
600
653
  });
601
654
 
602
655
  /** An orthographic camera; `zoom` is CSS pixels per world unit. */
@@ -608,6 +661,7 @@ export const OrthographicCamera = Schema.TaggedStruct('Orthographic', {
608
661
  lookAt: Vec3,
609
662
  damping: CameraDamping,
610
663
  autoRotate: Schema.Finite,
664
+ shake: absentAsNull(CameraShake),
611
665
  });
612
666
 
613
667
  /** The active camera. */
@@ -12,7 +12,7 @@ import { hostShaderGrid } from './shaderGrid.ts';
12
12
  import { hostSky } from './sky.ts';
13
13
 
14
14
  export const webglKit: RenderKit = {
15
- drawsLabels: true,
15
+ shading: 'webgl',
16
16
  sky: hostSky,
17
17
  shaderGrid: hostShaderGrid,
18
18
  contactShadows: createContactShadows,
@@ -11,8 +11,8 @@ import { hostShaderGrid } from './shaderGrid.ts';
11
11
  import { hostSky } from './sky.ts';
12
12
 
13
13
  export const webgpuKit: RenderKit = {
14
- // troika's labels are WebGL materials; real WebGPU rejects their draw calls.
15
- drawsLabels: false,
14
+ // Node materials: label engines draw with their TSL implementation (`createTsl`).
15
+ shading: 'tsl',
16
16
  sky: hostSky,
17
17
  shaderGrid: hostShaderGrid,
18
18
  contactShadows: createContactShadows,