@opsydyn/effect-three 0.2.0 → 0.4.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 (134) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +14 -1
  3. package/dist/{index-01kwfekm.js → index-h3vf50fn.js} +2418 -85
  4. package/dist/index-h3vf50fn.js.map +53 -0
  5. package/dist/index-ttwy0znv.js +145 -0
  6. package/dist/index-ttwy0znv.js.map +12 -0
  7. package/dist/index.js +543 -149
  8. package/dist/index.js.map +8 -6
  9. package/dist/kernel/behaviours.d.ts.map +1 -1
  10. package/dist/kernel/boxSelect.d.ts +22 -0
  11. package/dist/kernel/boxSelect.d.ts.map +1 -0
  12. package/dist/kernel/build.d.ts +197 -6
  13. package/dist/kernel/build.d.ts.map +1 -1
  14. package/dist/kernel/cameraMotion.d.ts +3 -1
  15. package/dist/kernel/cameraMotion.d.ts.map +1 -1
  16. package/dist/kernel/cameraShake.d.ts +33 -0
  17. package/dist/kernel/cameraShake.d.ts.map +1 -0
  18. package/dist/kernel/describeScene.d.ts +50 -0
  19. package/dist/kernel/describeScene.d.ts.map +1 -0
  20. package/dist/kernel/description.d.ts +240 -2
  21. package/dist/kernel/description.d.ts.map +1 -1
  22. package/dist/kernel/diff.d.ts +18 -1
  23. package/dist/kernel/diff.d.ts.map +1 -1
  24. package/dist/kernel/equality.d.ts +13 -1
  25. package/dist/kernel/equality.d.ts.map +1 -1
  26. package/dist/kernel/gizmo.d.ts +69 -0
  27. package/dist/kernel/gizmo.d.ts.map +1 -0
  28. package/dist/kernel/history.d.ts +61 -0
  29. package/dist/kernel/history.d.ts.map +1 -0
  30. package/dist/kernel/index.d.ts +13 -2
  31. package/dist/kernel/index.d.ts.map +1 -1
  32. package/dist/kernel/index.js +116 -3
  33. package/dist/kernel/index.js.map +1 -1
  34. package/dist/kernel/inspect.d.ts +85 -0
  35. package/dist/kernel/inspect.d.ts.map +1 -0
  36. package/dist/kernel/lineGeometry.d.ts +25 -0
  37. package/dist/kernel/lineGeometry.d.ts.map +1 -0
  38. package/dist/kernel/lockstep.d.ts +114 -0
  39. package/dist/kernel/lockstep.d.ts.map +1 -0
  40. package/dist/kernel/loop.d.ts.map +1 -1
  41. package/dist/kernel/materials.d.ts.map +1 -1
  42. package/dist/kernel/pivot.d.ts +54 -0
  43. package/dist/kernel/pivot.d.ts.map +1 -0
  44. package/dist/kernel/pointBuffers.d.ts +17 -0
  45. package/dist/kernel/pointBuffers.d.ts.map +1 -0
  46. package/dist/kernel/pointer.d.ts +12 -0
  47. package/dist/kernel/pointer.d.ts.map +1 -1
  48. package/dist/kernel/renderKit.d.ts +21 -1
  49. package/dist/kernel/renderKit.d.ts.map +1 -1
  50. package/dist/kernel/renderer.d.ts +6 -0
  51. package/dist/kernel/renderer.d.ts.map +1 -1
  52. package/dist/kernel/resources.d.ts.map +1 -1
  53. package/dist/kernel/sceneChanges.d.ts +87 -0
  54. package/dist/kernel/sceneChanges.d.ts.map +1 -0
  55. package/dist/kernel/sceneGraph.d.ts +6 -1
  56. package/dist/kernel/sceneGraph.d.ts.map +1 -1
  57. package/dist/kernel/seeded.d.ts +9 -0
  58. package/dist/kernel/seeded.d.ts.map +1 -0
  59. package/dist/kernel/shots.d.ts +26 -0
  60. package/dist/kernel/shots.d.ts.map +1 -0
  61. package/dist/kernel/tape.d.ts +38 -0
  62. package/dist/kernel/tape.d.ts.map +1 -0
  63. package/dist/kernel/trail.d.ts +31 -0
  64. package/dist/kernel/trail.d.ts.map +1 -0
  65. package/dist/kernel/viewportGizmo.d.ts +25 -0
  66. package/dist/kernel/viewportGizmo.d.ts.map +1 -0
  67. package/dist/runtime/Inspector.d.ts +20 -0
  68. package/dist/runtime/Inspector.d.ts.map +1 -0
  69. package/dist/runtime/SceneFile.d.ts +448 -0
  70. package/dist/runtime/SceneFile.d.ts.map +1 -0
  71. package/dist/runtime/SceneSchema.d.ts +614 -5
  72. package/dist/runtime/SceneSchema.d.ts.map +1 -1
  73. package/dist/runtime/errors.d.ts +9 -0
  74. package/dist/runtime/errors.d.ts.map +1 -1
  75. package/dist/runtime/index.d.ts +3 -1
  76. package/dist/runtime/index.d.ts.map +1 -1
  77. package/dist/webgl/index.d.ts.map +1 -1
  78. package/dist/webgl/index.js +228 -3
  79. package/dist/webgl/index.js.map +6 -4
  80. package/dist/webgl/line.d.ts +4 -0
  81. package/dist/webgl/line.d.ts.map +1 -0
  82. package/dist/webgl/points.d.ts +4 -0
  83. package/dist/webgl/points.d.ts.map +1 -0
  84. package/dist/webgpu/index.d.ts.map +1 -1
  85. package/dist/webgpu/index.js +206 -26
  86. package/dist/webgpu/index.js.map +6 -4
  87. package/dist/webgpu/line.d.ts +4 -0
  88. package/dist/webgpu/line.d.ts.map +1 -0
  89. package/dist/webgpu/points.d.ts +4 -0
  90. package/dist/webgpu/points.d.ts.map +1 -0
  91. package/package.json +2 -1
  92. package/src/kernel/behaviours.ts +5 -0
  93. package/src/kernel/bodySources.ts +1 -1
  94. package/src/kernel/boxSelect.ts +73 -0
  95. package/src/kernel/build.ts +430 -0
  96. package/src/kernel/cameraMotion.ts +3 -0
  97. package/src/kernel/cameraShake.ts +87 -0
  98. package/src/kernel/describeScene.ts +224 -0
  99. package/src/kernel/description.ts +266 -1
  100. package/src/kernel/diff.ts +46 -1
  101. package/src/kernel/equality.ts +148 -0
  102. package/src/kernel/gizmo.ts +478 -0
  103. package/src/kernel/history.ts +118 -0
  104. package/src/kernel/index.ts +102 -0
  105. package/src/kernel/inspect.ts +214 -0
  106. package/src/kernel/lineGeometry.ts +75 -0
  107. package/src/kernel/lockstep.ts +237 -0
  108. package/src/kernel/loop.ts +5 -0
  109. package/src/kernel/materials.ts +3 -0
  110. package/src/kernel/pivot.ts +418 -0
  111. package/src/kernel/pointBuffers.ts +115 -0
  112. package/src/kernel/pointer.ts +63 -12
  113. package/src/kernel/renderKit.ts +23 -1
  114. package/src/kernel/renderer.ts +6 -0
  115. package/src/kernel/resources.ts +69 -0
  116. package/src/kernel/sceneChanges.ts +265 -0
  117. package/src/kernel/sceneGraph.ts +461 -9
  118. package/src/kernel/seeded.ts +18 -0
  119. package/src/kernel/shots.ts +167 -0
  120. package/src/kernel/tape.ts +124 -0
  121. package/src/kernel/trail.ts +170 -0
  122. package/src/kernel/viewportGizmo.ts +194 -0
  123. package/src/runtime/Inspector.ts +43 -0
  124. package/src/runtime/SceneFile.ts +46 -0
  125. package/src/runtime/SceneSchema.ts +305 -4
  126. package/src/runtime/errors.ts +6 -0
  127. package/src/runtime/index.ts +3 -1
  128. package/src/webgl/index.ts +4 -0
  129. package/src/webgl/line.ts +66 -0
  130. package/src/webgl/points.ts +205 -0
  131. package/src/webgpu/index.ts +4 -0
  132. package/src/webgpu/line.ts +67 -0
  133. package/src/webgpu/points.ts +193 -0
  134. package/dist/index-01kwfekm.js.map +0 -40
@@ -21,20 +21,25 @@ 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';
31
33
  import { type BodyCollision, type BodyContactForce, createBodyHost } from './bodies.ts';
32
34
  import { type BodySource, instanceSources, nodeSource, placeObject } from './bodySources.ts';
33
- import { renderSettings, scene as describeScene } from './build.ts';
35
+ import { orthographicCamera, 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 {
39
+ Hud,
36
40
  AmbientLightNode,
37
41
  BorrowedNode,
42
+ Edges,
38
43
  DirectionalLightNode,
39
44
  GridNode,
40
45
  HemisphereLightNode,
@@ -42,6 +47,8 @@ import type {
42
47
  Instance,
43
48
  InstancesNode,
44
49
  LabelNode,
50
+ LineNode,
51
+ PointsNode,
45
52
  MaterialRef,
46
53
  ShaderGridNode,
47
54
  MeshNode,
@@ -65,9 +72,16 @@ import type {
65
72
  ViewRect,
66
73
  } from './description.ts';
67
74
  import { diff, type Field, type Op } from './diff.ts';
68
- import { contactShadowsEquals, environmentEquals, overlaysEqual, skyEquals } from './equality.ts';
75
+ import {
76
+ contactShadowsEquals,
77
+ edgesEquals,
78
+ environmentEquals,
79
+ overlaysEqual,
80
+ skyEquals,
81
+ } from './equality.ts';
69
82
  import { createInstances, fits, type HostInstances, writeInstances } from './instances.ts';
70
83
  import type { HostedLabel, LabelEngine, LabelLayout } from './labels.ts';
84
+ import { edgeLine, edgePoints } from './lineGeometry.ts';
71
85
  import type { HostMaterial } from './materials.ts';
72
86
  import {
73
87
  type Behaviour,
@@ -77,7 +91,14 @@ import {
77
91
  resetPose,
78
92
  } from './motion.ts';
79
93
  import type { BodyPose, PhysicsEngine } from './physics.ts';
80
- import type { HostedShaderGrid, HostedSky, RenderKit } from './renderKit.ts';
94
+ import { animatedLook } from './pointBuffers.ts';
95
+ import type {
96
+ HostedLine,
97
+ HostedPoints,
98
+ HostedShaderGrid,
99
+ HostedSky,
100
+ RenderKit,
101
+ } from './renderKit.ts';
81
102
  import { createResourceStore, type ResourceStore } from './resources.ts';
82
103
  import {
83
104
  type DescriptionError,
@@ -86,6 +107,7 @@ import {
86
107
  type ParentKey,
87
108
  type SceneIndex,
88
109
  } from './sceneIndex.ts';
110
+ import { createTrailRibbon, type TrailRibbon } from './trail.ts';
89
111
  import { applyTween, startTween, type Tween } from './tween.ts';
90
112
 
91
113
  /** The mesh type the kernel hosts. */
@@ -139,6 +161,20 @@ type Entry =
139
161
  readonly _tag: 'HemisphereLight';
140
162
  node: HemisphereLightNode;
141
163
  readonly object: HemisphereLight;
164
+ }
165
+ // `hosted` is the points the render kit drew, inside the wrapper; none without a kit.
166
+ | {
167
+ readonly _tag: 'Points';
168
+ node: PointsNode;
169
+ readonly object: Group;
170
+ readonly hosted: HostedPoints | undefined;
171
+ }
172
+ // `hosted` is the line the render kit drew, inside the wrapper; none without a kit.
173
+ | {
174
+ readonly _tag: 'Line';
175
+ node: LineNode;
176
+ readonly object: Group;
177
+ readonly hosted: HostedLine | undefined;
142
178
  };
143
179
 
144
180
  /** Entries with a transform: position, rotation, scale and transitions. */
@@ -151,12 +187,26 @@ type Player = {
151
187
  readonly startedAt: number;
152
188
  };
153
189
 
190
+ /** A mesh's edge lines: the kit's line, and the geometry and look it was built from. */
191
+ type EdgeLines = {
192
+ readonly hosted: HostedLine;
193
+ geometry: BufferGeometry | undefined;
194
+ edges: Edges | undefined;
195
+ };
196
+
154
197
  /** Descriptions with a transform. */
155
- type PlacedNode = GroupNode | MeshNode | InstancesNode | BorrowedNode | LabelNode;
198
+ type PlacedNode =
199
+ | GroupNode
200
+ | MeshNode
201
+ | InstancesNode
202
+ | BorrowedNode
203
+ | LabelNode
204
+ | LineNode
205
+ | PointsNode;
156
206
 
157
207
  type Placed = Extract<
158
208
  Entry,
159
- { readonly _tag: 'Group' | 'Mesh' | 'Instances' | 'Borrowed' | 'Label' }
209
+ { readonly _tag: 'Group' | 'Mesh' | 'Instances' | 'Borrowed' | 'Label' | 'Line' | 'Points' }
160
210
  >;
161
211
 
162
212
  /** Entries drawing a geometry with a material. */
@@ -167,12 +217,17 @@ const isPlaced = (entry: Entry): entry is Placed =>
167
217
  entry._tag === 'Mesh' ||
168
218
  entry._tag === 'Instances' ||
169
219
  entry._tag === 'Borrowed' ||
170
- entry._tag === 'Label';
220
+ entry._tag === 'Label' ||
221
+ entry._tag === 'Line' ||
222
+ entry._tag === 'Points';
171
223
 
172
224
  const isSurfaced = (entry: Entry): entry is Surfaced =>
173
225
  entry._tag === 'Mesh' || entry._tag === 'Instances';
174
226
 
175
227
  /** A render texture as hosted: its id, size and the nested graph that draws it. */
228
+ /** The HUD as hosted: its nested graph, drawn over the scene by the frame loop. */
229
+ export type HostedHud = { readonly graph: SceneGraph };
230
+
176
231
  export type HostedTexture = {
177
232
  readonly id: string;
178
233
  readonly graph: SceneGraph;
@@ -229,6 +284,8 @@ export type SceneGraph = {
229
284
  readonly turned: (view: string | undefined) => Vec3 | undefined;
230
285
  /** Render textures, each a nested graph to draw into its texture (by the frame loop). */
231
286
  readonly renderTextures: () => ReadonlyArray<HostedTexture>;
287
+ /** The HUD, a nested graph drawn over the scene (by the frame loop), if there is one. */
288
+ readonly hud: () => HostedHud | undefined;
232
289
  /** Whether the scene claims the pointer's gestures from the page. */
233
290
  readonly gestures: () => Gestures;
234
291
  /** Points to show HTML at; the frame loop places them after drawing. */
@@ -426,6 +483,14 @@ const noInstances: ReadonlyArray<Instance> = [];
426
483
  const emptyScene = describeScene({});
427
484
 
428
485
  /** Creates an empty scene graph. */
486
+ /**
487
+ * A copy of a lent object for a second place to show it, sharing its geometry and
488
+ * materials, as Drei's `Clone` (`bf6f4ad`, `src/core/Clone.tsx`): a skinned model through
489
+ * three's `SkeletonUtils.clone`, so that the copy has bones of its own to animate.
490
+ */
491
+ const cloneLoan = (lent: Object3D): Object3D =>
492
+ lent.getObjectByProperty('isSkinnedMesh', true) === undefined ? lent.clone() : cloneSkinned(lent);
493
+
429
494
  export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph => {
430
495
  const store = options.store ?? createResourceStore();
431
496
  const ownsStore = options.store === undefined;
@@ -437,6 +502,9 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
437
502
  { readonly graph: SceneGraph; description: RenderTexture }
438
503
  >();
439
504
  let textureList: ReadonlyArray<HostedTexture> = [];
505
+ let hudHosted:
506
+ | { readonly graph: SceneGraph; description: Hud; parent: SceneDescription }
507
+ | undefined;
440
508
  let viewDescriptions: ReadonlyArray<View> = [];
441
509
  let gestures: Gestures = emptyScene.gestures;
442
510
  let overlayList: ReadonlyArray<Overlay> = emptyScene.overlays;
@@ -470,8 +538,22 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
470
538
  hostedSky = undefined;
471
539
  }
472
540
  const cameraMotion = createCameraMotion();
541
+ const cameraShaker = createCameraShaker();
473
542
  /** Each view's camera motion, by the view's key. */
474
543
  const viewMotions = new Map<string, CameraMotion>();
544
+ /** Each view's camera shake, by the view's key. */
545
+ const viewShakers = new Map<string, CameraShaker>();
546
+ const shaking = (): boolean => {
547
+ if (cameraShaker.shaking(elapsed)) return true;
548
+ for (const shaker of viewShakers.values()) if (shaker.shaking(elapsed)) return true;
549
+ return false;
550
+ };
551
+ /** Shakes a view's camera on top of where its motion aims it. */
552
+ const shakeView = (view: HostedView): void => {
553
+ const motion = viewMotions.get(view.key);
554
+ if (motion === undefined) return;
555
+ viewShakers.get(view.key)?.apply(view.camera, motion.target(), elapsed, animating);
556
+ };
475
557
  const viewsMoving = (): boolean => {
476
558
  for (const motion of viewMotions.values()) if (motion.moving()) return true;
477
559
  return false;
@@ -505,6 +587,9 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
505
587
  applyLens(hostedCamera, description.camera);
506
588
  const motion = viewMotions.get(description.key) ?? createCameraMotion();
507
589
  viewMotions.set(description.key, motion);
590
+ const shaker = viewShakers.get(description.key) ?? createCameraShaker();
591
+ viewShakers.set(description.key, shaker);
592
+ shaker.describe(description.camera.shake, elapsed);
508
593
  // As the scene's camera: a new kind of camera has nothing on screen to travel from.
509
594
  motion.aim(hostedCamera, description.camera, elapsed, hostedCamera !== previous || !animating);
510
595
  const area = areaOf(description.rect);
@@ -520,10 +605,41 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
520
605
  for (const key of viewMotions.keys()) {
521
606
  if (!described.some((description) => description.key === key)) viewMotions.delete(key);
522
607
  }
608
+ for (const key of viewShakers.keys()) {
609
+ if (!described.some((description) => description.key === key)) viewShakers.delete(key);
610
+ }
523
611
  };
524
612
  const entries = new Map<string, Entry>();
525
613
  const keys = new WeakMap<Object3D, string>();
526
614
  const spinning = new Set<Entry>();
615
+ // Billboards: turned towards the camera each frame, after everything else has moved.
616
+ const facing = new Set<Entry>();
617
+ const cameraTurn = new Quaternion();
618
+ const parentTurn = new Quaternion();
619
+ // Points that move by themselves (sparkles, stars), ticked in scene time while animating.
620
+ const sparkling = new Set<Entry>();
621
+ const trackSparkle = (entry: Entry, node: PointsNode): void => {
622
+ if (animatedLook(node.look)) sparkling.add(entry);
623
+ else sparkling.delete(entry);
624
+ if (entry._tag === 'Points') entry.hosted?.tick(animating ? elapsed : 0);
625
+ };
626
+ const sparkle = (entry: Entry): void => {
627
+ if (entry._tag === 'Points') entry.hosted?.tick(elapsed);
628
+ };
629
+ // Groups and meshes with trails: each ribbon, in the scene's space.
630
+ const trailing = new Map<Entry, TrailRibbon>();
631
+ const trailPlace = new Vector3();
632
+ const eye = new Vector3();
633
+ const trailsMoving = (): boolean => {
634
+ for (const ribbon of trailing.values()) if (!ribbon.settled()) return true;
635
+ return false;
636
+ };
637
+ // Meshes with edge lines: rebuilt when the mesh's geometry or the edges' look changes.
638
+ const edged = new Map<Entry, EdgeLines>();
639
+ // Groups with levels of detail: one child shown at a time, by the camera's distance.
640
+ const detailing = new Set<Entry>();
641
+ const cameraPlace = new Vector3();
642
+ const groupPlace = new Vector3();
527
643
  const playing = new Set<Borrowing>();
528
644
  const registry: Readonly<Record<string, Behaviour>> = {
529
645
  ...builtinBehaviours,
@@ -580,6 +696,152 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
580
696
  writeRotation(entry.object.rotation, node.rotation, elapsed);
581
697
  if (node.rotation._tag === 'Spin') spinning.add(entry);
582
698
  else spinning.delete(entry);
699
+ if (node.rotation._tag === 'Billboard') facing.add(entry);
700
+ else facing.delete(entry);
701
+ };
702
+
703
+ /**
704
+ * Drei's `Billboard`: the node's rotation is its parent's world rotation undone, then the
705
+ * camera's, so its world rotation is the camera's; locked axes go back to 0.
706
+ */
707
+ const faceCamera = (entry: Entry): void => {
708
+ if (!isPlaced(entry) || entry.node.rotation._tag !== 'Billboard') return;
709
+ const { lockX, lockY, lockZ } = entry.node.rotation;
710
+ const object = entry.object;
711
+ parentTurn.identity();
712
+ object.parent?.getWorldQuaternion(parentTurn);
713
+ object.quaternion.copy(cameraTurn).premultiply(parentTurn.invert());
714
+ if (lockX) object.rotation.x = 0;
715
+ if (lockY) object.rotation.y = 0;
716
+ if (lockZ) object.rotation.z = 0;
717
+ };
718
+
719
+ /** Where a trail follows its node: the world position, or with `local` its own. */
720
+ const trailTarget = (object: Object3D, local: boolean): Vector3 =>
721
+ local ? trailPlace.copy(object.position) : object.getWorldPosition(trailPlace);
722
+
723
+ /** Adds, restyles or removes a node's trail as its description says. */
724
+ const trackTrail = (entry: Entry, node: GroupNode | MeshNode): void => {
725
+ const current = trailing.get(entry);
726
+ if (node.trail === null) {
727
+ if (current === undefined) return;
728
+ current.object.removeFromParent();
729
+ current.dispose();
730
+ trailing.delete(entry);
731
+ return;
732
+ }
733
+ const start = trailTarget(entry.object, node.trail.local);
734
+ if (current !== undefined) {
735
+ current.restyle(node.trail, start, elapsed);
736
+ return;
737
+ }
738
+ const ribbon = createTrailRibbon(node.trail, start, elapsed);
739
+ scene.add(ribbon.object);
740
+ trailing.set(entry, ribbon);
741
+ };
742
+
743
+ /** Records where a trailing node is now, and turns its ribbon to the camera. */
744
+ const followTrail = (ribbon: TrailRibbon, entry: Entry): void => {
745
+ if (entry._tag !== 'Group' && entry._tag !== 'Mesh') return;
746
+ if (entry.node.trail === null) return;
747
+ ribbon.record(trailTarget(entry.object, entry.node.trail.local), elapsed);
748
+ ribbon.face(eye);
749
+ };
750
+
751
+ /** Drei's `Edges` turns off picking on its line: hits belong to the mesh. */
752
+ const unpickable = (part: Object3D): void => {
753
+ part.raycast = () => undefined;
754
+ };
755
+
756
+ /** Adds, keeps or removes a mesh's edge lines as its description says. */
757
+ const trackEdges = (entry: Entry, node: MeshNode): void => {
758
+ const current = edged.get(entry);
759
+ const draw = options.kit?.line;
760
+ if (node.edges === null || draw === undefined) {
761
+ if (current === undefined) return;
762
+ current.hosted.object.removeFromParent();
763
+ current.hosted.dispose();
764
+ edged.delete(entry);
765
+ return;
766
+ }
767
+ if (current !== undefined) return;
768
+ const hosted = draw(edgeLine(node.key, node.edges, []));
769
+ entry.object.add(hosted.object);
770
+ edged.set(entry, { hosted, geometry: undefined, edges: undefined });
771
+ };
772
+
773
+ /**
774
+ * Builds the edge lines again when the mesh shows another geometry (its own changed, a
775
+ * shared one was replaced, a lent one arrived) or the edges' look changed.
776
+ */
777
+ const refreshEdges = (lines: EdgeLines, entry: Entry): void => {
778
+ if (entry._tag !== 'Mesh' || entry.node.edges === null) return;
779
+ const { geometry } = entry.object;
780
+ const look = entry.node.edges;
781
+ if (
782
+ geometry === lines.geometry &&
783
+ lines.edges !== undefined &&
784
+ edgesEquals(look, lines.edges)
785
+ ) {
786
+ return;
787
+ }
788
+ lines.hosted.update(edgeLine(entry.node.key, look, edgePoints(geometry, look.threshold)));
789
+ lines.hosted.object.traverse(unpickable);
790
+ lines.geometry = geometry;
791
+ lines.edges = look;
792
+ };
793
+
794
+ /** The object of a group's `index`th child, as described. */
795
+ const childObject = (node: GroupNode, index: number): Object3D | undefined => {
796
+ const child = node.children[index];
797
+ return child === undefined ? undefined : entries.get(child.key)?.object;
798
+ };
799
+
800
+ /**
801
+ * three's `LOD.update`, as Drei's `Detailed` runs it each frame, over the group's children
802
+ * in order: the first level shows, each later one takes over once the camera is at least
803
+ * its distance away (less its hysteresis while it shows), and the rest hide.
804
+ */
805
+ const showLevel = (entry: Entry): void => {
806
+ if (entry._tag !== 'Group') return;
807
+ const { node } = entry;
808
+ const { detail } = node;
809
+ if (detail === null) return;
810
+ const { distances, hysteresis } = detail;
811
+ const count = node.children.length;
812
+ if (count < 2) return;
813
+ entry.object.getWorldPosition(groupPlace);
814
+ const distance = cameraPlace.distanceTo(groupPlace) / camera.zoom;
815
+ const first = childObject(node, 0);
816
+ if (first !== undefined) first.visible = true;
817
+ let level = 1;
818
+ for (; level < count; level++) {
819
+ const object = childObject(node, level);
820
+ const from = distances[level] ?? Number.NaN;
821
+ const reach = object?.visible === true ? from - from * hysteresis : from;
822
+ if (!(distance >= reach)) break;
823
+ const previous = childObject(node, level - 1);
824
+ if (previous !== undefined) previous.visible = false;
825
+ if (object !== undefined) object.visible = true;
826
+ }
827
+ for (; level < count; level++) {
828
+ const object = childObject(node, level);
829
+ if (object !== undefined) object.visible = false;
830
+ }
831
+ };
832
+
833
+ const trackDetail = (entry: Entry, node: GroupNode): void => {
834
+ if (node.detail !== null) {
835
+ detailing.add(entry);
836
+ return;
837
+ }
838
+ // No longer levels: every child shows again.
839
+ if (detailing.delete(entry)) {
840
+ for (let index = 0; index < node.children.length; index++) {
841
+ const object = childObject(node, index);
842
+ if (object !== undefined) object.visible = true;
843
+ }
844
+ }
583
845
  };
584
846
 
585
847
  const build = (node: SceneNode): Entry => {
@@ -593,6 +855,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
593
855
  trackInteractive(entry.object, node.interactive);
594
856
  // A group following a tracked space shows only once the space is found.
595
857
  if (node.follow !== null) entry.object.visible = false;
858
+ trackDetail(entry, node);
859
+ trackTrail(entry, node);
596
860
  return entry;
597
861
  }
598
862
  case 'Mesh': {
@@ -608,6 +872,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
608
872
  trackInteractive(object, node.interactive);
609
873
  object.castShadow = node.castShadow;
610
874
  object.receiveShadow = node.receiveShadow;
875
+ trackEdges(entry, node);
876
+ trackTrail(entry, node);
611
877
  return entry;
612
878
  }
613
879
  case 'Instances': {
@@ -688,6 +954,28 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
688
954
  const hosted = options.kit?.shaderGrid(node);
689
955
  return { _tag: 'ShaderGrid', node, object: hosted?.object ?? new Group(), hosted };
690
956
  }
957
+ case 'Points': {
958
+ const hosted = options.kit?.points?.(node);
959
+ const entry: Entry = { _tag: 'Points', node, object: new Group(), hosted };
960
+ if (hosted !== undefined) entry.object.add(hosted.object);
961
+ writeVec3(entry.object.position, node.position);
962
+ writeVec3(entry.object.scale, node.scale);
963
+ trackRotation(entry, node);
964
+ trackBehaviour(entry, node);
965
+ trackSparkle(entry, node);
966
+ return entry;
967
+ }
968
+ case 'Line': {
969
+ const hosted = options.kit?.line?.(node);
970
+ const entry: Entry = { _tag: 'Line', node, object: new Group(), hosted };
971
+ if (hosted !== undefined) entry.object.add(hosted.object);
972
+ writeVec3(entry.object.position, node.position);
973
+ writeVec3(entry.object.scale, node.scale);
974
+ trackRotation(entry, node);
975
+ trackBehaviour(entry, node);
976
+ trackInteractive(entry.object, node.interactive);
977
+ return entry;
978
+ }
691
979
  case 'HemisphereLight': {
692
980
  const object = new HemisphereLight();
693
981
  writeHemisphereLight(object, node);
@@ -706,12 +994,22 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
706
994
  entry.shown = undefined;
707
995
  const lent = store.lent(entry.node.id);
708
996
  if (lent === undefined) return;
709
- const shown = lent.parent === null ? lent : lent.clone();
997
+ const shown = lent.parent === null ? lent : cloneLoan(lent);
710
998
  entry.object.add(shown);
711
999
  entry.shown = shown;
1000
+ castShadows(shown, entry.node);
712
1001
  startPlayer(entry);
713
1002
  };
714
1003
 
1004
+ /** Drei's `Clone` shadow shorthands: each mesh casts or receives where asked. */
1005
+ const castShadows = (shown: Object3D, node: BorrowedNode): void => {
1006
+ if (!node.castShadow && !node.receiveShadow) return;
1007
+ shown.traverse((part) => {
1008
+ if (part instanceof Mesh && node.castShadow) part.castShadow = true;
1009
+ if (part instanceof Mesh && node.receiveShadow) part.receiveShadow = true;
1010
+ });
1011
+ };
1012
+
715
1013
  const stopPlayer = (entry: Borrowing): void => {
716
1014
  if (entry.player === undefined) return;
717
1015
  entry.player.mixer.stopAllAction();
@@ -850,6 +1148,19 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
850
1148
  const release = (entry: Entry): void => {
851
1149
  if (isPlaced(entry)) behaving.delete(entry);
852
1150
  spinning.delete(entry);
1151
+ facing.delete(entry);
1152
+ detailing.delete(entry);
1153
+ const lines = edged.get(entry);
1154
+ if (lines !== undefined) {
1155
+ lines.hosted.dispose();
1156
+ edged.delete(entry);
1157
+ }
1158
+ const ribbon = trailing.get(entry);
1159
+ if (ribbon !== undefined) {
1160
+ ribbon.object.removeFromParent();
1161
+ ribbon.dispose();
1162
+ trailing.delete(entry);
1163
+ }
853
1164
  moving.delete(entry);
854
1165
  reparented.delete(entry);
855
1166
  trackInteractive(entry.object, false);
@@ -861,7 +1172,10 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
861
1172
  if (entry._tag === 'Instances') entry.object.dispose();
862
1173
  // A grid's lines are its own.
863
1174
  if (entry._tag === 'Grid') disposeGridLines(entry.helper);
864
- if (entry._tag === 'ShaderGrid') entry.hosted?.dispose();
1175
+ if (entry._tag === 'ShaderGrid' || entry._tag === 'Line' || entry._tag === 'Points') {
1176
+ entry.hosted?.dispose();
1177
+ }
1178
+ sparkling.delete(entry);
865
1179
  if (entry._tag === 'Label') {
866
1180
  entry.hosted?.dispose();
867
1181
  if (entry.node.material !== null && entry.material !== undefined) {
@@ -991,6 +1305,12 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
991
1305
  entry.node = next;
992
1306
  if (movesTransform(entry, fields)) retarget(entry, next);
993
1307
  if (fields.includes('rotation')) trackRotation(entry, next);
1308
+ if (entry._tag === 'Mesh' && next._tag === 'Mesh' && fields.includes('edges')) {
1309
+ trackEdges(entry, next);
1310
+ }
1311
+ if (entry._tag === 'Mesh' && next._tag === 'Mesh' && fields.includes('trail')) {
1312
+ trackTrail(entry, next);
1313
+ }
994
1314
  if (fields.includes('behaviour')) trackBehaviour(entry, next);
995
1315
  if (fields.includes('interactive')) trackInteractive(mesh, next.interactive);
996
1316
  mesh.castShadow = next.castShadow;
@@ -1005,6 +1325,9 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1005
1325
  if (fields.includes('interactive')) trackInteractive(entry.object, next.interactive);
1006
1326
  if (fields.includes('id')) showLoan(entry);
1007
1327
  else if (fields.includes('animation')) startPlayer(entry);
1328
+ if (fields.includes('castShadow') || fields.includes('receiveShadow')) {
1329
+ if (entry.shown !== undefined) castShadows(entry.shown, next);
1330
+ }
1008
1331
  return;
1009
1332
  }
1010
1333
  if (entry._tag === 'Label' && next._tag === 'Label') {
@@ -1023,6 +1346,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1023
1346
  if (fields.includes('rotation')) trackRotation(entry, next);
1024
1347
  if (fields.includes('behaviour')) trackBehaviour(entry, next);
1025
1348
  if (fields.includes('interactive')) trackInteractive(entry.object, next.interactive);
1349
+ if (fields.includes('detail')) trackDetail(entry, next);
1350
+ if (fields.includes('trail')) trackTrail(entry, next);
1026
1351
  if (fields.includes('follow')) {
1027
1352
  // Back to its own transform, or hidden until the new space is found.
1028
1353
  entry.object.visible = next.follow === null;
@@ -1059,6 +1384,26 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1059
1384
  entry.node = next;
1060
1385
  return;
1061
1386
  }
1387
+ if (entry._tag === 'Points' && next._tag === 'Points') {
1388
+ entry.node = next;
1389
+ if (movesTransform(entry, fields)) retarget(entry, next);
1390
+ if (fields.includes('rotation')) trackRotation(entry, next);
1391
+ if (fields.includes('behaviour')) trackBehaviour(entry, next);
1392
+ if (fields.includes('look')) {
1393
+ entry.hosted?.update(next);
1394
+ trackSparkle(entry, next);
1395
+ }
1396
+ return;
1397
+ }
1398
+ if (entry._tag === 'Line' && next._tag === 'Line') {
1399
+ entry.node = next;
1400
+ if (movesTransform(entry, fields)) retarget(entry, next);
1401
+ if (fields.includes('rotation')) trackRotation(entry, next);
1402
+ if (fields.includes('behaviour')) trackBehaviour(entry, next);
1403
+ if (fields.includes('interactive')) trackInteractive(entry.object, next.interactive);
1404
+ if (fields.includes('line')) entry.hosted?.update(next);
1405
+ return;
1406
+ }
1062
1407
  if (entry._tag === 'ShaderGrid' && next._tag === 'ShaderGrid') {
1063
1408
  entry.hosted?.update(entry.node, next);
1064
1409
  entry.node = next;
@@ -1098,6 +1443,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1098
1443
  applyLens(camera, op.camera);
1099
1444
  // A new kind of camera has nothing on screen to travel from.
1100
1445
  cameraMotion.aim(camera, op.camera, elapsed, camera !== previous || !animating);
1446
+ cameraShaker.describe(op.camera.shake, elapsed);
1101
1447
  fitCamera(camera, viewport);
1102
1448
  return;
1103
1449
  }
@@ -1189,6 +1535,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1189
1535
  gestures: 'page',
1190
1536
  views: [],
1191
1537
  renderTextures: [],
1538
+ hud: null,
1192
1539
  overlays: [],
1193
1540
  environment: null,
1194
1541
  sky: null,
@@ -1201,6 +1548,78 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1201
1548
  children: texture.children,
1202
1549
  });
1203
1550
 
1551
+ /**
1552
+ * The HUD's camera: orthographic in CSS pixels, from in front, aimed so that the HUD's
1553
+ * origin falls `margin` in from the aligned edges (as Drei's `GizmoHelper` places its
1554
+ * group). The camera moves rather than the nodes, so their positions stay the Model's.
1555
+ */
1556
+ const hudCamera = (hud: Hud): CameraDescription => {
1557
+ const [marginX, marginY] = hud.margin;
1558
+ const [vertical, horizontal] = hud.alignment.split('-');
1559
+ const across = { left: -viewport.width / 2 + marginX, right: viewport.width / 2 - marginX };
1560
+ const up = { top: viewport.height / 2 - marginY, bottom: -viewport.height / 2 + marginY };
1561
+ const x = across[horizontal as keyof typeof across] ?? 0;
1562
+ const y = up[vertical as keyof typeof up] ?? 0;
1563
+ return orthographicCamera({
1564
+ zoom: 1,
1565
+ near: 0.1,
1566
+ far: 2000,
1567
+ position: [-x, -y, 200],
1568
+ lookAt: [-x, -y, 0],
1569
+ });
1570
+ };
1571
+
1572
+ /** The HUD as a scene of its own, sharing the parent's resources. */
1573
+ const hudScene = (hud: Hud, parent: SceneDescription): SceneDescription =>
1574
+ textureScene(
1575
+ {
1576
+ id: 'hud',
1577
+ width: viewport.width,
1578
+ height: viewport.height,
1579
+ camera: hudCamera(hud),
1580
+ background: null,
1581
+ children: hud.children,
1582
+ },
1583
+ parent,
1584
+ );
1585
+
1586
+ /**
1587
+ * Brings the HUD in line with the description, `parent` the scene it is over: a nested
1588
+ * graph on the same store, created, reconciled, re-aimed or disposed.
1589
+ */
1590
+ const syncHud = (next: SceneDescription): ReadonlyArray<Op> => {
1591
+ if (next.hud === null) {
1592
+ if (hudHosted === undefined) return [];
1593
+ hudHosted.graph.dispose();
1594
+ hudHosted = undefined;
1595
+ return [{ _tag: 'RemoveHud' }];
1596
+ }
1597
+ const created = hudHosted === undefined;
1598
+ const hosted = hudHosted ?? {
1599
+ graph: createSceneGraph({
1600
+ store,
1601
+ ...(options.behaviours === undefined ? {} : { behaviours: options.behaviours }),
1602
+ ...(options.kit === undefined ? {} : { kit: options.kit }),
1603
+ }),
1604
+ description: next.hud,
1605
+ parent: next,
1606
+ };
1607
+ if (created) hosted.graph.resize(viewport.width, viewport.height);
1608
+ const result = hosted.graph.reconcile(hudScene(next.hud, next));
1609
+ hosted.description = next.hud;
1610
+ hosted.parent = next;
1611
+ hudHosted = hosted;
1612
+ const ops = result._tag === 'Applied' ? result.ops : [];
1613
+ return created || ops.length > 0 ? [{ _tag: 'UpdateHud', ops }] : [];
1614
+ };
1615
+
1616
+ /** The HUD fitted to a resized canvas: its camera re-aimed at the aligned point. */
1617
+ const refitHud = (): void => {
1618
+ if (hudHosted === undefined) return;
1619
+ hudHosted.graph.resize(viewport.width, viewport.height);
1620
+ hudHosted.graph.reconcile(hudScene(hudHosted.description, hudHosted.parent));
1621
+ };
1622
+
1204
1623
  /** A hosted render texture as the loop sees it; none if it is not hosted. */
1205
1624
  function hostedTexture(texture: RenderTexture): ReadonlyArray<HostedTexture> {
1206
1625
  const hosted = textureGraphs.get(texture.id);
@@ -1261,6 +1680,10 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1261
1680
  const checked = indexScene(textureScene(texture, next), knownBehaviours);
1262
1681
  if (checked._tag === 'Invalid') return checked;
1263
1682
  }
1683
+ if (next.hud !== null) {
1684
+ const checked = indexScene(hudScene(next.hud, next), knownBehaviours);
1685
+ if (checked._tag === 'Invalid') return checked;
1686
+ }
1264
1687
  const result = diff(index, next, knownBehaviours);
1265
1688
  if (result._tag === 'Invalid') return result;
1266
1689
  for (const op of result.ops) apply(op);
@@ -1294,7 +1717,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1294
1717
  // Measured after the next drawn frame; a new list needs one.
1295
1718
  if (next.measure.join('\u0000') !== measured.join('\u0000')) invalidate();
1296
1719
  measured = next.measure;
1297
- const ops = [...result.ops, ...syncTextures(next)];
1720
+ const ops = [...result.ops, ...syncTextures(next), ...syncHud(next)];
1298
1721
  if (ops.length > 0) invalidate();
1299
1722
  return { _tag: 'Applied', ops };
1300
1723
  };
@@ -1307,6 +1730,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1307
1730
  const tick = (elapsedSeconds: number): void => {
1308
1731
  elapsed = elapsedSeconds;
1309
1732
  for (const texture of textureList) texture.graph.tick(elapsedSeconds);
1733
+ hudHosted?.graph.tick(elapsedSeconds);
1310
1734
  for (const entry of spinning) {
1311
1735
  if (isPlaced(entry)) {
1312
1736
  writeRotation(entry.object.rotation, entry.node.rotation, elapsed);
@@ -1317,8 +1741,27 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1317
1741
  behaving.forEach(applyBehaviour);
1318
1742
  cameraMotion.step(camera, elapsed);
1319
1743
  for (const view of views) viewMotions.get(view.key)?.step(view.camera, elapsed);
1744
+ // Shake on top of wherever the cameras are aimed now.
1745
+ cameraShaker.apply(camera, cameraMotion.target(), elapsed, animating);
1746
+ views.forEach(shakeView);
1320
1747
  // Last: a dynamic body's pose is the simulation's, whatever else moved the mesh.
1321
1748
  bodyHost.tick(elapsed, animating);
1749
+ // After the camera and everything a billboard may sit on have moved.
1750
+ if (facing.size > 0) {
1751
+ camera.getWorldQuaternion(cameraTurn);
1752
+ facing.forEach(faceCamera);
1753
+ }
1754
+ edged.forEach(refreshEdges);
1755
+ // Reduced motion holds sparkles and stars where they are.
1756
+ if (animating) sparkling.forEach(sparkle);
1757
+ if (trailing.size > 0) {
1758
+ camera.getWorldPosition(eye);
1759
+ trailing.forEach(followTrail);
1760
+ }
1761
+ if (detailing.size > 0) {
1762
+ camera.getWorldPosition(cameraPlace);
1763
+ detailing.forEach(showLevel);
1764
+ }
1322
1765
  };
1323
1766
 
1324
1767
  const finishTween = (tween: Tween, entry: Entry): void => {
@@ -1328,6 +1771,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1328
1771
  const setAnimating = (next: boolean): void => {
1329
1772
  if (next !== animating) invalidate();
1330
1773
  for (const texture of textureList) texture.graph.setAnimating(next);
1774
+ hudHosted?.graph.setAnimating(next);
1331
1775
  animating = next;
1332
1776
  if (next) return;
1333
1777
  moving.forEach(finishTween);
@@ -1342,6 +1786,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1342
1786
  for (const texture of textureList) texture.graph.dispose();
1343
1787
  textureGraphs.clear();
1344
1788
  textureList = [];
1789
+ hudHosted?.graph.dispose();
1790
+ hudHosted = undefined;
1345
1791
  for (const entry of entries.values()) {
1346
1792
  entry.object.removeFromParent();
1347
1793
  release(entry);
@@ -1372,6 +1818,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1372
1818
  return viewMotions.get(view)?.turned();
1373
1819
  },
1374
1820
  renderTextures: () => textureList,
1821
+ hud: () => hudHosted,
1375
1822
  gestures: () => gestures,
1376
1823
  overlays: () => overlayList,
1377
1824
  environment: () => environmentDescription,
@@ -1400,7 +1847,9 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1400
1847
  renderSettings: () => settings,
1401
1848
  needsFrame: () =>
1402
1849
  dirty ||
1850
+ trailsMoving() ||
1403
1851
  textureList.some((texture) => texture.graph.needsFrame()) ||
1852
+ (hudHosted?.graph.needsFrame() ?? false) ||
1404
1853
  (animating &&
1405
1854
  (spinning.size > 0 ||
1406
1855
  moving.size > 0 ||
@@ -1408,6 +1857,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1408
1857
  clipsRunning() ||
1409
1858
  cameraMotion.moving() ||
1410
1859
  viewsMoving() ||
1860
+ shaking() ||
1861
+ sparkling.size > 0 ||
1411
1862
  bodyHost.needsFrame())),
1412
1863
  drawn: () => {
1413
1864
  dirty = false;
@@ -1435,6 +1886,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1435
1886
  viewport = { width, height };
1436
1887
  fitCamera(camera, viewport);
1437
1888
  hostViews(viewDescriptions);
1889
+ refitHud();
1438
1890
  invalidate();
1439
1891
  },
1440
1892
  dispose,