@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.
- package/CHANGELOG.md +6 -1
- package/README.md +2 -1
- package/dist/{index-01kwfekm.js → index-8cf8t6by.js} +327 -24
- 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/pointer.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/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/pointer.ts +8 -0
- package/src/kernel/resources.ts +69 -0
- package/src/kernel/sceneGraph.ts +142 -1
- package/src/runtime/SceneSchema.ts +58 -4
- package/dist/index-01kwfekm.js.map +0 -40
package/src/kernel/build.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import type {
|
|
7
7
|
AmbientLightNode,
|
|
8
8
|
Animation,
|
|
9
|
+
CameraShake,
|
|
10
|
+
Detail,
|
|
9
11
|
BasicMaterial,
|
|
10
12
|
Body,
|
|
11
13
|
Behave,
|
|
@@ -40,6 +42,8 @@ import type {
|
|
|
40
42
|
ColliderDescription,
|
|
41
43
|
ColliderShapeDescription,
|
|
42
44
|
CapsuleGeometry,
|
|
45
|
+
RoundedBoxGeometry,
|
|
46
|
+
BackdropGeometry,
|
|
43
47
|
CircleGeometry,
|
|
44
48
|
ConeGeometry,
|
|
45
49
|
CylinderGeometry,
|
|
@@ -106,6 +110,21 @@ export const spin = (options: {
|
|
|
106
110
|
phase: options.phase ?? 0,
|
|
107
111
|
});
|
|
108
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Faces the camera every frame, as Drei's `Billboard` (`bf6f4ad`, `src/core/Billboard.tsx`):
|
|
115
|
+
* for labels, markers and sprites that should always read face on. Lock an axis to keep
|
|
116
|
+
* it still, `lockX` and `lockZ` together for something that stays upright and only turns
|
|
117
|
+
* round. To rotate it as well, put it in a rotated group.
|
|
118
|
+
*/
|
|
119
|
+
export const billboard = (
|
|
120
|
+
options: { readonly lockX?: boolean; readonly lockY?: boolean; readonly lockZ?: boolean } = {},
|
|
121
|
+
): Rotation => ({
|
|
122
|
+
_tag: 'Billboard',
|
|
123
|
+
lockX: options.lockX ?? false,
|
|
124
|
+
lockY: options.lockY ?? false,
|
|
125
|
+
lockZ: options.lockZ ?? false,
|
|
126
|
+
});
|
|
127
|
+
|
|
109
128
|
/** Box geometry. Each dimension defaults to 1. */
|
|
110
129
|
export const box = (
|
|
111
130
|
size: { readonly width?: number; readonly height?: number; readonly depth?: number } = {},
|
|
@@ -197,6 +216,47 @@ export const capsule = (
|
|
|
197
216
|
radialSegments: options.radialSegments ?? 8,
|
|
198
217
|
});
|
|
199
218
|
|
|
219
|
+
/**
|
|
220
|
+
* A box with rounded edges and corners, as Drei's `RoundedBox` (`bf6f4ad`,
|
|
221
|
+
* `src/core/RoundedBox.tsx`), with its defaults: 1 × 1 × 1, radius 0.05, smoothness 4,
|
|
222
|
+
* 4 bevel segments, 1 step, crease angle 0.4.
|
|
223
|
+
*/
|
|
224
|
+
export const roundedBox = (
|
|
225
|
+
options: {
|
|
226
|
+
readonly width?: number;
|
|
227
|
+
readonly height?: number;
|
|
228
|
+
readonly depth?: number;
|
|
229
|
+
readonly radius?: number;
|
|
230
|
+
readonly smoothness?: number;
|
|
231
|
+
readonly bevelSegments?: number;
|
|
232
|
+
readonly steps?: number;
|
|
233
|
+
readonly creaseAngle?: number;
|
|
234
|
+
} = {},
|
|
235
|
+
): RoundedBoxGeometry => ({
|
|
236
|
+
_tag: 'RoundedBox',
|
|
237
|
+
width: options.width ?? 1,
|
|
238
|
+
height: options.height ?? 1,
|
|
239
|
+
depth: options.depth ?? 1,
|
|
240
|
+
radius: options.radius ?? 0.05,
|
|
241
|
+
smoothness: options.smoothness ?? 4,
|
|
242
|
+
bevelSegments: options.bevelSegments ?? 4,
|
|
243
|
+
steps: options.steps ?? 1,
|
|
244
|
+
creaseAngle: options.creaseAngle ?? 0.4,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A curved photo backdrop, as Drei's `Backdrop` (`bf6f4ad`, `src/core/Backdrop.tsx`), with
|
|
249
|
+
* its defaults: floor 0.25, 20 segments. One unit each way: scale the mesh to size, and
|
|
250
|
+
* give it `receiveShadow` to catch shadows as Drei's prop does.
|
|
251
|
+
*/
|
|
252
|
+
export const backdrop = (
|
|
253
|
+
options: { readonly floor?: number; readonly segments?: number } = {},
|
|
254
|
+
): BackdropGeometry => ({
|
|
255
|
+
_tag: 'Backdrop',
|
|
256
|
+
floor: options.floor ?? 0.25,
|
|
257
|
+
segments: options.segments ?? 20,
|
|
258
|
+
});
|
|
259
|
+
|
|
200
260
|
/** Ring geometry facing +Z. Defaults (as three.js): radii 0.5 and 1, 32 segments. */
|
|
201
261
|
export const ring = (
|
|
202
262
|
options: {
|
|
@@ -478,6 +538,7 @@ export const group = (
|
|
|
478
538
|
readonly children?: ReadonlyArray<SceneNode>;
|
|
479
539
|
readonly body?: Body | null;
|
|
480
540
|
readonly follow?: XrSpace | null;
|
|
541
|
+
readonly detail?: Detail | null;
|
|
481
542
|
},
|
|
482
543
|
): GroupNode => ({
|
|
483
544
|
_tag: 'Group',
|
|
@@ -491,8 +552,20 @@ export const group = (
|
|
|
491
552
|
children: options.children ?? [],
|
|
492
553
|
body: options.body ?? null,
|
|
493
554
|
follow: options.follow ?? null,
|
|
555
|
+
detail: options.detail ?? null,
|
|
494
556
|
});
|
|
495
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Levels of detail for a group, as Drei's `Detailed` (`bf6f4ad`, `src/core/Detailed.tsx`):
|
|
560
|
+
* `group({ detail: detailed({ distances: [0, 10, 20] }), children: [near, middle, far] })`
|
|
561
|
+
* shows `near` until the camera is 10 away, then `middle`, then `far` from 20. Hysteresis
|
|
562
|
+
* defaults to 0, as Drei's.
|
|
563
|
+
*/
|
|
564
|
+
export const detailed = (options: {
|
|
565
|
+
readonly distances: ReadonlyArray<number>;
|
|
566
|
+
readonly hysteresis?: number;
|
|
567
|
+
}): Detail => ({ distances: options.distances, hysteresis: options.hysteresis ?? 0 });
|
|
568
|
+
|
|
496
569
|
/** The head (the viewer), for content that stays in view. */
|
|
497
570
|
export const headSpace = (): XrSpace => ({ _tag: 'Head' });
|
|
498
571
|
|
|
@@ -859,6 +932,8 @@ export const borrowed = (
|
|
|
859
932
|
readonly interactive?: boolean;
|
|
860
933
|
readonly animation?: Animation | null;
|
|
861
934
|
readonly body?: Body | null;
|
|
935
|
+
readonly castShadow?: boolean;
|
|
936
|
+
readonly receiveShadow?: boolean;
|
|
862
937
|
},
|
|
863
938
|
): BorrowedNode => ({
|
|
864
939
|
_tag: 'Borrowed',
|
|
@@ -872,6 +947,8 @@ export const borrowed = (
|
|
|
872
947
|
behaviour: options.behaviour ?? null,
|
|
873
948
|
interactive: options.interactive ?? false,
|
|
874
949
|
body: options.body ?? null,
|
|
950
|
+
castShadow: options.castShadow ?? false,
|
|
951
|
+
receiveShadow: options.receiveShadow ?? false,
|
|
875
952
|
});
|
|
876
953
|
|
|
877
954
|
/**
|
|
@@ -1060,6 +1137,7 @@ export const perspectiveCamera = (
|
|
|
1060
1137
|
lookAt: options.lookAt ?? origin,
|
|
1061
1138
|
damping: options.damping ?? 0,
|
|
1062
1139
|
autoRotate: options.autoRotate ?? 0,
|
|
1140
|
+
shake: options.shake ?? null,
|
|
1063
1141
|
});
|
|
1064
1142
|
|
|
1065
1143
|
/**
|
|
@@ -1077,6 +1155,26 @@ export const orthographicCamera = (
|
|
|
1077
1155
|
lookAt: options.lookAt ?? origin,
|
|
1078
1156
|
damping: options.damping ?? 0,
|
|
1079
1157
|
autoRotate: options.autoRotate ?? 0,
|
|
1158
|
+
shake: options.shake ?? null,
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* Camera shake, as Drei's `CameraShake` (`bf6f4ad`, `src/core/CameraShake.tsx`), with its
|
|
1163
|
+
* defaults: intensity 1, no decay (rate 0.65 a second when on), and 0.1 radians at 0.1 for
|
|
1164
|
+
* each of yaw, pitch and roll. `perspectiveCamera({ shake: cameraShake({ decay: true }) })`
|
|
1165
|
+
* shakes once and settles; bump `trigger` to shake again.
|
|
1166
|
+
*/
|
|
1167
|
+
export const cameraShake = (options: Partial<CameraShake> = {}): CameraShake => ({
|
|
1168
|
+
intensity: options.intensity ?? 1,
|
|
1169
|
+
decay: options.decay ?? false,
|
|
1170
|
+
decayRate: options.decayRate ?? 0.65,
|
|
1171
|
+
maxYaw: options.maxYaw ?? 0.1,
|
|
1172
|
+
maxPitch: options.maxPitch ?? 0.1,
|
|
1173
|
+
maxRoll: options.maxRoll ?? 0.1,
|
|
1174
|
+
yawFrequency: options.yawFrequency ?? 0.1,
|
|
1175
|
+
pitchFrequency: options.pitchFrequency ?? 0.1,
|
|
1176
|
+
rollFrequency: options.rollFrequency ?? 0.1,
|
|
1177
|
+
trigger: options.trigger ?? 0,
|
|
1080
1178
|
});
|
|
1081
1179
|
|
|
1082
1180
|
const noEffects: ReadonlyArray<PostEffect> = [];
|
|
@@ -94,6 +94,8 @@ export type CameraMotion = {
|
|
|
94
94
|
readonly hold: (held: boolean) => void;
|
|
95
95
|
/** Where turning has taken the camera, when it has turned since its description. */
|
|
96
96
|
readonly turned: () => Vec3 | undefined;
|
|
97
|
+
/** The point the camera looks at now, on its way to the aim. Not to be changed. */
|
|
98
|
+
readonly target: () => Vector3;
|
|
97
99
|
};
|
|
98
100
|
|
|
99
101
|
/** Radians a turning camera turns per second per unit of `autoRotate` (three's `2π / 60`). */
|
|
@@ -198,5 +200,6 @@ export const createCameraMotion = (): CameraMotion => {
|
|
|
198
200
|
position.setFromSpherical(goal.spherical).add(goal.target);
|
|
199
201
|
return [position.x, position.y, position.z];
|
|
200
202
|
},
|
|
203
|
+
target: () => current.target,
|
|
201
204
|
};
|
|
202
205
|
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Camera shake, as Drei's `CameraShake` (`bf6f4ad`, `src/core/CameraShake.tsx`): yaw, pitch
|
|
3
|
+
* and roll added to the camera's rotation from simplex noise, scaled by the square of the
|
|
4
|
+
* intensity. Here it is a pure function of scene time: the noise is seeded (Drei seeds
|
|
5
|
+
* from `Math.random`), and decay counts down from when the shake began, so the same scene
|
|
6
|
+
* time always shakes the same way, at any frame rate. Reduced motion holds it still.
|
|
7
|
+
*
|
|
8
|
+
* The camera always looks at its target here, so each frame it is aimed there again and
|
|
9
|
+
* the shake added on top: whatever moved the camera in between, nothing accumulates.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import type { OrthographicCamera, PerspectiveCamera, Vector3 } from 'three';
|
|
14
|
+
import { SimplexNoise } from 'three/addons/math/SimplexNoise.js';
|
|
15
|
+
|
|
16
|
+
import type { CameraShake } from './description.ts';
|
|
17
|
+
import { cameraShakeEquals } from './equality.ts';
|
|
18
|
+
|
|
19
|
+
/** A small seeded generator (mulberry32), so the noise is the same on every page load. */
|
|
20
|
+
const seeded = (seed: number): { readonly random: () => number } => {
|
|
21
|
+
let state = seed >>> 0;
|
|
22
|
+
return {
|
|
23
|
+
random: () => {
|
|
24
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
25
|
+
let t = state;
|
|
26
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
27
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
28
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const yawNoise = new SimplexNoise(seeded(1));
|
|
34
|
+
const pitchNoise = new SimplexNoise(seeded(2));
|
|
35
|
+
const rollNoise = new SimplexNoise(seeded(3));
|
|
36
|
+
|
|
37
|
+
/** The intensity at `seconds` after the shake began: Drei's, clamped to [0, 1], decaying. */
|
|
38
|
+
export const shakeIntensity = (shake: CameraShake, seconds: number): number => {
|
|
39
|
+
const decayed = shake.decay ? shake.intensity - shake.decayRate * seconds : shake.intensity;
|
|
40
|
+
return Math.min(1, Math.max(0, decayed));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type CameraShaker = {
|
|
44
|
+
/**
|
|
45
|
+
* The camera's shake as described at scene time `now`: one that first appears, changes
|
|
46
|
+
* or is triggered again begins then.
|
|
47
|
+
*/
|
|
48
|
+
readonly describe: (shake: CameraShake | null, now: number) => void;
|
|
49
|
+
/**
|
|
50
|
+
* Aims the camera at `target` and adds the shake at scene time `now`, or only aims it
|
|
51
|
+
* when the shake has ended or motion is reduced. Does nothing when nothing was added.
|
|
52
|
+
*/
|
|
53
|
+
readonly apply: (
|
|
54
|
+
camera: PerspectiveCamera | OrthographicCamera,
|
|
55
|
+
target: Vector3,
|
|
56
|
+
now: number,
|
|
57
|
+
animating: boolean,
|
|
58
|
+
) => void;
|
|
59
|
+
/** Whether the camera is shaking at scene time `now`, and so needs frames. */
|
|
60
|
+
readonly shaking: (now: number) => boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** One camera's shake. Allocation-free once created. */
|
|
64
|
+
export const createCameraShaker = (): CameraShaker => {
|
|
65
|
+
let current: CameraShake | null = null;
|
|
66
|
+
let since = 0;
|
|
67
|
+
// Whether the camera wears an offset that a still frame must take off again.
|
|
68
|
+
let offset = false;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
describe: (shake, now) => {
|
|
72
|
+
if (shake !== null && (current === null || !cameraShakeEquals(current, shake))) since = now;
|
|
73
|
+
current = shake;
|
|
74
|
+
},
|
|
75
|
+
apply: (camera, target, now, animating) => {
|
|
76
|
+
const shake = current;
|
|
77
|
+
const strength =
|
|
78
|
+
shake === null || !animating ? 0 : shakeIntensity(shake, Math.max(0, now - since)) ** 2;
|
|
79
|
+
if (shake === null || strength === 0) {
|
|
80
|
+
if (offset) camera.lookAt(target);
|
|
81
|
+
offset = false;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
camera.lookAt(target);
|
|
85
|
+
camera.rotation.set(
|
|
86
|
+
camera.rotation.x +
|
|
87
|
+
shake.maxPitch * strength * pitchNoise.noise(now * shake.pitchFrequency, 1),
|
|
88
|
+
camera.rotation.y + shake.maxYaw * strength * yawNoise.noise(now * shake.yawFrequency, 1),
|
|
89
|
+
camera.rotation.z +
|
|
90
|
+
shake.maxRoll * strength * rollNoise.noise(now * shake.rollFrequency, 1),
|
|
91
|
+
);
|
|
92
|
+
offset = true;
|
|
93
|
+
},
|
|
94
|
+
shaking: (now) =>
|
|
95
|
+
offset || (current !== null && shakeIntensity(current, Math.max(0, now - since)) > 0),
|
|
96
|
+
};
|
|
97
|
+
};
|
|
@@ -30,6 +30,17 @@ export type Rotation =
|
|
|
30
30
|
readonly axis: Axis;
|
|
31
31
|
readonly radiansPerSecond: number;
|
|
32
32
|
readonly phase: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Faces the scene's camera every frame, as Drei's `Billboard`: the node turns so its
|
|
36
|
+
* world rotation is the camera's. A locked axis stays at 0 in the node's own Euler
|
|
37
|
+
* angles (Drei keeps its previous value, which starts at 0).
|
|
38
|
+
*/
|
|
39
|
+
| {
|
|
40
|
+
readonly _tag: 'Billboard';
|
|
41
|
+
readonly lockX: boolean;
|
|
42
|
+
readonly lockY: boolean;
|
|
43
|
+
readonly lockZ: boolean;
|
|
33
44
|
};
|
|
34
45
|
|
|
35
46
|
/** Box geometry parameters. */
|
|
@@ -90,6 +101,36 @@ export type CapsuleGeometry = {
|
|
|
90
101
|
readonly radialSegments: number;
|
|
91
102
|
};
|
|
92
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A box with rounded edges and corners, centred, as Drei's `RoundedBox`: a rounded
|
|
106
|
+
* rectangle extruded and bevelled, with normals creased at `creaseAngle`.
|
|
107
|
+
*/
|
|
108
|
+
export type RoundedBoxGeometry = {
|
|
109
|
+
readonly _tag: 'RoundedBox';
|
|
110
|
+
readonly width: number;
|
|
111
|
+
readonly height: number;
|
|
112
|
+
readonly depth: number;
|
|
113
|
+
readonly radius: number;
|
|
114
|
+
/** Segments in each rounded corner of the outline. */
|
|
115
|
+
readonly smoothness: number;
|
|
116
|
+
/** Segments across the bevel, which Drei doubles. */
|
|
117
|
+
readonly bevelSegments: number;
|
|
118
|
+
readonly steps: number;
|
|
119
|
+
/** Faces meeting at more than this angle (radians) keep a hard edge. */
|
|
120
|
+
readonly creaseAngle: number;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A photo backdrop, as Drei's `Backdrop`: a floor that sweeps up into a wall behind it,
|
|
125
|
+
* one unit each way (scale it to size). The floor lies along +Z towards the viewer, the
|
|
126
|
+
* wall rises at its back, and `floor` extends the flat front.
|
|
127
|
+
*/
|
|
128
|
+
export type BackdropGeometry = {
|
|
129
|
+
readonly _tag: 'Backdrop';
|
|
130
|
+
readonly floor: number;
|
|
131
|
+
readonly segments: number;
|
|
132
|
+
};
|
|
133
|
+
|
|
93
134
|
/** A flat ring (annulus) in the XY plane, facing +Z. */
|
|
94
135
|
export type RingGeometry = {
|
|
95
136
|
readonly _tag: 'Ring';
|
|
@@ -150,7 +191,9 @@ export type ShapeGeometry =
|
|
|
150
191
|
| ConeGeometry
|
|
151
192
|
| CapsuleGeometry
|
|
152
193
|
| RingGeometry
|
|
153
|
-
| CircleGeometry
|
|
194
|
+
| CircleGeometry
|
|
195
|
+
| RoundedBoxGeometry
|
|
196
|
+
| BackdropGeometry;
|
|
154
197
|
|
|
155
198
|
/** Which faces a material draws. */
|
|
156
199
|
export type MaterialSide = 'front' | 'back' | 'double';
|
|
@@ -342,6 +385,22 @@ export type GroupNode = {
|
|
|
342
385
|
* not tracked (or no session presents). `null` for the group's own transform.
|
|
343
386
|
*/
|
|
344
387
|
readonly follow: XrSpace | null;
|
|
388
|
+
/**
|
|
389
|
+
* Levels of detail: the group shows one child at a time, chosen by the camera's
|
|
390
|
+
* distance (Drei's `Detailed`, three's `LOD`), or `null` to show them all.
|
|
391
|
+
*/
|
|
392
|
+
readonly detail: Detail | null;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Levels of detail for a group's children, in order: child `i` shows from
|
|
397
|
+
* `distances[i]` (scene units from the camera, divided by its zoom) until the next level's
|
|
398
|
+
* distance. `hysteresis` (a fraction) keeps a shown level until the camera is that much
|
|
399
|
+
* nearer than its distance, so the switch does not flicker at the boundary.
|
|
400
|
+
*/
|
|
401
|
+
export type Detail = {
|
|
402
|
+
readonly distances: ReadonlyArray<number>;
|
|
403
|
+
readonly hysteresis: number;
|
|
345
404
|
};
|
|
346
405
|
|
|
347
406
|
/** The joints of a tracked hand, as WebXR names them (`XRHandJoint`). */
|
|
@@ -656,6 +715,13 @@ export type BorrowedNode = {
|
|
|
656
715
|
readonly animation: Animation | null;
|
|
657
716
|
/** A rigid body whose colliders are the model's meshes, once it is lent; or `null`. */
|
|
658
717
|
readonly body: Body | null;
|
|
718
|
+
/**
|
|
719
|
+
* Every mesh in what it shows casts (receives) shadows, as Drei's `Clone` shorthands;
|
|
720
|
+
* `false` leaves each mesh's own setting. Set on the lent object itself where it is
|
|
721
|
+
* shown as lent rather than cloned.
|
|
722
|
+
*/
|
|
723
|
+
readonly castShadow: boolean;
|
|
724
|
+
readonly receiveShadow: boolean;
|
|
659
725
|
};
|
|
660
726
|
|
|
661
727
|
/** Where a label's origin is along its width: an edge, the middle, or a fraction of it. */
|
|
@@ -902,6 +968,8 @@ export type PerspectiveCamera = {
|
|
|
902
968
|
* (`autoRotated(camera, detail)`). A new position or `lookAt` turns on from there.
|
|
903
969
|
*/
|
|
904
970
|
readonly autoRotate: number;
|
|
971
|
+
/** Shakes the rendered camera (Drei's `CameraShake`), or `null` for none. */
|
|
972
|
+
readonly shake: CameraShake | null;
|
|
905
973
|
};
|
|
906
974
|
|
|
907
975
|
/**
|
|
@@ -919,6 +987,28 @@ export type OrthographicCamera = {
|
|
|
919
987
|
readonly damping: number;
|
|
920
988
|
/** As on `PerspectiveCamera`. */
|
|
921
989
|
readonly autoRotate: number;
|
|
990
|
+
/** As on `PerspectiveCamera`. */
|
|
991
|
+
readonly shake: CameraShake | null;
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Camera shake, as Drei's `CameraShake`: yaw, pitch and roll of up to `max*` radians,
|
|
996
|
+
* from noise at `*Frequency`, scaled by `intensity` squared (0 to 1). With `decay` the
|
|
997
|
+
* intensity falls by `decayRate` a second from when the shake began, so a decaying shake
|
|
998
|
+
* is a one-off: it begins when it first appears or changes, and `trigger` (a count the
|
|
999
|
+
* Model bumps) begins the same shake again. Reduced motion holds the camera still.
|
|
1000
|
+
*/
|
|
1001
|
+
export type CameraShake = {
|
|
1002
|
+
readonly intensity: number;
|
|
1003
|
+
readonly decay: boolean;
|
|
1004
|
+
readonly decayRate: number;
|
|
1005
|
+
readonly maxYaw: number;
|
|
1006
|
+
readonly maxPitch: number;
|
|
1007
|
+
readonly maxRoll: number;
|
|
1008
|
+
readonly yawFrequency: number;
|
|
1009
|
+
readonly pitchFrequency: number;
|
|
1010
|
+
readonly rollFrequency: number;
|
|
1011
|
+
readonly trigger: number;
|
|
922
1012
|
};
|
|
923
1013
|
|
|
924
1014
|
/** The single active camera. */
|
package/src/kernel/diff.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
labelMaterialEquals,
|
|
32
32
|
labelTypeEquals,
|
|
33
33
|
xrSpaceEquals,
|
|
34
|
+
detailEquals,
|
|
34
35
|
shaderGridLookEquals,
|
|
35
36
|
instancesEqual,
|
|
36
37
|
materialRefEquals,
|
|
@@ -82,7 +83,9 @@ export type Field =
|
|
|
82
83
|
/** A shader grid's look: anything but where it is. */
|
|
83
84
|
| 'grid'
|
|
84
85
|
/** The tracked space a group follows. */
|
|
85
|
-
| 'follow'
|
|
86
|
+
| 'follow'
|
|
87
|
+
/** A group's levels of detail. */
|
|
88
|
+
| 'detail';
|
|
86
89
|
|
|
87
90
|
/** A single change to apply to the hosted scene graph. */
|
|
88
91
|
export type Op =
|
|
@@ -131,6 +134,7 @@ export const changedFields = (
|
|
|
131
134
|
if (prev.interactive !== next.interactive) fields.push('interactive');
|
|
132
135
|
if (!bodyEquals(prev.body, next.body)) fields.push('body');
|
|
133
136
|
if (!xrSpaceEquals(prev.follow, next.follow)) fields.push('follow');
|
|
137
|
+
if (!detailEquals(prev.detail, next.detail)) fields.push('detail');
|
|
134
138
|
return fields;
|
|
135
139
|
}
|
|
136
140
|
case 'Mesh': {
|
|
@@ -175,6 +179,8 @@ export const changedFields = (
|
|
|
175
179
|
if (!animationEquals(prev.animation, next.animation)) fields.push('animation');
|
|
176
180
|
if (prev.interactive !== next.interactive) fields.push('interactive');
|
|
177
181
|
if (!bodyEquals(prev.body, next.body)) fields.push('body');
|
|
182
|
+
if (prev.castShadow !== next.castShadow) fields.push('castShadow');
|
|
183
|
+
if (prev.receiveShadow !== next.receiveShadow) fields.push('receiveShadow');
|
|
178
184
|
return fields;
|
|
179
185
|
}
|
|
180
186
|
case 'Label': {
|
package/src/kernel/equality.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import type {
|
|
8
8
|
Animation,
|
|
9
9
|
Behave,
|
|
10
|
+
CameraShake,
|
|
11
|
+
Detail,
|
|
10
12
|
Body,
|
|
11
13
|
BodyRef,
|
|
12
14
|
GeometryRef,
|
|
@@ -46,6 +48,17 @@ import type {
|
|
|
46
48
|
export const vec3Equals = (a: Vec3, b: Vec3): boolean =>
|
|
47
49
|
a === b || (Object.is(a[0], b[0]) && Object.is(a[1], b[1]) && Object.is(a[2], b[2]));
|
|
48
50
|
|
|
51
|
+
/** Levels-of-detail equality: the same distances and hysteresis. */
|
|
52
|
+
export const detailEquals = (a: Detail | null, b: Detail | null): boolean => {
|
|
53
|
+
if (a === b) return true;
|
|
54
|
+
if (a === null || b === null) return false;
|
|
55
|
+
return (
|
|
56
|
+
Object.is(a.hysteresis, b.hysteresis) &&
|
|
57
|
+
a.distances.length === b.distances.length &&
|
|
58
|
+
a.distances.every((distance, index) => Object.is(distance, b.distances[index]))
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
49
62
|
/** Rotation equality, including behaviour parameters. */
|
|
50
63
|
export const rotationEquals = (a: Rotation, b: Rotation): boolean => {
|
|
51
64
|
if (a === b) return true;
|
|
@@ -57,6 +70,9 @@ export const rotationEquals = (a: Rotation, b: Rotation): boolean => {
|
|
|
57
70
|
Object.is(a.phase, b.phase)
|
|
58
71
|
);
|
|
59
72
|
}
|
|
73
|
+
if (a._tag === 'Billboard' && b._tag === 'Billboard') {
|
|
74
|
+
return a.lockX === b.lockX && a.lockY === b.lockY && a.lockZ === b.lockZ;
|
|
75
|
+
}
|
|
60
76
|
return false;
|
|
61
77
|
};
|
|
62
78
|
|
|
@@ -120,6 +136,20 @@ export const shapeEquals = (a: ShapeGeometry, b: ShapeGeometry): boolean => {
|
|
|
120
136
|
);
|
|
121
137
|
case 'Circle':
|
|
122
138
|
return b._tag === 'Circle' && Object.is(a.radius, b.radius) && a.segments === b.segments;
|
|
139
|
+
case 'RoundedBox':
|
|
140
|
+
return (
|
|
141
|
+
b._tag === 'RoundedBox' &&
|
|
142
|
+
Object.is(a.width, b.width) &&
|
|
143
|
+
Object.is(a.height, b.height) &&
|
|
144
|
+
Object.is(a.depth, b.depth) &&
|
|
145
|
+
Object.is(a.radius, b.radius) &&
|
|
146
|
+
a.smoothness === b.smoothness &&
|
|
147
|
+
a.bevelSegments === b.bevelSegments &&
|
|
148
|
+
a.steps === b.steps &&
|
|
149
|
+
Object.is(a.creaseAngle, b.creaseAngle)
|
|
150
|
+
);
|
|
151
|
+
case 'Backdrop':
|
|
152
|
+
return b._tag === 'Backdrop' && Object.is(a.floor, b.floor) && a.segments === b.segments;
|
|
123
153
|
case 'Text':
|
|
124
154
|
return (
|
|
125
155
|
b._tag === 'Text' &&
|
|
@@ -223,6 +253,22 @@ export const materialRefEquals = (a: MaterialRef, b: MaterialRef): boolean => {
|
|
|
223
253
|
};
|
|
224
254
|
|
|
225
255
|
/** Camera equality: same kind, same lens and same placement. */
|
|
256
|
+
/** Camera shake equality: every setting, and the trigger count. */
|
|
257
|
+
export const cameraShakeEquals = (a: CameraShake | null, b: CameraShake | null): boolean =>
|
|
258
|
+
a === b ||
|
|
259
|
+
(a !== null &&
|
|
260
|
+
b !== null &&
|
|
261
|
+
Object.is(a.intensity, b.intensity) &&
|
|
262
|
+
a.decay === b.decay &&
|
|
263
|
+
Object.is(a.decayRate, b.decayRate) &&
|
|
264
|
+
Object.is(a.maxYaw, b.maxYaw) &&
|
|
265
|
+
Object.is(a.maxPitch, b.maxPitch) &&
|
|
266
|
+
Object.is(a.maxRoll, b.maxRoll) &&
|
|
267
|
+
Object.is(a.yawFrequency, b.yawFrequency) &&
|
|
268
|
+
Object.is(a.pitchFrequency, b.pitchFrequency) &&
|
|
269
|
+
Object.is(a.rollFrequency, b.rollFrequency) &&
|
|
270
|
+
Object.is(a.trigger, b.trigger));
|
|
271
|
+
|
|
226
272
|
export const cameraEquals = (a: Camera, b: Camera): boolean => {
|
|
227
273
|
if (a === b) return true;
|
|
228
274
|
const sameLens =
|
|
@@ -234,6 +280,7 @@ export const cameraEquals = (a: Camera, b: Camera): boolean => {
|
|
|
234
280
|
Object.is(a.far, b.far) &&
|
|
235
281
|
Object.is(a.damping, b.damping) &&
|
|
236
282
|
Object.is(a.autoRotate, b.autoRotate) &&
|
|
283
|
+
cameraShakeEquals(a.shake, b.shake) &&
|
|
237
284
|
vec3Equals(a.position, b.position) &&
|
|
238
285
|
vec3Equals(a.lookAt, b.lookAt)
|
|
239
286
|
);
|
package/src/kernel/index.ts
CHANGED
|
@@ -96,12 +96,17 @@ export {
|
|
|
96
96
|
pointLight,
|
|
97
97
|
renderSettings,
|
|
98
98
|
ring,
|
|
99
|
+
roundedBox,
|
|
100
|
+
backdrop,
|
|
99
101
|
scene,
|
|
100
102
|
sharedGeometry,
|
|
101
103
|
lentGeometry,
|
|
102
104
|
sharedMaterial,
|
|
103
105
|
sphere,
|
|
104
106
|
spin,
|
|
107
|
+
cameraShake,
|
|
108
|
+
detailed,
|
|
109
|
+
billboard,
|
|
105
110
|
spotLight,
|
|
106
111
|
standard,
|
|
107
112
|
overlay,
|
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/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
|
|