Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
I really only wanted to spill some fountain pen ink. Not literally. That would be expensive. 🖋️
I wanted to take colours inspired by Pilot’s Iroshizuku fountain pen inks, drop them somewhere on a blank canvas, and let them flow.
That led me to flow fields.
< section id="first-where-do-i-drop-the-ink" class="level2">First: where do I drop the ink?
Before worrying about how anything moves, I need some starting points.
For that, I went back to an old favourite: phyllotaxis – the mathematical arrangement associated with sunflower seeds and other plant structures.
The important part is the golden angle – about 137.5^.
Each new point rotates by that angle and moves a little farther from the center.
This gives me a deterministic set of locations.
No flow yet. Just places where I can drop particles.
< section id="what-is-a-flow-field" class="level2">What is a flow field?
This was the part I initially made much more complicated in my head than it needed to be. A flow field is simply a rule:
At position
(x, y), which direction should I go?
For example, this field:
field_vortex <- function(x, y) {
atan2(y, x) + pi / 2
}
returns a direction perpendicular to the line from the origin.
I like thinking of the field as an invisible landscape.
The landscape doesn’t draw anything itself.
I have to drop something into it.
💧
< section id="the-walker" class="level2">The walker
A particle begins at (x0, y0).
At every step it:
- asks the field which direction it should travel,
- converts that angle into horizontal and vertical movement,
- takes one tiny step,
- asks again.
The important bit of the walker is essentially:
angle <- field(x, y) x_new <- x + cos(angle) * step_size y_new <- y + sin(angle) * step_size
cos(angle) gives the horizontal component of the movement. sin(angle) gives the vertical component.
And step_size determines how far the particle moves each time. So a curve isn’t actually being drawn. I’m recording the history of a moving particle. That distinction finally made flow fields click for me.
Drop the Iroshizuku ink and let it flow
Now I can combine the two ideas. Phyllotaxis decides where the particles are born. The flow field decides what happens to them afterward.
< section id="spiral" class="level3">Spiral
Pulls the paths around the centre while gradually drifting outward — a bit like ink swirling outward as you stir a cup
spiral_paths <- make_flow_paths( field = field_spiral, # rule that determines which direction each particle moves n = 500, # number of particles dropped into the field n_steps = 220, # how many steps each particle takes = like how LONG it keeps walking step_size = 0.006 # distance travelled with each step = how FAR it moves each time ) plot_flow_paths( spiral_paths, linewidth = 0.9, # thickness of each particle trail alpha = 0.6 # transparency: lower = more see-through )
Same birth pattern. Different laws of physics.
< section id="change-the-field-change-the-world" class="level2">Change the field, change the world
Here’s the part I find addictive.
I don’t need to rewrite the walker.
I can just give it a different function.
The starting geometry stays the same.
The walking process stays the same.
Only the field changes — and with it, the entire world the particles move through.
Radial fields
These fields are all centered around the origin, but they guide the particles in different ways: circling, spiralling, moving outward, or pulling inward.
Pulls the paths into circular motion — a bit like star trails in a long-exposure photograph.
draw_flow_field( field = field_vortex, # circular motion around the centre n = 500, # lower = sparser star trails; higher = denser sky n_steps = 220, # lower = shorter trails, like ending the exposure early step_size = 0.006, # lower = smoother, tighter curves; higher = bigger jumps linewidth = 1.2, # lower = finer light trails; higher = bolder streaks alpha = 0.6 # lower = softer/fainter trails; higher = more opaque )
Pulls the paths around the centre while gradually drifting outward — a bit like ink swirling outward as you stir a cup.
draw_flow_field( field = field_spiral, # circular motion with a gentle outward drift n = 500, # lower = fewer ink trails; higher = denser layering n_steps = 220, # lower = shorter spirals; higher = longer outward journeys step_size = 0.006, # lower = smoother spirals; higher = bigger jumps through the field linewidth = 0.9, # lower = finer strands; higher = thicker ink strokes alpha = 0.6 # lower = softer layering; higher = stronger overlap )
Pushes the paths away from the centre, like sparks or ink flung outward from a single point.
draw_flow_field( field = field_outward, # motion directly away from the centre n = 500, # lower = fewer outward traces; higher = denser burst n_steps = 120, # lower = shorter bursts; higher = longer radiating paths step_size = 0.01, # lower = smoother expansion; higher = more dramatic outward jumps linewidth = 0.8, # lower = finer streaks; higher = bolder marks alpha = 0.6 # lower = softer trails; higher = stronger opacity )
Draws the paths back toward the centre, like everything on the page is being gently pulled inward.
draw_flow_field( field = field_inward, # motion directly toward the centre n = 500, # lower = fewer inward traces; higher = denser convergence n_steps = 120, # lower = shorter inward motion; higher = longer collapsing paths step_size = 0.01, # lower = smoother inward pull; higher = more abrupt movement linewidth = 0.8, # lower = finer threads; higher = heavier strokes alpha = 0.6 # lower = softer layering; higher = stronger overlap near the centre )
Wave fields
These fields are less about the centre and more about movement across the page: swaying, slanting, colliding, and rippling.
Bends the paths back and forth, like loose strands of seaweed drifting with the current.
draw_flow_field( field = field_wavy, # back-and-forth motion, like seaweed drifting in a current n = 500, # lower = fewer strands; higher = a fuller, more tangled flow n_steps = 150, # lower = shorter strands; higher = longer, more continuous sweeps step_size = 0.01, # lower = smoother bends; higher = looser, more exaggerated movement linewidth = 2, # lower = finer strands; higher = thicker, more painterly strokes alpha = 0.7 # lower = lighter layering; higher = stronger overlapping colour )
Nudges the paths along a slanted current, as if a breeze were pushing them diagonally across the page.
draw_flow_field( field = field_diagonal, # slanted wave-like motion across the page n = 500, # lower = fewer paths; higher = denser layering n_steps = 125, # lower = shorter marks; higher = longer, more continuous paths step_size = 0.01, # lower = smoother motion; higher = larger jumps linewidth = 1, # lower = finer strands; higher = thicker strokes alpha = 0.7 # lower = lighter layering; higher = stronger overlap )
Lets crossing wave patterns push against each other, creating a more tangled, woven motion.
draw_flow_field( field = field_interference, # crossing wave patterns, like ripples meeting on water n = 500, # lower = more open crossings; higher = denser woven structure n_steps = 250, # lower = shorter fragments; higher = longer interlaced paths step_size = 0.005, # lower = finer, smoother weaving; higher = more abrupt directional changes linewidth = 0.5, # lower = delicate threads; higher = heavier woven strokes alpha = 0.6 # lower = softer overlaps; higher = stronger visual interference )
Pushes the paths through expanding rings, a bit like ripples spreading outward across water.
draw_flow_field( field = field_ripple, # radial wave motion, like ripples spreading across water n = 500, # lower = fewer visible ripple traces; higher = denser layering n_steps = 180, # lower = shorter paths; higher = longer ripple-following trails step_size = 0.008, # lower = smoother ripples; higher = more exaggerated movement linewidth = 0.8, # lower = finer rings; higher = bolder ripple marks alpha = 0.6 # lower = softer layering; higher = stronger overlap )
The particles themselves have not changed.
The walker has not changed.
Only the field has changed — and that is enough to make each world feel different.
How n changes the crowd
Before changing how far the particles travel, I can change something even simpler: how many particles I release into the field.
n controls the number of starting points.
The field does not change.
The walker does not change.
Each particle follows the same rules.
There are simply more — or fewer — travelers moving through the same world.
With a small n, the structure of the field feels sparse and exposed.
As n increases, the individual paths begin to overlap and the drawing becomes denser, richer, and more like a continuous texture.
draw_flow_field( field = field_vortex, # same circular field in every tab n = 50, # very few particles = sparse, isolated trails n_steps = 180, # fixed so journey length does not change step_size = 0.006, # fixed so stride does not change linewidth = 1.2, # fixed drawing style alpha = 0.6 # fixed transparency )
draw_flow_field( field = field_vortex, # same circular field in every tab n = 100, # more particles = more of the field becomes visible n_steps = 180, # fixed so journey length does not change step_size = 0.006, # fixed so stride does not change linewidth = 1.2, # fixed drawing style alpha = 0.6 # fixed transparency )
draw_flow_field( field = field_vortex, # same circular field in every tab n = 250, # overlapping trails begin to create a fuller structure n_steps = 180, # fixed so journey length does not change step_size = 0.006, # fixed so stride does not change linewidth = 1.2, # fixed drawing style alpha = 0.6 # fixed transparency )
draw_flow_field( field = field_vortex, # same circular field in every tab n = 500, # many particles = dense, layered star trails n_steps = 180, # fixed so journey length does not change step_size = 0.006, # fixed so stride does not change linewidth = 1.2, # fixed drawing style alpha = 0.6 # fixed transparency )
Changing n does not change the rules of motion. It changes how densely those rules are sampled.
A sparse field gives me individual paths.
A crowded field begins to reveal a texture.
How n_steps changes the journey
Keeping the same field and starting geometry, I varied only n_steps to see how the length of the journey changes the final drawing.
draw_flow_field( field = field_diagonal, # slanted wave-like motion across the page n = 500, # lower = fewer paths; higher = denser layering n_steps = 5, # very short paths step_size = 0.01, # lower = smoother motion; higher = larger jumps linewidth = 2, # lower = finer strands; higher = thicker strokes alpha = 0.7 # lower = lighter layering; higher = stronger overlap )
draw_flow_field( field = field_diagonal, # slanted wave-like motion across the page n = 500, # lower = fewer paths; higher = denser layering n_steps = 25, # medium-length paths step_size = 0.01, # lower = smoother motion; higher = larger jumps linewidth = 2, # lower = finer strands; higher = thicker strokes alpha = 0.7 # lower = lighter layering; higher = stronger overlap )
draw_flow_field( field = field_diagonal, # slanted wave-like motion across the page n = 500, # lower = fewer paths; higher = denser layering n_steps = 125, # long flowing paths step_size = 0.01, # lower = smoother motion; higher = larger jumps linewidth = 2, # lower = finer strands; higher = thicker strokes alpha = 0.7 # lower = lighter layering; higher = stronger overlap )
draw_flow_field( field = field_diagonal, # slanted wave-like motion across the page n = 500, # lower = fewer paths; higher = denser layering n_steps = 250, # long flowing paths step_size = 0.01, # lower = smoother motion; higher = larger jumps linewidth = 2, # lower = finer strands; higher = thicker strokes alpha = 0.7 # lower = lighter layering; higher = stronger overlap )
How step_size changes the stride
If n_steps controls how long the journey lasts, step_size controls the particle’s stride.
Here I kept the field, particle count, number of steps, and drawing style the same, and changed only step_size. Smaller values trace the field more delicately, while larger values move farther at each step and exaggerate the motion.
draw_flow_field( field = field_spiral, # circular motion with a gentle outward drift n = 500, # lower = fewer ink trails; higher = denser layering n_steps = 50, # fixed here so only step_size changes step_size = 0.001, # tiny stride = very fine, tightly sampled motion linewidth = 0.8, # lower = finer strands; higher = thicker ink strokes alpha = 0.7 # lower = softer layering; higher = stronger overlap )
draw_flow_field( field = field_spiral, # circular motion with a gentle outward drift n = 500, # lower = fewer ink trails; higher = denser layering n_steps = 50, # fixed here so only step_size changes step_size = 0.01, # medium stride = balanced between smoothness and movement linewidth = 0.8, # lower = finer strands; higher = thicker ink strokes alpha = 0.7 # lower = softer layering; higher = stronger overlap )
draw_flow_field( field = field_spiral, # circular motion with a gentle outward drift n = 500, # lower = fewer ink trails; higher = denser layering n_steps = 50, # fixed here so only step_size changes step_size = 0.08, # large stride = exaggerated jumps through the field linewidth = 0.8, # lower = finer strands; higher = thicker ink strokes alpha = 0.7 # lower = softer layering; higher = stronger overlap )
draw_flow_field( field = field_spiral, # circular motion with a gentle outward drift n = 500, # lower = fewer ink trails; higher = denser layering n_steps = 50, # fixed here so only step_size changes step_size = 4, # large stride = exaggerated jumps through the field linewidth = 0.8, # lower = finer strands; higher = thicker ink strokes alpha = 0.7 # lower = softer layering; higher = stronger overlap )
With the same journey length, changing only the stride makes the paths feel very different. Small steps hug the field more closely, while larger steps produce bolder, looser motion.
< section id="a-tiny-system" class="level2">A tiny system
What started as “I want to spill some ink” turned into a surprisingly useful little programming lesson.
The system now has three independent ideas:
STARTING POINTS
↓
particles
↓
FLOW FIELD
↓
direction
↓
WALKER
↓
paths
The starting geometry answers:
Where do you begin?
The field answers:
Given where you are, which direction should you go?
And the walker answers:
How do you move through that field — how long is the journey, and how big is each step?
Once those responsibilities are separated, I can change one without rewriting the others.
Which means there are now far too many things I want to try. 😬
Different starting geometries. Different mathematical fields. Noise. Curl.
Maybe even particles that pay attention to each other instead of only listening to the environment.
But that’s for another ink spill.
< !-- -->R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
