@opsydyn/effect-three 0.2.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 (44) hide show
  1. package/CHANGELOG.md +6 -1
  2. package/README.md +2 -1
  3. package/dist/{index-01kwfekm.js → index-8cf8t6by.js} +327 -24
  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/pointer.d.ts.map +1 -1
  25. package/dist/kernel/resources.d.ts.map +1 -1
  26. package/dist/kernel/sceneGraph.d.ts +0 -1
  27. package/dist/kernel/sceneGraph.d.ts.map +1 -1
  28. package/dist/runtime/SceneSchema.d.ts +296 -5
  29. package/dist/runtime/SceneSchema.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/kernel/behaviours.ts +5 -0
  32. package/src/kernel/bodySources.ts +1 -1
  33. package/src/kernel/build.ts +98 -0
  34. package/src/kernel/cameraMotion.ts +3 -0
  35. package/src/kernel/cameraShake.ts +97 -0
  36. package/src/kernel/description.ts +91 -1
  37. package/src/kernel/diff.ts +7 -1
  38. package/src/kernel/equality.ts +47 -0
  39. package/src/kernel/index.ts +5 -0
  40. package/src/kernel/pointer.ts +8 -0
  41. package/src/kernel/resources.ts +69 -0
  42. package/src/kernel/sceneGraph.ts +142 -1
  43. package/src/runtime/SceneSchema.ts +58 -4
  44. package/dist/index-01kwfekm.js.map +0 -40
@@ -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': {
@@ -706,12 +817,22 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
706
817
  entry.shown = undefined;
707
818
  const lent = store.lent(entry.node.id);
708
819
  if (lent === undefined) return;
709
- const shown = lent.parent === null ? lent : lent.clone();
820
+ const shown = lent.parent === null ? lent : cloneLoan(lent);
710
821
  entry.object.add(shown);
711
822
  entry.shown = shown;
823
+ castShadows(shown, entry.node);
712
824
  startPlayer(entry);
713
825
  };
714
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
+
715
836
  const stopPlayer = (entry: Borrowing): void => {
716
837
  if (entry.player === undefined) return;
717
838
  entry.player.mixer.stopAllAction();
@@ -850,6 +971,8 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
850
971
  const release = (entry: Entry): void => {
851
972
  if (isPlaced(entry)) behaving.delete(entry);
852
973
  spinning.delete(entry);
974
+ facing.delete(entry);
975
+ detailing.delete(entry);
853
976
  moving.delete(entry);
854
977
  reparented.delete(entry);
855
978
  trackInteractive(entry.object, false);
@@ -1005,6 +1128,9 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1005
1128
  if (fields.includes('interactive')) trackInteractive(entry.object, next.interactive);
1006
1129
  if (fields.includes('id')) showLoan(entry);
1007
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
+ }
1008
1134
  return;
1009
1135
  }
1010
1136
  if (entry._tag === 'Label' && next._tag === 'Label') {
@@ -1023,6 +1149,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1023
1149
  if (fields.includes('rotation')) trackRotation(entry, next);
1024
1150
  if (fields.includes('behaviour')) trackBehaviour(entry, next);
1025
1151
  if (fields.includes('interactive')) trackInteractive(entry.object, next.interactive);
1152
+ if (fields.includes('detail')) trackDetail(entry, next);
1026
1153
  if (fields.includes('follow')) {
1027
1154
  // Back to its own transform, or hidden until the new space is found.
1028
1155
  entry.object.visible = next.follow === null;
@@ -1098,6 +1225,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1098
1225
  applyLens(camera, op.camera);
1099
1226
  // A new kind of camera has nothing on screen to travel from.
1100
1227
  cameraMotion.aim(camera, op.camera, elapsed, camera !== previous || !animating);
1228
+ cameraShaker.describe(op.camera.shake, elapsed);
1101
1229
  fitCamera(camera, viewport);
1102
1230
  return;
1103
1231
  }
@@ -1317,8 +1445,20 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1317
1445
  behaving.forEach(applyBehaviour);
1318
1446
  cameraMotion.step(camera, elapsed);
1319
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);
1320
1451
  // Last: a dynamic body's pose is the simulation's, whatever else moved the mesh.
1321
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
+ }
1322
1462
  };
1323
1463
 
1324
1464
  const finishTween = (tween: Tween, entry: Entry): void => {
@@ -1408,6 +1548,7 @@ export const createSceneGraph = (options: SceneGraphOptions = {}): SceneGraph =>
1408
1548
  clipsRunning() ||
1409
1549
  cameraMotion.moving() ||
1410
1550
  viewsMoving() ||
1551
+ shaking() ||
1411
1552
  bodyHost.needsFrame())),
1412
1553
  drawn: () => {
1413
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. */