@waica/engine 0.30.1 → 0.31.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.
- package/README.md +48 -3
- package/dist/camera-rig.d.ts +2 -0
- package/dist/camera-rig.js +4 -0
- package/dist/components/animator-blends.d.ts +54 -0
- package/dist/components/animator-blends.js +86 -0
- package/dist/components/animator-mix.d.ts +53 -0
- package/dist/components/animator-mix.js +92 -0
- package/dist/components/animator-params.d.ts +21 -0
- package/dist/components/animator-params.js +50 -0
- package/dist/components/animator-sources.d.ts +59 -0
- package/dist/components/animator-sources.js +125 -0
- package/dist/components/animator.d.ts +84 -10
- package/dist/components/animator.js +163 -47
- package/dist/components/model.d.ts +17 -1
- package/dist/components/model.js +49 -3
- package/dist/components/point-light.d.ts +38 -3
- package/dist/components/point-light.js +6 -3
- package/dist/components/shadow-light.d.ts +63 -0
- package/dist/components/shadow-light.js +71 -0
- package/dist/components/sun.d.ts +38 -3
- package/dist/components/sun.js +6 -3
- package/dist/entity.js +3 -0
- package/dist/game-shadows.d.ts +54 -0
- package/dist/game-shadows.js +260 -0
- package/dist/game.d.ts +3 -0
- package/dist/game.js +15 -2
- package/dist/index.d.ts +7 -4
- package/dist/index.js +3 -1
- package/dist/point-shadow.d.ts +14 -0
- package/dist/point-shadow.js +54 -0
- package/dist/runtime-lighting-snapshot.d.ts +9 -1
- package/dist/runtime-lighting-snapshot.js +24 -4
- package/dist/scene-shadows.d.ts +82 -0
- package/dist/scene-shadows.js +151 -0
- package/dist/scene.d.ts +11 -1
- package/dist/scene.js +2 -1
- package/dist/shadow-renderer.d.ts +8 -0
- package/dist/shadow-renderer.js +37 -0
- package/dist/sun-shadow-camera.d.ts +17 -0
- package/dist/sun-shadow-camera.js +67 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,6 +25,21 @@ A `Game` draws through three's `WebGPURenderer` (ADR 0025): WebGPU when the brow
|
|
|
25
25
|
- **Tests under happy-dom.** happy-dom has no GPU: a project test that builds a `Game` replaces `WebGPURenderer` from `three/webgpu` with a fake (it previously replaced `WebGLRenderer` from `three`), whose `init()` resolves.
|
|
26
26
|
- **Run Sessions.** A Run Session waits for the Render Backend before it reports ready, steps or screenshots, and its snapshots carry `backend`. A renderer that cannot initialize ends `start_project` with a runtime error that names the failure.
|
|
27
27
|
|
|
28
|
+
## Migrating to 0.31.0: animation blending
|
|
29
|
+
|
|
30
|
+
The `Animator` mixes its clips (issue #205, ADR 0033). Every scene file loads as it did, the Clip Target contract and the `StateMachine` are unchanged, and a single clip played from the start poses exactly as before; what changes:
|
|
31
|
+
|
|
32
|
+
- **Fades leave from the current weight.** The Animator schedules its own fades instead of three's `crossFadeTo`: a fade moves the entering clip from its current weight to 1 and every other sounding clip from its current weight to 0. A fade interrupted by another `play` no longer jumps the leaving clip back to full weight, so a pose in the middle of an interrupted fade differs from 0.30; a project test that pinned such a pose re-pins it.
|
|
33
|
+
- **A clip asked for again continues.** `play(name)` on a clip that is still fading out continues from its time and rises from its weight instead of restarting at 0; a silent clip and a finished one-shot still restart.
|
|
34
|
+
- **`clips.<name>.fade`** (new) is the fade into that clip in seconds, in place of `fade`; `0` cuts into it.
|
|
35
|
+
- **`blends`** (new) are 1D blends, virtual clips mixing the file's clips by a named parameter: `{ <Name>: { param, points: [{ clip, at }], smoothing } }`. A state plays one with `clip: "<Name>"`. `setParam(name, value)` (new) aims the parameter.
|
|
36
|
+
- **The Runtime Snapshot's Animator** adds `weights` (each glb clip's effective weight above 0) and `params` (the smoothed values); with a blend playing, `current` is the blend's name and `time` its phase in seconds.
|
|
37
|
+
- **`authoringDefaults`** of the `Animator` gains `blends: {}`, so a test pinning its defaults adds it.
|
|
38
|
+
- **`Animator.previewStep(dt)`** (new) advances the parameters, the fades and the mixer of one Animator while `game.simulate` is false, and does nothing while it is true: the editor previews a blend with it, and `Animator.previewReset()` (new) ends the preview as a freshly built stage starts. `DEFAULT_BLEND_SMOOTHING` (0.15) is exported.
|
|
39
|
+
- **The `character` and `enemy` roles write `speed`**, the planar speed of their body, to a beside Animator every step (`setParam`); an Animator with no blend reading it plays as before.
|
|
40
|
+
- **`@waica/archetype-third-person`'s Player** plays a `Locomotion` blend (`Idle`@0, `Walking_A`@6, `Running_A`@10 by `speed`, smoothing 0.15) in idle, walk and run, and fades into its attack and dodge over 0.05 s and its death over 0.3 s. A project created earlier keeps its own `src/characters/player.character.json` and plays as before; copy the Animator's `blends` and `clips` and the three states' `clip` from the new prefab to take it.
|
|
41
|
+
- **`validate_project`** accepts a blend's name wherever an Animator clip name goes (`initialClip`, a `clips` key, a state's `clip`), warns `clip-end-on-looping-clip` for a state leaving a blend on `clip:end`, extends `missing-clip` to blend points, and adds the errors `invalid-clip-fade`, `invalid-blend`, `blend-name-collides-with-clip` and `blend-thresholds-not-increasing` (two points at one threshold, once sorted by `at` as the runtime sorts them; an `at` only has to be finite) and the warnings `blend-point-one-shot`, `blend-points-out-of-order` (points written out of `at` order, which play the same) and `blend-clip-override-ignored` (a blend's `clips` entry sets more than its `fade`).
|
|
42
|
+
|
|
28
43
|
## Migrating to 0.29.1: checkpoint banners on the walls
|
|
29
44
|
|
|
30
45
|
A fix release for the third-person demo, whose checkpoint banners stood free in the yard: KayKit's banner is single-sided and modelled to hang on a wall, so it floated and vanished from behind. Every existing scene file loads as it did; what changes:
|
|
@@ -286,20 +301,50 @@ A scene declares its space (ADR 0027): `render.space: '2d'` (the default) or `'3
|
|
|
286
301
|
|
|
287
302
|
- **Camera.** `{ kind: 'perspective', position, target, fov, near, far }`, defaults `[0, 5, 10]`, `[0, 0, 0]`, 60, 0.1, 1000; the 2D fields other than `follow` are not accepted. A 3D scene without a camera block gets the defaults. Without `follow` the camera is fixed by the scene; game code may move `game.camera`. With `follow: '<entity>'` it is the Orbit Camera (ADR 0031): it looks at the entity plus `lookHeight` (1.2) from `distance` (6) at `yaw` (0: looking toward −z) and `pitch` (20) in degrees, turned by the step's locked mouse movement times `sensitivity` (0.2 °/px; `invertY`) and by the Actions `orbitLeftAction`, `orbitRightAction`, `orbitUpAction` and `orbitDownAction` (`look_left`… by default) at `orbitSpeed` (120 °/s), pitch clamped to `minPitch`..`maxPitch` (−10..70), the wheel moving `distance` within `minDistance`..`maxDistance` (2..12); once per Simulation Step a ray toward it keeps it 0.2 in front of fixed level geometry (never nearer than 0.5; entities with a `RigidBody` never block it) without changing the chosen distance. A click on the canvas takes the pointer lock while it plays. `game.cameraYaw` is its yaw, `null` without one. `resize()` keeps the letterbox and sets the camera's aspect from the letterboxed size. Camera Fade, Flash and Shake work in 3D.
|
|
288
303
|
- **Model.** Draws a glTF/glb from `src` (a `waica:` uri or a project path under `src/art/`, loaded once through `game.assets.model(uri)`) or a primitive `shape` (`'box'`, `'sphere'`, `'plane'` — the plane lies on the ground) of a `color` (`0xrrggbb`) and a uniform `size`, with a standard (lit) material. With both `src` and `shape`, `src` wins. The entity's `position`, `rotation` and `scale` place it; `color` and `size` follow the inspector live, a new `src` or `shape` rebuilds it. `handle` is the `ModelHandle` of the file it draws (null for a primitive): its clips play through an `Animator` beside it.
|
|
289
|
-
- **Animator.** Plays the glTF clips of the `Model` beside it (ADR 0029): one `AnimationMixer` over the entity's own clone, advanced by the Simulation Step, nothing per render frame. It is the 3D Clip Target, so a `StateMachine` plays each state's clip through it and `clip:end` fires when a `loop: false` clip finishes. Params: `initialClip` (plays when the file settles, if nothing asked for a clip before it loaded — a `play` before the model loads is remembered and applied then; `clipsPending` is true meanwhile, so a `StateMachine` asks for its state's clip anyway) and `fade` (seconds of
|
|
290
|
-
- **Lights.** The Ambient Light of a 3D scene drives one `THREE.AmbientLight` the Game owns (`render.lighting.ambient` and `game.lighting.ambient`, full white by default). `Sun` is a directional light: `direction` `[x, y, z]` in world space (default `[-1, -2, -1]`), `color`, `intensity`. `PointLight` shines from its entity plus `offsetX/Y/Z`: `color`, `intensity`, `distance` (0 = unlimited), `decay` (default 2). Both create nothing in a 2D scene, follow their props live and leave with their entity.
|
|
304
|
+
- **Animator.** Plays the glTF clips of the `Model` beside it (ADR 0029; mixing, ADR 0033): one `AnimationMixer` over the entity's own clone, advanced by the Simulation Step, nothing per render frame. It is the 3D Clip Target, so a `StateMachine` plays each state's clip through it and `clip:end` fires when a `loop: false` clip finishes. Params: `initialClip` (plays when the file settles, if nothing asked for a clip before it loaded — a `play` before the model loads is remembered and applied then; `clipsPending` is true meanwhile, so a `StateMachine` asks for its state's clip anyway) and `fade` (seconds of fade between clips, default 0.15; `0` cuts). The `clips` prop, authored in JSON like `AnimatedSprite.clips`, holds per-clip overrides keyed by the glTF clip name: `{ loop: false }` plays once and holds the last pose, `{ speed: 2 }` doubles the rate, and `clips.<name>.fade` is the fade into that clip in place of `fade` (`0` cuts into it); `loop` and `speed` are read when the actions are built, as the file settles, and `fade` at every `play`. A fade moves the entering clip from its current weight to 1 and every other sounding clip from its current weight to 0, so an interrupted fade never jumps, and a clip asked for again while it still carries weight continues from its time (a silent one or a finished one-shot restarts). The `blends` prop holds 1D blends, `{ <Name>: { param, points: [{ clip, at }], smoothing } }`: each is a virtual clip that `play` and a state's `clip` accept (its name joins `clipNames` and it never finishes), mixing the two points around the value of its parameter linearly, every clip on one shared phase; `setParam(name, value)` aims a parameter (0 until written, kept when set before the file loads, a non-finite value ignored), which follows by a first-order lag whose time constant is the blend's `smoothing` in seconds (default 0.15; not a duration: each step closes dt / `smoothing` of the gap, so 0.15 covers about 65 % of a jump in 0.15 s); a blend's `param` and `points` are read when the actions are built, its `smoothing` every step. A Model rebuild (a new `src` or `shape`) is noticed on the next step: the Animator drops the mixer over the old clone, attaches to the new one and asks for the clip it was playing. Clip names match the file exactly (a state's `clip` override maps `idle` to a pack's `Idle`); an unknown name warns once, as do a blend named like a clip of the file (the clip wins) and a blend point whose clip the file lacks (it is skipped). `previewStep(dt)` advances it alone while the Game does not simulate, following a rebuilt or late Model as a step does (the editor's edit-mode preview of a blend; a no-op in play), and `previewReset()` ends such a preview as a freshly built stage starts: no parameter written, every clip stopped (the rig back in its built pose) and `initialClip` from its start. `inspect_runtime` shows `{ loaded, current, time, playing, poseHash, weights, params }`: `weights` is each glb clip's effective weight above 0 (the clips fading out included), `params` the smoothed values, and with a blend playing `current` is its name and `time` its phase in seconds of the interpolated cycle. In a 2D scene it creates nothing and `play` is a no-op; without a `Model` file beside it it warns once at runtime and `validate_project` reports `animator-without-model` per scene entity (an instance may complete its prefab with a `Model.src` override), while `missing-clip` checks its states, `initialClip`, override keys and blend points against the glb's clips (a blend's name counts as a clip; a point naming a blend does not), `invalid-clip-fade` reports a per-clip `fade` below 0 or not a number, and `invalid-blend`, `blend-name-collides-with-clip`, `blend-thresholds-not-increasing` (two points at one threshold) and the warnings `blend-point-one-shot`, `blend-points-out-of-order` and `blend-clip-override-ignored` (a blend's `clips` entry sets more than its `fade`) check its blends, on the prefab and again only on an instance that changes them (`clips`, `blends`, `initialClip` or the Model's `src`; the fades again only for `clips`, the blends' shapes only for `blends`).
|
|
305
|
+
- **Lights.** The Ambient Light of a 3D scene drives one `THREE.AmbientLight` the Game owns (`render.lighting.ambient` and `game.lighting.ambient`, full white by default). `Sun` is a directional light: `direction` `[x, y, z]` in world space (default `[-1, -2, -1]`), `color`, `intensity`. `PointLight` shines from its entity plus `offsetX/Y/Z`: `color`, `intensity`, `distance` (0 = unlimited), `decay` (default 2). Both cast scene-scoped shadows by default, create nothing in a 2D scene, follow their props live and leave with their entity. three's lights are physically based: a point light of intensity 1 is dim, so a scene tunes intensities (the third-person demo uses 2.2 for the Sun and 14 for each torch).
|
|
291
306
|
- **`game.worldToScreen(point)`.** `{ x, y, z? }` in logical coordinates to canvas CSS pixels inside the letterbox, or `null` when a perspective camera cannot see the point (behind its near plane). Anchored Pieces and spatial audio go through the same projection: in 3D a piece behind the camera is hidden, sounds attenuate by the 3D distance from the camera and pan by where they land on screen.
|
|
292
307
|
- **Picking.** A click in a 3D scene casts a ray through the letterboxed point against every live `Model` (a glTF still loading is skipped) and resolves `PointerPick` with the nearest hit's entity and its world-space point (`point.z`). With no hit the entity is `null` and the point is where the ray meets the ground plane y = 0; a ray at the sky yields no pick.
|
|
293
308
|
- **Assets.** `game.assets.model(uri)` returns a `ModelHandle` at once: `root` is empty until the file settles, then holds your own clone of the cached glTF (skinned meshes cloned with `SkeletonUtils.clone`), `animations` its clips, `settled` resolves `'loaded'` or `'failed'`. One backend load per URL for the Game's life; `status` counts models with textures, `preload` routes `.glb`/`.gltf` to them, `GameOptions.models` replaces the real `GLTFLoader` backend (`ModelBackend { load(url) }`) for a project's own tests. Only a `.glb`, or a `.gltf` with every buffer and image embedded as a `data:` uri, works in the editor (which serves each file from a `blob:` url) and in a built project (which hashes every art file's name): a `.gltf` that points at an external `.bin` or textures loads empty there, and `validate_project` warns (`gltf-external-resource`).
|
|
294
309
|
- **Simulation.** A 3D scene gets one Rapier world (ADR 0028, `@dimforge/rapier3d-deterministic-compat` 0.21.0), stepped once per Simulation Step after every component's `onUpdate`. `simulation: { gravity: [x, y, z] }` in the scene sets its gravity (default `[0, -9.81, 0]`; a `simulation` block in a 2D scene is an error, `invalid-scene-simulation`). Rapier is imported dynamically by the first 3D scene that loads, counts as a pending asset (`game.assets.ready()` and `game.ready()` of a Game with a live 3D scene wait for it, and `game.ready()` rejects naming the package if it fails), and a 2D project never loads it. Steps taken while it loads run every component update and skip only the physics. `GameOptions.physics` replaces the import for a project's own tests.
|
|
295
310
|
- **Collider.** `shape` (`'box'`, `'sphere'`, `'capsule'`), `size` (a box's full extents), `radius`, `height` (a capsule's end-to-end height), `offset` (in the entity's local frame), `sensor`, `layer`, `collidesWith`, `friction`, `restitution`. Alone on an entity it is a fixed wall or floor, created at the entity's position and rotation and scaled by its `scale` (box size and capsule height per axis, a sphere's radius by the largest axis, a capsule's by the larger of x and z); moving the entity later does not move it. A capsule's `height` is at least twice its radius (a shorter one would be a taller sphere). `validate_project` reports `invalid-collider-param` for an unknown shape, a non-positive dimension, a radius or height under 0.01, a capsule shorter than twice its radius, an `offset` that is not three finite numbers, a bad layer or mask and a friction or restitution outside 0 to 1; at runtime such a Collider creates no body and warns once.
|
|
296
311
|
- **RigidBody.** `type` (`'dynamic'` or `'kinematic'`), `mass`, `gravityScale`, `linearDamping`, `angularDamping`, `lockRotations`, `velocity` (initial). It needs a `Collider` on the same entity (`rigid-body-without-collider`). A dynamic body is Rapier's: the engine writes the entity's position and rotation from it after every step; `linearVelocity` (get and set) and `applyImpulse` are its public API (`grounded` is always false on it). A kinematic body is a character: each step it moves through Rapier's character controller by `desiredVelocity` (`{ x, z }`, set by a motor) and its own vertical velocity under gravity, climbs steps up to `stepHeight`, stands on slopes up to `maxSlope` degrees and slides down steeper ones, sticks to the ground within `snapDistance`, pushes dynamic bodies, ignores sensors, and `jump(speed)` launches it while `grounded` and reports whether it did. Setting its entity's position teleports it, with no fall speed left. `validate_project` reports a `type` outside its options, a number outside its range or a `velocity` that is not three numbers as `invalid-rigid-body-param`; at runtime such a body is not created and one warning says why. `CharacterMotor` (`@waica/behaviors`) is the stock motor: `speed`, `jumpSpeed`, `turnSpeed` and five action names, walking on the world axes (`-z` is forward, diagonals at the cardinal speed), consuming the jump press only when it jumped; `character-motor-without-body` reports one without a kinematic `RigidBody` and a `Collider`, and `invalid-character-motor-param` a param outside the range its spec declares.
|
|
297
|
-
- **Character.** A 3D character faces where it walks by turning, not by directional clips or flips (issue #166): each step it has a non-zero desired velocity, `CharacterMotor` turns the entity's yaw toward it through `turnYawToward(node, dirX, dirZ, turnSpeed, dt)` (`@waica/behaviors`; the target yaw is `atan2(dirX, dirZ)`, so a glTF's +z front leads; the shortest arc, at most `turnSpeed` degrees per second), and with no input, or a `speed` of 0, the yaw holds. `turnSpeed` defaults to 540 (about a third of a second for a U-turn), `0` snaps, and the kinematic body is unaffected: it keeps the rotation it spawned with, so give a turning character a `Collider` that looks the same from every side — a capsule or a sphere with no x/z `offset` — or the model and a box or offset collider disagree about where it ends. The `character` role (`CHARACTER_ROLE`, `CHARACTER_STATE_GRAPH`, `characterUpdate`) is the 3D counterpart of `player`: `idle`, `walk`, `jump` and `fall` on the signals its update emits by reading the kinematic `RigidBody` — `land` plus `move` (planar speed above 0.1 u/s) or `stop` on the ground, `rise` or `fall` in the air once it has vertical speed (air with none — the spawn, the steps while Rapier loads, the step after a teleport — reports nothing) — while the motor keeps reading the input itself; an `Animator` beside the machine plays each state's clip (a per-state `clip` maps `idle` to a pack's `Idle`). Since issue #181 the motor also walks relative to the Orbit Camera (`relativeTo: 'camera'`), runs (`runAction`, `runSpeed` on the ground) and rolls (`dodgeAction`: `dodgeDuration` seconds at `dodgeSpeed` along the input or the facing, opening a beside Health's invulnerability without the blink, then `dodgeCooldown`), and the role adds `run`, `roll`, `attack` (on an attack press beside a `MeleeAttack`, striking once at 40 % of its clip and back to `idle` on `clip:end`) and `die` (on a Health death; beside a `Respawnable`, a fade out, the respawn with full health and a fade in). A project registers it with `defineRole('character', CHARACTER_ROLE)` in `src/roles/character.ts`; `validate_project` recognizes a `@waica/behaviors` role passed by reference there, so its states report no `no-state-code`.
|
|
312
|
+
- **Character.** A 3D character faces where it walks by turning, not by directional clips or flips (issue #166): each step it has a non-zero desired velocity, `CharacterMotor` turns the entity's yaw toward it through `turnYawToward(node, dirX, dirZ, turnSpeed, dt)` (`@waica/behaviors`; the target yaw is `atan2(dirX, dirZ)`, so a glTF's +z front leads; the shortest arc, at most `turnSpeed` degrees per second), and with no input, or a `speed` of 0, the yaw holds. `turnSpeed` defaults to 540 (about a third of a second for a U-turn), `0` snaps, and the kinematic body is unaffected: it keeps the rotation it spawned with, so give a turning character a `Collider` that looks the same from every side — a capsule or a sphere with no x/z `offset` — or the model and a box or offset collider disagree about where it ends. The `character` role (`CHARACTER_ROLE`, `CHARACTER_STATE_GRAPH`, `characterUpdate`) is the 3D counterpart of `player`: `idle`, `walk`, `jump` and `fall` on the signals its update emits by reading the kinematic `RigidBody` — `land` plus `move` (planar speed above 0.1 u/s) or `stop` on the ground, `rise` or `fall` in the air once it has vertical speed (air with none — the spawn, the steps while Rapier loads, the step after a teleport — reports nothing) — while the motor keeps reading the input itself; an `Animator` beside the machine plays each state's clip (a per-state `clip` maps `idle` to a pack's `Idle`). Since issue #181 the motor also walks relative to the Orbit Camera (`relativeTo: 'camera'`), runs (`runAction`, `runSpeed` on the ground) and rolls (`dodgeAction`: `dodgeDuration` seconds at `dodgeSpeed` along the input or the facing, opening a beside Health's invulnerability without the blink, then `dodgeCooldown`), and the role adds `run`, `roll`, `attack` (on an attack press beside a `MeleeAttack`, striking once at 40 % of its clip and back to `idle` on `clip:end`) and `die` (on a Health death; beside a `Respawnable`, a fade out, the respawn with full health and a fade in). Every step it runs, in the air and rolling too, the role writes the body's planar speed to a beside Animator's `speed` parameter (`setParam`), which a blend like the third-person Knight's `Locomotion` reads (issue #205); the `enemy` role does the same after each of its states' steps. A project registers it with `defineRole('character', CHARACTER_ROLE)` in `src/roles/character.ts`; `validate_project` recognizes a `@waica/behaviors` role passed by reference there, so its states report no `no-state-code`.
|
|
298
313
|
- **Contacts and sensors.** Components on an entity with a solid `Collider` get `onBodyContact({ entity, other, normal, point })` once per step for each entity they touch, the normal pointing from the entity to the other; kinematic bodies included, so a character hears the floor and the walls it touches. A `Collider{ sensor: true }` is the 3D `Hitbox`: it fires `onCollide(other)` every step it overlaps another collider, on each side whose `collidesWith` names the other's `layer` (ADR 0016); a solid collider is told only if it declares its own mask, and a sensor with the default mask (`['*']`) is not told about fixed solid colliders (the level's walls and floors), as a Hitbox never sees a Solid — a mask naming their layer still is.
|
|
299
314
|
- **Queries.** `game.query` gains 3D forms told apart by argument shape: `ray(origin, direction, maxDistance, filter?)` returns the first solid collider (`{ entity, collider, distance, point, normal }`, sensors never block it), `area({ shape: 'box', center, size, yaw? } | { shape: 'sphere', center, radius }, filter?)` and `point(at, filter?)` return the entities whose sensor colliders intersect or contain them (an `area` volume with `colliders: 'all'` reads solid colliders too, and a box turns by `yaw` degrees about +y — how a 3D `MeleeAttack` finds its targets), `nearest(at, filter?)` the closest transform with `z`. The filter object is the 2D one; its `where` runs after Rapier has answered, so it may throw or change the scene, and `area` answers `[]` for a volume that is not a finite box or sphere. A form in the other space's scene throws an error naming the space. Rapier updates what queries read inside the step, so the engine refreshes it when bodies came or went.
|
|
300
315
|
- **Runtime Snapshot.** `space`, `view` (`{ kind: 'orthographic', position, zoom }` or `{ kind: 'perspective', position, target, fov }`, plus `follow`, `yaw`, `pitch`, `distance` and `distanceActual` for an Orbit Camera) and, in a 3D scene, `lighting.sun[]` and `lighting.pointLights[]`, and `physics: { state: 'loading' | 'ready' | 'failed', gravity, bodies: [{ entity, id, type: 'fixed' | 'dynamic' | 'kinematic', velocity, grounded }] }` (bodies in spawn order, never filtered).
|
|
301
316
|
- **Editor.** A 3D scene is edited through the Editor Camera (ADR 0030): in edit mode the viewport draws, picks and projects through an editor-owned perspective camera set with `game.cameraRig.setViewCamera(camera)` (`game.camera` returns it while set; `game.cameraRig.sceneCamera` keeps the one the JSON places; `null` restores it; a 2D scene ignores it with a warning). It starts at the scene camera's pose, orbits (middle button or Alt+left), pans (right button or Shift+middle), zooms (wheel), steps with the arrows, Shift+arrows, `+` and `-` on the focused viewport, frames the selection with `F`, and is remembered per scene; Play draws through the scene camera. A click picks through `raycastPick` (Models, and the icons of Suns, PointLights, meshless entities and the scene camera); `TransformControls` move, rotate and scale the selection (W, E, R; World or Local; Ctrl snaps to per-user steps), one undo per drag; a prefab drops where the ray meets a Model or the ground; an XZ grid and the helpers (the camera's frustum, each Sun's direction, each PointLight's range, each Collider's shape) draw in edit mode only. The inspector edits X/Y/Z, rotation and scale and a perspective camera's position, target and fov, and the Animator's clip fields list the glb's clips, read by `glbClipNames(bytes)`. In a 3D archetype the editor's New prefab writes the same 3D set as `scaffold_prefab`, and the "+ behaviour…" picker offers only the components that run in the edited scene's space.
|
|
302
317
|
|
|
318
|
+
### 3D shadows and migration
|
|
319
|
+
|
|
320
|
+
**Existing 3D scenes change appearance and render cost:** shadows default on, even without `render.lighting`. 2D lighting, simulation, physics and Game Time are unchanged. To restore the previous shadow-free render:
|
|
321
|
+
|
|
322
|
+
```json
|
|
323
|
+
{ "render": { "space": "3d", "shadows": { "quality": "off" } } }
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`render.shadows` accepts `quality` and `maxDistance` (world units, default 40). Missing configuration means Medium. Only soft PCF is supported through the existing Three WebGPURenderer on WebGL2/WebGPU, without a separate backend pipeline or dependency.
|
|
327
|
+
|
|
328
|
+
| Quality | Sun map | PointLight map, per face |
|
|
329
|
+
|---|---|---|
|
|
330
|
+
| `off` | no active pass/map | no active pass/map |
|
|
331
|
+
| `low` | 1024² | 256² |
|
|
332
|
+
| `medium` | 2048² | 512² |
|
|
333
|
+
| `high` | 4096² | 1024² |
|
|
334
|
+
|
|
335
|
+
- **Participation:** `Model.castShadows` and `Model.receiveShadows` are independent booleans, both true. They apply to primitives and all cloned glTF meshes/SkinnedMeshes, including assets settling later, without changing cached resources or restarting Animator. Each Sun/PointLight has `castShadows: true`; disabling it leaves illumination intact. Global Off always wins.
|
|
336
|
+
- **Overrides:** light fields `shadowMapSize`, `shadowMaxDistance`, `shadowBias`, `shadowNormalBias`, `shadowRadius` default to `null`, inheriting scene/defaults. Explicit zero is valid for bias, normal bias and radius. Defaults: bias −0.0005, normalBias 0.02, PCF radius 1. Sizes: 256/512/1024/2048/4096; ranges: distance 0.01–1000, bias −0.1–0.1, normalBias 0–1, radius 0–8. Validation reports malformed authored values even when Off. Runtime clamps finite ranges, inherits unreadable numeric overrides, and defaults unreadable booleans to true without truthy coercion. An unsupported active map raises a capacity error, never a silent downgrade; live setters retain their last valid value.
|
|
337
|
+
- **Two inheritance levels:** scene 2048, prefab 1024, instance 512: “Inherit scene” stores explicit `null`, resolving 2048. “Reset to the prefab's value” removes the instance override and restores 1024. Opening writes no defaults; quality/Off changes preserve overrides. Advanced controls require single selection; basic multi-selection boolean editing remains.
|
|
338
|
+
- **Coverage:** Sun fits the active view, including the Editor Camera, from near to `min(camera.far, shadowMaxDistance)`, with caster-depth padding and texel-stable translation, not the entity/world origin. No cascades. PointLight follows its world emission position including transformed offset. Its Euclidean shadow radius is `min(positive light.distance, shadowMaxDistance)`; unlimited lighting (`distance: 0`) still has finite shadow reach. Illumination can extend beyond that radius, including cubemap diagonals.
|
|
339
|
+
- **Live/inspection:** `game.shadows.quality`, `.maxDistance`, `.policy`, `.resolve(sunOrPoint)` expose effective settings. Changes apply before the next render, even paused/editing. Snapshot `lighting.shadows` and each light's `shadows` distinguish effective and requested nullable values. Selected-light helpers are read-only, under the existing toggle, never in Play/game captures.
|
|
340
|
+
- **Cost/lifetime:** PointLight can add six shadow views; the demo's Sun plus four torches can add 25. No automatic light cap, FPS-based degradation or universal FPS promise. Off/destruction releases obsolete targets; resize retires GPU storage through native `RenderTarget.setSize` without disposing active cached nodes. Moving camera/light or changing bias/radius does not allocate targets per frame. Real renderer counters, not scene-material counts, test retained resources.
|
|
341
|
+
- **Materials:** preserve Three's opaque/alphaTest behavior. Unlit materials do not visibly receive lighting/shadows merely because their flag is true. No physical transparent shadow transmission or material conversion.
|
|
342
|
+
- **Three compatibility:** fixed-frame 3D stepping uses a checked, isolated Three 0.186 NodeFrame boundary to update cached shadows/skeletons on manual renders without advancing native RAF twice. An incompatible clock fails explicitly. Revalidate when upgrading Three.
|
|
343
|
+
|
|
344
|
+
See [benchmark notes](../bench/README.md). WebGL2 runtime/editor captures live in `.sdd/evidence/issue-203-3d-shadows/`. WebGPU pixels/resources are not positively verified on this host. The user withdrew those two acceptance gates (CA-18/21) from issue #203's delivery on 2026-10-17; the external protocol remains optional future verification. Support and strict assertions remain. Blank frames, WebGL2 fallback and the legacy issue-151 exception are not positive evidence.
|
|
345
|
+
|
|
346
|
+
Automatic `pnpm test:e2e` still requires the legacy scenarios on both backends and the new shadow pixels on WebGL2. Run `pnpm test:e2e --webgpu-shadows` to request the unchanged strict runtime/editor shadow pixel suites on WebGPU too. A requested unsupported/blank WebGPU result still fails; no automatic retry or weaker pixel threshold is added.
|
|
347
|
+
|
|
303
348
|
## State machines
|
|
304
349
|
|
|
305
350
|
A `StateMachine` owns a character's per-frame logic: its `states` and their `transitions` are prefab data, the state code comes from the role's logic set (`defineRole`, `defineStates`), and `current` names the active state. Each transition is `{ on, to }`; a trigger is one of:
|
package/dist/camera-rig.d.ts
CHANGED
|
@@ -43,6 +43,8 @@ export declare class CameraRig {
|
|
|
43
43
|
get camera(): GameCamera;
|
|
44
44
|
/** The camera placed from the scene's JSON, whatever view camera is set; `adopt` keeps updating it. */
|
|
45
45
|
get sceneCamera(): GameCamera;
|
|
46
|
+
/** Retained across scene swaps, even while a 2D scene temporarily hides it. */
|
|
47
|
+
get viewCamera(): THREE.PerspectiveCamera | null;
|
|
46
48
|
/**
|
|
47
49
|
* Draws a 3D scene through `camera` instead of the scene camera, until
|
|
48
50
|
* cleared with null (issue #173 CA-1): the editor's own camera, which game
|
package/dist/camera-rig.js
CHANGED
|
@@ -40,6 +40,10 @@ export class CameraRig {
|
|
|
40
40
|
get sceneCamera() {
|
|
41
41
|
return this.current;
|
|
42
42
|
}
|
|
43
|
+
/** Retained across scene swaps, even while a 2D scene temporarily hides it. */
|
|
44
|
+
get viewCamera() {
|
|
45
|
+
return this.view;
|
|
46
|
+
}
|
|
43
47
|
/**
|
|
44
48
|
* Draws a 3D scene through `camera` instead of the scene camera, until
|
|
45
49
|
* cleared with null (issue #173 CA-1): the editor's own camera, which game
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import type { AnimatorClipOverride } from './animator.js';
|
|
3
|
+
import { BlendSource } from './animator-sources.js';
|
|
4
|
+
/** One point of a blend: a clip of the file and the parameter value at which it plays alone. */
|
|
5
|
+
export interface AnimatorBlendPoint {
|
|
6
|
+
clip: string;
|
|
7
|
+
at: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A 1D blend (issue #205, ADR 0033): a virtual clip of the Animator that mixes
|
|
11
|
+
* the file's clips by a named parameter, the two points around its value
|
|
12
|
+
* weighted linearly, every clip on one shared phase.
|
|
13
|
+
*/
|
|
14
|
+
export interface AnimatorBlend {
|
|
15
|
+
/** The parameter `setParam` writes. */
|
|
16
|
+
param: string;
|
|
17
|
+
/** The clips and their thresholds, played in threshold order. */
|
|
18
|
+
points: AnimatorBlendPoint[];
|
|
19
|
+
/**
|
|
20
|
+
* The time constant, in seconds, of the first-order lag the parameter follows
|
|
21
|
+
* toward a new value: each step closes dt / smoothing of the gap, so it is not
|
|
22
|
+
* how long the approach lasts (with 0.15, about 65 % of a jump is covered in
|
|
23
|
+
* 0.15 s). 0.15 by default, 0 follows at once; read every step.
|
|
24
|
+
*/
|
|
25
|
+
smoothing?: number;
|
|
26
|
+
}
|
|
27
|
+
/** The smoothing of a blend that does not set one. */
|
|
28
|
+
export declare const DEFAULT_BLEND_SMOOTHING = 0.15;
|
|
29
|
+
/** What building an Animator's blends needs from it. */
|
|
30
|
+
export interface BlendBuild {
|
|
31
|
+
readonly blends: Readonly<Record<string, unknown>>;
|
|
32
|
+
readonly clips: Readonly<Record<string, AnimatorClipOverride>>;
|
|
33
|
+
readonly mixer: THREE.AnimationMixer;
|
|
34
|
+
/** The file's clips. */
|
|
35
|
+
readonly animations: readonly THREE.AnimationClip[];
|
|
36
|
+
/** The smoothed value of a parameter. */
|
|
37
|
+
readonly value: (param: string) => number;
|
|
38
|
+
readonly warn: (key: string, message: string) => void;
|
|
39
|
+
}
|
|
40
|
+
/** An authored `blends` prop as a record by name, or undefined when it is not an object (JSON props arrive unchecked). */
|
|
41
|
+
export declare function blendsRecord(blends: unknown): Readonly<Record<string, unknown>> | undefined;
|
|
42
|
+
/** The parameter a blend reads, or undefined when it names none. */
|
|
43
|
+
export declare function blendParam(blend: unknown): string | undefined;
|
|
44
|
+
/** Each blend's name and the parameter it reads, in declaration order; one that names none is left out. */
|
|
45
|
+
export declare function blendReaders(blends: Readonly<Record<string, unknown>>): (readonly [string, string])[];
|
|
46
|
+
/** A blend's smoothing: its own when that is a finite number ≥ 0, else the default. */
|
|
47
|
+
export declare function blendSmoothing(blend: unknown): number;
|
|
48
|
+
/**
|
|
49
|
+
* The blends that can play on this file, as mix sources: a blend named like a
|
|
50
|
+
* clip of the file loses to it, a point whose clip the file lacks (another
|
|
51
|
+
* blend's name included) is skipped, and a blend left with no point does not
|
|
52
|
+
* play. Each case warns once.
|
|
53
|
+
*/
|
|
54
|
+
export declare function buildBlendSources(build: BlendBuild): BlendSource[];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import { BlendSource } from './animator-sources.js';
|
|
3
|
+
/** The smoothing of a blend that does not set one. */
|
|
4
|
+
export const DEFAULT_BLEND_SMOOTHING = 0.15;
|
|
5
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
/** An authored `blends` prop as a record by name, or undefined when it is not an object (JSON props arrive unchecked). */
|
|
7
|
+
export function blendsRecord(blends) {
|
|
8
|
+
return isRecord(blends) ? blends : undefined;
|
|
9
|
+
}
|
|
10
|
+
/** The parameter a blend reads, or undefined when it names none. */
|
|
11
|
+
export function blendParam(blend) {
|
|
12
|
+
const param = isRecord(blend) ? blend.param : undefined;
|
|
13
|
+
return typeof param === 'string' && param !== '' ? param : undefined;
|
|
14
|
+
}
|
|
15
|
+
/** Each blend's name and the parameter it reads, in declaration order; one that names none is left out. */
|
|
16
|
+
export function blendReaders(blends) {
|
|
17
|
+
return Object.entries(blends).flatMap(([name, blend]) => {
|
|
18
|
+
const param = blendParam(blend);
|
|
19
|
+
return param === undefined ? [] : [[name, param]];
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** A blend's smoothing: its own when that is a finite number ≥ 0, else the default. */
|
|
23
|
+
export function blendSmoothing(blend) {
|
|
24
|
+
const smoothing = isRecord(blend) ? blend.smoothing : undefined;
|
|
25
|
+
return typeof smoothing === 'number' && Number.isFinite(smoothing) && smoothing >= 0 ? smoothing : DEFAULT_BLEND_SMOOTHING;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The blends that can play on this file, as mix sources: a blend named like a
|
|
29
|
+
* clip of the file loses to it, a point whose clip the file lacks (another
|
|
30
|
+
* blend's name included) is skipped, and a blend left with no point does not
|
|
31
|
+
* play. Each case warns once.
|
|
32
|
+
*/
|
|
33
|
+
export function buildBlendSources(build) {
|
|
34
|
+
const clips = new Map(build.animations.map((clip) => [clip.name, clip]));
|
|
35
|
+
const sources = [];
|
|
36
|
+
for (const [name, blend] of Object.entries(build.blends)) {
|
|
37
|
+
if (clips.has(name)) {
|
|
38
|
+
build.warn(`blend:${name}`, `blend "${name}" has the name of a clip of the file; the clip plays`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const param = blendParam(blend);
|
|
42
|
+
const points = param === undefined ? [] : playablePoints(name, blend, { build, clips });
|
|
43
|
+
if (param === undefined)
|
|
44
|
+
build.warn(`blend:${name}`, `blend "${name}" names no param; it does not play`);
|
|
45
|
+
else if (points.length === 0)
|
|
46
|
+
build.warn(`blend:${name}`, `blend "${name}" has no point to play; it does not play`);
|
|
47
|
+
else
|
|
48
|
+
sources.push(new BlendSource(name, points, () => build.value(param)));
|
|
49
|
+
}
|
|
50
|
+
return sources;
|
|
51
|
+
}
|
|
52
|
+
/** A point's clip name and threshold as authored, whatever their types. */
|
|
53
|
+
function authoredPoint(point) {
|
|
54
|
+
if (!isRecord(point))
|
|
55
|
+
return { clip: String(point), at: undefined };
|
|
56
|
+
return { clip: typeof point.clip === 'string' ? point.clip : String(point.clip), at: point.at };
|
|
57
|
+
}
|
|
58
|
+
/** A blend's points that name a clip of the file at a finite threshold, in threshold order, each over an action of its own. */
|
|
59
|
+
function playablePoints(name, blend, scope) {
|
|
60
|
+
const authored = isRecord(blend) && Array.isArray(blend.points) ? blend.points : [];
|
|
61
|
+
const points = [];
|
|
62
|
+
for (const { clip: clipName, at } of authored.map(authoredPoint)) {
|
|
63
|
+
const clip = scope.clips.get(clipName);
|
|
64
|
+
const skip = (why) => scope.build.warn(`blend:${name}:${clipName}`, `blend "${name}" skips point "${clipName}": ${why}`);
|
|
65
|
+
if (!clip)
|
|
66
|
+
skip('the file has no such clip');
|
|
67
|
+
else if (typeof at !== 'number' || !Number.isFinite(at))
|
|
68
|
+
skip('its "at" is not a finite number');
|
|
69
|
+
else
|
|
70
|
+
points.push(blendPoint(clip, at, scope.build));
|
|
71
|
+
}
|
|
72
|
+
return points.sort((a, b) => a.at - b.at);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A point's own action, over a copy of the clip that shares its tracks: the
|
|
76
|
+
* mixer keeps one action per clip, and a blend's clips keep the blend's phase
|
|
77
|
+
* whatever the same clip does when it plays on its own. The mixer never
|
|
78
|
+
* advances it (time scale 0): the blend sets its time every step.
|
|
79
|
+
*/
|
|
80
|
+
function blendPoint(clip, at, build) {
|
|
81
|
+
const action = build.mixer.clipAction(new THREE.AnimationClip(clip.name, clip.duration, clip.tracks, clip.blendMode));
|
|
82
|
+
action.timeScale = 0;
|
|
83
|
+
const speed = build.clips[clip.name]?.speed;
|
|
84
|
+
const rate = typeof speed === 'number' && Number.isFinite(speed) && speed > 0 ? speed : 1;
|
|
85
|
+
return { clip: clip.name, at, action, duration: clip.duration, cycle: clip.duration / rate };
|
|
86
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One thing an Animator can sound (issue #205): a clip of the file, or a
|
|
3
|
+
* blend mixing several of them. The mix only weighs it; how it turns a weight
|
|
4
|
+
* into actions, and how far it is, is its own.
|
|
5
|
+
*/
|
|
6
|
+
export interface MixSource {
|
|
7
|
+
/** The clip or blend name `play` asked for. */
|
|
8
|
+
readonly name: string;
|
|
9
|
+
/** True once a one-shot reached its end (a blend never finishes). */
|
|
10
|
+
readonly finished: boolean;
|
|
11
|
+
/** Where it is, in seconds, for the Runtime Snapshot. */
|
|
12
|
+
readonly time: number;
|
|
13
|
+
/** How far it is, from 0 at its start to 1 at its end. */
|
|
14
|
+
readonly progress: number;
|
|
15
|
+
/** False when nothing of it advances (a finished one-shot holding its pose). */
|
|
16
|
+
readonly playing: boolean;
|
|
17
|
+
/** Plays it from its start. */
|
|
18
|
+
start(): void;
|
|
19
|
+
/** Stops its actions: a later start begins again at 0. */
|
|
20
|
+
stop(): void;
|
|
21
|
+
/** Gives its actions their share of `weight` for the step about to run, `dt` seconds long. */
|
|
22
|
+
apply(weight: number, dt: number): void;
|
|
23
|
+
/** Adds its share of each glb clip at `weight` into `into`. */
|
|
24
|
+
addWeights(weight: number, into: Record<string, number>): void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The fades of one Animator (issue #205, ADR 0033), scheduled by the Animator
|
|
28
|
+
* instead of three's `crossFadeTo`/`fadeOut`: entering a source moves it from
|
|
29
|
+
* the weight it has to 1 over the fade, and every other sounding source from
|
|
30
|
+
* the weight it has to 0 over that same fade, so an interrupted fade never
|
|
31
|
+
* jumps and the weights always add up to 1. A source that reaches 0 stops.
|
|
32
|
+
*/
|
|
33
|
+
export declare class AnimatorMix {
|
|
34
|
+
private voices;
|
|
35
|
+
private entered;
|
|
36
|
+
private elapsed;
|
|
37
|
+
private duration;
|
|
38
|
+
/** The source entered last: the one fading in, or sounding alone. */
|
|
39
|
+
get target(): MixSource | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Sounds `source` over `fade` seconds. One that still carries weight
|
|
42
|
+
* continues from where it is; a silent one, or a finished one-shot, starts
|
|
43
|
+
* at 0. With nothing sounding yet, or no fade, it cuts.
|
|
44
|
+
*/
|
|
45
|
+
enter(source: MixSource, fade: number): void;
|
|
46
|
+
/** Advances the running fade by `dt`, stops what fell silent and weighs the rest's actions for the step. */
|
|
47
|
+
step(dt: number): void;
|
|
48
|
+
/** Each glb clip's weight, only those above 0, the leaving ones included. */
|
|
49
|
+
weights(): Record<string, number>;
|
|
50
|
+
/** Stops every source and forgets them. */
|
|
51
|
+
clear(): void;
|
|
52
|
+
private cutTo;
|
|
53
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { SIMULATION_TIME_EPSILON } from '../fixed-step.js';
|
|
2
|
+
/**
|
|
3
|
+
* The fades of one Animator (issue #205, ADR 0033), scheduled by the Animator
|
|
4
|
+
* instead of three's `crossFadeTo`/`fadeOut`: entering a source moves it from
|
|
5
|
+
* the weight it has to 1 over the fade, and every other sounding source from
|
|
6
|
+
* the weight it has to 0 over that same fade, so an interrupted fade never
|
|
7
|
+
* jumps and the weights always add up to 1. A source that reaches 0 stops.
|
|
8
|
+
*/
|
|
9
|
+
export class AnimatorMix {
|
|
10
|
+
voices = [];
|
|
11
|
+
entered;
|
|
12
|
+
elapsed = 0;
|
|
13
|
+
duration = 0;
|
|
14
|
+
/** The source entered last: the one fading in, or sounding alone. */
|
|
15
|
+
get target() {
|
|
16
|
+
return this.entered?.source;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Sounds `source` over `fade` seconds. One that still carries weight
|
|
20
|
+
* continues from where it is; a silent one, or a finished one-shot, starts
|
|
21
|
+
* at 0. With nothing sounding yet, or no fade, it cuts.
|
|
22
|
+
*/
|
|
23
|
+
enter(source, fade) {
|
|
24
|
+
const sounding = this.voices.some((voice) => voice.weight > 0);
|
|
25
|
+
let voice = this.voices.find((candidate) => candidate.source === source);
|
|
26
|
+
if (!voice) {
|
|
27
|
+
voice = { source, weight: 0, from: 0, to: 1 };
|
|
28
|
+
this.voices.push(voice);
|
|
29
|
+
source.start();
|
|
30
|
+
}
|
|
31
|
+
else if (voice.weight <= 0 || source.finished) {
|
|
32
|
+
source.start();
|
|
33
|
+
}
|
|
34
|
+
this.entered = voice;
|
|
35
|
+
if (!sounding || !(fade > 0 && Number.isFinite(fade))) {
|
|
36
|
+
this.cutTo(voice);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const each of this.voices) {
|
|
40
|
+
each.from = each.weight;
|
|
41
|
+
each.to = each === voice ? 1 : 0;
|
|
42
|
+
}
|
|
43
|
+
this.elapsed = 0;
|
|
44
|
+
this.duration = fade;
|
|
45
|
+
}
|
|
46
|
+
/** Advances the running fade by `dt`, stops what fell silent and weighs the rest's actions for the step. */
|
|
47
|
+
step(dt) {
|
|
48
|
+
if (this.duration > 0) {
|
|
49
|
+
this.elapsed += dt;
|
|
50
|
+
// Steps of 1/60 add up to a hair under some fades (six make 0.09999…): within the epsilon the fade is over.
|
|
51
|
+
const t = this.elapsed + SIMULATION_TIME_EPSILON >= this.duration ? 1 : this.elapsed / this.duration;
|
|
52
|
+
for (const voice of this.voices)
|
|
53
|
+
voice.weight = t >= 1 ? voice.to : voice.from + (voice.to - voice.from) * t;
|
|
54
|
+
if (t >= 1)
|
|
55
|
+
this.duration = 0;
|
|
56
|
+
}
|
|
57
|
+
this.voices = this.voices.filter((voice) => {
|
|
58
|
+
if (voice.to > 0 || voice.weight > 0)
|
|
59
|
+
return true;
|
|
60
|
+
voice.source.stop();
|
|
61
|
+
return false;
|
|
62
|
+
});
|
|
63
|
+
for (const voice of this.voices)
|
|
64
|
+
voice.source.apply(voice.weight, dt);
|
|
65
|
+
}
|
|
66
|
+
/** Each glb clip's weight, only those above 0, the leaving ones included. */
|
|
67
|
+
weights() {
|
|
68
|
+
const sum = {};
|
|
69
|
+
for (const voice of this.voices)
|
|
70
|
+
if (voice.weight > 0)
|
|
71
|
+
voice.source.addWeights(voice.weight, sum);
|
|
72
|
+
return Object.fromEntries(Object.entries(sum).filter(([, weight]) => weight > 0));
|
|
73
|
+
}
|
|
74
|
+
/** Stops every source and forgets them. */
|
|
75
|
+
clear() {
|
|
76
|
+
for (const voice of this.voices)
|
|
77
|
+
voice.source.stop();
|
|
78
|
+
this.voices = [];
|
|
79
|
+
this.entered = undefined;
|
|
80
|
+
this.duration = 0;
|
|
81
|
+
}
|
|
82
|
+
cutTo(voice) {
|
|
83
|
+
for (const other of this.voices)
|
|
84
|
+
if (other !== voice)
|
|
85
|
+
other.source.stop();
|
|
86
|
+
this.voices = [voice];
|
|
87
|
+
voice.weight = 1;
|
|
88
|
+
voice.from = 1;
|
|
89
|
+
voice.to = 1;
|
|
90
|
+
this.duration = 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The named parameters of an Animator (issue #205, ADR 0033): each one
|
|
3
|
+
* approaches the target `setParam` gave it by a first-order lag, a fraction
|
|
4
|
+
* dt / smoothing of the gap per Simulation Step (all of it at once with no
|
|
5
|
+
* smoothing), and snaps onto it within 1e-4. Plain arithmetic, so the value
|
|
6
|
+
* after N steps is the same on every platform. A parameter nobody wrote is 0.
|
|
7
|
+
*/
|
|
8
|
+
export declare class AnimatorParams {
|
|
9
|
+
private readonly targets;
|
|
10
|
+
private readonly values;
|
|
11
|
+
/** The smoothed value of `name`. */
|
|
12
|
+
value(name: string): number;
|
|
13
|
+
/** Aims `name` at `target`; with no smoothing the value is there at once. */
|
|
14
|
+
set(name: string, target: number, smoothing: number): void;
|
|
15
|
+
/** One step of every parameter toward its target, each at its own smoothing. */
|
|
16
|
+
step(dt: number, smoothingOf: (name: string) => number): void;
|
|
17
|
+
/** Forgets every target and value: each parameter is 0 again, as nobody had written it. */
|
|
18
|
+
clear(): void;
|
|
19
|
+
/** Every smoothed value: the `declared` names first (0 until written), then the others written. */
|
|
20
|
+
snapshot(declared: Iterable<string>): Record<string, number>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** How near its target a smoothed parameter snaps onto it. */
|
|
2
|
+
const SNAP = 1e-4;
|
|
3
|
+
/**
|
|
4
|
+
* The named parameters of an Animator (issue #205, ADR 0033): each one
|
|
5
|
+
* approaches the target `setParam` gave it by a first-order lag, a fraction
|
|
6
|
+
* dt / smoothing of the gap per Simulation Step (all of it at once with no
|
|
7
|
+
* smoothing), and snaps onto it within 1e-4. Plain arithmetic, so the value
|
|
8
|
+
* after N steps is the same on every platform. A parameter nobody wrote is 0.
|
|
9
|
+
*/
|
|
10
|
+
export class AnimatorParams {
|
|
11
|
+
targets = new Map();
|
|
12
|
+
values = new Map();
|
|
13
|
+
/** The smoothed value of `name`. */
|
|
14
|
+
value(name) {
|
|
15
|
+
return this.values.get(name) ?? 0;
|
|
16
|
+
}
|
|
17
|
+
/** Aims `name` at `target`; with no smoothing the value is there at once. */
|
|
18
|
+
set(name, target, smoothing) {
|
|
19
|
+
this.targets.set(name, target);
|
|
20
|
+
if (smoothing > 0)
|
|
21
|
+
this.values.set(name, this.value(name));
|
|
22
|
+
else
|
|
23
|
+
this.values.set(name, target);
|
|
24
|
+
}
|
|
25
|
+
/** One step of every parameter toward its target, each at its own smoothing. */
|
|
26
|
+
step(dt, smoothingOf) {
|
|
27
|
+
for (const [name, target] of this.targets) {
|
|
28
|
+
const value = this.value(name);
|
|
29
|
+
if (value === target)
|
|
30
|
+
continue;
|
|
31
|
+
const smoothing = smoothingOf(name);
|
|
32
|
+
const next = smoothing > 0 ? value + (target - value) * Math.min(1, dt / smoothing) : target;
|
|
33
|
+
this.values.set(name, Math.abs(target - next) < SNAP ? target : next);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Forgets every target and value: each parameter is 0 again, as nobody had written it. */
|
|
37
|
+
clear() {
|
|
38
|
+
this.targets.clear();
|
|
39
|
+
this.values.clear();
|
|
40
|
+
}
|
|
41
|
+
/** Every smoothed value: the `declared` names first (0 until written), then the others written. */
|
|
42
|
+
snapshot(declared) {
|
|
43
|
+
const snapshot = {};
|
|
44
|
+
for (const name of declared)
|
|
45
|
+
snapshot[name] = this.value(name);
|
|
46
|
+
for (const [name, value] of this.values)
|
|
47
|
+
snapshot[name] = value;
|
|
48
|
+
return snapshot;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import type { MixSource } from './animator-mix.js';
|
|
3
|
+
/** A clip of the file as one sound of the mix: its own action, advanced by the mixer at its `speed`. */
|
|
4
|
+
export declare class ClipSource implements MixSource {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
private readonly action;
|
|
7
|
+
constructor(name: string, action: THREE.AnimationAction);
|
|
8
|
+
/** A `loop: false` clip clamps when it ends, which three marks by pausing it: nothing else pauses an action here. */
|
|
9
|
+
get finished(): boolean;
|
|
10
|
+
get time(): number;
|
|
11
|
+
get progress(): number;
|
|
12
|
+
get playing(): boolean;
|
|
13
|
+
start(): void;
|
|
14
|
+
stop(): void;
|
|
15
|
+
apply(weight: number): void;
|
|
16
|
+
addWeights(weight: number, into: Record<string, number>): void;
|
|
17
|
+
}
|
|
18
|
+
/** One point of a blend as it plays: its clip, threshold and own action, and the clip's length and cycle at its speed. */
|
|
19
|
+
export interface BlendPoint {
|
|
20
|
+
readonly clip: string;
|
|
21
|
+
readonly at: number;
|
|
22
|
+
readonly action: THREE.AnimationAction;
|
|
23
|
+
/** The clip's length in seconds. */
|
|
24
|
+
readonly duration: number;
|
|
25
|
+
/** Seconds of one cycle at the clip's `speed` override. */
|
|
26
|
+
readonly cycle: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A blend as one sound of the mix (issue #205, ADR 0033): its points share
|
|
30
|
+
* one normalized phase, advanced by the step over the cycle interpolated by
|
|
31
|
+
* the points' shares, so walk and run put their feet down together; each
|
|
32
|
+
* point's action sits at that phase of its own clip, weighted by its share.
|
|
33
|
+
*/
|
|
34
|
+
export declare class BlendSource implements MixSource {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
private readonly points;
|
|
37
|
+
private readonly value;
|
|
38
|
+
private phase;
|
|
39
|
+
constructor(name: string, points: readonly BlendPoint[], value: () => number);
|
|
40
|
+
/** A blend loops: it never finishes. */
|
|
41
|
+
get finished(): boolean;
|
|
42
|
+
/** The phase as seconds of the interpolated cycle. */
|
|
43
|
+
get time(): number;
|
|
44
|
+
get progress(): number;
|
|
45
|
+
get playing(): boolean;
|
|
46
|
+
start(): void;
|
|
47
|
+
stop(): void;
|
|
48
|
+
apply(weight: number, dt: number): void;
|
|
49
|
+
addWeights(weight: number, into: Record<string, number>): void;
|
|
50
|
+
private cycle;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Each point's share of a 1D blend at `value`: the two points around it,
|
|
54
|
+
* weighted linearly by where it falls between their thresholds; past either
|
|
55
|
+
* end, that end alone. `points` are in threshold order.
|
|
56
|
+
*/
|
|
57
|
+
export declare function blendShares(points: readonly {
|
|
58
|
+
readonly at: number;
|
|
59
|
+
}[], value: number): number[];
|