@msb235/blits 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/README.md +9 -2
- package/dist/channels.d.ts +41 -7
- package/dist/channels.js +184 -29
- package/dist/clock.d.ts +19 -0
- package/dist/clock.js +44 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/lanes.d.ts +207 -0
- package/dist/lanes.js +928 -0
- package/dist/mixer.d.ts +185 -2
- package/dist/mixer.js +1264 -139
- package/dist/motion.d.ts +169 -9
- package/dist/motion.js +564 -112
- package/dist/numbers.d.ts +22 -0
- package/dist/numbers.js +64 -0
- package/dist/patch.d.ts +13 -2
- package/dist/patch.js +44 -18
- package/dist/reading.d.ts +23 -0
- package/dist/reading.js +23 -0
- package/dist/types.d.ts +242 -11
- package/docs/schema.html +119 -24
- package/package.json +3 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,102 @@ This package follows [semver](https://semver.org). Below 1.0.0, a breaking chang
|
|
|
4
4
|
version and everything else the patch. Each release lists its changes as **Breaking**, **Added** and
|
|
5
5
|
**Fixed**, and the release workflow refuses a tag with no section here.
|
|
6
6
|
|
|
7
|
+
## 0.3.0
|
|
8
|
+
|
|
9
|
+
### Breaking
|
|
10
|
+
|
|
11
|
+
- `spring` and `glide` build patches of a third form, `'motion'`, instead of `'fn'`, and carry
|
|
12
|
+
their kind and constants on `patch.motion`, a `MotionSpec`. `Patch.form` and `Engine.runs` take
|
|
13
|
+
`'motion'`, so an engine that declares `runs` must list it to run a spring or a glide. A motion
|
|
14
|
+
patch throws when two of its subjects, or a subject's start, target and velocity, or a `to` or
|
|
15
|
+
`push`, move on different numbers of axes. It makes a subject's first stretch at its first `to`
|
|
16
|
+
or `push` as well as at its first read, so `read(subject, at)` answers from then on.
|
|
17
|
+
- An untimed retarget lands at the subject's own voice time at the mix's latest sync, not at the
|
|
18
|
+
subject's next read, so a subject the host did not probe changes at the same moment as one it
|
|
19
|
+
did; `read` with no time answers at that time, counting every change due by then. For a subject
|
|
20
|
+
no frame of its voice has met yet, or still inside its stagger, an untimed change applies at its
|
|
21
|
+
first read, after any timed change due by then, and `read` with no time returns undefined, as it
|
|
22
|
+
does once the voice is gone. Changes apply in time order, not the order they were made.
|
|
23
|
+
- `drop(subject)` also forgets a motion patch's state for the subject, so it starts afresh.
|
|
24
|
+
- With lanes on, a stateless patch calling `setting.send` from `at` sends every frame for every
|
|
25
|
+
subject the mix has met, probed that frame or not; and a patch that first calls `setting.keep`
|
|
26
|
+
partway through playing can advance that state once more for one subject the host did not probe
|
|
27
|
+
that frame.
|
|
28
|
+
- `mixHex`, and so the `hex` channel, blends in OKLCH rather than sRGB, taking hue the short way
|
|
29
|
+
round and a gray end's hue from the other end. A crossfade from red to blue passes through
|
|
30
|
+
0xba00c2 rather than 0x800080, and black to white through 0x636363 rather than 0x808080. A blend
|
|
31
|
+
costs about 45 ns where the two ends repeat frame to frame and 210 ns where every call brings new
|
|
32
|
+
colors, against 8 ns in sRGB. `hex({ space: 'srgb' })` keeps the sRGB blend.
|
|
33
|
+
- `Mix` has a `pull` method, so an engine's `create` must return a mix that implements it.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `tween(writes, { from, to, ms, ease? })`, a motion patch that eases each subject from one value
|
|
38
|
+
to another over `ms`, with `from` and `to` given per subject and read once when the mix first
|
|
39
|
+
meets it. `to(subject, target)` retargets one subject from where it is over a full `ms` again,
|
|
40
|
+
and `read` reports value and velocity as a spring's does. It runs on lanes as springs do, with
|
|
41
|
+
each subject's endpoints as data, so a tween over every subject no longer needs a `fn` that looks
|
|
42
|
+
its endpoints up on each call: in weasel's shape (10,000 string ids, endpoints in a map) a frame
|
|
43
|
+
costs 0.83 of that `fn`'s and pauses for collection a quarter as long. A motion lane no longer
|
|
44
|
+
allocates a delta per subject either, which takes a spring frame over 10,000 subjects to
|
|
45
|
+
0.82–0.90 of the last build's. `MotionSpec` gains
|
|
46
|
+
`{ kind: 'tween', ms, ease }`.
|
|
47
|
+
- Lanes: a channel every voice writing it can run that way is computed for every subject at once,
|
|
48
|
+
in flat arrays, at the frame's first probe, rather than subject by subject, and gives the same
|
|
49
|
+
pose. `keys`, a stateless `fn` and `motion` voices with a number weight qualify. Three `keys`
|
|
50
|
+
voices over 10,000 subjects fell from 6.1 to 2.6 ms a frame. A lane whose subjects were mostly
|
|
51
|
+
left unprobed last frame stops filling and its subjects take the general path until probes pick
|
|
52
|
+
up again, so a host probing 5% of 10,000 subjects pays about what it would with lanes off, not
|
|
53
|
+
ten times it. `MixOptions.lanes: false` turns lanes off. They cost a little on frames they
|
|
54
|
+
don't serve: a voice per subject reads up to 7% slower at 10,000 subjects (about 20% at
|
|
55
|
+
100–1,000, a few hundredths of a millisecond), a mix probing few of its subjects about 0.03 ms
|
|
56
|
+
more, and a projection made every frame about 10% more.
|
|
57
|
+
- `mix.pull(subjects, { channel: Float64Array, ... })` writes each subject's pose into one array per
|
|
58
|
+
channel, in the order given, giving what `probe(subject, out)` gives without a pose object per
|
|
59
|
+
subject. A channel of `n` numbers takes `n` places a subject; one with no value writes NaN. While
|
|
60
|
+
every voice runs on a lane it copies straight from the lanes: a frame of three `keys` voices
|
|
61
|
+
over 10,000 subjects costs 0.84 of the same frame read through `probe`, a spring 0.91.
|
|
62
|
+
- `mix.project(timestamp)` reads the mix at another time without moving it: `probe` gives the pose
|
|
63
|
+
then, and `assess` says per channel whether it is `exact`, `stepped` or `held`. Ahead it plays
|
|
64
|
+
what is cued forward; behind it needs `MixOptions.history: { ms, every? }`, which keeps every
|
|
65
|
+
handle change, the voices that left, and copies of stateful state, so a read back under `stepMs`
|
|
66
|
+
lands on the pose the mix showed, and with `inputs` it records what input weight signals and the
|
|
67
|
+
host fields patches `reads` held, so a read back over a `level` or a pointer is known. `Patch.clone` copies state `structuredClone` cannot.
|
|
68
|
+
- The score: a voice takes a `name` and an `anchor` placement, `start` or `in` and `out` or `end`,
|
|
69
|
+
each a timestamp or another voice's mark (`after`, `with`, `before`, or `of` plus `mark`),
|
|
70
|
+
selected by name, tag or written channel with a resolver. Each source can keep its own `score`,
|
|
71
|
+
with names namespaced per score. `mix.announce(name, { at? })` puts a named mark on a score for
|
|
72
|
+
anchors to wait on, and `mix.marks(from, to)` lists every mark the plan knows.
|
|
73
|
+
- `spring` and `glide` keep every stretch within the mix's history, so a read before a retarget
|
|
74
|
+
finds where the subject was.
|
|
75
|
+
- A cue can name its subjects, `subjects: [a, b]`, in place of a `target`. The mix files the voice
|
|
76
|
+
under each one, so a voice per subject costs the same as one voice: the first frame of 1,000
|
|
77
|
+
subjects on a voice each fell from about 500 ms to 1 ms.
|
|
78
|
+
|
|
79
|
+
### Fixed
|
|
80
|
+
|
|
81
|
+
- A voice faded before its start plays once its start arrives. It used to stay out of every
|
|
82
|
+
subject's fold until some other voice was cued or left.
|
|
83
|
+
- A probe after a `seek` in the same frame reads the voice where the seek put it, rather than the
|
|
84
|
+
value an earlier probe that frame read before the seek. A stateful patch still steps once.
|
|
85
|
+
- A probe looks its subject up once rather than once per voice, which makes frames with several
|
|
86
|
+
voices 5–12% faster.
|
|
87
|
+
- A `keys` patch reads a keyed `vec` channel into an array its voice reuses, rather than a new one
|
|
88
|
+
each read, which halves collections at 10,000 subjects and leaves frame time unchanged.
|
|
89
|
+
|
|
90
|
+
- A finite loop's fade out starts when its last pass ended, not at the first frame after, so it
|
|
91
|
+
plays the same at any frame rate.
|
|
92
|
+
- The published `package.json` no longer lists `workspaces: ["site"]`, which only the repo's docs
|
|
93
|
+
site uses.
|
|
94
|
+
|
|
95
|
+
## 0.2.1
|
|
96
|
+
|
|
97
|
+
### Added
|
|
98
|
+
|
|
99
|
+
- Channel `bounds`: `sum`, `mul` and `max` take `{ bounds: [min, max] }`, and `vec` inherits its
|
|
100
|
+
axis channel's. The mix clamps the folded value to the range, so stacked voices and a retarget
|
|
101
|
+
that carries speed stop flat at the bound. Bounds are part of the channel's kind.
|
|
102
|
+
|
|
7
103
|
## 0.2.0
|
|
8
104
|
|
|
9
105
|
### Breaking
|
package/README.md
CHANGED
|
@@ -68,10 +68,17 @@ npm run site:smoke # build, then load every page headless and fail on any erro
|
|
|
68
68
|
`.github/workflows/site.yml` deploys it to GitHub Pages. It runs only by hand until Pages is enabled
|
|
69
69
|
on the repo.
|
|
70
70
|
|
|
71
|
+
## Packages
|
|
72
|
+
|
|
73
|
+
`packages/quarks` is `@msb235/blits-quarks`, a driver that turns poses into
|
|
74
|
+
[three.quarks](https://github.com/Alchemist0823/three.quarks) particles. It is published separately
|
|
75
|
+
so the engine keeps no dependencies; its README says how it works.
|
|
76
|
+
|
|
71
77
|
## Status
|
|
72
78
|
|
|
73
|
-
v0.
|
|
74
|
-
its systems — letter motion,
|
|
79
|
+
v0.3.0, with two consumers. magicsmoke runs every fault on it, and
|
|
80
|
+
[klieg](https://github.com/orochi235/klieg) composes all three of its systems — letter motion,
|
|
81
|
+
part effects, and the environment a sign is lit by — on a mix each, on a branch not yet merged.
|
|
75
82
|
That port was an extraction rather than a rewrite, so it is also the evidence that the arithmetic
|
|
76
83
|
here is the arithmetic a working renderer already had: klieg's suite of 1974 cases and its
|
|
77
84
|
screenshot baselines came through it unchanged.
|
package/dist/channels.d.ts
CHANGED
|
@@ -1,22 +1,53 @@
|
|
|
1
1
|
import type { Channel, Kit } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Writes `lerp(a, b, u)` into `out` and returns it, or returns a new array where `out` is absent or
|
|
4
|
+
* the wrong length. The caller must own `out`: nothing else may hold it.
|
|
5
|
+
*/
|
|
6
|
+
export type LerpInto = (out: unknown[] | undefined, a: unknown, b: unknown, u: number) => unknown[];
|
|
7
|
+
/**
|
|
8
|
+
* The in-place form of a stock array channel's `lerp`. Keyed by the channel rather than its `lerp`,
|
|
9
|
+
* since only the stock fold and merge are known to copy the array rather than keep it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function lerpInto(channel: Channel<unknown>): LerpInto | undefined;
|
|
12
|
+
/** What a lane needs of a stock numeric channel beyond the channel: its arithmetic by name, its axes. */
|
|
13
|
+
export interface Numeric {
|
|
14
|
+
op: 'sum' | 'mul' | 'max';
|
|
15
|
+
axes: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The stock arithmetic of a channel `sum`, `mul`, `max` or `vec` made, keyed by the object itself,
|
|
19
|
+
* so a custom channel that claims a stock `kind` is not trusted with a lane.
|
|
20
|
+
*/
|
|
21
|
+
export declare function numericOf(channel: Channel<unknown>): Numeric | undefined;
|
|
22
|
+
/** `merge(acc, scale(v, w))` for a stock numeric channel, in exactly its arithmetic. */
|
|
23
|
+
export declare function foldNumber(op: Numeric['op'], acc: number, v: number, w: number): number;
|
|
24
|
+
/**
|
|
25
|
+
* What a stock numeric channel takes.
|
|
26
|
+
*
|
|
27
|
+
* @category channel
|
|
28
|
+
*/
|
|
29
|
+
export interface NumberOptions {
|
|
30
|
+
/** The range the value means anything in; the mix clamps to it. */
|
|
31
|
+
bounds?: readonly [min: number, max: number];
|
|
32
|
+
}
|
|
2
33
|
/**
|
|
3
34
|
* Position axes, rotation, crawl, yaw, pitch, offset.
|
|
4
35
|
*
|
|
5
36
|
* @category channel
|
|
6
37
|
*/
|
|
7
|
-
export declare function sum(): Channel<number>;
|
|
38
|
+
export declare function sum(opts?: NumberOptions): Channel<number>;
|
|
8
39
|
/**
|
|
9
40
|
* Gain, scale, opacity, hold.
|
|
10
41
|
*
|
|
11
42
|
* @category channel
|
|
12
43
|
*/
|
|
13
|
-
export declare function mul(): Channel<number>;
|
|
44
|
+
export declare function mul(opts?: NumberOptions): Channel<number>;
|
|
14
45
|
/**
|
|
15
46
|
* Dark, aperture.
|
|
16
47
|
*
|
|
17
48
|
* @category channel
|
|
18
49
|
*/
|
|
19
|
-
export declare function max(): Channel<number>;
|
|
50
|
+
export declare function max(opts?: NumberOptions): Channel<number>;
|
|
20
51
|
/**
|
|
21
52
|
* A channel with no rest: the last influence to pass wins, and a weight can only gate it. The
|
|
22
53
|
* default `lerp` steps at the midpoint, which is all a value with no arithmetic can promise; a
|
|
@@ -34,18 +65,21 @@ export declare function last<V>(opts?: {
|
|
|
34
65
|
*/
|
|
35
66
|
export declare function vec(n: number, of: Channel<number>): Channel<number[]>;
|
|
36
67
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
68
|
+
* OKLCH, hue the short way round, so a crossfade between hues keeps its lightness and saturation
|
|
69
|
+
* instead of dipping through a muddy middle. A mix that leaves sRGB's gamut is clipped per channel.
|
|
39
70
|
*
|
|
40
71
|
* @category channel
|
|
41
72
|
*/
|
|
42
73
|
export declare function mixHex(a: number, b: number, u: number): number;
|
|
43
74
|
/**
|
|
44
|
-
* Color, as 0xrrggbb
|
|
75
|
+
* Color, as 0xrrggbb, blended in OKLCH as `mixHex` does, or with `space: 'srgb'` channel by
|
|
76
|
+
* channel in sRGB, which keeps a port's arithmetic identical to a host that blended that way.
|
|
45
77
|
*
|
|
46
78
|
* @category channel
|
|
47
79
|
*/
|
|
48
|
-
export declare function hex(
|
|
80
|
+
export declare function hex(opts?: {
|
|
81
|
+
space?: 'oklch' | 'srgb';
|
|
82
|
+
}): Channel<number>;
|
|
49
83
|
/**
|
|
50
84
|
* A kit is plain data; this only fixes the type.
|
|
51
85
|
*
|
package/dist/channels.js
CHANGED
|
@@ -1,45 +1,86 @@
|
|
|
1
1
|
const mix = (a, b, u) => a + (b - a) * u;
|
|
2
|
+
const inPlace = new WeakMap();
|
|
3
|
+
/**
|
|
4
|
+
* The in-place form of a stock array channel's `lerp`. Keyed by the channel rather than its `lerp`,
|
|
5
|
+
* since only the stock fold and merge are known to copy the array rather than keep it.
|
|
6
|
+
*/
|
|
7
|
+
export function lerpInto(channel) {
|
|
8
|
+
return inPlace.get(channel);
|
|
9
|
+
}
|
|
10
|
+
/** The stock channels' arithmetic, once: their `merge` and `scale` are these, and so is `foldNumber`. */
|
|
11
|
+
const add = (a, b) => a + b;
|
|
12
|
+
const times = (v, w) => v * w;
|
|
13
|
+
const product = (a, b) => a * b;
|
|
14
|
+
const toward = (v, w) => 1 + (v - 1) * w;
|
|
15
|
+
const larger = (a, b) => (a > b ? a : b);
|
|
16
|
+
const numerics = new WeakMap();
|
|
17
|
+
/**
|
|
18
|
+
* The stock arithmetic of a channel `sum`, `mul`, `max` or `vec` made, keyed by the object itself,
|
|
19
|
+
* so a custom channel that claims a stock `kind` is not trusted with a lane.
|
|
20
|
+
*/
|
|
21
|
+
export function numericOf(channel) {
|
|
22
|
+
return numerics.get(channel);
|
|
23
|
+
}
|
|
24
|
+
/** `merge(acc, scale(v, w))` for a stock numeric channel, in exactly its arithmetic. */
|
|
25
|
+
export function foldNumber(op, acc, v, w) {
|
|
26
|
+
if (op === 'sum')
|
|
27
|
+
return add(acc, times(v, w));
|
|
28
|
+
if (op === 'mul')
|
|
29
|
+
return product(acc, toward(v, w));
|
|
30
|
+
return larger(acc, times(v, w));
|
|
31
|
+
}
|
|
32
|
+
/** Bounds change how a value folds at its edges, so they are part of the kind. */
|
|
33
|
+
const kindOf = (name, bounds) => bounds === undefined ? name : `${name}[${bounds[0]}, ${bounds[1]}]`;
|
|
2
34
|
/**
|
|
3
35
|
* Position axes, rotation, crawl, yaw, pitch, offset.
|
|
4
36
|
*
|
|
5
37
|
* @category channel
|
|
6
38
|
*/
|
|
7
|
-
export function sum() {
|
|
8
|
-
|
|
9
|
-
kind: 'sum',
|
|
39
|
+
export function sum(opts) {
|
|
40
|
+
const channel = {
|
|
41
|
+
kind: kindOf('sum', opts?.bounds),
|
|
42
|
+
bounds: opts?.bounds,
|
|
10
43
|
rest: 0,
|
|
11
|
-
merge:
|
|
12
|
-
scale:
|
|
44
|
+
merge: add,
|
|
45
|
+
scale: times,
|
|
13
46
|
lerp: mix,
|
|
14
47
|
};
|
|
48
|
+
numerics.set(channel, { op: 'sum', axes: 1 });
|
|
49
|
+
return channel;
|
|
15
50
|
}
|
|
16
51
|
/**
|
|
17
52
|
* Gain, scale, opacity, hold.
|
|
18
53
|
*
|
|
19
54
|
* @category channel
|
|
20
55
|
*/
|
|
21
|
-
export function mul() {
|
|
22
|
-
|
|
23
|
-
kind: 'mul',
|
|
56
|
+
export function mul(opts) {
|
|
57
|
+
const channel = {
|
|
58
|
+
kind: kindOf('mul', opts?.bounds),
|
|
59
|
+
bounds: opts?.bounds,
|
|
24
60
|
rest: 1,
|
|
25
|
-
merge:
|
|
26
|
-
scale:
|
|
61
|
+
merge: product,
|
|
62
|
+
scale: toward,
|
|
27
63
|
lerp: mix,
|
|
28
64
|
};
|
|
65
|
+
numerics.set(channel, { op: 'mul', axes: 1 });
|
|
66
|
+
return channel;
|
|
29
67
|
}
|
|
30
68
|
/**
|
|
31
69
|
* Dark, aperture.
|
|
32
70
|
*
|
|
33
71
|
* @category channel
|
|
34
72
|
*/
|
|
35
|
-
export function max() {
|
|
36
|
-
|
|
37
|
-
kind: 'max',
|
|
73
|
+
export function max(opts) {
|
|
74
|
+
const channel = {
|
|
75
|
+
kind: kindOf('max', opts?.bounds),
|
|
76
|
+
bounds: opts?.bounds,
|
|
38
77
|
rest: 0,
|
|
39
|
-
merge:
|
|
40
|
-
scale:
|
|
78
|
+
merge: larger,
|
|
79
|
+
scale: times,
|
|
41
80
|
lerp: mix,
|
|
42
81
|
};
|
|
82
|
+
numerics.set(channel, { op: 'max', axes: 1 });
|
|
83
|
+
return channel;
|
|
43
84
|
}
|
|
44
85
|
/**
|
|
45
86
|
* A channel with no rest: the last influence to pass wins, and a weight can only gate it. The
|
|
@@ -56,17 +97,39 @@ export function last(opts) {
|
|
|
56
97
|
lerp: opts?.lerp ?? ((a, b, u) => (u < 0.5 ? a : b)),
|
|
57
98
|
};
|
|
58
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* `n` copies of `v` in an array the engine stores as doubles from the start, so a copy of it, and
|
|
102
|
+
* every fraction written into that copy, keeps one element kind: a pose's arrays then look alike to
|
|
103
|
+
* every fold, whichever path made them.
|
|
104
|
+
*/
|
|
105
|
+
function doubles(n, v) {
|
|
106
|
+
if (n === 0)
|
|
107
|
+
return [];
|
|
108
|
+
const a = [0.5];
|
|
109
|
+
for (let i = 1; i < n; i++)
|
|
110
|
+
a.push(0.5);
|
|
111
|
+
for (let i = 0; i < n; i++)
|
|
112
|
+
a[i] = v;
|
|
113
|
+
return a;
|
|
114
|
+
}
|
|
59
115
|
/**
|
|
60
116
|
* Vec3 position, premultiplied light: one channel's arithmetic applied down an axis list.
|
|
61
117
|
*
|
|
62
118
|
* @category channel
|
|
63
119
|
*/
|
|
64
120
|
export function vec(n, of) {
|
|
65
|
-
const rest = of.rest === undefined ? undefined :
|
|
121
|
+
const rest = of.rest === undefined ? undefined : doubles(n, of.rest);
|
|
66
122
|
const scale = of.scale;
|
|
67
123
|
const fill = of.rest ?? 0;
|
|
68
|
-
|
|
124
|
+
const lerpTo = (out, a, b, u) => {
|
|
125
|
+
const o = out?.length === n ? out : new Array(n);
|
|
126
|
+
for (let i = 0; i < n; i++)
|
|
127
|
+
o[i] = of.lerp(a[i] ?? fill, b[i] ?? fill, u);
|
|
128
|
+
return o;
|
|
129
|
+
};
|
|
130
|
+
const channel = {
|
|
69
131
|
kind: of.kind === undefined ? undefined : `vec(${n}, ${of.kind})`,
|
|
132
|
+
bounds: of.bounds,
|
|
70
133
|
rest,
|
|
71
134
|
merge: (a, b) => {
|
|
72
135
|
const out = new Array(n);
|
|
@@ -89,22 +152,113 @@ export function vec(n, of) {
|
|
|
89
152
|
into[i] = of.merge(into[i] ?? fill, scale(v[i] ?? fill, w));
|
|
90
153
|
return into;
|
|
91
154
|
},
|
|
92
|
-
lerp: (a, b, u) =>
|
|
93
|
-
const out = new Array(n);
|
|
94
|
-
for (let i = 0; i < n; i++)
|
|
95
|
-
out[i] = of.lerp(a[i] ?? fill, b[i] ?? fill, u);
|
|
96
|
-
return out;
|
|
97
|
-
},
|
|
155
|
+
lerp: (a, b, u) => lerpTo(undefined, a, b, u),
|
|
98
156
|
};
|
|
157
|
+
inPlace.set(channel, lerpTo);
|
|
158
|
+
const inner = numerics.get(of);
|
|
159
|
+
if (inner !== undefined && inner.axes === 1)
|
|
160
|
+
numerics.set(channel, { ...inner, axes: n });
|
|
161
|
+
return channel;
|
|
99
162
|
}
|
|
100
|
-
const
|
|
163
|
+
const LINEAR = Array.from({ length: 256 }, (_, c) => {
|
|
164
|
+
const v = c / 255;
|
|
165
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
166
|
+
});
|
|
167
|
+
/** The linear value halfway between byte c - 1 and c: at or above it, a value rounds to c. */
|
|
168
|
+
const ROUNDS_UP = LINEAR.map((_, c) => {
|
|
169
|
+
const v = (c - 0.5) / 255;
|
|
170
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
171
|
+
});
|
|
172
|
+
/** The byte each 1/4096 of linear light starts at; a bin holds at most one rounding point. */
|
|
173
|
+
const BINS = 4096;
|
|
174
|
+
const FIRST = Array.from({ length: BINS + 1 }, (_, i) => {
|
|
175
|
+
let c = 0;
|
|
176
|
+
while (c < 255 && i / BINS >= ROUNDS_UP[c + 1])
|
|
177
|
+
c++;
|
|
178
|
+
return c;
|
|
179
|
+
});
|
|
180
|
+
const toByte = (v) => {
|
|
181
|
+
if (v <= 0)
|
|
182
|
+
return 0;
|
|
183
|
+
if (v >= 1)
|
|
184
|
+
return 255;
|
|
185
|
+
const c = FIRST[(v * BINS) | 0];
|
|
186
|
+
return c < 255 && v >= ROUNDS_UP[c + 1] ? c + 1 : c;
|
|
187
|
+
};
|
|
188
|
+
/** Below this chroma a color is gray and its hue is noise, so the lerp takes the other end's. */
|
|
189
|
+
const GRAY = 1e-4;
|
|
101
190
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
191
|
+
* Colors already turned into OKLCH, direct-mapped on a byte of their hash: a mix reads the same
|
|
192
|
+
* few keyframe colors every frame, and the table stays 256 colors however many it meets.
|
|
193
|
+
*/
|
|
194
|
+
const SLOTS = 256;
|
|
195
|
+
const slotColor = new Int32Array(SLOTS).fill(-1);
|
|
196
|
+
const slotL = new Float64Array(SLOTS);
|
|
197
|
+
const slotC = new Float64Array(SLOTS);
|
|
198
|
+
const slotH = new Float64Array(SLOTS);
|
|
199
|
+
/** 0xrrggbb to its slot, filled with [L, C, h], h in radians; Björn Ottosson's OKLab matrices. */
|
|
200
|
+
function oklch(rgb) {
|
|
201
|
+
const slot = Math.imul(rgb, 0x9e3779b1) >>> 24;
|
|
202
|
+
if (slotColor[slot] === rgb)
|
|
203
|
+
return slot;
|
|
204
|
+
const r = LINEAR[(rgb >> 16) & 0xff];
|
|
205
|
+
const g = LINEAR[(rgb >> 8) & 0xff];
|
|
206
|
+
const b = LINEAR[rgb & 0xff];
|
|
207
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
208
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
209
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
210
|
+
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
|
211
|
+
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
|
212
|
+
slotColor[slot] = rgb;
|
|
213
|
+
slotL[slot] = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
|
214
|
+
slotC[slot] = Math.sqrt(A * A + B * B);
|
|
215
|
+
slotH[slot] = Math.atan2(B, A);
|
|
216
|
+
return slot;
|
|
217
|
+
}
|
|
218
|
+
/** [L, C, h] to 0xrrggbb, clipping each channel to sRGB's gamut. */
|
|
219
|
+
function fromOklch(L, C, h) {
|
|
220
|
+
const A = C * Math.cos(h);
|
|
221
|
+
const B = C * Math.sin(h);
|
|
222
|
+
const l_ = L + 0.3963377774 * A + 0.2158037573 * B;
|
|
223
|
+
const m_ = L - 0.1055613458 * A - 0.0638541728 * B;
|
|
224
|
+
const s_ = L - 0.0894841775 * A - 1.291485548 * B;
|
|
225
|
+
const l = l_ * l_ * l_;
|
|
226
|
+
const m = m_ * m_ * m_;
|
|
227
|
+
const s = s_ * s_ * s_;
|
|
228
|
+
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
229
|
+
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
230
|
+
const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
|
|
231
|
+
return (toByte(r) << 16) | (toByte(g) << 8) | toByte(b);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* OKLCH, hue the short way round, so a crossfade between hues keeps its lightness and saturation
|
|
235
|
+
* instead of dipping through a muddy middle. A mix that leaves sRGB's gamut is clipped per channel.
|
|
104
236
|
*
|
|
105
237
|
* @category channel
|
|
106
238
|
*/
|
|
107
239
|
export function mixHex(a, b, u) {
|
|
240
|
+
if (u === 0 || a === b)
|
|
241
|
+
return a;
|
|
242
|
+
if (u === 1)
|
|
243
|
+
return b;
|
|
244
|
+
const sa = oklch(a);
|
|
245
|
+
const al = slotL[sa];
|
|
246
|
+
const ac = slotC[sa];
|
|
247
|
+
const ah = slotH[sa];
|
|
248
|
+
const sb = oklch(b);
|
|
249
|
+
const bc = slotC[sb];
|
|
250
|
+
const from = ac < GRAY ? slotH[sb] : ah;
|
|
251
|
+
const to = bc < GRAY ? ah : slotH[sb];
|
|
252
|
+
let turn = to - from;
|
|
253
|
+
if (turn > Math.PI)
|
|
254
|
+
turn -= 2 * Math.PI;
|
|
255
|
+
else if (turn < -Math.PI)
|
|
256
|
+
turn += 2 * Math.PI;
|
|
257
|
+
return fromOklch(mix(al, slotL[sb], u), mix(ac, bc, u), from + turn * u);
|
|
258
|
+
}
|
|
259
|
+
const clamp255 = (v) => (v < 0 ? 0 : v > 255 ? 255 : Math.round(v));
|
|
260
|
+
/** Each of red, green and blue on its own, as klieg's light channel blends. */
|
|
261
|
+
function mixSrgb(a, b, u) {
|
|
108
262
|
const ar = (a >> 16) & 0xff;
|
|
109
263
|
const ag = (a >> 8) & 0xff;
|
|
110
264
|
const ab = a & 0xff;
|
|
@@ -114,12 +268,13 @@ export function mixHex(a, b, u) {
|
|
|
114
268
|
return ((clamp255(mix(ar, br, u)) << 16) | (clamp255(mix(ag, bg, u)) << 8) | clamp255(mix(ab, bb, u)));
|
|
115
269
|
}
|
|
116
270
|
/**
|
|
117
|
-
* Color, as 0xrrggbb
|
|
271
|
+
* Color, as 0xrrggbb, blended in OKLCH as `mixHex` does, or with `space: 'srgb'` channel by
|
|
272
|
+
* channel in sRGB, which keeps a port's arithmetic identical to a host that blended that way.
|
|
118
273
|
*
|
|
119
274
|
* @category channel
|
|
120
275
|
*/
|
|
121
|
-
export function hex() {
|
|
122
|
-
return { ...last({ lerp: mixHex }), kind: 'hex' };
|
|
276
|
+
export function hex(opts) {
|
|
277
|
+
return { ...last({ lerp: opts?.space === 'srgb' ? mixSrgb : mixHex }), kind: 'hex' };
|
|
123
278
|
}
|
|
124
279
|
/**
|
|
125
280
|
* A kit is plain data; this only fixes the type.
|
package/dist/clock.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Curve } from './easing.js';
|
|
2
|
+
/** How many passes a voice's `loop` plays. */
|
|
3
|
+
export declare function passesOf(loop: boolean | number | undefined): number;
|
|
4
|
+
/**
|
|
5
|
+
* The phase `elapsed` voice ms into a patch of `period` ms playing `passes` passes, held at 1 once
|
|
6
|
+
* they are done. A pure function rather than one writing phase and pass together, so a hot caller
|
|
7
|
+
* keeps both in registers.
|
|
8
|
+
*/
|
|
9
|
+
export declare function phaseAt(elapsed: number, period: number, passes: number): number;
|
|
10
|
+
/** The pass `elapsed` voice ms falls in, held at the last once they are done. */
|
|
11
|
+
export declare function passAt(elapsed: number, period: number, passes: number): number;
|
|
12
|
+
/** A voice's own fade this frame, 0..1: in from `since` over `fadeIn`, and out along its ramp. */
|
|
13
|
+
export declare function envelope(fadeIn: number, out: {
|
|
14
|
+
at: number;
|
|
15
|
+
over: number;
|
|
16
|
+
rest: boolean;
|
|
17
|
+
} | null, ease: Curve | undefined, reduced: boolean, now: number, since: number): number;
|
|
18
|
+
/** A weight held to 0..1. */
|
|
19
|
+
export declare function clampWeight(raw: number): number;
|
package/dist/clock.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** How many passes a voice's `loop` plays. */
|
|
2
|
+
export function passesOf(loop) {
|
|
3
|
+
const l = loop ?? true;
|
|
4
|
+
return l === true ? Number.POSITIVE_INFINITY : l === false ? 1 : l;
|
|
5
|
+
}
|
|
6
|
+
/** Whether `elapsed` voice ms is past the last of `passes` passes of a patch of `period` ms. */
|
|
7
|
+
const over = (elapsed, period, passes) => Number.isFinite(passes) && elapsed >= period * passes;
|
|
8
|
+
/**
|
|
9
|
+
* The phase `elapsed` voice ms into a patch of `period` ms playing `passes` passes, held at 1 once
|
|
10
|
+
* they are done. A pure function rather than one writing phase and pass together, so a hot caller
|
|
11
|
+
* keeps both in registers.
|
|
12
|
+
*/
|
|
13
|
+
export function phaseAt(elapsed, period, passes) {
|
|
14
|
+
if (!(period > 0))
|
|
15
|
+
return 0;
|
|
16
|
+
return over(elapsed, period, passes) ? 1 : (elapsed % period) / period;
|
|
17
|
+
}
|
|
18
|
+
/** The pass `elapsed` voice ms falls in, held at the last once they are done. */
|
|
19
|
+
export function passAt(elapsed, period, passes) {
|
|
20
|
+
if (!(period > 0))
|
|
21
|
+
return 0;
|
|
22
|
+
return over(elapsed, period, passes) ? passes - 1 : Math.floor(elapsed / period);
|
|
23
|
+
}
|
|
24
|
+
/** A voice's own fade this frame, 0..1: in from `since` over `fadeIn`, and out along its ramp. */
|
|
25
|
+
export function envelope(fadeIn, out, ease, reduced, now, since) {
|
|
26
|
+
let w = 1;
|
|
27
|
+
if (fadeIn > 0 && !reduced) {
|
|
28
|
+
const u = (now - since) / fadeIn;
|
|
29
|
+
if (u < 1)
|
|
30
|
+
w *= ease ? ease(Math.max(0, u)) : Math.max(0, u);
|
|
31
|
+
}
|
|
32
|
+
if (out && !out.rest) {
|
|
33
|
+
if (reduced || out.over === 0)
|
|
34
|
+
return 0;
|
|
35
|
+
const u = 1 - (now - out.at) / out.over;
|
|
36
|
+
const clamped = u < 0 ? 0 : u > 1 ? 1 : u;
|
|
37
|
+
w *= ease ? ease(clamped) : clamped;
|
|
38
|
+
}
|
|
39
|
+
return w;
|
|
40
|
+
}
|
|
41
|
+
/** A weight held to 0..1. */
|
|
42
|
+
export function clampWeight(raw) {
|
|
43
|
+
return raw < 0 ? 0 : raw > 1 ? 1 : raw;
|
|
44
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
export type { NumberOptions } from './channels.js';
|
|
1
2
|
export { hex, kit, last, max, mixHex, mul, sum, vec } from './channels.js';
|
|
2
3
|
export { mix, mixer } from './mixer.js';
|
|
3
4
|
export type { Motion, Moving, PerSubject, Value } from './motion.js';
|
|
4
|
-
export { glide, spring } from './motion.js';
|
|
5
|
+
export { glide, spring, tween } from './motion.js';
|
|
5
6
|
export type { KeysOptions, PatchOptions } from './patch.js';
|
|
6
7
|
export { keys, patch } from './patch.js';
|
|
7
8
|
export { gate, lag, level, peak, slew } from './signals.js';
|
|
8
|
-
export type { Channel, Easing, Engine, FadeOptions, FadeSpec, Handle, Keyframe, Kit, Mix, MixOptions, Patch, Sent, Setting, Signal, VoiceSpec, } from './types.js';
|
|
9
|
+
export type { Anchor, Channel, Columns, Doubt, Easing, Engine, FadeOptions, FadeSpec, Handle, Keyframe, Kit, Mark, Marked, Mix, MixOptions, MotionSpec, Patch, Placement, Projection, Query, Sent, Setting, Signal, VoiceSpec, } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { hex, kit, last, max, mixHex, mul, sum, vec } from './channels.js';
|
|
2
2
|
export { mix, mixer } from './mixer.js';
|
|
3
|
-
export { glide, spring } from './motion.js';
|
|
3
|
+
export { glide, spring, tween } from './motion.js';
|
|
4
4
|
export { keys, patch } from './patch.js';
|
|
5
5
|
export { gate, lag, level, peak, slew } from './signals.js';
|