@pmndrs/viverse 0.2.0 → 0.2.1

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 (48) hide show
  1. package/dist/animation/bone-map.d.ts +4 -0
  2. package/dist/animation/bone-map.js +11 -0
  3. package/dist/animation/default.d.ts +9 -1
  4. package/dist/animation/default.js +16 -9
  5. package/dist/animation/index.d.ts +8 -13
  6. package/dist/animation/index.js +61 -39
  7. package/dist/animation/mask.d.ts +4 -1
  8. package/dist/animation/mask.js +50 -0
  9. package/dist/camera.d.ts +6 -2
  10. package/dist/camera.js +22 -7
  11. package/dist/input/action.d.ts +41 -0
  12. package/dist/input/action.js +97 -0
  13. package/dist/input/index.d.ts +12 -26
  14. package/dist/input/index.js +17 -70
  15. package/dist/input/keyboard.d.ts +28 -5
  16. package/dist/input/keyboard.js +83 -69
  17. package/dist/input/pointer-capture.d.ts +5 -10
  18. package/dist/input/pointer-capture.js +9 -28
  19. package/dist/input/pointer-lock.d.ts +5 -10
  20. package/dist/input/pointer-lock.js +7 -28
  21. package/dist/input/screen-joystick.d.ts +11 -6
  22. package/dist/input/screen-joystick.js +31 -34
  23. package/dist/input/screen-jump-button.d.ts +2 -4
  24. package/dist/input/screen-jump-button.js +7 -12
  25. package/dist/model/index.d.ts +1 -2
  26. package/dist/simple-character/apply-input-options.d.ts +2 -0
  27. package/dist/simple-character/apply-input-options.js +28 -0
  28. package/dist/simple-character/index.d.ts +21 -6
  29. package/dist/simple-character/index.js +12 -17
  30. package/dist/simple-character/state/jump-down.js +2 -1
  31. package/dist/simple-character/state/jump-forward.js +4 -2
  32. package/dist/simple-character/state/jump-loop.js +2 -1
  33. package/dist/simple-character/state/jump-start.js +2 -2
  34. package/dist/simple-character/state/jump-up.js +4 -2
  35. package/dist/simple-character/state/movement.js +12 -8
  36. package/dist/simple-character/update-input-velocity.d.ts +1 -2
  37. package/dist/simple-character/update-input-velocity.js +4 -4
  38. package/dist/utils.d.ts +5 -5
  39. package/dist/utils.js +15 -6
  40. package/package.json +2 -2
  41. package/dist/animation/bvh.d.ts +0 -4
  42. package/dist/animation/bvh.js +0 -8
  43. package/dist/animation/fbx.d.ts +0 -4
  44. package/dist/animation/fbx.js +0 -8
  45. package/dist/animation/gltf.d.ts +0 -3
  46. package/dist/animation/gltf.js +0 -8
  47. package/dist/animation/vrma.d.ts +0 -3
  48. package/dist/animation/vrma.js +0 -8
@@ -1,18 +1,25 @@
1
- export async function loadDefaultCharacterAnimationUrl(type) {
2
- switch (type) {
3
- case 'idle':
1
+ export const IdleAnimationUrl = Symbol('idle-animation-url');
2
+ export const JumpUpAnimationUrl = Symbol('jump-up-animation-url');
3
+ export const JumpDownAnimationUrl = Symbol('jump-down-animation-url');
4
+ export const JumpForwardAnimationUrl = Symbol('jump-forward-animation-url');
5
+ export const JumpLoopAnimationUrl = Symbol('jump-loop-animation-url');
6
+ export const RunAnimationUrl = Symbol('run-animation-url');
7
+ export const WalkAnimationUrl = Symbol('walk-animation-url');
8
+ export async function resolveDefaultCharacterAnimationUrl(url) {
9
+ switch (url) {
10
+ case IdleAnimationUrl:
4
11
  return (await import('../assets/idle.js')).url;
5
- case 'jumpDown':
12
+ case JumpDownAnimationUrl:
6
13
  return (await import('../assets/jump-down.js')).url;
7
- case 'jumpForward':
14
+ case JumpForwardAnimationUrl:
8
15
  return (await import('../assets/jump-forward.js')).url;
9
- case 'jumpLoop':
16
+ case JumpLoopAnimationUrl:
10
17
  return (await import('../assets/jump-loop.js')).url;
11
- case 'jumpUp':
18
+ case JumpUpAnimationUrl:
12
19
  return (await import('../assets/jump-up.js')).url;
13
- case 'run':
20
+ case RunAnimationUrl:
14
21
  return (await import('../assets/run.js')).url;
15
- case 'walk':
22
+ case WalkAnimationUrl:
16
23
  return (await import('../assets/walk.js')).url;
17
24
  }
18
25
  }
@@ -1,17 +1,14 @@
1
1
  import { VRMHumanBoneName } from '@pixiv/three-vrm';
2
2
  import { AnimationClip, Object3D } from 'three';
3
- import { loadDefaultCharacterAnimationUrl } from './default.js';
4
- import type { CharacterModel } from '../model/index.js';
5
- export declare function fixModelAnimationClip(model: CharacterModel, clip: AnimationClip, clipScene: Object3D | undefined, removeXZMovement: boolean, boneMap?: Record<string, VRMHumanBoneName>): void;
6
- export * from './gltf.js';
7
- export * from './fbx.js';
8
- export * from './vrma.js';
3
+ import { DefaultUrl } from './default.js';
4
+ import { type CharacterAnimationMask } from './mask.js';
5
+ import { type CharacterModel } from '../model/index.js';
6
+ export declare function fixModelAnimationClip(model: CharacterModel, clip: AnimationClip, clipScene: Object3D | undefined, removeXZMovement: boolean): void;
9
7
  export * from './utils.js';
10
- export type CharacterAnimationMask = (boneName: VRMHumanBoneName) => boolean;
8
+ export * from './default.js';
9
+ export * from './mask.js';
11
10
  export type CharacterAnimationOptions = {
12
- url: string | {
13
- default: Parameters<typeof loadDefaultCharacterAnimationUrl>[0];
14
- };
11
+ url: string | DefaultUrl;
15
12
  type?: 'mixamo' | 'gltf' | 'vrma' | 'fbx' | 'bvh';
16
13
  removeXZMovement?: boolean;
17
14
  trimTime?: {
@@ -24,8 +21,6 @@ export type CharacterAnimationOptions = {
24
21
  };
25
22
  export type Tail<T extends any[]> = T extends [any, ...infer Rest] ? Rest : never;
26
23
  export declare function flattenCharacterAnimationOptions(options: Exclude<CharacterAnimationOptions, false>): Tail<Parameters<typeof loadCharacterAnimation>>;
27
- export declare function loadCharacterAnimation(model: CharacterModel, url: string | {
28
- default: Parameters<typeof loadDefaultCharacterAnimationUrl>[0];
29
- }, type?: CharacterAnimationOptions['type'], removeXZMovement?: boolean, trimStartTime?: number | undefined, trimEndTime?: number | undefined, boneMap?: Record<string, VRMHumanBoneName> | undefined, scaleTime?: number | undefined, mask?: CharacterAnimationMask): Promise<AnimationClip>;
24
+ export declare function loadCharacterAnimation(model: CharacterModel, url: string | DefaultUrl, type?: CharacterAnimationOptions['type'], removeXZMovement?: boolean, trimStartTime?: number | undefined, trimEndTime?: number | undefined, boneMap?: Record<string, VRMHumanBoneName> | undefined, scaleTime?: number | undefined, mask?: CharacterAnimationMask): Promise<AnimationClip>;
30
25
  export declare const mixamoBoneMap: Record<string, VRMHumanBoneName>;
31
26
  export declare const bvhBoneMap: Record<string, VRMHumanBoneName>;
@@ -1,13 +1,15 @@
1
1
  import { VRM } from '@pixiv/three-vrm';
2
2
  import { Euler, Quaternion, QuaternionKeyframeTrack, Vector3, VectorKeyframeTrack, } from 'three';
3
+ import { BVHLoader } from 'three/examples/jsm/loaders/BVHLoader.js';
4
+ import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
5
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
6
+ import { applyBoneMap } from './bone-map.js';
3
7
  import _bvhBoneMap from './bvh-bone-map.json';
4
- import { loadVrmModelBvhAnimations } from './bvh.js';
5
- import { loadDefaultCharacterAnimationUrl } from './default.js';
6
- import { loadVrmModelFbxAnimations } from './fbx.js';
7
- import { loadVrmModelGltfAnimations } from './gltf.js';
8
+ import { resolveDefaultCharacterAnimationUrl } from './default.js';
9
+ import { applyMask } from './mask.js';
8
10
  import _mixamoBoneMap from './mixamo-bone-map.json';
9
11
  import { scaleAnimationClipTime, trimAnimationClip } from './utils.js';
10
- import { loadVrmModelVrmaAnimations } from './vrma.js';
12
+ import { vrmaLoader } from '../model/index.js';
11
13
  //helper variables for the quaternion retargeting
12
14
  const baseThisLocalRestRotation_inverse = new Quaternion();
13
15
  const baseThisLocalCurrentRotation = new Quaternion();
@@ -21,11 +23,7 @@ const targetThisLocalCurrentRotation = new Quaternion();
21
23
  const position = new Vector3();
22
24
  const nonVrmRotationOffset = new Quaternion().setFromEuler(new Euler(0, Math.PI, 0));
23
25
  //TODO: currently assumes the model is not yet transformed - loaded for the first time
24
- export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement, boneMap) {
25
- const hipsClipBoneName = boneMap == null ? 'hips' : Object.entries(boneMap).find(([, vrmBoneName]) => vrmBoneName === 'hips')?.[0];
26
- if (hipsClipBoneName == null) {
27
- throw new Error('Failed to determine hips bone name for VRM animation. Please check the bone map or animation file.');
28
- }
26
+ export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement) {
29
27
  let restRoot;
30
28
  let restRootParent;
31
29
  if (!(model instanceof VRM)) {
@@ -39,24 +37,22 @@ export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement,
39
37
  let positionScale = 1;
40
38
  let clipSceneHips;
41
39
  if (clipScene != null) {
42
- clipSceneHips = clipScene.getObjectByName(hipsClipBoneName);
40
+ clipSceneHips = clipScene.getObjectByName('hips');
43
41
  clipSceneHips?.parent?.updateMatrixWorld();
44
42
  const vrmHipsPosition = model instanceof VRM
45
43
  ? model.humanoid.normalizedRestPose.hips?.position
46
44
  : model.scene.getObjectByName('rest_hips')?.getWorldPosition(new Vector3()).toArray();
47
- if (clipSceneHips == null || vrmHipsPosition == null) {
48
- throw new Error('Failed to load animation: missing animation hips object or VRM hips position.');
45
+ if (clipSceneHips != null && vrmHipsPosition != null) {
46
+ // Adjust with reference to hips height.
47
+ const motionHipsHeight = clipSceneHips.getWorldPosition(position).y;
48
+ const [_, vrmHipsHeight] = vrmHipsPosition;
49
+ positionScale = vrmHipsHeight / motionHipsHeight;
49
50
  }
50
- // Adjust with reference to hips height.
51
- const motionHipsHeight = clipSceneHips.getWorldPosition(position).y;
52
- const [_, vrmHipsHeight] = vrmHipsPosition;
53
- positionScale = vrmHipsHeight / motionHipsHeight;
54
51
  }
55
52
  for (const track of clip.tracks) {
56
53
  // Convert each tracks for VRM use, and push to `tracks`
57
54
  const [clipBoneName, propertyName] = track.name.split('.');
58
- const vrmBoneName = (boneMap?.[clipBoneName] ?? clipBoneName);
59
- const targetNormalizedBoneName = model instanceof VRM ? model.humanoid.getNormalizedBoneNode(vrmBoneName)?.name : vrmBoneName;
55
+ const targetNormalizedBoneName = model instanceof VRM ? model.humanoid.getNormalizedBoneNode(clipBoneName)?.name : clipBoneName;
60
56
  if (targetNormalizedBoneName == null) {
61
57
  continue;
62
58
  }
@@ -69,7 +65,7 @@ export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement,
69
65
  targetParentWorldBoneTransform = { rotation: [0, 0, 0, 1] };
70
66
  }
71
67
  else {
72
- const targetBone = model.scene.getObjectByName(`rest_${vrmBoneName}`);
68
+ const targetBone = model.scene.getObjectByName(`rest_${clipBoneName}`);
73
69
  if (targetBone != null) {
74
70
  targetLocalBoneTransform = { rotation: targetBone.quaternion.toArray() };
75
71
  }
@@ -123,7 +119,7 @@ export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement,
123
119
  targetThisLocalCurrentRotation.x *= -1;
124
120
  targetThisLocalCurrentRotation.z *= -1;
125
121
  }
126
- if (!(model instanceof VRM) && vrmBoneName === 'hips') {
122
+ if (!(model instanceof VRM) && clipBoneName === 'hips') {
127
123
  targetThisLocalCurrentRotation.premultiply(nonVrmRotationOffset);
128
124
  }
129
125
  targetThisLocalCurrentRotation.toArray(track.values, i);
@@ -131,7 +127,7 @@ export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement,
131
127
  track.name = trackName;
132
128
  }
133
129
  else if (track instanceof VectorKeyframeTrack) {
134
- if (vrmBoneName != 'hips' && vrmBoneName != 'root') {
130
+ if (clipBoneName != 'hips' && clipBoneName != 'root') {
135
131
  continue;
136
132
  }
137
133
  if (propertyName != 'position') {
@@ -140,7 +136,7 @@ export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement,
140
136
  for (let i = 0; i < track.values.length; i += 3) {
141
137
  position.fromArray(track.values, i);
142
138
  if (clipSceneHips?.parent != null) {
143
- if (vrmBoneName === 'hips') {
139
+ if (clipBoneName === 'hips') {
144
140
  position.applyMatrix4(clipSceneHips.parent.matrixWorld);
145
141
  }
146
142
  else {
@@ -148,7 +144,7 @@ export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement,
148
144
  }
149
145
  }
150
146
  position.multiplyScalar(positionScale);
151
- if (!(model instanceof VRM) && vrmBoneName === 'hips') {
147
+ if (!(model instanceof VRM) && clipBoneName === 'hips') {
152
148
  position.applyQuaternion(nonVrmRotationOffset);
153
149
  }
154
150
  if (model instanceof VRM) {
@@ -157,7 +153,7 @@ export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement,
157
153
  position.y *= -1;
158
154
  }
159
155
  }
160
- if (vrmBoneName === 'hips' && removeXZMovement) {
156
+ if (clipBoneName === 'hips' && removeXZMovement) {
161
157
  position.x = 0;
162
158
  position.z = 0;
163
159
  }
@@ -170,10 +166,9 @@ export function fixModelAnimationClip(model, clip, clipScene, removeXZMovement,
170
166
  restRoot.parent = restRootParent;
171
167
  }
172
168
  }
173
- export * from './gltf.js';
174
- export * from './fbx.js';
175
- export * from './vrma.js';
176
169
  export * from './utils.js';
170
+ export * from './default.js';
171
+ export * from './mask.js';
177
172
  export function flattenCharacterAnimationOptions(options) {
178
173
  return [
179
174
  options.url,
@@ -186,12 +181,17 @@ export function flattenCharacterAnimationOptions(options) {
186
181
  options.mask,
187
182
  ];
188
183
  }
184
+ const gltfLoader = new GLTFLoader();
185
+ const fbxLoader = new FBXLoader();
186
+ const bvhLoader = new BVHLoader();
189
187
  export async function loadCharacterAnimation(model, url, type, removeXZMovement = false, trimStartTime, trimEndTime, boneMap, scaleTime, mask) {
190
- if (typeof url === 'object') {
191
- url = await loadDefaultCharacterAnimationUrl(url.default);
188
+ if (typeof url === 'symbol') {
189
+ url = await resolveDefaultCharacterAnimationUrl(url);
192
190
  type = 'gltf';
193
191
  }
194
192
  let clips;
193
+ let clipScene;
194
+ let defaultBoneMap;
195
195
  if (type == null) {
196
196
  const lowerCaseUrl = url.toLocaleLowerCase();
197
197
  if (lowerCaseUrl.endsWith('.glb') || lowerCaseUrl.endsWith('.gltf')) {
@@ -211,29 +211,51 @@ export async function loadCharacterAnimation(model, url, type, removeXZMovement
211
211
  }
212
212
  }
213
213
  switch (type) {
214
- case 'gltf':
215
- clips = await loadVrmModelGltfAnimations(model, url, removeXZMovement, boneMap);
214
+ case 'gltf': {
215
+ const { animations, scene } = await gltfLoader.loadAsync(url);
216
+ clips = animations;
217
+ clipScene = scene;
216
218
  break;
217
- case 'fbx':
218
- clips = await loadVrmModelFbxAnimations(model, url, removeXZMovement, boneMap);
219
+ }
220
+ case 'fbx': {
221
+ const scene = await fbxLoader.loadAsync(url);
222
+ clips = scene.animations;
223
+ clipScene = scene;
219
224
  break;
220
- case 'bvh':
221
- clips = await loadVrmModelBvhAnimations(model, url, removeXZMovement, boneMap ?? bvhBoneMap);
225
+ }
226
+ case 'bvh': {
227
+ const { clip, skeleton } = await bvhLoader.loadAsync(url);
228
+ clips = [clip];
229
+ boneMap ??= bvhBoneMap;
222
230
  break;
223
- case 'mixamo':
224
- clips = await loadVrmModelFbxAnimations(model, url, removeXZMovement, boneMap ?? mixamoBoneMap);
231
+ }
232
+ case 'mixamo': {
233
+ const scene = await fbxLoader.loadAsync(url);
234
+ clips = scene.animations;
235
+ clipScene = scene;
236
+ boneMap ??= mixamoBoneMap;
225
237
  break;
238
+ }
226
239
  case 'vrma':
227
240
  if (!(model instanceof VRM)) {
228
241
  throw new Error(`Model must be an instance of VRM to load VRMA animations`);
229
242
  }
230
- clips = await loadVrmModelVrmaAnimations(model, url, removeXZMovement);
243
+ clips = (await vrmaLoader.loadAsync(url)).userData.vrmAnimations;
231
244
  break;
232
245
  }
233
246
  if (clips.length != 1) {
234
247
  throw new Error(`Expected exactly one animation clip, but got ${clips.length} for url ${url}`);
235
248
  }
236
249
  const [clip] = clips;
250
+ if (boneMap != null) {
251
+ applyBoneMap(clip, clipScene, boneMap);
252
+ }
253
+ if (mask != null) {
254
+ applyMask(clip, mask);
255
+ }
256
+ if (type != 'vrma') {
257
+ fixModelAnimationClip(model, clip, clipScene, removeXZMovement);
258
+ }
237
259
  if (trimStartTime != null || trimEndTime != null) {
238
260
  trimAnimationClip(clip, trimStartTime, trimEndTime);
239
261
  }
@@ -1,3 +1,6 @@
1
- import type { CharacterAnimationMask } from './index.js';
1
+ import type { VRMHumanBoneName } from '@pixiv/three-vrm';
2
2
  import type { AnimationClip } from 'three';
3
+ export type CharacterAnimationMask = (boneName: VRMHumanBoneName) => boolean;
3
4
  export declare function applyMask(clip: AnimationClip, mask: CharacterAnimationMask): void;
5
+ export declare const upperBody: CharacterAnimationMask;
6
+ export declare const lowerBody: CharacterAnimationMask;
@@ -1,3 +1,53 @@
1
1
  export function applyMask(clip, mask) {
2
2
  clip.tracks = clip.tracks.filter((track) => mask(track.name.split('.')[0]));
3
3
  }
4
+ const upperBodyParts = [
5
+ 'spine',
6
+ 'chest',
7
+ 'upperChest',
8
+ 'neck',
9
+ 'head',
10
+ 'leftEye',
11
+ 'rightEye',
12
+ 'jaw',
13
+ 'leftShoulder',
14
+ 'leftUpperArm',
15
+ 'leftLowerArm',
16
+ 'leftHand',
17
+ 'rightShoulder',
18
+ 'rightUpperArm',
19
+ 'rightLowerArm',
20
+ 'rightHand',
21
+ 'leftThumbMetacarpal',
22
+ 'leftThumbProximal',
23
+ 'leftThumbDistal',
24
+ 'leftIndexProximal',
25
+ 'leftIndexIntermediate',
26
+ 'leftIndexDistal',
27
+ 'leftMiddleProximal',
28
+ 'leftMiddleIntermediate',
29
+ 'leftMiddleDistal',
30
+ 'leftRingProximal',
31
+ 'leftRingIntermediate',
32
+ 'leftRingDistal',
33
+ 'leftLittleProximal',
34
+ 'leftLittleIntermediate',
35
+ 'leftLittleDistal',
36
+ 'rightThumbMetacarpal',
37
+ 'rightThumbProximal',
38
+ 'rightThumbDistal',
39
+ 'rightIndexProximal',
40
+ 'rightIndexIntermediate',
41
+ 'rightIndexDistal',
42
+ 'rightMiddleProximal',
43
+ 'rightMiddleIntermediate',
44
+ 'rightMiddleDistal',
45
+ 'rightRingProximal',
46
+ 'rightRingIntermediate',
47
+ 'rightRingDistal',
48
+ 'rightLittleProximal',
49
+ 'rightLittleIntermediate',
50
+ 'rightLittleDistal',
51
+ ];
52
+ export const upperBody = (name) => upperBodyParts.includes(name);
53
+ export const lowerBody = (name) => !upperBodyParts.includes(name);
package/dist/camera.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Object3D, Vector3, Vector3Tuple, Ray } from 'three';
2
- import { InputSystem } from './input/index.js';
3
2
  export declare const FirstPersonCharacterCameraBehavior: SimpleCharacterCameraBehaviorOptions;
4
3
  export type SimpleCharacterCameraBehaviorOptions = {
5
4
  /**
@@ -61,6 +60,10 @@ export declare class CharacterCameraBehavior {
61
60
  zoomDistance: number;
62
61
  private collisionFreeZoomDistance;
63
62
  private firstUpdate;
63
+ private readonly abortController;
64
+ private readonly yawReader;
65
+ private readonly pitchReader;
66
+ private readonly zoomReader;
64
67
  private setRotationFromDelta;
65
68
  private setDistanceFromDelta;
66
69
  private computeCharacterBaseOffset;
@@ -70,5 +73,6 @@ export declare class CharacterCameraBehavior {
70
73
  /**
71
74
  * @param delta in seconds
72
75
  */
73
- update(camera: Object3D, target: Object3D, inputSystem: InputSystem, deltaTime: number, raycast?: (ray: Ray, far: number) => number | undefined, options?: SimpleCharacterCameraBehaviorOptions): void;
76
+ update(camera: Object3D, target: Object3D, deltaTime: number, raycast?: (ray: Ray, far: number) => number | undefined, options?: SimpleCharacterCameraBehaviorOptions): void;
77
+ dispose(): void;
74
78
  }
package/dist/camera.js CHANGED
@@ -1,6 +1,6 @@
1
- import { Vector3, Euler, Ray } from 'three';
1
+ import { Vector3, Euler, Ray, Quaternion } from 'three';
2
2
  import { clamp } from 'three/src/math/MathUtils.js';
3
- import { DeltaYawField, DeltaPitchField, DeltaZoomField } from './input/index.js';
3
+ import { RotatePitchAction, RotateYawAction, ZoomAction } from './input/index.js';
4
4
  export const FirstPersonCharacterCameraBehavior = {
5
5
  characterBaseOffset: [0, 1.6, 0],
6
6
  zoom: { maxDistance: 0, minDistance: 0 },
@@ -11,6 +11,7 @@ const sphericalOffset = new Vector3();
11
11
  const characterWorldPosition = new Vector3();
12
12
  const euler = new Euler();
13
13
  const rayHelper = new Ray();
14
+ const quaternionHelper = new Quaternion();
14
15
  export class CharacterCameraBehavior {
15
16
  rotationPitch = (-20 * Math.PI) / 180;
16
17
  rotationYaw = 0;
@@ -18,6 +19,10 @@ export class CharacterCameraBehavior {
18
19
  //internal state
19
20
  collisionFreeZoomDistance = this.zoomDistance;
20
21
  firstUpdate = true;
22
+ abortController = new AbortController();
23
+ yawReader = RotateYawAction.createReader(this.abortController.signal);
24
+ pitchReader = RotatePitchAction.createReader(this.abortController.signal);
25
+ zoomReader = ZoomAction.createReader(this.abortController.signal);
21
26
  setRotationFromDelta(camera, delta, rotationOptions) {
22
27
  if (delta.lengthSq() < 0.0001) {
23
28
  // use current camera rotation if very close to target
@@ -55,7 +60,7 @@ export class CharacterCameraBehavior {
55
60
  /**
56
61
  * @param delta in seconds
57
62
  */
58
- update(camera, target, inputSystem, deltaTime, raycast, options = true) {
63
+ update(camera, target, deltaTime, raycast, options = true) {
59
64
  if (options === false) {
60
65
  this.firstUpdate = true;
61
66
  return;
@@ -66,6 +71,8 @@ export class CharacterCameraBehavior {
66
71
  }
67
72
  //compute character->camera delta through offset
68
73
  this.computeCharacterBaseOffset(chracterBaseOffsetHelper, options.characterBaseOffset);
74
+ target.getWorldQuaternion(quaternionHelper);
75
+ chracterBaseOffsetHelper.applyQuaternion(quaternionHelper);
69
76
  target.getWorldPosition(characterWorldPosition);
70
77
  characterWorldPosition.add(chracterBaseOffsetHelper);
71
78
  camera.getWorldPosition(deltaHelper);
@@ -75,8 +82,10 @@ export class CharacterCameraBehavior {
75
82
  if (!this.firstUpdate && rotationOptions !== false) {
76
83
  rotationOptions = rotationOptions === true ? {} : rotationOptions;
77
84
  const rotationSpeed = rotationOptions.speed ?? 1000.0;
78
- const deltaYaw = inputSystem.get(DeltaYawField);
79
- const deltaPitch = inputSystem.get(DeltaPitchField);
85
+ this.yawReader.update();
86
+ this.pitchReader.update();
87
+ const deltaYaw = this.yawReader.get();
88
+ const deltaPitch = this.pitchReader.get();
80
89
  this.rotationYaw = this.clampYaw(this.rotationYaw + deltaYaw * rotationSpeed * deltaTime, rotationOptions);
81
90
  this.rotationPitch = this.clampPitch(this.rotationPitch + deltaPitch * rotationSpeed * deltaTime, rotationOptions);
82
91
  }
@@ -92,7 +101,8 @@ export class CharacterCameraBehavior {
92
101
  if (!this.firstUpdate && zoomOptions !== false) {
93
102
  zoomOptions = zoomOptions === true ? {} : zoomOptions;
94
103
  const zoomSpeed = zoomOptions.speed ?? 1000.0;
95
- const deltaZoom = inputSystem.get(DeltaZoomField);
104
+ this.zoomReader.update();
105
+ const deltaZoom = this.zoomReader.get();
96
106
  const zoomFactor = 1 + deltaZoom * zoomSpeed * deltaTime;
97
107
  if (deltaZoom >= 0) {
98
108
  this.zoomDistance *= zoomFactor;
@@ -120,10 +130,15 @@ export class CharacterCameraBehavior {
120
130
  sphericalOffset.set(0, 0, this.collisionFreeZoomDistance);
121
131
  sphericalOffset.applyEuler(camera.rotation);
122
132
  // Get target position with offset (reuse helper vector)
123
- target.getWorldPosition(characterWorldPosition);
124
133
  this.computeCharacterBaseOffset(chracterBaseOffsetHelper, options.characterBaseOffset);
134
+ target.getWorldQuaternion(quaternionHelper);
135
+ chracterBaseOffsetHelper.applyQuaternion(quaternionHelper);
136
+ target.getWorldPosition(characterWorldPosition);
125
137
  characterWorldPosition.add(chracterBaseOffsetHelper);
126
138
  // Set camera position relative to target
127
139
  camera.position.copy(characterWorldPosition).add(sphericalOffset);
128
140
  }
141
+ dispose() {
142
+ this.abortController.abort();
143
+ }
129
144
  }
@@ -0,0 +1,41 @@
1
+ export declare class EventAction<T = void> {
2
+ private latestTime;
3
+ private readonly subscriptionListeners;
4
+ emit(value: T): void;
5
+ subscribe(callback: (value: T) => void, options?: {
6
+ once?: true;
7
+ signal?: AbortSignal;
8
+ }): void;
9
+ waitFor(signal?: AbortSignal): Promise<T>;
10
+ getLatestTime(): number;
11
+ }
12
+ export type StateActionWriter<T> = {
13
+ write(value: T): void;
14
+ };
15
+ /**
16
+ * StateAction keeps the latest state per writer and merges them on read.
17
+ * Values persist until the writer is disposed (abortSignal aborts).
18
+ */
19
+ export declare class StateAction<T> {
20
+ private readonly mergeWriters;
21
+ private readonly neutral;
22
+ private readonly absoluteActions;
23
+ constructor(mergeWriters: (...values: Array<T>) => T, neutral: T);
24
+ createWriter(abortSignal: AbortSignal): StateActionWriter<T>;
25
+ get(): T;
26
+ }
27
+ /**
28
+ * DeltaAction accumulates transient values each frame and clears them on frame advance.
29
+ * Multiple writes per frame from any source are combined using combine().
30
+ */
31
+ export declare class DeltaAction<T> {
32
+ private readonly combine;
33
+ private readonly neutral;
34
+ constructor(combine: (...values: Array<T>) => T, neutral: T);
35
+ private readonly readers;
36
+ write(value: T): void;
37
+ createReader(abortSignal: AbortSignal): {
38
+ update(): void;
39
+ get(): T;
40
+ };
41
+ }
@@ -0,0 +1,97 @@
1
+ // no external imports
2
+ export class EventAction {
3
+ latestTime = -Infinity;
4
+ subscriptionListeners = new Set();
5
+ emit(value) {
6
+ this.latestTime = performance.now() / 1000;
7
+ for (const listener of this.subscriptionListeners) {
8
+ listener(value);
9
+ }
10
+ }
11
+ subscribe(callback, options) {
12
+ const listener = (value) => {
13
+ if (options?.once === true) {
14
+ this.subscriptionListeners.delete(listener);
15
+ }
16
+ callback(value);
17
+ };
18
+ this.subscriptionListeners.add(listener);
19
+ if (options?.signal != null) {
20
+ options.signal.addEventListener('abort', () => this.subscriptionListeners.delete(listener), { once: true });
21
+ }
22
+ }
23
+ waitFor(signal) {
24
+ return new Promise((resolve) => this.subscribe(resolve, { once: true, signal }));
25
+ }
26
+ getLatestTime() {
27
+ return this.latestTime;
28
+ }
29
+ }
30
+ /**
31
+ * StateAction keeps the latest state per writer and merges them on read.
32
+ * Values persist until the writer is disposed (abortSignal aborts).
33
+ */
34
+ export class StateAction {
35
+ mergeWriters;
36
+ neutral;
37
+ absoluteActions = new Map();
38
+ constructor(mergeWriters, neutral) {
39
+ this.mergeWriters = mergeWriters;
40
+ this.neutral = neutral;
41
+ }
42
+ createWriter(abortSignal) {
43
+ const emitterUuid = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
44
+ abortSignal.addEventListener('abort', () => this.absoluteActions.delete(emitterUuid), { once: true });
45
+ return {
46
+ write: (value) => {
47
+ if (!abortSignal.aborted) {
48
+ this.absoluteActions.set(emitterUuid, value);
49
+ }
50
+ },
51
+ };
52
+ }
53
+ get() {
54
+ const values = [...this.absoluteActions.values()];
55
+ return values.length ? this.mergeWriters(...values) : this.neutral;
56
+ }
57
+ }
58
+ /**
59
+ * DeltaAction accumulates transient values each frame and clears them on frame advance.
60
+ * Multiple writes per frame from any source are combined using combine().
61
+ */
62
+ export class DeltaAction {
63
+ combine;
64
+ neutral;
65
+ constructor(combine, neutral) {
66
+ this.combine = combine;
67
+ this.neutral = neutral;
68
+ }
69
+ readers = new Set();
70
+ write(value) {
71
+ for (const reader of this.readers) {
72
+ reader.next.push(value);
73
+ }
74
+ }
75
+ createReader(abortSignal) {
76
+ const reader = { next: new Array(), current: new Array() };
77
+ this.readers.add(reader);
78
+ abortSignal.addEventListener('abort', () => {
79
+ this.readers.delete(reader);
80
+ }, { once: true });
81
+ return {
82
+ update: () => {
83
+ reader.current.length = 0;
84
+ if (reader.next.length) {
85
+ reader.current.push(...reader.next);
86
+ reader.next.length = 0;
87
+ }
88
+ },
89
+ get: () => {
90
+ if (reader.current.length === 0) {
91
+ return this.neutral;
92
+ }
93
+ return this.combine(...reader.current);
94
+ },
95
+ };
96
+ }
97
+ }
@@ -1,31 +1,17 @@
1
- export declare class InputSystem<T extends {} = {}> {
2
- options: T;
3
- readonly inputs: Array<Input>;
4
- constructor(options?: T);
5
- add(input: Input): void;
6
- remove(input: Input): void;
7
- dispose(): void;
8
- get<T>(field: InputField<T>): T;
9
- }
10
- export type InputField<T> = {
11
- default: T;
12
- combine: (v1: any, v2: any) => T;
13
- };
14
- export declare const MoveForwardField: InputField<number>;
15
- export declare const MoveBackwardField: InputField<number>;
16
- export declare const MoveLeftField: InputField<number>;
17
- export declare const MoveRightField: InputField<number>;
18
- export declare const LastTimeJumpPressedField: InputField<number | null>;
19
- export declare const RunField: InputField<boolean>;
20
- export declare const DeltaZoomField: InputField<number>;
21
- export declare const DeltaYawField: InputField<number>;
22
- export declare const DeltaPitchField: InputField<number>;
23
- export interface Input<O = {}> {
24
- get<T>(field: InputField<T>, options: O): T | undefined;
25
- dispose?(): void;
26
- }
1
+ import { EventAction, StateAction, DeltaAction } from './action.js';
2
+ export declare function BooleanOr(...values: Array<boolean>): boolean;
3
+ export declare const MoveForwardAction: StateAction<number>;
4
+ export declare const MoveBackwardAction: StateAction<number>;
5
+ export declare const MoveLeftAction: StateAction<number>;
6
+ export declare const MoveRightAction: StateAction<number>;
7
+ export declare const RunAction: StateAction<boolean>;
8
+ export declare const JumpAction: EventAction<void>;
9
+ export declare const ZoomAction: DeltaAction<number>;
10
+ export declare const RotateYawAction: DeltaAction<number>;
11
+ export declare const RotatePitchAction: DeltaAction<number>;
27
12
  export * from './pointer-lock.js';
28
13
  export * from './pointer-capture.js';
29
14
  export * from './keyboard.js';
30
15
  export * from './screen-joystick.js';
31
16
  export * from './screen-jump-button.js';
17
+ export * from './action.js';