@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.
- package/CHANGELOG.md +16 -0
- package/README.md +3 -4
- package/dist/{index-ef8pbv9g.js → index-8cf8t6by.js} +328 -25
- package/dist/index-8cf8t6by.js.map +41 -0
- package/dist/index.js +61 -8
- package/dist/index.js.map +3 -3
- package/dist/kernel/behaviours.d.ts.map +1 -1
- package/dist/kernel/build.d.ts +56 -1
- package/dist/kernel/build.d.ts.map +1 -1
- package/dist/kernel/cameraMotion.d.ts +3 -1
- package/dist/kernel/cameraMotion.d.ts.map +1 -1
- package/dist/kernel/cameraShake.d.ts +33 -0
- package/dist/kernel/cameraShake.d.ts.map +1 -0
- package/dist/kernel/description.d.ts +85 -1
- package/dist/kernel/description.d.ts.map +1 -1
- package/dist/kernel/diff.d.ts +3 -1
- package/dist/kernel/diff.d.ts.map +1 -1
- package/dist/kernel/equality.d.ts +5 -1
- package/dist/kernel/equality.d.ts.map +1 -1
- package/dist/kernel/index.d.ts +1 -1
- package/dist/kernel/index.d.ts.map +1 -1
- package/dist/kernel/index.js +12 -2
- package/dist/kernel/index.js.map +1 -1
- package/dist/kernel/labels.d.ts +6 -0
- package/dist/kernel/labels.d.ts.map +1 -1
- package/dist/kernel/pointer.d.ts.map +1 -1
- package/dist/kernel/renderKit.d.ts +5 -4
- package/dist/kernel/renderKit.d.ts.map +1 -1
- package/dist/kernel/resources.d.ts.map +1 -1
- package/dist/kernel/sceneGraph.d.ts +0 -1
- package/dist/kernel/sceneGraph.d.ts.map +1 -1
- package/dist/runtime/SceneSchema.d.ts +296 -5
- package/dist/runtime/SceneSchema.d.ts.map +1 -1
- package/dist/webgl/index.js +2 -2
- package/dist/webgl/index.js.map +3 -3
- package/dist/webgpu/index.js +2 -2
- package/dist/webgpu/index.js.map +3 -3
- package/package.json +1 -1
- package/src/kernel/behaviours.ts +5 -0
- package/src/kernel/bodySources.ts +1 -1
- package/src/kernel/build.ts +98 -0
- package/src/kernel/cameraMotion.ts +3 -0
- package/src/kernel/cameraShake.ts +97 -0
- package/src/kernel/description.ts +91 -1
- package/src/kernel/diff.ts +7 -1
- package/src/kernel/equality.ts +47 -0
- package/src/kernel/index.ts +5 -0
- package/src/kernel/labels.ts +6 -0
- package/src/kernel/pointer.ts +8 -0
- package/src/kernel/renderKit.ts +5 -4
- package/src/kernel/resources.ts +69 -0
- package/src/kernel/sceneGraph.ts +146 -3
- package/src/runtime/SceneSchema.ts +58 -4
- package/src/webgl/index.ts +1 -1
- package/src/webgpu/index.ts +2 -2
- package/dist/index-ef8pbv9g.js.map +0 -40
package/src/kernel/pointer.ts
CHANGED
|
@@ -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) {
|
package/src/kernel/renderKit.ts
CHANGED
|
@@ -42,11 +42,12 @@ export type ShadowedScene = {
|
|
|
42
42
|
/** A renderer's shaded parts. */
|
|
43
43
|
export type RenderKit = {
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
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;
|
package/src/kernel/resources.ts
CHANGED
|
@@ -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
|
|
package/src/kernel/sceneGraph.ts
CHANGED
|
@@ -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
|
-
//
|
|
758
|
+
// The engine's implementation for the renderer's kind of material, if it has one.
|
|
648
759
|
const hosted =
|
|
649
|
-
options.kit?.
|
|
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
|
|
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,
|
|
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. */
|
package/src/webgl/index.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
+
shading: 'webgl',
|
|
16
16
|
sky: hostSky,
|
|
17
17
|
shaderGrid: hostShaderGrid,
|
|
18
18
|
contactShadows: createContactShadows,
|
package/src/webgpu/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
15
|
-
|
|
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,
|