nodality 1.0.180 → 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.
Files changed (192) hide show
  1. package/dist/animator.cjs.js +1 -1
  2. package/dist/animator.esm.js +1 -1
  3. package/dist/audionew.cjs.js +1 -1
  4. package/dist/audionew.esm.js +1 -1
  5. package/dist/base.cjs.js +1 -1
  6. package/dist/base.esm.js +1 -1
  7. package/dist/beta-desktop-bar.cjs.js +1 -1
  8. package/dist/beta-desktop-bar.esm.js +1 -1
  9. package/dist/beta-mobile-bar.cjs.js +1 -1
  10. package/dist/beta-mobile-bar.esm.js +1 -1
  11. package/dist/bundle.umd.js +1 -1
  12. package/dist/button.cjs.js +1 -1
  13. package/dist/button.esm.js +1 -1
  14. package/dist/center.cjs.js +1 -1
  15. package/dist/center.esm.js +1 -1
  16. package/dist/checkbox.cjs.js +1 -1
  17. package/dist/checkbox.esm.js +1 -1
  18. package/dist/code.cjs.js +1 -1
  19. package/dist/code.esm.js +1 -1
  20. package/dist/container.cjs.js +1 -1
  21. package/dist/container.esm.js +1 -1
  22. package/dist/data-list.cjs.js +1 -1
  23. package/dist/data-list.esm.js +1 -1
  24. package/dist/designer.cjs.js +1 -1
  25. package/dist/designer.esm.js +1 -1
  26. package/dist/dropdown.cjs.js +1 -1
  27. package/dist/dropdown.esm.js +1 -1
  28. package/dist/element-mapper.cjs.js +1 -1
  29. package/dist/element-mapper.esm.js +1 -1
  30. package/dist/finalresult.esm.js +1 -1
  31. package/dist/flex-card.cjs.js +1 -1
  32. package/dist/flex-card.esm.js +1 -1
  33. package/dist/flex-grid.cjs.js +1 -1
  34. package/dist/flex-grid.esm.js +1 -1
  35. package/dist/flex-row.cjs.js +1 -1
  36. package/dist/flex-row.esm.js +1 -1
  37. package/dist/floating-input.cjs.js +1 -1
  38. package/dist/floating-input.esm.js +1 -1
  39. package/dist/free.cjs.js +1 -1
  40. package/dist/free.esm.js +1 -1
  41. package/dist/horizontal-scroller.cjs.js +1 -1
  42. package/dist/horizontal-scroller.esm.js +1 -1
  43. package/dist/image-picker.cjs.js +1 -1
  44. package/dist/image-picker.esm.js +1 -1
  45. package/dist/image.cjs.js +1 -1
  46. package/dist/image.esm.js +1 -1
  47. package/dist/index.cjs.js +1 -1
  48. package/dist/index.esm.js +1 -1
  49. package/dist/link-getter.cjs.js +1 -1
  50. package/dist/link-getter.esm.js +1 -1
  51. package/dist/link.cjs.js +1 -1
  52. package/dist/link.esm.js +1 -1
  53. package/dist/meta-adder.cjs.js +1 -1
  54. package/dist/meta-adder.esm.js +1 -1
  55. package/dist/modal-2025.cjs.js +1 -1
  56. package/dist/modal-2025.esm.js +1 -1
  57. package/dist/multiswitcher.cjs.js +1 -1
  58. package/dist/multiswitcher.esm.js +1 -1
  59. package/dist/new-nav-bar.cjs.js +1 -1
  60. package/dist/new-nav-bar.esm.js +1 -1
  61. package/dist/picker.cjs.js +1 -1
  62. package/dist/picker.esm.js +1 -1
  63. package/dist/progress.cjs.js +1 -1
  64. package/dist/progress.esm.js +1 -1
  65. package/dist/radio.cjs.js +1 -1
  66. package/dist/radio.esm.js +1 -1
  67. package/dist/range.cjs.js +1 -1
  68. package/dist/range.esm.js +1 -1
  69. package/dist/side-bar.cjs.js +1 -1
  70. package/dist/side-bar.esm.js +1 -1
  71. package/dist/side-nav-bar.cjs.js +1 -1
  72. package/dist/side-nav-bar.esm.js +1 -1
  73. package/dist/simple-bar.cjs.js +1 -1
  74. package/dist/simple-bar.esm.js +1 -1
  75. package/dist/slider-2025.cjs.js +1 -1
  76. package/dist/slider-2025.esm.js +1 -1
  77. package/dist/spacer.cjs.js +1 -1
  78. package/dist/spacer.esm.js +1 -1
  79. package/dist/stack.cjs.js +1 -1
  80. package/dist/stack.esm.js +1 -1
  81. package/dist/table.cjs.js +1 -1
  82. package/dist/table.esm.js +1 -1
  83. package/dist/text-field.cjs.js +1 -1
  84. package/dist/text-field.esm.js +1 -1
  85. package/dist/text.cjs.js +1 -1
  86. package/dist/text.esm.js +1 -1
  87. package/dist/ulist.cjs.js +1 -1
  88. package/dist/ulist.esm.js +1 -1
  89. package/dist/video.cjs.js +1 -1
  90. package/dist/video.esm.js +1 -1
  91. package/dist/wrap.cjs.js +1 -1
  92. package/dist/wrap.esm.js +1 -1
  93. package/dist/zoom-card.cjs.js +1 -1
  94. package/dist/zoom-card.esm.js +1 -1
  95. package/layout/animator.js +1 -1
  96. package/layout/audio.js +1 -1
  97. package/layout/audionew.js +1 -1
  98. package/layout/base-2.js +1 -1
  99. package/layout/base.js +1 -1
  100. package/layout/beta-desktop-bar.js +1 -1
  101. package/layout/beta-mobile-bar.js +1 -1
  102. package/layout/box.js +1 -1
  103. package/layout/button.js +1 -1
  104. package/layout/cards.js +1 -1
  105. package/layout/center.js +1 -1
  106. package/layout/checkbox.js +1 -1
  107. package/layout/circle.js +1 -1
  108. package/layout/clean-row.js +1 -1
  109. package/layout/code.js +1 -1
  110. package/layout/container.js +1 -1
  111. package/layout/custom.js +1 -1
  112. package/layout/div-image.js +1 -1
  113. package/layout/dropdown-2025.js +1 -1
  114. package/layout/dropdown.js +1 -1
  115. package/layout/empty-element.js +1 -1
  116. package/layout/external-stylesheet.js +1 -1
  117. package/layout/flex-card.js +1 -1
  118. package/layout/flex-grid.js +1 -1
  119. package/layout/flex-row.js +1 -1
  120. package/layout/footer.js +1 -1
  121. package/layout/form-components/custom.js +1 -1
  122. package/layout/form-components/data-list.js +1 -1
  123. package/layout/form-components/floating-input.js +1 -1
  124. package/layout/form-components/form-all.js +1 -1
  125. package/layout/form-components/form.js +1 -1
  126. package/layout/form-components/image-picker.js +1 -1
  127. package/layout/form-components/picker.js +1 -1
  128. package/layout/form-components/radio.js +1 -1
  129. package/layout/form-components/radiogroup.js +1 -1
  130. package/layout/form-components/range.js +1 -1
  131. package/layout/free.js +1 -1
  132. package/layout/grid-new.js +1 -1
  133. package/layout/grid-switcher.js +1 -1
  134. package/layout/grid.js +1 -1
  135. package/layout/group.js +1 -1
  136. package/layout/header.js +1 -1
  137. package/layout/horizontal-scroller.js +1 -1
  138. package/layout/image-old.js +1 -1
  139. package/layout/image.js +1 -1
  140. package/layout/index.js +1 -1
  141. package/layout/label.js +1 -1
  142. package/layout/link.js +1 -1
  143. package/layout/list-OLD.js +1 -1
  144. package/layout/list.js +1 -1
  145. package/layout/meta-adder.js +1 -1
  146. package/layout/modal-2025.js +1 -1
  147. package/layout/modernwrap.js +1 -1
  148. package/layout/multiswitcher.js +1 -1
  149. package/layout/multiswitcherBeta.js +1 -1
  150. package/layout/nav-bar.js +1 -1
  151. package/layout/nav-factor/custom-div.js +1 -1
  152. package/layout/navBar-OLD.js +1 -1
  153. package/layout/new-flat-adder.js +1 -1
  154. package/layout/new-nav-bar.js +1 -1
  155. package/layout/offset-container.js +1 -1
  156. package/layout/polygon.js +1 -1
  157. package/layout/prerender-site.js +1 -1
  158. package/layout/prerender.js +1 -1
  159. package/layout/progress.js +1 -1
  160. package/layout/row.js +1 -1
  161. package/layout/saved-new-nav-bar.js +1 -1
  162. package/layout/scroll-video.js +1 -1
  163. package/layout/side-bar.js +1 -1
  164. package/layout/side-nav-bar.js +1 -1
  165. package/layout/simple-bar.js +1 -1
  166. package/layout/slider-2025.js +1 -1
  167. package/layout/spacer.js +1 -1
  168. package/layout/stack.js +1 -1
  169. package/layout/styler.js +1 -1
  170. package/layout/svg.js +1 -1
  171. package/layout/switcher.js +1 -1
  172. package/layout/table.js +1 -1
  173. package/layout/text-field.js +1 -1
  174. package/layout/text.js +1 -1
  175. package/layout/ulist.js +1 -1
  176. package/layout/video.js +1 -1
  177. package/layout/without-new.js +1 -1
  178. package/layout/wrap.js +1 -1
  179. package/layout/zoom-card.js +1 -1
  180. package/lib/card-getter.js +1 -1
  181. package/lib/data.js +1 -1
  182. package/lib/designer.js +1 -1
  183. package/lib/element-mapper.js +1 -1
  184. package/lib/keyframe-animation.js +1 -1
  185. package/lib/link-getter.js +1 -1
  186. package/lib/raster-ops.js +691 -33
  187. package/lib/scroll-video.js +1 -1
  188. package/lib/seo.js +1 -1
  189. package/lib/stacker.js +1 -1
  190. package/lib/theme.js +1 -1
  191. package/lib/transform-anim.js +1 -1
  192. package/package.json +1 -1
package/lib/raster-ops.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.0.180
2
+ * nodality v1.0.181
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -44,6 +44,8 @@
44
44
  // Ops execute in nodes-array order within their stage. Uniforms are
45
45
  // namespaced per node index so the same op can appear twice.
46
46
 
47
+ const MAX_BLOBS = 12;
48
+
47
49
  const REGISTRY = {
48
50
  hexalize: {
49
51
  stage: "cell",
@@ -124,6 +126,400 @@ const REGISTRY = {
124
126
  strength: ["1f", node.strength != null ? node.strength : 1.0],
125
127
  }),
126
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
+ },
127
523
  };
128
524
 
129
525
  function hexToRgb(c) {
@@ -157,30 +553,34 @@ function buildFragmentShader(nodes) {
157
553
  const def = REGISTRY[node.op];
158
554
  if (!def) return;
159
555
  const p = `u${i}_`;
160
- decls += def.decl(p) + "\n";
161
- const snippet = def.code(p, node) + "\n";
162
- if (def.stage === "warp") warp += snippet;
163
- else if (def.stage === "cell") cell += snippet;
164
- else if (def.stage === "displace") displace += snippet;
165
- else color += snippet;
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
+ });
166
568
  });
167
- // When an edges op is present it emits a border layer (edgeCol /
168
- // edgeCov) and the content (text) is composited OVER it, so borders
169
- // sit below the glyphs. Otherwise cells get the default subtle
170
- // darkening so the tiling still reads.
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.
171
576
  const hasEdges = nodes.some((n) => n.op === "edges");
172
- const finalBlock = hasEdges
173
- ? ` // text (col, tex.a) OVER border (edgeCol, edgeCov): straight alpha
174
- float outA = tex.a + edgeCov * (1.0 - tex.a);
175
- vec3 outRGB = (col * tex.a + edgeCol * edgeCov * (1.0 - tex.a)) / max(outA, 1e-4);
176
- gl_FragColor = vec4(outRGB, outA);`
177
- : ` col *= 1.0 - edge * 0.55;
178
- gl_FragColor = vec4(col, tex.a);`;
577
+ const defaultSeam = hasEdges ? "" : " col *= 1.0 - edge * 0.55;";
179
578
  return `
180
579
  precision highp float;
181
580
  uniform sampler2D u_tex;
182
581
  uniform vec2 u_res;
183
582
  uniform vec2 u_mouse;
583
+ uniform float u_time;
184
584
  ${decls}
185
585
  void main() {
186
586
  vec2 frag = vec2(gl_FragCoord.x, u_res.y - gl_FragCoord.y);
@@ -196,8 +596,17 @@ ${displace}
196
596
  vec3 col = tex.rgb;
197
597
  vec3 edgeCol = vec3(1.0);
198
598
  float edgeCov = 0.0;
599
+ vec3 ovCol = vec3(0.0);
600
+ float ovA = 0.0;
199
601
  ${color}
200
- ${finalBlock}
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);
201
610
  }`;
202
611
  }
203
612
 
@@ -247,7 +656,25 @@ function applyRasterPipeline(el, rasterNodes) {
247
656
  if (rect.width < 2 || rect.height < 2) return null;
248
657
 
249
658
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
250
- const wantLive = rasterNodes.some((n) => n.live === true);
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);
251
678
  const apiAvailable =
252
679
  (typeof WebGL2RenderingContext !== "undefined" &&
253
680
  "texElementImage2D" in WebGL2RenderingContext.prototype) ||
@@ -263,11 +690,15 @@ function applyRasterPipeline(el, rasterNodes) {
263
690
  // browser prepares the canvas for laid-out children (origin trial).
264
691
  if (live) canvas.setAttribute("layoutsubtree", "");
265
692
  const ctxOpts = { alpha: true, premultipliedAlpha: false };
266
- // The origin trial wires texElementImage2D primarily on WebGL2 —
267
- // prefer it in live mode, fall back to WebGL1 (same GLSL either way).
268
- const gl = (live && canvas.getContext("webgl2", ctxOpts)) ||
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) ||
269
697
  canvas.getContext("webgl", ctxOpts);
270
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);
271
702
  if (live && !("texElementImage2D" in gl)) {
272
703
  console.info("[nodality] texElementImage2D missing on the created context - snapshot fallback.");
273
704
  canvas.removeAttribute("layoutsubtree");
@@ -324,45 +755,228 @@ function applyRasterPipeline(el, rasterNodes) {
324
755
  const prog = gl.createProgram();
325
756
  gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
326
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");
327
762
  gl.linkProgram(prog);
328
763
  gl.useProgram(prog);
329
764
  gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
330
765
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
331
- const aLoc = gl.getAttribLocation(prog, "a");
332
- gl.enableVertexAttribArray(aLoc);
333
- gl.vertexAttribPointer(aLoc, 2, gl.FLOAT, false, 0, 0);
766
+ gl.enableVertexAttribArray(0);
767
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
334
768
  const U = (n) => gl.getUniformLocation(prog, n);
335
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
+
336
777
  // Static uniforms from the node data.
337
778
  rasterNodes.forEach((node, i) => {
338
779
  const def = REGISTRY[node.op];
339
780
  if (!def || !def.uniforms) return;
340
781
  const us = def.uniforms(node, dpr);
341
782
  for (const key in us) {
342
- const [kind, value] = us[key];
343
- const loc = U(`u${i}_${key}`);
344
- if (kind === "1f") gl.uniform1f(loc, value);
345
- else if (kind === "2fv") gl.uniform2fv(loc, value);
346
- else if (kind === "3fv") gl.uniform3fv(loc, value);
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;
347
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);
348
895
  });
349
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
+
350
927
  const tex = gl.createTexture();
351
928
  gl.bindTexture(gl.TEXTURE_2D, tex);
352
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
929
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER,
930
+ needMips ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR);
353
931
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
354
932
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
355
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); };
356
937
 
357
938
  let textureReady = false;
358
939
  let destroyed = false;
359
940
  let mode = live ? "live" : "snapshot";
360
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
+
361
953
  // Snapshot the host's children (excluding our own canvas). Once the
362
954
  // texture is live, the host's own painting is hidden via the
363
955
  // visibility trick (the canvas child re-shows itself) so transparent
364
956
  // hosts — e.g. a bare headline — don't ghost-double under the effect.
365
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
+ }
366
980
  canvas.style.display = "none";
367
981
  const prevVisibility = el.style.visibility;
368
982
  el.style.visibility = "";
@@ -371,8 +985,13 @@ function applyRasterPipeline(el, rasterNodes) {
371
985
  if (destroyed) return;
372
986
  gl.bindTexture(gl.TEXTURE_2D, tex);
373
987
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
988
+ finishUpload();
374
989
  textureReady = true;
375
- el.style.visibility = "hidden";
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";
376
995
  canvas.style.visibility = "visible";
377
996
  })
378
997
  .catch((e) => {
@@ -422,6 +1041,7 @@ function applyRasterPipeline(el, rasterNodes) {
422
1041
  for (const i of order) {
423
1042
  try {
424
1043
  gl.texElementImage2D.apply(gl, forms[i]);
1044
+ finishUpload();
425
1045
  if (liveForm < 0) {
426
1046
  liveForm = i;
427
1047
  console.info("[nodality] texElementImage2D signature discovered: form",
@@ -484,11 +1104,41 @@ function applyRasterPipeline(el, rasterNodes) {
484
1104
  const draw = () => {
485
1105
  if (destroyed) return;
486
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);
487
1117
  gl.viewport(0, 0, canvas.width, canvas.height);
488
1118
  gl.clearColor(0, 0, 0, 0);
489
1119
  gl.clear(gl.COLOR_BUFFER_BIT);
490
1120
  gl.uniform2f(U("u_res"), canvas.width, canvas.height);
491
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;
492
1142
  gl.drawArrays(gl.TRIANGLES, 0, 3);
493
1143
  }
494
1144
  raf = requestAnimationFrame(draw);
@@ -583,6 +1233,14 @@ function applyRasterPipeline(el, rasterNodes) {
583
1233
  el.removeEventListener("mousemove", onMove);
584
1234
  el.removeEventListener("touchmove", onMove);
585
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;
586
1244
  while (sourceEl !== el && sourceEl.firstChild) el.appendChild(sourceEl.firstChild);
587
1245
  el.style.visibility = "";
588
1246
  canvas.remove();