nodality 1.0.178 → 1.0.181
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/dist/animator.cjs.js +1 -1
- package/dist/animator.esm.js +1 -1
- package/dist/audionew.cjs.js +1 -1
- package/dist/audionew.esm.js +1 -1
- package/dist/base.cjs.js +1 -1
- package/dist/base.esm.js +1 -1
- package/dist/beta-desktop-bar.cjs.js +1 -1
- package/dist/beta-desktop-bar.esm.js +1 -1
- package/dist/beta-mobile-bar.cjs.js +1 -1
- package/dist/beta-mobile-bar.esm.js +1 -1
- package/dist/bundle.umd.js +1 -1
- package/dist/button.cjs.js +1 -1
- package/dist/button.esm.js +1 -1
- package/dist/center.cjs.js +1 -1
- package/dist/center.esm.js +1 -1
- package/dist/checkbox.cjs.js +1 -1
- package/dist/checkbox.esm.js +1 -1
- package/dist/code.cjs.js +1 -1
- package/dist/code.esm.js +1 -1
- package/dist/container.cjs.js +1 -1
- package/dist/container.esm.js +1 -1
- package/dist/data-list.cjs.js +1 -1
- package/dist/data-list.esm.js +1 -1
- package/dist/designer.cjs.js +1 -1
- package/dist/designer.esm.js +1 -1
- package/dist/dropdown.cjs.js +1 -1
- package/dist/dropdown.esm.js +1 -1
- package/dist/element-mapper.cjs.js +1 -1
- package/dist/element-mapper.esm.js +1 -1
- package/dist/finalresult.esm.js +1 -1
- package/dist/flex-card.cjs.js +1 -1
- package/dist/flex-card.esm.js +1 -1
- package/dist/flex-grid.cjs.js +1 -1
- package/dist/flex-grid.esm.js +1 -1
- package/dist/flex-row.cjs.js +1 -1
- package/dist/flex-row.esm.js +1 -1
- package/dist/floating-input.cjs.js +1 -1
- package/dist/floating-input.esm.js +1 -1
- package/dist/free.cjs.js +1 -1
- package/dist/free.esm.js +1 -1
- package/dist/horizontal-scroller.cjs.js +1 -1
- package/dist/horizontal-scroller.esm.js +1 -1
- package/dist/image-picker.cjs.js +1 -1
- package/dist/image-picker.esm.js +1 -1
- package/dist/image.cjs.js +1 -1
- package/dist/image.esm.js +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/link-getter.cjs.js +1 -1
- package/dist/link-getter.esm.js +1 -1
- package/dist/link.cjs.js +1 -1
- package/dist/link.esm.js +1 -1
- package/dist/meta-adder.cjs.js +1 -1
- package/dist/meta-adder.esm.js +1 -1
- package/dist/modal-2025.cjs.js +1 -1
- package/dist/modal-2025.esm.js +1 -1
- package/dist/multiswitcher.cjs.js +1 -1
- package/dist/multiswitcher.esm.js +1 -1
- package/dist/new-nav-bar.cjs.js +1 -1
- package/dist/new-nav-bar.esm.js +1 -1
- package/dist/picker.cjs.js +1 -1
- package/dist/picker.esm.js +1 -1
- package/dist/progress.cjs.js +1 -1
- package/dist/progress.esm.js +1 -1
- package/dist/radio.cjs.js +1 -1
- package/dist/radio.esm.js +1 -1
- package/dist/range.cjs.js +1 -1
- package/dist/range.esm.js +1 -1
- package/dist/side-bar.cjs.js +1 -1
- package/dist/side-bar.esm.js +1 -1
- package/dist/side-nav-bar.cjs.js +1 -1
- package/dist/side-nav-bar.esm.js +1 -1
- package/dist/simple-bar.cjs.js +1 -1
- package/dist/simple-bar.esm.js +1 -1
- package/dist/slider-2025.cjs.js +1 -1
- package/dist/slider-2025.esm.js +1 -1
- package/dist/spacer.cjs.js +1 -1
- package/dist/spacer.esm.js +1 -1
- package/dist/stack.cjs.js +1 -1
- package/dist/stack.esm.js +1 -1
- package/dist/table.cjs.js +1 -1
- package/dist/table.esm.js +1 -1
- package/dist/text-field.cjs.js +1 -1
- package/dist/text-field.esm.js +1 -1
- package/dist/text.cjs.js +1 -1
- package/dist/text.esm.js +1 -1
- package/dist/ulist.cjs.js +1 -1
- package/dist/ulist.esm.js +1 -1
- package/dist/video.cjs.js +1 -1
- package/dist/video.esm.js +1 -1
- package/dist/wrap.cjs.js +1 -1
- package/dist/wrap.esm.js +1 -1
- package/dist/zoom-card.cjs.js +1 -1
- package/dist/zoom-card.esm.js +1 -1
- package/layout/animator.js +34 -1
- package/layout/audio.js +1 -1
- package/layout/audionew.js +1 -1
- package/layout/base-2.js +1 -1
- package/layout/base.js +1 -1
- package/layout/beta-desktop-bar.js +1 -1
- package/layout/beta-mobile-bar.js +1 -1
- package/layout/box.js +1 -1
- package/layout/button.js +1 -1
- package/layout/cards.js +1 -1
- package/layout/center.js +1 -1
- package/layout/checkbox.js +1 -1
- package/layout/circle.js +1 -1
- package/layout/clean-row.js +1 -1
- package/layout/code.js +1 -1
- package/layout/container.js +1 -1
- package/layout/custom.js +1 -1
- package/layout/div-image.js +1 -1
- package/layout/dropdown-2025.js +1 -1
- package/layout/dropdown.js +1 -1
- package/layout/empty-element.js +1 -1
- package/layout/external-stylesheet.js +1 -1
- package/layout/flex-card.js +1 -1
- package/layout/flex-grid.js +1 -1
- package/layout/flex-row.js +1 -1
- package/layout/footer.js +1 -1
- package/layout/form-components/custom.js +1 -1
- package/layout/form-components/data-list.js +1 -1
- package/layout/form-components/floating-input.js +1 -1
- package/layout/form-components/form-all.js +1 -1
- package/layout/form-components/form.js +1 -1
- package/layout/form-components/image-picker.js +1 -1
- package/layout/form-components/picker.js +1 -1
- package/layout/form-components/radio.js +1 -1
- package/layout/form-components/radiogroup.js +1 -1
- package/layout/form-components/range.js +1 -1
- package/layout/free.js +1 -1
- package/layout/grid-new.js +1 -1
- package/layout/grid-switcher.js +1 -1
- package/layout/grid.js +1 -1
- package/layout/group.js +1 -1
- package/layout/header.js +1 -1
- package/layout/horizontal-scroller.js +1 -1
- package/layout/image-old.js +1 -1
- package/layout/image.js +1 -1
- package/layout/index.js +1 -1
- package/layout/label.js +1 -1
- package/layout/link.js +1 -1
- package/layout/list-OLD.js +1 -1
- package/layout/list.js +1 -1
- package/layout/meta-adder.js +1 -1
- package/layout/modal-2025.js +1 -1
- package/layout/modernwrap.js +1 -1
- package/layout/multiswitcher.js +1 -1
- package/layout/multiswitcherBeta.js +1 -1
- package/layout/nav-bar.js +1 -1
- package/layout/nav-factor/custom-div.js +1 -1
- package/layout/navBar-OLD.js +1 -1
- package/layout/new-flat-adder.js +1 -1
- package/layout/new-nav-bar.js +1 -1
- package/layout/offset-container.js +1 -1
- package/layout/polygon.js +1 -1
- 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/saved-new-nav-bar.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/styler.js +1 -1
- package/layout/svg.js +1 -1
- package/layout/switcher.js +1 -1
- package/layout/table.js +1 -1
- package/layout/text-field.js +1 -1
- package/layout/text.js +5 -2
- package/layout/ulist.js +1 -1
- package/layout/video.js +1 -1
- package/layout/without-new.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/data.js +1 -1
- package/lib/designer.js +17 -2
- package/lib/element-mapper.js +24 -1
- package/lib/keyframe-animation.js +1 -1
- package/lib/link-getter.js +1 -1
- package/lib/raster-ops.js +1251 -0
- package/lib/scroll-video.js +1 -1
- package/lib/seo.js +1 -1
- package/lib/stacker.js +1 -1
- package/lib/theme.js +1 -1
- package/lib/transform-anim.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* nodality v1.0.181
|
|
3
|
+
* (c) 2026 Filip Vabrousek
|
|
4
|
+
* License: MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// raster-ops.js — Houdini-like raster operation pipeline for Nodality.
|
|
8
|
+
//
|
|
9
|
+
// A second op category next to the CSS-level ops in designer.js
|
|
10
|
+
// ("blast", "gradient", ...): raster ops compile the *rendered page*
|
|
11
|
+
// into a WebGL texture and run an ordered chain of GPU stages over it,
|
|
12
|
+
// driven by the same plain-data `nodes` syntax:
|
|
13
|
+
//
|
|
14
|
+
// let nodes = [
|
|
15
|
+
// { op: "hexalize", size: 26 },
|
|
16
|
+
// { op: "offset", by: "mouse", strength: 22, radius: 280 },
|
|
17
|
+
// { op: "duotone", by: "mouse", colors: ["#104B87", "#E8FF00"], radius: 200 },
|
|
18
|
+
// ];
|
|
19
|
+
//
|
|
20
|
+
// Texture backends (capture of the live DOM):
|
|
21
|
+
// snapshot (default) — XMLSerializer -> SVG foreignObject -> Image ->
|
|
22
|
+
// texture. Works in every browser. Nodality output is inline-styled,
|
|
23
|
+
// so fidelity is high. Static: recaptured on resize (or via
|
|
24
|
+
// handle.refresh()). External images / webfonts do not load inside
|
|
25
|
+
// the SVG image context — system fonts and data: URIs only.
|
|
26
|
+
// live (opt-in: any raster node carries `live: true`) — the emerging
|
|
27
|
+
// HTML-in-Canvas API (WICG, Chrome origin trial): the mount subtree
|
|
28
|
+
// moves inside the effect <canvas layoutsubtree> and is uploaded per
|
|
29
|
+
// frame with gl.texElementImage2D(), staying interactive and live.
|
|
30
|
+
// Falls back to snapshot when the API is absent.
|
|
31
|
+
//
|
|
32
|
+
// Safety: this module touches nothing at import time, so it is inert
|
|
33
|
+
// under jsdom prerender (nodality/ssg). applyRasterPipeline() returns
|
|
34
|
+
// null (never throws) when WebGL / DOM / motion are unavailable.
|
|
35
|
+
|
|
36
|
+
// ── Op registry ──────────────────────────────────────────────────────
|
|
37
|
+
// Each op contributes GLSL to one stage of a fixed frame skeleton:
|
|
38
|
+
// warp — displace the coordinate space itself (warped) e.g. offset;
|
|
39
|
+
// runs BEFORE the cell stage so grids/edges move WITH the
|
|
40
|
+
// content instead of staying a fixed screen-space lattice
|
|
41
|
+
// cell — redefine the sampling cell (center, edge) e.g. hexalize
|
|
42
|
+
// displace — move only the sample position (sampleP)
|
|
43
|
+
// color — transform the sampled colour (col) e.g. duotone
|
|
44
|
+
// Ops execute in nodes-array order within their stage. Uniforms are
|
|
45
|
+
// namespaced per node index so the same op can appear twice.
|
|
46
|
+
|
|
47
|
+
const MAX_BLOBS = 12;
|
|
48
|
+
|
|
49
|
+
const REGISTRY = {
|
|
50
|
+
hexalize: {
|
|
51
|
+
stage: "cell",
|
|
52
|
+
decl: (p) => `uniform float ${p}size;`,
|
|
53
|
+
code: (p) => `
|
|
54
|
+
{
|
|
55
|
+
vec2 hp = warped / ${p}size;
|
|
56
|
+
vec2 hr = vec2(1.0, 1.7320508), hh = hr * 0.5;
|
|
57
|
+
vec2 ha = mod(hp, hr) - hh, hb = mod(hp - hh, hr) - hh;
|
|
58
|
+
vec2 gv = dot(ha, ha) < dot(hb, hb) ? ha : hb;
|
|
59
|
+
center = (hp - gv) * ${p}size;
|
|
60
|
+
gv = abs(gv);
|
|
61
|
+
edge = max(edge, smoothstep(0.44, 0.5,
|
|
62
|
+
max(dot(gv, normalize(vec2(1.0, 1.7320508))), gv.x)));
|
|
63
|
+
}`,
|
|
64
|
+
uniforms: (node, dpr) => ({ size: ["1f", (node.size || 24) * dpr] }),
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
offset: {
|
|
68
|
+
// Warp-stage: displaces the coordinate space before the cell
|
|
69
|
+
// grid is computed, so tiles — content AND borders — move
|
|
70
|
+
// together away from the pointer. (The old displace-stage
|
|
71
|
+
// version shifted only the texture lookup, which left grid
|
|
72
|
+
// edges frozen in screen space.)
|
|
73
|
+
stage: "warp",
|
|
74
|
+
decl: (p) => `uniform float ${p}strength; uniform float ${p}radius;`,
|
|
75
|
+
code: (p) => `
|
|
76
|
+
{
|
|
77
|
+
float d = length(warped - u_mouse);
|
|
78
|
+
float fall = 1.0 - smoothstep(0.0, ${p}radius, d);
|
|
79
|
+
vec2 dir = d > 0.5 ? (warped - u_mouse) / d : vec2(0.0);
|
|
80
|
+
warped -= dir * ${p}strength * fall;
|
|
81
|
+
}`,
|
|
82
|
+
uniforms: (node, dpr) => ({
|
|
83
|
+
strength: ["1f", (node.strength || 20) * dpr],
|
|
84
|
+
radius: ["1f", (node.radius || 260) * dpr],
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
duotone: {
|
|
89
|
+
stage: "color",
|
|
90
|
+
decl: (p) => `uniform vec3 ${p}a; uniform vec3 ${p}b; uniform float ${p}radius;`,
|
|
91
|
+
code: (p, node) => `
|
|
92
|
+
{
|
|
93
|
+
float lum = dot(col, vec3(0.299, 0.587, 0.114));
|
|
94
|
+
vec3 duo = mix(${p}a, ${p}b, lum);
|
|
95
|
+
${node.by === "mouse"
|
|
96
|
+
? `float mask = 1.0 - smoothstep(${p}radius * 0.35, ${p}radius, length(center - u_mouse));`
|
|
97
|
+
: `float mask = 1.0;`}
|
|
98
|
+
col = mix(col, duo, mask);
|
|
99
|
+
}`,
|
|
100
|
+
uniforms: (node, dpr) => {
|
|
101
|
+
const cols = node.colors || ["#104B87", "#E8FF00"];
|
|
102
|
+
return {
|
|
103
|
+
a: ["3fv", hexToRgb(cols[0])],
|
|
104
|
+
b: ["3fv", hexToRgb(cols[1])],
|
|
105
|
+
radius: ["1f", (node.radius || 220) * dpr],
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// Styles the cell seams produced by the cell stage (hexalize et al)
|
|
111
|
+
// as a separate BORDER LAYER (edgeCol / edgeCov) rather than mixing
|
|
112
|
+
// into the content. The final composite draws the element's content
|
|
113
|
+
// (the text) OVER this layer, so borders sit below the glyphs:
|
|
114
|
+
// e.g. { op: "edges", color: "#FFFFFF" } gives white borders under
|
|
115
|
+
// the text. When present it also replaces the default seam darkening.
|
|
116
|
+
edges: {
|
|
117
|
+
stage: "color",
|
|
118
|
+
decl: (p) => `uniform vec3 ${p}color; uniform float ${p}strength;`,
|
|
119
|
+
code: (p) => `
|
|
120
|
+
{
|
|
121
|
+
edgeCol = ${p}color;
|
|
122
|
+
edgeCov = max(edgeCov, edge * ${p}strength);
|
|
123
|
+
}`,
|
|
124
|
+
uniforms: (node) => ({
|
|
125
|
+
color: ["3fv", hexToRgb(node.color || "#FFFFFF")],
|
|
126
|
+
strength: ["1f", node.strength != null ? node.strength : 1.0],
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// Stirred liquid: the content swirls as if it were paint being
|
|
131
|
+
// stirred with a spoon. Unlike every other op here this one is
|
|
132
|
+
// STATEFUL — it owns a low-res vector field (a ping-pong pair of
|
|
133
|
+
// render targets) that evolves frame to frame; the main pass just
|
|
134
|
+
// samples the finished field. See the `field` descriptor below and
|
|
135
|
+
// the field-simulation block in applyRasterPipeline().
|
|
136
|
+
//
|
|
137
|
+
// Formulation (deliberately not the usual Stable-Fluids solver):
|
|
138
|
+
// the classic GPU fluid pipeline is advect + vorticity confinement +
|
|
139
|
+
// a divergence/pressure Jacobi solve (Stam 1999; Harris, GPU Gems
|
|
140
|
+
// 2003) — roughly ten passes. This op instead advects momentum along
|
|
141
|
+
// its own flow (semi-Lagrangian backtrace, the one genuinely
|
|
142
|
+
// universal piece) and then ROTATES each momentum vector by the
|
|
143
|
+
// local curl angle. Rotation preserves magnitude, so eddies keep
|
|
144
|
+
// spinning instead of being pushed around, and because it cannot
|
|
145
|
+
// inject divergence energy the field stays stable with no pressure
|
|
146
|
+
// projection at all — the whole simulation is a single pass.
|
|
147
|
+
//
|
|
148
|
+
// { op: "stir", strength: 26, swirl: 2.4, decay: 0.985 }
|
|
149
|
+
stir: {
|
|
150
|
+
// Warp: swirl the coordinate space (so a cell op downstream tiles
|
|
151
|
+
// the ALREADY-stirred content). Color: a faint moving sheen.
|
|
152
|
+
stage: ["warp", "color"],
|
|
153
|
+
decl: (p) => `
|
|
154
|
+
uniform sampler2D ${p}field;
|
|
155
|
+
uniform float ${p}strength;
|
|
156
|
+
uniform float ${p}sheen;
|
|
157
|
+
uniform vec3 ${p}tint;
|
|
158
|
+
uniform float ${p}enc;
|
|
159
|
+
uniform float ${p}range;
|
|
160
|
+
vec2 ${p}flow(vec2 uvp) {
|
|
161
|
+
vec4 t = texture2D(${p}field, clamp(uvp, 0.001, 0.999));
|
|
162
|
+
return ${p}enc > 0.5 ? (t.xy * 2.0 - 1.0) * ${p}range : t.xy;
|
|
163
|
+
}`,
|
|
164
|
+
code: (p, node, stage) => stage === "warp" ? `
|
|
165
|
+
{
|
|
166
|
+
// frag/warped are top-down pixels; the field is bottom-up
|
|
167
|
+
// [0,1] — flip y going in, and flip the y of the vector
|
|
168
|
+
// coming out so content drags WITH the pointer.
|
|
169
|
+
vec2 fuv = vec2(warped.x / u_res.x, 1.0 - warped.y / u_res.y);
|
|
170
|
+
vec2 m = ${p}flow(fuv);
|
|
171
|
+
warped += vec2(m.x, -m.y) * ${p}strength;
|
|
172
|
+
}` : `
|
|
173
|
+
{
|
|
174
|
+
// Moving liquid catches light: brighten toward the tint
|
|
175
|
+
// where the flow is fast. Sampled at the cell centre so it
|
|
176
|
+
// stays flat per tile when a cell op runs upstream.
|
|
177
|
+
vec2 suv = vec2(center.x / u_res.x, 1.0 - center.y / u_res.y);
|
|
178
|
+
float sm = length(${p}flow(suv));
|
|
179
|
+
col = mix(col, ${p}tint, (1.0 - exp(-sm * 5.0)) * ${p}sheen);
|
|
180
|
+
}`,
|
|
181
|
+
uniforms: (node, dpr) => ({
|
|
182
|
+
strength: ["1f", (node.strength != null ? node.strength : 26) * dpr],
|
|
183
|
+
sheen: ["1f", node.sheen != null ? node.sheen : 0.25],
|
|
184
|
+
tint: ["3fv", hexToRgb(node.tint || "#BFE9FF")],
|
|
185
|
+
}),
|
|
186
|
+
// Stateful simulation. The pipeline creates the ping-pong pair,
|
|
187
|
+
// compiles this fragment source, and feeds s_point / s_impulse /
|
|
188
|
+
// s_dt every frame; everything else comes from `uniforms` here.
|
|
189
|
+
field: {
|
|
190
|
+
frag: () => `
|
|
191
|
+
precision highp float;
|
|
192
|
+
uniform sampler2D s_field;
|
|
193
|
+
uniform vec2 s_texel;
|
|
194
|
+
uniform float s_dt;
|
|
195
|
+
uniform float s_decay;
|
|
196
|
+
uniform float s_swirl;
|
|
197
|
+
uniform float s_advect;
|
|
198
|
+
uniform vec2 s_point;
|
|
199
|
+
uniform vec2 s_impulse;
|
|
200
|
+
uniform float s_radius;
|
|
201
|
+
uniform float s_aspect;
|
|
202
|
+
uniform float s_idle;
|
|
203
|
+
uniform float s_time;
|
|
204
|
+
uniform float s_enc;
|
|
205
|
+
uniform float s_range;
|
|
206
|
+
|
|
207
|
+
vec2 rd(vec2 uvp) {
|
|
208
|
+
vec4 t = texture2D(s_field, clamp(uvp, 0.0, 1.0));
|
|
209
|
+
return s_enc > 0.5 ? (t.xy * 2.0 - 1.0) * s_range : t.xy;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
void main() {
|
|
213
|
+
vec2 uv = gl_FragCoord.xy * s_texel;
|
|
214
|
+
vec2 m = rd(uv);
|
|
215
|
+
|
|
216
|
+
// 1. Semi-Lagrangian backtrace: momentum rides its own flow.
|
|
217
|
+
vec2 a = rd(uv - m * s_advect * s_dt);
|
|
218
|
+
|
|
219
|
+
// 2. Curl of the field, normalised so the swirl setting is
|
|
220
|
+
// resolution independent (central differences over two texels).
|
|
221
|
+
float r = rd(uv + vec2(s_texel.x, 0.0)).y;
|
|
222
|
+
float l = rd(uv - vec2(s_texel.x, 0.0)).y;
|
|
223
|
+
float t = rd(uv + vec2(0.0, s_texel.y)).x;
|
|
224
|
+
float b = rd(uv - vec2(0.0, s_texel.y)).x;
|
|
225
|
+
float curl = ((r - l) - (t - b)) * 0.5 / s_texel.y;
|
|
226
|
+
|
|
227
|
+
// 3. Rotate momentum by the local curl angle. Norm preserving, so
|
|
228
|
+
// eddies spin and survive rather than smearing out — this is
|
|
229
|
+
// what replaces vorticity confinement + pressure projection.
|
|
230
|
+
float th = curl * s_swirl * s_dt;
|
|
231
|
+
float cs = cos(th), sn = sin(th);
|
|
232
|
+
a = mat2(cs, sn, -sn, cs) * a;
|
|
233
|
+
|
|
234
|
+
// 4. Viscous decay (frame-rate independent).
|
|
235
|
+
a *= pow(s_decay, s_dt * 60.0);
|
|
236
|
+
|
|
237
|
+
// 5. The spoon: a gaussian impulse of pointer velocity.
|
|
238
|
+
vec2 d = uv - s_point;
|
|
239
|
+
d.x *= s_aspect;
|
|
240
|
+
a += s_impulse * exp(-dot(d, d) / max(s_radius * s_radius, 1e-6));
|
|
241
|
+
|
|
242
|
+
// 6. Optional hands-free stir: a source orbiting the centre.
|
|
243
|
+
if (s_idle > 0.0) {
|
|
244
|
+
float ia = s_time * 0.6;
|
|
245
|
+
vec2 ic = vec2(0.5 + 0.22 * cos(ia), 0.5 + 0.22 * sin(ia));
|
|
246
|
+
vec2 id = uv - ic;
|
|
247
|
+
id.x *= s_aspect;
|
|
248
|
+
a += vec2(-sin(ia), cos(ia)) * s_idle *
|
|
249
|
+
exp(-dot(id, id) / max(s_radius * s_radius, 1e-6)) * s_dt * 60.0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Keep the field inside the encodable range.
|
|
253
|
+
float mg = length(a);
|
|
254
|
+
if (mg > s_range) a *= s_range / mg;
|
|
255
|
+
|
|
256
|
+
gl_FragColor = s_enc > 0.5
|
|
257
|
+
? vec4(a / s_range * 0.5 + 0.5, 0.0, 1.0)
|
|
258
|
+
: vec4(a, 0.0, 1.0);
|
|
259
|
+
}`,
|
|
260
|
+
uniforms: (node) => ({
|
|
261
|
+
s_decay: ["1f", node.decay != null ? node.decay : 0.985],
|
|
262
|
+
s_swirl: ["1f", node.swirl != null ? node.swirl : 2.4],
|
|
263
|
+
s_advect: ["1f", node.advect != null ? node.advect : 0.35],
|
|
264
|
+
s_radius: ["1f", node.radius != null ? node.radius : 0.10],
|
|
265
|
+
s_idle: ["1f", node.idle != null ? node.idle : 0.0],
|
|
266
|
+
}),
|
|
267
|
+
force: (node) => (node.force != null ? node.force : 1.0),
|
|
268
|
+
resolution: (node) => Math.max(32, Math.min(node.resolution || 192, 512)),
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
// Liquid metaballs: a set of moving circles summed into a scalar
|
|
273
|
+
// field; where the field crosses a threshold is "inside" a blob, and
|
|
274
|
+
// neighbouring blobs fuse smoothly (the gooey / liquid look). One
|
|
275
|
+
// blob tracks the pointer; the rest float freely and bounce off the
|
|
276
|
+
// edges. Rendered as a refractive glassy overlay (lens distortion of
|
|
277
|
+
// the content beneath + a rim light + a soft specular glint).
|
|
278
|
+
//
|
|
279
|
+
// Own implementation of the classic metaball technique: the field is
|
|
280
|
+
// Sum(r_i^2 / |p - c_i|^2), and the surface normal is its analytic
|
|
281
|
+
// gradient. init()/tick() run the particle sim on the CPU and feed
|
|
282
|
+
// the positions in as a per-frame uniform array.
|
|
283
|
+
blobs: {
|
|
284
|
+
stage: "color",
|
|
285
|
+
overlay: true,
|
|
286
|
+
mips: true,
|
|
287
|
+
decl: (p) => `
|
|
288
|
+
uniform vec3 ${p}blobs[${MAX_BLOBS}];
|
|
289
|
+
uniform int ${p}count;
|
|
290
|
+
uniform vec3 ${p}tint;
|
|
291
|
+
uniform float ${p}refract;
|
|
292
|
+
uniform float ${p}rim;
|
|
293
|
+
uniform float ${p}alpha;
|
|
294
|
+
uniform float ${p}irid;
|
|
295
|
+
uniform float ${p}disp;
|
|
296
|
+
uniform float ${p}frost;
|
|
297
|
+
float ${p}hash(vec3 v) {
|
|
298
|
+
return fract(sin(dot(v, vec3(21.9898, 63.233, 41.719))) * 41739.317);
|
|
299
|
+
}
|
|
300
|
+
float ${p}noise(vec3 v) {
|
|
301
|
+
vec3 f = floor(v);
|
|
302
|
+
vec3 r = v - f;
|
|
303
|
+
r = r * r * (3.0 - 2.0 * r);
|
|
304
|
+
float c000 = ${p}hash(f);
|
|
305
|
+
float c100 = ${p}hash(f + vec3(1.0, 0.0, 0.0));
|
|
306
|
+
float c010 = ${p}hash(f + vec3(0.0, 1.0, 0.0));
|
|
307
|
+
float c110 = ${p}hash(f + vec3(1.0, 1.0, 0.0));
|
|
308
|
+
float c001 = ${p}hash(f + vec3(0.0, 0.0, 1.0));
|
|
309
|
+
float c101 = ${p}hash(f + vec3(1.0, 0.0, 1.0));
|
|
310
|
+
float c011 = ${p}hash(f + vec3(0.0, 1.0, 1.0));
|
|
311
|
+
float c111 = ${p}hash(f + vec3(1.0, 1.0, 1.0));
|
|
312
|
+
return mix(
|
|
313
|
+
mix(mix(c000, c100, r.x), mix(c010, c110, r.x), r.y),
|
|
314
|
+
mix(mix(c001, c101, r.x), mix(c011, c111, r.x), r.y), r.z);
|
|
315
|
+
}`,
|
|
316
|
+
code: (p) => `
|
|
317
|
+
{
|
|
318
|
+
float field = 0.0;
|
|
319
|
+
vec2 grad = vec2(0.0);
|
|
320
|
+
for (int bi = 0; bi < ${MAX_BLOBS}; bi++) {
|
|
321
|
+
if (bi >= ${p}count) break;
|
|
322
|
+
vec2 bd = frag - ${p}blobs[bi].xy;
|
|
323
|
+
float br = ${p}blobs[bi].z;
|
|
324
|
+
float dd = max(dot(bd, bd), 1.0);
|
|
325
|
+
field += (br * br) / dd;
|
|
326
|
+
grad += (-2.0 * (br * br) / (dd * dd)) * bd;
|
|
327
|
+
}
|
|
328
|
+
float bmask = smoothstep(0.6, 1.0, field);
|
|
329
|
+
if (bmask > 0.003) {
|
|
330
|
+
float gmag = length(grad);
|
|
331
|
+
vec2 g2 = gmag > 1e-6 ? -grad / gmag : vec2(0.0);
|
|
332
|
+
// Pseudo-3D surface normal from the 2D field. For a
|
|
333
|
+
// metaball field (sum of r^2/d^2), 1/sqrt(field) IS the
|
|
334
|
+
// normalized radial position: 0 at the centre, 1 at the
|
|
335
|
+
// surface. That yields a true hemisphere profile — the
|
|
336
|
+
// curvature (and therefore refraction) varies smoothly
|
|
337
|
+
// across the WHOLE disc, not just an edge annulus.
|
|
338
|
+
float s = clamp(inversesqrt(max(field, 1e-4)), 0.0, 1.0);
|
|
339
|
+
vec3 n = normalize(vec3(g2 * s, sqrt(max(1.0 - s * s, 0.004))));
|
|
340
|
+
vec3 rd = vec3(0.0, 0.0, -1.0);
|
|
341
|
+
|
|
342
|
+
// Dispersion: refract each channel with a slightly
|
|
343
|
+
// different index (water-glass ~1.33). Near the rim the
|
|
344
|
+
// exit direction flattens (|z| -> 0), so the sampling
|
|
345
|
+
// offsets stretch — producing the smooth concentric
|
|
346
|
+
// colour-fringed bands of a thick lens.
|
|
347
|
+
float ca = 0.03 * ${p}disp;
|
|
348
|
+
vec3 tR = refract(rd, n, 1.0 / (1.33 - ca));
|
|
349
|
+
vec3 tG = refract(rd, n, 1.0 / 1.33);
|
|
350
|
+
vec3 tB = refract(rd, n, 1.0 / (1.33 + ca));
|
|
351
|
+
vec2 oR = tR.xy * (${p}refract / max(abs(tR.z), 0.3));
|
|
352
|
+
vec2 oG = tG.xy * (${p}refract / max(abs(tG.z), 0.3));
|
|
353
|
+
vec2 oB = tB.xy * (${p}refract / max(abs(tB.z), 0.3));
|
|
354
|
+
|
|
355
|
+
// Blur grows with how far the ray was bent (mip bias), so
|
|
356
|
+
// strongly-refracted regions smear smoothly instead of
|
|
357
|
+
// showing sharp displaced copies. frost adds a base blur.
|
|
358
|
+
float lod = ${p}frost * 5.0 + log2(1.0 + length(oG) * 0.035);
|
|
359
|
+
vec2 px = 1.0 / u_res;
|
|
360
|
+
vec4 sG = texture2D(u_tex, clamp(uv + oG * px, 0.002, 0.998), lod);
|
|
361
|
+
vec3 refr;
|
|
362
|
+
refr.r = texture2D(u_tex, clamp(uv + oR * px, 0.002, 0.998), lod).r;
|
|
363
|
+
refr.g = sG.g;
|
|
364
|
+
refr.b = texture2D(u_tex, clamp(uv + oB * px, 0.002, 0.998), lod).b;
|
|
365
|
+
// Blend in (approximately) linear light for clean fringes.
|
|
366
|
+
refr = pow(max(refr, 0.0), vec3(2.2));
|
|
367
|
+
|
|
368
|
+
vec3 tintLin = pow(${p}tint, vec3(2.2));
|
|
369
|
+
vec3 bodyLin = mix(tintLin * 0.35, refr, sG.a);
|
|
370
|
+
|
|
371
|
+
// Glass darkens toward the silhouette (absorption), the
|
|
372
|
+
// opposite of an additive glow ring.
|
|
373
|
+
float edge = pow(1.0 - clamp(n.z, 0.0, 1.0), 1.5);
|
|
374
|
+
bodyLin *= 1.0 - 0.35 * ${p}rim * edge;
|
|
375
|
+
vec3 body = pow(max(bodyLin, 0.0), vec3(1.0 / 2.2));
|
|
376
|
+
|
|
377
|
+
// Sparse iridescent glints: two drifting value-noise
|
|
378
|
+
// fields sampled on the reflection direction, one warm
|
|
379
|
+
// one cool, sharpened hard so only sparkles survive.
|
|
380
|
+
vec3 rf = reflect(rd, n);
|
|
381
|
+
float nA = ${p}noise(rf * 2.3 + u_time * 0.5);
|
|
382
|
+
float nB = ${p}noise(rf * 2.3 - u_time * 0.5);
|
|
383
|
+
vec3 glint = pow(max(vec3(0.30, 0.52, 0.86) * nA +
|
|
384
|
+
vec3(0.80, 0.56, 0.34) * nB, 0.0), vec3(6.0));
|
|
385
|
+
body += glint * ${p}irid;
|
|
386
|
+
|
|
387
|
+
// Small hard specular from a fixed key light.
|
|
388
|
+
vec3 L = normalize(vec3(-0.5, 0.7, 0.6));
|
|
389
|
+
float spec = pow(max(dot(reflect(-L, n), vec3(0.0, 0.0, 1.0)), 0.0), 60.0);
|
|
390
|
+
body += spec * 0.35;
|
|
391
|
+
|
|
392
|
+
ovCol = body;
|
|
393
|
+
ovA = max(ovA, bmask * ${p}alpha);
|
|
394
|
+
}
|
|
395
|
+
}`,
|
|
396
|
+
uniforms: (node, dpr) => ({
|
|
397
|
+
tint: ["3fv", hexToRgb(node.color || "#EAF4EE")],
|
|
398
|
+
refract: ["1f", (node.refract != null ? node.refract : 70) * dpr],
|
|
399
|
+
rim: ["1f", node.rim != null ? node.rim : 0.6],
|
|
400
|
+
alpha: ["1f", node.alpha != null ? node.alpha : 1.0],
|
|
401
|
+
irid: ["1f", node.iridescence != null ? node.iridescence : 0.8],
|
|
402
|
+
disp: ["1f", node.dispersion != null ? node.dispersion : 1.0],
|
|
403
|
+
frost: ["1f", node.frost != null ? node.frost : 0.25],
|
|
404
|
+
}),
|
|
405
|
+
// CPU particle sim. The cursor blob is a TRAIL of shrinking
|
|
406
|
+
// spheres laid along recent pointer positions: when the pointer
|
|
407
|
+
// rests they coincide (radii normalized so the union is exactly
|
|
408
|
+
// the base circle), when it moves they spread into a gooey
|
|
409
|
+
// teardrop — the kinetic deformation. Extra blobs float freely.
|
|
410
|
+
init: (node, dpr, w, h) => {
|
|
411
|
+
const R = (node.radius || 46) * dpr;
|
|
412
|
+
const nTrail = Math.max(1, Math.min(node.trail != null ? node.trail : 7, MAX_BLOBS));
|
|
413
|
+
// Two radius profiles per trail slot, blended by how stretched
|
|
414
|
+
// the chain currently is:
|
|
415
|
+
// idle — normalized so coincident spheres union to exactly R
|
|
416
|
+
// moving — near-full-size spheres, so the spread chain reads
|
|
417
|
+
// as a fat elastic tube instead of a bead string
|
|
418
|
+
const idleW = [];
|
|
419
|
+
let s2 = 0;
|
|
420
|
+
for (let i = 0; i < nTrail; i++) {
|
|
421
|
+
const wgt = Math.pow(0.86, i);
|
|
422
|
+
idleW.push(wgt);
|
|
423
|
+
s2 += wgt * wgt;
|
|
424
|
+
}
|
|
425
|
+
const norm = Math.sqrt(s2);
|
|
426
|
+
const trailIdleR = idleW.map((wgt) => (R * wgt) / norm);
|
|
427
|
+
const trailMoveR = idleW.map((_, i) => R * 0.85 * Math.pow(0.955, i));
|
|
428
|
+
|
|
429
|
+
const rand = (a, b) => a + Math.random() * (b - a);
|
|
430
|
+
const nDrift = Math.max(0, Math.min((node.count || 1) - 1, MAX_BLOBS - nTrail));
|
|
431
|
+
const drifters = [];
|
|
432
|
+
for (let i = 0; i < nDrift; i++) {
|
|
433
|
+
const ang = rand(0, Math.PI * 2);
|
|
434
|
+
const spd = rand(30, 80) * dpr;
|
|
435
|
+
drifters.push({
|
|
436
|
+
x: rand(R, w - R), y: rand(R, h - R),
|
|
437
|
+
r: R * rand(0.45, 0.8),
|
|
438
|
+
vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
|
|
439
|
+
phase: rand(0, Math.PI * 2),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
const head = { x: w * 0.5, y: h * 0.55 };
|
|
443
|
+
// The chain: one node per trail sphere, all seeded on the head
|
|
444
|
+
// so it starts as a single collapsed circle. seg is the rest
|
|
445
|
+
// gap the tail relaxes toward the leader over.
|
|
446
|
+
const chain = [];
|
|
447
|
+
for (let i = 0; i < nTrail; i++) chain.push({ x: head.x, y: head.y });
|
|
448
|
+
return {
|
|
449
|
+
head, chain, nTrail, drifters,
|
|
450
|
+
trailIdleR, trailMoveR,
|
|
451
|
+
stretch: 0, seg: R * 0.9,
|
|
452
|
+
};
|
|
453
|
+
},
|
|
454
|
+
tick: (state, ctx) => {
|
|
455
|
+
const dt = Math.min(ctx.dt, 0.05);
|
|
456
|
+
const w = ctx.w, h = ctx.h;
|
|
457
|
+
const k = 1 - Math.exp(-dt * 9);
|
|
458
|
+
state.head.x += (ctx.mouseX - state.head.x) * k;
|
|
459
|
+
state.head.y += (ctx.mouseY - state.head.y) * k;
|
|
460
|
+
|
|
461
|
+
// Follow-the-leader: node 0 pins to the head, each following
|
|
462
|
+
// node eases toward the one ahead. At rest the whole chain
|
|
463
|
+
// relaxes onto the head (a single circle); while the pointer
|
|
464
|
+
// moves the tail lags behind and the chain spreads out — that
|
|
465
|
+
// spread is the stretch we measure below.
|
|
466
|
+
const chain = state.chain;
|
|
467
|
+
chain[0].x = state.head.x;
|
|
468
|
+
chain[0].y = state.head.y;
|
|
469
|
+
const follow = 1 - Math.exp(-dt * 16);
|
|
470
|
+
for (let i = 1; i < chain.length; i++) {
|
|
471
|
+
const lead = chain[i - 1], node = chain[i];
|
|
472
|
+
let dx = lead.x - node.x, dy = lead.y - node.y;
|
|
473
|
+
const d = Math.hypot(dx, dy) || 1e-4;
|
|
474
|
+
// Cap the lag so a fast flick can't tear the tube apart,
|
|
475
|
+
// then ease the remainder — gives an elastic pull.
|
|
476
|
+
const maxLag = state.seg;
|
|
477
|
+
if (d > maxLag) { node.x += dx * (1 - maxLag / d); node.y += dy * (1 - maxLag / d); }
|
|
478
|
+
node.x += (lead.x - node.x) * follow;
|
|
479
|
+
node.y += (lead.y - node.y) * follow;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Stretch = how far the tail trails the head, normalised by the
|
|
483
|
+
// chain's natural reach. Smoothed so radii swell/settle softly.
|
|
484
|
+
const tail = chain[chain.length - 1];
|
|
485
|
+
const spread = Math.hypot(tail.x - state.head.x, tail.y - state.head.y);
|
|
486
|
+
const reach = Math.max(1, state.seg * (state.nTrail - 1));
|
|
487
|
+
const target = Math.min(1, spread / reach);
|
|
488
|
+
state.stretch += (target - state.stretch) * (1 - Math.exp(-dt * 8));
|
|
489
|
+
|
|
490
|
+
for (const b of state.drifters) {
|
|
491
|
+
b.x += b.vx * dt;
|
|
492
|
+
b.y += b.vy * dt;
|
|
493
|
+
if (b.x < b.r) { b.x = b.r; b.vx = Math.abs(b.vx); }
|
|
494
|
+
else if (b.x > w - b.r) { b.x = w - b.r; b.vx = -Math.abs(b.vx); }
|
|
495
|
+
if (b.y < b.r) { b.y = b.r; b.vy = Math.abs(b.vy); }
|
|
496
|
+
else if (b.y > h - b.r) { b.y = h - b.r; b.vy = -Math.abs(b.vy); }
|
|
497
|
+
b.phase += dt * 1.6;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const arr = new Float32Array(MAX_BLOBS * 3);
|
|
501
|
+
const st = state.stretch;
|
|
502
|
+
for (let i = 0; i < state.nTrail; i++) {
|
|
503
|
+
const p = chain[i];
|
|
504
|
+
arr[i * 3] = p.x;
|
|
505
|
+
arr[i * 3 + 1] = p.y;
|
|
506
|
+
// Idle → normalised radii (spheres union to exactly R);
|
|
507
|
+
// stretched → near-full radii so the spread reads as a fat
|
|
508
|
+
// elastic tube rather than a string of shrinking beads.
|
|
509
|
+
arr[i * 3 + 2] = state.trailIdleR[i] + (state.trailMoveR[i] - state.trailIdleR[i]) * st;
|
|
510
|
+
}
|
|
511
|
+
state.drifters.forEach((b, j) => {
|
|
512
|
+
const i = state.nTrail + j;
|
|
513
|
+
arr[i * 3] = b.x;
|
|
514
|
+
arr[i * 3 + 1] = b.y;
|
|
515
|
+
arr[i * 3 + 2] = b.r * (1.0 + 0.12 * Math.sin(b.phase));
|
|
516
|
+
});
|
|
517
|
+
return [
|
|
518
|
+
{ name: "blobs", kind: "3fv", value: arr },
|
|
519
|
+
{ name: "count", kind: "1i", value: state.nTrail + state.drifters.length },
|
|
520
|
+
];
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
function hexToRgb(c) {
|
|
526
|
+
return [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16) / 255);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Names designer.js uses to route nodes into this pipeline instead of
|
|
530
|
+
// the CSS-level op loop. Kept as a live array so registerRasterOp()
|
|
531
|
+
// extends routing too.
|
|
532
|
+
const RASTER_OP_NAMES = Object.keys(REGISTRY);
|
|
533
|
+
|
|
534
|
+
// Extension surface — mirrors the CSS-level operation registry:
|
|
535
|
+
// registerRasterOp("pixelate", { stage, decl, code, uniforms }).
|
|
536
|
+
function registerRasterOp(name, def) {
|
|
537
|
+
REGISTRY[name] = def;
|
|
538
|
+
if (!RASTER_OP_NAMES.includes(name)) RASTER_OP_NAMES.push(name);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function isHTMLInCanvasAvailable() {
|
|
542
|
+
if (typeof WebGLRenderingContext === "undefined") return false;
|
|
543
|
+
return "texElementImage2D" in WebGLRenderingContext.prototype;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ── Shader assembly ──────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
const VS = `attribute vec2 a; void main(){ gl_Position = vec4(a, 0.0, 1.0); }`;
|
|
549
|
+
|
|
550
|
+
function buildFragmentShader(nodes) {
|
|
551
|
+
let decls = "", warp = "", cell = "", displace = "", color = "";
|
|
552
|
+
nodes.forEach((node, i) => {
|
|
553
|
+
const def = REGISTRY[node.op];
|
|
554
|
+
if (!def) return;
|
|
555
|
+
const p = `u${i}_`;
|
|
556
|
+
decls += def.decl(p, node) + "\n";
|
|
557
|
+
// An op may contribute to more than one stage (stage: ["warp",
|
|
558
|
+
// "color"]) — code() then receives the stage it is emitting for.
|
|
559
|
+
// A plain string stage keeps the original single-snippet form.
|
|
560
|
+
const stages = Array.isArray(def.stage) ? def.stage : [def.stage];
|
|
561
|
+
stages.forEach((st) => {
|
|
562
|
+
const snippet = def.code(p, node, st) + "\n";
|
|
563
|
+
if (st === "warp") warp += snippet;
|
|
564
|
+
else if (st === "cell") cell += snippet;
|
|
565
|
+
else if (st === "displace") displace += snippet;
|
|
566
|
+
else color += snippet;
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
// Compositing, in three layers (all straight-alpha):
|
|
570
|
+
// 1. border layer (edgeCol/edgeCov, from an edges op) UNDER
|
|
571
|
+
// 2. content layer (col over tex.a) then
|
|
572
|
+
// 3. overlay layer (ovCol/ovA, from an overlay op e.g. blobs) ON TOP
|
|
573
|
+
// Absent ops leave their contribution at zero, so this reduces to
|
|
574
|
+
// the plain content for every prior combination. The default seam
|
|
575
|
+
// darkening only applies when a cell op runs without an edges op.
|
|
576
|
+
const hasEdges = nodes.some((n) => n.op === "edges");
|
|
577
|
+
const defaultSeam = hasEdges ? "" : " col *= 1.0 - edge * 0.55;";
|
|
578
|
+
return `
|
|
579
|
+
precision highp float;
|
|
580
|
+
uniform sampler2D u_tex;
|
|
581
|
+
uniform vec2 u_res;
|
|
582
|
+
uniform vec2 u_mouse;
|
|
583
|
+
uniform float u_time;
|
|
584
|
+
${decls}
|
|
585
|
+
void main() {
|
|
586
|
+
vec2 frag = vec2(gl_FragCoord.x, u_res.y - gl_FragCoord.y);
|
|
587
|
+
vec2 warped = frag;
|
|
588
|
+
${warp}
|
|
589
|
+
vec2 center = warped;
|
|
590
|
+
float edge = 0.0;
|
|
591
|
+
${cell}
|
|
592
|
+
vec2 sampleP = warped;
|
|
593
|
+
${displace}
|
|
594
|
+
vec2 uv = vec2(sampleP.x / u_res.x, 1.0 - sampleP.y / u_res.y);
|
|
595
|
+
vec4 tex = texture2D(u_tex, clamp(uv, 0.001, 0.999));
|
|
596
|
+
vec3 col = tex.rgb;
|
|
597
|
+
vec3 edgeCol = vec3(1.0);
|
|
598
|
+
float edgeCov = 0.0;
|
|
599
|
+
vec3 ovCol = vec3(0.0);
|
|
600
|
+
float ovA = 0.0;
|
|
601
|
+
${color}
|
|
602
|
+
${defaultSeam}
|
|
603
|
+
// content OVER border
|
|
604
|
+
float baseA = tex.a + edgeCov * (1.0 - tex.a);
|
|
605
|
+
vec3 baseRGB = (col * tex.a + edgeCol * edgeCov * (1.0 - tex.a)) / max(baseA, 1e-4);
|
|
606
|
+
// overlay OVER (content+border)
|
|
607
|
+
float outA = ovA + baseA * (1.0 - ovA);
|
|
608
|
+
vec3 outRGB = (ovCol * ovA + baseRGB * baseA * (1.0 - ovA)) / max(outA, 1e-4);
|
|
609
|
+
gl_FragColor = vec4(outRGB, outA);
|
|
610
|
+
}`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ── Snapshot backend: DOM subtree -> SVG foreignObject -> texture ────
|
|
614
|
+
|
|
615
|
+
function snapshotToImage(el, w, h, dpr) {
|
|
616
|
+
return new Promise((resolve, reject) => {
|
|
617
|
+
const serialized = new XMLSerializer().serializeToString(el);
|
|
618
|
+
const svg =
|
|
619
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${w * dpr}" height="${h * dpr}" viewBox="0 0 ${w} ${h}">` +
|
|
620
|
+
`<foreignObject width="100%" height="100%">` +
|
|
621
|
+
`<div xmlns="http://www.w3.org/1999/xhtml">${serialized}</div>` +
|
|
622
|
+
`</foreignObject></svg>`;
|
|
623
|
+
const img = new Image();
|
|
624
|
+
img.onload = () => resolve(img);
|
|
625
|
+
img.onerror = reject;
|
|
626
|
+
img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── Pipeline runner ──────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
function applyRasterPipeline(el, rasterNodes) {
|
|
633
|
+
// Hard guards — every early-out is silent by design so that jsdom
|
|
634
|
+
// prerender, old browsers and reduced-motion users get the plain
|
|
635
|
+
// page untouched.
|
|
636
|
+
if (!el || typeof document === "undefined" || typeof window === "undefined") return null;
|
|
637
|
+
if (!rasterNodes || rasterNodes.length === 0) return null;
|
|
638
|
+
if (typeof window.matchMedia === "function" &&
|
|
639
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches) return null;
|
|
640
|
+
|
|
641
|
+
// One pipeline per host element (set() chains can fire more than
|
|
642
|
+
// once across codegen instances; only the mounted one matters).
|
|
643
|
+
if (el.querySelector && el.querySelector(":scope > canvas[data-nodality-raster]")) return null;
|
|
644
|
+
|
|
645
|
+
// Content-box measurement: some elements (fluid-sized Text) keep a
|
|
646
|
+
// collapsed layout box while their glyphs overflow it, so fall back
|
|
647
|
+
// to scroll dimensions when the rect is degenerate.
|
|
648
|
+
const measure = () => {
|
|
649
|
+
const r = el.getBoundingClientRect();
|
|
650
|
+
return {
|
|
651
|
+
width: Math.max(r.width, el.scrollWidth || 0),
|
|
652
|
+
height: Math.max(r.height, el.scrollHeight || 0),
|
|
653
|
+
};
|
|
654
|
+
};
|
|
655
|
+
const rect = measure();
|
|
656
|
+
if (rect.width < 2 || rect.height < 2) return null;
|
|
657
|
+
|
|
658
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
659
|
+
const isOverlay = rasterNodes.some((n) => (REGISTRY[n.op] || {}).overlay);
|
|
660
|
+
// Stacking: a pipeline may mix in-place ops (hexalize/offset/duotone/
|
|
661
|
+
// edges) with an overlay op (blobs). The single shader composites them
|
|
662
|
+
// in one pass, but the HOST visibility differs — only a PURELY overlay
|
|
663
|
+
// chain leaves the host visible (the lens refracts untouched content).
|
|
664
|
+
// The moment an in-place op joins, the canvas fully carries the look,
|
|
665
|
+
// so the host must be hidden or the original content ghosts through.
|
|
666
|
+
const pureOverlay = rasterNodes.every((n) => (REGISTRY[n.op] || {}).overlay);
|
|
667
|
+
// Live-first (HTML-in-Canvas). The live backend captures content that
|
|
668
|
+
// lives INSIDE the canvas (texElementImage2D over the restructured
|
|
669
|
+
// subtree) — the same model canvasUI's bubble uses: content in the
|
|
670
|
+
// canvas, the lens drawn over that live capture. So live is viable
|
|
671
|
+
// whenever the host is hidden and its content moves into the canvas,
|
|
672
|
+
// i.e. every chain EXCEPT a pure-overlay one (blobs alone floating
|
|
673
|
+
// over untouched DOM — nothing is restructured, so there is no live
|
|
674
|
+
// subtree to sample; that case stays snapshot). A combined chain
|
|
675
|
+
// (blobs + hexalize/offset/…) hides the host and therefore runs live,
|
|
676
|
+
// lens included. Opt out per pipeline with `live: false` on any node.
|
|
677
|
+
const wantLive = !pureOverlay && !rasterNodes.some((n) => n.live === false);
|
|
678
|
+
const apiAvailable =
|
|
679
|
+
(typeof WebGL2RenderingContext !== "undefined" &&
|
|
680
|
+
"texElementImage2D" in WebGL2RenderingContext.prototype) ||
|
|
681
|
+
(typeof WebGLRenderingContext !== "undefined" &&
|
|
682
|
+
"texElementImage2D" in WebGLRenderingContext.prototype);
|
|
683
|
+
let live = wantLive && apiAvailable;
|
|
684
|
+
if (wantLive && !live) {
|
|
685
|
+
console.info("[nodality] HTML-in-Canvas API not available - raster ops fall back to snapshot capture.");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const canvas = document.createElement("canvas");
|
|
689
|
+
// layoutsubtree must be present before context creation so the
|
|
690
|
+
// browser prepares the canvas for laid-out children (origin trial).
|
|
691
|
+
if (live) canvas.setAttribute("layoutsubtree", "");
|
|
692
|
+
const ctxOpts = { alpha: true, premultipliedAlpha: false };
|
|
693
|
+
// WebGL2-first everywhere: NPOT mipmaps (offset-scaled blur for the
|
|
694
|
+
// glassy ops) and texElementImage2D both need it; WebGL1 remains as
|
|
695
|
+
// a sharp-rendering fallback (mip blur simply disables).
|
|
696
|
+
const gl = canvas.getContext("webgl2", ctxOpts) ||
|
|
697
|
+
canvas.getContext("webgl", ctxOpts);
|
|
698
|
+
if (!gl) return null;
|
|
699
|
+
const isGL2 = (typeof WebGL2RenderingContext !== "undefined") &&
|
|
700
|
+
(gl instanceof WebGL2RenderingContext);
|
|
701
|
+
const needMips = isGL2 && rasterNodes.some((n) => (REGISTRY[n.op] || {}).mips);
|
|
702
|
+
if (live && !("texElementImage2D" in gl)) {
|
|
703
|
+
console.info("[nodality] texElementImage2D missing on the created context - snapshot fallback.");
|
|
704
|
+
canvas.removeAttribute("layoutsubtree");
|
|
705
|
+
live = false;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Overlay the effect canvas on the host element. The real DOM stays
|
|
709
|
+
// in place underneath (accessibility tree, selection, focus), the
|
|
710
|
+
// canvas is purely visual and lets pointer events through.
|
|
711
|
+
const cs = window.getComputedStyle(el);
|
|
712
|
+
if (cs.position === "static") el.style.position = "relative";
|
|
713
|
+
canvas.width = Math.round(rect.width * dpr);
|
|
714
|
+
canvas.height = Math.round(rect.height * dpr);
|
|
715
|
+
canvas.setAttribute("data-nodality-raster", live ? "live" : "snapshot");
|
|
716
|
+
console.info("[nodality] raster backend:", live ? "html-in-canvas (live)" : "snapshot");
|
|
717
|
+
|
|
718
|
+
let sourceEl = el; // element uploaded as the texture in live mode
|
|
719
|
+
if (live) {
|
|
720
|
+
// HTML-in-Canvas: the subtree becomes canvas children (wrapped in
|
|
721
|
+
// a single div we can pass to texElementImage2D) so it keeps
|
|
722
|
+
// layout, interactivity and its accessibility-tree presence while
|
|
723
|
+
// the canvas paints it (WICG proposal; Chrome origin trial behind
|
|
724
|
+
// chrome://flags/#canvas-draw-element). layoutsubtree was already
|
|
725
|
+
// set before context creation above.
|
|
726
|
+
const wrap = document.createElement("div");
|
|
727
|
+
while (el.firstChild) wrap.appendChild(el.firstChild);
|
|
728
|
+
canvas.appendChild(wrap);
|
|
729
|
+
sourceEl = wrap;
|
|
730
|
+
// The host collapses once its children move into the canvas, so
|
|
731
|
+
// the canvas itself carries the box in normal flow (not overlay).
|
|
732
|
+
canvas.style.cssText =
|
|
733
|
+
`display:block;width:${rect.width}px;height:${rect.height}px;`;
|
|
734
|
+
} else {
|
|
735
|
+
// Snapshot mode: purely visual overlay; the real DOM stays put
|
|
736
|
+
// underneath for accessibility, selection and events.
|
|
737
|
+
canvas.style.cssText =
|
|
738
|
+
"position:absolute;top:0;left:0;" +
|
|
739
|
+
`width:${rect.width}px;height:${rect.height}px;` +
|
|
740
|
+
"pointer-events:none;z-index:2147483000;";
|
|
741
|
+
canvas.setAttribute("aria-hidden", "true");
|
|
742
|
+
}
|
|
743
|
+
el.appendChild(canvas);
|
|
744
|
+
|
|
745
|
+
// Compile the pipeline.
|
|
746
|
+
const compile = (type, src) => {
|
|
747
|
+
const s = gl.createShader(type);
|
|
748
|
+
gl.shaderSource(s, src);
|
|
749
|
+
gl.compileShader(s);
|
|
750
|
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
|
751
|
+
throw new Error("[nodality] raster shader: " + gl.getShaderInfoLog(s));
|
|
752
|
+
}
|
|
753
|
+
return s;
|
|
754
|
+
};
|
|
755
|
+
const prog = gl.createProgram();
|
|
756
|
+
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
|
|
757
|
+
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, buildFragmentShader(rasterNodes)));
|
|
758
|
+
// Pin "a" to location 0 so a field-simulation program (which shares
|
|
759
|
+
// this vertex buffer and attribute array) can be swapped in without
|
|
760
|
+
// respecifying the pointer.
|
|
761
|
+
gl.bindAttribLocation(prog, 0, "a");
|
|
762
|
+
gl.linkProgram(prog);
|
|
763
|
+
gl.useProgram(prog);
|
|
764
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
|
|
765
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
|
766
|
+
gl.enableVertexAttribArray(0);
|
|
767
|
+
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
|
768
|
+
const U = (n) => gl.getUniformLocation(prog, n);
|
|
769
|
+
|
|
770
|
+
const setUniform = (loc, kind, value) => {
|
|
771
|
+
if (kind === "1f") gl.uniform1f(loc, value);
|
|
772
|
+
else if (kind === "1i") gl.uniform1i(loc, value);
|
|
773
|
+
else if (kind === "2fv") gl.uniform2fv(loc, value);
|
|
774
|
+
else if (kind === "3fv") gl.uniform3fv(loc, value);
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
// Static uniforms from the node data.
|
|
778
|
+
rasterNodes.forEach((node, i) => {
|
|
779
|
+
const def = REGISTRY[node.op];
|
|
780
|
+
if (!def || !def.uniforms) return;
|
|
781
|
+
const us = def.uniforms(node, dpr);
|
|
782
|
+
for (const key in us) {
|
|
783
|
+
setUniform(U(`u${i}_${key}`), us[key][0], us[key][1]);
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// Ops with a CPU simulation (e.g. blobs) get per-frame state and
|
|
788
|
+
// feed dynamic uniforms each draw (see the render loop below).
|
|
789
|
+
const dynamicOps = [];
|
|
790
|
+
rasterNodes.forEach((node, i) => {
|
|
791
|
+
const def = REGISTRY[node.op];
|
|
792
|
+
if (def && typeof def.init === "function") {
|
|
793
|
+
dynamicOps.push({ i, def, state: def.init(node, dpr, canvas.width, canvas.height) });
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
let lastFrame = (typeof performance !== "undefined") ? performance.now() : 0;
|
|
797
|
+
|
|
798
|
+
// ── Field simulations (ops with a `field` descriptor, e.g. stir) ──
|
|
799
|
+
// Each owns a ping-pong pair of low-res targets holding a vector
|
|
800
|
+
// field that is advanced by its own program every frame and then
|
|
801
|
+
// sampled by the main pass. Half-float targets when the platform
|
|
802
|
+
// can render to them; otherwise the vectors are range-encoded into
|
|
803
|
+
// ordinary RGBA8, so the op still runs on WebGL1.
|
|
804
|
+
const fieldOps = [];
|
|
805
|
+
const makeField = (node, i) => {
|
|
806
|
+
const def = REGISTRY[node.op];
|
|
807
|
+
const res = def.field.resolution(node);
|
|
808
|
+
const RANGE = 2.0;
|
|
809
|
+
const canFloat = isGL2 && !!gl.getExtension("EXT_color_buffer_float");
|
|
810
|
+
|
|
811
|
+
const mkPair = (enc) => {
|
|
812
|
+
const targets = [];
|
|
813
|
+
for (let k = 0; k < 2; k++) {
|
|
814
|
+
const t = gl.createTexture();
|
|
815
|
+
gl.bindTexture(gl.TEXTURE_2D, t);
|
|
816
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
817
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
818
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
819
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
820
|
+
if (enc) {
|
|
821
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, res, res, 0,
|
|
822
|
+
gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
823
|
+
} else {
|
|
824
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, res, res, 0,
|
|
825
|
+
gl.RGBA, gl.HALF_FLOAT, null);
|
|
826
|
+
}
|
|
827
|
+
const f = gl.createFramebuffer();
|
|
828
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, f);
|
|
829
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
|
|
830
|
+
gl.TEXTURE_2D, t, 0);
|
|
831
|
+
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
|
|
832
|
+
targets.forEach((x) => { gl.deleteTexture(x.tex); gl.deleteFramebuffer(x.fbo); });
|
|
833
|
+
gl.deleteTexture(t);
|
|
834
|
+
gl.deleteFramebuffer(f);
|
|
835
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
// Zero field == the encoded midpoint in the 8-bit path.
|
|
839
|
+
gl.viewport(0, 0, res, res);
|
|
840
|
+
gl.clearColor(enc ? 0.5 : 0, enc ? 0.5 : 0, 0, 1);
|
|
841
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
842
|
+
targets.push({ tex: t, fbo: f });
|
|
843
|
+
}
|
|
844
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
845
|
+
return targets;
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
let enc = canFloat ? 0 : 1;
|
|
849
|
+
let pair = canFloat ? mkPair(0) : null;
|
|
850
|
+
if (!pair) { enc = 1; pair = mkPair(1); }
|
|
851
|
+
if (!pair) return null;
|
|
852
|
+
|
|
853
|
+
let simProg;
|
|
854
|
+
try {
|
|
855
|
+
simProg = gl.createProgram();
|
|
856
|
+
gl.attachShader(simProg, compile(gl.VERTEX_SHADER, VS));
|
|
857
|
+
gl.attachShader(simProg, compile(gl.FRAGMENT_SHADER, def.field.frag(node)));
|
|
858
|
+
gl.bindAttribLocation(simProg, 0, "a");
|
|
859
|
+
gl.linkProgram(simProg);
|
|
860
|
+
if (!gl.getProgramParameter(simProg, gl.LINK_STATUS)) {
|
|
861
|
+
throw new Error(gl.getProgramInfoLog(simProg));
|
|
862
|
+
}
|
|
863
|
+
} catch (e) {
|
|
864
|
+
console.warn("[nodality] raster field sim unavailable for '" + node.op + "':", e);
|
|
865
|
+
pair.forEach((x) => { gl.deleteTexture(x.tex); gl.deleteFramebuffer(x.fbo); });
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Static sim uniforms (set once).
|
|
870
|
+
gl.useProgram(simProg);
|
|
871
|
+
const SU = (n) => gl.getUniformLocation(simProg, n);
|
|
872
|
+
const statics = def.field.uniforms(node);
|
|
873
|
+
for (const key in statics) setUniform(SU(key), statics[key][0], statics[key][1]);
|
|
874
|
+
gl.uniform2f(SU("s_texel"), 1 / res, 1 / res);
|
|
875
|
+
gl.uniform1f(SU("s_enc"), enc);
|
|
876
|
+
gl.uniform1f(SU("s_range"), RANGE);
|
|
877
|
+
|
|
878
|
+
// The main pass needs to know how to decode what it samples.
|
|
879
|
+
gl.useProgram(prog);
|
|
880
|
+
gl.uniform1f(U(`u${i}_enc`), enc);
|
|
881
|
+
gl.uniform1f(U(`u${i}_range`), RANGE);
|
|
882
|
+
|
|
883
|
+
return {
|
|
884
|
+
i, res, prog: simProg, SU,
|
|
885
|
+
src: pair[0], dst: pair[1],
|
|
886
|
+
force: def.field.force(node),
|
|
887
|
+
prevX: 0.5, prevY: 0.5, primed: false,
|
|
888
|
+
};
|
|
889
|
+
};
|
|
890
|
+
rasterNodes.forEach((node, i) => {
|
|
891
|
+
const def = REGISTRY[node.op];
|
|
892
|
+
if (!def || !def.field) return;
|
|
893
|
+
const f = makeField(node, i);
|
|
894
|
+
if (f) fieldOps.push(f);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// Advance one field by a frame, ping-ponging its targets.
|
|
898
|
+
const stepField = (f, dt, now) => {
|
|
899
|
+
gl.useProgram(f.prog);
|
|
900
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, f.dst.fbo);
|
|
901
|
+
gl.viewport(0, 0, f.res, f.res);
|
|
902
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
903
|
+
gl.bindTexture(gl.TEXTURE_2D, f.src.tex);
|
|
904
|
+
gl.uniform1i(f.SU("s_field"), 0);
|
|
905
|
+
|
|
906
|
+
// Pointer position in field space (bottom-up [0,1]) and the
|
|
907
|
+
// velocity it picked up since the previous frame — the spoon.
|
|
908
|
+
const px = mouse[0] / canvas.width;
|
|
909
|
+
const py = 1 - mouse[1] / canvas.height;
|
|
910
|
+
let ix = 0, iy = 0;
|
|
911
|
+
if (f.primed) {
|
|
912
|
+
ix = (px - f.prevX) * 60 * f.force;
|
|
913
|
+
iy = (py - f.prevY) * 60 * f.force;
|
|
914
|
+
}
|
|
915
|
+
f.prevX = px; f.prevY = py; f.primed = true;
|
|
916
|
+
|
|
917
|
+
gl.uniform2f(f.SU("s_point"), px, py);
|
|
918
|
+
gl.uniform2f(f.SU("s_impulse"), ix, iy);
|
|
919
|
+
gl.uniform1f(f.SU("s_dt"), Math.min(dt, 1 / 30));
|
|
920
|
+
gl.uniform1f(f.SU("s_aspect"), canvas.width / Math.max(canvas.height, 1));
|
|
921
|
+
gl.uniform1f(f.SU("s_time"), now / 1000);
|
|
922
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
923
|
+
|
|
924
|
+
const swap = f.src; f.src = f.dst; f.dst = swap;
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
const tex = gl.createTexture();
|
|
928
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
929
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER,
|
|
930
|
+
needMips ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR);
|
|
931
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
932
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
933
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
934
|
+
// Regenerate the mip chain after every content upload so the
|
|
935
|
+
// offset-scaled blur (texture2D bias) has levels to sample.
|
|
936
|
+
const finishUpload = () => { if (needMips) gl.generateMipmap(gl.TEXTURE_2D); };
|
|
937
|
+
|
|
938
|
+
let textureReady = false;
|
|
939
|
+
let destroyed = false;
|
|
940
|
+
let mode = live ? "live" : "snapshot";
|
|
941
|
+
|
|
942
|
+
// For an overlay effect whose content is (or contains) a same-origin
|
|
943
|
+
// image, upload that image straight into the texture. This bypasses
|
|
944
|
+
// the foreignObject snapshot (which can't rasterise <img>) and gives
|
|
945
|
+
// the lens a real photo to refract — the glassy, distorting look.
|
|
946
|
+
const contentImg = () => {
|
|
947
|
+
const im = (el.tagName === "IMG") ? el
|
|
948
|
+
: (el.querySelector ? el.querySelector("img") : null);
|
|
949
|
+
return (im && im.complete && im.naturalWidth > 0) ? im : null;
|
|
950
|
+
};
|
|
951
|
+
let imgListenerBound = false;
|
|
952
|
+
|
|
953
|
+
// Snapshot the host's children (excluding our own canvas). Once the
|
|
954
|
+
// texture is live, the host's own painting is hidden via the
|
|
955
|
+
// visibility trick (the canvas child re-shows itself) so transparent
|
|
956
|
+
// hosts — e.g. a bare headline — don't ghost-double under the effect.
|
|
957
|
+
const snapshotCapture = () => {
|
|
958
|
+
// Overlay + image content: use the image directly as the texture.
|
|
959
|
+
if (isOverlay) {
|
|
960
|
+
const im = contentImg();
|
|
961
|
+
if (im) {
|
|
962
|
+
try {
|
|
963
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
964
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, im);
|
|
965
|
+
finishUpload();
|
|
966
|
+
textureReady = true;
|
|
967
|
+
return Promise.resolve();
|
|
968
|
+
} catch (e) {
|
|
969
|
+
// Cross-origin taint -> fall through to snapshot.
|
|
970
|
+
}
|
|
971
|
+
} else if (!imgListenerBound) {
|
|
972
|
+
// Image not decoded yet: recapture once it loads.
|
|
973
|
+
const im2 = (el.tagName === "IMG") ? el : (el.querySelector && el.querySelector("img"));
|
|
974
|
+
if (im2) {
|
|
975
|
+
imgListenerBound = true;
|
|
976
|
+
im2.addEventListener("load", () => { if (!destroyed) snapshotCapture(); }, { once: true });
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
canvas.style.display = "none";
|
|
981
|
+
const prevVisibility = el.style.visibility;
|
|
982
|
+
el.style.visibility = "";
|
|
983
|
+
const p = snapshotToImage(el, rect.width, rect.height, dpr)
|
|
984
|
+
.then((img) => {
|
|
985
|
+
if (destroyed) return;
|
|
986
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
987
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
|
988
|
+
finishUpload();
|
|
989
|
+
textureReady = true;
|
|
990
|
+
// In-place transforms hide the host (the canvas replaces
|
|
991
|
+
// its look). A pure-overlay chain keeps it visible so the
|
|
992
|
+
// real content shows through the bubble; a mixed chain
|
|
993
|
+
// (in-place + overlay) hides it — the canvas carries all.
|
|
994
|
+
el.style.visibility = pureOverlay ? "" : "hidden";
|
|
995
|
+
canvas.style.visibility = "visible";
|
|
996
|
+
})
|
|
997
|
+
.catch((e) => {
|
|
998
|
+
el.style.visibility = prevVisibility;
|
|
999
|
+
console.warn("[nodality] raster snapshot failed:", e);
|
|
1000
|
+
});
|
|
1001
|
+
canvas.style.display = "";
|
|
1002
|
+
return p;
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
// Live uploads are driven by the canvas `paint` event, per the
|
|
1006
|
+
// HTML-in-Canvas origin trial: it fires whenever the nested subtree
|
|
1007
|
+
// renders or redraws. Uploading outside of it yields an empty
|
|
1008
|
+
// (fully transparent) texture — which looks like a blank box.
|
|
1009
|
+
//
|
|
1010
|
+
// The trial's texElementImage2D signature has drifted across Chrome
|
|
1011
|
+
// builds (the element parameter has moved as the arg list shrank),
|
|
1012
|
+
// so the working call form is discovered at runtime — guided by the
|
|
1013
|
+
// function's declared arity — and cached after the first success.
|
|
1014
|
+
let liveForm = -1;
|
|
1015
|
+
const onPaint = () => {
|
|
1016
|
+
if (destroyed || mode !== "live") return;
|
|
1017
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
1018
|
+
// Confirmed signature in Chrome 148+ (origin trial):
|
|
1019
|
+
// texElementImage2D(target, internalformat, element)
|
|
1020
|
+
// where internalformat must be a *sized* WebGL2 format
|
|
1021
|
+
// (RGBA8 / SRGB8_ALPHA8 / RGBA16F / RGBA32F) — not gl.RGBA.
|
|
1022
|
+
const RGBA8 = gl.RGBA8 || 0x8058;
|
|
1023
|
+
const forms = [
|
|
1024
|
+
[gl.TEXTURE_2D, RGBA8, sourceEl], // 0 confirmed Chrome 148+
|
|
1025
|
+
[gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, sourceEl], // 1 explainer/blog form
|
|
1026
|
+
[gl.TEXTURE_2D, 0, RGBA8, sourceEl], // 2 (target, level, internalformat, element)
|
|
1027
|
+
[gl.TEXTURE_2D, 0, sourceEl], // 3 (target, level, element)
|
|
1028
|
+
];
|
|
1029
|
+
let order;
|
|
1030
|
+
if (liveForm >= 0) {
|
|
1031
|
+
order = [liveForm];
|
|
1032
|
+
} else {
|
|
1033
|
+
order = [0, 1, 2, 3];
|
|
1034
|
+
const byArity = { 3: 0, 6: 1, 4: 2 }[gl.texElementImage2D.length];
|
|
1035
|
+
if (byArity != null) {
|
|
1036
|
+
order.splice(order.indexOf(byArity), 1);
|
|
1037
|
+
order.unshift(byArity);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
const errs = [];
|
|
1041
|
+
for (const i of order) {
|
|
1042
|
+
try {
|
|
1043
|
+
gl.texElementImage2D.apply(gl, forms[i]);
|
|
1044
|
+
finishUpload();
|
|
1045
|
+
if (liveForm < 0) {
|
|
1046
|
+
liveForm = i;
|
|
1047
|
+
console.info("[nodality] texElementImage2D signature discovered: form",
|
|
1048
|
+
i, "(declared arity " + gl.texElementImage2D.length + ")");
|
|
1049
|
+
}
|
|
1050
|
+
textureReady = true;
|
|
1051
|
+
return;
|
|
1052
|
+
} catch (e) {
|
|
1053
|
+
errs.push("form" + i + " -> " + (e && e.name ? e.name : "?") + ": " +
|
|
1054
|
+
(e && e.message ? e.message : e));
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
fallbackToSnapshot("texElementImage2D rejected all known signatures (arity " +
|
|
1058
|
+
gl.texElementImage2D.length + "): " + errs.join(" | "));
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
// If the live path misbehaves (API surface drift, paint event never
|
|
1062
|
+
// firing), never leave a blank box behind: undo the restructuring
|
|
1063
|
+
// and continue with the portable snapshot backend.
|
|
1064
|
+
const fallbackToSnapshot = (reason) => {
|
|
1065
|
+
if (destroyed || mode !== "live") return;
|
|
1066
|
+
mode = "snapshot";
|
|
1067
|
+
console.warn("[nodality] live raster backend failed (" + reason + ") - falling back to snapshot.");
|
|
1068
|
+
canvas.removeEventListener("paint", onPaint);
|
|
1069
|
+
while (sourceEl.firstChild) el.appendChild(sourceEl.firstChild);
|
|
1070
|
+
if (sourceEl.parentNode === canvas) canvas.removeChild(sourceEl);
|
|
1071
|
+
canvas.removeAttribute("layoutsubtree");
|
|
1072
|
+
canvas.style.cssText =
|
|
1073
|
+
"position:absolute;top:0;left:0;" +
|
|
1074
|
+
`width:${rect.width}px;height:${rect.height}px;` +
|
|
1075
|
+
"pointer-events:none;z-index:2147483000;";
|
|
1076
|
+
canvas.setAttribute("aria-hidden", "true");
|
|
1077
|
+
canvas.setAttribute("data-nodality-raster", "snapshot-fallback");
|
|
1078
|
+
snapshotCapture();
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
if (live) {
|
|
1082
|
+
canvas.addEventListener("paint", onPaint);
|
|
1083
|
+
setTimeout(() => {
|
|
1084
|
+
if (!destroyed && mode === "live" && !textureReady) {
|
|
1085
|
+
fallbackToSnapshot("no paint event within 1500ms");
|
|
1086
|
+
}
|
|
1087
|
+
}, 1500);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Pointer -> u_mouse (listen on the host so the overlay stays
|
|
1091
|
+
// pointer-events: none).
|
|
1092
|
+
let mouse = [canvas.width * 0.5, canvas.height * 0.5];
|
|
1093
|
+
const onMove = (e) => {
|
|
1094
|
+
const r = canvas.getBoundingClientRect();
|
|
1095
|
+
const pt = e.touches ? e.touches[0] : e;
|
|
1096
|
+
mouse = [(pt.clientX - r.left) * dpr, (pt.clientY - r.top) * dpr];
|
|
1097
|
+
};
|
|
1098
|
+
el.addEventListener("mousemove", onMove, { passive: true });
|
|
1099
|
+
el.addEventListener("touchmove", onMove, { passive: true });
|
|
1100
|
+
|
|
1101
|
+
// Render loop — paused while off-screen.
|
|
1102
|
+
let raf = 0;
|
|
1103
|
+
let visible = true;
|
|
1104
|
+
const draw = () => {
|
|
1105
|
+
if (destroyed) return;
|
|
1106
|
+
if (visible && textureReady) {
|
|
1107
|
+
const now = (typeof performance !== "undefined") ? performance.now() : lastFrame + 16;
|
|
1108
|
+
const dt = Math.max(0, (now - lastFrame) / 1000);
|
|
1109
|
+
|
|
1110
|
+
// 1. Advance stateful field simulations into their own
|
|
1111
|
+
// off-screen targets (this rebinds framebuffer/program).
|
|
1112
|
+
for (const f of fieldOps) stepField(f, dt, now);
|
|
1113
|
+
|
|
1114
|
+
// 2. Composite pass, back on the visible framebuffer.
|
|
1115
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
1116
|
+
gl.useProgram(prog);
|
|
1117
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
1118
|
+
gl.clearColor(0, 0, 0, 0);
|
|
1119
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
1120
|
+
gl.uniform2f(U("u_res"), canvas.width, canvas.height);
|
|
1121
|
+
gl.uniform2f(U("u_mouse"), mouse[0], mouse[1]);
|
|
1122
|
+
gl.uniform1f(U("u_time"), now / 1000);
|
|
1123
|
+
// Advance and upload per-frame CPU simulations.
|
|
1124
|
+
if (dynamicOps.length > 0) {
|
|
1125
|
+
const ctx = { w: canvas.width, h: canvas.height, mouseX: mouse[0], mouseY: mouse[1], dt: dt, t: now / 1000 };
|
|
1126
|
+
for (const d of dynamicOps) {
|
|
1127
|
+
const ups = d.def.tick(d.state, ctx);
|
|
1128
|
+
for (const u of ups) setUniform(U(`u${d.i}_${u.name}`), u.kind, u.value);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
// Content on unit 0, each simulated field on its own unit.
|
|
1132
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
1133
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
1134
|
+
gl.uniform1i(U("u_tex"), 0);
|
|
1135
|
+
fieldOps.forEach((f, k) => {
|
|
1136
|
+
gl.activeTexture(gl.TEXTURE0 + 1 + k);
|
|
1137
|
+
gl.bindTexture(gl.TEXTURE_2D, f.src.tex);
|
|
1138
|
+
gl.uniform1i(U(`u${f.i}_field`), 1 + k);
|
|
1139
|
+
});
|
|
1140
|
+
gl.activeTexture(gl.TEXTURE0); // texture uploads assume unit 0
|
|
1141
|
+
lastFrame = now;
|
|
1142
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
1143
|
+
}
|
|
1144
|
+
raf = requestAnimationFrame(draw);
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
let io = null;
|
|
1148
|
+
if (typeof IntersectionObserver !== "undefined") {
|
|
1149
|
+
io = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; });
|
|
1150
|
+
io.observe(el);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
let ro = null;
|
|
1154
|
+
let resizeTimer = 0;
|
|
1155
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
1156
|
+
ro = new ResizeObserver(() => {
|
|
1157
|
+
clearTimeout(resizeTimer);
|
|
1158
|
+
resizeTimer = setTimeout(() => {
|
|
1159
|
+
if (destroyed || mode !== "snapshot") return;
|
|
1160
|
+
const m = measure();
|
|
1161
|
+
rect.width = m.width;
|
|
1162
|
+
rect.height = m.height;
|
|
1163
|
+
canvas.width = Math.round(m.width * dpr);
|
|
1164
|
+
canvas.height = Math.round(m.height * dpr);
|
|
1165
|
+
canvas.style.width = m.width + "px";
|
|
1166
|
+
canvas.style.height = m.height + "px";
|
|
1167
|
+
snapshotCapture();
|
|
1168
|
+
}, 150);
|
|
1169
|
+
});
|
|
1170
|
+
ro.observe(el);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Window-resize handling for both modes. This backs up the
|
|
1174
|
+
// ResizeObserver above: RO callbacks ride the rendering frame loop
|
|
1175
|
+
// and stall in backgrounded tabs, while the resize event always
|
|
1176
|
+
// fires. Snapshot mode re-measures the host and recaptures. Live
|
|
1177
|
+
// mode is RO-blind anyway (the host is collapsed; its children live
|
|
1178
|
+
// inside the canvas): widen the canvas to the host's flow width,
|
|
1179
|
+
// let the subtree reflow, then fit the height and re-upload.
|
|
1180
|
+
let winResizeTimer = 0;
|
|
1181
|
+
const onWinResize = () => {
|
|
1182
|
+
clearTimeout(winResizeTimer);
|
|
1183
|
+
winResizeTimer = setTimeout(() => {
|
|
1184
|
+
if (destroyed) return;
|
|
1185
|
+
if (mode === "snapshot") {
|
|
1186
|
+
const m = measure();
|
|
1187
|
+
rect.width = m.width;
|
|
1188
|
+
rect.height = m.height;
|
|
1189
|
+
canvas.width = Math.round(m.width * dpr);
|
|
1190
|
+
canvas.height = Math.round(m.height * dpr);
|
|
1191
|
+
canvas.style.width = m.width + "px";
|
|
1192
|
+
canvas.style.height = m.height + "px";
|
|
1193
|
+
snapshotCapture();
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
const w = el.clientWidth ||
|
|
1197
|
+
(el.parentElement && el.parentElement.clientWidth) || rect.width;
|
|
1198
|
+
canvas.style.width = w + "px";
|
|
1199
|
+
setTimeout(() => {
|
|
1200
|
+
if (destroyed || mode !== "live") return;
|
|
1201
|
+
const h = Math.max(2, Math.round(sourceEl.getBoundingClientRect().height)) || rect.height;
|
|
1202
|
+
rect.width = w;
|
|
1203
|
+
rect.height = h;
|
|
1204
|
+
canvas.style.height = h + "px";
|
|
1205
|
+
canvas.width = Math.round(w * dpr);
|
|
1206
|
+
canvas.height = Math.round(h * dpr);
|
|
1207
|
+
onPaint(); // fresh upload at the new size
|
|
1208
|
+
}, 50);
|
|
1209
|
+
}, 150);
|
|
1210
|
+
};
|
|
1211
|
+
if (typeof window !== "undefined") {
|
|
1212
|
+
window.addEventListener("resize", onWinResize);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (mode === "live") {
|
|
1216
|
+
// Texture arrives via the paint event; start drawing right away.
|
|
1217
|
+
raf = requestAnimationFrame(draw);
|
|
1218
|
+
} else {
|
|
1219
|
+
snapshotCapture().then(() => { raf = requestAnimationFrame(draw); });
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
return {
|
|
1223
|
+
canvas,
|
|
1224
|
+
refresh: () => (mode === "live" ? onPaint() : snapshotCapture()),
|
|
1225
|
+
destroy() {
|
|
1226
|
+
destroyed = true;
|
|
1227
|
+
cancelAnimationFrame(raf);
|
|
1228
|
+
clearTimeout(resizeTimer);
|
|
1229
|
+
clearTimeout(winResizeTimer);
|
|
1230
|
+
if (io) io.disconnect();
|
|
1231
|
+
if (ro) ro.disconnect();
|
|
1232
|
+
if (typeof window !== "undefined") window.removeEventListener("resize", onWinResize);
|
|
1233
|
+
el.removeEventListener("mousemove", onMove);
|
|
1234
|
+
el.removeEventListener("touchmove", onMove);
|
|
1235
|
+
canvas.removeEventListener("paint", onPaint);
|
|
1236
|
+
for (const f of fieldOps) {
|
|
1237
|
+
[f.src, f.dst].forEach((t) => {
|
|
1238
|
+
gl.deleteFramebuffer(t.fbo);
|
|
1239
|
+
gl.deleteTexture(t.tex);
|
|
1240
|
+
});
|
|
1241
|
+
gl.deleteProgram(f.prog);
|
|
1242
|
+
}
|
|
1243
|
+
fieldOps.length = 0;
|
|
1244
|
+
while (sourceEl !== el && sourceEl.firstChild) el.appendChild(sourceEl.firstChild);
|
|
1245
|
+
el.style.visibility = "";
|
|
1246
|
+
canvas.remove();
|
|
1247
|
+
},
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
export { applyRasterPipeline, registerRasterOp, RASTER_OP_NAMES, isHTMLInCanvasAvailable };
|