nodality 1.0.181 → 1.0.182

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 +484 -221
  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.181
2
+ * nodality v1.0.182
3
3
  * (c) 2026 Filip Vabrousek
4
4
  * License: MIT
5
5
  */
@@ -146,126 +146,348 @@ const REGISTRY = {
146
146
  // projection at all — the whole simulation is a single pass.
147
147
  //
148
148
  // { op: "stir", strength: 26, swirl: 2.4, decay: 0.985 }
149
+ // Stirred liquid: an incompressible fluid whose velocity field warps
150
+ // the content and whose dye field colours it.
151
+ //
152
+ // Implemented from the published algorithm, not from any particular
153
+ // existing codebase:
154
+ // J. Stam, "Stable Fluids", SIGGRAPH '99, 121-128 - the
155
+ // unconditionally stable operator splitting used here: add
156
+ // forces, advect by a backward particle trace, then project the
157
+ // velocity onto its divergence-free part via a Poisson solve.
158
+ // M. Harris, "Fast Fluid Dynamics Simulation on the GPU", GPU Gems
159
+ // ch. 38 (2003) - mapping each operator onto a fragment program
160
+ // over ping-ponged textures, Jacobi iterations for the pressure.
161
+ // R. Fedkiw, J. Stam, H. Jensen, "Visual Simulation of Smoke",
162
+ // SIGGRAPH '01 - vorticity confinement, which restores the
163
+ // small-scale swirl that a coarse grid numerically damps away.
164
+ //
165
+ // Per frame: splat -> curl -> confine -> divergence -> pressure
166
+ // (Jacobi) -> subtract gradient -> advect velocity -> advect dye.
167
+ // The projection step is what distinguishes this from a plain
168
+ // velocity-texture smear: enforcing div(v)=0 is what makes the flow
169
+ // roll into persistent vortices instead of piling up and dissipating.
170
+ //
171
+ // Needs WebGL2 with float render targets; without them the op
172
+ // disables itself and the rest of the chain still renders.
149
173
  stir: {
150
- // Warp: swirl the coordinate space (so a cell op downstream tiles
151
- // the ALREADY-stirred content). Color: a faint moving sheen.
152
174
  stage: ["warp", "color"],
153
175
  decl: (p) => `
154
- uniform sampler2D ${p}field;
176
+ uniform sampler2D ${p}vel;
177
+ uniform sampler2D ${p}dye;
155
178
  uniform float ${p}strength;
179
+ uniform float ${p}dyemix;
180
+ uniform float ${p}intensity;
156
181
  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
- }`,
182
+ uniform vec3 ${p}tint;`,
164
183
  code: (p, node, stage) => stage === "warp" ? `
165
184
  {
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.
185
+ // frag/warped are top-down pixels, the field is bottom-up
186
+ // [0,1]: flip y going in, and flip the y of the vector coming
187
+ // out so the content drags WITH the pointer.
169
188
  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;
189
+ vec2 v = texture2D(${p}vel, clamp(fuv, 0.001, 0.999)).xy;
190
+ warped += vec2(v.x, -v.y) * ${p}strength;
172
191
  }` : `
173
192
  {
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);
193
+ // Sampled at the CONTINUOUS warped position, never at the
194
+ // cell centre: a cell op upstream (hexalize) snaps the cell
195
+ // centre to one point per tile, which would quantise the
196
+ // liquid into flat per-hex patches instead of smooth swirls.
197
+ vec2 suv = vec2(warped.x / u_res.x, 1.0 - warped.y / u_res.y);
198
+ vec3 dc = texture2D(${p}dye, clamp(suv, 0.001, 0.999)).rgb;
199
+ float mag = length(dc);
200
+ // Colour is carried IN the dye field and advected with it, so
201
+ // a ribbon keeps its hue while the flow folds it. Deriving
202
+ // colour from the local flow direction instead would make
203
+ // neighbouring pixels jump hue and read as confetti.
204
+ vec3 liquid = mag > 1e-4 ? clamp(dc / mag * 1.25, 0.0, 1.0) : ${p}tint;
205
+ float amt = clamp((1.0 - exp(-mag * ${p}intensity)) * ${p}dyemix, 0.0, 1.0);
206
+ // Straight-alpha OVER so a later overlay op (blobs) still
207
+ // composites correctly on top of the liquid.
208
+ float na = amt + ovA * (1.0 - amt);
209
+ ovCol = (liquid * amt + ovCol * ovA * (1.0 - amt)) / max(na, 1e-4);
210
+ ovA = na;
211
+
212
+ // Faint sheen on the content itself where the flow is fast.
213
+ float sp = length(texture2D(${p}vel, clamp(suv, 0.001, 0.999)).xy);
214
+ col = mix(col, ${p}tint, (1.0 - exp(-sp * 0.7)) * ${p}sheen);
180
215
  }`,
181
216
  uniforms: (node, dpr) => ({
182
217
  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")],
218
+ sheen: ["1f", node.sheen != null ? node.sheen : 0.15],
219
+ tint: ["3fv", hexToRgb(node.tint || "#7FD4FF")],
220
+ dyemix: ["1f", node.dye != null ? node.dye : 0.85],
221
+ intensity: ["1f", node.intensity != null ? node.intensity : 1.6],
185
222
  }),
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: () => `
223
+
224
+ // ── The simulation ────────────────────────────────────────────
225
+ // Declared as data: the pipeline allocates the targets, compiles
226
+ // the programs, and calls step() once per frame.
227
+ solver: {
228
+ resolutions: (node) => ({
229
+ sim: Math.max(32, Math.min(node.resolution || 128, 512)),
230
+ dye: Math.max(64, Math.min(node.dyeResolution || 512, 1024)),
231
+ }),
232
+ // read/write pairs ping-pong; single targets are scratch.
233
+ targets: {
234
+ velocity: { double: true, res: "sim", fmt: "rg", smooth: true },
235
+ dye: { double: true, res: "dye", fmt: "rgba", smooth: true },
236
+ pressure: { double: true, res: "sim", fmt: "r", smooth: false },
237
+ divergence: { double: false, res: "sim", fmt: "r", smooth: false },
238
+ vorticity: { double: false, res: "sim", fmt: "r", smooth: false },
239
+ },
240
+ // Which target each main-pass sampler reads.
241
+ samplers: { vel: "velocity", dye: "dye" },
242
+
243
+ programs: {
244
+ // Gaussian source term. Distance is measured as a
245
+ // fraction of the element's LONGER side so the radius
246
+ // means the same thing whatever the element's shape.
247
+ splat: `
191
248
  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
- }
249
+ uniform sampler2D u_src;
250
+ uniform vec2 u_texel;
251
+ uniform vec2 u_point;
252
+ uniform vec3 u_amount;
253
+ uniform float u_radius;
254
+ uniform float u_aspect;
255
+ void main() {
256
+ vec2 uv = gl_FragCoord.xy * u_texel;
257
+ vec2 d = uv - u_point;
258
+ if (u_aspect >= 1.0) d.y /= u_aspect; else d.x *= u_aspect;
259
+ float g = exp(-dot(d, d) / max(u_radius * u_radius, 1e-6));
260
+ gl_FragColor = vec4(texture2D(u_src, uv).xyz + u_amount * g, 1.0);
261
+ }`,
211
262
 
263
+ // Semi-Lagrangian advection (Stam 1999): trace the
264
+ // characteristic backwards and resample. Unconditionally
265
+ // stable for any timestep, which is the whole point.
266
+ advect: `
267
+ precision highp float;
268
+ uniform sampler2D u_vel;
269
+ uniform sampler2D u_src;
270
+ uniform vec2 u_texel;
271
+ uniform float u_dt;
272
+ uniform float u_diss;
212
273
  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
- }
274
+ vec2 uv = gl_FragCoord.xy * u_texel;
275
+ vec2 back = uv - texture2D(u_vel, uv).xy * u_dt;
276
+ gl_FragColor = vec4(texture2D(u_src, clamp(back, 0.0, 1.0)).xyz * u_diss, 1.0);
277
+ }`,
251
278
 
252
- // Keep the field inside the encodable range.
253
- float mg = length(a);
254
- if (mg > s_range) a *= s_range / mg;
279
+ // Scalar vorticity of a 2D field: d(vy)/dx - d(vx)/dy.
280
+ curl: `
281
+ precision highp float;
282
+ uniform sampler2D u_vel;
283
+ uniform vec2 u_texel;
284
+ void main() {
285
+ vec2 uv = gl_FragCoord.xy * u_texel;
286
+ float r = texture2D(u_vel, uv + vec2(u_texel.x, 0.0)).y;
287
+ float l = texture2D(u_vel, uv - vec2(u_texel.x, 0.0)).y;
288
+ float t = texture2D(u_vel, uv + vec2(0.0, u_texel.y)).x;
289
+ float b = texture2D(u_vel, uv - vec2(0.0, u_texel.y)).x;
290
+ gl_FragColor = vec4(0.5 * ((r - l) - (t - b)), 0.0, 0.0, 1.0);
291
+ }`,
255
292
 
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);
293
+ // Vorticity confinement (Fedkiw et al. 2001): push the
294
+ // velocity along N x omega, where N is the unit gradient
295
+ // of |omega|, i.e. toward the centre of each eddy. This
296
+ // feeds energy back into swirl the grid would otherwise
297
+ // smooth out.
298
+ confine: `
299
+ precision highp float;
300
+ uniform sampler2D u_vel;
301
+ uniform sampler2D u_curl;
302
+ uniform vec2 u_texel;
303
+ uniform float u_eps;
304
+ uniform float u_dt;
305
+ void main() {
306
+ vec2 uv = gl_FragCoord.xy * u_texel;
307
+ float r = texture2D(u_curl, uv + vec2(u_texel.x, 0.0)).x;
308
+ float l = texture2D(u_curl, uv - vec2(u_texel.x, 0.0)).x;
309
+ float t = texture2D(u_curl, uv + vec2(0.0, u_texel.y)).x;
310
+ float b = texture2D(u_curl, uv - vec2(0.0, u_texel.y)).x;
311
+ float c = texture2D(u_curl, uv).x;
312
+ vec2 grad = 0.5 * vec2(abs(r) - abs(l), abs(t) - abs(b));
313
+ vec2 n = grad / (length(grad) + 1e-5);
314
+ vec2 force = u_eps * c * vec2(n.y, -n.x);
315
+ vec2 v = texture2D(u_vel, uv).xy + force * u_dt;
316
+ gl_FragColor = vec4(clamp(v, -64.0, 64.0), 0.0, 1.0);
259
317
  }`,
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)),
318
+
319
+ // div(v). The walls reflect, so the fluid stays in the box.
320
+ divergence: `
321
+ precision highp float;
322
+ uniform sampler2D u_vel;
323
+ uniform vec2 u_texel;
324
+ void main() {
325
+ vec2 uv = gl_FragCoord.xy * u_texel;
326
+ float r = texture2D(u_vel, uv + vec2(u_texel.x, 0.0)).x;
327
+ float l = texture2D(u_vel, uv - vec2(u_texel.x, 0.0)).x;
328
+ float t = texture2D(u_vel, uv + vec2(0.0, u_texel.y)).y;
329
+ float b = texture2D(u_vel, uv - vec2(0.0, u_texel.y)).y;
330
+ vec2 c = texture2D(u_vel, uv).xy;
331
+ if (uv.x - u_texel.x < 0.0) l = -c.x;
332
+ if (uv.x + u_texel.x > 1.0) r = -c.x;
333
+ if (uv.y - u_texel.y < 0.0) b = -c.y;
334
+ if (uv.y + u_texel.y > 1.0) t = -c.y;
335
+ gl_FragColor = vec4(0.5 * ((r - l) + (t - b)), 0.0, 0.0, 1.0);
336
+ }`,
337
+
338
+ // One Jacobi sweep of the Poisson equation lap(p) = div(v)
339
+ // (Harris 2003). Run a handful of these per frame.
340
+ jacobi: `
341
+ precision highp float;
342
+ uniform sampler2D u_p;
343
+ uniform sampler2D u_div;
344
+ uniform vec2 u_texel;
345
+ void main() {
346
+ vec2 uv = gl_FragCoord.xy * u_texel;
347
+ float r = texture2D(u_p, uv + vec2(u_texel.x, 0.0)).x;
348
+ float l = texture2D(u_p, uv - vec2(u_texel.x, 0.0)).x;
349
+ float t = texture2D(u_p, uv + vec2(0.0, u_texel.y)).x;
350
+ float b = texture2D(u_p, uv - vec2(0.0, u_texel.y)).x;
351
+ float d = texture2D(u_div, uv).x;
352
+ gl_FragColor = vec4((r + l + t + b - d) * 0.25, 0.0, 0.0, 1.0);
353
+ }`,
354
+
355
+ // v <- v - grad(p): the projection itself. After this the
356
+ // velocity is (discretely) divergence free.
357
+ subtract: `
358
+ precision highp float;
359
+ uniform sampler2D u_p;
360
+ uniform sampler2D u_vel;
361
+ uniform vec2 u_texel;
362
+ void main() {
363
+ vec2 uv = gl_FragCoord.xy * u_texel;
364
+ float r = texture2D(u_p, uv + vec2(u_texel.x, 0.0)).x;
365
+ float l = texture2D(u_p, uv - vec2(u_texel.x, 0.0)).x;
366
+ float t = texture2D(u_p, uv + vec2(0.0, u_texel.y)).x;
367
+ float b = texture2D(u_p, uv - vec2(0.0, u_texel.y)).x;
368
+ vec2 v = texture2D(u_vel, uv).xy - 0.5 * vec2(r - l, t - b);
369
+ gl_FragColor = vec4(v, 0.0, 1.0);
370
+ }`,
371
+
372
+ // Scale a target in place (used to carry a fraction of
373
+ // the previous pressure into the next solve).
374
+ decay: `
375
+ precision highp float;
376
+ uniform sampler2D u_tex;
377
+ uniform vec2 u_texel;
378
+ uniform float u_value;
379
+ void main() {
380
+ gl_FragColor = texture2D(u_tex, gl_FragCoord.xy * u_texel) * u_value;
381
+ }`,
382
+ },
383
+
384
+ // Colour dropped in by the spoon. With rainbow on, the hue
385
+ // comes from the stroke direction at the moment of injection
386
+ // and is then carried by the dye, so it stays coherent as the
387
+ // flow folds it.
388
+ splatColor: (node, dx, dy) => {
389
+ const amt = node.dyeAmount != null ? node.dyeAmount : 0.6;
390
+ if (!node.rainbow) {
391
+ const c = hexToRgb(node.tint || "#7FD4FF");
392
+ return [c[0] * amt, c[1] * amt, c[2] * amt];
393
+ }
394
+ const a = Math.atan2(dy, dx);
395
+ return [
396
+ (0.5 + 0.5 * Math.cos(a)) * amt,
397
+ (0.5 + 0.5 * Math.cos(a + 2.0944)) * amt,
398
+ (0.5 + 0.5 * Math.cos(a + 4.1888)) * amt,
399
+ ];
400
+ },
401
+
402
+ step: (S, node, ctx) => {
403
+ const dt = Math.min(ctx.dt, 1 / 30);
404
+ const simT = 1 / S.res.sim;
405
+ const dyeT = 1 / S.res.dye;
406
+ const pow = (base, d) => Math.pow(base, d * 60);
407
+
408
+ // 1. Forces: splat momentum and dye at the pointer.
409
+ if (ctx.moving) {
410
+ const f = node.force != null ? node.force : 1.0;
411
+ S.use("splat");
412
+ S.f1("u_radius", node.radius != null ? node.radius : 0.1);
413
+ S.f1("u_aspect", ctx.aspect);
414
+ S.f2("u_point", ctx.px, ctx.py);
415
+
416
+ S.f2("u_texel", simT, simT);
417
+ S.smp("u_src", S.read("velocity"), 0);
418
+ S.f3("u_amount", ctx.dx * f, ctx.dy * f, 0);
419
+ S.blit(S.write("velocity"));
420
+ S.swap("velocity");
421
+
422
+ S.f2("u_texel", dyeT, dyeT);
423
+ S.smp("u_src", S.read("dye"), 0);
424
+ S.f3("u_amount", ctx.col[0], ctx.col[1], ctx.col[2]);
425
+ S.blit(S.write("dye"));
426
+ S.swap("dye");
427
+ }
428
+
429
+ // 2. Vorticity confinement.
430
+ S.use("curl");
431
+ S.f2("u_texel", simT, simT);
432
+ S.smp("u_vel", S.read("velocity"), 0);
433
+ S.blit(S.single("vorticity"));
434
+
435
+ S.use("confine");
436
+ S.f2("u_texel", simT, simT);
437
+ S.f1("u_eps", node.curl != null ? node.curl : 2.2);
438
+ S.f1("u_dt", dt);
439
+ S.smp("u_vel", S.read("velocity"), 0);
440
+ S.smp("u_curl", S.single("vorticity"), 1);
441
+ S.blit(S.write("velocity"));
442
+ S.swap("velocity");
443
+
444
+ // 3. Projection: divergence -> pressure -> subtract.
445
+ S.use("divergence");
446
+ S.f2("u_texel", simT, simT);
447
+ S.smp("u_vel", S.read("velocity"), 0);
448
+ S.blit(S.single("divergence"));
449
+
450
+ S.use("decay");
451
+ S.f2("u_texel", simT, simT);
452
+ S.f1("u_value", pow(node.pressure != null ? node.pressure : 0.8, dt));
453
+ S.smp("u_tex", S.read("pressure"), 0);
454
+ S.blit(S.write("pressure"));
455
+ S.swap("pressure");
456
+
457
+ S.use("jacobi");
458
+ S.f2("u_texel", simT, simT);
459
+ S.smp("u_div", S.single("divergence"), 1);
460
+ const iters = Math.max(1, Math.min(node.pressureIterations || 6, 40));
461
+ for (let k = 0; k < iters; k++) {
462
+ S.smp("u_p", S.read("pressure"), 0);
463
+ S.blit(S.write("pressure"));
464
+ S.swap("pressure");
465
+ }
466
+
467
+ S.use("subtract");
468
+ S.f2("u_texel", simT, simT);
469
+ S.smp("u_p", S.read("pressure"), 0);
470
+ S.smp("u_vel", S.read("velocity"), 1);
471
+ S.blit(S.write("velocity"));
472
+ S.swap("velocity");
473
+
474
+ // 4. Advection: velocity by itself, then dye by velocity.
475
+ S.use("advect");
476
+ S.f1("u_dt", dt);
477
+ S.f2("u_texel", simT, simT);
478
+ S.smp("u_vel", S.read("velocity"), 0);
479
+ S.smp("u_src", S.read("velocity"), 0);
480
+ S.f1("u_diss", pow(node.decay != null ? node.decay : 0.985, dt));
481
+ S.blit(S.write("velocity"));
482
+ S.swap("velocity");
483
+
484
+ S.f2("u_texel", dyeT, dyeT);
485
+ S.smp("u_vel", S.read("velocity"), 0);
486
+ S.smp("u_src", S.read("dye"), 1);
487
+ S.f1("u_diss", pow(node.dyeFade != null ? node.dyeFade : 0.97, dt));
488
+ S.blit(S.write("dye"));
489
+ S.swap("dye");
490
+ },
269
491
  },
270
492
  },
271
493
 
@@ -795,133 +1017,171 @@ function applyRasterPipeline(el, rasterNodes) {
795
1017
  });
796
1018
  let lastFrame = (typeof performance !== "undefined") ? performance.now() : 0;
797
1019
 
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) => {
1020
+ // ── Fluid solvers (ops with a `solver` descriptor, e.g. stir) ─────
1021
+ // An op may declare a set of render targets and fragment programs
1022
+ // plus a step() that sequences the passes. The pipeline allocates
1023
+ // everything here, runs step() once per frame, and binds the named
1024
+ // result textures into the main composite pass.
1025
+ //
1026
+ // Float render targets are required (velocity and pressure are
1027
+ // signed and need range well outside [0,1]). Without them the op
1028
+ // disables itself rather than rendering something wrong.
1029
+ const solverOps = [];
1030
+ const makeSolver = (node, i) => {
806
1031
  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;
1032
+ if (!isGL2 || !gl.getExtension("EXT_color_buffer_float")) {
1033
+ console.info("[nodality] '" + node.op +
1034
+ "' needs WebGL2 float render targets - op skipped.");
1035
+ return null;
1036
+ }
1037
+ gl.getExtension("OES_texture_float_linear");
1038
+ const res = def.solver.resolutions(node);
1039
+ const FMT = {
1040
+ r: [gl.R16F, gl.RED],
1041
+ rg: [gl.RG16F, gl.RG],
1042
+ rgba: [gl.RGBA16F, gl.RGBA],
846
1043
  };
847
1044
 
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;
1045
+ const made = [];
1046
+ const mkTarget = (size, fmt, smooth) => {
1047
+ const [internal, format] = FMT[fmt];
1048
+ const t = gl.createTexture();
1049
+ gl.bindTexture(gl.TEXTURE_2D, t);
1050
+ const flt = smooth ? gl.LINEAR : gl.NEAREST;
1051
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, flt);
1052
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, flt);
1053
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1054
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1055
+ gl.texImage2D(gl.TEXTURE_2D, 0, internal, size, size, 0,
1056
+ format, gl.HALF_FLOAT, null);
1057
+ const fbo = gl.createFramebuffer();
1058
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
1059
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
1060
+ gl.TEXTURE_2D, t, 0);
1061
+ const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
1062
+ gl.viewport(0, 0, size, size);
1063
+ gl.clearColor(0, 0, 0, 1);
1064
+ gl.clear(gl.COLOR_BUFFER_BIT);
1065
+ const rec = { tex: t, fbo, size, ok };
1066
+ made.push(rec);
1067
+ return rec;
1068
+ };
1069
+
1070
+ const targets = {};
1071
+ let allOk = true;
1072
+ for (const name in def.solver.targets) {
1073
+ const spec = def.solver.targets[name];
1074
+ const size = res[spec.res];
1075
+ if (spec.double) {
1076
+ targets[name] = {
1077
+ a: mkTarget(size, spec.fmt, spec.smooth),
1078
+ b: mkTarget(size, spec.fmt, spec.smooth),
1079
+ };
1080
+ allOk = allOk && targets[name].a.ok && targets[name].b.ok;
1081
+ } else {
1082
+ targets[name] = { a: mkTarget(size, spec.fmt, spec.smooth) };
1083
+ allOk = allOk && targets[name].a.ok;
1084
+ }
1085
+ }
1086
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1087
+
1088
+ const cleanup = () => made.forEach((t) => {
1089
+ gl.deleteFramebuffer(t.fbo);
1090
+ gl.deleteTexture(t.tex);
1091
+ });
852
1092
 
853
- let simProg;
1093
+ if (!allOk) {
1094
+ console.info("[nodality] '" + node.op +
1095
+ "' float targets incomplete - op skipped.");
1096
+ cleanup();
1097
+ return null;
1098
+ }
1099
+
1100
+ const progs = {};
854
1101
  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));
1102
+ for (const name in def.solver.programs) {
1103
+ const pr = gl.createProgram();
1104
+ gl.attachShader(pr, compile(gl.VERTEX_SHADER, VS));
1105
+ gl.attachShader(pr, compile(gl.FRAGMENT_SHADER, def.solver.programs[name]));
1106
+ gl.bindAttribLocation(pr, 0, "a");
1107
+ gl.linkProgram(pr);
1108
+ if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) {
1109
+ throw new Error(name + ": " + gl.getProgramInfoLog(pr));
1110
+ }
1111
+ progs[name] = { prog: pr, loc: {} };
862
1112
  }
863
1113
  } 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); });
1114
+ console.warn("[nodality] '" + node.op + "' solver programs failed:", e);
1115
+ cleanup();
1116
+ for (const n in progs) gl.deleteProgram(progs[n].prog);
866
1117
  return null;
867
1118
  }
868
1119
 
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
1120
  gl.useProgram(prog);
880
- gl.uniform1f(U(`u${i}_enc`), enc);
881
- gl.uniform1f(U(`u${i}_range`), RANGE);
882
-
883
1121
  return {
884
- i, res, prog: simProg, SU,
885
- src: pair[0], dst: pair[1],
886
- force: def.field.force(node),
1122
+ i, node, def, res, targets, progs, cleanup,
887
1123
  prevX: 0.5, prevY: 0.5, primed: false,
888
1124
  };
889
1125
  };
890
1126
  rasterNodes.forEach((node, i) => {
891
1127
  const def = REGISTRY[node.op];
892
- if (!def || !def.field) return;
893
- const f = makeField(node, i);
894
- if (f) fieldOps.push(f);
1128
+ if (!def || !def.solver) return;
1129
+ const s = makeSolver(node, i);
1130
+ if (s) solverOps.push(s);
895
1131
  });
896
1132
 
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.
1133
+ // One frame of a solver. `S` is the small API the op's step() drives.
1134
+ const stepSolver = (s, dt) => {
1135
+ let cur = null;
1136
+ const S = {
1137
+ res: s.res,
1138
+ use(name) {
1139
+ cur = s.progs[name];
1140
+ gl.useProgram(cur.prog);
1141
+ },
1142
+ u(name) {
1143
+ if (!(name in cur.loc)) cur.loc[name] = gl.getUniformLocation(cur.prog, name);
1144
+ return cur.loc[name];
1145
+ },
1146
+ f1(n, v) { gl.uniform1f(S.u(n), v); },
1147
+ f2(n, x, y) { gl.uniform2f(S.u(n), x, y); },
1148
+ f3(n, x, y, z) { gl.uniform3f(S.u(n), x, y, z); },
1149
+ smp(n, target, unit) {
1150
+ gl.activeTexture(gl.TEXTURE0 + unit);
1151
+ gl.bindTexture(gl.TEXTURE_2D, target.tex);
1152
+ gl.uniform1i(S.u(n), unit);
1153
+ },
1154
+ read: (name) => s.targets[name].a,
1155
+ write: (name) => s.targets[name].b,
1156
+ single: (name) => s.targets[name].a,
1157
+ swap(name) {
1158
+ const t = s.targets[name];
1159
+ const tmp = t.a; t.a = t.b; t.b = tmp;
1160
+ },
1161
+ blit(target) {
1162
+ gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);
1163
+ gl.viewport(0, 0, target.size, target.size);
1164
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
1165
+ },
1166
+ };
1167
+
1168
+ // Pointer in field space (bottom-up [0,1]) and the velocity it
1169
+ // picked up since the previous frame - the spoon.
908
1170
  const px = mouse[0] / canvas.width;
909
1171
  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;
1172
+ let dx = 0, dy = 0;
1173
+ if (s.primed) {
1174
+ dx = (px - s.prevX) / Math.max(dt, 1e-3);
1175
+ dy = (py - s.prevY) / Math.max(dt, 1e-3);
914
1176
  }
915
- f.prevX = px; f.prevY = py; f.primed = true;
1177
+ s.prevX = px; s.prevY = py; s.primed = true;
1178
+ const moving = Math.abs(dx) + Math.abs(dy) > 1e-3;
916
1179
 
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;
1180
+ s.def.solver.step(S, s.node, {
1181
+ dt, px, py, dx, dy, moving,
1182
+ aspect: canvas.width / Math.max(canvas.height, 1),
1183
+ col: s.def.solver.splatColor(s.node, dx, dy),
1184
+ });
925
1185
  };
926
1186
 
927
1187
  const tex = gl.createTexture();
@@ -1107,9 +1367,9 @@ function applyRasterPipeline(el, rasterNodes) {
1107
1367
  const now = (typeof performance !== "undefined") ? performance.now() : lastFrame + 16;
1108
1368
  const dt = Math.max(0, (now - lastFrame) / 1000);
1109
1369
 
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);
1370
+ // 1. Advance stateful fluid solvers into their own off-screen
1371
+ // targets (this rebinds framebuffer/program/textures).
1372
+ for (const s of solverOps) stepSolver(s, dt);
1113
1373
 
1114
1374
  // 2. Composite pass, back on the visible framebuffer.
1115
1375
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
@@ -1128,15 +1388,21 @@ function applyRasterPipeline(el, rasterNodes) {
1128
1388
  for (const u of ups) setUniform(U(`u${d.i}_${u.name}`), u.kind, u.value);
1129
1389
  }
1130
1390
  }
1131
- // Content on unit 0, each simulated field on its own unit.
1391
+ // Content on unit 0, then each solver's named result
1392
+ // textures (velocity, dye, ...) on units above it.
1132
1393
  gl.activeTexture(gl.TEXTURE0);
1133
1394
  gl.bindTexture(gl.TEXTURE_2D, tex);
1134
1395
  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
- });
1396
+ let unit = 1;
1397
+ for (const s of solverOps) {
1398
+ const smps = s.def.solver.samplers;
1399
+ for (const key in smps) {
1400
+ gl.activeTexture(gl.TEXTURE0 + unit);
1401
+ gl.bindTexture(gl.TEXTURE_2D, s.targets[smps[key]].a.tex);
1402
+ gl.uniform1i(U(`u${s.i}_${key}`), unit);
1403
+ unit++;
1404
+ }
1405
+ }
1140
1406
  gl.activeTexture(gl.TEXTURE0); // texture uploads assume unit 0
1141
1407
  lastFrame = now;
1142
1408
  gl.drawArrays(gl.TRIANGLES, 0, 3);
@@ -1233,14 +1499,11 @@ function applyRasterPipeline(el, rasterNodes) {
1233
1499
  el.removeEventListener("mousemove", onMove);
1234
1500
  el.removeEventListener("touchmove", onMove);
1235
1501
  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);
1502
+ for (const s of solverOps) {
1503
+ s.cleanup();
1504
+ for (const n in s.progs) gl.deleteProgram(s.progs[n].prog);
1242
1505
  }
1243
- fieldOps.length = 0;
1506
+ solverOps.length = 0;
1244
1507
  while (sourceEl !== el && sourceEl.firstChild) el.appendChild(sourceEl.firstChild);
1245
1508
  el.style.visibility = "";
1246
1509
  canvas.remove();