@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/README.md CHANGED
@@ -1,3 +1,63 @@
1
- # Temporary Holding Version
1
+ # codin-cod-ornament
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The sea behind [CodinCod](https://codincod.com): what lives in it, the weather
4
+ over it, and how each thing is drawn. Published as `@codincod/ornament`.
5
+
6
+ Outside `src/canvas/`, nothing here touches a browser. The modules decide where
7
+ a fish goes, what the sky is doing at this hour, and what each drawing looks
8
+ like, and hand that to a renderer. Three read it:
9
+
10
+ - CodinCod's site, whose `assets/js/ornament/` holds the SVG and WebGPU halves
11
+ and the LiveView hooks that start them, and draws with `src/canvas/` where
12
+ there is no graphics card.
13
+ - [codin-cod-maintenance](https://codeberg.org/reeven/codin-cod-maintenance),
14
+ the page codincod.com shows while it is down, which draws with `src/canvas/`
15
+ and bakes a still with `src/paint/svg.ts`.
16
+ - [seascape](https://github.com/reeveng/seascape), the desktop background, in a
17
+ bare V8 and in Qt's JavaScript engine.
18
+
19
+ `tsconfig.json` leaves the DOM out of `lib`, so `pnpm check` fails on any
20
+ module that reaches for one. `src/canvas/` is checked on its own, by
21
+ `tsconfig.canvas.json`, with the DOM.
22
+
23
+ ## Layout
24
+
25
+ - `src/*.ts`: one simulation per kind of thing in the water or the sky, each
26
+ with its test beside it.
27
+ - `src/paint/`: what one drawing looks like, and `sea.ts`, which builds a whole
28
+ frame out of them. `ink.ts` is the colour arithmetic every renderer shares,
29
+ `colour.ts` reads a theme's colours back out of a stylesheet, and `svg.ts`
30
+ writes a frame as SVG markup, for a page that has to look right with no
31
+ script.
32
+ - `src/sizes.ts` and `src/census.ts`: how big things are and how many of them
33
+ there are.
34
+ - `src/canvas/flat.ts`: a frame onto a 2D canvas, the one module here that
35
+ needs the DOM.
36
+
37
+ The package ships its TypeScript source. Import a module by its path:
38
+
39
+ ```ts
40
+ import { createShoal } from "@codincod/ornament/shoal.ts"
41
+ import { createSea } from "@codincod/ornament/paint/sea.ts"
42
+ ```
43
+
44
+ ## Commands
45
+
46
+ ```bash
47
+ pnpm install
48
+ pnpm test # node --test over src/**/*.test.ts
49
+ pnpm check # tsc, with no DOM
50
+ pnpm fmt # biome
51
+ ```
52
+
53
+ ## Releasing
54
+
55
+ 1. Bump `version`, then `pnpm publish`.
56
+ 2. In CodinCod, raise the pin in `assets/package.json`. A caret range on a
57
+ `0.x` version stops at the next minor, so `^0.1.0` never picks up 0.2.0.
58
+ 3. Run `pnpm install` from CodinCod's root, then `mix assets.build`.
59
+ 4. In seascape, rebuild the bundle (`js/build.sh`, then `cargo build --release`).
60
+
61
+ ## Licence
62
+
63
+ [AGPL-3.0](LICENSE), the same as CodinCod.
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@codincod/ornament",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "The sea behind CodinCod: what lives in it, the weather over it, how each thing is drawn, and a canvas to draw it on",
5
+ "keywords": [
6
+ "codincod",
7
+ "generative",
8
+ "ornament",
9
+ "simulation"
10
+ ],
11
+ "author": "CodinCod <hello@codincod.com>",
12
+ "license": "AGPL-3.0-only",
13
+ "type": "module",
14
+ "exports": {
15
+ "./*": "./src/*"
16
+ },
17
+ "sideEffects": false,
18
+ "files": [
19
+ "src",
20
+ "!src/**/*.test.ts"
21
+ ],
22
+ "devDependencies": {
23
+ "@biomejs/biome": "^2.5.15",
24
+ "@types/node": "^26.6.5",
25
+ "typescript": "^7.0.2"
26
+ },
27
+ "homepage": "https://codincod.com/",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://codeberg.org/reeven/codin-cod-ornament"
31
+ },
32
+ "scripts": {
33
+ "check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.canvas.json",
34
+ "fmt": "biome check --write .",
35
+ "fmt:check": "biome check .",
36
+ "test": "node --test \"src/**/*.test.ts\""
37
+ }
6
38
  }
package/src/biome.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The water everything is in, and the register of what is in it.
3
+ *
4
+ * Not a conductor. There is no score, nothing here decides what any animal
5
+ * does, and no call in this file makes anything happen. What it holds is a fact
6
+ * every animal in a sea has access to and none of them is told: what else is
7
+ * about, where, and how alarming it looks from a distance.
8
+ *
9
+ * ## Why the water needs a register at all
10
+ *
11
+ * Because the alternative is a director, and a director is what a sea does not
12
+ * have. A shark crossing a reef does not notify the clownfish. It swims, and
13
+ * the reef empties because every fish on it was watching the water and drew its
14
+ * own conclusion, at its own moment, at its own distance. That is why nothing
15
+ * in this ornament exposes a call anybody could fire at it. An animal is handed
16
+ * `about`, looks, and decides.
17
+ *
18
+ * The register is what makes that possible without every animal knowing about
19
+ * every other one. `nemos.ts`, `flora.ts` and `walkers.ts` each grew their own
20
+ * copy of one small shape while waiting for this, and all three said the same
21
+ * thing in their comments: a crab should not be importing a type from a
22
+ * clownfish. `Presence` is that shape, declared once, and a source of them is
23
+ * anything at all.
24
+ *
25
+ * ## A shark at the glass is not near a fish in the murk
26
+ *
27
+ * This water has depth and it is the dimension that gets forgotten,
28
+ * because a screen has only two. Everything in this ornament carries a `depth`:
29
+ * 0 is as far off as the water goes and 1 is up against the glass, and that
30
+ * range is most of the room in the scene. Two things can share a point on the
31
+ * screen and be a long way from each other.
32
+ *
33
+ * So a shark cruising past the camera has no business emptying a reef sitting
34
+ * back in the murk, however neatly the two line up. `abreast` is the share of
35
+ * that, and every reading in this ornament is weighed by it.
36
+ *
37
+ * ## Size and menace are different questions
38
+ *
39
+ * `size` decides how far off something is noticed, because that is what being
40
+ * seen underwater actually depends on. `menace` decides how much it matters
41
+ * once it has been. A turtle and a shark of the same length are the same
42
+ * silhouette at the same distance and they are not the same event, and a sea
43
+ * where they were is a sea where nothing that happens in it means anything.
44
+ *
45
+ * What either number turns into is not settled here. A clownfish quickens at
46
+ * something big and goes home for something hunting; a crab freezes at both; a
47
+ * crown of tentacles minds only what is nearly on top of it; a starfish never
48
+ * reacts to anything, because its answer takes hours and nobody is watching
49
+ * that long. One reading, many answers, and every one of them the animal's own.
50
+ *
51
+ * ## Nothing has to enter
52
+ *
53
+ * A water nobody entered is open water, and everything reading it goes about
54
+ * its day. That is not a degenerate case: it is what most of these scenes are,
55
+ * and it is why every `about` in this ornament is optional.
56
+ */
57
+
58
+ /**
59
+ * Something in the water, as much of it as another animal can make out.
60
+ *
61
+ * Where it is, how big it looks, and whether it looks like it hunts. Not what
62
+ * it is. Nothing reading this register can tell a shark from a turtle except by
63
+ * these numbers, which is the right amount for an animal to know: a small fish
64
+ * on a reef has never been told what species anything is either.
65
+ */
66
+ export interface Presence {
67
+ /** 0 is as far off as this water goes, 1 is up against the glass. */
68
+ readonly depth: number
69
+ /**
70
+ * How much this looks like something that hunts, 0 through 1.
71
+ *
72
+ * 0 is a shape that has never worried anybody, which is most of what crosses
73
+ * a sea. 1 empties a reef. Left out it is 0, so anything entering the water
74
+ * without an opinion about itself is merely large.
75
+ */
76
+ readonly menace?: number
77
+ /** How big it is across, in px, which is how far off it is noticed from. */
78
+ readonly size: number
79
+ readonly x: number
80
+ readonly y: number
81
+ }
82
+
83
+ /**
84
+ * Something that says where it is when asked.
85
+ *
86
+ * A function rather than a list, because everything in a sea is moving and a
87
+ * list handed over once is a list of where things were. It returns however many
88
+ * it speaks for: one animal, a pod of them, or none at all this moment.
89
+ */
90
+ export type Source = () => readonly Presence[]
91
+
92
+ export interface Biome {
93
+ /**
94
+ * Everything in the water at this moment.
95
+ *
96
+ * Gathered afresh on every ask, since nothing here keeps a clock and the
97
+ * answer is only true for as long as nothing has moved. The list belongs to
98
+ * the water and is refilled on the next ask, so read it and let it go rather
99
+ * than keeping it.
100
+ */
101
+ about(): readonly Presence[]
102
+ /**
103
+ * Put something in the water. What comes back takes it out again.
104
+ *
105
+ * Whatever built the animal usually does this, and an animal that does it for
106
+ * itself is better still, since what a thing looks like to something small is
107
+ * a fact about that thing. Nothing checks. A sea is not a guest list.
108
+ */
109
+ enter(source: Source): () => void
110
+ }
111
+
112
+ /**
113
+ * How far apart in the water two distances are before they stop mattering.
114
+ *
115
+ * A third of the way through, which is generous and meant to be. The depth of
116
+ * one of these scenes is not measured in anything, so this cannot be a real
117
+ * distance; what it has to do is stop the far murk and the near glass being the
118
+ * same place while leaving anything swimming at roughly your own distance
119
+ * plainly there. Wider than this and depth does nothing. Narrower and animals
120
+ * pass through each other unnoticed, which is worse than the fault it fixes.
121
+ */
122
+ const REACH = 0.36
123
+
124
+ /**
125
+ * How nearly abreast two things are, 1 side by side and 0 a water apart.
126
+ *
127
+ * The one piece of arithmetic in this file, and it is geometry rather than
128
+ * judgement: how far apart two things are is the water's own business, and what
129
+ * either of them does about it is not. It falls off rather than cutting off,
130
+ * because a threshold makes a shark switch from harmless to terrifying between
131
+ * two frames of swimming gently forward.
132
+ */
133
+ export function abreast(depth: number, other: number): number {
134
+ return Math.max(0, 1 - Math.abs(depth - other) / REACH)
135
+ }
136
+
137
+ export function createBiome(): Biome {
138
+ const sources = new Set<Source>()
139
+ const gathered: Presence[] = []
140
+
141
+ return {
142
+ about() {
143
+ gathered.length = 0
144
+ for (const source of sources) {
145
+ for (const one of source()) gathered.push(one)
146
+ }
147
+ return gathered
148
+ },
149
+
150
+ enter(source) {
151
+ sources.add(source)
152
+ return () => {
153
+ sources.delete(source)
154
+ }
155
+ },
156
+ }
157
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The sea, onto a canvas, with nothing but the browser's own rasteriser.
3
+ *
4
+ * The renderer every machine has. `card.ts` is faster wherever there is a card
5
+ * to talk to, and this is what draws the water when there is not: an old
6
+ * browser, a machine without WebGPU, a device that answered the adapter request
7
+ * with nothing. The picture is the same picture.
8
+ *
9
+ * ## Two things a card does per pixel that a canvas cannot
10
+ *
11
+ * A fragment shader reads the water's own gradient at the height of every pixel
12
+ * it fills, and works out how far a light has given out at that same height. A
13
+ * canvas fill takes one colour, or one gradient built before the fill starts.
14
+ *
15
+ * So the height a colour is read at is decided per drawing here. Most of what
16
+ * swims is small enough that one reading is the truth: a fish is a few dozen px
17
+ * tall and the water does not change over that. What is not small says so, by
18
+ * fading down the box or by covering a good share of it, and those are drawn
19
+ * through a gradient with the same arithmetic sampled along it. Both renderers
20
+ * agree on the numbers; they disagree on how often they ask.
21
+ *
22
+ * ## Why the fill rule matters more than it looks
23
+ *
24
+ * An animal is several closed shapes meant to overlap: a body, a tail swung off
25
+ * its joint, a fin rooted inside the back. Filled as one path with the non-zero
26
+ * rule they are one silhouette, which is the animal. Filled one after another
27
+ * they are three shapes painted over each other, and at the weight ornament is
28
+ * drawn at every overlap shows as a bright seam. A canvas fills non-zero by
29
+ * default, so this gets it for nothing; the card has to be told.
30
+ */
31
+ import { FORM, held, OWN, Reader, type View } from "../paint/frame.ts"
32
+ import { css, hueOf, leftAt, type Palette } from "../paint/ink.ts"
33
+
34
+ /**
35
+ * How tall a drawing has to be, as a share of the box, before it is worth a
36
+ * gradient rather than one reading of the water.
37
+ *
38
+ * Under this the error is smaller than the step between two neighbouring values
39
+ * of an eight bit channel, so a gradient would cost a fill and change nothing.
40
+ */
41
+ const TALL = 0.08
42
+
43
+ /**
44
+ * How many places a gradient is sampled at.
45
+ *
46
+ * Both the water's colour and how far a light has given out are curves, and a
47
+ * gradient between two stops is a straight line. This is where the two stop
48
+ * being told apart on a screen.
49
+ */
50
+ const STOPS = 7
51
+
52
+ export interface Box {
53
+ height: number
54
+ width: number
55
+ }
56
+
57
+ export interface FlatOptions {
58
+ /**
59
+ * How far past the box a drawing may stand before it is skipped, as a share
60
+ * of the box.
61
+ *
62
+ * Nothing in the water knows where the edge of the picture is, so a fish that
63
+ * has left is still swum and a plant behind a mass of near rock is still cut.
64
+ * A renderer is the last chance to not pay for them.
65
+ */
66
+ bleed?: number
67
+ }
68
+
69
+ /** What of the water the box is showing. */
70
+ interface Seen {
71
+ foot: number
72
+ left: number
73
+ right: number
74
+ top: number
75
+ }
76
+
77
+ export interface Flat {
78
+ draw(floats: Float32Array, palette: Palette, box: Box, density?: number, view?: View | null): void
79
+ }
80
+
81
+ /**
82
+ * A canvas that will draw a frame of this water whenever it is handed one.
83
+ *
84
+ * It keeps no clock and owns no simulation. Something else steps the sea and
85
+ * hands over the block; all this does is put it on the screen.
86
+ */
87
+ export function createFlat(canvas: HTMLCanvasElement, options: FlatOptions = {}): Flat | null {
88
+ const context = canvas.getContext("2d")
89
+ if (!context) return null
90
+
91
+ const bleed = options.bleed ?? 0.25
92
+
93
+ // Where each drawing is and how far back it stands, so the solid ones can be
94
+ // put in order. Kept between frames: a busy sea is a few thousand drawings,
95
+ // and three arrays of them rebuilt sixty times a second is the sort of
96
+ // housekeeping that costs more than the work it is for.
97
+ let places = new Int32Array(4096)
98
+ let lanes = new Float32Array(4096)
99
+ let order: number[] = []
100
+
101
+ const room = (needed: number) => {
102
+ if (needed > places.length) {
103
+ const size = 1 << Math.ceil(Math.log2(Math.max(16, needed)))
104
+ places = new Int32Array(size)
105
+ lanes = new Float32Array(size)
106
+ }
107
+ while (order.length < needed) order.push(order.length)
108
+ if (order.length > needed) order.length = needed
109
+ }
110
+
111
+ return {
112
+ /**
113
+ * One frame, onto the canvas.
114
+ *
115
+ * `box` is the water's own size in the units it was laid out in, which is
116
+ * not the canvas's size in pixels: a screen at twice the density draws the
117
+ * same sea out of four times as many of them.
118
+ */
119
+ draw(
120
+ floats: Float32Array,
121
+ palette: Palette,
122
+ box: Box,
123
+ density = 1,
124
+ view: View | null = null,
125
+ ): void {
126
+ const camera = held(floats)
127
+ const reader = new Reader(floats)
128
+
129
+ context.setTransform(density, 0, 0, density, 0, 0)
130
+ context.clearRect(0, 0, box.width, box.height)
131
+
132
+ const middle = { x: box.width / 2, y: box.height / 2 }
133
+
134
+ if (view) {
135
+ context.translate(middle.x, middle.y)
136
+ context.scale(view.scale, view.scale)
137
+ context.translate(-view.x, -view.y)
138
+ } else {
139
+ // The frame, held by somebody rather than bolted to a tripod. It is
140
+ // drawn a little larger than its box to pay for the wander, which is
141
+ // what overscan is for: a frame that has wandered must never be a frame
142
+ // with the page showing along one edge.
143
+ context.translate(middle.x + camera.swayX, middle.y + camera.swayY)
144
+ context.rotate(camera.tilt)
145
+ context.scale(camera.overscan, camera.overscan)
146
+ context.translate(-middle.x, -middle.y)
147
+ }
148
+
149
+ // What of the water is actually in the box, which a window on a larger
150
+ // water sees only a piece of. Nothing in the sea knows where the edge of
151
+ // the picture is, so this is the last chance not to pay for what is past
152
+ // it.
153
+ const seen: Seen = view
154
+ ? {
155
+ foot: view.y + box.height / (2 * view.scale),
156
+ left: view.x - box.width / (2 * view.scale),
157
+ right: view.x + box.width / (2 * view.scale),
158
+ top: view.y - box.height / (2 * view.scale),
159
+ }
160
+ : { foot: box.height, left: 0, right: box.width, top: 0 }
161
+
162
+ room(reader.count)
163
+
164
+ // Where everything is, and which of the two it is in. Anything you can
165
+ // see the water through is laid over what is already there in the order
166
+ // it arrives, since two lights over one another are a painting rather
167
+ // than a stack of distances; everything else takes its turn by how far
168
+ // back it stands, which an infinite lane arranges.
169
+ let kept = 0
170
+ while (reader.next()) {
171
+ const through =
172
+ reader.alpha < 0.999 || reader.form === FORM.light || reader.form === FORM.wash
173
+ places[kept] = reader.offset
174
+ lanes[kept] = through ? Number.POSITIVE_INFINITY : reader.lane
175
+ kept++
176
+ }
177
+
178
+ // Stable, because equal lanes have to keep the order they were drawn in.
179
+ order.length = kept
180
+ for (let at = 0; at < kept; at++) order[at] = at
181
+ order.sort((a, b) => {
182
+ const one = lanes[a] ?? 0
183
+ const two = lanes[b] ?? 0
184
+ return one === two ? a - b : one - two
185
+ })
186
+
187
+ const paint = new Reader(floats)
188
+ for (const which of order) {
189
+ paint.stand(places[which] ?? 0)
190
+ one(context, paint, palette, box, seen, bleed)
191
+ }
192
+
193
+ context.setTransform(1, 0, 0, 1, 0, 0)
194
+ },
195
+ }
196
+ }
197
+
198
+ /** One drawing, whichever of the four kinds it is. */
199
+ function one(
200
+ context: CanvasRenderingContext2D,
201
+ mark: Reader,
202
+ palette: Palette,
203
+ box: Box,
204
+ seen: Seen,
205
+ bleed: number,
206
+ ): void {
207
+ if (mark.alpha <= 0.002) return
208
+
209
+ if (mark.form === FORM.wash) {
210
+ context.fillStyle = down(context, mark, palette, box, 0, box.height)
211
+ context.fillRect(0, 0, box.width, box.height)
212
+ return
213
+ }
214
+
215
+ if (mark.form === FORM.light) {
216
+ light(context, mark, palette, box)
217
+ return
218
+ }
219
+
220
+ const floats = mark.floats
221
+ const shape = new Path2D()
222
+ let top = Number.POSITIVE_INFINITY
223
+ let foot = Number.NEGATIVE_INFINITY
224
+ let left = Number.POSITIVE_INFINITY
225
+ let right = Number.NEGATIVE_INFINITY
226
+
227
+ mark.each((from, points) => {
228
+ if (points < 2) return
229
+ for (let i = 0; i < points; i++) {
230
+ const x = floats[from + i * 2] ?? 0
231
+ const y = floats[from + i * 2 + 1] ?? 0
232
+ if (i === 0) shape.moveTo(x, y)
233
+ else shape.lineTo(x, y)
234
+ if (y < top) top = y
235
+ if (y > foot) foot = y
236
+ if (x < left) left = x
237
+ if (x > right) right = x
238
+ }
239
+ if (mark.form !== FORM.stroke) shape.closePath()
240
+ })
241
+
242
+ if (top > foot) return
243
+
244
+ // Off the picture entirely, which happens to a fish that has left and to
245
+ // every plant standing behind a mass of near rock.
246
+ const margin = Math.max(seen.right - seen.left, seen.foot - seen.top) * bleed
247
+ if (right < seen.left - margin || left > seen.right + margin) return
248
+ if (foot < seen.top - margin || top > seen.foot + margin) return
249
+
250
+ const tall = foot - top > box.height * TALL
251
+ const ink = tall
252
+ ? down(context, mark, palette, box, top, foot)
253
+ : flat(mark, palette, box, mark.shade === OWN ? (top + foot) / 2 : mark.shade)
254
+
255
+ if (mark.form === FORM.stroke) {
256
+ context.strokeStyle = ink
257
+ context.lineWidth = Math.max(0.1, mark.width)
258
+ context.lineCap = "round"
259
+ context.lineJoin = "round"
260
+ context.stroke(shape)
261
+ return
262
+ }
263
+
264
+ context.fillStyle = ink
265
+ context.fill(shape)
266
+ }
267
+
268
+ /**
269
+ * A round light, as an ellipse with the falling off built into a gradient.
270
+ *
271
+ * The card does this per pixel out of a distance from the middle. Here it is a
272
+ * radial gradient with the same curve sampled along it, which is the one place
273
+ * a canvas gets the better bargain: a gradient is a gradient however many
274
+ * pixels it covers.
275
+ */
276
+ function light(context: CanvasRenderingContext2D, mark: Reader, palette: Palette, box: Box): void {
277
+ const floats = mark.floats
278
+ const cx = floats[mark.first + 1] ?? 0
279
+ const cy = floats[mark.first + 2] ?? 0
280
+ const across = mark.width
281
+ if (across <= 0) return
282
+
283
+ const hue = hueOf(
284
+ palette,
285
+ mark.tone,
286
+ mark.weight,
287
+ mark.shade === OWN ? cy : mark.shade,
288
+ box.height,
289
+ )
290
+ // Under 1 this is a streak rather than a disc, which is what a lens does with
291
+ // a light it cannot quite hold. The gradient is built inside the squash as
292
+ // well as the shape, since a gradient is read in whatever space it is filled.
293
+ const thin = Math.max(0.001, mark.thin)
294
+
295
+ context.save()
296
+ context.translate(cx, cy)
297
+ context.scale(1, thin)
298
+
299
+ const glow = context.createRadialGradient(0, 0, 0, 0, 0, across)
300
+ for (let stop = 0; stop < STOPS; stop++) {
301
+ const t = stop / (STOPS - 1)
302
+ glow.addColorStop(t, css(hue, mark.alpha * (1 - t) ** mark.fall))
303
+ }
304
+
305
+ context.fillStyle = glow
306
+ context.beginPath()
307
+ context.arc(0, 0, across, 0, Math.PI * 2)
308
+ context.fill()
309
+ context.restore()
310
+ }
311
+
312
+ /** One colour, read at one height. */
313
+ function flat(mark: Reader, palette: Palette, box: Box, y: number): string {
314
+ const hue = hueOf(palette, mark.tone, mark.weight, y, box.height)
315
+ return css(hue, leftAt(mark.alpha, mark.fadeTop, mark.fadeSpan, mark.fall, mark.spent, y))
316
+ }
317
+
318
+ /** The same, sampled down the drawing's own height. */
319
+ function down(
320
+ context: CanvasRenderingContext2D,
321
+ mark: Reader,
322
+ palette: Palette,
323
+ box: Box,
324
+ top: number,
325
+ foot: number,
326
+ ): CanvasGradient {
327
+ const run = context.createLinearGradient(0, top, 0, Math.max(top + 1, foot))
328
+
329
+ for (let stop = 0; stop < STOPS; stop++) {
330
+ const t = stop / (STOPS - 1)
331
+ const y = top + (foot - top) * t
332
+ run.addColorStop(t, flat(mark, palette, box, mark.shade === OWN ? y : mark.shade))
333
+ }
334
+
335
+ return run
336
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Light off a moving surface, landing on the ground.
3
+ *
4
+ * A wave is a lens. The top of the water is never flat, so the light coming
5
+ * through it is focused into bright lines and starved between them, and what
6
+ * lands on the sand is a net of them sliding about. It is the one thing that
7
+ * says a sea is lit from a surface rather than merely lighter towards the top.
8
+ *
9
+ * ## Why this is a field and not a net
10
+ *
11
+ * A caustic drawn from above is the net everybody has seen. This water is seen
12
+ * from the side, and a floor seen edge on has no room for a net on it: what
13
+ * reaches the eye is the light on whatever faces upwards, which here is the
14
+ * ridges. So this is asked a place and answers how much light is on it, and the
15
+ * painter walks the ridges it has rather than laying a pattern over the box.
16
+ *
17
+ * ## Why the noise is ridged
18
+ *
19
+ * Smooth noise is cloud. What separates a caustic from a cloud is that it has
20
+ * lines in it: the focusing is sharp and the dark between two bright lines is
21
+ * darker than the average, so the thing to fold is the middle of the range up
22
+ * into a crease and then steepen it. Plain noise put through a curve stays a
23
+ * cloud however hard it is squeezed.
24
+ */
25
+ import { makeNoise2 } from "./perlin.ts"
26
+
27
+ export interface Caustics {
28
+ /**
29
+ * How much light is on the ground at a place, 0 to 1.
30
+ *
31
+ * `across` is a share of the box and `depth` is how far forward the ground
32
+ * there is, so two ridges at the same x and different distances are lit
33
+ * differently. They have to be: the same pattern on every ridge would flatten
34
+ * the whole bed into one surface, which is the opposite of the errand.
35
+ */
36
+ at(across: number, depth: number): number
37
+ step(seconds: number): void
38
+ }
39
+
40
+ export interface CausticsOptions {
41
+ seed: number
42
+ }
43
+
44
+ /** How many bright lines fit across the box, and across the water's distance. */
45
+ const CELLS = 7.5
46
+
47
+ const DEEP_CELLS = 2.4
48
+
49
+ /**
50
+ * How fast the whole pattern slides, in widths a second.
51
+ *
52
+ * Slow. The surface it comes off is moving quickly, but what a caustic does on
53
+ * the ground is mostly breathe: the lines wander and swell where they are
54
+ * rather than travelling, and a pattern crossing the picture at a rate reads as
55
+ * something being projected onto the sand.
56
+ */
57
+ const DRIFT = 0.045
58
+
59
+ /** How narrow the bright lines are, and how sharply they give out. */
60
+ const CREASE = 2.1
61
+
62
+ const SHARP = 3.2
63
+
64
+ export function createCaustics(options: CausticsOptions): Caustics {
65
+ const noise = makeNoise2(options.seed ^ 0x6c1f)
66
+ let slid = 0
67
+
68
+ return {
69
+ at(across: number, depth: number): number {
70
+ const held = noise(across * CELLS + slid, depth * DEEP_CELLS)
71
+ const crease = 1 - Math.abs(held) * CREASE
72
+ if (crease <= 0) return 0
73
+
74
+ return Math.min(1, crease) ** SHARP
75
+ },
76
+
77
+ step(seconds: number): void {
78
+ slid += seconds * DRIFT
79
+ },
80
+ }
81
+ }