nodality 1.0.181 → 1.0.184

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 +662 -230
  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 +2 -1
package/lib/raster-ops.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * nodality v1.0.181
2
+ * nodality v1.0.184
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -46,6 +46,32 @@
46
46
 
47
47
  const MAX_BLOBS = 12;
48
48
 
49
+ // ── Drivers ──────────────────────────────────────────────────────────
50
+ // What steers a reactive op. Previously "react to the pointer" was
51
+ // hardcoded into offset and duotone; a driver makes the input a value in
52
+ // the node data, so the same op can be aimed at different signals:
53
+ //
54
+ // { op: "offset", by: "mouse" } { op: "offset", by: "scroll" }
55
+ //
56
+ // Each driver returns a focus point in device pixels plus an amount in
57
+ // 0..1 that the op scales its effect by. Ops read them as u<i>_dpos and
58
+ // u<i>_damt; the pipeline evaluates and uploads them once per frame.
59
+ const DRIVERS = {
60
+ mouse: (c) => ({ x: c.mouseX, y: c.mouseY, amt: 1 }),
61
+ // Same focus as mouse, but faded in and out with the hover state, so
62
+ // the effect resolves away when the pointer leaves.
63
+ hover: (c) => ({ x: c.mouseX, y: c.mouseY, amt: c.hover }),
64
+ // A band that travels up the element as it crosses the viewport.
65
+ scroll: (c) => ({ x: c.w * 0.5, y: c.h * c.scroll, amt: 1 }),
66
+ // Hands-free: the focus drifts on a Lissajous path.
67
+ time: (c) => ({
68
+ x: c.w * (0.5 + 0.34 * Math.cos(c.t * 0.6)),
69
+ y: c.h * (0.5 + 0.34 * Math.sin(c.t * 0.8)),
70
+ amt: 1,
71
+ }),
72
+ };
73
+ const DRIVER_NAMES = Object.keys(DRIVERS);
74
+
49
75
  const REGISTRY = {
50
76
  hexalize: {
51
77
  stage: "cell",
@@ -71,13 +97,17 @@ const REGISTRY = {
71
97
  // version shifted only the texture lookup, which left grid
72
98
  // edges frozen in screen space.)
73
99
  stage: "warp",
74
- decl: (p) => `uniform float ${p}strength; uniform float ${p}radius;`,
100
+ // Steered by the pointer unless the node says otherwise, which is
101
+ // what this op did before drivers existed.
102
+ defaultDriver: "mouse",
103
+ decl: (p) => `uniform float ${p}strength; uniform float ${p}radius;
104
+ uniform vec2 ${p}dpos; uniform float ${p}damt;`,
75
105
  code: (p) => `
76
106
  {
77
- float d = length(warped - u_mouse);
107
+ float d = length(warped - ${p}dpos);
78
108
  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;
109
+ vec2 dir = d > 0.5 ? (warped - ${p}dpos) / d : vec2(0.0);
110
+ warped -= dir * ${p}strength * fall * ${p}damt;
81
111
  }`,
82
112
  uniforms: (node, dpr) => ({
83
113
  strength: ["1f", (node.strength || 20) * dpr],
@@ -87,13 +117,15 @@ const REGISTRY = {
87
117
 
88
118
  duotone: {
89
119
  stage: "color",
90
- decl: (p) => `uniform vec3 ${p}a; uniform vec3 ${p}b; uniform float ${p}radius;`,
120
+ decl: (p) => `uniform vec3 ${p}a; uniform vec3 ${p}b; uniform float ${p}radius;
121
+ uniform vec2 ${p}dpos; uniform float ${p}damt;`,
91
122
  code: (p, node) => `
92
123
  {
93
124
  float lum = dot(col, vec3(0.299, 0.587, 0.114));
94
125
  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));`
126
+ ${node.by
127
+ ? `float mask = (1.0 - smoothstep(${p}radius * 0.35, ${p}radius,
128
+ length(center - ${p}dpos))) * ${p}damt;`
97
129
  : `float mask = 1.0;`}
98
130
  col = mix(col, duo, mask);
99
131
  }`,
@@ -146,126 +178,429 @@ const REGISTRY = {
146
178
  // projection at all — the whole simulation is a single pass.
147
179
  //
148
180
  // { op: "stir", strength: 26, swirl: 2.4, decay: 0.985 }
181
+ // Halftone: the print dot screen. Tone is carried by dot AREA on a
182
+ // rotated grid rather than by intensity, the way offset lithography
183
+ // reproduces a continuous-tone image with a single ink.
184
+ //
185
+ // { op: "halftone", size: 6, angle: 15, ink: "#0B1B2B" }
186
+ //
187
+ // With a driver the screen coarsens toward the focus, like holding a
188
+ // loupe over the sheet.
189
+ halftone: {
190
+ stage: "color",
191
+ decl: (p) => `
192
+ uniform float ${p}size;
193
+ uniform float ${p}angle;
194
+ uniform vec3 ${p}ink;
195
+ uniform vec3 ${p}paper;
196
+ uniform float ${p}soft;
197
+ uniform float ${p}radius;
198
+ uniform vec2 ${p}dpos;
199
+ uniform float ${p}damt;`,
200
+ code: (p, node) => `
201
+ {
202
+ float lum = dot(col, vec3(0.299, 0.587, 0.114));
203
+ float sz = ${p}size;${node.by ? `
204
+ float fd = 1.0 - smoothstep(0.0, ${p}radius, length(frag - ${p}dpos));
205
+ sz *= 1.0 + fd * ${p}damt * 1.8;` : ``}
206
+ // Screen angle: rotating the grid is what stops the dots
207
+ // reading as a horizontal/vertical texture.
208
+ float ca = cos(${p}angle), sa = sin(${p}angle);
209
+ vec2 rp = vec2(ca * frag.x - sa * frag.y,
210
+ sa * frag.x + ca * frag.y) / max(sz, 1.0);
211
+ vec2 cell = fract(rp) - 0.5;
212
+ // Dot radius from tone: darker tone prints a bigger dot.
213
+ // sqrt because ink coverage goes as area, not radius.
214
+ float rr = sqrt(clamp(1.0 - lum, 0.0, 1.0)) * 0.55;
215
+ float dm = smoothstep(rr, rr - ${p}soft, length(cell));
216
+ col = mix(${p}paper, ${p}ink, dm);
217
+ }`,
218
+ uniforms: (node, dpr) => ({
219
+ size: ["1f", (node.size || 6) * dpr],
220
+ angle: ["1f", ((node.angle != null ? node.angle : 15) * Math.PI) / 180],
221
+ ink: ["3fv", hexToRgb(node.ink || "#0B1B2B")],
222
+ paper: ["3fv", hexToRgb(node.paper || "#FFFFFF")],
223
+ soft: ["1f", node.softness != null ? node.softness : 0.08],
224
+ radius: ["1f", (node.radius || 220) * dpr],
225
+ }),
226
+ },
227
+
228
+ // Chromatic aberration: the only op that uses the displace stage. It
229
+ // does not move the sample position itself, it sets `chroma`, the
230
+ // per-channel split the sampler applies - red and blue are fetched
231
+ // either side of green along the radial direction, the way a simple
232
+ // lens disperses wavelengths toward its edges.
233
+ //
234
+ // { op: "aberration", amount: 6 } // radial, lens-like
235
+ // { op: "aberration", amount: 14, by: "mouse" } // focused at the pointer
236
+ aberration: {
237
+ stage: "displace",
238
+ chroma: true,
239
+ decl: (p) => `
240
+ uniform float ${p}amount;
241
+ uniform float ${p}radius;
242
+ uniform vec2 ${p}dpos;
243
+ uniform float ${p}damt;`,
244
+ code: (p, node) => `
245
+ {
246
+ vec2 ctr = ${node.by ? `${p}dpos` : `u_res * 0.5`};
247
+ vec2 dv = sampleP - ctr;
248
+ float dl = length(dv);
249
+ vec2 dir = dl > 0.5 ? dv / dl : vec2(0.0);
250
+ ${node.by
251
+ ? `float amt = ${p}amount * ${p}damt *
252
+ (1.0 - smoothstep(0.0, ${p}radius, dl));`
253
+ : `float amt = ${p}amount * (dl / max(length(u_res) * 0.5, 1.0));`}
254
+ chroma += dir * amt;
255
+ }`,
256
+ uniforms: (node, dpr) => ({
257
+ amount: ["1f", (node.amount != null ? node.amount : 6) * dpr],
258
+ radius: ["1f", (node.radius || 240) * dpr],
259
+ }),
260
+ },
261
+
262
+ // Stirred liquid: an incompressible fluid whose velocity field warps
263
+ // the content and whose dye field colours it.
264
+ //
265
+ // Implemented from the published algorithm, not from any particular
266
+ // existing codebase:
267
+ // J. Stam, "Stable Fluids", SIGGRAPH '99, 121-128 - the
268
+ // unconditionally stable operator splitting used here: add
269
+ // forces, advect by a backward particle trace, then project the
270
+ // velocity onto its divergence-free part via a Poisson solve.
271
+ // M. Harris, "Fast Fluid Dynamics Simulation on the GPU", GPU Gems
272
+ // ch. 38 (2003) - mapping each operator onto a fragment program
273
+ // over ping-ponged textures, Jacobi iterations for the pressure.
274
+ // R. Fedkiw, J. Stam, H. Jensen, "Visual Simulation of Smoke",
275
+ // SIGGRAPH '01 - vorticity confinement, which restores the
276
+ // small-scale swirl that a coarse grid numerically damps away.
277
+ //
278
+ // Per frame: splat -> curl -> confine -> divergence -> pressure
279
+ // (Jacobi) -> subtract gradient -> advect velocity -> advect dye.
280
+ // The projection step is what distinguishes this from a plain
281
+ // velocity-texture smear: enforcing div(v)=0 is what makes the flow
282
+ // roll into persistent vortices instead of piling up and dissipating.
283
+ //
284
+ // Needs WebGL2 with float render targets; without them the op
285
+ // disables itself and the rest of the chain still renders.
149
286
  stir: {
150
- // Warp: swirl the coordinate space (so a cell op downstream tiles
151
- // the ALREADY-stirred content). Color: a faint moving sheen.
152
287
  stage: ["warp", "color"],
153
288
  decl: (p) => `
154
- uniform sampler2D ${p}field;
289
+ uniform sampler2D ${p}vel;
290
+ uniform sampler2D ${p}dye;
155
291
  uniform float ${p}strength;
292
+ uniform float ${p}dyemix;
293
+ uniform float ${p}intensity;
156
294
  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
- }`,
295
+ uniform vec3 ${p}tint;`,
164
296
  code: (p, node, stage) => stage === "warp" ? `
165
297
  {
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.
298
+ // frag/warped are top-down pixels, the field is bottom-up
299
+ // [0,1]: flip y going in, and flip the y of the vector coming
300
+ // out so the content drags WITH the pointer.
169
301
  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;
302
+ vec2 v = texture2D(${p}vel, clamp(fuv, 0.001, 0.999)).xy;
303
+ warped += vec2(v.x, -v.y) * ${p}strength;
172
304
  }` : `
173
305
  {
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);
306
+ // Sampled at the CONTINUOUS warped position, never at the
307
+ // cell centre: a cell op upstream (hexalize) snaps the cell
308
+ // centre to one point per tile, which would quantise the
309
+ // liquid into flat per-hex patches instead of smooth swirls.
310
+ vec2 suv = vec2(warped.x / u_res.x, 1.0 - warped.y / u_res.y);
311
+ vec3 dc = texture2D(${p}dye, clamp(suv, 0.001, 0.999)).rgb;
312
+ float mag = length(dc);
313
+ // Colour is carried IN the dye field and advected with it, so
314
+ // a ribbon keeps its hue while the flow folds it. Deriving
315
+ // colour from the local flow direction instead would make
316
+ // neighbouring pixels jump hue and read as confetti.
317
+ vec3 liquid = mag > 1e-4 ? clamp(dc / mag * 1.25, 0.0, 1.0) : ${p}tint;
318
+ float amt = clamp((1.0 - exp(-mag * ${p}intensity)) * ${p}dyemix, 0.0, 1.0);
319
+ // Straight-alpha OVER so a later overlay op (blobs) still
320
+ // composites correctly on top of the liquid.
321
+ float na = amt + ovA * (1.0 - amt);
322
+ ovCol = (liquid * amt + ovCol * ovA * (1.0 - amt)) / max(na, 1e-4);
323
+ ovA = na;
324
+
325
+ // Faint sheen on the content itself where the flow is fast.
326
+ float sp = length(texture2D(${p}vel, clamp(suv, 0.001, 0.999)).xy);
327
+ col = mix(col, ${p}tint, (1.0 - exp(-sp * 0.7)) * ${p}sheen);
180
328
  }`,
181
329
  uniforms: (node, dpr) => ({
182
330
  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")],
331
+ sheen: ["1f", node.sheen != null ? node.sheen : 0.15],
332
+ tint: ["3fv", hexToRgb(node.tint || "#7FD4FF")],
333
+ dyemix: ["1f", node.dye != null ? node.dye : 0.85],
334
+ intensity: ["1f", node.intensity != null ? node.intensity : 1.6],
185
335
  }),
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: () => `
336
+
337
+ // ── The simulation ────────────────────────────────────────────
338
+ // Declared as data: the pipeline allocates the targets, compiles
339
+ // the programs, and calls step() once per frame.
340
+ solver: {
341
+ resolutions: (node) => ({
342
+ sim: Math.max(32, Math.min(node.resolution || 128, 512)),
343
+ dye: Math.max(64, Math.min(node.dyeResolution || 512, 1024)),
344
+ }),
345
+ // read/write pairs ping-pong; single targets are scratch.
346
+ targets: {
347
+ velocity: { double: true, res: "sim", fmt: "rg", smooth: true },
348
+ dye: { double: true, res: "dye", fmt: "rgba", smooth: true },
349
+ pressure: { double: true, res: "sim", fmt: "r", smooth: false },
350
+ divergence: { double: false, res: "sim", fmt: "r", smooth: false },
351
+ vorticity: { double: false, res: "sim", fmt: "r", smooth: false },
352
+ },
353
+ // Which target each main-pass sampler reads.
354
+ samplers: { vel: "velocity", dye: "dye" },
355
+
356
+ programs: {
357
+ // Gaussian source term. Distance is measured as a
358
+ // fraction of the element's LONGER side so the radius
359
+ // means the same thing whatever the element's shape.
360
+ splat: `
191
361
  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
- }
362
+ uniform sampler2D u_src;
363
+ uniform vec2 u_texel;
364
+ uniform vec2 u_point;
365
+ uniform vec3 u_amount;
366
+ uniform float u_radius;
367
+ uniform float u_aspect;
368
+ void main() {
369
+ vec2 uv = gl_FragCoord.xy * u_texel;
370
+ vec2 d = uv - u_point;
371
+ if (u_aspect >= 1.0) d.y /= u_aspect; else d.x *= u_aspect;
372
+ float g = exp(-dot(d, d) / max(u_radius * u_radius, 1e-6));
373
+ gl_FragColor = vec4(texture2D(u_src, uv).xyz + u_amount * g, 1.0);
374
+ }`,
211
375
 
376
+ // Semi-Lagrangian advection (Stam 1999): trace the
377
+ // characteristic backwards and resample. Unconditionally
378
+ // stable for any timestep, which is the whole point.
379
+ advect: `
380
+ precision highp float;
381
+ uniform sampler2D u_vel;
382
+ uniform sampler2D u_src;
383
+ uniform vec2 u_texel;
384
+ uniform float u_dt;
385
+ uniform float u_diss;
212
386
  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
- }
387
+ vec2 uv = gl_FragCoord.xy * u_texel;
388
+ vec2 back = uv - texture2D(u_vel, uv).xy * u_dt;
389
+ gl_FragColor = vec4(texture2D(u_src, clamp(back, 0.0, 1.0)).xyz * u_diss, 1.0);
390
+ }`,
391
+
392
+ // Scalar vorticity of a 2D field: d(vy)/dx - d(vx)/dy.
393
+ curl: `
394
+ precision highp float;
395
+ uniform sampler2D u_vel;
396
+ uniform vec2 u_texel;
397
+ void main() {
398
+ vec2 uv = gl_FragCoord.xy * u_texel;
399
+ float r = texture2D(u_vel, uv + vec2(u_texel.x, 0.0)).y;
400
+ float l = texture2D(u_vel, uv - vec2(u_texel.x, 0.0)).y;
401
+ float t = texture2D(u_vel, uv + vec2(0.0, u_texel.y)).x;
402
+ float b = texture2D(u_vel, uv - vec2(0.0, u_texel.y)).x;
403
+ gl_FragColor = vec4(0.5 * ((r - l) - (t - b)), 0.0, 0.0, 1.0);
404
+ }`,
251
405
 
252
- // Keep the field inside the encodable range.
253
- float mg = length(a);
254
- if (mg > s_range) a *= s_range / mg;
406
+ // Vorticity confinement (Fedkiw et al. 2001): push the
407
+ // velocity along N x omega, where N is the unit gradient
408
+ // of |omega|, i.e. toward the centre of each eddy. This
409
+ // feeds energy back into swirl the grid would otherwise
410
+ // smooth out.
411
+ confine: `
412
+ precision highp float;
413
+ uniform sampler2D u_vel;
414
+ uniform sampler2D u_curl;
415
+ uniform vec2 u_texel;
416
+ uniform float u_eps;
417
+ uniform float u_dt;
418
+ void main() {
419
+ vec2 uv = gl_FragCoord.xy * u_texel;
420
+ float r = texture2D(u_curl, uv + vec2(u_texel.x, 0.0)).x;
421
+ float l = texture2D(u_curl, uv - vec2(u_texel.x, 0.0)).x;
422
+ float t = texture2D(u_curl, uv + vec2(0.0, u_texel.y)).x;
423
+ float b = texture2D(u_curl, uv - vec2(0.0, u_texel.y)).x;
424
+ float c = texture2D(u_curl, uv).x;
425
+ vec2 grad = 0.5 * vec2(abs(r) - abs(l), abs(t) - abs(b));
426
+ vec2 n = grad / (length(grad) + 1e-5);
427
+ vec2 force = u_eps * c * vec2(n.y, -n.x);
428
+ vec2 v = texture2D(u_vel, uv).xy + force * u_dt;
429
+ gl_FragColor = vec4(clamp(v, -64.0, 64.0), 0.0, 1.0);
430
+ }`,
255
431
 
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);
432
+ // div(v). The walls reflect, so the fluid stays in the box.
433
+ divergence: `
434
+ precision highp float;
435
+ uniform sampler2D u_vel;
436
+ uniform vec2 u_texel;
437
+ void main() {
438
+ vec2 uv = gl_FragCoord.xy * u_texel;
439
+ float r = texture2D(u_vel, uv + vec2(u_texel.x, 0.0)).x;
440
+ float l = texture2D(u_vel, uv - vec2(u_texel.x, 0.0)).x;
441
+ float t = texture2D(u_vel, uv + vec2(0.0, u_texel.y)).y;
442
+ float b = texture2D(u_vel, uv - vec2(0.0, u_texel.y)).y;
443
+ vec2 c = texture2D(u_vel, uv).xy;
444
+ if (uv.x - u_texel.x < 0.0) l = -c.x;
445
+ if (uv.x + u_texel.x > 1.0) r = -c.x;
446
+ if (uv.y - u_texel.y < 0.0) b = -c.y;
447
+ if (uv.y + u_texel.y > 1.0) t = -c.y;
448
+ gl_FragColor = vec4(0.5 * ((r - l) + (t - b)), 0.0, 0.0, 1.0);
259
449
  }`,
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)),
450
+
451
+ // One Jacobi sweep of the Poisson equation lap(p) = div(v)
452
+ // (Harris 2003). Run a handful of these per frame.
453
+ jacobi: `
454
+ precision highp float;
455
+ uniform sampler2D u_p;
456
+ uniform sampler2D u_div;
457
+ uniform vec2 u_texel;
458
+ void main() {
459
+ vec2 uv = gl_FragCoord.xy * u_texel;
460
+ float r = texture2D(u_p, uv + vec2(u_texel.x, 0.0)).x;
461
+ float l = texture2D(u_p, uv - vec2(u_texel.x, 0.0)).x;
462
+ float t = texture2D(u_p, uv + vec2(0.0, u_texel.y)).x;
463
+ float b = texture2D(u_p, uv - vec2(0.0, u_texel.y)).x;
464
+ float d = texture2D(u_div, uv).x;
465
+ gl_FragColor = vec4((r + l + t + b - d) * 0.25, 0.0, 0.0, 1.0);
466
+ }`,
467
+
468
+ // v <- v - grad(p): the projection itself. After this the
469
+ // velocity is (discretely) divergence free.
470
+ subtract: `
471
+ precision highp float;
472
+ uniform sampler2D u_p;
473
+ uniform sampler2D u_vel;
474
+ uniform vec2 u_texel;
475
+ void main() {
476
+ vec2 uv = gl_FragCoord.xy * u_texel;
477
+ float r = texture2D(u_p, uv + vec2(u_texel.x, 0.0)).x;
478
+ float l = texture2D(u_p, uv - vec2(u_texel.x, 0.0)).x;
479
+ float t = texture2D(u_p, uv + vec2(0.0, u_texel.y)).x;
480
+ float b = texture2D(u_p, uv - vec2(0.0, u_texel.y)).x;
481
+ vec2 v = texture2D(u_vel, uv).xy - 0.5 * vec2(r - l, t - b);
482
+ gl_FragColor = vec4(v, 0.0, 1.0);
483
+ }`,
484
+
485
+ // Scale a target in place (used to carry a fraction of
486
+ // the previous pressure into the next solve).
487
+ decay: `
488
+ precision highp float;
489
+ uniform sampler2D u_tex;
490
+ uniform vec2 u_texel;
491
+ uniform float u_value;
492
+ void main() {
493
+ gl_FragColor = texture2D(u_tex, gl_FragCoord.xy * u_texel) * u_value;
494
+ }`,
495
+ },
496
+
497
+ // Colour dropped in by the spoon. With rainbow on, the hue
498
+ // comes from the stroke direction at the moment of injection
499
+ // and is then carried by the dye, so it stays coherent as the
500
+ // flow folds it.
501
+ splatColor: (node, dx, dy) => {
502
+ const amt = node.dyeAmount != null ? node.dyeAmount : 0.6;
503
+ if (!node.rainbow) {
504
+ const c = hexToRgb(node.tint || "#7FD4FF");
505
+ return [c[0] * amt, c[1] * amt, c[2] * amt];
506
+ }
507
+ const a = Math.atan2(dy, dx);
508
+ return [
509
+ (0.5 + 0.5 * Math.cos(a)) * amt,
510
+ (0.5 + 0.5 * Math.cos(a + 2.0944)) * amt,
511
+ (0.5 + 0.5 * Math.cos(a + 4.1888)) * amt,
512
+ ];
513
+ },
514
+
515
+ step: (S, node, ctx) => {
516
+ const dt = Math.min(ctx.dt, 1 / 30);
517
+ const simT = 1 / S.res.sim;
518
+ const dyeT = 1 / S.res.dye;
519
+ const pow = (base, d) => Math.pow(base, d * 60);
520
+
521
+ // 1. Forces: splat momentum and dye at the pointer.
522
+ if (ctx.moving) {
523
+ const f = node.force != null ? node.force : 1.0;
524
+ S.use("splat");
525
+ S.f1("u_radius", node.radius != null ? node.radius : 0.1);
526
+ S.f1("u_aspect", ctx.aspect);
527
+ S.f2("u_point", ctx.px, ctx.py);
528
+
529
+ S.f2("u_texel", simT, simT);
530
+ S.smp("u_src", S.read("velocity"), 0);
531
+ S.f3("u_amount", ctx.dx * f, ctx.dy * f, 0);
532
+ S.blit(S.write("velocity"));
533
+ S.swap("velocity");
534
+
535
+ S.f2("u_texel", dyeT, dyeT);
536
+ S.smp("u_src", S.read("dye"), 0);
537
+ S.f3("u_amount", ctx.col[0], ctx.col[1], ctx.col[2]);
538
+ S.blit(S.write("dye"));
539
+ S.swap("dye");
540
+ }
541
+
542
+ // 2. Vorticity confinement.
543
+ S.use("curl");
544
+ S.f2("u_texel", simT, simT);
545
+ S.smp("u_vel", S.read("velocity"), 0);
546
+ S.blit(S.single("vorticity"));
547
+
548
+ S.use("confine");
549
+ S.f2("u_texel", simT, simT);
550
+ S.f1("u_eps", node.curl != null ? node.curl : 2.2);
551
+ S.f1("u_dt", dt);
552
+ S.smp("u_vel", S.read("velocity"), 0);
553
+ S.smp("u_curl", S.single("vorticity"), 1);
554
+ S.blit(S.write("velocity"));
555
+ S.swap("velocity");
556
+
557
+ // 3. Projection: divergence -> pressure -> subtract.
558
+ S.use("divergence");
559
+ S.f2("u_texel", simT, simT);
560
+ S.smp("u_vel", S.read("velocity"), 0);
561
+ S.blit(S.single("divergence"));
562
+
563
+ S.use("decay");
564
+ S.f2("u_texel", simT, simT);
565
+ S.f1("u_value", pow(node.pressure != null ? node.pressure : 0.8, dt));
566
+ S.smp("u_tex", S.read("pressure"), 0);
567
+ S.blit(S.write("pressure"));
568
+ S.swap("pressure");
569
+
570
+ S.use("jacobi");
571
+ S.f2("u_texel", simT, simT);
572
+ S.smp("u_div", S.single("divergence"), 1);
573
+ const iters = Math.max(1, Math.min(node.pressureIterations || 6, 40));
574
+ for (let k = 0; k < iters; k++) {
575
+ S.smp("u_p", S.read("pressure"), 0);
576
+ S.blit(S.write("pressure"));
577
+ S.swap("pressure");
578
+ }
579
+
580
+ S.use("subtract");
581
+ S.f2("u_texel", simT, simT);
582
+ S.smp("u_p", S.read("pressure"), 0);
583
+ S.smp("u_vel", S.read("velocity"), 1);
584
+ S.blit(S.write("velocity"));
585
+ S.swap("velocity");
586
+
587
+ // 4. Advection: velocity by itself, then dye by velocity.
588
+ S.use("advect");
589
+ S.f1("u_dt", dt);
590
+ S.f2("u_texel", simT, simT);
591
+ S.smp("u_vel", S.read("velocity"), 0);
592
+ S.smp("u_src", S.read("velocity"), 0);
593
+ S.f1("u_diss", pow(node.decay != null ? node.decay : 0.985, dt));
594
+ S.blit(S.write("velocity"));
595
+ S.swap("velocity");
596
+
597
+ S.f2("u_texel", dyeT, dyeT);
598
+ S.smp("u_vel", S.read("velocity"), 0);
599
+ S.smp("u_src", S.read("dye"), 1);
600
+ S.f1("u_diss", pow(node.dyeFade != null ? node.dyeFade : 0.97, dt));
601
+ S.blit(S.write("dye"));
602
+ S.swap("dye");
603
+ },
269
604
  },
270
605
  },
271
606
 
@@ -575,6 +910,13 @@ function buildFragmentShader(nodes) {
575
910
  // darkening only applies when a cell op runs without an edges op.
576
911
  const hasEdges = nodes.some((n) => n.op === "edges");
577
912
  const defaultSeam = hasEdges ? "" : " col *= 1.0 - edge * 0.55;";
913
+ // Per-channel resampling costs two extra fetches, so it is only
914
+ // emitted when a displace-stage op actually asks for dispersion.
915
+ const hasChroma = nodes.some((n) => (REGISTRY[n.op] || {}).chroma);
916
+ const chromaFetch = hasChroma ? `
917
+ vec2 cpx = vec2(chroma.x, -chroma.y) / u_res;
918
+ col.r = texture2D(u_tex, clamp(uv + cpx, 0.001, 0.999)).r;
919
+ col.b = texture2D(u_tex, clamp(uv - cpx, 0.001, 0.999)).b;` : "";
578
920
  return `
579
921
  precision highp float;
580
922
  uniform sampler2D u_tex;
@@ -590,10 +932,11 @@ ${warp}
590
932
  float edge = 0.0;
591
933
  ${cell}
592
934
  vec2 sampleP = warped;
935
+ vec2 chroma = vec2(0.0);
593
936
  ${displace}
594
937
  vec2 uv = vec2(sampleP.x / u_res.x, 1.0 - sampleP.y / u_res.y);
595
938
  vec4 tex = texture2D(u_tex, clamp(uv, 0.001, 0.999));
596
- vec3 col = tex.rgb;
939
+ vec3 col = tex.rgb;${chromaFetch}
597
940
  vec3 edgeCol = vec3(1.0);
598
941
  float edgeCov = 0.0;
599
942
  vec3 ovCol = vec3(0.0);
@@ -795,133 +1138,171 @@ function applyRasterPipeline(el, rasterNodes) {
795
1138
  });
796
1139
  let lastFrame = (typeof performance !== "undefined") ? performance.now() : 0;
797
1140
 
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) => {
1141
+ // ── Fluid solvers (ops with a `solver` descriptor, e.g. stir) ─────
1142
+ // An op may declare a set of render targets and fragment programs
1143
+ // plus a step() that sequences the passes. The pipeline allocates
1144
+ // everything here, runs step() once per frame, and binds the named
1145
+ // result textures into the main composite pass.
1146
+ //
1147
+ // Float render targets are required (velocity and pressure are
1148
+ // signed and need range well outside [0,1]). Without them the op
1149
+ // disables itself rather than rendering something wrong.
1150
+ const solverOps = [];
1151
+ const makeSolver = (node, i) => {
806
1152
  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;
1153
+ if (!isGL2 || !gl.getExtension("EXT_color_buffer_float")) {
1154
+ console.info("[nodality] '" + node.op +
1155
+ "' needs WebGL2 float render targets - op skipped.");
1156
+ return null;
1157
+ }
1158
+ gl.getExtension("OES_texture_float_linear");
1159
+ const res = def.solver.resolutions(node);
1160
+ const FMT = {
1161
+ r: [gl.R16F, gl.RED],
1162
+ rg: [gl.RG16F, gl.RG],
1163
+ rgba: [gl.RGBA16F, gl.RGBA],
1164
+ };
1165
+
1166
+ const made = [];
1167
+ const mkTarget = (size, fmt, smooth) => {
1168
+ const [internal, format] = FMT[fmt];
1169
+ const t = gl.createTexture();
1170
+ gl.bindTexture(gl.TEXTURE_2D, t);
1171
+ const flt = smooth ? gl.LINEAR : gl.NEAREST;
1172
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, flt);
1173
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, flt);
1174
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1175
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1176
+ gl.texImage2D(gl.TEXTURE_2D, 0, internal, size, size, 0,
1177
+ format, gl.HALF_FLOAT, null);
1178
+ const fbo = gl.createFramebuffer();
1179
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
1180
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
1181
+ gl.TEXTURE_2D, t, 0);
1182
+ const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
1183
+ gl.viewport(0, 0, size, size);
1184
+ gl.clearColor(0, 0, 0, 1);
1185
+ gl.clear(gl.COLOR_BUFFER_BIT);
1186
+ const rec = { tex: t, fbo, size, ok };
1187
+ made.push(rec);
1188
+ return rec;
846
1189
  };
847
1190
 
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;
1191
+ const targets = {};
1192
+ let allOk = true;
1193
+ for (const name in def.solver.targets) {
1194
+ const spec = def.solver.targets[name];
1195
+ const size = res[spec.res];
1196
+ if (spec.double) {
1197
+ targets[name] = {
1198
+ a: mkTarget(size, spec.fmt, spec.smooth),
1199
+ b: mkTarget(size, spec.fmt, spec.smooth),
1200
+ };
1201
+ allOk = allOk && targets[name].a.ok && targets[name].b.ok;
1202
+ } else {
1203
+ targets[name] = { a: mkTarget(size, spec.fmt, spec.smooth) };
1204
+ allOk = allOk && targets[name].a.ok;
1205
+ }
1206
+ }
1207
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1208
+
1209
+ const cleanup = () => made.forEach((t) => {
1210
+ gl.deleteFramebuffer(t.fbo);
1211
+ gl.deleteTexture(t.tex);
1212
+ });
852
1213
 
853
- let simProg;
1214
+ if (!allOk) {
1215
+ console.info("[nodality] '" + node.op +
1216
+ "' float targets incomplete - op skipped.");
1217
+ cleanup();
1218
+ return null;
1219
+ }
1220
+
1221
+ const progs = {};
854
1222
  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));
1223
+ for (const name in def.solver.programs) {
1224
+ const pr = gl.createProgram();
1225
+ gl.attachShader(pr, compile(gl.VERTEX_SHADER, VS));
1226
+ gl.attachShader(pr, compile(gl.FRAGMENT_SHADER, def.solver.programs[name]));
1227
+ gl.bindAttribLocation(pr, 0, "a");
1228
+ gl.linkProgram(pr);
1229
+ if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) {
1230
+ throw new Error(name + ": " + gl.getProgramInfoLog(pr));
1231
+ }
1232
+ progs[name] = { prog: pr, loc: {} };
862
1233
  }
863
1234
  } 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); });
1235
+ console.warn("[nodality] '" + node.op + "' solver programs failed:", e);
1236
+ cleanup();
1237
+ for (const n in progs) gl.deleteProgram(progs[n].prog);
866
1238
  return null;
867
1239
  }
868
1240
 
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
1241
  gl.useProgram(prog);
880
- gl.uniform1f(U(`u${i}_enc`), enc);
881
- gl.uniform1f(U(`u${i}_range`), RANGE);
882
-
883
1242
  return {
884
- i, res, prog: simProg, SU,
885
- src: pair[0], dst: pair[1],
886
- force: def.field.force(node),
1243
+ i, node, def, res, targets, progs, cleanup,
887
1244
  prevX: 0.5, prevY: 0.5, primed: false,
888
1245
  };
889
1246
  };
890
1247
  rasterNodes.forEach((node, i) => {
891
1248
  const def = REGISTRY[node.op];
892
- if (!def || !def.field) return;
893
- const f = makeField(node, i);
894
- if (f) fieldOps.push(f);
1249
+ if (!def || !def.solver) return;
1250
+ const s = makeSolver(node, i);
1251
+ if (s) solverOps.push(s);
895
1252
  });
896
1253
 
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.
1254
+ // One frame of a solver. `S` is the small API the op's step() drives.
1255
+ const stepSolver = (s, dt) => {
1256
+ let cur = null;
1257
+ const S = {
1258
+ res: s.res,
1259
+ use(name) {
1260
+ cur = s.progs[name];
1261
+ gl.useProgram(cur.prog);
1262
+ },
1263
+ u(name) {
1264
+ if (!(name in cur.loc)) cur.loc[name] = gl.getUniformLocation(cur.prog, name);
1265
+ return cur.loc[name];
1266
+ },
1267
+ f1(n, v) { gl.uniform1f(S.u(n), v); },
1268
+ f2(n, x, y) { gl.uniform2f(S.u(n), x, y); },
1269
+ f3(n, x, y, z) { gl.uniform3f(S.u(n), x, y, z); },
1270
+ smp(n, target, unit) {
1271
+ gl.activeTexture(gl.TEXTURE0 + unit);
1272
+ gl.bindTexture(gl.TEXTURE_2D, target.tex);
1273
+ gl.uniform1i(S.u(n), unit);
1274
+ },
1275
+ read: (name) => s.targets[name].a,
1276
+ write: (name) => s.targets[name].b,
1277
+ single: (name) => s.targets[name].a,
1278
+ swap(name) {
1279
+ const t = s.targets[name];
1280
+ const tmp = t.a; t.a = t.b; t.b = tmp;
1281
+ },
1282
+ blit(target) {
1283
+ gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);
1284
+ gl.viewport(0, 0, target.size, target.size);
1285
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
1286
+ },
1287
+ };
1288
+
1289
+ // Pointer in field space (bottom-up [0,1]) and the velocity it
1290
+ // picked up since the previous frame - the spoon.
908
1291
  const px = mouse[0] / canvas.width;
909
1292
  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;
1293
+ let dx = 0, dy = 0;
1294
+ if (s.primed) {
1295
+ dx = (px - s.prevX) / Math.max(dt, 1e-3);
1296
+ dy = (py - s.prevY) / Math.max(dt, 1e-3);
914
1297
  }
915
- f.prevX = px; f.prevY = py; f.primed = true;
1298
+ s.prevX = px; s.prevY = py; s.primed = true;
1299
+ const moving = Math.abs(dx) + Math.abs(dy) > 1e-3;
916
1300
 
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;
1301
+ s.def.solver.step(S, s.node, {
1302
+ dt, px, py, dx, dy, moving,
1303
+ aspect: canvas.width / Math.max(canvas.height, 1),
1304
+ col: s.def.solver.splatColor(s.node, dx, dy),
1305
+ });
925
1306
  };
926
1307
 
927
1308
  const tex = gl.createTexture();
@@ -1098,6 +1479,35 @@ function applyRasterPipeline(el, rasterNodes) {
1098
1479
  el.addEventListener("mousemove", onMove, { passive: true });
1099
1480
  el.addEventListener("touchmove", onMove, { passive: true });
1100
1481
 
1482
+ // Driver inputs beyond the pointer. hoverTarget flips on enter/leave
1483
+ // and `hover` eases toward it, so a hover-driven op resolves away
1484
+ // smoothly instead of snapping off.
1485
+ let hoverTarget = 0, hover = 0;
1486
+ const onEnter = () => { hoverTarget = 1; };
1487
+ const onLeave = () => { hoverTarget = 0; };
1488
+ el.addEventListener("mouseenter", onEnter, { passive: true });
1489
+ el.addEventListener("mouseleave", onLeave, { passive: true });
1490
+
1491
+ // Which nodes need a driver evaluated, and with what.
1492
+ const driverNodes = [];
1493
+ rasterNodes.forEach((node, i) => {
1494
+ const def = REGISTRY[node.op] || {};
1495
+ const name = node.by || def.defaultDriver;
1496
+ if (name && DRIVERS[name]) driverNodes.push({ i, fn: DRIVERS[name] });
1497
+ else if (node.by) {
1498
+ console.warn("[nodality] unknown driver '" + node.by + "' on op '" +
1499
+ node.op + "' - known drivers: " + DRIVER_NAMES.join(", "));
1500
+ }
1501
+ });
1502
+
1503
+ // How far the element has travelled across the viewport, 0..1.
1504
+ const scrollProgress = () => {
1505
+ if (typeof window === "undefined") return 0.5;
1506
+ const r = el.getBoundingClientRect();
1507
+ const vh = window.innerHeight || 1;
1508
+ return Math.max(0, Math.min(1, (vh - r.top) / (vh + r.height)));
1509
+ };
1510
+
1101
1511
  // Render loop — paused while off-screen.
1102
1512
  let raf = 0;
1103
1513
  let visible = true;
@@ -1107,9 +1517,9 @@ function applyRasterPipeline(el, rasterNodes) {
1107
1517
  const now = (typeof performance !== "undefined") ? performance.now() : lastFrame + 16;
1108
1518
  const dt = Math.max(0, (now - lastFrame) / 1000);
1109
1519
 
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);
1520
+ // 1. Advance stateful fluid solvers into their own off-screen
1521
+ // targets (this rebinds framebuffer/program/textures).
1522
+ for (const s of solverOps) stepSolver(s, dt);
1113
1523
 
1114
1524
  // 2. Composite pass, back on the visible framebuffer.
1115
1525
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
@@ -1120,6 +1530,20 @@ function applyRasterPipeline(el, rasterNodes) {
1120
1530
  gl.uniform2f(U("u_res"), canvas.width, canvas.height);
1121
1531
  gl.uniform2f(U("u_mouse"), mouse[0], mouse[1]);
1122
1532
  gl.uniform1f(U("u_time"), now / 1000);
1533
+ // Evaluate each reactive op's driver and upload its focus.
1534
+ if (driverNodes.length > 0) {
1535
+ hover += (hoverTarget - hover) * (1 - Math.exp(-dt * 8));
1536
+ const dctx = {
1537
+ mouseX: mouse[0], mouseY: mouse[1],
1538
+ w: canvas.width, h: canvas.height,
1539
+ t: now / 1000, hover, scroll: scrollProgress(),
1540
+ };
1541
+ for (const d of driverNodes) {
1542
+ const v = d.fn(dctx);
1543
+ gl.uniform2f(U(`u${d.i}_dpos`), v.x, v.y);
1544
+ gl.uniform1f(U(`u${d.i}_damt`), v.amt);
1545
+ }
1546
+ }
1123
1547
  // Advance and upload per-frame CPU simulations.
1124
1548
  if (dynamicOps.length > 0) {
1125
1549
  const ctx = { w: canvas.width, h: canvas.height, mouseX: mouse[0], mouseY: mouse[1], dt: dt, t: now / 1000 };
@@ -1128,15 +1552,21 @@ function applyRasterPipeline(el, rasterNodes) {
1128
1552
  for (const u of ups) setUniform(U(`u${d.i}_${u.name}`), u.kind, u.value);
1129
1553
  }
1130
1554
  }
1131
- // Content on unit 0, each simulated field on its own unit.
1555
+ // Content on unit 0, then each solver's named result
1556
+ // textures (velocity, dye, ...) on units above it.
1132
1557
  gl.activeTexture(gl.TEXTURE0);
1133
1558
  gl.bindTexture(gl.TEXTURE_2D, tex);
1134
1559
  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
- });
1560
+ let unit = 1;
1561
+ for (const s of solverOps) {
1562
+ const smps = s.def.solver.samplers;
1563
+ for (const key in smps) {
1564
+ gl.activeTexture(gl.TEXTURE0 + unit);
1565
+ gl.bindTexture(gl.TEXTURE_2D, s.targets[smps[key]].a.tex);
1566
+ gl.uniform1i(U(`u${s.i}_${key}`), unit);
1567
+ unit++;
1568
+ }
1569
+ }
1140
1570
  gl.activeTexture(gl.TEXTURE0); // texture uploads assume unit 0
1141
1571
  lastFrame = now;
1142
1572
  gl.drawArrays(gl.TRIANGLES, 0, 3);
@@ -1232,15 +1662,14 @@ function applyRasterPipeline(el, rasterNodes) {
1232
1662
  if (typeof window !== "undefined") window.removeEventListener("resize", onWinResize);
1233
1663
  el.removeEventListener("mousemove", onMove);
1234
1664
  el.removeEventListener("touchmove", onMove);
1665
+ el.removeEventListener("mouseenter", onEnter);
1666
+ el.removeEventListener("mouseleave", onLeave);
1235
1667
  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);
1668
+ for (const s of solverOps) {
1669
+ s.cleanup();
1670
+ for (const n in s.progs) gl.deleteProgram(s.progs[n].prog);
1242
1671
  }
1243
- fieldOps.length = 0;
1672
+ solverOps.length = 0;
1244
1673
  while (sourceEl !== el && sourceEl.firstChild) el.appendChild(sourceEl.firstChild);
1245
1674
  el.style.visibility = "";
1246
1675
  canvas.remove();
@@ -1248,4 +1677,7 @@ function applyRasterPipeline(el, rasterNodes) {
1248
1677
  };
1249
1678
  }
1250
1679
 
1251
- export { applyRasterPipeline, registerRasterOp, RASTER_OP_NAMES, isHTMLInCanvasAvailable };
1680
+ export {
1681
+ applyRasterPipeline, registerRasterOp, RASTER_OP_NAMES,
1682
+ isHTMLInCanvasAvailable, DRIVER_NAMES,
1683
+ };