@codincod/ornament 0.0.0-stage → 0.2.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/src/drift.ts ADDED
@@ -0,0 +1,435 @@
1
+ /**
2
+ * What the water carries: snow falling through it, and bubbles going up.
3
+ *
4
+ * The simulation behind the drifting half of a seascape, kept free of the DOM
5
+ * the way `shoal.ts` is, so the drawing half stays a drawing half.
6
+ *
7
+ * This exists because of what a shoal on its own looks like. Fish are large,
8
+ * few and far apart, so between them is water with nothing in it, and water
9
+ * with nothing in it does not read as water. It reads as a background colour
10
+ * that happens to have animals on top. The fix is not more fish: a screen busy
11
+ * with animals is a screen that wants looking at, which is the one thing an
12
+ * ornament may not ask for. The fix is smaller things.
13
+ *
14
+ * ## Snow
15
+ *
16
+ * Marine snow is the real thing this is named after, and the real thing is what
17
+ * makes it work. It is mostly falling, slowly, and it is everywhere at once, so
18
+ * the eye reads a volume of water rather than a plane. Each mote is a couple of
19
+ * pixels at most and faint with it, which is what lets there be enough of them
20
+ * to fill a screen without any one of them being a thing you notice.
21
+ *
22
+ * They fall at their own rate and slide with the same Perlin field that steers
23
+ * the fish, which is the whole reason they belong in this file and not in a
24
+ * stylesheet. Snow that fell straight down would be rain. Snow that agrees with
25
+ * the fish about which way the water is moving is the water becoming visible.
26
+ *
27
+ * ## Bubbles
28
+ *
29
+ * Bubbles come off a vent, which is a fixed point on the floor, in strings
30
+ * rather than singly, and then the vent goes quiet for a while. That rhythm is
31
+ * the point. A steady stream is a machine; a burst and a pause is something
32
+ * down there breathing.
33
+ *
34
+ * A bubble grows as it rises, because the pressure on it drops, and it wobbles
35
+ * because it is not rigid. Both are cheap and both are what the eye checks
36
+ * against without knowing it. What it does at the top is nothing: it reaches
37
+ * the surface and is gone, since a bubble that faded out in mid-water would be
38
+ * asking why.
39
+ *
40
+ * ## A mote is redrawn when it has fallen far enough to be worth it
41
+ *
42
+ * Snow is the cheapest thing in this water to simulate and among the dearest
43
+ * to hand over. A mote has no geometry, so nothing here is cut; what a renderer
44
+ * pays is the copy and the tessellation, for every mote on every frame, and
45
+ * most of them have gone nowhere a screen could show. So a mote holds the place
46
+ * it was last drawn at until it has fallen `tolerance` away from it, and `cut`
47
+ * is how a renderer knows. Two subtractions and a hypotenuse, on numbers the
48
+ * fall has already produced.
49
+ *
50
+ * `docs/decisions/0027-let-an-element-decide-when-its-drawing-is-stale.md` is
51
+ * the argument. Bubbles are left out of it. There are few of them, none lasts
52
+ * more than seconds, and one rises further between frames than any tolerance
53
+ * worth setting would hold it back by.
54
+ *
55
+ * Everything is seeded, so the same seed gives the same weather twice.
56
+ */
57
+
58
+ import { makeNoise2, makeRandom, type Noise2 } from "./perlin.ts"
59
+
60
+ export interface Bubble {
61
+ /** 0 is as far off as this water goes, 1 is up against the glass. */
62
+ readonly depth: number
63
+ /** Its own lane through the wobble, so no two in a string weave alike. */
64
+ readonly lane: number
65
+ /** Radius in px, which grows the whole way up; see `SWELL`. */
66
+ r: number
67
+ /** How long it has been up, in seconds, which is what drives the wobble. */
68
+ rose: number
69
+ x: number
70
+ y: number
71
+ }
72
+
73
+ export interface Mote {
74
+ /**
75
+ * How many times this mote has been redrawn.
76
+ *
77
+ * The only thing a renderer has to watch. While it holds, the mote has not
78
+ * fallen far enough for another drawing to differ from the one already on the
79
+ * screen, so a renderer that leaves it alone leaves a picture that is still
80
+ * true to within the tolerance.
81
+ */
82
+ cut: number
83
+ readonly depth: number
84
+ /** Its own lane through the field, so neighbours do not fall as a sheet. */
85
+ readonly lane: number
86
+ /** Radius in px, with depth already taken out of it. */
87
+ readonly r: number
88
+ /** Its own share of the fall, so a drift is not a curtain coming down. */
89
+ readonly rate: number
90
+ x: number
91
+ y: number
92
+ }
93
+
94
+ export interface Drift {
95
+ readonly bubbles: readonly Bubble[]
96
+ readonly motes: readonly Mote[]
97
+ /** Re-fit to a resized box, carrying what is in it across in proportion. */
98
+ resize(width: number, height: number, motes?: number): void
99
+ step(seconds: number): void
100
+ }
101
+
102
+ export interface DriftOptions {
103
+ /**
104
+ * Where the floor is at a given x, for anything that has to sit on it.
105
+ *
106
+ * A function rather than a number because a seabed is not level, and a vent
107
+ * that hangs in the water a little above the sand is worse than no vent. The
108
+ * default is the bottom of the box, which is what a surface with no seabed
109
+ * drawn on it wants.
110
+ */
111
+ floor?: (x: number) => number
112
+ height: number
113
+ motes: number
114
+ seed: number
115
+ /**
116
+ * How far a mote may fall from its last drawing before it is drawn again, in
117
+ * px.
118
+ *
119
+ * `FloraOptions.tolerance` for the snow, and the same bargain. At nothing
120
+ * every mote is redrawn on every step, which is how this behaved before. At a
121
+ * quarter of a pixel most of them are left alone on most frames, and what is
122
+ * given up is a fall that arrives in quarter-pixel steps on a dot drawn a
123
+ * couple of px across and faint with it.
124
+ */
125
+ tolerance?: number
126
+ /** How many places on the floor bubbles come from. */
127
+ vents?: number
128
+ width: number
129
+ }
130
+
131
+ /**
132
+ * How many of the field's features fit across the box.
133
+ *
134
+ * Deliberately coarser than the shoal's, so the snow is answering a broader
135
+ * swell than the fish are. Matching them exactly was the first attempt and it
136
+ * looked wrong in a way that took a while to name: every mote near a fish was
137
+ * doing what the fish did, so the snow read as the fish's wake rather than as
138
+ * the water both of them are in.
139
+ */
140
+ const FIELD_CELLS = 2.2
141
+
142
+ /** How fast the field slides, so nothing ever settles into a loop. */
143
+ const DRIFT = 0.04
144
+
145
+ /** How far the field can push a mote sideways, in px a second at depth 1. */
146
+ const SWAY = 13
147
+
148
+ /** How fast snow falls, slowest to fastest, in px a second at depth 1. */
149
+ const FALL_SLOWEST = 4
150
+
151
+ const FALL_FASTEST = 13
152
+
153
+ /**
154
+ * The spread of mote sizes in px, at depth 1, and how hard the draw leans small.
155
+ *
156
+ * Leaning hard, because size here is doing the work distance does. A field of
157
+ * evenly sized dots is a texture and the eye dismisses it; a field where most
158
+ * are at the limit of being visible and a few are clearly nearer is depth, and
159
+ * depth is the only reason to draw any of this.
160
+ *
161
+ * It is the one thing in this water not written down in metres, and that is
162
+ * deliberate. Snow is a fleck of a millimetre or two adrift within reach of the
163
+ * glass, and `sizes.ts` weighs a length against a distance: something that
164
+ * small that close comes out of the sum larger than the fish behind it. What is
165
+ * being drawn is a texture on the water rather than an animal in it.
166
+ */
167
+ const MOTE_SMALLEST = 0.4
168
+
169
+ const MOTE_LARGEST = 1.9
170
+
171
+ const MOTE_BIAS = 2.6
172
+
173
+ /** How far a mote falls before it is worth drawing again, in px, by default. */
174
+ const TOLERANCE = 0.25
175
+
176
+ /** How much of a mote's size and speed is its depth. */
177
+ const DEPTH_SIZE = 0.75
178
+
179
+ /** The nearest and furthest anything drifting is ever put. */
180
+ const DEPTH_NEAR = 1
181
+
182
+ const DEPTH_FAR = 0.08
183
+
184
+ /** How fast a bubble rises, slowest to fastest, in px a second at depth 1. */
185
+ const RISE_SLOWEST = 38
186
+
187
+ const RISE_FASTEST = 66
188
+
189
+ /** A bubble's radius where it leaves the vent, in px, and how much it gains. */
190
+ const BUBBLE_SMALLEST = 1.1
191
+
192
+ const BUBBLE_LARGEST = 2.9
193
+
194
+ /**
195
+ * How much bigger a bubble is at the surface than at the floor.
196
+ *
197
+ * Modest. The honest figure for a shallow sea is close to nothing, and the
198
+ * honest figure for a deep one is a great deal more than this, but a bubble
199
+ * that arrives at the top four times the size it left is a balloon. What this
200
+ * has to do is stop the string reading as a column of identical dots.
201
+ */
202
+ const SWELL = 0.6
203
+
204
+ /**
205
+ * How far a bubble weaves off its line, in px, and how fast it weaves.
206
+ *
207
+ * Wide enough to be seen from across a room. A vent draws the eye because it is
208
+ * the one thing in the water moving against everything else, so a string that
209
+ * goes up dead straight is the most obviously ruled line in the scene. What
210
+ * stops it being a line is that no two bubbles in the string are at the same
211
+ * point of the same weave, which is what `Bubble.lane` is for.
212
+ */
213
+ const WOBBLE = 9
214
+
215
+ const WOBBLE_RATE = 1.9
216
+
217
+ /**
218
+ * How long a vent runs, and how long it rests, in seconds.
219
+ *
220
+ * The rest is much the longer of the two, which is what keeps a seascape from
221
+ * turning into an aquarium ornament. Most of the time nothing is coming out of
222
+ * the floor at all, and then something is.
223
+ */
224
+ const VENT_RUN_LEAST = 0.7
225
+
226
+ const VENT_RUN_SPAN = 1.6
227
+
228
+ const VENT_REST_LEAST = 5
229
+
230
+ const VENT_REST_SPAN = 14
231
+
232
+ /**
233
+ * How long between bubbles while a vent is running, in seconds.
234
+ *
235
+ * A wide spread rather than a tight one, because the spacing up the string is
236
+ * the tell. Bubbles let go on a near-constant interval arrive evenly spaced and
237
+ * the whole column reads as a dotted line drawn on the water.
238
+ */
239
+ const VENT_GAP_LEAST = 0.07
240
+
241
+ const VENT_GAP_SPAN = 0.5
242
+
243
+ /** A floor for the box, so a sliver of a window never divides by nothing. */
244
+ const MIN_SPAN = 1
245
+
246
+ interface Vent {
247
+ /** Seconds until the next bubble, or until this vent changes its mind. */
248
+ next: number
249
+ /** Whether it is running or resting. */
250
+ running: boolean
251
+ /** Seconds left in the run or the rest it is in. */
252
+ until: number
253
+ readonly x: number
254
+ readonly y: number
255
+ }
256
+
257
+ export function createDrift(options: DriftOptions): Drift {
258
+ const noise = makeNoise2(options.seed ^ 0x1d3f)
259
+ const random = makeRandom(options.seed ^ 0x7a21)
260
+
261
+ let width = Math.max(MIN_SPAN, options.width)
262
+ let height = Math.max(MIN_SPAN, options.height)
263
+ let slide = 0
264
+
265
+ const floor = options.floor ?? (() => height)
266
+
267
+ const tolerance = Math.max(0, options.tolerance ?? TOLERANCE)
268
+
269
+ const mote = (): Mote => {
270
+ const depth = DEPTH_FAR + random() * (DEPTH_NEAR - DEPTH_FAR)
271
+ const shrink = 1 - DEPTH_SIZE + DEPTH_SIZE * depth
272
+
273
+ return {
274
+ cut: 0,
275
+ depth,
276
+ lane: random() * 200,
277
+ r: (MOTE_SMALLEST + random() ** MOTE_BIAS * (MOTE_LARGEST - MOTE_SMALLEST)) * shrink,
278
+ rate: (FALL_SLOWEST + random() * (FALL_FASTEST - FALL_SLOWEST)) * shrink,
279
+ x: random() * width,
280
+ y: random() * height,
281
+ }
282
+ }
283
+
284
+ const motes: Mote[] = []
285
+
286
+ /** Where each mote stood when it was last drawn, alongside the mote itself. */
287
+ const held: { x: number; y: number }[] = []
288
+
289
+ function sow(): void {
290
+ const one = mote()
291
+ motes.push(one)
292
+ held.push({ x: one.x, y: one.y })
293
+ }
294
+
295
+ /** Say a mote is where it is now, so a renderer comes and reads it again. */
296
+ function draw(at: number): void {
297
+ const one = motes[at]
298
+ const was = held[at]
299
+ if (!one || !was) return
300
+
301
+ was.x = one.x
302
+ was.y = one.y
303
+ one.cut++
304
+ }
305
+
306
+ for (let made = 0; made < Math.max(0, options.motes); made++) sow()
307
+
308
+ const vent = (): Vent => {
309
+ const x = random() * width
310
+ return {
311
+ next: 0,
312
+ running: false,
313
+ until: random() * (VENT_REST_LEAST + VENT_REST_SPAN),
314
+ x,
315
+ y: floor(x),
316
+ }
317
+ }
318
+
319
+ const vents: Vent[] = Array.from({ length: Math.max(0, options.vents ?? 0) }, vent)
320
+ const bubbles: Bubble[] = []
321
+
322
+ function release(from: Vent): void {
323
+ const depth = DEPTH_FAR + random() * (DEPTH_NEAR - DEPTH_FAR)
324
+
325
+ bubbles.push({
326
+ depth,
327
+ lane: random() * 200,
328
+ r: (BUBBLE_SMALLEST + random() * (BUBBLE_LARGEST - BUBBLE_SMALLEST)) * depth,
329
+ rose: 0,
330
+ x: from.x + (random() - 0.5) * WOBBLE,
331
+ y: from.y,
332
+ })
333
+ }
334
+
335
+ return {
336
+ bubbles,
337
+ motes,
338
+
339
+ resize(nextWidth, nextHeight, count) {
340
+ const scaleX = Math.max(MIN_SPAN, nextWidth) / width
341
+ const scaleY = Math.max(MIN_SPAN, nextHeight) / height
342
+ width = Math.max(MIN_SPAN, nextWidth)
343
+ height = Math.max(MIN_SPAN, nextHeight)
344
+
345
+ for (let at = 0; at < motes.length; at++) {
346
+ const one = motes[at]
347
+ if (!one) continue
348
+ one.x *= scaleX
349
+ one.y *= scaleY
350
+ draw(at)
351
+ }
352
+ for (const one of bubbles) {
353
+ one.x *= scaleX
354
+ one.y *= scaleY
355
+ }
356
+
357
+ if (count == null) return
358
+ while (motes.length > count) {
359
+ motes.pop()
360
+ held.pop()
361
+ }
362
+ while (motes.length < count) sow()
363
+ },
364
+
365
+ step(seconds) {
366
+ const dt = Math.min(Math.max(seconds, 0), 0.1)
367
+ slide += DRIFT * dt
368
+ const scale = FIELD_CELLS / width
369
+
370
+ for (let at = 0; at < motes.length; at++) {
371
+ const one = motes[at]
372
+ const was = held[at]
373
+ if (!one || !was) continue
374
+
375
+ const push = noise(one.x * scale + one.lane, one.y * scale + one.lane + slide)
376
+ one.x += push * SWAY * one.depth * dt
377
+ one.y += one.rate * dt
378
+
379
+ if (one.y > height + one.r) {
380
+ one.y = -one.r
381
+ one.x = random() * width
382
+ }
383
+ one.x = around(one.x, width, one.r)
384
+
385
+ if (Math.hypot(one.x - was.x, one.y - was.y) >= tolerance) draw(at)
386
+ }
387
+
388
+ for (const from of vents) {
389
+ from.until -= dt
390
+ if (from.until <= 0) {
391
+ from.running = !from.running
392
+ from.until = from.running
393
+ ? VENT_RUN_LEAST + random() * VENT_RUN_SPAN
394
+ : VENT_REST_LEAST + random() * VENT_REST_SPAN
395
+ from.next = 0
396
+ }
397
+
398
+ if (!from.running) continue
399
+
400
+ from.next -= dt
401
+ if (from.next > 0) continue
402
+
403
+ from.next = VENT_GAP_LEAST + random() * VENT_GAP_SPAN
404
+ release(from)
405
+ }
406
+
407
+ for (let at = bubbles.length - 1; at >= 0; at--) {
408
+ const one = bubbles[at]
409
+ if (!one) continue
410
+
411
+ const climb = (RISE_SLOWEST + one.depth * (RISE_FASTEST - RISE_SLOWEST)) * one.depth
412
+ one.rose += dt
413
+ one.y -= climb * dt
414
+ one.r += one.r * SWELL * (climb / Math.max(height, MIN_SPAN)) * dt
415
+ one.x += Math.cos(one.rose * WOBBLE_RATE + one.lane) * WOBBLE * dt
416
+
417
+ if (one.y + one.r < 0) bubbles.splice(at, 1)
418
+ }
419
+ },
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Off one side and back on the other.
425
+ *
426
+ * Snow wraps sideways and respawns at the top rather than wrapping there, which
427
+ * is not an inconsistency. A mote that left the bottom and arrived at the top in
428
+ * the same column would draw a visible seam down the picture, because the eye
429
+ * follows a falling thing and finds it again. Sideways nothing is following it.
430
+ */
431
+ function around(value: number, span: number, margin: number): number {
432
+ if (value > span + margin) return -margin
433
+ if (value < -margin) return span + margin
434
+ return value
435
+ }