Programming for Interactive Digital Arts
CS 2, Fall 2023

14. Spring systems

Arrays of unconnected springs

Just as we had arrays of moving objects, we can have arrays of springs. The appendix of Greenberg's book has a cool example of a bunch of unconnected springs (sketch 13), which he calls "box springs". She did it with sinusoids, but we will do it here by reusing our previous approach to springs. Mousing over a circle gives it an initial velocity, then the spring goes to work. The different springs have different spring constants and damping factors; try it with uniform ones.

[sketch1]
// Based on Greenberg Appendix A-13

Spring[] springs = new Spring[30];

function setup()
{
  createCanvas(400,300);
  smooth();
  
  // Evenly space the springs across the window,
  // with a bit of margin (25 on each side)
  var boxR = 0.5*(width-50.0)/springs.length;
  var x0 = 25 + boxR;
  for (var i=0; i<springs.length; i++)
    springs[i] = new Spring(2*boxR*i+x0,height/2, boxR); 
}

function draw()
{
  background(0);
  // Our usual convention: draw and update each object
  for (var i=0; i<springs.length; i++) springs[i].draw();
  for (var i=0; i<springs.length; i++) springs[i].update();
}
[Spring]
class Spring {
  var cx, cy;        // rest position
  var x, y;          // current position
  var vx=0, vy=0;    // velocity in the two directions   
  var r = 10;        // radius
  var k = 0.05;      // spring constant
  var d = 0.75;      // damping
  
  // Initialize a Spring at rest position (x0,y0), of size w*h
  Spring(var x0, var y0, var r0)
  {
    x = x0; y = y0;
    cx = x0; cy = y0;
    r = r0;
    // Random spring constant and damping
    k = random(0.01,0.02);
    d = random(0.975,1.0);
  }

  function draw()
  {
    stroke(128); strokeWeight(3);
    line(cx,cy,x,y);
    fill(255); stroke(128); strokeWeight(1);
    ellipse(x,y,2*r,2*r);
  }

  function update()
  {
    if(dist(mouseX,mouseY,x,y) < r) {
      // Give it a kick of velocity when (while) mouse is over it
      vy = 10;
    }
    
    // usual spring stuff
    vx -= k * (x-cx); vy -= k * (y-cy);
    vx *= d; vy *= d;
    x += vx; y += vy;
  }
}

Spring snakes

We can get interesting behaviors if we connect springs together, rather than having them move independently of each other. Let's start with just a pair of springs (following based on Reas and Fry, 50-16), and make one spring's center (rest position) follow the mouse and the other spring's center follow the current position of the first spring. Thus each frame we will draw the springs (here with an ellipse for the current position and a line from that to the center), adjust their centers, and do the usual spring stuff. Some gravity orients them vertically.

[sketch2]
// Based on R&F 50-16

// Two connected springs
Spring spring1, spring2;

function setup()
{
  createCanvas(400,400);
  smooth();

  spring1 = new Spring(width/2,height/2,10); 
  spring2 = new Spring(width/2,height/2,10); 
}

function draw()
{
  background(0);
  spring1.draw();
  spring2.draw();
  // Recenter spring1 at the mouse
  spring1.update(mouseX,mouseY);
  // Recenter spring2 at spring1
  spring2.update(spring1.x,spring1.y);
}
[Spring]
class Spring {
  var cx, cy;        // rest position
  var x, y;          // current position
  var vx=0, vy=0;    // velocity in the two directions   
  var r = 10;        // radius
  var k = 0.05;      // spring constant
  var d = 0.75;      // damping
  var g = 1;         // gravity
  
  // Initialize a Spring at rest position (x0,y0)
  Spring(var x0, var y0, var r0)
  {
    x = x0; y = y0;
    cx = x0; cy = y0;
    r = r0;
  }

  function draw()
  {
    stroke(128); strokeWeight(3);
    line(cx,cy,x,y);
    fill(255); stroke(128); strokeWeight(1);
    ellipse(x,y,2*r,2*r);
  }

  // Update the spring, with a new center
  function update(var ncx, var ncy)
  {
    // set the new center
    cx = ncx; cy = ncy;
    
    // usual spring stuff
    vx -= k * (x-cx); vy -= k * (y-cy); vy += g;
    vx *= d; vy *= d;
    x += vx; y += vy;
  }
}

Now we can do the same thing with an array of springs (based on Reas and Fry 50-17). When we create the springs, we stack them up vertically. Then each frame we update the zeroth spring to center on the mouse, the first to center on the zeroth's current position, the second to center on the first's, etc.

[sketch3]
// Based on R&F 50-17

Spring[] springs = new Spring[30];

function setup()
{
  createCanvas(400,600);
  smooth();

  // Construct springs at center horizontally, equally spaced vertically
  for (var i=0; i<springs.length; i++)
    springs[i] = new Spring(width/2, i*height/springs.length,10);
}

function draw()
{
  background(0);
  
  for (var i=0; i<springs.length; i++) springs[i].draw();
  
  // Recenter first spring at the mouse
  springs[0].update(mouseX,mouseY);
  // Recenter each following spring at preceding spring
  for (var i=1; i<springs.length; i++)
    springs[i].update(springs[i-1].x, springs[i-1].y);
}

[Spring]
class Spring {
  var cx, cy;        // rest position
  var x, y;          // current position
  var vx=0, vy=0;    // velocity in the two directions   
  var r = 10;        // radius
  var k = 0.05;      // spring constant
  var d = 0.75;      // damping
  var g = 1;         // gravity
  
  // Initialize a Spring at rest position (x0,y0)
  Spring(var x0, var y0, var r0)
  {
    x = x0; y = y0;
    cx = x0; cy = y0;
    r = r0;
  }

  function draw()
  {
    stroke(128); strokeWeight(3);
    line(cx,cy,x,y);
    fill(255); stroke(128); strokeWeight(1);
    ellipse(x,y,2*r,2*r);
  }

  // Update the spring, with a new center
  function update(var ncx, var ncy)
  {
    // set the new center
    cx = ncx; cy = ncy;
    
    // usual spring stuff
    vx -= k * (x-cx); vy -= k * (y-cy); vy += g;
    vx *= d; vy *= d;
    x += vx; y += vy;
  }
}

Greenberg (11-16) has a fancier version of this same basic idea, making a worm crawling toward food. Here we present a variation that uses our same Spring class (modified to draw outlines rather than filled), and to move toward the mouse. There are a few important aspects to this sketch. First note that when we create the springs (in setup()), they have different radii (for the worm look) as well as different spring constants and damping coefficients. The springs toward the back are stiffer than those toward the front, and are damped more; thus they don't spring as much. Second note that the worm moves at a constant speed. I did this because when I first had the first segment re-center on the mouse (as in our previous sketch), the worm really bounced all over the place, rather than slithered (give it a try). The approach to moving at a constant speed is kind of like easing -- look at the difference between the current position and the mouse position. But rather than moving a fraction of the way along that direction, we move a constant step along it.

[sketch4]
// Based on Greenberg 11-16

Spring[] segments = new Spring[30];

function setup()
{
  createCanvas(400,400);
  smooth();

  // Construct segments, with size and spring parameters depending on position along worm
  for (var i=0; i<segments.length; i++) {
    var r;
    if (i > segments.length/2) r = segments.length-i;  // smaller toward either end
    else r = i;
    // These numbers are pretty magical, from Greenberg
    segments[i] = new Spring(width/2, height/2, r, .0035*(i+1), .95-.02*i);
  }
}

function draw()
{
  background(0);
  
  for (var i=0; i<segments.length; i++) segments[i].draw();
  
  // Recenter first segment
  // Rather than targeting mouse position, target a constant-sized (speed)
  // step toward the mouse position
  var speed = 25;
  var dx = mouseX-segments[0].x, dy = mouseY-segments[0].y;
  var d = sqrt(dx*dx+dy*dy);
  // dx/d and dy/d give the fractions of the diagonal step that lie in the two directions
  if (d>0) segments[0].update(segments[0].x+speed*dx/d, segments[0].y+speed*dy/d);

  // Recenter each following segment at preceding segment
  for (var i=1; i<segments.length; i++)
    segments[i].update(segments[i-1].x, segments[i-1].y);
}
[Spring]
class Spring {
  var cx, cy;        // rest position
  var x, y;          // current position
  var vx=0, vy=0;    // velocity in the two directions   
  var r;             // radius
  var k;             // spring constant
  var d;             // damping
  
  // Initialize a Spring at rest position (x0,y0), radius r,
  // spring constant k, and damping d
  Spring(var x0, var y0, var r0, var k0, var d0)
  {
    x = x0; y = y0;
    cx = x0; cy = y0;
    r = r0; 
    k = k0; d = d0;
  }

  function draw()
  {
    fill(0); stroke(255); strokeWeight(2);
    ellipse(x,y,r*2,r*2);
  }

  // Update the spring, with a new center
  function update(var ncx, var ncy)
  {
    cx = ncx; cy = ncy;
    
    // usual spring stuff
    vx -= k * (x-cx); vy -= k * (y-cy);
    vx *= d; vy *= d;
    x += vx; y += vy;
  }
}

Mass-spring system

For some real fun with springs, we can set up an interactive system in which masses are hooked together with springs and then given a push. Soda Constructor is a full-fledged system for doing that (and more); a simplified Processing version is in the Synthesis section of Reas and Fry. Here is an even more simplified version that gets at the heart of the mass-spring system.

To represent the positions and velocities of the masses, we use the Ball class from before (stripped down to the relevant parts). To represent springs, we modify the Spring class so that rather than being defined in terms of a rest position, a spring is defined in terms of a rest length -- the initial distance between the balls that it is connecting. A spring accelerates the two balls to try to restore their distance to that rest length.

The interaction has two modes, building/simulating, selected by keypresses ('b'/'s') and indicated by background (gray/white). When building, a mass is added by a button press away from any existing mass, and a spring is added by clicking on one mass and then another (clicking on nothing or on the same mass twice aborts a half-created spring). When simulating, a mass is picked up by clicking on it; it can then be dragged around and released, giving it a new position and velocity. The pseudo-physics then kicks in. Dragging makes use of the Processing functions mouseDragged() and mouseReleased(), which are analogous to mousePressed(), called once for each event of the mouse being pressed/dragged/released.

[sketch5]
// Based on R&F synthesis "soda processing", 
// which is in turn based on the Soda Constructor, http://www.sodaplay.com

// The arrays of balls and springs
var maxBalls=1000, maxSprings=1000;
Ball[] balls = new Ball[maxBalls];
Spring[] springs = new Spring[maxSprings];
// How far we are into the arrays (how many have been created)
var currBall=0, currSpring=0;
// If a ball has been selected, its index (-1 if none)
var selBall=-1;
// Universal spring constant and drag coefficient
// (each Ball and Spring has its own field, so they could be set independently)
var k=0.1, drag=0.95; 
// Whether we are building (adding balls and springs) -- true
// or simulating -- false
var building=true;

function setup()
{
  createCanvas(400,400);
  smooth();
}

function draw()
{
  // Show mode by way of background
  if (building) background(128);
  else background(255);
  // Draw the springs first
  stroke(0); strokeWeight(2);
  for (var i=0; i<currSpring; i++)
    springs[i].draw();
  // Now draw the balls
  noStroke();
  for (var i=0; i<currBall; i++) {
    if (i == selBall) fill(0,255,0);  // highlight the selected one
    else fill(0);
    balls[i].draw();
  }
  
  if (!building) {
    // Simulate
    // First apply the spring forces
    for (var i=0; i<currSpring; i++)
      springs[i].apply();
    // Then update the ball velocities and positions
    for (var i=0; i<currBall; i++)
      balls[i].update();
  }
}

function mousePressed()
{
  // See if any ball is close enough to be selected
  // I found it useful to give a little slop -- 1.5*radius
  var sel = -1;
  for (var i=0; i<currBall; i++) {
    if (dist(mouseX,mouseY, balls[i].x,balls[i].y) < 1.5*balls[i].r) sel = i;
  }
  if (!building) {
    // When simulating, just remember that the ball was selected
    selBall = sel;
  }
  else if (sel < 0) { // Nobody's close
    if (selBall >= 0) { // Had previously selected -- cancel that
      selBall = -1;
    }
    else if (currBall<maxBalls-1) { // Add a new one
      balls[currBall] = new Ball(mouseX,mouseY,drag);
      currBall++;
    }
  }
  else { // Selected a ball -- must select two to add a spring
    if (selBall < 0) { // This is the first selected
      selBall = sel;
    }
    else if (selBall == sel) { // Same one twice -- cancel
      selBall = -1;
    }
    else if (currSpring<maxSprings-1) { // Second one -- make spring
      springs[currSpring] = new Spring(balls[selBall],balls[sel],k);
      currSpring++;
      selBall = -1;
    }
  }      
}

function mouseDragged()
{
  if (!building && selBall >= 0) {
    // When simulating, move ball and set its velocity
    balls[selBall].x = mouseX;
    balls[selBall].y = mouseY;
    balls[selBall].vx = mouseX-pmouseX;
    balls[selBall].vy = mouseY-pmouseY;
  }
}

function mouseReleased()
{
  if (!building) selBall = -1;
}

function keyPressed()
{
  if (key=='b') building=true;
  else if (key=='s') building=false;
  else if (key=='k') setK(k*0.5);
  else if (key=='K') setK(min(k*2,0.999));
  else if (key=='d') setDrag(1-(1-drag)*0.5);
  else if (key=='D') setDrag(max(1-(1-drag)*2,0.001));
}

// Set the spring constant for all springs
function setK(var newK)
{
  k = newK;
  println("k:"+k);
  for (var i=0; i<currSpring; i++) springs[i].k = k;
}

// Set the drag coefficient for all balls
function setDrag(var newDrag)
{
  drag = newDrag;
  println("drag:"+drag);
  for (var i=0; i<currBall; i++) balls[i].drag = drag;
}
[Ball]
class Ball {
  var x, y;          // position

  var vx=0, vy=0;    // velocity in the two directions   

  var r=5;           // radius

  var drag;          // multiplicative factor for velocity


  Ball(var x0, var y0, var drag)
  {
    x = x0; y = y0;
    this.drag = drag;
  }
  
  function draw()
  {
    ellipse(x,y,r*2,r*2);
  }

  function update()
  {
    // Damp according to drag

    vx *= drag;
    vy *= drag;
    // Move in the appropriate direction by the step size

    x += vx;
    y += vy;

    // Bounce, with frictionless walls

    if (x > width-r) { x=width-r; vx=-vx; }
    else if (x < r) { x=r; vx=-vx; }
    if (y > height-r) { y=height-r; vy=-vy; }
    else if (y < r) { y=r; vy = -vy; }
  }
}
[Spring]
class Spring {
  Ball b1, b2;
  var rest;     // rest length

  var k;        // spring constant

  
  // Initialize a Spring between balls b1 and b2, with spring constant k

  Spring(Ball b1, Ball b2, var k)
  {
    this.b1 = b1; this.b2 = b2; this.k = k;
    rest = dist(b1.x,b1.y, b2.x,b2.y);
  }

  function draw()
  {
    line(b1.x,b1.y, b2.x,b2.y);
  }

  // Apply the spring to its masses

  function apply()
  {
    // Restorative force is proportional to difference in length from rest length

    var len = dist(b1.x,b1.y, b2.x,b2.y);
    if (len>0) {
      var f = k*(len-rest);
      // Apply it to x coordinates, moving each toward the other

      var fx = f*(b1.x-b2.x)/len;
      b1.vx -= fx;
      b2.vx += fx;
      // Similarly with y

      var fy = f*(b1.y-b2.y)/len;
      b1.vy -= fy;
      b2.vy += fy;
    }
 }
}