@waica/engine 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +48 -3
  2. package/dist/camera-rig.d.ts +2 -0
  3. package/dist/camera-rig.js +4 -0
  4. package/dist/components/animator-blends.d.ts +54 -0
  5. package/dist/components/animator-blends.js +86 -0
  6. package/dist/components/animator-mix.d.ts +53 -0
  7. package/dist/components/animator-mix.js +92 -0
  8. package/dist/components/animator-params.d.ts +21 -0
  9. package/dist/components/animator-params.js +50 -0
  10. package/dist/components/animator-sources.d.ts +59 -0
  11. package/dist/components/animator-sources.js +125 -0
  12. package/dist/components/animator.d.ts +84 -10
  13. package/dist/components/animator.js +163 -47
  14. package/dist/components/model.d.ts +17 -1
  15. package/dist/components/model.js +49 -3
  16. package/dist/components/point-light.d.ts +38 -3
  17. package/dist/components/point-light.js +6 -3
  18. package/dist/components/shadow-light.d.ts +63 -0
  19. package/dist/components/shadow-light.js +71 -0
  20. package/dist/components/sun.d.ts +38 -3
  21. package/dist/components/sun.js +6 -3
  22. package/dist/entity.js +3 -0
  23. package/dist/game-shadows.d.ts +54 -0
  24. package/dist/game-shadows.js +260 -0
  25. package/dist/game.d.ts +3 -0
  26. package/dist/game.js +15 -2
  27. package/dist/index.d.ts +7 -4
  28. package/dist/index.js +3 -1
  29. package/dist/point-shadow.d.ts +14 -0
  30. package/dist/point-shadow.js +54 -0
  31. package/dist/runtime-lighting-snapshot.d.ts +9 -1
  32. package/dist/runtime-lighting-snapshot.js +24 -4
  33. package/dist/scene-shadows.d.ts +82 -0
  34. package/dist/scene-shadows.js +151 -0
  35. package/dist/scene.d.ts +11 -1
  36. package/dist/scene.js +2 -1
  37. package/dist/shadow-renderer.d.ts +8 -0
  38. package/dist/shadow-renderer.js +37 -0
  39. package/dist/sun-shadow-camera.d.ts +17 -0
  40. package/dist/sun-shadow-camera.js +67 -0
  41. package/package.json +1 -1
@@ -0,0 +1,125 @@
1
+ import * as THREE from 'three/webgpu';
2
+ /** A clip of the file as one sound of the mix: its own action, advanced by the mixer at its `speed`. */
3
+ export class ClipSource {
4
+ name;
5
+ action;
6
+ constructor(name, action) {
7
+ this.name = name;
8
+ this.action = action;
9
+ }
10
+ /** A `loop: false` clip clamps when it ends, which three marks by pausing it: nothing else pauses an action here. */
11
+ get finished() {
12
+ return this.action.loop === THREE.LoopOnce && this.action.paused;
13
+ }
14
+ get time() {
15
+ return this.action.time;
16
+ }
17
+ get progress() {
18
+ const { duration } = this.action.getClip();
19
+ return duration > 0 ? Math.min(this.action.time / duration, 1) : 1;
20
+ }
21
+ get playing() {
22
+ return this.action.isRunning();
23
+ }
24
+ start() {
25
+ this.action.reset().play();
26
+ }
27
+ stop() {
28
+ this.action.stop();
29
+ }
30
+ apply(weight) {
31
+ this.action.setEffectiveWeight(weight);
32
+ }
33
+ addWeights(weight, into) {
34
+ into[this.name] = (into[this.name] ?? 0) + weight;
35
+ }
36
+ }
37
+ /**
38
+ * A blend as one sound of the mix (issue #205, ADR 0033): its points share
39
+ * one normalized phase, advanced by the step over the cycle interpolated by
40
+ * the points' shares, so walk and run put their feet down together; each
41
+ * point's action sits at that phase of its own clip, weighted by its share.
42
+ */
43
+ export class BlendSource {
44
+ name;
45
+ points;
46
+ value;
47
+ phase = 0;
48
+ constructor(name, points, value) {
49
+ this.name = name;
50
+ this.points = points;
51
+ this.value = value;
52
+ }
53
+ /** A blend loops: it never finishes. */
54
+ get finished() {
55
+ return false;
56
+ }
57
+ /** The phase as seconds of the interpolated cycle. */
58
+ get time() {
59
+ return this.phase * this.cycle(blendShares(this.points, this.value()));
60
+ }
61
+ get progress() {
62
+ return this.phase;
63
+ }
64
+ get playing() {
65
+ return true;
66
+ }
67
+ start() {
68
+ this.phase = 0;
69
+ for (const point of this.points)
70
+ point.action.reset().play();
71
+ }
72
+ stop() {
73
+ for (const point of this.points)
74
+ point.action.stop();
75
+ }
76
+ apply(weight, dt) {
77
+ const shares = blendShares(this.points, this.value());
78
+ const cycle = this.cycle(shares);
79
+ if (cycle > 0) {
80
+ this.phase += dt / cycle;
81
+ this.phase -= Math.floor(this.phase);
82
+ }
83
+ for (const [index, point] of this.points.entries()) {
84
+ point.action.time = this.phase * point.duration;
85
+ point.action.setEffectiveWeight(weight * (shares[index] ?? 0));
86
+ }
87
+ }
88
+ addWeights(weight, into) {
89
+ const shares = blendShares(this.points, this.value());
90
+ for (const [index, point] of this.points.entries()) {
91
+ const share = shares[index] ?? 0;
92
+ if (share > 0)
93
+ into[point.clip] = (into[point.clip] ?? 0) + weight * share;
94
+ }
95
+ }
96
+ cycle(shares) {
97
+ return this.points.reduce((sum, point, index) => sum + (shares[index] ?? 0) * point.cycle, 0);
98
+ }
99
+ }
100
+ /**
101
+ * Each point's share of a 1D blend at `value`: the two points around it,
102
+ * weighted linearly by where it falls between their thresholds; past either
103
+ * end, that end alone. `points` are in threshold order.
104
+ */
105
+ export function blendShares(points, value) {
106
+ const shares = points.map(() => 0);
107
+ const last = points.length - 1;
108
+ const first = points[0];
109
+ const final = points[last];
110
+ if (!first || !final)
111
+ return shares;
112
+ if (value <= first.at)
113
+ shares[0] = 1;
114
+ else if (value >= final.at)
115
+ shares[last] = 1;
116
+ else {
117
+ const index = points.findIndex((point, i) => i < last && value < (points[i + 1]?.at ?? point.at));
118
+ const low = points[index]?.at ?? first.at;
119
+ const high = points[index + 1]?.at ?? low;
120
+ const t = high > low ? (value - low) / (high - low) : 1;
121
+ shares[index] = 1 - t;
122
+ shares[index + 1] = t;
123
+ }
124
+ return shares;
125
+ }
@@ -1,11 +1,16 @@
1
1
  import type { ClipTarget } from '../animation/clip-target.js';
2
2
  import { Component, type ComponentSpace } from '../component.js';
3
- /** Per-clip overrides, keyed by the glTF's clip name: a one-shot (`loop: false`) and a playback rate. */
3
+ import { type AnimatorBlend } from './animator-blends.js';
4
+ export type { AnimatorBlend, AnimatorBlendPoint } from './animator-blends.js';
5
+ export { DEFAULT_BLEND_SMOOTHING } from './animator-blends.js';
6
+ /** Per-clip overrides, keyed by the glTF's clip name: a one-shot (`loop: false`), a playback rate and a fade-in. */
4
7
  export interface AnimatorClipOverride {
5
8
  /** Defaults to true; false plays the clip once and holds its last pose. */
6
9
  loop?: boolean;
7
10
  /** Playback rate; 1 by default, 2 finishes in half the time. */
8
11
  speed?: number;
12
+ /** Seconds of fade into this clip (issue #205); without it, the Animator's `fade`. 0 cuts. */
13
+ fade?: number;
9
14
  }
10
15
  /** What `inspect_runtime` shows of an Animator. */
11
16
  export interface AnimatorState {
@@ -18,16 +23,23 @@ export interface AnimatorState {
18
23
  playing: boolean;
19
24
  /** The pose fingerprint of the rig (see poseHash); empty with no skeleton. */
20
25
  poseHash: string;
26
+ /** Each glb clip's effective weight, only those above 0, the ones fading out included (issue #205). */
27
+ weights: Record<string, number>;
28
+ /** The smoothed value of every parameter the blends read or `setParam` wrote. */
29
+ params: Record<string, number>;
21
30
  }
22
31
  /**
23
- * Plays the glTF clips of the Model beside it (issue #166, ADR 0029): the
24
- * 3D Clip Target. One AnimationMixer over the Model's own clone, one action
32
+ * Plays the glTF clips of the Model beside it (issue #166, ADR 0029; mixing,
33
+ * issue #205, ADR 0033): the 3D Clip Target. One AnimationMixer over the Model's own clone, one action
25
34
  * per clip, advanced by the Simulation Step in onUpdate — nothing runs per
26
35
  * render frame, so the pose is deterministic and visible step by step. A
27
36
  * StateMachine finds it like it finds an AnimatedSprite and plays the clip of
28
37
  * each state; `clip:end` fires when a `loop: false` clip finishes. A `play`
29
38
  * before the model loaded is remembered and applied when it settles;
30
- * otherwise `initialClip` plays then. In a 2D scene it creates nothing.
39
+ * otherwise `initialClip` plays then. A fade into a clip lasts its own
40
+ * `clips[name].fade` or `fade`, every sounding clip leaving from the weight
41
+ * it has; `blends` are virtual clips mixing several by a `setParam` value.
42
+ * In a 2D scene it creates nothing.
31
43
  */
32
44
  export declare class Animator extends Component implements ClipTarget {
33
45
  static componentName: string;
@@ -51,18 +63,30 @@ export declare class Animator extends Component implements ClipTarget {
51
63
  fade: number;
52
64
  /**
53
65
  * Per-clip overrides by glTF clip name (authored in JSON, like
54
- * AnimatedSprite.clips). Read when the actions are built, as the model
55
- * settles: a change after that applies on the next build (a Model rebuild).
66
+ * AnimatedSprite.clips). `loop` and `speed` are read when the actions are
67
+ * built, as the model settles, so a change after that applies on the next
68
+ * build (a Model rebuild); `fade` is read at every `play`.
56
69
  */
57
70
  clips: Record<string, AnimatorClipOverride>;
71
+ /**
72
+ * 1D blends by name (`AnimatorBlend`, authored in JSON like `clips`): each
73
+ * is a clip `play` accepts. Its `param` and `points` are read when the
74
+ * actions are built, as the model settles; its `smoothing` every step.
75
+ */
76
+ blends: Record<string, AnimatorBlend>;
58
77
  /** The clip playing now; undefined before the first play. */
59
78
  current: string | undefined;
60
79
  private mixer;
61
- private readonly actions;
62
- private active;
80
+ /** What `play` accepts, by name, for the current build: the file's clips, then the blends. */
81
+ private readonly sources;
82
+ /** The fades between them. */
83
+ private readonly mix;
84
+ /** The named parameters the blends read; they outlive a rebuild. */
85
+ private readonly params;
86
+ /** Each blend the current build made playable, by name, and the parameter it reads; null before a build. */
87
+ private built;
63
88
  /** A play asked for before the model loaded. */
64
89
  private requested;
65
- private finished;
66
90
  private root;
67
91
  private readonly warned;
68
92
  private attached;
@@ -82,11 +106,61 @@ export declare class Animator extends Component implements ClipTarget {
82
106
  */
83
107
  get clipProgress(): number;
84
108
  onReady(): void;
85
- /** Plays a clip: a no-op for the running one, a restart for a finished one, a crossfade from any other over `fade` seconds. */
109
+ /**
110
+ * Plays a clip: a no-op for the running one, a restart for a finished one,
111
+ * and from any other a fade over the clip's own `clips[name].fade`, else
112
+ * `fade` seconds, every sounding clip leaving from the weight it has (ADR 0033).
113
+ */
86
114
  play(name: string): void;
115
+ /**
116
+ * Aims a named parameter at `value`; the blends reading it follow, smoothed
117
+ * by the first blend that names it (once built, the first one the build made
118
+ * playable). Kept when made before the model loads;
119
+ * a value that is not a finite number is ignored with one warning.
120
+ */
121
+ setParam(name: string, value: number): void;
87
122
  onUpdate(dt: number): void;
123
+ /**
124
+ * One step of the parameters, the fades and the mixer while the Game does
125
+ * not simulate (issue #205): the editor's edit-mode preview of a blend
126
+ * drives the selected entity's Animator alone through it, once per frame,
127
+ * following its Model as onUpdate does. In Play it does nothing: onUpdate
128
+ * steps the Animator then.
129
+ */
130
+ previewStep(dt: number): void;
131
+ /**
132
+ * Ends an edit-mode preview (issue #205) as a freshly built stage starts:
133
+ * no parameter written, every clip stopped (three puts the rig back in the
134
+ * pose its file was built in) and `initialClip`, if any, asked for again
135
+ * from its start. In Play, or before the model loads, it does nothing.
136
+ */
137
+ previewReset(): void;
88
138
  onDestroy(): void;
89
139
  inspectState(): AnimatorState;
140
+ /** One step of the parameters, then of the fades, then of the mixer. */
141
+ private advance;
142
+ /** The smoothing of the first blend that reads `param`; none without one. */
143
+ private smoothingOf;
144
+ /** The parameters the blends read, in declaration order. */
145
+ private blendParams;
146
+ /**
147
+ * Each blend's name and the parameter it reads: once built, only the blends
148
+ * the build made playable (one it dropped decides nothing); before, every
149
+ * authored blend that names a parameter.
150
+ */
151
+ private blendReaders;
152
+ /** The authored blends by name; a `blends` that is not an object (JSON props arrive unchecked) warns once and counts as none. */
153
+ private authoredBlends;
154
+ /** The fade into `name`: its own `clips[name].fade` when that is a finite number ≥ 0, else `fade`. */
155
+ private fadeInto;
156
+ /**
157
+ * Keeps the mixer on the Model's current clone. A rebuilt Model (a new src
158
+ * or shape) draws a new clone: the mixer over the old one would animate a
159
+ * graph nobody draws, so it is torn down and the clip it was playing is
160
+ * asked for again on the new one. A Model listed after the Animator, which
161
+ * onReady could not see, is attached here.
162
+ */
163
+ private syncModel;
90
164
  /**
91
165
  * Takes the sibling Model's handle and builds the mixer when it settles. A
92
166
  * Model later in the component order is not there at onReady, so the first
@@ -2,16 +2,24 @@ import * as THREE from 'three/webgpu';
2
2
  import { poseHash } from '../animation/pose-hash.js';
3
3
  import { Component } from '../component.js';
4
4
  import { reportRejection } from '../report-rejection.js';
5
+ import { blendReaders, blendSmoothing, blendsRecord, buildBlendSources } from './animator-blends.js';
6
+ export { DEFAULT_BLEND_SMOOTHING } from './animator-blends.js';
7
+ import { AnimatorMix } from './animator-mix.js';
8
+ import { AnimatorParams } from './animator-params.js';
9
+ import { ClipSource } from './animator-sources.js';
5
10
  import { Model } from './model.js';
6
11
  /**
7
- * Plays the glTF clips of the Model beside it (issue #166, ADR 0029): the
8
- * 3D Clip Target. One AnimationMixer over the Model's own clone, one action
12
+ * Plays the glTF clips of the Model beside it (issue #166, ADR 0029; mixing,
13
+ * issue #205, ADR 0033): the 3D Clip Target. One AnimationMixer over the Model's own clone, one action
9
14
  * per clip, advanced by the Simulation Step in onUpdate — nothing runs per
10
15
  * render frame, so the pose is deterministic and visible step by step. A
11
16
  * StateMachine finds it like it finds an AnimatedSprite and plays the clip of
12
17
  * each state; `clip:end` fires when a `loop: false` clip finishes. A `play`
13
18
  * before the model loaded is remembered and applied when it settles;
14
- * otherwise `initialClip` plays then. In a 2D scene it creates nothing.
19
+ * otherwise `initialClip` plays then. A fade into a clip lasts its own
20
+ * `clips[name].fade` or `fade`, every sounding clip leaving from the weight
21
+ * it has; `blends` are virtual clips mixing several by a `setParam` value.
22
+ * In a 2D scene it creates nothing.
15
23
  */
16
24
  export class Animator extends Component {
17
25
  static componentName = 'Animator';
@@ -21,25 +29,37 @@ export class Animator extends Component {
21
29
  initialClip: { label: 'Initial clip' },
22
30
  fade: { label: 'Fade (s)', min: 0, max: 1, step: 0.05 },
23
31
  };
24
- static transient = ['current', 'mixer', 'actions', 'active', 'requested', 'finished', 'root', 'warned', 'attached', 'handle', 'names', 'pending'];
32
+ static transient = ['current', 'mixer', 'sources', 'mix', 'params', 'built', 'requested', 'root', 'warned', 'attached', 'handle', 'names', 'pending'];
25
33
  /** The clip that plays once the model settles, when nothing asked for one before. */
26
34
  initialClip = '';
27
35
  /** Crossfade between clips, in seconds; 0 cuts. */
28
36
  fade = 0.15;
29
37
  /**
30
38
  * Per-clip overrides by glTF clip name (authored in JSON, like
31
- * AnimatedSprite.clips). Read when the actions are built, as the model
32
- * settles: a change after that applies on the next build (a Model rebuild).
39
+ * AnimatedSprite.clips). `loop` and `speed` are read when the actions are
40
+ * built, as the model settles, so a change after that applies on the next
41
+ * build (a Model rebuild); `fade` is read at every `play`.
33
42
  */
34
43
  clips = {};
44
+ /**
45
+ * 1D blends by name (`AnimatorBlend`, authored in JSON like `clips`): each
46
+ * is a clip `play` accepts. Its `param` and `points` are read when the
47
+ * actions are built, as the model settles; its `smoothing` every step.
48
+ */
49
+ blends = {};
35
50
  /** The clip playing now; undefined before the first play. */
36
51
  current;
37
52
  mixer = null;
38
- actions = new Map();
39
- active = null;
53
+ /** What `play` accepts, by name, for the current build: the file's clips, then the blends. */
54
+ sources = new Map();
55
+ /** The fades between them. */
56
+ mix = new AnimatorMix();
57
+ /** The named parameters the blends read; they outlive a rebuild. */
58
+ params = new AnimatorParams();
59
+ /** Each blend the current build made playable, by name, and the parameter it reads; null before a build. */
60
+ built = null;
40
61
  /** A play asked for before the model loaded. */
41
62
  requested;
42
- finished = false;
43
63
  root = null;
44
64
  warned = new Set();
45
65
  attached = false;
@@ -53,7 +73,7 @@ export class Animator extends Component {
53
73
  return this.names;
54
74
  }
55
75
  get clipFinished() {
56
- return this.finished;
76
+ return this.mix.target?.finished ?? false;
57
77
  }
58
78
  get clipsPending() {
59
79
  return this.pending;
@@ -64,11 +84,7 @@ export class Animator extends Component {
64
84
  * loop starts over at 0. 0 while nothing plays.
65
85
  */
66
86
  get clipProgress() {
67
- const action = this.active;
68
- if (!action)
69
- return 0;
70
- const { duration } = action.getClip();
71
- return duration > 0 ? Math.min(action.time / duration, 1) : 1;
87
+ return this.mix.target?.progress ?? 0;
72
88
  }
73
89
  onReady() {
74
90
  if (this.game.space !== '3d')
@@ -76,7 +92,11 @@ export class Animator extends Component {
76
92
  this.pending = true;
77
93
  this.attach();
78
94
  }
79
- /** Plays a clip: a no-op for the running one, a restart for a finished one, a crossfade from any other over `fade` seconds. */
95
+ /**
96
+ * Plays a clip: a no-op for the running one, a restart for a finished one,
97
+ * and from any other a fade over the clip's own `clips[name].fade`, else
98
+ * `fade` seconds, every sounding clip leaving from the weight it has (ADR 0033).
99
+ */
80
100
  play(name) {
81
101
  if (this.game.space !== '3d')
82
102
  return;
@@ -84,50 +104,137 @@ export class Animator extends Component {
84
104
  this.requested = name;
85
105
  return;
86
106
  }
87
- const action = this.actions.get(name);
88
- if (!action) {
107
+ const source = this.sources.get(name);
108
+ if (!source) {
89
109
  this.warnOnce(name, `[waica] "${this.entity.name}": no clip "${name}"`);
90
110
  return;
91
111
  }
92
- if (action === this.active && !this.finished)
112
+ if (source === this.mix.target && !source.finished)
93
113
  return;
94
- const previous = this.active;
95
- this.active = action;
96
114
  this.current = name;
97
- this.finished = false;
98
- action.reset().setEffectiveWeight(1).play();
99
- if (!previous || previous === action)
115
+ this.mix.enter(source, this.fadeInto(name));
116
+ }
117
+ /**
118
+ * Aims a named parameter at `value`; the blends reading it follow, smoothed
119
+ * by the first blend that names it (once built, the first one the build made
120
+ * playable). Kept when made before the model loads;
121
+ * a value that is not a finite number is ignored with one warning.
122
+ */
123
+ setParam(name, value) {
124
+ if (this.game.space !== '3d')
125
+ return;
126
+ if (!Number.isFinite(value)) {
127
+ this.warnOnce(`param:${name}`, `[waica] "${this.entity.name}": setParam("${name}") needs a finite number; ignored`);
100
128
  return;
101
- if (this.fade <= 0)
102
- previous.stop();
103
- else
104
- previous.crossFadeTo(action, this.fade, false);
129
+ }
130
+ this.params.set(name, value, this.smoothingOf(name));
105
131
  }
106
132
  onUpdate(dt) {
107
133
  if (this.game.space !== '3d')
108
134
  return;
109
- // A rebuilt Model (a new src or shape) draws a new clone: the mixer over
110
- // the old one would animate a graph nobody draws, so it is torn down and
111
- // the clip it was playing is asked for again on the new one.
112
- if (this.attached && (this.entity.get(Model)?.handle ?? null) !== this.handle)
113
- this.detach();
114
- if (!this.attached)
115
- this.attach(true);
116
- this.mixer?.update(dt);
135
+ this.syncModel();
136
+ this.advance(dt);
137
+ }
138
+ /**
139
+ * One step of the parameters, the fades and the mixer while the Game does
140
+ * not simulate (issue #205): the editor's edit-mode preview of a blend
141
+ * drives the selected entity's Animator alone through it, once per frame,
142
+ * following its Model as onUpdate does. In Play it does nothing: onUpdate
143
+ * steps the Animator then.
144
+ */
145
+ previewStep(dt) {
146
+ if (this.game.simulate || this.game.space !== '3d')
147
+ return;
148
+ this.syncModel();
149
+ this.advance(dt);
150
+ }
151
+ /**
152
+ * Ends an edit-mode preview (issue #205) as a freshly built stage starts:
153
+ * no parameter written, every clip stopped (three puts the rig back in the
154
+ * pose its file was built in) and `initialClip`, if any, asked for again
155
+ * from its start. In Play, or before the model loads, it does nothing.
156
+ */
157
+ previewReset() {
158
+ if (this.game.simulate || this.game.space !== '3d' || !this.mixer)
159
+ return;
160
+ this.params.clear();
161
+ this.mix.clear();
162
+ this.current = undefined;
163
+ if (this.initialClip)
164
+ this.play(this.initialClip);
117
165
  }
118
166
  onDestroy() {
119
167
  this.release();
120
168
  this.pending = false;
121
169
  }
122
170
  inspectState() {
171
+ const target = this.mix.target;
123
172
  return {
124
173
  loaded: this.mixer !== null,
125
174
  current: this.current ?? null,
126
- time: this.active?.time ?? 0,
127
- playing: this.active !== null && this.active.isRunning(),
175
+ time: target?.time ?? 0,
176
+ playing: target?.playing ?? false,
128
177
  poseHash: this.root ? poseHash(this.root) : '',
178
+ weights: this.mix.weights(),
179
+ params: this.params.snapshot(this.blendParams()),
129
180
  };
130
181
  }
182
+ /** One step of the parameters, then of the fades, then of the mixer. */
183
+ advance(dt) {
184
+ this.params.step(dt, (name) => this.smoothingOf(name));
185
+ if (!this.mixer)
186
+ return;
187
+ this.mix.step(dt);
188
+ this.mixer.update(dt);
189
+ }
190
+ /** The smoothing of the first blend that reads `param`; none without one. */
191
+ smoothingOf(param) {
192
+ const reader = this.blendReaders().find(([, reads]) => reads === param);
193
+ return reader === undefined ? 0 : blendSmoothing(this.authoredBlends()[reader[0]]);
194
+ }
195
+ /** The parameters the blends read, in declaration order. */
196
+ blendParams() {
197
+ return [...new Set(this.blendReaders().map(([, param]) => param))];
198
+ }
199
+ /**
200
+ * Each blend's name and the parameter it reads: once built, only the blends
201
+ * the build made playable (one it dropped decides nothing); before, every
202
+ * authored blend that names a parameter.
203
+ */
204
+ blendReaders() {
205
+ return this.built ? [...this.built] : blendReaders(this.authoredBlends());
206
+ }
207
+ /** The authored blends by name; a `blends` that is not an object (JSON props arrive unchecked) warns once and counts as none. */
208
+ authoredBlends() {
209
+ const blends = blendsRecord(this.blends);
210
+ if (blends)
211
+ return blends;
212
+ this.warnOnce('blends', `[waica] "${this.entity.name}": blends must be an object of blends by name; none plays`);
213
+ return {};
214
+ }
215
+ /** The fade into `name`: its own `clips[name].fade` when that is a finite number ≥ 0, else `fade`. */
216
+ fadeInto(name) {
217
+ const own = this.clips[name]?.fade;
218
+ if (own === undefined)
219
+ return this.fade;
220
+ if (typeof own === 'number' && Number.isFinite(own) && own >= 0)
221
+ return own;
222
+ this.warnOnce(`fade:${name}`, `[waica] "${this.entity.name}": clips.${name}.fade must be a number of seconds ≥ 0; fading over ${this.fade}`);
223
+ return this.fade;
224
+ }
225
+ /**
226
+ * Keeps the mixer on the Model's current clone. A rebuilt Model (a new src
227
+ * or shape) draws a new clone: the mixer over the old one would animate a
228
+ * graph nobody draws, so it is torn down and the clip it was playing is
229
+ * asked for again on the new one. A Model listed after the Animator, which
230
+ * onReady could not see, is attached here.
231
+ */
232
+ syncModel() {
233
+ if (this.attached && (this.entity.get(Model)?.handle ?? null) !== this.handle)
234
+ this.detach();
235
+ if (!this.attached)
236
+ this.attach(true);
237
+ }
131
238
  /**
132
239
  * Takes the sibling Model's handle and builds the mixer when it settles. A
133
240
  * Model later in the component order is not there at onReady, so the first
@@ -162,14 +269,14 @@ export class Animator extends Component {
162
269
  }
163
270
  /** Stops and drops the mixer, its actions and the clone they ran over. */
164
271
  release() {
272
+ this.mix.clear();
165
273
  this.mixer?.stopAllAction();
166
274
  if (this.mixer && this.root)
167
275
  this.mixer.uncacheRoot(this.root);
168
276
  this.mixer = null;
169
- this.active = null;
170
277
  this.current = undefined;
171
- this.finished = false;
172
- this.actions.clear();
278
+ this.sources.clear();
279
+ this.built = null;
173
280
  this.names = [];
174
281
  this.root = null;
175
282
  this.handle = null;
@@ -179,13 +286,22 @@ export class Animator extends Component {
179
286
  this.mixer = mixer;
180
287
  this.root = handle.root;
181
288
  for (const clip of handle.animations)
182
- this.actions.set(clip.name, this.configure(mixer.clipAction(clip), clip.name));
183
- this.names = [...this.actions.keys()];
184
- this.pending = false;
185
- mixer.addEventListener('finished', (event) => {
186
- if (event.action === this.active)
187
- this.finished = true;
289
+ this.sources.set(clip.name, new ClipSource(clip.name, this.configure(mixer.clipAction(clip), clip.name)));
290
+ const authored = this.authoredBlends();
291
+ const blends = buildBlendSources({
292
+ blends: authored,
293
+ clips: this.clips,
294
+ mixer,
295
+ animations: handle.animations,
296
+ value: (param) => this.params.value(param),
297
+ warn: (key, message) => this.warnOnce(key, `[waica] "${this.entity.name}": ${message}`),
188
298
  });
299
+ for (const blend of blends)
300
+ this.sources.set(blend.name, blend);
301
+ const playable = new Set(blends.map((blend) => blend.name));
302
+ this.built = new Map(blendReaders(authored).filter(([name]) => playable.has(name)));
303
+ this.names = [...this.sources.keys()];
304
+ this.pending = false;
189
305
  const first = this.requested ?? this.initialClip;
190
306
  this.requested = undefined;
191
307
  if (first)
@@ -34,6 +34,12 @@ export declare class Model extends Component {
34
34
  min: number;
35
35
  step: number;
36
36
  };
37
+ castShadows: {
38
+ label: string;
39
+ };
40
+ receiveShadows: {
41
+ label: string;
42
+ };
37
43
  };
38
44
  static transient: string[];
39
45
  /** What this Model draws, under the entity's node; null until it is ready. The Pointer ray-casts against it. */
@@ -49,6 +55,8 @@ export declare class Model extends Component {
49
55
  private _shape;
50
56
  private _color;
51
57
  private _size;
58
+ private _castShadows;
59
+ private _receiveShadows;
52
60
  private _primitive;
53
61
  /** A `waica:` uri or project path to a `.glb` / `.gltf`; empty draws the primitive `shape`. */
54
62
  get src(): string;
@@ -61,11 +69,19 @@ export declare class Model extends Component {
61
69
  /** Uniform scale of a primitive: the box side, sphere diameter or plane side in world units. */
62
70
  get size(): number;
63
71
  set size(value: number);
72
+ /** Whether this Model's meshes cast shadows; only false opts out. */
73
+ get castShadows(): boolean;
74
+ set castShadows(value: unknown);
75
+ /** Whether this Model's meshes receive shadows; only false opts out. */
76
+ get receiveShadows(): boolean;
77
+ set receiveShadows(value: unknown);
64
78
  onReady(): void;
65
79
  onDestroy(): void;
66
80
  private build;
67
81
  private buildPrimitive;
68
82
  private rebuild;
69
- /** Empties the root and disposes the primitive this Model created; a glTF's clone only leaves the graph. */
83
+ /** Participation belongs to this Model's meshes, never their shared glTF resources. */
84
+ private applyShadowFlags;
85
+ /** Retire clone-local render state; shared glTF geometry/materials stay cache-owned. */
70
86
  private clear;
71
87
  }