nodality 1.0.221 → 1.1.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/API.md +227 -0
- package/bin/nodality.js +64 -0
- package/dist/animator.cjs.js +1 -1
- package/dist/animator.cjs.js.LICENSE.txt +1 -1
- package/dist/animator.esm.js +1 -1
- package/dist/animator.esm.js.LICENSE.txt +1 -1
- package/dist/audionew.cjs.js +1 -1
- package/dist/audionew.cjs.js.LICENSE.txt +1 -1
- package/dist/audionew.esm.js +1 -1
- package/dist/audionew.esm.js.LICENSE.txt +1 -1
- package/dist/base.cjs.js +1 -1
- package/dist/base.cjs.js.LICENSE.txt +1 -1
- package/dist/base.esm.js +1 -1
- package/dist/base.esm.js.LICENSE.txt +1 -1
- package/dist/beta-desktop-bar.cjs.js +1 -1
- package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/beta-desktop-bar.esm.js +1 -1
- package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
- package/dist/beta-mobile-bar.cjs.js +1 -1
- package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/beta-mobile-bar.esm.js +1 -1
- package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
- package/dist/bundle.umd.js +1 -1
- package/dist/bundle.umd.js.LICENSE.txt +131 -1
- package/dist/button.cjs.js +1 -1
- package/dist/button.cjs.js.LICENSE.txt +1 -1
- package/dist/button.esm.js +1 -1
- package/dist/button.esm.js.LICENSE.txt +1 -1
- package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
- package/dist/card-getter.esm.js.LICENSE.txt +1 -1
- package/dist/center.cjs.js +1 -1
- package/dist/center.cjs.js.LICENSE.txt +1 -1
- package/dist/center.esm.js +1 -1
- package/dist/center.esm.js.LICENSE.txt +1 -1
- package/dist/checkbox.cjs.js +1 -1
- package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
- package/dist/checkbox.esm.js +1 -1
- package/dist/checkbox.esm.js.LICENSE.txt +1 -1
- package/dist/code.cjs.js +1 -1
- package/dist/code.cjs.js.LICENSE.txt +1 -1
- package/dist/code.esm.js +1 -1
- package/dist/code.esm.js.LICENSE.txt +1 -1
- package/dist/container.cjs.js +1 -1
- package/dist/container.cjs.js.LICENSE.txt +1 -1
- package/dist/container.esm.js +1 -1
- package/dist/container.esm.js.LICENSE.txt +1 -1
- package/dist/data-list.cjs.js +1 -1
- package/dist/data-list.cjs.js.LICENSE.txt +1 -1
- package/dist/data-list.esm.js +1 -1
- package/dist/data-list.esm.js.LICENSE.txt +1 -1
- package/dist/designer.cjs.js +1 -1
- package/dist/designer.cjs.js.LICENSE.txt +97 -1
- package/dist/designer.esm.js +1 -1
- package/dist/designer.esm.js.LICENSE.txt +97 -1
- package/dist/element-mapper.cjs.js +1 -1
- package/dist/element-mapper.cjs.js.LICENSE.txt +17 -1
- package/dist/element-mapper.esm.js +1 -1
- package/dist/element-mapper.esm.js.LICENSE.txt +17 -1
- package/dist/finalresult.esm.js +1 -1
- package/dist/finalresult.esm.js.LICENSE.txt +158 -1
- package/dist/flex-card.cjs.js +1 -1
- package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-card.esm.js +1 -1
- package/dist/flex-card.esm.js.LICENSE.txt +1 -1
- package/dist/flex-grid.cjs.js +1 -1
- package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-grid.esm.js +1 -1
- package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
- package/dist/flex-row.cjs.js +1 -1
- package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-row.esm.js +1 -1
- package/dist/flex-row.esm.js.LICENSE.txt +1 -1
- package/dist/floating-input.cjs.js +1 -1
- package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
- package/dist/floating-input.esm.js +1 -1
- package/dist/floating-input.esm.js.LICENSE.txt +1 -1
- package/dist/free.cjs.js +1 -1
- package/dist/free.cjs.js.LICENSE.txt +1 -1
- package/dist/free.esm.js +1 -1
- package/dist/free.esm.js.LICENSE.txt +1 -1
- package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
- package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
- package/dist/image-picker.cjs.js +1 -1
- package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
- package/dist/image-picker.esm.js +1 -1
- package/dist/image-picker.esm.js.LICENSE.txt +1 -1
- package/dist/image.cjs.js +1 -1
- package/dist/image.cjs.js.LICENSE.txt +1 -1
- package/dist/image.esm.js +1 -1
- package/dist/image.esm.js.LICENSE.txt +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.cjs.js.LICENSE.txt +158 -1
- package/dist/index.d.ts +962 -0
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.LICENSE.txt +158 -1
- package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
- package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
- package/dist/link-getter.cjs.js +1 -1
- package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
- package/dist/link-getter.esm.js +1 -1
- package/dist/link-getter.esm.js.LICENSE.txt +1 -1
- package/dist/link.cjs.js +1 -1
- package/dist/link.cjs.js.LICENSE.txt +1 -1
- package/dist/link.esm.js +1 -1
- package/dist/link.esm.js.LICENSE.txt +1 -1
- package/dist/meta-adder.cjs.js +1 -1
- package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
- package/dist/meta-adder.esm.js +1 -1
- package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
- package/dist/modal-2025.cjs.js +1 -1
- package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
- package/dist/modal-2025.esm.js +1 -1
- package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
- package/dist/multiswitcher.cjs.js +1 -1
- package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
- package/dist/multiswitcher.esm.js +1 -1
- package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
- package/dist/new-nav-bar.cjs.js +1 -1
- package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/new-nav-bar.esm.js +1 -1
- package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
- package/dist/picker.cjs.js +1 -1
- package/dist/picker.cjs.js.LICENSE.txt +1 -1
- package/dist/picker.esm.js +1 -1
- package/dist/picker.esm.js.LICENSE.txt +1 -1
- package/dist/progress.cjs.js +1 -1
- package/dist/progress.cjs.js.LICENSE.txt +1 -1
- package/dist/progress.esm.js +1 -1
- package/dist/progress.esm.js.LICENSE.txt +1 -1
- package/dist/radio.cjs.js +1 -1
- package/dist/radio.cjs.js.LICENSE.txt +1 -1
- package/dist/radio.esm.js +1 -1
- package/dist/radio.esm.js.LICENSE.txt +1 -1
- package/dist/range.cjs.js +1 -1
- package/dist/range.cjs.js.LICENSE.txt +1 -1
- package/dist/range.esm.js +1 -1
- package/dist/range.esm.js.LICENSE.txt +1 -1
- package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
- package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
- package/dist/side-bar.cjs.js +1 -1
- package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/side-bar.esm.js +1 -1
- package/dist/side-bar.esm.js.LICENSE.txt +1 -1
- package/dist/side-nav-bar.cjs.js +1 -1
- package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/side-nav-bar.esm.js +1 -1
- package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
- package/dist/simple-bar.cjs.js +1 -1
- package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/simple-bar.esm.js +1 -1
- package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
- package/dist/slider-2025.cjs.js +1 -1
- package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
- package/dist/slider-2025.esm.js +1 -1
- package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
- package/dist/spacer.cjs.js +1 -1
- package/dist/spacer.cjs.js.LICENSE.txt +1 -1
- package/dist/spacer.esm.js +1 -1
- package/dist/spacer.esm.js.LICENSE.txt +1 -1
- package/dist/stack.cjs.js +1 -1
- package/dist/stack.cjs.js.LICENSE.txt +1 -1
- package/dist/stack.esm.js +1 -1
- package/dist/stack.esm.js.LICENSE.txt +1 -1
- package/dist/stacker.cjs.js.LICENSE.txt +1 -1
- package/dist/stacker.esm.js.LICENSE.txt +1 -1
- package/dist/table.cjs.js +1 -1
- package/dist/table.cjs.js.LICENSE.txt +1 -1
- package/dist/table.esm.js +1 -1
- package/dist/table.esm.js.LICENSE.txt +1 -1
- package/dist/text-field.cjs.js +1 -1
- package/dist/text-field.cjs.js.LICENSE.txt +1 -1
- package/dist/text-field.esm.js +1 -1
- package/dist/text-field.esm.js.LICENSE.txt +1 -1
- package/dist/text.cjs.js +1 -1
- package/dist/text.cjs.js.LICENSE.txt +1 -1
- package/dist/text.esm.js +1 -1
- package/dist/text.esm.js.LICENSE.txt +1 -1
- package/dist/theme.cjs.js.LICENSE.txt +1 -1
- package/dist/theme.esm.js.LICENSE.txt +1 -1
- package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
- package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
- package/dist/ulist.cjs.js +1 -1
- package/dist/ulist.cjs.js.LICENSE.txt +1 -1
- package/dist/ulist.esm.js +1 -1
- package/dist/ulist.esm.js.LICENSE.txt +1 -1
- package/dist/video.cjs.js +1 -1
- package/dist/video.cjs.js.LICENSE.txt +1 -1
- package/dist/video.esm.js +1 -1
- package/dist/video.esm.js.LICENSE.txt +1 -1
- package/dist/wrap.cjs.js +1 -1
- package/dist/wrap.cjs.js.LICENSE.txt +1 -1
- package/dist/wrap.esm.js +1 -1
- package/dist/wrap.esm.js.LICENSE.txt +1 -1
- package/dist/zoom-card.cjs.js +1 -1
- package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
- package/dist/zoom-card.esm.js +1 -1
- package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
- package/examples/custom-raster-op.js +174 -0
- package/layout/animator.js +30 -2
- package/layout/audio.js +1 -1
- package/layout/audionew.js +1 -1
- package/layout/base.js +1 -1
- package/layout/beta-desktop-bar.js +1 -1
- package/layout/beta-mobile-bar.js +3 -2
- package/layout/button.js +1 -1
- package/layout/center.js +1 -1
- package/layout/checkbox.js +2 -2
- package/layout/circle.js +3 -2
- package/layout/code.js +3 -2
- package/layout/container.js +3 -4
- package/layout/dropdown-2025.js +1 -1
- package/layout/flex-card.js +1 -1
- package/layout/flex-grid.js +1 -1
- package/layout/flex-row.js +3 -2
- package/layout/form-components/custom.js +1 -1
- package/layout/form-components/data-list.js +2 -2
- package/layout/form-components/floating-input.js +2 -2
- package/layout/form-components/form-all.js +2 -2
- package/layout/form-components/form.js +1 -1
- package/layout/form-components/image-picker.js +1 -1
- package/layout/form-components/picker.js +2 -2
- package/layout/form-components/radio.js +2 -2
- package/layout/form-components/radiogroup.js +2 -2
- package/layout/form-components/range.js +2 -2
- package/layout/free.js +3 -4
- package/layout/grid-switcher.js +1 -1
- package/layout/grid.js +1 -1
- package/layout/horizontal-scroller.js +1 -1
- package/layout/image.js +1 -1
- package/layout/index.js +1 -1
- package/layout/link.js +5 -5
- package/layout/list.js +3 -4
- package/layout/meta-adder.js +1 -1
- package/layout/modal-2025.js +1 -1
- package/layout/morph.js +1003 -0
- package/layout/multiswitcher.js +11 -3
- package/layout/nav-bar.js +2 -3
- package/layout/nav-factor/custom-div.js +2 -2
- package/layout/new-nav-bar.js +11 -4
- package/layout/polygon.js +3 -4
- package/layout/prerender-site.js +1 -1
- package/layout/prerender.js +1 -1
- package/layout/progress.js +1 -1
- package/layout/row.js +1 -1
- package/layout/scroll-video.js +1 -1
- package/layout/side-bar.js +1 -1
- package/layout/side-nav-bar.js +1 -1
- package/layout/simple-bar.js +1 -1
- package/layout/slider-2025.js +1 -1
- package/layout/spacer.js +1 -1
- package/layout/stack.js +1 -1
- package/layout/svg.js +3 -4
- package/layout/switcher.js +1 -1
- package/layout/table.js +1 -1
- package/layout/text-field.js +1 -1
- package/layout/text.js +5 -5
- package/layout/ulist.js +1 -1
- package/layout/video.js +1 -1
- package/layout/wrap.js +1 -1
- package/layout/zoom-card.js +1 -1
- package/lib/card-getter.js +1 -1
- package/lib/codegen.js +62 -0
- package/lib/data.js +37 -1
- package/lib/designer.js +18 -1
- package/lib/element-mapper.js +351 -247
- package/lib/keyframe-animation.js +1 -1
- package/lib/link-getter.js +1 -1
- package/lib/morph-node.js +424 -0
- package/lib/raster-inspect.js +409 -0
- package/lib/raster-ops.js +1980 -81
- package/lib/raster-presets.js +329 -0
- package/lib/scroll-video.js +1 -1
- package/lib/seo.js +28 -1
- package/lib/stacker.js +1 -1
- package/lib/suggest.js +66 -0
- package/lib/theme.js +1 -1
- package/lib/transform-anim.js +1 -1
- package/lib/transition.js +205 -0
- package/package.json +20 -7
package/lib/raster-ops.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* nodality v1.0
|
|
2
|
+
* nodality v1.1.0
|
|
3
3
|
* (c) 2026 Filip Vabrousek
|
|
4
4
|
* License: MIT
|
|
5
5
|
*/
|
|
@@ -23,11 +23,21 @@
|
|
|
23
23
|
// so fidelity is high. Static: recaptured on resize (or via
|
|
24
24
|
// handle.refresh()). External images / webfonts do not load inside
|
|
25
25
|
// the SVG image context — system fonts and data: URIs only.
|
|
26
|
-
// live (
|
|
27
|
-
// HTML-in-Canvas API (WICG, Chrome origin trial):
|
|
28
|
-
// moves inside the effect <canvas layoutsubtree> and
|
|
29
|
-
// frame with gl.texElementImage2D(), staying
|
|
30
|
-
// Falls back to snapshot when the API is absent
|
|
26
|
+
// live (the DEFAULT where it applies; opt OUT with `live: false` on any
|
|
27
|
+
// node) — the emerging HTML-in-Canvas API (WICG, Chrome origin trial):
|
|
28
|
+
// the mount subtree moves inside the effect <canvas layoutsubtree> and
|
|
29
|
+
// is uploaded per frame with gl.texElementImage2D(), staying
|
|
30
|
+
// interactive and live. Falls back to snapshot when the API is absent,
|
|
31
|
+
// and again if no paint event arrives within 1500ms.
|
|
32
|
+
//
|
|
33
|
+
// "Where it applies" = any chain that is not PURE overlay. A
|
|
34
|
+
// pure-overlay chain (blobs alone) restructures nothing, so there is
|
|
35
|
+
// no live subtree to sample and it stays on snapshot. See `wantLive`
|
|
36
|
+
// in applyRasterPipeline, which is the authority:
|
|
37
|
+
// !pureOverlay && !nodes.some(n => n.live === false)
|
|
38
|
+
// (This said "opt-in: any raster node carries `live: true`" until
|
|
39
|
+
// 2026-08-12. It had been the opposite of the code for some time —
|
|
40
|
+
// `live: true` is not read anywhere.)
|
|
31
41
|
//
|
|
32
42
|
// Safety: this module touches nothing at import time, so it is inert
|
|
33
43
|
// under jsdom prerender (nodality/ssg). applyRasterPipeline() returns
|
|
@@ -44,8 +54,30 @@
|
|
|
44
54
|
// Ops execute in nodes-array order within their stage. Uniforms are
|
|
45
55
|
// namespaced per node index so the same op can appear twice.
|
|
46
56
|
|
|
57
|
+
// Same "did you mean" matching the element mapper and morph use, so a
|
|
58
|
+
// mistyped stage reads like a mistyped element type. Pure module, no DOM
|
|
59
|
+
// — importing it keeps this file inert at import (phase P1).
|
|
60
|
+
import { didYouMean } from "./suggest.js";
|
|
61
|
+
|
|
47
62
|
const MAX_BLOBS = 12;
|
|
48
63
|
|
|
64
|
+
// The unit vocabulary doc.params draws on. Not enforcement — nothing
|
|
65
|
+
// converts by unit — but it is what lets the inspector label a control
|
|
66
|
+
// and pick a sane step, and a closed list means "pixels" vs "px" vs "PX"
|
|
67
|
+
// cannot drift across 15 ops.
|
|
68
|
+
const RASTER_UNITS = [
|
|
69
|
+
"px", // a length in CSS pixels; the op scales it by dpr itself
|
|
70
|
+
"ratio", // 0..1, or a multiplier — never scaled by dpr
|
|
71
|
+
"deg", // degrees, converted to radians at upload
|
|
72
|
+
"count", // an integer quantity of things
|
|
73
|
+
"color", // "#rrggbb"
|
|
74
|
+
"name", // an identifier: a field name, a driver, a blend mode
|
|
75
|
+
"point", // [x, y], fractions of the element box
|
|
76
|
+
"range", // [lo, hi], a remap
|
|
77
|
+
"bool", // present/absent toggle
|
|
78
|
+
"seconds", // a duration or a rate per second
|
|
79
|
+
];
|
|
80
|
+
|
|
49
81
|
// ── Drivers ──────────────────────────────────────────────────────────
|
|
50
82
|
// What steers a reactive op. Previously "react to the pointer" was
|
|
51
83
|
// hardcoded into offset and duotone; a driver makes the input a value in
|
|
@@ -78,6 +110,24 @@ const DRIVER_NAMES = Object.keys(DRIVERS);
|
|
|
78
110
|
|
|
79
111
|
const REGISTRY = {
|
|
80
112
|
hexalize: {
|
|
113
|
+
doc: {
|
|
114
|
+
summary: "Hexagonal cell grid. Snaps sampling to a hex lattice and "
|
|
115
|
+
+ "draws cell borders; with `lift`, cells near the driver swell "
|
|
116
|
+
+ "toward the viewer and their content magnifies to match.",
|
|
117
|
+
params: {
|
|
118
|
+
size: { default: 24, unit: "px", summary: "lattice pitch — the width of one cell" },
|
|
119
|
+
lift: {
|
|
120
|
+
default: 0, unit: "ratio", structural: true,
|
|
121
|
+
summary: "how much bigger a cell gets at the focus: 0.3 renders the "
|
|
122
|
+
+ "nearest hexagons at 1.3x. 0 compiles the cheap path entirely away, "
|
|
123
|
+
+ "so crossing zero rebuilds.",
|
|
124
|
+
},
|
|
125
|
+
radius: { default: 200, unit: "px", summary: "falloff distance around the driver focus" },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
// `lift` at 0 compiles the cheap path; non-zero compiles the
|
|
129
|
+
// seven-neighbour probe. Only crossing zero needs a new shader.
|
|
130
|
+
structuralOnToggle: ["lift"],
|
|
81
131
|
stage: "cell",
|
|
82
132
|
decl: (p) => `
|
|
83
133
|
uniform float ${p}size;
|
|
@@ -181,9 +231,25 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
181
231
|
lift: ["1f", node.lift != null ? node.lift : 0],
|
|
182
232
|
radius: ["1f", (node.radius || 200) * dpr],
|
|
183
233
|
}),
|
|
234
|
+
// Without `lift` the cheap path never writes `warped` — it only
|
|
235
|
+
// sets center/edge — so the coordinate is unchanged. WITH lift it
|
|
236
|
+
// probes seven neighbours to find which grown hexagon covers the
|
|
237
|
+
// fragment, and mirroring that on the CPU would be a second
|
|
238
|
+
// implementation of the subtlest code here. Returns null instead,
|
|
239
|
+
// which routes the query to the GPU readback.
|
|
240
|
+
map: (pt, node) => (node.lift ? null : pt),
|
|
184
241
|
},
|
|
185
242
|
|
|
186
243
|
offset: {
|
|
244
|
+
doc: {
|
|
245
|
+
summary: "Pushes the coordinate space away from the driver focus. Warp "
|
|
246
|
+
+ "stage, so cell grids and borders move WITH the content instead of "
|
|
247
|
+
+ "staying a fixed screen-space lattice.",
|
|
248
|
+
params: {
|
|
249
|
+
strength: { default: 20, unit: "px", summary: "how far the space is pushed at full amount" },
|
|
250
|
+
radius: { default: 260, unit: "px", summary: "falloff distance around the focus" },
|
|
251
|
+
},
|
|
252
|
+
},
|
|
187
253
|
// Warp-stage: displaces the coordinate space before the cell
|
|
188
254
|
// grid is computed, so tiles — content AND borders — move
|
|
189
255
|
// together away from the pointer. (The old displace-stage
|
|
@@ -206,9 +272,136 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
206
272
|
strength: ["1f", (node.strength || 20) * dpr],
|
|
207
273
|
radius: ["1f", (node.radius || 260) * dpr],
|
|
208
274
|
}),
|
|
275
|
+
// Phase I2. The CPU twin of code() above, so a hit-test costs no
|
|
276
|
+
// GPU sync. Checked against the readback rather than trusted —
|
|
277
|
+
// see the property test in raster-probe.spec.js.
|
|
278
|
+
map: (pt, node, ctx) => {
|
|
279
|
+
const strength = (node.strength || 20) * ctx.dpr;
|
|
280
|
+
const radius = (node.radius || 260) * ctx.dpr;
|
|
281
|
+
const vx = pt[0] - ctx.dpos[0], vy = pt[1] - ctx.dpos[1];
|
|
282
|
+
const d = Math.hypot(vx, vy);
|
|
283
|
+
const fall = 1 - smoothstep(0, radius, d);
|
|
284
|
+
if (!(d > 0.5)) return pt;
|
|
285
|
+
const k = (strength * fall * ctx.damt) / d;
|
|
286
|
+
return [pt[0] - vx * k, pt[1] - vy * k];
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
// Curl-noise flow: the coordinate space drifts along a divergence-free
|
|
291
|
+
// vector field, so content slides as if carried by a current. Warp
|
|
292
|
+
// stage, which buys two things for free — it is maskable (STAGE_VARS
|
|
293
|
+
// snapshots `warped`) and it composes with every cell and colour op,
|
|
294
|
+
// because those run downstream of it.
|
|
295
|
+
//
|
|
296
|
+
// { op: "flow", strength: 18, scale: 120 } // whole element
|
|
297
|
+
// { op: "flow", by: "mouse", radius: 320 } // a current at the pointer
|
|
298
|
+
// { op: "flow", by: "time", speed: 0.4 } // hands-free drift
|
|
299
|
+
//
|
|
300
|
+
// Divergence-free BY CONSTRUCTION: the field is the curl of a scalar
|
|
301
|
+
// potential ψ, and curl of a gradient-free scalar has zero divergence.
|
|
302
|
+
// That is what makes it read as a fluid without a pressure solve —
|
|
303
|
+
// `stir` pays for a full Stable-Fluids projection to get a field that
|
|
304
|
+
// also responds to input; this one is free and static-by-seed.
|
|
305
|
+
//
|
|
306
|
+
// Without `by:` the current fills the element. With a driver, strength
|
|
307
|
+
// falls off around the focus (the `duotone`/`halftone` convention), so
|
|
308
|
+
// the effect resolves away from the pointer instead of being global.
|
|
309
|
+
flow: {
|
|
310
|
+
doc: {
|
|
311
|
+
summary: "Curl-noise current. The coordinate space drifts along a "
|
|
312
|
+
+ "divergence-free field, so content slides as if carried by water. "
|
|
313
|
+
+ "Divergence-free means the flow never piles up or tears.",
|
|
314
|
+
params: {
|
|
315
|
+
strength: { default: 18, unit: "px", summary: "how far the current carries the space" },
|
|
316
|
+
scale: { default: 120, unit: "px", summary: "size of one eddy — small is turbulent, large is a slow drift" },
|
|
317
|
+
speed: { default: 0.25, unit: "seconds", summary: "how fast the field evolves; 0 freezes it" },
|
|
318
|
+
seed: { default: 0, unit: "count", summary: "picks a different field of the same character" },
|
|
319
|
+
radius: {
|
|
320
|
+
default: 320, unit: "px",
|
|
321
|
+
summary: "falloff around the focus. Only used when `by` names a driver; "
|
|
322
|
+
+ "without one the current covers the whole element.",
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
structural: ["by"],
|
|
327
|
+
stage: "warp",
|
|
328
|
+
decl: (p) => `
|
|
329
|
+
uniform float ${p}scale;
|
|
330
|
+
uniform float ${p}strength;
|
|
331
|
+
uniform float ${p}speed;
|
|
332
|
+
uniform float ${p}seed;
|
|
333
|
+
uniform float ${p}radius;
|
|
334
|
+
uniform vec2 ${p}dpos;
|
|
335
|
+
uniform float ${p}damt;
|
|
336
|
+
// Polynomial bit-mixing hash, deliberately NOT the usual
|
|
337
|
+
// fract(sin(dot(...))). Trig-based hashes disagree across GPU
|
|
338
|
+
// vendors — sin's precision at large arguments is not
|
|
339
|
+
// specified — so the same seed would render a different field
|
|
340
|
+
// on different hardware. This one is multiply/fract only, so
|
|
341
|
+
// the field is at least stable per-device. (Determinism rule,
|
|
342
|
+
// MORPH-IMPL-SPEC §2.9.5a.)
|
|
343
|
+
float ${p}h(vec2 v) {
|
|
344
|
+
vec3 q = fract(vec3(v.xyx) * 0.1031 + ${p}seed);
|
|
345
|
+
q += dot(q, q.yzx + 33.33);
|
|
346
|
+
return fract((q.x + q.y) * q.z);
|
|
347
|
+
}
|
|
348
|
+
float ${p}n(vec2 v) {
|
|
349
|
+
vec2 i = floor(v), f = v - i;
|
|
350
|
+
f = f * f * (3.0 - 2.0 * f);
|
|
351
|
+
return mix(mix(${p}h(i), ${p}h(i + vec2(1.0, 0.0)), f.x),
|
|
352
|
+
mix(${p}h(i + vec2(0.0, 1.0)), ${p}h(i + vec2(1.0, 1.0)), f.x), f.y);
|
|
353
|
+
}
|
|
354
|
+
// The potential. Two octaves: enough structure to read as a
|
|
355
|
+
// current without the cost of a full fBm.
|
|
356
|
+
float ${p}psi(vec2 v) {
|
|
357
|
+
return ${p}n(v) * 0.65 + ${p}n(v * 2.3 + 11.0) * 0.35;
|
|
358
|
+
}`,
|
|
359
|
+
code: (p, node) => `
|
|
360
|
+
{
|
|
361
|
+
float sc = max(${p}scale, 1.0);
|
|
362
|
+
vec2 drift = vec2(0.0, u_time * ${p}speed);${node.by ? `
|
|
363
|
+
float fall = 1.0 - smoothstep(0.0, ${p}radius, length(warped - ${p}dpos));
|
|
364
|
+
float amt = ${p}strength * fall * ${p}damt;` : `
|
|
365
|
+
float amt = ${p}strength;`}
|
|
366
|
+
// Three fixed Euler steps along curl(psi). Fixed count, not
|
|
367
|
+
// adaptive: the step budget is a compile-time property so the
|
|
368
|
+
// shader cost cannot vary with the content.
|
|
369
|
+
for (int i = 0; i < 3; i++) {
|
|
370
|
+
vec2 q = warped / sc + drift;
|
|
371
|
+
float e = 0.35;
|
|
372
|
+
float dy = ${p}psi(q + vec2(0.0, e)) - ${p}psi(q - vec2(0.0, e));
|
|
373
|
+
float dx = ${p}psi(q + vec2(e, 0.0)) - ${p}psi(q - vec2(e, 0.0));
|
|
374
|
+
// curl of a 2D scalar potential = (dpsi/dy, -dpsi/dx)
|
|
375
|
+
warped += vec2(dy, -dx) / (2.0 * e) * amt * 0.3333;
|
|
376
|
+
}
|
|
377
|
+
}`,
|
|
378
|
+
uniforms: (node, dpr) => ({
|
|
379
|
+
// Feature size of the current, in px. Larger = broader, slower
|
|
380
|
+
// -turning eddies.
|
|
381
|
+
scale: ["1f", (node.scale != null ? node.scale : 120) * dpr],
|
|
382
|
+
// Displacement in px. Scaled by dpr: it is a length.
|
|
383
|
+
strength: ["1f", (node.strength != null ? node.strength : 18) * dpr],
|
|
384
|
+
speed: ["1f", node.speed != null ? node.speed : 0.25],
|
|
385
|
+
// NOT dpr-scaled — it selects a field, it is not a length.
|
|
386
|
+
seed: ["1f", node.seed != null ? node.seed : 0],
|
|
387
|
+
radius: ["1f", (node.radius != null ? node.radius : 320) * dpr],
|
|
388
|
+
}),
|
|
209
389
|
},
|
|
210
390
|
|
|
211
391
|
duotone: {
|
|
392
|
+
doc: {
|
|
393
|
+
summary: "Maps luminance onto a two-colour ramp. With `by`, the mapping "
|
|
394
|
+
+ "is confined to a spot around the driver focus and the rest of the "
|
|
395
|
+
+ "element keeps its own colour.",
|
|
396
|
+
params: {
|
|
397
|
+
colors: {
|
|
398
|
+
default: ["#104B87", "#E8FF00"], unit: "color",
|
|
399
|
+
summary: "[shadow, highlight] — the two ends of the ramp",
|
|
400
|
+
},
|
|
401
|
+
radius: { default: 220, unit: "px", summary: "spot size when `by` names a driver" },
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
structural: ["by"],
|
|
212
405
|
stage: "color",
|
|
213
406
|
decl: (p) => `uniform vec3 ${p}a; uniform vec3 ${p}b; uniform float ${p}radius;
|
|
214
407
|
uniform vec2 ${p}dpos; uniform float ${p}damt;`,
|
|
@@ -239,6 +432,15 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
239
432
|
// e.g. { op: "edges", color: "#FFFFFF" } gives white borders under
|
|
240
433
|
// the text. When present it also replaces the default seam darkening.
|
|
241
434
|
edges: {
|
|
435
|
+
doc: {
|
|
436
|
+
summary: "Draws the cell borders a cell-stage op produced. On its own it "
|
|
437
|
+
+ "shows nothing — it colours `edge`, which only hexalize (or another "
|
|
438
|
+
+ "cell op) writes.",
|
|
439
|
+
params: {
|
|
440
|
+
color: { default: "#FFFFFF", unit: "color", summary: "border colour" },
|
|
441
|
+
strength: { default: 1.0, unit: "ratio", summary: "border opacity" },
|
|
442
|
+
},
|
|
443
|
+
},
|
|
242
444
|
stage: "color",
|
|
243
445
|
decl: (p) => `uniform vec3 ${p}color; uniform float ${p}strength;`,
|
|
244
446
|
code: (p) => `
|
|
@@ -285,9 +487,24 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
285
487
|
// With a driver the screen coarsens toward the focus, like holding a
|
|
286
488
|
// loupe over the sheet.
|
|
287
489
|
halftone: {
|
|
490
|
+
doc: {
|
|
491
|
+
summary: "Print halftone: the image is redrawn as a grid of ink dots on "
|
|
492
|
+
+ "paper, dot size following local luminance.",
|
|
493
|
+
params: {
|
|
494
|
+
size: { default: 6, unit: "px", summary: "dot pitch" },
|
|
495
|
+
angle: { default: 15, unit: "deg", summary: "screen angle of the dot grid" },
|
|
496
|
+
ink: { default: "#0B1B2B", unit: "color", summary: "dot colour" },
|
|
497
|
+
paper: { default: "#FFFFFF", unit: "color", summary: "background colour" },
|
|
498
|
+
softness: { default: 0.08, unit: "ratio", summary: "dot edge softness — 0 is a hard stencil" },
|
|
499
|
+
amount: { default: 1, unit: "ratio", summary: "blend back toward the un-screened image; 0 is identity. Keyframe it to 0 at both ends of a transition so the shader hands over to the real element without a pop." },
|
|
500
|
+
radius: { default: 220, unit: "px", summary: "spot size when `by` names a driver" },
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
structural: ["by"],
|
|
288
504
|
stage: "color",
|
|
289
505
|
decl: (p) => `
|
|
290
506
|
uniform float ${p}size;
|
|
507
|
+
uniform float ${p}amount;
|
|
291
508
|
uniform float ${p}angle;
|
|
292
509
|
uniform vec3 ${p}ink;
|
|
293
510
|
uniform vec3 ${p}paper;
|
|
@@ -311,10 +528,11 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
311
528
|
// sqrt because ink coverage goes as area, not radius.
|
|
312
529
|
float rr = sqrt(clamp(1.0 - lum, 0.0, 1.0)) * 0.55;
|
|
313
530
|
float dm = smoothstep(rr, rr - ${p}soft, length(cell));
|
|
314
|
-
col = mix(${p}paper, ${p}ink, dm);
|
|
531
|
+
col = mix(col, mix(${p}paper, ${p}ink, dm), clamp(${p}amount, 0.0, 1.0));
|
|
315
532
|
}`,
|
|
316
533
|
uniforms: (node, dpr) => ({
|
|
317
534
|
size: ["1f", (node.size || 6) * dpr],
|
|
535
|
+
amount: ["1f", node.amount != null ? node.amount : 1],
|
|
318
536
|
angle: ["1f", ((node.angle != null ? node.angle : 15) * Math.PI) / 180],
|
|
319
537
|
ink: ["3fv", hexToRgb(node.ink || "#0B1B2B")],
|
|
320
538
|
paper: ["3fv", hexToRgb(node.paper || "#FFFFFF")],
|
|
@@ -341,6 +559,25 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
341
559
|
//
|
|
342
560
|
// `by` coarsens the pattern toward the driver focus, as halftone does.
|
|
343
561
|
dither: {
|
|
562
|
+
doc: {
|
|
563
|
+
summary: "Ordered (Bayer) dithering to a small palette — the look of an "
|
|
564
|
+
+ "8-bit display, and the one op that gets sharper as the element gets "
|
|
565
|
+
+ "smaller.",
|
|
566
|
+
params: {
|
|
567
|
+
size: { default: 1, unit: "px", summary: "size of one dither cell; 1 is per-pixel" },
|
|
568
|
+
levels: { default: 6, unit: "count", summary: "quantisation steps per channel" },
|
|
569
|
+
amount: { default: 1, unit: "ratio", summary: "blend back toward the undithered image" },
|
|
570
|
+
mono: {
|
|
571
|
+
default: false, unit: "bool", structural: true,
|
|
572
|
+
summary: "quantise luminance to ink/paper instead of per-channel colour. "
|
|
573
|
+
+ "A different shader, so toggling rebuilds.",
|
|
574
|
+
},
|
|
575
|
+
ink: { default: "#0B1B2B", unit: "color", summary: "dark end, mono only" },
|
|
576
|
+
paper: { default: "#FFFFFF", unit: "color", summary: "light end, mono only" },
|
|
577
|
+
radius: { default: 220, unit: "px", summary: "spot size when `by` names a driver" },
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
structural: ["by", "mono"],
|
|
344
581
|
stage: "color",
|
|
345
582
|
decl: (p) => `
|
|
346
583
|
uniform float ${p}levels;
|
|
@@ -400,6 +637,20 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
400
637
|
// { op: "aberration", amount: 6 } // radial, lens-like
|
|
401
638
|
// { op: "aberration", amount: 14, by: "mouse" } // focused at the pointer
|
|
402
639
|
aberration: {
|
|
640
|
+
// Displace stage, but it only spreads the per-channel taps
|
|
641
|
+
// (`chroma`) — the primary sample position is untouched. Declared
|
|
642
|
+
// so a hit-test can skip it rather than give up on the CPU path;
|
|
643
|
+
// a unit test checks this against the op's own source.
|
|
644
|
+
movesCoords: false,
|
|
645
|
+
doc: {
|
|
646
|
+
summary: "Chromatic aberration: the three colour channels are sampled "
|
|
647
|
+
+ "at slightly different offsets, like a cheap lens.",
|
|
648
|
+
params: {
|
|
649
|
+
amount: { default: 6, unit: "px", summary: "channel separation" },
|
|
650
|
+
radius: { default: 240, unit: "px", summary: "falloff around the focus when `by` names a driver" },
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
structural: ["by"],
|
|
403
654
|
stage: "displace",
|
|
404
655
|
chroma: true,
|
|
405
656
|
decl: (p) => `
|
|
@@ -439,6 +690,30 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
439
690
|
|
|
440
691
|
// Geometric / content masks.
|
|
441
692
|
mask: {
|
|
693
|
+
doc: {
|
|
694
|
+
summary: "Writes a scalar FIELD for later ops to read — draws nothing "
|
|
695
|
+
+ "itself. Give a downstream op `masked: \"name\"` and it applies only "
|
|
696
|
+
+ "where this field is high. This is how any op becomes local without "
|
|
697
|
+
+ "knowing anything about masking.",
|
|
698
|
+
params: {
|
|
699
|
+
as: {
|
|
700
|
+
default: "mask", unit: "name", structural: true,
|
|
701
|
+
summary: "name of the field written. Consumers name it in `masked:`.",
|
|
702
|
+
},
|
|
703
|
+
from: {
|
|
704
|
+
default: "radial", unit: "name", structural: true,
|
|
705
|
+
summary: "field shape — compiled in, so changing it rebuilds",
|
|
706
|
+
},
|
|
707
|
+
at: {
|
|
708
|
+
default: [0.5, 0.5], unit: "point", structural: true,
|
|
709
|
+
summary: "centre as a fraction of the element box; survives resizes",
|
|
710
|
+
},
|
|
711
|
+
radius: { default: 260, unit: "px", summary: "field radius" },
|
|
712
|
+
invert: { default: false, unit: "bool", summary: "swap inside and outside" },
|
|
713
|
+
remap: { default: [0, 1], unit: "range", summary: "rescale the field's output range" },
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
structural: ["as", "from", "at"],
|
|
442
717
|
stage: "field",
|
|
443
718
|
producesField: true,
|
|
444
719
|
defaultDriver: "static",
|
|
@@ -483,6 +758,22 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
483
758
|
// { op: "noise", as: "turbulence", scale: 3, speed: 0.4 },
|
|
484
759
|
// { op: "offset", masked: "turbulence", strength: 30 },
|
|
485
760
|
noise: {
|
|
761
|
+
doc: {
|
|
762
|
+
summary: "Writes an animated noise FIELD. Same contract as `mask` — it "
|
|
763
|
+
+ "draws nothing; downstream ops read it via `masked:`. Use it to make "
|
|
764
|
+
+ "any other op flicker, breathe or crawl.",
|
|
765
|
+
params: {
|
|
766
|
+
as: {
|
|
767
|
+
default: "mask", unit: "name", structural: true,
|
|
768
|
+
summary: "name of the field written",
|
|
769
|
+
},
|
|
770
|
+
scale: { default: 3, unit: "ratio", summary: "noise frequency across the element" },
|
|
771
|
+
speed: { default: 0.3, unit: "seconds", summary: "how fast it evolves; 0 freezes it" },
|
|
772
|
+
amount: { default: 1, unit: "ratio", summary: "blend between flat 1.0 and full noise" },
|
|
773
|
+
remap: { default: [0, 1], unit: "range", summary: "rescale the field's output range" },
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
structural: ["as"],
|
|
486
777
|
stage: "field",
|
|
487
778
|
producesField: true,
|
|
488
779
|
decl: (p) => `
|
|
@@ -535,6 +826,35 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
535
826
|
// earlier colour ops, so a copy of a halftoned element is a copy of
|
|
536
827
|
// the unhalftoned source with the screen applied over it.
|
|
537
828
|
copy: {
|
|
829
|
+
// Phase I4. Draws N stamps of the ORIGINAL texture over the
|
|
830
|
+
// result, so one screen point genuinely shows several sources and
|
|
831
|
+
// `sourceAt` can only report the primary sample. There is no
|
|
832
|
+
// correct single answer here, only a documented one.
|
|
833
|
+
multiSample: true,
|
|
834
|
+
doc: {
|
|
835
|
+
summary: "Stamps extra copies of the element over itself. Copies sample "
|
|
836
|
+
+ "the ORIGINAL texture, not the result of earlier colour ops, so a copy "
|
|
837
|
+
+ "of a halftoned element is a copy of the unhalftoned source.",
|
|
838
|
+
params: {
|
|
839
|
+
count: {
|
|
840
|
+
default: 5, unit: "count", structural: true,
|
|
841
|
+
summary: "stamps arranged in a ring around the focus. The loop is "
|
|
842
|
+
+ "unrolled at compile time, so this rebuilds — not something to animate.",
|
|
843
|
+
},
|
|
844
|
+
points: {
|
|
845
|
+
default: null, unit: "point", structural: true,
|
|
846
|
+
summary: "explicit [[x,y], ...] in fractions of the element box, instead "
|
|
847
|
+
+ "of a ring. Survives resizes. Overrides `count`.",
|
|
848
|
+
},
|
|
849
|
+
radius: { default: 110, unit: "px", summary: "ring radius when using `count`" },
|
|
850
|
+
scale: { default: 0.5, unit: "ratio", summary: "size of each copy" },
|
|
851
|
+
rotate: { default: 0, unit: "deg", summary: "rotation applied per copy" },
|
|
852
|
+
fade: { default: 1, unit: "ratio", summary: "opacity falloff across the set" },
|
|
853
|
+
amount: { default: 1, unit: "ratio", summary: "overall blend of the copies over the original" },
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
// `count`/`points` unroll the stamp loop at compile time.
|
|
857
|
+
structural: ["count", "points"],
|
|
538
858
|
stage: "color",
|
|
539
859
|
defaultDriver: "static",
|
|
540
860
|
decl: (p) => `
|
|
@@ -605,6 +925,22 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
605
925
|
// no extra framebuffer and no second capture of the DOM. It cannot,
|
|
606
926
|
// for the same reason, give the branches different source content.
|
|
607
927
|
merge: {
|
|
928
|
+
doc: {
|
|
929
|
+
summary: "Runs two sub-chains over the same source and blends the "
|
|
930
|
+
+ "results. The only op that takes other ops as arguments — everything "
|
|
931
|
+
+ "else composes by sequence, this composes in parallel.",
|
|
932
|
+
params: {
|
|
933
|
+
a: { default: null, summary: "first branch: an array of nodes", structural: true },
|
|
934
|
+
b: { default: null, summary: "second branch: an array of nodes", structural: true },
|
|
935
|
+
mode: {
|
|
936
|
+
default: "over", unit: "name", structural: true,
|
|
937
|
+
summary: "how the branches combine: over, add, screen, multiply, "
|
|
938
|
+
+ "difference, lighten. Compiled in, so it rebuilds.",
|
|
939
|
+
},
|
|
940
|
+
mix: { default: 1, unit: "ratio", summary: "how far to blend b into a" },
|
|
941
|
+
},
|
|
942
|
+
},
|
|
943
|
+
structural: ["mode", "a", "b"],
|
|
608
944
|
// Never routed through the normal stage path — emitMerge()
|
|
609
945
|
// handles it — but it still needs decl/uniforms so its `mix`
|
|
610
946
|
// gets declared and uploaded like any other op's.
|
|
@@ -632,6 +968,17 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
632
968
|
// skeleton composites underneath the content, so the ghosts sit
|
|
633
969
|
// behind the live text rather than veiling it.
|
|
634
970
|
echo: {
|
|
971
|
+
doc: {
|
|
972
|
+
summary: "Temporal accumulation — a fading trail of what CHANGED. Typing "
|
|
973
|
+
+ "smears, a ticking counter leaves a comet. The one op that does nothing "
|
|
974
|
+
+ "on the snapshot backend, because a frozen image has no motion to record.",
|
|
975
|
+
params: {
|
|
976
|
+
strength: { default: 0.85, unit: "ratio", summary: "opacity of the trail" },
|
|
977
|
+
decay: { default: 0.92, unit: "ratio", summary: "per-second persistence — higher is a longer tail" },
|
|
978
|
+
tint: { default: "#7FD4FF", unit: "color", summary: "colour pushed into the trail" },
|
|
979
|
+
tintAmount: { default: 0.5, unit: "ratio", summary: "how far the trail is tinted" },
|
|
980
|
+
},
|
|
981
|
+
},
|
|
635
982
|
stage: "color",
|
|
636
983
|
decl: (p) => `
|
|
637
984
|
uniform sampler2D ${p}hist;
|
|
@@ -747,6 +1094,29 @@ void main() {
|
|
|
747
1094
|
// Needs WebGL2 with float render targets; without them the op
|
|
748
1095
|
// disables itself and the rest of the chain still renders.
|
|
749
1096
|
stir: {
|
|
1097
|
+
doc: {
|
|
1098
|
+
summary: "Stirred liquid: a real fluid simulation the content is dragged "
|
|
1099
|
+
+ "through. STATEFUL — it owns a ping-pong pair of render targets that "
|
|
1100
|
+
+ "evolve frame to frame, so it is the most expensive op here.",
|
|
1101
|
+
params: {
|
|
1102
|
+
strength: { default: 26, unit: "px", summary: "how far the fluid carries the content" },
|
|
1103
|
+
force: { default: 1.0, unit: "ratio", summary: "how hard the pointer pushes the fluid" },
|
|
1104
|
+
radius: { default: 0.1, unit: "ratio", summary: "splat size, as a fraction of the sim grid" },
|
|
1105
|
+
curl: { default: 2.2, unit: "ratio", summary: "vorticity — how much the fluid curls into eddies" },
|
|
1106
|
+
decay: { default: 0.985, unit: "ratio", summary: "per-second velocity persistence" },
|
|
1107
|
+
pressure: { default: 0.8, unit: "ratio", summary: "pressure retained between frames" },
|
|
1108
|
+
pressureIterations: { default: 6, unit: "count", summary: "solver iterations; higher is stiffer and slower" },
|
|
1109
|
+
intensity: { default: 1.6, unit: "ratio", summary: "brightness of the dye layer" },
|
|
1110
|
+
sheen: { default: 0.15, unit: "ratio", summary: "specular highlight on the surface" },
|
|
1111
|
+
tint: { default: "#7FD4FF", unit: "color", summary: "dye colour, unless `rainbow`" },
|
|
1112
|
+
rainbow: { default: false, unit: "bool", summary: "cycle dye hue instead of using `tint`" },
|
|
1113
|
+
dye: { default: 0.85, unit: "ratio", summary: "how much dye colour reaches the final image" },
|
|
1114
|
+
dyeAmount: { default: 0.6, unit: "ratio", summary: "how much dye each splat injects" },
|
|
1115
|
+
dyeFade: { default: 0.97, unit: "ratio", summary: "per-second dye persistence" },
|
|
1116
|
+
resolution: { default: 128, unit: "count", summary: "velocity grid size, clamped 32..512" },
|
|
1117
|
+
dyeResolution: { default: 512, unit: "count", summary: "dye grid size, clamped 64..1024" },
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
750
1120
|
stage: ["warp", "color"],
|
|
751
1121
|
decl: (p) => `
|
|
752
1122
|
uniform sampler2D ${p}vel;
|
|
@@ -1079,6 +1449,32 @@ void main() {
|
|
|
1079
1449
|
// gradient. init()/tick() run the particle sim on the CPU and feed
|
|
1080
1450
|
// the positions in as a per-frame uniform array.
|
|
1081
1451
|
blobs: {
|
|
1452
|
+
doc: {
|
|
1453
|
+
summary: "Liquid-glass blobs that follow the pointer: a chain of "
|
|
1454
|
+
+ "metaballs refracting the content behind them, stretching apart on a "
|
|
1455
|
+
+ "fast flick and relaxing back to a single circle at rest.",
|
|
1456
|
+
params: {
|
|
1457
|
+
count: {
|
|
1458
|
+
default: 1, unit: "count", structural: true,
|
|
1459
|
+
summary: "independent drifting blobs, on top of the trail chain",
|
|
1460
|
+
},
|
|
1461
|
+
trail: {
|
|
1462
|
+
default: 7, unit: "count", structural: true,
|
|
1463
|
+
summary: "blobs in the follow-the-leader chain. Unrolled at compile "
|
|
1464
|
+
+ "time, so it rebuilds.",
|
|
1465
|
+
},
|
|
1466
|
+
radius: { default: 46, unit: "px", summary: "blob radius at rest" },
|
|
1467
|
+
color: { default: "#EAF4EE", unit: "color", summary: "glass tint" },
|
|
1468
|
+
refract: { default: 70, unit: "px", summary: "how far the glass bends what is behind it" },
|
|
1469
|
+
rim: { default: 0.6, unit: "ratio", summary: "brightness of the rim light" },
|
|
1470
|
+
alpha: { default: 1.0, unit: "ratio", summary: "overall opacity" },
|
|
1471
|
+
iridescence: { default: 0.8, unit: "ratio", summary: "colour shift across the rim" },
|
|
1472
|
+
dispersion: { default: 1.0, unit: "ratio", summary: "per-channel refraction spread" },
|
|
1473
|
+
frost: { default: 0.25, unit: "ratio", summary: "blur of what shows through" },
|
|
1474
|
+
},
|
|
1475
|
+
},
|
|
1476
|
+
// Read by init(), which allocates the particle chain.
|
|
1477
|
+
structural: ["count", "trail"],
|
|
1082
1478
|
stage: "color",
|
|
1083
1479
|
overlay: true,
|
|
1084
1480
|
mips: true,
|
|
@@ -1266,15 +1662,18 @@ void main() {
|
|
|
1266
1662
|
chain[0].y = state.head.y;
|
|
1267
1663
|
const follow = 1 - Math.exp(-dt * 16);
|
|
1268
1664
|
for (let i = 1; i < chain.length; i++) {
|
|
1269
|
-
|
|
1270
|
-
|
|
1665
|
+
// `b` is a blob in the chain, NOT the raster node — it was
|
|
1666
|
+
// called `node` and shadowed the op's own parameter, which
|
|
1667
|
+
// reads as this op having x/y params it does not have.
|
|
1668
|
+
const lead = chain[i - 1], b = chain[i];
|
|
1669
|
+
let dx = lead.x - b.x, dy = lead.y - b.y;
|
|
1271
1670
|
const d = Math.hypot(dx, dy) || 1e-4;
|
|
1272
1671
|
// Cap the lag so a fast flick can't tear the tube apart,
|
|
1273
1672
|
// then ease the remainder — gives an elastic pull.
|
|
1274
1673
|
const maxLag = state.seg;
|
|
1275
|
-
if (d > maxLag) {
|
|
1276
|
-
|
|
1277
|
-
|
|
1674
|
+
if (d > maxLag) { b.x += dx * (1 - maxLag / d); b.y += dy * (1 - maxLag / d); }
|
|
1675
|
+
b.x += (lead.x - b.x) * follow;
|
|
1676
|
+
b.y += (lead.y - b.y) * follow;
|
|
1278
1677
|
}
|
|
1279
1678
|
|
|
1280
1679
|
// Stretch = how far the tail trails the head, normalised by the
|
|
@@ -1320,6 +1719,12 @@ void main() {
|
|
|
1320
1719
|
},
|
|
1321
1720
|
};
|
|
1322
1721
|
|
|
1722
|
+
// GLSL smoothstep, for the CPU coordinate twins (phase I2).
|
|
1723
|
+
function smoothstep(e0, e1, x) {
|
|
1724
|
+
const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0 || 1e-6)));
|
|
1725
|
+
return t * t * (3 - 2 * t);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1323
1728
|
function hexToRgb(c) {
|
|
1324
1729
|
return [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16) / 255);
|
|
1325
1730
|
}
|
|
@@ -1333,11 +1738,258 @@ function hexToRgb(c) {
|
|
|
1333
1738
|
// node carries `target:`; the sub-chains inherit whatever it matched.
|
|
1334
1739
|
const RASTER_OP_NAMES = Object.keys(REGISTRY).concat("switch");
|
|
1335
1740
|
|
|
1741
|
+
/**
|
|
1742
|
+
* How an op behaves under hit-testing (phase I4).
|
|
1743
|
+
*
|
|
1744
|
+
* DERIVED from what the op already declares rather than hand-listed, so
|
|
1745
|
+
* the taxonomy cannot drift from the contract the way a maintained table
|
|
1746
|
+
* would. The classes, and what each means for `pipeline.sourceAt`:
|
|
1747
|
+
*
|
|
1748
|
+
* "neutral" does not move the sampling coordinate, so the browser's
|
|
1749
|
+
* own hit-testing was already right (halftone, duotone,
|
|
1750
|
+
* and aberration, which spreads only the per-channel taps)
|
|
1751
|
+
* "analytic" declares a CPU twin that always answers, so a hit-test
|
|
1752
|
+
* costs no GPU sync (offset, and the pixelate example)
|
|
1753
|
+
* "conditional" declares a twin that answers only for some parameters —
|
|
1754
|
+
* hexalize is identity without `lift` and declines with it
|
|
1755
|
+
* "readback" moves coordinates with no twin, so every query is an
|
|
1756
|
+
* exact GPU round trip (flow, stir)
|
|
1757
|
+
* "many-to-one" one screen point maps to several sources; sourceAt
|
|
1758
|
+
* reports the primary sample (copy)
|
|
1759
|
+
*
|
|
1760
|
+
* @param {string} op
|
|
1761
|
+
* @returns {"neutral"|"analytic"|"conditional"|"readback"|"many-to-one"|"unknown"}
|
|
1762
|
+
*/
|
|
1763
|
+
function interactionClass(op) {
|
|
1764
|
+
const def = REGISTRY[op];
|
|
1765
|
+
if (!def) return "unknown";
|
|
1766
|
+
if (def.multiSample) return "many-to-one";
|
|
1767
|
+
const stages = Array.isArray(def.stage) ? def.stage : [def.stage];
|
|
1768
|
+
const movesStage = stages.some((s) => s === "warp" || s === "cell" || s === "displace");
|
|
1769
|
+
if (!movesStage || def.movesCoords === false) return "neutral";
|
|
1770
|
+
if (typeof def.map !== "function") return "readback";
|
|
1771
|
+
// A twin that can decline for some parameter values is not the same
|
|
1772
|
+
// promise as one that always answers, and the difference is exactly
|
|
1773
|
+
// what a caller budgeting for GPU syncs needs to know.
|
|
1774
|
+
return /return\s+null|\?\s*null|null\s*:/.test(String(def.map)) ? "conditional" : "analytic";
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// ── Phase T1: keyframed params ───────────────────────────────────────
|
|
1778
|
+
//
|
|
1779
|
+
// A param may be given as an array of numbers, sampled over transition
|
|
1780
|
+
// progress `t`:
|
|
1781
|
+
//
|
|
1782
|
+
// { op: "flow", strength: [0, 40, 0] } // in, peak, out
|
|
1783
|
+
//
|
|
1784
|
+
// The problem this has to solve: plenty of params are LEGITIMATELY
|
|
1785
|
+
// arrays. `mask.at` is [x, y], `remap` is [lo, hi], `duotone.colors` is
|
|
1786
|
+
// a pair, `copy.points` is a list, `merge.a` is a sub-chain. Guessing
|
|
1787
|
+
// from the value alone would break all of them.
|
|
1788
|
+
//
|
|
1789
|
+
// H4's `doc.params` already carries the answer: a param whose declared
|
|
1790
|
+
// unit is a SCALAR quantity, given as two or more numbers, is keyframes.
|
|
1791
|
+
// Anything else is data. So this is another property bought by the
|
|
1792
|
+
// declaration an op already makes, and an op with no doc never
|
|
1793
|
+
// keyframes — which is the safe direction to be wrong in.
|
|
1794
|
+
const KEYFRAMABLE_UNITS = ["px", "ratio", "deg", "count", "seconds"];
|
|
1795
|
+
|
|
1796
|
+
function isKeyframed(op, key, value) {
|
|
1797
|
+
if (!Array.isArray(value) || value.length < 2) return false;
|
|
1798
|
+
for (const v of value) if (typeof v !== "number" || !Number.isFinite(v)) return false;
|
|
1799
|
+
const def = REGISTRY[op];
|
|
1800
|
+
const meta = def && def.doc && def.doc.params && def.doc.params[key];
|
|
1801
|
+
return !!meta && KEYFRAMABLE_UNITS.includes(meta.unit);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/** Piecewise-linear sample of a keyframe array at t in [0, 1]. */
|
|
1805
|
+
function sampleKeyframes(kf, t) {
|
|
1806
|
+
const n = kf.length - 1;
|
|
1807
|
+
const x = Math.min(1, Math.max(0, t)) * n;
|
|
1808
|
+
const i = Math.min(n - 1, Math.floor(x));
|
|
1809
|
+
return kf[i] + (kf[i + 1] - kf[i]) * (x - i);
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// ── Phase T3: choreography ───────────────────────────────────────────
|
|
1813
|
+
//
|
|
1814
|
+
// Two node-level controls, both acting on the LOCAL progress an op's
|
|
1815
|
+
// keyframes are sampled against. Neither is a new authoring language —
|
|
1816
|
+
// they reshape t, and everything downstream is unchanged.
|
|
1817
|
+
//
|
|
1818
|
+
// window: [0.2, 0.8] this op's keyframes span that slice of t, so
|
|
1819
|
+
// several ops in one chain can stagger
|
|
1820
|
+
// ease: "in-out" easing applied to the local progress
|
|
1821
|
+
//
|
|
1822
|
+
// Easing lives here rather than in the timeline because a chain wants
|
|
1823
|
+
// ops on different curves; the timeline that drives `t` stays linear and
|
|
1824
|
+
// therefore still scrubbable and reversible (P-1).
|
|
1825
|
+
const EASINGS = {
|
|
1826
|
+
linear: (x) => x,
|
|
1827
|
+
in: (x) => x * x * x,
|
|
1828
|
+
out: (x) => 1 - Math.pow(1 - x, 3),
|
|
1829
|
+
"in-out": (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2),
|
|
1830
|
+
// Overshoots past 1 and settles — the classic UI "pop". Keyframes are
|
|
1831
|
+
// sampled with clamping, so an overshoot reads as a hold at the end
|
|
1832
|
+
// unless the author gives it somewhere to go.
|
|
1833
|
+
back: (x) => 1 + 2.70158 * Math.pow(x - 1, 3) + 1.70158 * Math.pow(x - 1, 2),
|
|
1834
|
+
};
|
|
1835
|
+
const EASING_NAMES = Object.keys(EASINGS);
|
|
1836
|
+
|
|
1837
|
+
/** An op's own progress: its window, then its easing. */
|
|
1838
|
+
function localProgress(node, t) {
|
|
1839
|
+
let x = Math.min(1, Math.max(0, t));
|
|
1840
|
+
const w = node.window;
|
|
1841
|
+
if (Array.isArray(w) && w.length === 2 && w[1] !== w[0]) {
|
|
1842
|
+
x = Math.min(1, Math.max(0, (x - w[0]) / (w[1] - w[0])));
|
|
1843
|
+
}
|
|
1844
|
+
const ease = node.ease && EASINGS[node.ease];
|
|
1845
|
+
return ease ? ease(x) : x;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* The node as the op should see it at progress `t` — keyframe arrays
|
|
1850
|
+
* collapsed to scalars, everything else untouched.
|
|
1851
|
+
*
|
|
1852
|
+
* Returns the ORIGINAL object when nothing is keyframed, so the common
|
|
1853
|
+
* case allocates nothing per frame.
|
|
1854
|
+
*/
|
|
1855
|
+
function resolveNode(node, t) {
|
|
1856
|
+
let out = null;
|
|
1857
|
+
const local = (node.window || node.ease) ? localProgress(node, t) : t;
|
|
1858
|
+
for (const key in node) {
|
|
1859
|
+
if (!isKeyframed(node.op, key, node[key])) continue;
|
|
1860
|
+
out = out || Object.assign({}, node);
|
|
1861
|
+
out[key] = sampleKeyframes(node[key], local);
|
|
1862
|
+
}
|
|
1863
|
+
return out || node;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/** Does this node carry any keyframed param? */
|
|
1867
|
+
function hasKeyframes(node) {
|
|
1868
|
+
for (const key in node) if (isKeyframed(node.op, key, node[key])) return true;
|
|
1869
|
+
return false;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1336
1872
|
// Extension surface — mirrors the CSS-level operation registry:
|
|
1337
1873
|
// registerRasterOp("pixelate", { stage, decl, code, uniforms }).
|
|
1874
|
+
//
|
|
1875
|
+
// This used to be two lines that assigned and returned. Every way of
|
|
1876
|
+
// getting it wrong therefore succeeded, and surfaced later as a shader
|
|
1877
|
+
// that failed to compile with no indication of which op wrote the bad
|
|
1878
|
+
// line — or, worse, as an op that compiled and did nothing, because a
|
|
1879
|
+
// misspelt `stage` matched no stage and its code was never emitted.
|
|
1880
|
+
// That is the silently-ignored-key class again, one level up: not a bad
|
|
1881
|
+
// option on a node, but a bad op in the registry.
|
|
1882
|
+
//
|
|
1883
|
+
// Registration is a one-time cost paid at startup, so it validates
|
|
1884
|
+
// eagerly and throws with the vocabulary listed. Everything here is
|
|
1885
|
+
// checkable without a GL context, which is why it runs at registration
|
|
1886
|
+
// rather than at first compile.
|
|
1887
|
+
function validateRasterOp(name, def) {
|
|
1888
|
+
const where = `registerRasterOp("${name}")`;
|
|
1889
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
1890
|
+
throw new TypeError("[nodality] registerRasterOp needs a non-empty name");
|
|
1891
|
+
}
|
|
1892
|
+
if (!def || typeof def !== "object") {
|
|
1893
|
+
throw new TypeError(`[nodality] ${where}: definition must be an object`);
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// stage — the one field whose typo is invisible at runtime.
|
|
1897
|
+
const stages = Array.isArray(def.stage) ? def.stage : [def.stage];
|
|
1898
|
+
if (def.stage == null || !stages.length) {
|
|
1899
|
+
throw new TypeError(`[nodality] ${where}: missing "stage". ` +
|
|
1900
|
+
`Valid stages: ${RASTER_STAGES.join(", ")}.`);
|
|
1901
|
+
}
|
|
1902
|
+
for (const s of stages) {
|
|
1903
|
+
if (typeof s !== "string" || !RASTER_STAGES.includes(s)) {
|
|
1904
|
+
throw new TypeError(`[nodality] ${where}: ` +
|
|
1905
|
+
didYouMean(s, RASTER_STAGES, "stage"));
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// The two functions that produce GLSL, and the optional one that
|
|
1910
|
+
// feeds it. `uniforms` is optional because an op may be entirely
|
|
1911
|
+
// compile-time (see `copy`, which unrolls its loop in code()).
|
|
1912
|
+
for (const k of ["decl", "code"]) {
|
|
1913
|
+
if (typeof def[k] !== "function") {
|
|
1914
|
+
throw new TypeError(`[nodality] ${where}: "${k}" must be a function ` +
|
|
1915
|
+
`(got ${def[k] === undefined ? "nothing" : typeof def[k]}).`);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
if (def.uniforms != null && typeof def.uniforms !== "function") {
|
|
1919
|
+
throw new TypeError(`[nodality] ${where}: "uniforms" must be a function if present.`);
|
|
1920
|
+
}
|
|
1921
|
+
// Phase I2. `map` is the CPU twin of the op's coordinate arithmetic;
|
|
1922
|
+
// `movesCoords: false` says a coordinate-stage op leaves the sampling
|
|
1923
|
+
// position alone (aberration only spreads the per-channel taps). Both
|
|
1924
|
+
// let a hit-test skip the GPU readback, so a wrong shape here would
|
|
1925
|
+
// silently route every query the slow way.
|
|
1926
|
+
if (def.map != null && typeof def.map !== "function") {
|
|
1927
|
+
throw new TypeError(`[nodality] ${where}: "map" must be a function if present.`);
|
|
1928
|
+
}
|
|
1929
|
+
if (def.movesCoords != null && typeof def.movesCoords !== "boolean") {
|
|
1930
|
+
throw new TypeError(`[nodality] ${where}: "movesCoords" must be a boolean if present.`);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// Rebuild hints. A name here that is not a real param is not fatal,
|
|
1934
|
+
// but the shape being wrong means isStructuralChange() would throw
|
|
1935
|
+
// mid-drag, so the array-of-strings part is enforced.
|
|
1936
|
+
for (const k of ["structural", "structuralOnToggle"]) {
|
|
1937
|
+
if (def[k] == null) continue;
|
|
1938
|
+
if (!Array.isArray(def[k]) || def[k].some((s) => typeof s !== "string")) {
|
|
1939
|
+
throw new TypeError(`[nodality] ${where}: "${k}" must be an array of param names.`);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
if (def.defaultDriver != null && !DRIVER_NAMES.includes(def.defaultDriver)) {
|
|
1944
|
+
throw new TypeError(`[nodality] ${where}: ` +
|
|
1945
|
+
didYouMean(def.defaultDriver, DRIVER_NAMES, "driver"));
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// `doc` is optional — a third-party op stays registerable without it,
|
|
1949
|
+
// and the inspector falls back to introspection. But a doc that IS
|
|
1950
|
+
// supplied has to be the shape every reader assumes, or the panel
|
|
1951
|
+
// throws while rendering someone else's op.
|
|
1952
|
+
if (def.doc != null) {
|
|
1953
|
+
if (typeof def.doc !== "object") {
|
|
1954
|
+
throw new TypeError(`[nodality] ${where}: "doc" must be an object.`);
|
|
1955
|
+
}
|
|
1956
|
+
if (typeof def.doc.summary !== "string" || !def.doc.summary.trim()) {
|
|
1957
|
+
throw new TypeError(`[nodality] ${where}: "doc.summary" must be a non-empty string.`);
|
|
1958
|
+
}
|
|
1959
|
+
const params = def.doc.params;
|
|
1960
|
+
if (params != null) {
|
|
1961
|
+
if (typeof params !== "object" || Array.isArray(params)) {
|
|
1962
|
+
throw new TypeError(`[nodality] ${where}: "doc.params" must be an object ` +
|
|
1963
|
+
`keyed by param name.`);
|
|
1964
|
+
}
|
|
1965
|
+
for (const [k, v] of Object.entries(params)) {
|
|
1966
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
1967
|
+
throw new TypeError(`[nodality] ${where}: doc.params.${k} must be an ` +
|
|
1968
|
+
`object like { default, unit }.`);
|
|
1969
|
+
}
|
|
1970
|
+
if (v.unit != null && !RASTER_UNITS.includes(v.unit)) {
|
|
1971
|
+
throw new TypeError(`[nodality] ${where}: doc.params.${k}: ` +
|
|
1972
|
+
didYouMean(v.unit, RASTER_UNITS, "unit"));
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
return def;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1338
1980
|
function registerRasterOp(name, def) {
|
|
1981
|
+
validateRasterOp(name, def);
|
|
1982
|
+
// Replacing a built-in is legitimate — overriding `halftone` with your
|
|
1983
|
+
// own is a reason this surface exists — but doing it by accident,
|
|
1984
|
+
// because two libraries picked the same word, is not. Warn rather than
|
|
1985
|
+
// throw: the caller may well mean it, and there is no way to tell.
|
|
1986
|
+
if (Object.prototype.hasOwnProperty.call(REGISTRY, name) &&
|
|
1987
|
+
typeof console !== "undefined" && console.warn) {
|
|
1988
|
+
console.warn(`[nodality] registerRasterOp("${name}") replaces an existing op.`);
|
|
1989
|
+
}
|
|
1339
1990
|
REGISTRY[name] = def;
|
|
1340
1991
|
if (!RASTER_OP_NAMES.includes(name)) RASTER_OP_NAMES.push(name);
|
|
1992
|
+
return def;
|
|
1341
1993
|
}
|
|
1342
1994
|
|
|
1343
1995
|
function isHTMLInCanvasAvailable() {
|
|
@@ -1359,6 +2011,71 @@ const STAGE_VARS = {
|
|
|
1359
2011
|
color: ["col", "edgeCol", "edgeCov", "ovCol", "ovA"],
|
|
1360
2012
|
};
|
|
1361
2013
|
|
|
2014
|
+
// What `stage:` may say. Derived from STAGE_VARS rather than written out
|
|
2015
|
+
// again, so adding a stage cannot leave the validator rejecting it.
|
|
2016
|
+
// `field` is the exception that has no stage vars of its own: a field op
|
|
2017
|
+
// writes a scalar for later ops to read, it does not modify the frame.
|
|
2018
|
+
//
|
|
2019
|
+
// Read by validateRasterOp, which is defined above this line but only
|
|
2020
|
+
// ever RUNS from registerRasterOp — i.e. after this module has finished
|
|
2021
|
+
// evaluating. Nothing registers an op during our own module eval.
|
|
2022
|
+
const RASTER_STAGES = ["field", ...Object.keys(STAGE_VARS)];
|
|
2023
|
+
|
|
2024
|
+
// Params the PIPELINE reads off every node, whatever the op. An op does
|
|
2025
|
+
// not declare these and must not document them — they would be fifteen
|
|
2026
|
+
// identical paragraphs that drift apart on the first edit. Defined once
|
|
2027
|
+
// here; the inspector merges them in, and API.md documents them once.
|
|
2028
|
+
const FRAMEWORK_DOC = {
|
|
2029
|
+
op: { default: null, unit: "name", summary: "which op this node is" },
|
|
2030
|
+
target: {
|
|
2031
|
+
default: null, unit: "name",
|
|
2032
|
+
summary: "element ids this node applies to, e.g. [\"#hero\"]",
|
|
2033
|
+
},
|
|
2034
|
+
side: {
|
|
2035
|
+
default: null, unit: "name", structural: true,
|
|
2036
|
+
summary: "\"old\" or \"new\" — scope this op to ONE half of a morph " +
|
|
2037
|
+
"instead of the blended result, so the outgoing and incoming " +
|
|
2038
|
+
"states can be art-directed differently. Colour-stage ops only: " +
|
|
2039
|
+
"both sides share one sampling coordinate, so a sided warp has " +
|
|
2040
|
+
"no meaning. Ignored outside a transition.",
|
|
2041
|
+
},
|
|
2042
|
+
by: {
|
|
2043
|
+
default: "static", unit: "name", structural: true,
|
|
2044
|
+
summary: `what steers the effect: ${DRIVER_NAMES.join(", ")}. ` +
|
|
2045
|
+
"Compiled in, so changing it rebuilds.",
|
|
2046
|
+
},
|
|
2047
|
+
masked: {
|
|
2048
|
+
default: false, unit: "name",
|
|
2049
|
+
summary: "read a field written upstream — true for the default " +
|
|
2050
|
+
"\"mask\", or a name. Works for ANY op; the op needs no knowledge of it.",
|
|
2051
|
+
},
|
|
2052
|
+
live: {
|
|
2053
|
+
default: true, unit: "bool",
|
|
2054
|
+
summary: "false forces the snapshot backend for this element " +
|
|
2055
|
+
"instead of HTML-in-Canvas capture",
|
|
2056
|
+
},
|
|
2057
|
+
// Phase T3. Both reshape the progress this node's keyframes are
|
|
2058
|
+
// sampled against; neither is read by any op.
|
|
2059
|
+
window: {
|
|
2060
|
+
default: null, unit: "range",
|
|
2061
|
+
summary: "[from, to] slice of transition progress this node's " +
|
|
2062
|
+
"keyframes span — how ops in one chain stagger",
|
|
2063
|
+
},
|
|
2064
|
+
ease: {
|
|
2065
|
+
default: "linear", unit: "name",
|
|
2066
|
+
summary: `easing for this node's local progress: ${EASING_NAMES.join(", ")}`,
|
|
2067
|
+
},
|
|
2068
|
+
interactive: {
|
|
2069
|
+
default: true, unit: "bool",
|
|
2070
|
+
summary: "false opts this pipeline out of pointer retargeting",
|
|
2071
|
+
},
|
|
2072
|
+
hoverAttr: {
|
|
2073
|
+
default: false, unit: "bool",
|
|
2074
|
+
summary: "mirror hover onto [data-nodality-hover] at the DRAWN " +
|
|
2075
|
+
"position; costs the zero-DOM-mutation property, so opt-in",
|
|
2076
|
+
},
|
|
2077
|
+
};
|
|
2078
|
+
|
|
1362
2079
|
// A node opts into a field with `masked: true` (the default field) or
|
|
1363
2080
|
// `masked: "name"`. Field producers declare `as:` to name what they
|
|
1364
2081
|
// write. This is the whole of the node-to-node data flow: one op writes
|
|
@@ -1484,7 +2201,16 @@ function emitMerge(rec, buckets) {
|
|
|
1484
2201
|
}
|
|
1485
2202
|
}
|
|
1486
2203
|
|
|
1487
|
-
|
|
2204
|
+
/**
|
|
2205
|
+
* @param {boolean} [probe] emit the coordinate-readback variant (phase I1)
|
|
2206
|
+
* instead of the visible one. Identical up to and including the
|
|
2207
|
+
* displace stage; then writes the packed source coordinate rather
|
|
2208
|
+
* than a colour.
|
|
2209
|
+
*/
|
|
2210
|
+
function buildFragmentShader(recs, flat, probe, transition) {
|
|
2211
|
+
// "opaque" (default) keeps the union solid; "dissolve" is a true
|
|
2212
|
+
// cross-dissolve. See nodBlend.
|
|
2213
|
+
const fade = (transition && transition.fade) || "opaque";
|
|
1488
2214
|
let decls = "";
|
|
1489
2215
|
|
|
1490
2216
|
// Every field any node writes or reads, declared up front at 1.0 so
|
|
@@ -1504,8 +2230,71 @@ function buildFragmentShader(recs, flat) {
|
|
|
1504
2230
|
if (def) decls += def.decl(`u${i}_`, node) + "\n";
|
|
1505
2231
|
});
|
|
1506
2232
|
|
|
2233
|
+
// PER-SIDE effects. A node may carry `side: "old" | "new"`, which
|
|
2234
|
+
// scopes it to one half of a morph instead of the blended result.
|
|
2235
|
+
//
|
|
2236
|
+
// Everything else in a transition chain decorates the CROSSFADE — it
|
|
2237
|
+
// runs after old and new have already been mixed, so both ends get
|
|
2238
|
+
// the same treatment. That cannot express "the outgoing state burns
|
|
2239
|
+
// out while the incoming one develops", which is the thing a designer
|
|
2240
|
+
// actually reaches for. A sided op runs on its own side's colour
|
|
2241
|
+
// BEFORE nodBlend sees it.
|
|
2242
|
+
//
|
|
2243
|
+
// Colour stage only, and deliberately: warp/displace ops rewrite the
|
|
2244
|
+
// sampling coordinate, and there is one coordinate shared by both
|
|
2245
|
+
// sides — honouring `side` there would mean two independent
|
|
2246
|
+
// coordinate pipelines. A sided warp is rejected loudly rather than
|
|
2247
|
+
// silently ignored.
|
|
2248
|
+
const sideOf = (rec) => {
|
|
2249
|
+
const v = rec && rec.node && rec.node.side;
|
|
2250
|
+
if (v !== "old" && v !== "new") return null;
|
|
2251
|
+
const def = REGISTRY[rec.node.op];
|
|
2252
|
+
const stages = def ? (Array.isArray(def.stage) ? def.stage : [def.stage]) : [];
|
|
2253
|
+
if (!stages.every((st) => st === "color")) {
|
|
2254
|
+
console.warn(`[nodality] "${rec.node.op}" is a ${stages.join("+")} op, ` +
|
|
2255
|
+
`so side:"${v}" cannot apply to it — a sided op must be colour-only, ` +
|
|
2256
|
+
`because both sides share one sampling coordinate. Running it on ` +
|
|
2257
|
+
`the blended result instead.`);
|
|
2258
|
+
return null;
|
|
2259
|
+
}
|
|
2260
|
+
if (!transition) {
|
|
2261
|
+
console.warn(`[nodality] side:"${v}" has no meaning without a ` +
|
|
2262
|
+
`transition — there is only one image. Ignoring it.`);
|
|
2263
|
+
return null;
|
|
2264
|
+
}
|
|
2265
|
+
return v;
|
|
2266
|
+
};
|
|
2267
|
+
const mainRecs = recs.filter((r) => !sideOf(r));
|
|
2268
|
+
const oldRecs = recs.filter((r) => sideOf(r) === "old");
|
|
2269
|
+
const newRecs = recs.filter((r) => sideOf(r) === "new");
|
|
2270
|
+
|
|
1507
2271
|
const buckets = emptyBuckets();
|
|
1508
|
-
emitStages(
|
|
2272
|
+
emitStages(mainRecs, buckets);
|
|
2273
|
+
|
|
2274
|
+
// One scope per side, with the colour stage variables declared local
|
|
2275
|
+
// so an op that touches edgeCol/ovA compiles here exactly as it does
|
|
2276
|
+
// in main(). `frag` is the sample position, which is what a sided op
|
|
2277
|
+
// means by "where am I".
|
|
2278
|
+
const sideBlock = (list, src) => {
|
|
2279
|
+
if (!list.length) return "";
|
|
2280
|
+
const b = emptyBuckets();
|
|
2281
|
+
emitStages(list, b);
|
|
2282
|
+
if (!b.color) return "";
|
|
2283
|
+
const decls = STAGE_VARS.color
|
|
2284
|
+
.filter((v) => v !== "col")
|
|
2285
|
+
.map((v) => ` ${glslType(v)} ${v} = ${glslType(v) === "vec3"
|
|
2286
|
+
? "vec3(0.0)" : "0.0"};`).join("\n");
|
|
2287
|
+
return ` {
|
|
2288
|
+
vec2 frag = p;
|
|
2289
|
+
vec3 col = ${src}.rgb;
|
|
2290
|
+
${decls}
|
|
2291
|
+
${b.color}
|
|
2292
|
+
${src}.rgb = col;
|
|
2293
|
+
}
|
|
2294
|
+
`;
|
|
2295
|
+
};
|
|
2296
|
+
const oldSideCode = sideBlock(oldRecs, "o");
|
|
2297
|
+
const newSideCode = sideBlock(newRecs, "n");
|
|
1509
2298
|
const { field, warp, cell, displace, color } = buckets;
|
|
1510
2299
|
const nodes = flat;
|
|
1511
2300
|
// Compositing, in three layers (all straight-alpha):
|
|
@@ -1520,19 +2309,161 @@ function buildFragmentShader(recs, flat) {
|
|
|
1520
2309
|
// Per-channel resampling costs two extra fetches, so it is only
|
|
1521
2310
|
// emitted when a displace-stage op actually asks for dispersion.
|
|
1522
2311
|
const hasChroma = nodes.some((n) => (REGISTRY[n.op] || {}).chroma);
|
|
1523
|
-
|
|
2312
|
+
// In transition mode the per-channel taps must see BOTH captures —
|
|
2313
|
+
// sampling u_tex alone would drop the old element out of the red and
|
|
2314
|
+
// blue channels for the whole morph.
|
|
2315
|
+
const chromaFetch = !hasChroma ? "" : (transition ? `
|
|
2316
|
+
vec2 cdev = vec2(chroma.x, chroma.y);
|
|
2317
|
+
col.r = nodSampleAt(sampleP + cdev).r;
|
|
2318
|
+
col.b = nodSampleAt(sampleP - cdev).b;` : `
|
|
1524
2319
|
vec2 cpx = vec2(chroma.x, -chroma.y) / u_res;
|
|
1525
2320
|
col.r = texture2D(u_tex, clamp(uv + cpx, 0.001, 0.999)).r;
|
|
1526
|
-
col.b = texture2D(u_tex, clamp(uv - cpx, 0.001, 0.999)).b;`
|
|
2321
|
+
col.b = texture2D(u_tex, clamp(uv - cpx, 0.001, 0.999)).b;`);
|
|
2322
|
+
// Phase I1. The PROBE variant is the same shader with a different last
|
|
2323
|
+
// line: instead of compositing a colour it writes the source
|
|
2324
|
+
// coordinate the fragment sampled from. Everything between `main()`
|
|
2325
|
+
// and the tail is byte-identical to the visible pass, which is the
|
|
2326
|
+
// whole point — a hit-test that recomputed the coordinate its own way
|
|
2327
|
+
// could disagree with what is actually drawn, and that disagreement
|
|
2328
|
+
// would be invisible.
|
|
2329
|
+
//
|
|
2330
|
+
// In probe mode the fragment coordinate is SUPPLIED rather than
|
|
2331
|
+
// derived: `u_probe` names the pixel being asked about, so a 1x1
|
|
2332
|
+
// framebuffer with a plain viewport can stand in for any pixel of the
|
|
2333
|
+
// full-size render. No full-resolution attachment is allocated, and
|
|
2334
|
+
// no negative-origin viewport is needed — an earlier attempt offset
|
|
2335
|
+
// the viewport instead and rasterised nothing, which readback
|
|
2336
|
+
// reported as a confident (0, 0).
|
|
2337
|
+
const probeDecl = probe ? "uniform vec2 u_probe;\n" : "";
|
|
2338
|
+
const fragCoord = probe ? "u_probe + vec2(0.5)" : "gl_FragCoord.xy";
|
|
2339
|
+
// Two bytes per axis: hi in one channel, lo in the next, so a 4000px
|
|
2340
|
+
// element resolves to about 0.06px. Decoded in sourceAt().
|
|
2341
|
+
const probeTail = `
|
|
2342
|
+
vec2 q = clamp(sampleP / u_res, 0.0, 1.0) * 255.0;
|
|
2343
|
+
vec2 hi = floor(q);
|
|
2344
|
+
vec2 lo = floor(fract(q) * 255.0);
|
|
2345
|
+
gl_FragColor = vec4(hi.x, lo.x, hi.y, lo.y) / 255.0;
|
|
2346
|
+
}`;
|
|
2347
|
+
|
|
1527
2348
|
return `
|
|
1528
2349
|
precision highp float;
|
|
1529
2350
|
uniform sampler2D u_tex;
|
|
1530
2351
|
uniform vec2 u_res;
|
|
1531
2352
|
uniform vec2 u_mouse;
|
|
1532
2353
|
uniform float u_time;
|
|
1533
|
-
|
|
2354
|
+
uniform float u_t; // phase T1: transition progress, 0..1
|
|
2355
|
+
|
|
2356
|
+
// Op uniforms are declared HERE, above the transition helpers, because a
|
|
2357
|
+
// per-side op's code is emitted inside nodSampleAt. GLSL requires
|
|
2358
|
+
// declaration before use, and with the declarations further down every
|
|
2359
|
+
// sided op failed to compile with "'u0_amount' : undeclared identifier".
|
|
2360
|
+
${probeDecl}${decls}
|
|
2361
|
+
${transition ? `
|
|
2362
|
+
// ── phase T2: transition mode ──────────────────────────────────────
|
|
2363
|
+
// Two captures instead of one. u_tex is the NEW element, u_old the
|
|
2364
|
+
// frozen OLD one, and u_box is the content box they are both drawn
|
|
2365
|
+
// into — lerped from the old rect to the new rect by u_t. So geometry
|
|
2366
|
+
// (the box) and pixels (the effect chain) stay separate problems, which
|
|
2367
|
+
// is the split View Transitions also makes and the one that keeps this
|
|
2368
|
+
// maintainable.
|
|
2369
|
+
uniform sampler2D u_old;
|
|
2370
|
+
${transition && transition.newImage ? "uniform sampler2D u_newimg;" : ""}
|
|
2371
|
+
// Phase T2, gap 2. TWO boxes, not one. With a single shared box the
|
|
2372
|
+
// only expressible motion is "both sides march together", so the classic
|
|
2373
|
+
// "old slides out while new slides in" was impossible. Each capture now
|
|
2374
|
+
// interpolates in its own rect, which keeps per-side motion in the
|
|
2375
|
+
// GEOMETRY tier (P-3) instead of needing per-side op scoping.
|
|
2376
|
+
uniform vec4 u_boxOld; // x, y, w, h — device px, top-down
|
|
2377
|
+
uniform vec4 u_boxNew;
|
|
2378
|
+
|
|
2379
|
+
// sRGB <-> linear. A naive crossfade in gamma space dips visibly in
|
|
2380
|
+
// brightness at mid-fade on photographic content; this is the classic
|
|
2381
|
+
// artifact and it is why the mix below is not just mix().
|
|
2382
|
+
vec3 nodToLinear(vec3 c) { return pow(c, vec3(2.2)); }
|
|
2383
|
+
vec3 nodToSRGB(vec3 c) { return pow(c, vec3(1.0 / 2.2)); }
|
|
2384
|
+
|
|
2385
|
+
// Premultiplied, so anti-aliased edges over transparency do not pick up
|
|
2386
|
+
// a dark halo as the two sides cross.
|
|
2387
|
+
vec4 nodBlend(vec4 a, vec4 b, float t, float d) {
|
|
2388
|
+
vec3 pa = nodToLinear(a.rgb) * a.a;
|
|
2389
|
+
vec3 pb = nodToLinear(b.rgb) * b.a;
|
|
2390
|
+
${fade === "morph" ? `
|
|
2391
|
+
// MORPH — a per-pixel CHOICE, not a mix. d is a stable hash of the
|
|
2392
|
+
// output pixel, so each pixel flips from old to new at its own point
|
|
2393
|
+
// in t. At any instant almost every pixel is showing exactly ONE
|
|
2394
|
+
// side, and the two contents are never both legible at once.
|
|
2395
|
+
//
|
|
2396
|
+
// A uniform crossfade cannot do this: at t=0.5 it is by definition
|
|
2397
|
+
// 50% of each, which reads as a double exposure the moment the two
|
|
2398
|
+
// states differ much — a wide nav bar and a small card, say.
|
|
2399
|
+
float k = smoothstep(d - 0.12, d + 0.12, t);
|
|
2400
|
+
vec3 pm = pa * (1.0 - k) + pb * k;
|
|
2401
|
+
float al = a.a * (1.0 - k) + b.a * k;` : fade === "dissolve" ? `
|
|
2402
|
+
// DISSOLVE — a true cross-dissolve: alpha lerps between the two.
|
|
2403
|
+
// Correct, and the right choice when one side really should fade to
|
|
2404
|
+
// nothing. Wrong for a shape morph, because any region covered by
|
|
2405
|
+
// only ONE side is translucent for the whole transition and the
|
|
2406
|
+
// morph reads as "everything is disappearing".
|
|
2407
|
+
vec3 pm = mix(pa, pb, t);
|
|
2408
|
+
float al = mix(a.a, b.a, t);` : `
|
|
2409
|
+
// OPAQUE (default) — the colour crosses over without the shape
|
|
2410
|
+
// thinning out. The coverage-weighted lerp already gives that for
|
|
2411
|
+
// free, and it is worth seeing why:
|
|
2412
|
+
//
|
|
2413
|
+
// both sides cover a.a = b.a = 1 -> w = 1 at every t. Solid
|
|
2414
|
+
// throughout. No mid-fade.
|
|
2415
|
+
// only the old b.a = 0 -> w = 1-t. Releases.
|
|
2416
|
+
// only the new a.a = 0 -> w = t. Arrives.
|
|
2417
|
+
//
|
|
2418
|
+
// So one expression covers the union case AND each side's release,
|
|
2419
|
+
// linearly, with no curve to pop against.
|
|
2420
|
+
vec3 pm = pa * (1.0 - t) + pb * t;
|
|
2421
|
+
float al = a.a * (1.0 - t) + b.a * t;` }
|
|
2422
|
+
return vec4(nodToSRGB(pm / max(al, 1e-4)), al);
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// EVERY fetch in transition mode goes through here. A displace-stage op
|
|
2426
|
+
// that sampled u_tex directly — aberration does exactly that for its
|
|
2427
|
+
// per-channel taps — would show fringes from the new capture only, with
|
|
2428
|
+
// the old one silently absent from those channels.
|
|
2429
|
+
// Takes a position in DEVICE PX (sampleP space), not uv, because the two
|
|
2430
|
+
// captures no longer share a uv. Each is mapped through its own box and
|
|
2431
|
+
// masked to it, then blended.
|
|
2432
|
+
vec2 nodBoxUV(vec4 box, vec2 p) {
|
|
2433
|
+
return vec2((p.x - box.x) / max(box.z, 1.0),
|
|
2434
|
+
1.0 - (p.y - box.y) / max(box.w, 1.0));
|
|
2435
|
+
}
|
|
2436
|
+
float nodInBox(vec2 uv) {
|
|
2437
|
+
return step(0.0, uv.x) * step(uv.x, 1.0) * step(0.0, uv.y) * step(uv.y, 1.0);
|
|
2438
|
+
}
|
|
2439
|
+
vec4 nodSampleAt(vec2 p) {
|
|
2440
|
+
vec2 uo = nodBoxUV(u_boxOld, p);
|
|
2441
|
+
vec2 un = nodBoxUV(u_boxNew, p);
|
|
2442
|
+
vec4 o = texture2D(u_old, clamp(uo, 0.001, 0.999));
|
|
2443
|
+
// With newImage the new side is a capture of the ELEMENT, so both
|
|
2444
|
+
// sides can travel between their own rects and converge into one
|
|
2445
|
+
// shape. Without it the new side is the HOST capture, which can only
|
|
2446
|
+
// be mapped 1:1 — the destination then sits at its final place from
|
|
2447
|
+
// t=0 while the old shrinks toward it, and the result reads as two
|
|
2448
|
+
// things on screen at once rather than one thing morphing.
|
|
2449
|
+
vec4 n = texture2D(${transition && transition.newImage ? "u_newimg" : "u_tex"},
|
|
2450
|
+
clamp(un, 0.001, 0.999));
|
|
2451
|
+
o.a *= nodInBox(uo);
|
|
2452
|
+
n.a *= nodInBox(un);
|
|
2453
|
+
${oldSideCode}${newSideCode}
|
|
2454
|
+
// The threshold each pixel flips at. Mostly a COHERENT ramp down the
|
|
2455
|
+
// box with a little grain on top — pure white noise chooses correctly
|
|
2456
|
+
// (never two legible images at once) but reads as television static
|
|
2457
|
+
// rather than as one thing becoming another. The ramp makes it a
|
|
2458
|
+
// wipe; the grain keeps the edge from looking like a ruler.
|
|
2459
|
+
float grain = fract(sin(dot(floor(p / 3.0), vec2(12.9898, 78.233))) * 43758.5453);
|
|
2460
|
+
float ramp = clamp(un.y, 0.0, 1.0);
|
|
2461
|
+
float d = ramp * 0.82 + grain * 0.18;
|
|
2462
|
+
return nodBlend(o, n, u_t, d);
|
|
2463
|
+
}` : ""}
|
|
1534
2464
|
void main() {
|
|
1535
|
-
vec2
|
|
2465
|
+
vec2 fc = ${fragCoord};
|
|
2466
|
+
vec2 frag = vec2(fc.x, u_res.y - fc.y);
|
|
1536
2467
|
${fieldDecls}
|
|
1537
2468
|
${field}
|
|
1538
2469
|
vec2 warped = frag;
|
|
@@ -1542,9 +2473,13 @@ ${warp}
|
|
|
1542
2473
|
${cell}
|
|
1543
2474
|
vec2 sampleP = warped;
|
|
1544
2475
|
vec2 chroma = vec2(0.0);
|
|
1545
|
-
${displace}
|
|
2476
|
+
${displace}${probe ? probeTail : `
|
|
2477
|
+
${transition ? `
|
|
2478
|
+
// uv stays defined, as the NEW box's, for ops that reference it.
|
|
2479
|
+
vec2 uv = nodBoxUV(u_boxNew, sampleP);
|
|
2480
|
+
vec4 tex = nodSampleAt(sampleP);` : `
|
|
1546
2481
|
vec2 uv = vec2(sampleP.x / u_res.x, 1.0 - sampleP.y / u_res.y);
|
|
1547
|
-
vec4 tex = texture2D(u_tex, clamp(uv, 0.001, 0.999))
|
|
2482
|
+
vec4 tex = texture2D(u_tex, clamp(uv, 0.001, 0.999));`}
|
|
1548
2483
|
vec3 col = tex.rgb;${chromaFetch}
|
|
1549
2484
|
vec3 edgeCol = vec3(1.0);
|
|
1550
2485
|
float edgeCov = 0.0;
|
|
@@ -1559,7 +2494,7 @@ ${defaultSeam}
|
|
|
1559
2494
|
float outA = ovA + baseA * (1.0 - ovA);
|
|
1560
2495
|
vec3 outRGB = (ovCol * ovA + baseRGB * baseA * (1.0 - ovA)) / max(outA, 1e-4);
|
|
1561
2496
|
gl_FragColor = vec4(outRGB, outA);
|
|
1562
|
-
}`;
|
|
2497
|
+
}`}`;
|
|
1563
2498
|
}
|
|
1564
2499
|
|
|
1565
2500
|
// ── Snapshot backend: DOM subtree -> SVG foreignObject -> texture ────
|
|
@@ -1574,19 +2509,97 @@ ${defaultSeam}
|
|
|
1574
2509
|
// computes, on a clone, so the snapshot matches the live layout.
|
|
1575
2510
|
const VIEWPORT_UNIT = /\d(?:vw|vh|vmin|vmax|dvw|dvh|svw|svh|lvw|lvh)\b/i;
|
|
1576
2511
|
|
|
1577
|
-
|
|
2512
|
+
// Inheritable text properties. A foreignObject rendered from a data: URI
|
|
2513
|
+
// carries NO page stylesheet, and a clone carries only inline styles — so
|
|
2514
|
+
// anything a rule or an ancestor supplied is gone. Nodality's own
|
|
2515
|
+
// components style inline and survive; hand-written markup styled by CSS
|
|
2516
|
+
// captures as default black serif on transparent, which is what made the
|
|
2517
|
+
// demo panels render dark text on a dark panel.
|
|
2518
|
+
//
|
|
2519
|
+
// Frozen onto the clone only where an element's computed value DIFFERS
|
|
2520
|
+
// from its parent's, so inheritance still does the work and the
|
|
2521
|
+
// serialized string does not balloon on a large subtree.
|
|
2522
|
+
const INHERITED = [
|
|
2523
|
+
"color", "font-family", "font-size", "font-weight", "font-style",
|
|
2524
|
+
"line-height", "letter-spacing", "text-align", "text-transform",
|
|
2525
|
+
"text-decoration-color", "white-space", "word-break",
|
|
2526
|
+
];
|
|
2527
|
+
|
|
2528
|
+
// Painted, NON-inherited properties — see part 3 of freezeStyles. Layout
|
|
2529
|
+
// properties are deliberately absent: freezing width or padding would
|
|
2530
|
+
// relayout the clone and move the glyphs off the real text.
|
|
2531
|
+
const PAINTED = [
|
|
2532
|
+
"background-color", "background-image", "background-size",
|
|
2533
|
+
"background-position", "background-repeat", "background-clip",
|
|
2534
|
+
"border-radius", "box-shadow", "opacity", "outline-color",
|
|
2535
|
+
];
|
|
2536
|
+
|
|
2537
|
+
// Initial values, skipped so a page of plain elements does not gain ten
|
|
2538
|
+
// no-op declarations per node.
|
|
2539
|
+
const PAINT_INITIAL = new Set([
|
|
2540
|
+
"none", "rgba(0, 0, 0, 0)", "transparent", "0px", "auto", "1",
|
|
2541
|
+
"repeat", "0% 0%", "border-box", "0% 0% / auto repeat scroll padding-box border-box",
|
|
2542
|
+
]);
|
|
2543
|
+
|
|
2544
|
+
function freezeStyles(original, clone) {
|
|
1578
2545
|
const origs = [original, ...original.querySelectorAll("*")];
|
|
1579
2546
|
const clones = [clone, ...clone.querySelectorAll("*")];
|
|
1580
2547
|
for (let i = 0; i < origs.length && i < clones.length; i++) {
|
|
1581
2548
|
const inline = clones[i].style;
|
|
1582
|
-
if (!inline
|
|
1583
|
-
|
|
1584
|
-
//
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
2549
|
+
if (!inline) continue;
|
|
2550
|
+
|
|
2551
|
+
// 1. Viewport units resolve against the SVG's own size inside a
|
|
2552
|
+
// foreignObject, not the browser viewport, so `calc(1.6rem +
|
|
2553
|
+
// 5vw)` renders at a different size than it does on the page.
|
|
2554
|
+
// The glyphs then sit somewhere the DOM text does not, which
|
|
2555
|
+
// is what makes a selection highlight look offset.
|
|
2556
|
+
if (inline.length > 0) {
|
|
2557
|
+
let computed = null;
|
|
2558
|
+
// Iterate a snapshot of the names: writing to style mutates the list.
|
|
2559
|
+
for (const prop of Array.from(inline)) {
|
|
2560
|
+
if (!VIEWPORT_UNIT.test(inline.getPropertyValue(prop))) continue;
|
|
2561
|
+
computed = computed || window.getComputedStyle(origs[i]);
|
|
2562
|
+
const px = computed.getPropertyValue(prop);
|
|
2563
|
+
if (px) inline.setProperty(prop, px, inline.getPropertyPriority(prop));
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
// 2. Inherited text styling, which the clone would otherwise lose.
|
|
2568
|
+
const cs = window.getComputedStyle(origs[i]);
|
|
2569
|
+
const parent = origs[i].parentElement;
|
|
2570
|
+
const ps = i === 0 || !parent ? null : window.getComputedStyle(parent);
|
|
2571
|
+
for (const prop of INHERITED) {
|
|
2572
|
+
const v = cs.getPropertyValue(prop);
|
|
2573
|
+
if (!v) continue;
|
|
2574
|
+
// The root always states its value — there is no ancestor
|
|
2575
|
+
// inside the foreignObject to inherit from.
|
|
2576
|
+
if (ps && ps.getPropertyValue(prop) === v) continue;
|
|
2577
|
+
if (inline.getPropertyValue(prop)) continue; // caller was explicit
|
|
2578
|
+
inline.setProperty(prop, v);
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
// 3. Painted, NON-inherited properties. Inheritance cannot rescue
|
|
2582
|
+
// these: `.card { background: #e8eef5 }` is simply absent from
|
|
2583
|
+
// the clone, so the element captures fully transparent.
|
|
2584
|
+
//
|
|
2585
|
+
// The symptom is easy to misread. In a transition the two
|
|
2586
|
+
// captures crossfade against each other, and with backgrounds
|
|
2587
|
+
// missing both sides are transparent everywhere except their
|
|
2588
|
+
// glyphs — so the morph looks like it fades away toward the
|
|
2589
|
+
// middle and "disappears", when every pixel is exactly where
|
|
2590
|
+
// it belongs and merely has nothing opaque behind it.
|
|
2591
|
+
//
|
|
2592
|
+
// Restricted to properties that do not affect LAYOUT. Freezing
|
|
2593
|
+
// width, padding or border-width would relayout the clone and
|
|
2594
|
+
// move the glyphs away from where the real text sits — the
|
|
2595
|
+
// very bug this function was written to prevent.
|
|
2596
|
+
for (const prop of PAINTED) {
|
|
2597
|
+
if (inline.getPropertyValue(prop)) continue; // caller was explicit
|
|
2598
|
+
const v = cs.getPropertyValue(prop);
|
|
2599
|
+
// Skip initial values, or every node gains a handful of
|
|
2600
|
+
// no-op declarations and the serialized string balloons.
|
|
2601
|
+
if (!v || PAINT_INITIAL.has(v)) continue;
|
|
2602
|
+
inline.setProperty(prop, v);
|
|
1590
2603
|
}
|
|
1591
2604
|
}
|
|
1592
2605
|
return clone;
|
|
@@ -1697,7 +2710,7 @@ function snapshotToImage(el, w, h, dpr) {
|
|
|
1697
2710
|
|
|
1698
2711
|
return new Promise((resolve, reject) => {
|
|
1699
2712
|
const serialized = new XMLSerializer()
|
|
1700
|
-
.serializeToString(
|
|
2713
|
+
.serializeToString(freezeStyles(el, el.cloneNode(true)));
|
|
1701
2714
|
const svg =
|
|
1702
2715
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${w * dpr}" height="${h * dpr}" viewBox="0 0 ${w} ${h}">` +
|
|
1703
2716
|
`<foreignObject width="100%" height="100%">` +
|
|
@@ -1826,17 +2839,101 @@ function wrapReplacedHost(el) {
|
|
|
1826
2839
|
|
|
1827
2840
|
// ── Pipeline runner ──────────────────────────────────────────────────
|
|
1828
2841
|
|
|
1829
|
-
|
|
2842
|
+
// Fired on `document` whenever a pipeline param changes through setParam,
|
|
2843
|
+
// so any UI showing that data — the inspector, a code preview, an editor —
|
|
2844
|
+
// stays in step without polling or knowing about the others.
|
|
2845
|
+
// detail: { pipeline, el, index, key, value, prev, how }
|
|
2846
|
+
// `how` is "uniform" or "rebuild", the same value setParam returns.
|
|
2847
|
+
const RASTER_PARAM_EVENT = "nodality:raster-param";
|
|
2848
|
+
|
|
2849
|
+
// ── Live pipelines (phase H3) ────────────────────────────────────────
|
|
2850
|
+
// Every attached pipeline, so a dev tool can find what is running without
|
|
2851
|
+
// the page having to hand it over. A Set, not a WeakSet: enumerating is
|
|
2852
|
+
// the entire point, and destroy() removes deterministically.
|
|
2853
|
+
const ACTIVE = new Set();
|
|
2854
|
+
|
|
2855
|
+
/** Every raster pipeline currently attached, in attach order. */
|
|
2856
|
+
function activeRasterPipelines() {
|
|
2857
|
+
return [...ACTIVE];
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// Params that are compiled into the shader rather than uploaded as a
|
|
2861
|
+
// uniform, per op. Measured from which of code()/uniforms() reads them —
|
|
2862
|
+
// see the table in HOUDINI-DECISIONS. `masked`, `op` and `live` are
|
|
2863
|
+
// structural for every op: they change the emitted code or the backend.
|
|
2864
|
+
const ALWAYS_STRUCTURAL = ["op", "masked", "live"];
|
|
2865
|
+
|
|
2866
|
+
/**
|
|
2867
|
+
* Does changing `key` on an op of this type require a new shader?
|
|
2868
|
+
*
|
|
2869
|
+
* Unknown keys rebuild. That is deliberately the pessimistic default: a
|
|
2870
|
+
* missed structural param renders the wrong thing with no error, while an
|
|
2871
|
+
* unnecessary rebuild only costs a frame.
|
|
2872
|
+
*/
|
|
2873
|
+
function isStructuralChange(op, key, next, prev) {
|
|
2874
|
+
if (ALWAYS_STRUCTURAL.includes(key)) return true;
|
|
2875
|
+
const def = REGISTRY[op];
|
|
2876
|
+
if (!def) return true;
|
|
2877
|
+
if ((def.structural || []).includes(key)) return true;
|
|
2878
|
+
// A param that only branches the shader when it crosses zero —
|
|
2879
|
+
// hexalize's `lift` picks the cheap path at 0 and the seven-neighbour
|
|
2880
|
+
// probe otherwise, so 0 -> 0.3 needs a rebuild but 0.3 -> 0.5 does not.
|
|
2881
|
+
if ((def.structuralOnToggle || []).includes(key)) return !next !== !prev;
|
|
2882
|
+
// Known uniform? Then it is live.
|
|
2883
|
+
if (def.uniforms) {
|
|
2884
|
+
try { if (key in def.uniforms(Object.assign({ op }, { [key]: next }), 1)) return false; }
|
|
2885
|
+
catch (e) { /* fall through to the pessimistic default */ }
|
|
2886
|
+
}
|
|
2887
|
+
return true;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
/**
|
|
2891
|
+
* @param {HTMLElement} el
|
|
2892
|
+
* @param {Array} rasterNodes
|
|
2893
|
+
* @param {object} [opts]
|
|
2894
|
+
* @param {object} [opts.transition] phase T2 — run in transition mode.
|
|
2895
|
+
* `{ oldImage, oldRect, newRect }` where oldImage is an already
|
|
2896
|
+
* captured Image/canvas of the OLD element (it has to be captured
|
|
2897
|
+
* before the DOM swap, because by the time the morph runs that subtree
|
|
2898
|
+
* is gone), and the rects are CSS px relative to `el`.
|
|
2899
|
+
*/
|
|
2900
|
+
// One stylesheet for the whole library: while a host's ink is hidden, a
|
|
2901
|
+
// selection inside it paints its glyphs in the colour they had before the
|
|
2902
|
+
// canvas took over. Unselected text stays transparent (the canvas is
|
|
2903
|
+
// drawing it), so what the user sees is exactly the selected run appearing
|
|
2904
|
+
// in place — rather than a blank rectangle over an invisible one.
|
|
2905
|
+
let selectionStyleAdded = false;
|
|
2906
|
+
function ensureSelectionStyle() {
|
|
2907
|
+
if (selectionStyleAdded || typeof document === "undefined") return;
|
|
2908
|
+
selectionStyleAdded = true;
|
|
2909
|
+
const s = document.createElement("style");
|
|
2910
|
+
s.setAttribute("data-nodality", "selection");
|
|
2911
|
+
s.textContent =
|
|
2912
|
+
"[data-nodality-ink]::selection,[data-nodality-ink] *::selection{" +
|
|
2913
|
+
"color:var(--nod-ink);-webkit-text-fill-color:var(--nod-ink)}" +
|
|
2914
|
+
"[data-nodality-ink]::-moz-selection,[data-nodality-ink] *::-moz-selection{" +
|
|
2915
|
+
"color:var(--nod-ink);-webkit-text-fill-color:var(--nod-ink)}";
|
|
2916
|
+
(document.head || document.documentElement).appendChild(s);
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
function applyRasterPipeline(el, rasterNodes, opts) {
|
|
2920
|
+
// Kept before flattening: rebuild() needs the tree, not the flat list.
|
|
2921
|
+
const sourceNodes = rasterNodes;
|
|
2922
|
+
const transition = (opts && opts.transition) || null;
|
|
1830
2923
|
// Hard guards — every early-out is silent by design so that jsdom
|
|
1831
2924
|
// prerender, old browsers and reduced-motion users get the plain
|
|
1832
2925
|
// page untouched.
|
|
1833
2926
|
if (!el || typeof document === "undefined" || typeof window === "undefined") return null;
|
|
1834
|
-
|
|
2927
|
+
// An empty chain is nothing to do — EXCEPT in transition mode, where
|
|
2928
|
+
// the crossfade between the two captures IS the effect and ops are
|
|
2929
|
+
// only decoration on top of it.
|
|
2930
|
+
if ((!rasterNodes || rasterNodes.length === 0) && !transition) return null;
|
|
2931
|
+
if (!rasterNodes) rasterNodes = [];
|
|
1835
2932
|
// Flatten any switch nodes down to the chain this device actually
|
|
1836
2933
|
// gets, before anything reads the list.
|
|
1837
2934
|
if (rasterNodes.some((n) => n && n.op === "switch")) {
|
|
1838
2935
|
rasterNodes = resolveSwitches(rasterNodes, 0);
|
|
1839
|
-
if (rasterNodes.length === 0) return null;
|
|
2936
|
+
if (rasterNodes.length === 0 && !transition) return null;
|
|
1840
2937
|
}
|
|
1841
2938
|
// Flatten merge sub-chains into the linear list that owns the
|
|
1842
2939
|
// uniform slots. `tree` keeps the branch structure for the shader
|
|
@@ -1888,7 +2985,14 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1888
2985
|
const rect = measure();
|
|
1889
2986
|
if (rect.width < 2 || rect.height < 2) return null;
|
|
1890
2987
|
|
|
1891
|
-
|
|
2988
|
+
// Render resolution. The cap exists because every op runs per output
|
|
2989
|
+
// pixel, so cost is quadratic in it — but capping at 2 also means a 3x
|
|
2990
|
+
// phone or a scaled 4K desktop renders the effect at LOWER density than
|
|
2991
|
+
// the text beside it, and the canvas reads as soft next to real DOM.
|
|
2992
|
+
// Default to the display's own density up to 3, and let a caller that
|
|
2993
|
+
// knows its budget say otherwise.
|
|
2994
|
+
const dpr = Math.max(1, Math.min(
|
|
2995
|
+
opts && opts.resolution ? opts.resolution : (window.devicePixelRatio || 1), 3));
|
|
1892
2996
|
const isOverlay = rasterNodes.some((n) => (REGISTRY[n.op] || {}).overlay);
|
|
1893
2997
|
// Stacking: a pipeline may mix in-place ops (hexalize/offset/duotone/
|
|
1894
2998
|
// edges) with an overlay op (blobs). The single shader composites them
|
|
@@ -1896,7 +3000,14 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1896
3000
|
// chain leaves the host visible (the lens refracts untouched content).
|
|
1897
3001
|
// The moment an in-place op joins, the canvas fully carries the look,
|
|
1898
3002
|
// so the host must be hidden or the original content ghosts through.
|
|
1899
|
-
|
|
3003
|
+
// `.every()` on an EMPTY array is vacuously true, which used to be
|
|
3004
|
+
// unreachable — a chain with no ops returned null long before here.
|
|
3005
|
+
// Transition mode made it reachable, and an empty transition chain
|
|
3006
|
+
// was classified a pure overlay: the canvas composited over untouched
|
|
3007
|
+
// DOM instead of replacing it, so the same chain rendered differently
|
|
3008
|
+
// with and without a zero-strength op in it.
|
|
3009
|
+
const pureOverlay = rasterNodes.length > 0 && !transition &&
|
|
3010
|
+
rasterNodes.every((n) => (REGISTRY[n.op] || {}).overlay);
|
|
1900
3011
|
// Live-first (HTML-in-Canvas). The live backend captures content that
|
|
1901
3012
|
// lives INSIDE the canvas (texElementImage2D over the restructured
|
|
1902
3013
|
// subtree) — the same model canvasUI's bubble uses: content in the
|
|
@@ -1907,7 +3018,29 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1907
3018
|
// subtree to sample; that case stays snapshot). A combined chain
|
|
1908
3019
|
// (blobs + hexalize/offset/…) hides the host and therefore runs live,
|
|
1909
3020
|
// lens included. Opt out per pipeline with `live: false` on any node.
|
|
1910
|
-
|
|
3021
|
+
// A TRANSITION never wants the live backend, and the reason is not a
|
|
3022
|
+
// preference — it is that live costs interactivity and buys nothing
|
|
3023
|
+
// here.
|
|
3024
|
+
//
|
|
3025
|
+
// Buys nothing: a morph samples u_old and u_newimg, two frozen
|
|
3026
|
+
// captures. The live upload of the host is never read (see
|
|
3027
|
+
// nodSampleAt), so restructuring the DOM into the canvas produces a
|
|
3028
|
+
// texture nobody looks at.
|
|
3029
|
+
//
|
|
3030
|
+
// Costs interactivity: the live path MOVES the host's children into
|
|
3031
|
+
// the canvas, where they become canvas fallback content — present in
|
|
3032
|
+
// the accessibility tree but not hit-testable the way ordinary DOM
|
|
3033
|
+
// is. Every control inside the morphed element then stops responding
|
|
3034
|
+
// to the pointer, which is what "the back button is not clickable"
|
|
3035
|
+
// is. It only reproduces on a browser with the HTML-in-Canvas API,
|
|
3036
|
+
// so a snapshot-only run says everything is fine.
|
|
3037
|
+
//
|
|
3038
|
+
// `transition.live: true` overrides this deliberately, for comparing
|
|
3039
|
+
// the two backends side by side. Expect the morph to LOOK the same and
|
|
3040
|
+
// stop responding to the pointer — that is the trade being shown.
|
|
3041
|
+
const forceLive = !!(transition && transition.live === true);
|
|
3042
|
+
const wantLive = !pureOverlay && (!transition || forceLive) &&
|
|
3043
|
+
!rasterNodes.some((n) => n.live === false);
|
|
1911
3044
|
const apiAvailable =
|
|
1912
3045
|
(typeof WebGL2RenderingContext !== "undefined" &&
|
|
1913
3046
|
"texElementImage2D" in WebGL2RenderingContext.prototype) ||
|
|
@@ -1948,6 +3081,9 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1948
3081
|
canvas.setAttribute("data-nodality-raster", live ? "live" : "snapshot");
|
|
1949
3082
|
console.info("[nodality] raster backend:", live ? "html-in-canvas (live)" : "snapshot");
|
|
1950
3083
|
|
|
3084
|
+
// In live mode the canvas fills the host's CONTENT box, not its border
|
|
3085
|
+
// box (it is a child of the host). Filled in below when live attaches.
|
|
3086
|
+
const liveBox = { width: rect.width, height: rect.height };
|
|
1951
3087
|
let sourceEl = el; // element uploaded as the texture in live mode
|
|
1952
3088
|
if (live) {
|
|
1953
3089
|
// HTML-in-Canvas: the subtree becomes canvas children (wrapped in
|
|
@@ -1976,9 +3112,42 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1976
3112
|
// mode the children never leave the host.
|
|
1977
3113
|
const hostCS = getComputedStyle(el);
|
|
1978
3114
|
const hostIsFlex = hostCS.display === "flex" || hostCS.display === "inline-flex";
|
|
3115
|
+
// GRID too, and for a sharper reason than flex.
|
|
3116
|
+
//
|
|
3117
|
+
// Dropping a flex host's layout misplaced its children; dropping a
|
|
3118
|
+
// GRID host's layout changes the box SIZE. Columns collapse, the
|
|
3119
|
+
// children stack vertically, and they overflow the fixed-height
|
|
3120
|
+
// wrapper — at which point measure()'s scrollHeight fallback (the
|
|
3121
|
+
// host's own rect is collapsed in live mode) starts counting the
|
|
3122
|
+
// overflowing canvas, which is a child of the thing being measured.
|
|
3123
|
+
// Canvas grows -> host grows -> ResizeObserver -> repeat. A
|
|
3124
|
+
// 98px-tall two-column panel reached 4014px before this line.
|
|
3125
|
+
const hostIsGrid = hostCS.display === "grid" || hostCS.display === "inline-grid";
|
|
3126
|
+
|
|
3127
|
+
// The canvas is a CHILD of the host, so it lives in the host's
|
|
3128
|
+
// CONTENT box — but `rect` is the border box. Sizing the canvas to
|
|
3129
|
+
// `rect` therefore overflows by the padding on both axes and makes
|
|
3130
|
+
// the host padding taller than it started, every time. Measure the
|
|
3131
|
+
// content box once, here, and use it for both the canvas and the
|
|
3132
|
+
// subtree; the host keeps its own padding and nothing is applied
|
|
3133
|
+
// twice.
|
|
3134
|
+
const px = (v) => parseFloat(v) || 0;
|
|
3135
|
+
const padX = px(hostCS.paddingLeft) + px(hostCS.paddingRight);
|
|
3136
|
+
const padY = px(hostCS.paddingTop) + px(hostCS.paddingBottom);
|
|
3137
|
+
liveBox.width = Math.max(2, rect.width - padX);
|
|
3138
|
+
liveBox.height = Math.max(2, rect.height - padY);
|
|
3139
|
+
|
|
1979
3140
|
wrap.style.cssText =
|
|
1980
|
-
`display:${hostIsFlex ?
|
|
1981
|
-
`width:${
|
|
3141
|
+
`display:${hostIsFlex || hostIsGrid ? hostCS.display : "block"};` +
|
|
3142
|
+
`width:${liveBox.width}px;height:${liveBox.height}px;`;
|
|
3143
|
+
if (hostIsGrid) {
|
|
3144
|
+
wrap.style.gridTemplateColumns = hostCS.gridTemplateColumns;
|
|
3145
|
+
wrap.style.gridTemplateRows = hostCS.gridTemplateRows;
|
|
3146
|
+
wrap.style.gridTemplateAreas = hostCS.gridTemplateAreas;
|
|
3147
|
+
wrap.style.gap = hostCS.gap;
|
|
3148
|
+
wrap.style.alignItems = hostCS.alignItems;
|
|
3149
|
+
wrap.style.justifyItems = hostCS.justifyItems;
|
|
3150
|
+
}
|
|
1982
3151
|
if (hostIsFlex) {
|
|
1983
3152
|
wrap.style.flexDirection = hostCS.flexDirection;
|
|
1984
3153
|
wrap.style.alignItems = hostCS.alignItems;
|
|
@@ -1991,7 +3160,7 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1991
3160
|
// The host collapses once its children move into the canvas, so
|
|
1992
3161
|
// the canvas itself carries the box in normal flow (not overlay).
|
|
1993
3162
|
canvas.style.cssText =
|
|
1994
|
-
`display:block;width:${
|
|
3163
|
+
`display:block;width:${liveBox.width}px;height:${liveBox.height}px;`;
|
|
1995
3164
|
} else {
|
|
1996
3165
|
// Snapshot mode: purely visual overlay.
|
|
1997
3166
|
canvas.style.cssText =
|
|
@@ -2026,7 +3195,8 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2026
3195
|
};
|
|
2027
3196
|
const prog = gl.createProgram();
|
|
2028
3197
|
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
|
|
2029
|
-
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER,
|
|
3198
|
+
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER,
|
|
3199
|
+
buildFragmentShader(rasterTree, rasterNodes, false, transition)));
|
|
2030
3200
|
// Pin "a" to location 0 so a field-simulation program (which shares
|
|
2031
3201
|
// this vertex buffer and attribute array) can be swapped in without
|
|
2032
3202
|
// respecifying the pointer.
|
|
@@ -2046,15 +3216,46 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2046
3216
|
else if (kind === "3fv") gl.uniform3fv(loc, value);
|
|
2047
3217
|
};
|
|
2048
3218
|
|
|
2049
|
-
// Static uniforms from the node data.
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
3219
|
+
// Static uniforms from the node data. Uploaded once per PROGRAM
|
|
3220
|
+
// rather than once per pipeline: phase I1's probe is a second program
|
|
3221
|
+
// running the same chain, and without these its op uniforms would all
|
|
3222
|
+
// read 0 — a `size` of 0 divides to infinity and the whole coordinate
|
|
3223
|
+
// computation becomes NaN, which readback reports as a confident 0.
|
|
3224
|
+
// Phase T1. Transition progress. Static uniforms are resolved AT this
|
|
3225
|
+
// value, so a keyframed param is correct from the first frame rather
|
|
3226
|
+
// than jumping once something scrubs.
|
|
3227
|
+
let progress = 0;
|
|
3228
|
+
const keyframed = rasterNodes
|
|
3229
|
+
.map((node, i) => ({ node, i }))
|
|
3230
|
+
.filter(({ node }) => hasKeyframes(node));
|
|
3231
|
+
|
|
3232
|
+
const applyStaticUniforms = (Uq, only) => {
|
|
3233
|
+
rasterNodes.forEach((node, i) => {
|
|
3234
|
+
if (only && !only.has(i)) return;
|
|
3235
|
+
const def = REGISTRY[node.op];
|
|
3236
|
+
if (!def || !def.uniforms) return;
|
|
3237
|
+
const us = def.uniforms(resolveNode(node, progress), dpr);
|
|
3238
|
+
for (const key in us) {
|
|
3239
|
+
setUniform(Uq(`u${i}_${key}`), us[key][0], us[key][1]);
|
|
3240
|
+
}
|
|
3241
|
+
});
|
|
3242
|
+
};
|
|
3243
|
+
applyStaticUniforms(U);
|
|
3244
|
+
|
|
3245
|
+
// Re-upload only the keyframed nodes. Cheap enough to run per frame
|
|
3246
|
+
// while a transition is scrubbing, and a no-op for every chain that
|
|
3247
|
+
// has no keyframes — which is all of them until someone opts in.
|
|
3248
|
+
const kfIndices = new Set(keyframed.map((k) => k.i));
|
|
3249
|
+
const uploadKeyframes = () => {
|
|
3250
|
+
if (!keyframed.length) return;
|
|
3251
|
+
gl.useProgram(prog);
|
|
3252
|
+
applyStaticUniforms(U, kfIndices);
|
|
3253
|
+
if (probe && !probe.broken) {
|
|
3254
|
+
gl.useProgram(probe.prog);
|
|
3255
|
+
applyStaticUniforms(probe.U, kfIndices);
|
|
3256
|
+
gl.useProgram(prog);
|
|
2056
3257
|
}
|
|
2057
|
-
}
|
|
3258
|
+
};
|
|
2058
3259
|
|
|
2059
3260
|
// Ops with a CPU simulation (e.g. blobs) get per-frame state and
|
|
2060
3261
|
// feed dynamic uniforms each draw (see the render loop below).
|
|
@@ -2262,6 +3463,52 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2262
3463
|
const finishUpload = () => { if (needMips) gl.generateMipmap(gl.TEXTURE_2D); };
|
|
2263
3464
|
|
|
2264
3465
|
let textureReady = false;
|
|
3466
|
+
|
|
3467
|
+
// Phase T2. The frozen capture of the element being transitioned
|
|
3468
|
+
// FROM. Uploaded once — it is a still by definition, which is also
|
|
3469
|
+
// why transitions need no live-capture support and therefore work in
|
|
3470
|
+
// every browser that has WebGL, not just those in the origin trial.
|
|
3471
|
+
let oldTex = null;
|
|
3472
|
+
let newTex = null;
|
|
3473
|
+
// NOTE: the new element's ink is NOT hidden here. An earlier attempt
|
|
3474
|
+
// did it on the next animation frame and raced the capture — the
|
|
3475
|
+
// texture was taken from an already-transparent subtree, so the new
|
|
3476
|
+
// side of every morph was blank. The existing post-capture path
|
|
3477
|
+
// (`if (!pureOverlay) hideHostInk()`) runs at the right moment, and
|
|
3478
|
+
// transition mode is never pureOverlay; setProgress governs it from
|
|
3479
|
+
// then on.
|
|
3480
|
+
if (transition && transition.oldImage) {
|
|
3481
|
+
oldTex = gl.createTexture();
|
|
3482
|
+
gl.bindTexture(gl.TEXTURE_2D, oldTex);
|
|
3483
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
3484
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
3485
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
3486
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
3487
|
+
try {
|
|
3488
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,
|
|
3489
|
+
gl.UNSIGNED_BYTE, transition.oldImage);
|
|
3490
|
+
} catch (e) {
|
|
3491
|
+
console.warn("[nodality] transition: old capture upload failed:", e);
|
|
3492
|
+
gl.deleteTexture(oldTex);
|
|
3493
|
+
oldTex = null;
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
if (transition && transition.newImage) {
|
|
3497
|
+
newTex = gl.createTexture();
|
|
3498
|
+
gl.bindTexture(gl.TEXTURE_2D, newTex);
|
|
3499
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
3500
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
3501
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
3502
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
3503
|
+
try {
|
|
3504
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,
|
|
3505
|
+
gl.UNSIGNED_BYTE, transition.newImage);
|
|
3506
|
+
} catch (e) {
|
|
3507
|
+
console.warn("[nodality] transition: new capture upload failed:", e);
|
|
3508
|
+
gl.deleteTexture(newTex);
|
|
3509
|
+
newTex = null;
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
2265
3512
|
let destroyed = false;
|
|
2266
3513
|
let mode = live ? "live" : "snapshot";
|
|
2267
3514
|
|
|
@@ -2305,12 +3552,31 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2305
3552
|
|
|
2306
3553
|
const hideHostInk = () => {
|
|
2307
3554
|
if (inkSaved) return;
|
|
3555
|
+
ensureSelectionStyle();
|
|
2308
3556
|
inkSaved = [];
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
3557
|
+
el.setAttribute("data-nodality-ink", "");
|
|
3558
|
+
const nodes = [el, ...el.querySelectorAll("*")]
|
|
3559
|
+
.filter((n) => n !== canvas && n.tagName !== "CANVAS");
|
|
3560
|
+
// Selection has to stay LEGIBLE while the ink is transparent. The
|
|
3561
|
+
// host is deliberately still selectable (see above), but with
|
|
3562
|
+
// color:transparent a drag paints the selection rectangle over
|
|
3563
|
+
// invisible glyphs — a blank block that hides the effect instead
|
|
3564
|
+
// of highlighting anything. Stash each node's real colour so the
|
|
3565
|
+
// ::selection rule can repaint just the selected run.
|
|
3566
|
+
//
|
|
3567
|
+
// Read EVERY colour before writing ANY, in two passes. Colour is
|
|
3568
|
+
// inherited, so transparentising a parent inside a single loop
|
|
3569
|
+
// makes each later child read back the already-hidden value —
|
|
3570
|
+
// which stores transparent as the "real" colour and restores
|
|
3571
|
+
// nothing.
|
|
3572
|
+
const realInk = nodes.map((n) => getComputedStyle(n).color);
|
|
3573
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
3574
|
+
const node = nodes[i];
|
|
3575
|
+
const saved = { op: node.style.getPropertyValue("opacity"),
|
|
3576
|
+
ink: node.style.getPropertyValue("--nod-ink") };
|
|
2312
3577
|
for (const p in INK) saved[p] = node.style.getPropertyValue(p);
|
|
2313
3578
|
inkSaved.push([node, saved]);
|
|
3579
|
+
node.style.setProperty("--nod-ink", realInk[i]);
|
|
2314
3580
|
for (const p in INK) node.style.setProperty(p, INK[p], "important");
|
|
2315
3581
|
// Replaced content has no "ink" property to neutralise.
|
|
2316
3582
|
if (node !== el && node.matches && node.matches(REPLACED)) {
|
|
@@ -2325,6 +3591,7 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2325
3591
|
|
|
2326
3592
|
const showHostInk = () => {
|
|
2327
3593
|
el.style.visibility = "";
|
|
3594
|
+
el.removeAttribute("data-nodality-ink");
|
|
2328
3595
|
if (!inkSaved) return;
|
|
2329
3596
|
for (const [node, saved] of inkSaved) {
|
|
2330
3597
|
for (const p in INK) {
|
|
@@ -2333,12 +3600,76 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2333
3600
|
}
|
|
2334
3601
|
if (saved.op) node.style.setProperty("opacity", saved.op);
|
|
2335
3602
|
else node.style.removeProperty("opacity");
|
|
3603
|
+
if (saved.ink) node.style.setProperty("--nod-ink", saved.ink);
|
|
3604
|
+
else node.style.removeProperty("--nod-ink");
|
|
2336
3605
|
}
|
|
2337
3606
|
inkSaved = null;
|
|
2338
3607
|
el.style.isolation = "";
|
|
2339
3608
|
canvas.style.zIndex = "";
|
|
2340
3609
|
};
|
|
2341
3610
|
|
|
3611
|
+
// Phase T2. WHO presents: the canvas while a transition is running,
|
|
3612
|
+
// the real DOM once it completes. They are alternatives, never both.
|
|
3613
|
+
//
|
|
3614
|
+
// This has to be one function called from everywhere, because the
|
|
3615
|
+
// snapshot path hides ink on EVERY capture completion — and a capture
|
|
3616
|
+
// can land long after the transition has finished (a resize, a late
|
|
3617
|
+
// image load, a refresh()). When that happened after t reached 1 the
|
|
3618
|
+
// ink was hidden again while the canvas was already down, and the
|
|
3619
|
+
// element vanished. It only reproduced on a real browser: headless
|
|
3620
|
+
// finishes its single capture before anything can scrub.
|
|
3621
|
+
//
|
|
3622
|
+
// Returns true when it took ownership, so callers know to stand down.
|
|
3623
|
+
// Re-asserted EVERY FRAME rather than only on the events that change
|
|
3624
|
+
// it, because at least three other subsystems write the same state
|
|
3625
|
+
// for their own reasons and the last writer wins:
|
|
3626
|
+
//
|
|
3627
|
+
// - snapshotCapture() calls showHostInk() before serialising, so
|
|
3628
|
+
// the capture sees real ink rather than the transparent version;
|
|
3629
|
+
// - its completion hides the ink again;
|
|
3630
|
+
// - the live -> snapshot fallback rewrites canvas.style.cssText
|
|
3631
|
+
// wholesale, discarding `visibility: hidden` along with it.
|
|
3632
|
+
//
|
|
3633
|
+
// Any of those can land after a transition completes and leave the
|
|
3634
|
+
// element hidden behind an already-hidden canvas, which is exactly
|
|
3635
|
+
// the "it disappears at t=1" report. Ordering fixes kept missing a
|
|
3636
|
+
// path; a cheap idempotent re-assert cannot. Two property reads per
|
|
3637
|
+
// frame, and only in transition mode.
|
|
3638
|
+
const syncTransitionView = () => {
|
|
3639
|
+
if (!transition) return false;
|
|
3640
|
+
const done = progress >= 1;
|
|
3641
|
+
// Standing the canvas down at t=1 assumes it OVERLAYS the content,
|
|
3642
|
+
// so hiding it reveals the real element underneath. That is true
|
|
3643
|
+
// of the snapshot backend, but the live backend MOVES the
|
|
3644
|
+
// element's children into the canvas (canvas.appendChild(wrap)) —
|
|
3645
|
+
// there the canvas is where the content lives, and hiding it hides
|
|
3646
|
+
// the very thing being handed over to.
|
|
3647
|
+
//
|
|
3648
|
+
// The test is `sourceEl !== el`, not `mode === "live"`. A live
|
|
3649
|
+
// pipeline only restructures when it actually needs the live
|
|
3650
|
+
// upload; a transition draws from the two frozen captures instead,
|
|
3651
|
+
// so it stays an overlay even on a live-capable browser. Keying
|
|
3652
|
+
// off the mode suppressed ink suppression on every such browser.
|
|
3653
|
+
const hostsContent = sourceEl !== el;
|
|
3654
|
+
// A morph owns the screen strictly BETWEEN its endpoints. At t=1
|
|
3655
|
+
// the new element takes over; opting in with `standDownAtStart`
|
|
3656
|
+
// makes t=0 symmetric, so the OLD element presents there — real,
|
|
3657
|
+
// clickable, selectable — instead of a picture of itself. Opt-in
|
|
3658
|
+
// because the library cannot supply the old element: it belongs to
|
|
3659
|
+
// the caller, who must put it back. Without it, t=0 keeps showing
|
|
3660
|
+
// the old capture, which is the right default for a morph that is
|
|
3661
|
+
// about to run.
|
|
3662
|
+
const atStart = transition.standDownAtStart && progress <= 0;
|
|
3663
|
+
const wantVis = (done || atStart) && !hostsContent ? "hidden" : "visible";
|
|
3664
|
+
if (canvas.style.visibility !== wantVis) canvas.style.visibility = wantVis;
|
|
3665
|
+
if (done) {
|
|
3666
|
+
if (inkSaved) showHostInk();
|
|
3667
|
+
} else if (!inkSaved) {
|
|
3668
|
+
hideHostInk();
|
|
3669
|
+
}
|
|
3670
|
+
return true;
|
|
3671
|
+
};
|
|
3672
|
+
|
|
2342
3673
|
const snapshotCapture = () => {
|
|
2343
3674
|
// Overlay + image content: use the image directly as the texture.
|
|
2344
3675
|
if (isOverlay) {
|
|
@@ -2374,8 +3705,12 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2374
3705
|
// A pure-overlay chain leaves the host alone; the lens
|
|
2375
3706
|
// floats over untouched content. Otherwise the canvas
|
|
2376
3707
|
// carries the look and the host's own ink is suppressed.
|
|
2377
|
-
|
|
2378
|
-
|
|
3708
|
+
// In transition mode progress decides this, not the
|
|
3709
|
+
// capture — see syncTransitionView.
|
|
3710
|
+
if (!syncTransitionView()) {
|
|
3711
|
+
if (!pureOverlay) hideHostInk();
|
|
3712
|
+
canvas.style.visibility = "visible";
|
|
3713
|
+
}
|
|
2379
3714
|
})
|
|
2380
3715
|
.catch((e) => {
|
|
2381
3716
|
showHostInk();
|
|
@@ -2519,9 +3854,420 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2519
3854
|
|
|
2520
3855
|
// Render loop — paused while off-screen.
|
|
2521
3856
|
let raf = 0;
|
|
3857
|
+
// The last frame's uniform values, so the phase-I1 probe pass can
|
|
3858
|
+
// reproduce that exact frame on the probe program. Null until the
|
|
3859
|
+
// first draw — sourceAt() reports "not ready" rather than guessing.
|
|
3860
|
+
let lastSnap = null;
|
|
3861
|
+
|
|
3862
|
+
/** Write a frame snapshot to whichever program `Uq` resolves against. */
|
|
3863
|
+
const applyUniforms = (Uq, s) => {
|
|
3864
|
+
gl.uniform2f(Uq("u_res"), s.res[0], s.res[1]);
|
|
3865
|
+
gl.uniform2f(Uq("u_mouse"), s.mouse[0], s.mouse[1]);
|
|
3866
|
+
gl.uniform1f(Uq("u_time"), s.time);
|
|
3867
|
+
gl.uniform1f(Uq("u_t"), s.t);
|
|
3868
|
+
for (const d of s.drivers) {
|
|
3869
|
+
gl.uniform2f(Uq(`u${d.i}_dpos`), d.x, d.y);
|
|
3870
|
+
gl.uniform1f(Uq(`u${d.i}_damt`), d.amt);
|
|
3871
|
+
}
|
|
3872
|
+
for (const d of s.dyn) {
|
|
3873
|
+
for (const u of d.ups) setUniform(Uq(`u${d.i}_${u.name}`), u.kind, u.value);
|
|
3874
|
+
}
|
|
3875
|
+
// Content on unit 0, then each solver's named result textures
|
|
3876
|
+
// (velocity, dye, ...) on units above it.
|
|
3877
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
3878
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
3879
|
+
gl.uniform1i(Uq("u_tex"), 0);
|
|
3880
|
+
let unit = 1;
|
|
3881
|
+
if (transition && oldTex) {
|
|
3882
|
+
// Phase T2. The frozen old capture, and the box both sides
|
|
3883
|
+
// are drawn into — lerped by progress, in device px.
|
|
3884
|
+
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
3885
|
+
gl.bindTexture(gl.TEXTURE_2D, oldTex);
|
|
3886
|
+
gl.uniform1i(Uq("u_old"), unit);
|
|
3887
|
+
unit++;
|
|
3888
|
+
// Each side interpolates in its own rect. `oldTo` is where
|
|
3889
|
+
// the outgoing element travels to (default: the new rect, so
|
|
3890
|
+
// they converge), `newFrom` where the incoming one starts
|
|
3891
|
+
// from (default: the old rect). Supplying both is how you get
|
|
3892
|
+
// "old exits left while new enters from the right".
|
|
3893
|
+
const t = s.t;
|
|
3894
|
+
const lerpBox = (from, to) => [
|
|
3895
|
+
(from.x + (to.x - from.x) * t) * dpr,
|
|
3896
|
+
(from.y + (to.y - from.y) * t) * dpr,
|
|
3897
|
+
(from.w + (to.w - from.w) * t) * dpr,
|
|
3898
|
+
(from.h + (to.h - from.h) * t) * dpr,
|
|
3899
|
+
];
|
|
3900
|
+
const oldBox = lerpBox(transition.oldRect,
|
|
3901
|
+
transition.oldTo || transition.newRect);
|
|
3902
|
+
const newBox = lerpBox(transition.newFrom || transition.oldRect,
|
|
3903
|
+
transition.newRect);
|
|
3904
|
+
gl.uniform4f(Uq("u_boxOld"), oldBox[0], oldBox[1], oldBox[2], oldBox[3]);
|
|
3905
|
+
gl.uniform4f(Uq("u_boxNew"), newBox[0], newBox[1], newBox[2], newBox[3]);
|
|
3906
|
+
if (newTex) {
|
|
3907
|
+
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
3908
|
+
gl.bindTexture(gl.TEXTURE_2D, newTex);
|
|
3909
|
+
gl.uniform1i(Uq("u_newimg"), unit);
|
|
3910
|
+
unit++;
|
|
3911
|
+
}
|
|
3912
|
+
}
|
|
3913
|
+
for (const so of solverOps) {
|
|
3914
|
+
const smps = so.def.solver.samplers;
|
|
3915
|
+
for (const key in smps) {
|
|
3916
|
+
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
3917
|
+
gl.bindTexture(gl.TEXTURE_2D, so.targets[smps[key]].a.tex);
|
|
3918
|
+
gl.uniform1i(Uq(`u${so.i}_${key}`), unit);
|
|
3919
|
+
unit++;
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
gl.activeTexture(gl.TEXTURE0); // texture uploads assume unit 0
|
|
3923
|
+
};
|
|
3924
|
+
|
|
3925
|
+
// ── Phase I1: coordinate readback ────────────────────────────────
|
|
3926
|
+
//
|
|
3927
|
+
// The overlay canvas is pointer-events:none and the DOM underneath
|
|
3928
|
+
// stays where layout put it, so once a warp displaces content, a link
|
|
3929
|
+
// is clickable where it is NOT drawn. Fixing that needs one fact:
|
|
3930
|
+
// given a screen point, which source pixel is shown there?
|
|
3931
|
+
//
|
|
3932
|
+
// The shader already computes it — output pixel -> sampleP -> fetch.
|
|
3933
|
+
// So rather than reimplement each op's arithmetic on the CPU (which
|
|
3934
|
+
// could silently disagree with what is drawn, and is impossible for
|
|
3935
|
+
// `stir`, a fluid sim with no closed form), run the SAME chain and
|
|
3936
|
+
// read the answer back.
|
|
3937
|
+
//
|
|
3938
|
+
// Cost is one draw of a single fragment plus a 1-pixel readPixels.
|
|
3939
|
+
// Built on first use, so a page that never hit-tests pays nothing.
|
|
3940
|
+
let probe = null;
|
|
3941
|
+
const buildProbe = () => {
|
|
3942
|
+
if (probe) return probe;
|
|
3943
|
+
const p = gl.createProgram();
|
|
3944
|
+
gl.attachShader(p, compile(gl.VERTEX_SHADER, VS));
|
|
3945
|
+
gl.attachShader(p, compile(gl.FRAGMENT_SHADER,
|
|
3946
|
+
buildFragmentShader(rasterTree, rasterNodes, true, transition)));
|
|
3947
|
+
gl.bindAttribLocation(p, 0, "a");
|
|
3948
|
+
gl.linkProgram(p);
|
|
3949
|
+
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
|
|
3950
|
+
console.warn("[nodality] probe program failed:", gl.getProgramInfoLog(p));
|
|
3951
|
+
gl.deleteProgram(p);
|
|
3952
|
+
probe = { broken: true };
|
|
3953
|
+
return probe;
|
|
3954
|
+
}
|
|
3955
|
+
// One pixel is the entire render target: the viewport is offset so
|
|
3956
|
+
// that the queried pixel is the only one rasterised.
|
|
3957
|
+
const t = gl.createTexture();
|
|
3958
|
+
gl.bindTexture(gl.TEXTURE_2D, t);
|
|
3959
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
3960
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
3961
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
3962
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
3963
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
3964
|
+
const fbo = gl.createFramebuffer();
|
|
3965
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
|
3966
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);
|
|
3967
|
+
const st = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
|
|
3968
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
3969
|
+
if (st !== gl.FRAMEBUFFER_COMPLETE) {
|
|
3970
|
+
console.warn("[nodality] probe framebuffer incomplete:", st);
|
|
3971
|
+
gl.deleteProgram(p); gl.deleteFramebuffer(fbo); gl.deleteTexture(t);
|
|
3972
|
+
probe = { broken: true };
|
|
3973
|
+
return probe;
|
|
3974
|
+
}
|
|
3975
|
+
const cache = {};
|
|
3976
|
+
const Up = (n) => (n in cache ? cache[n] : (cache[n] = gl.getUniformLocation(p, n)));
|
|
3977
|
+
// The chain's static op uniforms, on this program too.
|
|
3978
|
+
gl.useProgram(p);
|
|
3979
|
+
applyStaticUniforms(Up);
|
|
3980
|
+
gl.useProgram(prog);
|
|
3981
|
+
probe = { prog: p, fbo, tex: t, px: new Uint8Array(4), U: Up };
|
|
3982
|
+
return probe;
|
|
3983
|
+
};
|
|
3984
|
+
|
|
3985
|
+
/**
|
|
3986
|
+
* Which source pixel is drawn at this point on screen?
|
|
3987
|
+
*
|
|
3988
|
+
* @param {number} clientX viewport coordinate, as on a PointerEvent
|
|
3989
|
+
* @param {number} clientY
|
|
3990
|
+
* @returns {{x: number, y: number} | null} the point in CLIENT
|
|
3991
|
+
* coordinates that the content visible at (clientX, clientY) came
|
|
3992
|
+
* from — feed it straight to document.elementFromPoint(). Null if
|
|
3993
|
+
* nothing has been drawn yet, the point is outside the canvas, or
|
|
3994
|
+
* the probe could not be built.
|
|
3995
|
+
*
|
|
3996
|
+
* Caveat, documented rather than hidden: `copy` draws several stamps,
|
|
3997
|
+
* so one screen point genuinely maps to several sources. This returns
|
|
3998
|
+
* the primary sample. Phase I4 classifies that case.
|
|
3999
|
+
*/
|
|
4000
|
+
// ── Phase I2: the CPU path ───────────────────────────────────────
|
|
4001
|
+
//
|
|
4002
|
+
// A GPU readback is a pipeline sync, which is fine per click and too
|
|
4003
|
+
// expensive per pointermove. An op may declare `map` — the same
|
|
4004
|
+
// coordinate arithmetic its GLSL does, in JS — and a chain whose
|
|
4005
|
+
// coordinate-moving ops all declare one is answered without touching
|
|
4006
|
+
// the GPU.
|
|
4007
|
+
//
|
|
4008
|
+
// The duplication is safe only because I1 exists to check it: the
|
|
4009
|
+
// property test asserts twin and readback agree across the parameter
|
|
4010
|
+
// space. A twin that drifts from its shader is caught, not trusted.
|
|
4011
|
+
//
|
|
4012
|
+
// Bails to null — meaning "ask the GPU" — for anything it cannot do
|
|
4013
|
+
// faithfully:
|
|
4014
|
+
// - an op in a coordinate stage with no `map` and no explicit
|
|
4015
|
+
// `movesCoords: false` (flow, stir)
|
|
4016
|
+
// - a `map` that declines (hexalize with lift)
|
|
4017
|
+
// - any MASKED op, because its effect is lerped by a field this
|
|
4018
|
+
// path does not evaluate
|
|
4019
|
+
const COORD_STAGES = ["warp", "cell", "displace"];
|
|
4020
|
+
const cpuSourceAt = (px, py) => {
|
|
4021
|
+
if (!lastSnap) return null;
|
|
4022
|
+
const byIndex = {};
|
|
4023
|
+
for (const d of lastSnap.drivers) byIndex[d.i] = d;
|
|
4024
|
+
let pt = [px, py];
|
|
4025
|
+
for (const stage of COORD_STAGES) {
|
|
4026
|
+
for (let i = 0; i < rasterNodes.length; i++) {
|
|
4027
|
+
const node = rasterNodes[i];
|
|
4028
|
+
const def = REGISTRY[node.op];
|
|
4029
|
+
if (!def) continue;
|
|
4030
|
+
const stages = Array.isArray(def.stage) ? def.stage : [def.stage];
|
|
4031
|
+
if (!stages.includes(stage)) continue;
|
|
4032
|
+
if (maskedField(node)) return null;
|
|
4033
|
+
if (typeof def.map !== "function") {
|
|
4034
|
+
if (def.movesCoords === false) continue;
|
|
4035
|
+
return null;
|
|
4036
|
+
}
|
|
4037
|
+
const d = byIndex[i];
|
|
4038
|
+
const out = def.map(pt, node, {
|
|
4039
|
+
res: lastSnap.res,
|
|
4040
|
+
dpr,
|
|
4041
|
+
dpos: d ? [d.x, d.y] : [lastSnap.res[0] / 2, lastSnap.res[1] / 2],
|
|
4042
|
+
damt: d ? d.amt : 1,
|
|
4043
|
+
time: lastSnap.time,
|
|
4044
|
+
mouse: lastSnap.mouse,
|
|
4045
|
+
});
|
|
4046
|
+
if (!out) return null;
|
|
4047
|
+
pt = out;
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
return pt;
|
|
4051
|
+
};
|
|
4052
|
+
|
|
4053
|
+
/**
|
|
4054
|
+
* @param {object} [opts]
|
|
4055
|
+
* @param {boolean} [opts.gpu] force the readback path, skipping any
|
|
4056
|
+
* declared CPU twin. This is the oracle the twins are checked
|
|
4057
|
+
* against — a `map` that drifted from its shader would
|
|
4058
|
+
* otherwise be undetectable, since both paths would be asked
|
|
4059
|
+
* the same question and only one of them consulted.
|
|
4060
|
+
*/
|
|
4061
|
+
const sourceAt = (clientX, clientY, opts) => {
|
|
4062
|
+
if (destroyed || !lastSnap || !textureReady) return null;
|
|
4063
|
+
|
|
4064
|
+
const rect = canvas.getBoundingClientRect();
|
|
4065
|
+
if (rect.width && rect.height && !(opts && opts.gpu)) {
|
|
4066
|
+
// Try the CPU path first — same answer, no pipeline sync.
|
|
4067
|
+
const sxc = canvas.width / rect.width, syc = canvas.height / rect.height;
|
|
4068
|
+
const dx = (clientX - rect.left) * sxc, dy = (clientY - rect.top) * syc;
|
|
4069
|
+
if (dx >= 0 && dy >= 0 && dx < canvas.width && dy < canvas.height) {
|
|
4070
|
+
// The SAME pixel-centre convention the readback uses: floor
|
|
4071
|
+
// to a pixel, then take its centre. Without this the two
|
|
4072
|
+
// paths ask about points up to half a pixel apart, which is
|
|
4073
|
+
// invisible for most ops and enormous for `offset` near its
|
|
4074
|
+
// focus, where dir = (warped - dpos)/d is a singularity.
|
|
4075
|
+
const fx = Math.floor(dx);
|
|
4076
|
+
const fy = Math.floor(canvas.height - dy);
|
|
4077
|
+
const cpu = cpuSourceAt(fx + 0.5, canvas.height - fy - 0.5);
|
|
4078
|
+
if (cpu) {
|
|
4079
|
+
return {
|
|
4080
|
+
x: rect.left + Math.min(Math.max(cpu[0], 0), canvas.width) / sxc,
|
|
4081
|
+
y: rect.top + Math.min(Math.max(cpu[1], 0), canvas.height) / syc,
|
|
4082
|
+
};
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
const pr = buildProbe();
|
|
4088
|
+
if (pr.broken) return null;
|
|
4089
|
+
|
|
4090
|
+
const r = canvas.getBoundingClientRect();
|
|
4091
|
+
if (!r.width || !r.height) return null;
|
|
4092
|
+
// Client -> canvas device pixels. The canvas may be scaled by CSS,
|
|
4093
|
+
// so go through the rect rather than assuming dpr.
|
|
4094
|
+
const sx = canvas.width / r.width, sy = canvas.height / r.height;
|
|
4095
|
+
const px = (clientX - r.left) * sx;
|
|
4096
|
+
const py = (clientY - r.top) * sy;
|
|
4097
|
+
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) return null;
|
|
4098
|
+
|
|
4099
|
+
// gl_FragCoord is bottom-up; `py` is top-down.
|
|
4100
|
+
const fx = Math.floor(px);
|
|
4101
|
+
const fy = Math.floor(canvas.height - py);
|
|
4102
|
+
|
|
4103
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, pr.fbo);
|
|
4104
|
+
gl.useProgram(pr.prog);
|
|
4105
|
+
// The whole target is one pixel; the shader reads the coordinate
|
|
4106
|
+
// it is standing in for from u_probe rather than gl_FragCoord.
|
|
4107
|
+
gl.viewport(0, 0, 1, 1);
|
|
4108
|
+
gl.uniform2f(pr.U("u_probe"), fx, fy);
|
|
4109
|
+
applyUniforms(pr.U, lastSnap);
|
|
4110
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
4111
|
+
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pr.px);
|
|
4112
|
+
|
|
4113
|
+
// Restore what draw() expects to find.
|
|
4114
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
4115
|
+
gl.useProgram(prog);
|
|
4116
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
4117
|
+
|
|
4118
|
+
// Two bytes per axis, as packed in the probe tail.
|
|
4119
|
+
const b = pr.px;
|
|
4120
|
+
const qx = (b[0] + b[1] / 255) / 255;
|
|
4121
|
+
const qy = (b[2] + b[3] / 255) / 255;
|
|
4122
|
+
// Device px (top-down) -> client coordinates.
|
|
4123
|
+
return {
|
|
4124
|
+
x: r.left + (qx * canvas.width) / sx,
|
|
4125
|
+
y: r.top + (qy * canvas.height) / sy,
|
|
4126
|
+
};
|
|
4127
|
+
};
|
|
4128
|
+
|
|
4129
|
+
// ── Phase I3: event retargeting ──────────────────────────────────
|
|
4130
|
+
//
|
|
4131
|
+
// With I1 able to say where a pixel came from, the fix is mechanical:
|
|
4132
|
+
// catch pointer events on the host in the CAPTURE phase (before they
|
|
4133
|
+
// reach the element the browser picked), ask where the content under
|
|
4134
|
+
// the cursor actually lives, and re-dispatch there.
|
|
4135
|
+
//
|
|
4136
|
+
// Honest boundary, stated because it cannot be fixed from here: CSS
|
|
4137
|
+
// `:hover` and `:active` are computed by the browser from the real
|
|
4138
|
+
// pointer position over the real box. A synthetic MouseEvent does not
|
|
4139
|
+
// move them. So JS handlers retarget correctly and CSS pseudo-classes
|
|
4140
|
+
// still follow the undisplaced layout. Retargeting those would mean
|
|
4141
|
+
// moving the DOM, which is the one thing this library promises not to
|
|
4142
|
+
// do. Focus and keyboard were never wrong — they never went through
|
|
4143
|
+
// coordinates.
|
|
4144
|
+
const RETARGET = "__nodalityRetargeted";
|
|
4145
|
+
// Opt out per pipeline, like `live: false`. An effect used as pure
|
|
4146
|
+
// decoration should not pay for a GPU readback per click.
|
|
4147
|
+
const interactive = !rasterNodes.some((n) => n.interactive === false);
|
|
4148
|
+
|
|
4149
|
+
// Phase I3c. CSS :hover is computed by the browser from the real
|
|
4150
|
+
// pointer over the real box, and a synthetic MouseEvent does not move
|
|
4151
|
+
// it — so on a displaced element the wrong thing lights up. It CAN be
|
|
4152
|
+
// mirrored: track the hovered ancestor chain at the SOURCE position
|
|
4153
|
+
// and mark it with an attribute the page can style.
|
|
4154
|
+
//
|
|
4155
|
+
// { op: "flow", hoverAttr: true } → [data-nodality-hover]
|
|
4156
|
+
//
|
|
4157
|
+
// OPT-IN, and this is the reason: writing that attribute is a DOM
|
|
4158
|
+
// mutation, and this library's headline property is that an effect
|
|
4159
|
+
// never touches the host subtree — asserted with a MutationObserver
|
|
4160
|
+
// in the e2e suite. Turning it on trades that property for hover
|
|
4161
|
+
// fidelity, so it must be the page's decision, not a default. Page
|
|
4162
|
+
// authors also have to write `[data-nodality-hover]` rather than
|
|
4163
|
+
// `:hover`, which is a real cost.
|
|
4164
|
+
const HOVER_ATTR = "data-nodality-hover";
|
|
4165
|
+
const hoverMirror = rasterNodes.some((n) => n.hoverAttr === true);
|
|
4166
|
+
let hoverChain = [];
|
|
4167
|
+
const setHoverChain = (target) => {
|
|
4168
|
+
const next = [];
|
|
4169
|
+
for (let n = target; n && n !== el.parentNode; n = n.parentElement) next.push(n);
|
|
4170
|
+
const keep = new Set(next);
|
|
4171
|
+
for (const n of hoverChain) if (!keep.has(n)) n.removeAttribute(HOVER_ATTR);
|
|
4172
|
+
for (const n of next) if (!n.hasAttribute(HOVER_ATTR)) n.setAttribute(HOVER_ATTR, "");
|
|
4173
|
+
hoverChain = next;
|
|
4174
|
+
};
|
|
4175
|
+
|
|
4176
|
+
/** Build an event of the same kind, at corrected coordinates. */
|
|
4177
|
+
const cloneAt = (e, x, y) => {
|
|
4178
|
+
const Ctor = (typeof PointerEvent !== "undefined" && e instanceof PointerEvent)
|
|
4179
|
+
? PointerEvent
|
|
4180
|
+
: MouseEvent;
|
|
4181
|
+
const ev = new Ctor(e.type, {
|
|
4182
|
+
bubbles: true, cancelable: e.cancelable, composed: true,
|
|
4183
|
+
view: e.view || (typeof window !== "undefined" ? window : null),
|
|
4184
|
+
detail: e.detail,
|
|
4185
|
+
clientX: x, clientY: y,
|
|
4186
|
+
// Keep screen coords consistent with the shift we applied.
|
|
4187
|
+
screenX: e.screenX + (x - e.clientX),
|
|
4188
|
+
screenY: e.screenY + (y - e.clientY),
|
|
4189
|
+
ctrlKey: e.ctrlKey, altKey: e.altKey,
|
|
4190
|
+
shiftKey: e.shiftKey, metaKey: e.metaKey,
|
|
4191
|
+
button: e.button, buttons: e.buttons,
|
|
4192
|
+
relatedTarget: e.relatedTarget,
|
|
4193
|
+
// Ignored by MouseEvent, meaningful for PointerEvent.
|
|
4194
|
+
pointerId: e.pointerId, pointerType: e.pointerType,
|
|
4195
|
+
isPrimary: e.isPrimary, pressure: e.pressure,
|
|
4196
|
+
});
|
|
4197
|
+
ev[RETARGET] = true;
|
|
4198
|
+
return ev;
|
|
4199
|
+
};
|
|
4200
|
+
|
|
4201
|
+
// A GPU readback per pointermove would be one sync per frame. Moves
|
|
4202
|
+
// are throttled to one probe per animation frame; clicks are rare and
|
|
4203
|
+
// always probed. Phase I2 removes the sync for ops that can declare
|
|
4204
|
+
// their coordinate math on the CPU.
|
|
4205
|
+
let lastMoveProbe = 0;
|
|
4206
|
+
|
|
4207
|
+
const onRetarget = (e) => {
|
|
4208
|
+
if (!interactive || destroyed || e[RETARGET]) return;
|
|
4209
|
+
if (e.type === "pointermove" || e.type === "mousemove") {
|
|
4210
|
+
const t = (typeof performance !== "undefined") ? performance.now() : 0;
|
|
4211
|
+
if (t - lastMoveProbe < 12) return;
|
|
4212
|
+
lastMoveProbe = t;
|
|
4213
|
+
}
|
|
4214
|
+
const s = sourceAt(e.clientX, e.clientY);
|
|
4215
|
+
if (!s) return;
|
|
4216
|
+
// Nothing moved here: let the browser's own hit-testing stand.
|
|
4217
|
+
if (Math.abs(s.x - e.clientX) < 0.5 && Math.abs(s.y - e.clientY) < 0.5) return;
|
|
4218
|
+
|
|
4219
|
+
// Returns null when the source lands outside the VIEWPORT — an
|
|
4220
|
+
// element scrolled half off-screen whose displacement pushes the
|
|
4221
|
+
// source past the edge. Declining is the right answer there: the
|
|
4222
|
+
// native hit stands, rather than dispatching somewhere wrong.
|
|
4223
|
+
const target = typeof document.elementFromPoint === "function"
|
|
4224
|
+
? document.elementFromPoint(s.x, s.y)
|
|
4225
|
+
: null;
|
|
4226
|
+
// Only ever redirect INTO our own subtree. A displacement that
|
|
4227
|
+
// resolves outside the host is a bug or an edge case, and
|
|
4228
|
+
// dispatching into unrelated page content would be worse than
|
|
4229
|
+
// doing nothing.
|
|
4230
|
+
if (!target || target === e.target || !el.contains(target)) return;
|
|
4231
|
+
|
|
4232
|
+
// Hover, if the page opted in. Done for moves as well as clicks,
|
|
4233
|
+
// since that is when hover state changes.
|
|
4234
|
+
if (hoverMirror) setHoverChain(target);
|
|
4235
|
+
|
|
4236
|
+
// Phase I3b. Focus follows the pointer to the element that was
|
|
4237
|
+
// actually clicked. Without this, clicking a displaced link moves
|
|
4238
|
+
// focus to whatever sits under the cursor in the undisplaced
|
|
4239
|
+
// layout — so the picture, the click and the focus ring disagree.
|
|
4240
|
+
// Only on activating events: a pointermove must not steal focus.
|
|
4241
|
+
if (e.type === "pointerdown" || e.type === "mousedown" || e.type === "click") {
|
|
4242
|
+
const focusable = target.closest &&
|
|
4243
|
+
target.closest("a, button, input, select, textarea, [tabindex]");
|
|
4244
|
+
if (focusable && typeof focusable.focus === "function") {
|
|
4245
|
+
focusable.focus({ preventScroll: true });
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
|
|
4249
|
+
// Stop the original in the capture phase so the element the
|
|
4250
|
+
// browser picked never sees it, then deliver the corrected one.
|
|
4251
|
+
e.stopPropagation();
|
|
4252
|
+
if (e.cancelable) e.preventDefault();
|
|
4253
|
+
target.dispatchEvent(cloneAt(e, s.x, s.y));
|
|
4254
|
+
};
|
|
4255
|
+
|
|
4256
|
+
const RETARGET_EVENTS = ["pointerdown", "pointerup", "pointermove", "click",
|
|
4257
|
+
"dblclick", "contextmenu", "mousedown", "mouseup"];
|
|
4258
|
+
// Leaving the element must clear the mirrored hover, or it sticks.
|
|
4259
|
+
const onRetargetLeave = () => { if (hoverMirror) setHoverChain(null); };
|
|
4260
|
+
if (interactive) {
|
|
4261
|
+
for (const t of RETARGET_EVENTS) el.addEventListener(t, onRetarget, true);
|
|
4262
|
+
el.addEventListener("pointerleave", onRetargetLeave, true);
|
|
4263
|
+
}
|
|
4264
|
+
|
|
2522
4265
|
let visible = true;
|
|
2523
4266
|
const draw = () => {
|
|
2524
4267
|
if (destroyed) return;
|
|
4268
|
+
// Self-healing: whatever else touched the ink or the canvas since
|
|
4269
|
+
// the last frame, progress is the authority.
|
|
4270
|
+
syncTransitionView();
|
|
2525
4271
|
if (visible && textureReady) {
|
|
2526
4272
|
const now = (typeof performance !== "undefined") ? performance.now() : lastFrame + 16;
|
|
2527
4273
|
const dt = Math.max(0, (now - lastFrame) / 1000);
|
|
@@ -2540,10 +4286,20 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2540
4286
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
2541
4287
|
gl.clearColor(0, 0, 0, 0);
|
|
2542
4288
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
//
|
|
4289
|
+
// Everything the frame needs, computed ONCE, then written to
|
|
4290
|
+
// a program. Split this way for phase I1: the probe pass has
|
|
4291
|
+
// to reproduce this frame on a different program, and both
|
|
4292
|
+
// the hover easing above and `tick()` below MUTATE — running
|
|
4293
|
+
// them again for a hit-test would advance every animation by
|
|
4294
|
+
// an extra step per pointer event.
|
|
4295
|
+
const snap = {
|
|
4296
|
+
res: [canvas.width, canvas.height],
|
|
4297
|
+
mouse: [mouse[0], mouse[1]],
|
|
4298
|
+
time: now / 1000,
|
|
4299
|
+
t: progress,
|
|
4300
|
+
drivers: [],
|
|
4301
|
+
dyn: [],
|
|
4302
|
+
};
|
|
2547
4303
|
if (driverNodes.length > 0) {
|
|
2548
4304
|
hover += (hoverTarget - hover) * (1 - Math.exp(-dt * 8));
|
|
2549
4305
|
const dctx = {
|
|
@@ -2553,34 +4309,17 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2553
4309
|
};
|
|
2554
4310
|
for (const d of driverNodes) {
|
|
2555
4311
|
const v = d.fn(dctx);
|
|
2556
|
-
|
|
2557
|
-
gl.uniform1f(U(`u${d.i}_damt`), v.amt);
|
|
4312
|
+
snap.drivers.push({ i: d.i, x: v.x, y: v.y, amt: v.amt });
|
|
2558
4313
|
}
|
|
2559
4314
|
}
|
|
2560
|
-
// Advance and upload per-frame CPU simulations.
|
|
2561
4315
|
if (dynamicOps.length > 0) {
|
|
2562
4316
|
const ctx = { w: canvas.width, h: canvas.height, mouseX: mouse[0], mouseY: mouse[1], dt: dt, t: now / 1000 };
|
|
2563
4317
|
for (const d of dynamicOps) {
|
|
2564
|
-
|
|
2565
|
-
for (const u of ups) setUniform(U(`u${d.i}_${u.name}`), u.kind, u.value);
|
|
2566
|
-
}
|
|
2567
|
-
}
|
|
2568
|
-
// Content on unit 0, then each solver's named result
|
|
2569
|
-
// textures (velocity, dye, ...) on units above it.
|
|
2570
|
-
gl.activeTexture(gl.TEXTURE0);
|
|
2571
|
-
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
2572
|
-
gl.uniform1i(U("u_tex"), 0);
|
|
2573
|
-
let unit = 1;
|
|
2574
|
-
for (const s of solverOps) {
|
|
2575
|
-
const smps = s.def.solver.samplers;
|
|
2576
|
-
for (const key in smps) {
|
|
2577
|
-
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
2578
|
-
gl.bindTexture(gl.TEXTURE_2D, s.targets[smps[key]].a.tex);
|
|
2579
|
-
gl.uniform1i(U(`u${s.i}_${key}`), unit);
|
|
2580
|
-
unit++;
|
|
4318
|
+
snap.dyn.push({ i: d.i, ups: d.def.tick(d.state, ctx) });
|
|
2581
4319
|
}
|
|
2582
4320
|
}
|
|
2583
|
-
|
|
4321
|
+
lastSnap = snap;
|
|
4322
|
+
applyUniforms(U, snap);
|
|
2584
4323
|
lastFrame = now;
|
|
2585
4324
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
2586
4325
|
}
|
|
@@ -2650,6 +4389,22 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2650
4389
|
|
|
2651
4390
|
if (typeof ResizeObserver !== "undefined") {
|
|
2652
4391
|
ro = new ResizeObserver(() => {
|
|
4392
|
+
// SNAPSHOT ONLY. In live mode the host's children have moved
|
|
4393
|
+
// inside the canvas, so the canvas is the host's only in-flow
|
|
4394
|
+
// child and the host's height is padding + canvas + padding.
|
|
4395
|
+
// Measuring the host to size the canvas is therefore circular:
|
|
4396
|
+
// every observation adds the padding back, resizes the canvas,
|
|
4397
|
+
// and re-triggers this observer. A 98px two-column panel walked
|
|
4398
|
+
// to ~4000px on load, growing by its own padding each pass.
|
|
4399
|
+
//
|
|
4400
|
+
// The live path is handled by onWinResize, which measures the
|
|
4401
|
+
// SUBTREE inside the canvas rather than the host — the one
|
|
4402
|
+
// measurement that is not downstream of the value being set.
|
|
4403
|
+
//
|
|
4404
|
+
// Guarded on the current mode rather than by not observing at
|
|
4405
|
+
// all, so a mid-session fallbackToSnapshot re-enables it with
|
|
4406
|
+
// no re-observation.
|
|
4407
|
+
if (mode === "live") return;
|
|
2653
4408
|
syncCanvasBox();
|
|
2654
4409
|
clearTimeout(resizeTimer);
|
|
2655
4410
|
resizeTimer = setTimeout(remeasureAndCapture, 150);
|
|
@@ -2709,10 +4464,122 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2709
4464
|
}
|
|
2710
4465
|
}
|
|
2711
4466
|
|
|
2712
|
-
|
|
4467
|
+
// A param change is DATA changing, and more than one thing may be
|
|
4468
|
+
// showing that data: the inspector panel, a code preview, a future node
|
|
4469
|
+
// editor. Whoever writes a value cannot know who else is displaying it,
|
|
4470
|
+
// so setParam announces rather than expecting callers to coordinate.
|
|
4471
|
+
//
|
|
4472
|
+
// A DOM CustomEvent rather than a subscriber list: consumers come and
|
|
4473
|
+
// go with the page, listeners unregister themselves, and nothing has to
|
|
4474
|
+
// hold a reference to a pipeline that may be rebuilt out from under it.
|
|
4475
|
+
const announce = (index, key, value, prev, how) => {
|
|
4476
|
+
if (typeof CustomEvent !== "function" || typeof document === "undefined") return;
|
|
4477
|
+
document.dispatchEvent(new CustomEvent(RASTER_PARAM_EVENT, {
|
|
4478
|
+
detail: { pipeline: handle, el, index, key, value, prev, how },
|
|
4479
|
+
}));
|
|
4480
|
+
};
|
|
4481
|
+
|
|
4482
|
+
// ── Live introspection (phase H3) ─────────────────────────────────
|
|
4483
|
+
// A param is LIVE if the op reads it in uniforms() — changing it is a
|
|
4484
|
+
// uniform upload. It is STRUCTURAL if the op reads it in code(),
|
|
4485
|
+
// because that value is compiled into the GLSL and the only way to
|
|
4486
|
+
// change it is to build a new shader. See `structural` on each
|
|
4487
|
+
// registry entry; anything unrecognised rebuilds, which is the safe
|
|
4488
|
+
// direction to be wrong in.
|
|
4489
|
+
const handle = {
|
|
2713
4490
|
canvas,
|
|
4491
|
+
// The FLATTENED node list — the one that owns the uniform slots,
|
|
4492
|
+
// so index i here is the `u<i>_` prefix in the shader and what an
|
|
4493
|
+
// inspector must show. Nodes nested in a `merge` appear inline.
|
|
4494
|
+
nodes: rasterNodes,
|
|
4495
|
+
get backend() { return mode; },
|
|
4496
|
+
|
|
4497
|
+
// Phase I1. Where the content visible at a screen point came
|
|
4498
|
+
// from, in client coordinates — the input to hit-testing through
|
|
4499
|
+
// a displacement. Defined below, next to the probe program.
|
|
4500
|
+
sourceAt: (clientX, clientY, opts) => sourceAt(clientX, clientY, opts),
|
|
4501
|
+
|
|
4502
|
+
// Phase T1. Transition progress. `t` is an INPUT, not a timer —
|
|
4503
|
+
// a timeline is one driver of it, scroll scrub and a test are
|
|
4504
|
+
// others. Everything downstream is a pure function of it, which
|
|
4505
|
+
// is what makes transitions deterministic to test and
|
|
4506
|
+
// interruptible for free.
|
|
4507
|
+
get progress() { return progress; },
|
|
4508
|
+
setProgress(t) {
|
|
4509
|
+
const next = Math.min(1, Math.max(0, Number(t) || 0));
|
|
4510
|
+
if (next === progress) return progress;
|
|
4511
|
+
progress = next;
|
|
4512
|
+
uploadKeyframes();
|
|
4513
|
+
// Phase T2, gap 1. The real NEW element is still in the tree
|
|
4514
|
+
// under the canvas, so without this it is visible at t=0 —
|
|
4515
|
+
// the morph would show the destination behind its own start
|
|
4516
|
+
// frame. Suppress its ink for the duration and release at
|
|
4517
|
+
// completion, which is also the moment the DOM becomes the
|
|
4518
|
+
// truth again: selection, hover and video resume on the real
|
|
4519
|
+
// element rather than on a picture of it.
|
|
4520
|
+
//
|
|
4521
|
+
// Driven off `progress` rather than a lifecycle callback, so
|
|
4522
|
+
// it stays a pure function of t (P-1) and a scrub backwards
|
|
4523
|
+
// out of 1 re-hides correctly.
|
|
4524
|
+
syncTransitionView();
|
|
4525
|
+
return progress;
|
|
4526
|
+
},
|
|
4527
|
+
|
|
4528
|
+
/**
|
|
4529
|
+
* Change one param of one node, live where possible.
|
|
4530
|
+
* Returns "uniform" | "rebuild" | false (unknown node).
|
|
4531
|
+
*/
|
|
4532
|
+
setParam(i, key, value) {
|
|
4533
|
+
const node = rasterNodes[i];
|
|
4534
|
+
if (!node || destroyed) return false;
|
|
4535
|
+
const prev = node[key];
|
|
4536
|
+
node[key] = value;
|
|
4537
|
+
|
|
4538
|
+
if (isStructuralChange(node.op, key, value, prev)) {
|
|
4539
|
+
handle.rebuild();
|
|
4540
|
+
announce(i, key, value, prev, "rebuild");
|
|
4541
|
+
return "rebuild";
|
|
4542
|
+
}
|
|
4543
|
+
const def = REGISTRY[node.op];
|
|
4544
|
+
if (def && def.uniforms) {
|
|
4545
|
+
const us = def.uniforms(node, dpr);
|
|
4546
|
+
gl.useProgram(prog);
|
|
4547
|
+
for (const k in us) setUniform(U(`u${i}_${k}`), us[k][0], us[k][1]);
|
|
4548
|
+
// The phase-I1 probe is a SECOND program running the same
|
|
4549
|
+
// chain, so it needs the new value too. Without this it
|
|
4550
|
+
// keeps answering with the parameters it was built with —
|
|
4551
|
+
// and since the inspector drives setParam on every drag,
|
|
4552
|
+
// hit-testing would silently drift away from the picture
|
|
4553
|
+
// the moment anyone tuned anything.
|
|
4554
|
+
if (probe && !probe.broken) {
|
|
4555
|
+
gl.useProgram(probe.prog);
|
|
4556
|
+
for (const k in us) setUniform(probe.U(`u${i}_${k}`), us[k][0], us[k][1]);
|
|
4557
|
+
gl.useProgram(prog);
|
|
4558
|
+
}
|
|
4559
|
+
}
|
|
4560
|
+
announce(i, key, value, prev, "uniform");
|
|
4561
|
+
return "uniform";
|
|
4562
|
+
},
|
|
4563
|
+
|
|
4564
|
+
/**
|
|
4565
|
+
* Tear down and re-apply from the (mutated) node tree. Needed for
|
|
4566
|
+
* anything baked into the shader. `sourceNodes` is the ORIGINAL
|
|
4567
|
+
* tree, merge sub-chains and all — flattening loses that nesting,
|
|
4568
|
+
* and rebuilding from the flat list would silently promote a
|
|
4569
|
+
* merge branch to a top-level op.
|
|
4570
|
+
*/
|
|
4571
|
+
rebuild() {
|
|
4572
|
+
if (destroyed) return null;
|
|
4573
|
+
const host = el;
|
|
4574
|
+
handle.destroy();
|
|
4575
|
+
return applyRasterPipeline(host, sourceNodes);
|
|
4576
|
+
},
|
|
4577
|
+
};
|
|
4578
|
+
ACTIVE.add(handle);
|
|
4579
|
+
return Object.assign(handle, {
|
|
2714
4580
|
refresh: () => (mode === "live" ? onPaint() : snapshotCapture()),
|
|
2715
4581
|
destroy() {
|
|
4582
|
+
ACTIVE.delete(handle);
|
|
2716
4583
|
destroyed = true;
|
|
2717
4584
|
cancelAnimationFrame(raf);
|
|
2718
4585
|
clearTimeout(resizeTimer);
|
|
@@ -2722,6 +4589,11 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2722
4589
|
if (typeof window !== "undefined") window.removeEventListener("resize", onWinResize);
|
|
2723
4590
|
el.removeEventListener("mousemove", onMove);
|
|
2724
4591
|
el.removeEventListener("touchmove", onMove);
|
|
4592
|
+
// Phase I3 listeners are registered in the capture phase, so
|
|
4593
|
+
// they must be removed with the same flag or they leak.
|
|
4594
|
+
for (const t of RETARGET_EVENTS) el.removeEventListener(t, onRetarget, true);
|
|
4595
|
+
el.removeEventListener("pointerleave", onRetargetLeave, true);
|
|
4596
|
+
setHoverChain(null); // never leave the attribute behind
|
|
2725
4597
|
el.removeEventListener("mouseenter", onEnter);
|
|
2726
4598
|
el.removeEventListener("mouseleave", onLeave);
|
|
2727
4599
|
canvas.removeEventListener("paint", onPaint);
|
|
@@ -2730,17 +4602,44 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2730
4602
|
for (const n in s.progs) gl.deleteProgram(s.progs[n].prog);
|
|
2731
4603
|
}
|
|
2732
4604
|
solverOps.length = 0;
|
|
4605
|
+
// The probe pass owns a program, a 1x1 texture and an FBO —
|
|
4606
|
+
// only allocated if something hit-tested.
|
|
4607
|
+
if (probe && !probe.broken) {
|
|
4608
|
+
gl.deleteProgram(probe.prog);
|
|
4609
|
+
gl.deleteFramebuffer(probe.fbo);
|
|
4610
|
+
gl.deleteTexture(probe.tex);
|
|
4611
|
+
}
|
|
4612
|
+
probe = null;
|
|
4613
|
+
if (oldTex) { gl.deleteTexture(oldTex); oldTex = null; }
|
|
4614
|
+
if (newTex) { gl.deleteTexture(newTex); newTex = null; }
|
|
2733
4615
|
while (sourceEl !== el && sourceEl.firstChild) el.appendChild(sourceEl.firstChild);
|
|
2734
4616
|
showHostInk();
|
|
2735
4617
|
el.style.visibility = "";
|
|
2736
4618
|
canvas.remove();
|
|
2737
4619
|
},
|
|
2738
|
-
};
|
|
4620
|
+
});
|
|
2739
4621
|
}
|
|
2740
4622
|
|
|
2741
4623
|
export {
|
|
2742
4624
|
applyRasterPipeline, registerRasterOp, RASTER_OP_NAMES,
|
|
2743
4625
|
isHTMLInCanvasAvailable, DRIVER_NAMES,
|
|
4626
|
+
// Phase H3. The registry is already effectively public — registerRasterOp
|
|
4627
|
+
// mutates it — and a dev tool needs to read an op's stage and params to
|
|
4628
|
+
// show them. `isStructuralChange` is exported so the inspector can label
|
|
4629
|
+
// a control "rebuilds" before the user drags it, rather than after.
|
|
4630
|
+
activeRasterPipelines, isStructuralChange, REGISTRY, RASTER_PARAM_EVENT,
|
|
4631
|
+
// Phase H4. The op contract, as data: the vocabularies a third-party
|
|
4632
|
+
// op is checked against, and the shared params every op inherits from
|
|
4633
|
+
// the pipeline rather than declaring itself.
|
|
4634
|
+
RASTER_STAGES, RASTER_UNITS, FRAMEWORK_DOC, validateRasterOp,
|
|
4635
|
+
// Phase T1. Keyframed params, resolved against the units an op
|
|
4636
|
+
// already declares.
|
|
4637
|
+
isKeyframed, sampleKeyframes, resolveNode,
|
|
4638
|
+
// Phase T3. Choreography: per-node windows and easing over progress.
|
|
4639
|
+
EASINGS, EASING_NAMES, localProgress,
|
|
4640
|
+
// Phase I4. How each op behaves under hit-testing, derived from its
|
|
4641
|
+
// own declarations.
|
|
4642
|
+
interactionClass,
|
|
2744
4643
|
// Exported so which branch a `switch` takes can be asserted directly
|
|
2745
4644
|
// rather than inferred from pixels.
|
|
2746
4645
|
resolveSwitches,
|