@msb235/blits 0.2.1 → 0.4.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 +140 -0
- package/README.md +9 -2
- package/dist/channels.d.ts +34 -5
- package/dist/channels.js +172 -23
- package/dist/clock.d.ts +19 -0
- package/dist/clock.js +44 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/lanes.d.ts +353 -0
- package/dist/lanes.js +1850 -0
- package/dist/mixer.d.ts +212 -2
- package/dist/mixer.js +1775 -167
- package/dist/motion.d.ts +209 -9
- package/dist/motion.js +674 -114
- 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 +279 -18
- package/docs/schema.html +146 -27
- package/package.json +3 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,146 @@ 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.4.0
|
|
8
|
+
|
|
9
|
+
### Breaking
|
|
10
|
+
|
|
11
|
+
- A tween's `MotionSpec` reads `ms: undefined` where its `ms` is a function of the subject.
|
|
12
|
+
- `Handle.state` can read `'held'`. Only a voice cued with `hold` reaches it, so nothing changes at
|
|
13
|
+
run time for one cued without, but a `switch` over every state no longer covers them all.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `mix.inert` is true when another frame would change no pose, so a host's loop may sleep while
|
|
18
|
+
`live` is still true: every voice is done, held after its passes at a plain weight, or a motion
|
|
19
|
+
whose every subject has landed on its target. A retarget, a fade or a cue makes it false again.
|
|
20
|
+
- `handle.fade({ subject, over })` fades one subject out of a voice, which plays on for the rest.
|
|
21
|
+
Once the ramp ends the voice forgets the subject (its record, its lane position and a motion
|
|
22
|
+
patch's state for it) and no longer reaches it; a motion patch's `to` brings it back, met afresh.
|
|
23
|
+
`mix.drop(subject)` clears it too. It keeps the voice on its lane.
|
|
24
|
+
- `tween`'s `ms` may be a function of the subject, asked each time one of its stretches starts.
|
|
25
|
+
- `hold: 'before' | 'after' | 'both'` on a voice. Before, a subject shows the voice's first frame
|
|
26
|
+
while the voice is pending and while the subject waits out its `stagger`; after, a finite loop
|
|
27
|
+
shows its last frame once its passes are done and stays, `held`, until faded. `fade.in` counts
|
|
28
|
+
from the first frame shown. What WAAPI calls `fill`.
|
|
29
|
+
- `handle.played` resolves true when a finite loop's last pass ends, false if the voice leaves
|
|
30
|
+
first. It never rejects.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- A `keys` or stateless `fn` voice naming one subject and writing one channel fills from its
|
|
35
|
+
channel's crowd, as a motion voice does, where it took the general path. On teitou 10k `fn`
|
|
36
|
+
voices of one subject each read by `pull` take 0.7 of the time they did.
|
|
37
|
+
- A voice naming its subjects that is cued, starts or retires relinks only those subjects, and
|
|
38
|
+
joins or leaves its crowd in place, where it made every subject relink and the lanes qualify
|
|
39
|
+
every voice again: a frame stopping one of 10k such voices and cueing another fell from about
|
|
40
|
+
9 ms to 0.6 by `pull` on teitou. `mix.drop` looks only at the voices over every subject and
|
|
41
|
+
those naming the one dropped, where it walked every voice.
|
|
42
|
+
- `mix.drop` frees a subject from a voice faded off it with `handle.fade({ subject })`, and a mix
|
|
43
|
+
whose last ramp of that kind is dropped can read `inert` again; it also frees the subject from a
|
|
44
|
+
motion patch that another voice plays on after the voice naming the subject left.
|
|
45
|
+
- `pull` reads a list it read before in the same order from the lanes in one run, checking the
|
|
46
|
+
frame once rather than per subject: a tween voice over 10k subjects read by `pull` takes about
|
|
47
|
+
0.83 of the time it did on teitou.
|
|
48
|
+
- A lane gives a `vec(1)` channel an array of one, as the general path does, where it gave a
|
|
49
|
+
bare number; and a motion voice on one folds without making an array per subject per frame.
|
|
50
|
+
- A `tween` divides elapsed by its length in milliseconds, so a point a whole fraction of the way
|
|
51
|
+
in lands exactly: 150 ms into a 200 ms linear tween from 0 to 100 reads 75, not 74.99999999999999.
|
|
52
|
+
- `pull` over a list read again in the same order skips looking each subject up, and copies lane
|
|
53
|
+
values a column at a time; a motion voice's lane fills in one loop. One tween voice over 10k
|
|
54
|
+
subjects read by `pull` takes 0.59–0.66 of the time it did on teitou; a spring 0.68–0.74.
|
|
55
|
+
- Motion voices of one subject each fill from one crowd of flat rows per channel, and `sync` visits
|
|
56
|
+
only voices with something due: 10k tween voices of one subject each take 0.54–0.59 of the time
|
|
57
|
+
they did on teitou, 100k take 0.31–0.33.
|
|
58
|
+
|
|
59
|
+
## 0.3.0
|
|
60
|
+
|
|
61
|
+
### Breaking
|
|
62
|
+
|
|
63
|
+
- `spring` and `glide` build patches of a third form, `'motion'`, instead of `'fn'`, and carry
|
|
64
|
+
their kind and constants on `patch.motion`, a `MotionSpec`. `Patch.form` and `Engine.runs` take
|
|
65
|
+
`'motion'`, so an engine that declares `runs` must list it to run a spring or a glide. A motion
|
|
66
|
+
patch throws when two of its subjects, or a subject's start, target and velocity, or a `to` or
|
|
67
|
+
`push`, move on different numbers of axes. It makes a subject's first stretch at its first `to`
|
|
68
|
+
or `push` as well as at its first read, so `read(subject, at)` answers from then on.
|
|
69
|
+
- An untimed retarget lands at the subject's own voice time at the mix's latest sync, not at the
|
|
70
|
+
subject's next read, so a subject the host did not probe changes at the same moment as one it
|
|
71
|
+
did; `read` with no time answers at that time, counting every change due by then. For a subject
|
|
72
|
+
no frame of its voice has met yet, or still inside its stagger, an untimed change applies at its
|
|
73
|
+
first read, after any timed change due by then, and `read` with no time returns undefined, as it
|
|
74
|
+
does once the voice is gone. Changes apply in time order, not the order they were made.
|
|
75
|
+
- `drop(subject)` also forgets a motion patch's state for the subject, so it starts afresh.
|
|
76
|
+
- With lanes on, a stateless patch calling `setting.send` from `at` sends every frame for every
|
|
77
|
+
subject the mix has met, probed that frame or not; and a patch that first calls `setting.keep`
|
|
78
|
+
partway through playing can advance that state once more for one subject the host did not probe
|
|
79
|
+
that frame.
|
|
80
|
+
- `mixHex`, and so the `hex` channel, blends in OKLCH rather than sRGB, taking hue the short way
|
|
81
|
+
round and a gray end's hue from the other end. A crossfade from red to blue passes through
|
|
82
|
+
0xba00c2 rather than 0x800080, and black to white through 0x636363 rather than 0x808080. A blend
|
|
83
|
+
costs about 45 ns where the two ends repeat frame to frame and 210 ns where every call brings new
|
|
84
|
+
colors, against 8 ns in sRGB. `hex({ space: 'srgb' })` keeps the sRGB blend.
|
|
85
|
+
- `Mix` has a `pull` method, so an engine's `create` must return a mix that implements it.
|
|
86
|
+
|
|
87
|
+
### Added
|
|
88
|
+
|
|
89
|
+
- `tween(writes, { from, to, ms, ease? })`, a motion patch that eases each subject from one value
|
|
90
|
+
to another over `ms`, with `from` and `to` given per subject and read once when the mix first
|
|
91
|
+
meets it. `to(subject, target)` retargets one subject from where it is over a full `ms` again,
|
|
92
|
+
and `read` reports value and velocity as a spring's does. It runs on lanes as springs do, with
|
|
93
|
+
each subject's endpoints as data, so a tween over every subject no longer needs a `fn` that looks
|
|
94
|
+
its endpoints up on each call: in weasel's shape (10,000 string ids, endpoints in a map) a frame
|
|
95
|
+
costs 0.83 of that `fn`'s and pauses for collection a quarter as long. A motion lane no longer
|
|
96
|
+
allocates a delta per subject either, which takes a spring frame over 10,000 subjects to
|
|
97
|
+
0.82–0.90 of the last build's. `MotionSpec` gains
|
|
98
|
+
`{ kind: 'tween', ms, ease }`.
|
|
99
|
+
- Lanes: a channel every voice writing it can run that way is computed for every subject at once,
|
|
100
|
+
in flat arrays, at the frame's first probe, rather than subject by subject, and gives the same
|
|
101
|
+
pose. `keys`, a stateless `fn` and `motion` voices with a number weight qualify. Three `keys`
|
|
102
|
+
voices over 10,000 subjects fell from 6.1 to 2.6 ms a frame. A lane whose subjects were mostly
|
|
103
|
+
left unprobed last frame stops filling and its subjects take the general path until probes pick
|
|
104
|
+
up again, so a host probing 5% of 10,000 subjects pays about what it would with lanes off, not
|
|
105
|
+
ten times it. `MixOptions.lanes: false` turns lanes off. They cost a little on frames they
|
|
106
|
+
don't serve: a voice per subject reads up to 7% slower at 10,000 subjects (about 20% at
|
|
107
|
+
100–1,000, a few hundredths of a millisecond), a mix probing few of its subjects about 0.03 ms
|
|
108
|
+
more, and a projection made every frame about 10% more.
|
|
109
|
+
- `mix.pull(subjects, { channel: Float64Array, ... })` writes each subject's pose into one array per
|
|
110
|
+
channel, in the order given, giving what `probe(subject, out)` gives without a pose object per
|
|
111
|
+
subject. A channel of `n` numbers takes `n` places a subject; one with no value writes NaN. While
|
|
112
|
+
every voice runs on a lane it copies straight from the lanes: a frame of three `keys` voices
|
|
113
|
+
over 10,000 subjects costs 0.84 of the same frame read through `probe`, a spring 0.91.
|
|
114
|
+
- `mix.project(timestamp)` reads the mix at another time without moving it: `probe` gives the pose
|
|
115
|
+
then, and `assess` says per channel whether it is `exact`, `stepped` or `held`. Ahead it plays
|
|
116
|
+
what is cued forward; behind it needs `MixOptions.history: { ms, every? }`, which keeps every
|
|
117
|
+
handle change, the voices that left, and copies of stateful state, so a read back under `stepMs`
|
|
118
|
+
lands on the pose the mix showed, and with `inputs` it records what input weight signals and the
|
|
119
|
+
host fields patches `reads` held, so a read back over a `level` or a pointer is known. `Patch.clone` copies state `structuredClone` cannot.
|
|
120
|
+
- The score: a voice takes a `name` and an `anchor` placement, `start` or `in` and `out` or `end`,
|
|
121
|
+
each a timestamp or another voice's mark (`after`, `with`, `before`, or `of` plus `mark`),
|
|
122
|
+
selected by name, tag or written channel with a resolver. Each source can keep its own `score`,
|
|
123
|
+
with names namespaced per score. `mix.announce(name, { at? })` puts a named mark on a score for
|
|
124
|
+
anchors to wait on, and `mix.marks(from, to)` lists every mark the plan knows.
|
|
125
|
+
- `spring` and `glide` keep every stretch within the mix's history, so a read before a retarget
|
|
126
|
+
finds where the subject was.
|
|
127
|
+
- A cue can name its subjects, `subjects: [a, b]`, in place of a `target`. The mix files the voice
|
|
128
|
+
under each one, so a voice per subject costs the same as one voice: the first frame of 1,000
|
|
129
|
+
subjects on a voice each fell from about 500 ms to 1 ms.
|
|
130
|
+
|
|
131
|
+
### Fixed
|
|
132
|
+
|
|
133
|
+
- A voice faded before its start plays once its start arrives. It used to stay out of every
|
|
134
|
+
subject's fold until some other voice was cued or left.
|
|
135
|
+
- A probe after a `seek` in the same frame reads the voice where the seek put it, rather than the
|
|
136
|
+
value an earlier probe that frame read before the seek. A stateful patch still steps once.
|
|
137
|
+
- A probe looks its subject up once rather than once per voice, which makes frames with several
|
|
138
|
+
voices 5–12% faster.
|
|
139
|
+
- A `keys` patch reads a keyed `vec` channel into an array its voice reuses, rather than a new one
|
|
140
|
+
each read, which halves collections at 10,000 subjects and leaves frame time unchanged.
|
|
141
|
+
|
|
142
|
+
- A finite loop's fade out starts when its last pass ended, not at the first frame after, so it
|
|
143
|
+
plays the same at any frame rate.
|
|
144
|
+
- The published `package.json` no longer lists `workspaces: ["site"]`, which only the repo's docs
|
|
145
|
+
site uses.
|
|
146
|
+
|
|
7
147
|
## 0.2.1
|
|
8
148
|
|
|
9
149
|
### Added
|
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.4.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.
|
|
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,5 +1,31 @@
|
|
|
1
1
|
import type { Channel, Kit } from './types.js';
|
|
2
|
-
/**
|
|
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
|
+
*/
|
|
3
29
|
export interface NumberOptions {
|
|
4
30
|
/** The range the value means anything in; the mix clamps to it. */
|
|
5
31
|
bounds?: readonly [min: number, max: number];
|
|
@@ -39,18 +65,21 @@ export declare function last<V>(opts?: {
|
|
|
39
65
|
*/
|
|
40
66
|
export declare function vec(n: number, of: Channel<number>): Channel<number[]>;
|
|
41
67
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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.
|
|
44
70
|
*
|
|
45
71
|
* @category channel
|
|
46
72
|
*/
|
|
47
73
|
export declare function mixHex(a: number, b: number, u: number): number;
|
|
48
74
|
/**
|
|
49
|
-
* 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.
|
|
50
77
|
*
|
|
51
78
|
* @category channel
|
|
52
79
|
*/
|
|
53
|
-
export declare function hex(
|
|
80
|
+
export declare function hex(opts?: {
|
|
81
|
+
space?: 'oklch' | 'srgb';
|
|
82
|
+
}): Channel<number>;
|
|
54
83
|
/**
|
|
55
84
|
* A kit is plain data; this only fixes the type.
|
|
56
85
|
*
|
package/dist/channels.js
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
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
|
+
}
|
|
2
32
|
/** Bounds change how a value folds at its edges, so they are part of the kind. */
|
|
3
33
|
const kindOf = (name, bounds) => bounds === undefined ? name : `${name}[${bounds[0]}, ${bounds[1]}]`;
|
|
4
34
|
/**
|
|
@@ -7,14 +37,16 @@ const kindOf = (name, bounds) => bounds === undefined ? name : `${name}[${bounds
|
|
|
7
37
|
* @category channel
|
|
8
38
|
*/
|
|
9
39
|
export function sum(opts) {
|
|
10
|
-
|
|
40
|
+
const channel = {
|
|
11
41
|
kind: kindOf('sum', opts?.bounds),
|
|
12
42
|
bounds: opts?.bounds,
|
|
13
43
|
rest: 0,
|
|
14
|
-
merge:
|
|
15
|
-
scale:
|
|
44
|
+
merge: add,
|
|
45
|
+
scale: times,
|
|
16
46
|
lerp: mix,
|
|
17
47
|
};
|
|
48
|
+
numerics.set(channel, { op: 'sum', axes: 1 });
|
|
49
|
+
return channel;
|
|
18
50
|
}
|
|
19
51
|
/**
|
|
20
52
|
* Gain, scale, opacity, hold.
|
|
@@ -22,14 +54,16 @@ export function sum(opts) {
|
|
|
22
54
|
* @category channel
|
|
23
55
|
*/
|
|
24
56
|
export function mul(opts) {
|
|
25
|
-
|
|
57
|
+
const channel = {
|
|
26
58
|
kind: kindOf('mul', opts?.bounds),
|
|
27
59
|
bounds: opts?.bounds,
|
|
28
60
|
rest: 1,
|
|
29
|
-
merge:
|
|
30
|
-
scale:
|
|
61
|
+
merge: product,
|
|
62
|
+
scale: toward,
|
|
31
63
|
lerp: mix,
|
|
32
64
|
};
|
|
65
|
+
numerics.set(channel, { op: 'mul', axes: 1 });
|
|
66
|
+
return channel;
|
|
33
67
|
}
|
|
34
68
|
/**
|
|
35
69
|
* Dark, aperture.
|
|
@@ -37,14 +71,16 @@ export function mul(opts) {
|
|
|
37
71
|
* @category channel
|
|
38
72
|
*/
|
|
39
73
|
export function max(opts) {
|
|
40
|
-
|
|
74
|
+
const channel = {
|
|
41
75
|
kind: kindOf('max', opts?.bounds),
|
|
42
76
|
bounds: opts?.bounds,
|
|
43
77
|
rest: 0,
|
|
44
|
-
merge:
|
|
45
|
-
scale:
|
|
78
|
+
merge: larger,
|
|
79
|
+
scale: times,
|
|
46
80
|
lerp: mix,
|
|
47
81
|
};
|
|
82
|
+
numerics.set(channel, { op: 'max', axes: 1 });
|
|
83
|
+
return channel;
|
|
48
84
|
}
|
|
49
85
|
/**
|
|
50
86
|
* A channel with no rest: the last influence to pass wins, and a weight can only gate it. The
|
|
@@ -61,16 +97,37 @@ export function last(opts) {
|
|
|
61
97
|
lerp: opts?.lerp ?? ((a, b, u) => (u < 0.5 ? a : b)),
|
|
62
98
|
};
|
|
63
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
|
+
}
|
|
64
115
|
/**
|
|
65
116
|
* Vec3 position, premultiplied light: one channel's arithmetic applied down an axis list.
|
|
66
117
|
*
|
|
67
118
|
* @category channel
|
|
68
119
|
*/
|
|
69
120
|
export function vec(n, of) {
|
|
70
|
-
const rest = of.rest === undefined ? undefined :
|
|
121
|
+
const rest = of.rest === undefined ? undefined : doubles(n, of.rest);
|
|
71
122
|
const scale = of.scale;
|
|
72
123
|
const fill = of.rest ?? 0;
|
|
73
|
-
|
|
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 = {
|
|
74
131
|
kind: of.kind === undefined ? undefined : `vec(${n}, ${of.kind})`,
|
|
75
132
|
bounds: of.bounds,
|
|
76
133
|
rest,
|
|
@@ -95,22 +152,113 @@ export function vec(n, of) {
|
|
|
95
152
|
into[i] = of.merge(into[i] ?? fill, scale(v[i] ?? fill, w));
|
|
96
153
|
return into;
|
|
97
154
|
},
|
|
98
|
-
lerp: (a, b, u) =>
|
|
99
|
-
const out = new Array(n);
|
|
100
|
-
for (let i = 0; i < n; i++)
|
|
101
|
-
out[i] = of.lerp(a[i] ?? fill, b[i] ?? fill, u);
|
|
102
|
-
return out;
|
|
103
|
-
},
|
|
155
|
+
lerp: (a, b, u) => lerpTo(undefined, a, b, u),
|
|
104
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;
|
|
105
162
|
}
|
|
106
|
-
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;
|
|
107
190
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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.
|
|
110
236
|
*
|
|
111
237
|
* @category channel
|
|
112
238
|
*/
|
|
113
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) {
|
|
114
262
|
const ar = (a >> 16) & 0xff;
|
|
115
263
|
const ag = (a >> 8) & 0xff;
|
|
116
264
|
const ab = a & 0xff;
|
|
@@ -120,12 +268,13 @@ export function mixHex(a, b, u) {
|
|
|
120
268
|
return ((clamp255(mix(ar, br, u)) << 16) | (clamp255(mix(ag, bg, u)) << 8) | clamp255(mix(ab, bb, u)));
|
|
121
269
|
}
|
|
122
270
|
/**
|
|
123
|
-
* 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.
|
|
124
273
|
*
|
|
125
274
|
* @category channel
|
|
126
275
|
*/
|
|
127
|
-
export function hex() {
|
|
128
|
-
return { ...last({ lerp: mixHex }), kind: 'hex' };
|
|
276
|
+
export function hex(opts) {
|
|
277
|
+
return { ...last({ lerp: opts?.space === 'srgb' ? mixSrgb : mixHex }), kind: 'hex' };
|
|
129
278
|
}
|
|
130
279
|
/**
|
|
131
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
|
@@ -2,8 +2,8 @@ export type { NumberOptions } from './channels.js';
|
|
|
2
2
|
export { hex, kit, last, max, mixHex, mul, sum, vec } from './channels.js';
|
|
3
3
|
export { mix, mixer } from './mixer.js';
|
|
4
4
|
export type { Motion, Moving, PerSubject, Value } from './motion.js';
|
|
5
|
-
export { glide, spring } from './motion.js';
|
|
5
|
+
export { glide, spring, tween } from './motion.js';
|
|
6
6
|
export type { KeysOptions, PatchOptions } from './patch.js';
|
|
7
7
|
export { keys, patch } from './patch.js';
|
|
8
8
|
export { gate, lag, level, peak, slew } from './signals.js';
|
|
9
|
-
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';
|