Fixed-area Bubble: an experiment with 2D soft bodies

A first experiment with 2D soft bodies:


--
-- Fixed-Area Bubble in Lua for Codea
--
-- Based on E W Jordan's algorithm implemented in Processing
-- http://www.ewjordan.com/processing/VolumeBlob/ConstantAreaBlob.pde
-- Licence: Unknown
--

supportedOrientations(LANDSCAPE_ANY)
displayMode(FULLSCREEN)
function setup()
    twoPi   = 2 * math.pi
    epsilon = .0001
    g       = 9.8 * 3 -- Gravity
    
    l = 10            -- Left edge
    r = WIDTH - 10    -- Right edge
    b = 10            -- Bottom edge
    t = HEIGHT - 10   -- Top edge
    
    tRadius = 50      -- Size of touch circle
    tX = WIDTH/4      -- Position of touch circle
    tY = HEIGHT/4
    tvX = 0           -- Velocity of touch circle
    tvY = 0
    k = 3             -- Spring strength
    drag = 2          -- Drag on movement
    destX = tX        -- Destination of touch circle
    destY = tY
    
    jForce = 200      -- Strength of jump, if double-tapped
    
    n           = 40  -- Number of points in bubble
    bRadius     = 100 -- Size of bubble
    nIters  = 5   
    relax   = 0.9

    setupBubble()     -- Create the bubble
    textMode(CORNER)
end

function draw()
    respondToEvents()
    integrate(DeltaTime)
    constrainEdges()
    collideWithWalls()
    collideWithTouch()
    updateTouch(DeltaTime)
    
    background(0)
    fill(255)
    text("Fixed-area Bubble (with acknowledgements to E W Jordan)",
        10 , HEIGHT - 25)    
    drawBubble()
    drawTouch()
end

function touched(touch)
    if isInsideBubble(touch.x, touch.y) then
        if touch.tapCount == 2 and touch.state == ENDED and
            hitFloor then
            jump = true
        end
    else
        destX = touch.x
        destY = touch.y
    end
end

function isInsideBubble(pX, pY)
    for i = 1, n do
        local c = (pY - y[i]) * (x[i % n + 1] - x[i]) - 
            (pX - x[i]) * (y[i % n + 1] - y[i])
        if c > 0 then return false end
    end
    return true
end

function updateTouch(dt)
    local fX = (destX - tX) * k - tvX * drag
    local fY = (destY - tY) * k - tvY * drag
    tvX = tvX + fX * dt
    tvY = tvY + fY * dt
    tX = tX + tvX * dt
    tY = tY + tvY * dt
end  

function respondToEvents()  
    if jump and hitFloor then
        local cmx = 0
        local cmy = 0
        for i = 1, n do
            cmx = cmx + x[i]
            cmy = cmy + y[i]
        end
        cmx = cmx / n
        cmy = cmy / n
        for i = 1, n do
            ax[i] = ax[i] - (x[i] - cmx) * jForce
        end
        jump = false
    end
end

function drawTouch()
    stroke(0, 240, 255)
    strokeWidth(5)
    noFill()
    ellipse(tX, tY, 2 * tRadius)
end

function drawBubble()
    fill(255)
    stroke(255)
    strokeWidth(5)
    for i = 1, n do
        line(x[i], y[i], x[i % n + 1], y[i % n + 1])
    end
end

function setupBubble()
    x = {}
    y = {}
    xLast = {}
    yLast = {}
    ax = {}
    ay = {}
  
    local cx = WIDTH/2
    local cy = HEIGHT/2
    
    for i = 1, n do
        local a = (i - 1)/n * twoPi
        x[i] = cx + math.sin(a) * bRadius
        y[i] = cy + math.cos(a) * bRadius
        xLast[i] = x[i]
        yLast[i] = y[i]
        ax[i] = 0
        ay[i] = 0
    end
    local dx = x[2] - x[1]
    local dy = y[2] - y[1]
    len = math.sqrt(dx * dx + dy * dy)
    bubbleAreaTarget = bubbleArea()
end

function fixEdge()
    local dx = {}
    local dy = {}
    for i = 1, n do
        dx[i] = 0
        dy[i] = 0
    end 
    for count = 1, nIters do
        for i = 1, n do
            local j = i % n + 1
            local eX = x[j] - x[i]
            local eY = y[j] - y[i]
            local d = math.sqrt(eX * eX + eY * eY)
            if d < epsilon then d = 1 end
            local ratio = 1 - len / d
            dx[i] = dx[i] + relax * eX * ratio / 2
            dy[i] = dy[i] + relax * eY * ratio / 2
            dx[j] = dx[j] - relax * eX * ratio / 2
            dy[j] = dy[j] - relax * eY * ratio / 2
        end  
        for i = 1, n do
            x[i] = x[i] + dx[i]
            y[i] = y[i] + dy[i]
            dx[i] = 0
            dy[i] = 0
        end
    end
end

function constrainEdges()
    fixEdge()
    local edge = 0
    local nx = {}
    local ny = {}
    for i = 1, n do
        local j = i % n + 1
        local dx = x[j] - x[i]
        local dy = y[j] - y[i]
        local d = math.sqrt(dx * dx + dy * dy)
        if d < epsilon then d = 1 end
        nx[i] =  dy / d
        ny[i] = -dx / d
        edge = edge + d
    end  
    local dArea = bubbleAreaTarget - bubbleArea()
    local dH = 0.5 * dArea / edge  
    for i = 1, n do
        local j = i % n + 1
        x[j] = x[j] + dH * (nx[i] + nx[j])
        y[j] = y[j] + dH * (ny[i] + ny[j])
    end
end

function bubbleArea()
    local area = 0
    for i= 1, n do
        area = area + x[i] * y[i % n + 1] - x[i % n + 1] * y[i]
    end
    area = area/2
    return area
end

function integrate(dt)
    local dtSqr = dt * dt
    for i = 1, n do
        local tempX = x[i]
        local tempY = y[i]
        x[i] = 2 * x[i] - xLast[i] + ax[i] * dtSqr
        y[i] = 2 * y[i] - yLast[i] + ay[i] * dtSqr - g * dtSqr
        xLast[i] = tempX
        yLast[i] = tempY
        ax[i] = 0
        ay[i] = 0
    end
end

function collideWithWalls()
    hitFloor = false
    for i = 1, n do
        if x[i] < l then x[i] = l end
        if x[i] > r then x[i] = r end
        if y[i] < b then
            y[i] = b
            xLast[i] = x[i]
            hitFloor = true
        end
        if y[i] > t then y[i] = t end
    end
end

function collideWithTouch()
    for i = 1, n do
        local dx = tX - x[i]
        local dy = tY - y[i]
        local dSqr = dx * dx + dy * dy
        if not (dSqr > tRadius*tRadius or
            dSqr < epsilon * epsilon) then
            local d = math.sqrt(dSqr)
            x[i] = x[i] - dx * (tRadius/d - 1)
            y[i] = y[i] - dy * (tRadius/d - 1)
        end
    end
end

An alternative to the drawBubble() function, for a solid bubble:


function drawBubble()
    local p = {}
    for i = 1, n do
        p[i] = vec2(x[i], y[i])
    end
    local t = triangulate(p)
    local m = mesh()
    m.vertices = t
    m:setColors(0, 0, 200)
    m:draw()
end

Nice! This is great!

Yes, very impressive

That is fantastic. So much fun to play with.

Wow, I made some great blobs! With enough dots they are like blobs of viscous fluid…

Thanks to E W Jordan for publishing the Processing example. The code below is a variation on the same theme. The main addition allows collisions with blocks. (Updated.)


--
-- Fixed-Area Bubble with Blocks
--
-- Based on E W Jordan's algorithm implemented in Processing
-- http://www.ewjordan.com/processing/VolumeBlob/ConstantAreaBlob.pde
-- Licence: Unknown
--
supportedOrientations(LANDSCAPE_ANY)
displayMode(FULLSCREEN)
function setup()
    twoPi = 2 * math.pi
    epsilon = .0001
    g = 9.8 * 3    
    l = 10
    r = WIDTH - 10
    b = 10
    t = HEIGHT - 10   
    tRadius = 50
    tX = WIDTH/4
    tY = HEIGHT*4/5
    tvX = 0
    tvY = 0
    k = 3
    drag = 2
    destX = tX
    destY = tY
    x, y = {}, {}
    isEdged = true
    nIters = 5
    relax = 0.9   
    boxes = {}
    for i = 1, 3 do
        boxes[i * 2 - 1] =
            initBox(WIDTH/3, HEIGHT * i/5, WIDTH/(i + 3), 50)
        boxes[i * 2] =
            initBox(WIDTH * 2/3, HEIGHT * i/5, WIDTH/(i + 3), 50)
    end
    textMode(CORNER)
end

function draw()
    background(0)
    if hasBubble then
        integrate(DeltaTime)
        constrainEdges()
        collideWithWalls()
        collideWithTouch()
        collideWithBoxes()
        updateTouch(DeltaTime) 
    else
        fill(255)
        text("Fixed-area Bubble with Blocks"..
            " (with acknowledgements to E W Jordan).",
            10 , HEIGHT - 25)
        text("Draw a closed bubble clockwise, slowly, to begin.",
            10, HEIGHT - 50)
        text("Manipulate the bubble with the pulsing orb.",
            10, HEIGHT - 75)
        end
    for i = 1, #boxes do
        boxes[i]:drawBox()
    end
    if n then drawBubble() end
    drawTouch()
end

function touched(touch)
    if not hasBubble then
        table.insert(x, touch.x)
        table.insert(y, touch.y)
        n = #x
        if touch.state == ENDED then
            hasBubble = true
            setupBubble()
        end
        return
    end
    destX = touch.x
    destY = touch.y
end

function updateTouch(dt)
    local fX = (destX - tX) * k - tvX * drag
    local fY = (destY - tY) * k - tvY * drag
    tvX = tvX + fX * dt
    tvY = tvY + fY * dt
    tX = tX + tvX * dt
    tY = tY + tvY * dt
end

function drawTouch()
    noStroke()
    local a = 32 + 8 * math.sin(ElapsedTime * 3)
    fill(255, 0, 192, a)
    for i = 1, 16 do
        ellipse(tX, tY, tRadius * i/8)
    end
end

function drawBubble()
    local p = {}
    for i = 1, n do
        p[i] = vec2(x[i], y[i])
    end
    local t = triangulate(p)
    local m = mesh()
    m.vertices = t
    m:setColors(0, 0, 200)
    m:draw()
    if isEdged then
        stroke(0, 255, 255)
        strokeWidth(5)
        for i = 1, n do
            line(x[i], y[i], x[i % n + 1], y[i % n + 1])
        end
    end
end

function setupBubble()
    xLast = {}
    yLast = {}
    ax    = {}
    ay    = {}
    for i = 1, n do
        xLast[i] = x[i]
        yLast[i] = y[i]
        ax[i] = 0
        ay[i] = 0
    end
    bubbleAreaTarget = bubbleArea()
    len = 2 * math.sqrt(math.pi * math.abs(bubbleAreaTarget)) / n
end

function fixEdge()
    local dx = {}
    local dy = {}
    for i = 1, n do
        dx[i] = 0
        dy[i] = 0
    end 
    for count = 1, nIters do
        for i = 1, n do
            local j = i % n + 1
            local eX = x[j] - x[i]
            local eY = y[j] - y[i]
            local d = math.sqrt(eX * eX + eY * eY)
            if d < epsilon then d = 1 end
            local ratio = 1 - len / d
            dx[i] = dx[i] + relax * eX * ratio / 2
            dy[i] = dy[i] + relax * eY * ratio / 2
            dx[j] = dx[j] - relax * eX * ratio / 2
            dy[j] = dy[j] - relax * eY * ratio / 2
        end  
        for i = 1, n do
            x[i] = x[i] + dx[i]
            y[i] = y[i] + dy[i]
            dx[i] = 0
            dy[i] = 0
        end
    end
end

function constrainEdges()
    fixEdge()
    local edge = 0
    local nx = {}
    local ny = {}
    for i = 1, n do
        local j = i % n + 1
        local dx = x[j] - x[i]
        local dy = y[j] - y[i]
        local d = math.sqrt(dx * dx + dy * dy)
        if d < epsilon then d = 1 end
        nx[i] =  dy / d
        ny[i] = -dx / d
        edge = edge + d
    end  
    local dArea = bubbleAreaTarget - bubbleArea()
    local dH = 0.5 * dArea / edge  
    for i = 1, n do
        local j = i % n + 1
        x[j] = x[j] + dH * (nx[i] + nx[j])
        y[j] = y[j] + dH * (ny[i] + ny[j])
    end
end

function bubbleArea()
    local area = 0
    for i= 1, n do
        area = area + x[i] * y[i % n + 1] - x[i % n + 1] * y[i]
    end
    area = area/2
    return area
end

-- Verlet integration
function integrate(dt)
    local dtSqr = dt * dt
    for i = 1, n do
        local tempX = x[i]
        local tempY = y[i]
        x[i] = 2 * x[i] - xLast[i] + ax[i] * dtSqr
        y[i] = 2 * y[i] - yLast[i] + ay[i] * dtSqr - g * dtSqr
        xLast[i] = tempX
        yLast[i] = tempY
        ax[i] = 0 -- Reset acceleration
        ay[i] = 0
    end
end

function collideWithWalls()
    for i = 1, n do
        if x[i] < l then x[i] = l end
        if x[i] > r then x[i] = r end
        if y[i] < b then
            y[i] = b
            xLast[i] = x[i]
        end
        if y[i] > t then y[i] = t end
    end
end

function collideWithTouch()
    for i = 1, n do
        local dx = tX - x[i]
        local dy = tY - y[i]
        local dSqr = dx * dx + dy * dy
        if not (dSqr > tRadius*tRadius or
            dSqr < epsilon * epsilon) then
            local d = math.sqrt(dSqr)
            x[i] = x[i] - dx * (tRadius/d - 1)
            y[i] = y[i] - dy * (tRadius/d - 1)
        end
    end
end

function collideWithBoxes()
    for j = 1, #boxes do
        local box = boxes[j]
        for i = 1, n do
            if box:isInBox(x[i], y[i]) then
                x[i], y[i] = box:moveToBoxEdge(x[i], y[i])
            end
        end
    end
end


function isInBox(self, px, py)
    if px < self.x - self.w/2 then return false end
    if px > self.x + self.w/2 then return false end
    if py < self.y - self.h/2 then return false end
    if py > self.y + self.h/2 then return false end
    return true
end

function moveToBoxEdge(self, px, py)
    local dx = px - self.x
    local dy = py - self.y
    local dex = self.w/2 - math.abs(dx)
    local dey = self.h/2 - math.abs(dy)
    if dex < dey then
        if dx > 0 then
            return self.x + self.w/2, py
        else
            return self.x - self.w/2, py
        end
    else
        if dy > 0 then
            return px, self.y + self.h/2
        else
            return px, self.y - self.h/2
        end
    end
end

function initBox(x, y, w, h)
    return {x = x, y = y, w = w, h = h,
    drawBox = drawBox, 
    isInBox = isInBox,
    moveToBoxEdge = moveToBoxEdge}
end

function drawBox(self)
    fill(255, 127, 0, 255)
    noStroke()
    rectMode(CENTER)
    rect(self.x, self.y, self.w, self.h)
end    

I have updated setupBubble() in the variation above. len is the constraint for the distance between each of the n points of the edge. For a bubble, len should have been 1/n th of the circumference of a circle of equivalent target area, not 1/n th of the total length of the edge of the initial bubble.

As, for a circle, area = pi * r * r and circumference = 2 * pi * r, that gives:


function setupBubble()
    ...
    bubbleAreaTarget = bubbleArea()
    len = 2 * math.sqrt(math.pi * math.abs(bubbleAreaTarget)) / n
end

math.abs() is required because the signed area of the bubble (calculated by bubbleArea()) can be (and is) negative.

Before I ran out of characters in the comment above, I wanted to acknowledge that, in the revised code, drawing the initial bubble was inspired by @juaxix’s 2D physics game sandbox and the shading effect on the orb’s force field was inspired by part of one of @Connorbot999’s special effects.

Cool you you got inspired by my rubish coding skills :slight_smile:

My attempt of soft bodey’s (goo ball’s):

function setup()
    iparameter("xforce",-50,50,0)
    physics.body(EDGE,vec2(0,0),vec2(WIDTH,0))
   thingy = makejelli(333,333,20,math.random(0,900),5)
end
function draw()
    background(0, 0, 0, 255)
    strokeWidth(30)
    stroke(35, 255, 0, 255)
   drawjelli(thingy) 
end
function makejelli(xx,yy,dist,angle,squish)
    local squishy = 3/squish
    local poses = {
    vec2(-dist,-dist),
    vec2(dist,-dist),
    vec2(dist,dist),
    vec2(-dist,dist),
    vec2(0,0)
    }
    local derps = {}
    for i = 1,5 do
        local merr = 0
 if i == 5 then       
merr = physics.body(CIRCLE,5)  else
merr = physics.body(CIRCLE,3) end
merr.position = poses[i]:rotate(math.rad(angle))
merr.position = merr.position + vec2(xx,yy)
merr.fixedRotation = false
merr.linearVelocity= vec2(math.random(-1,1),math.random(-1,1))
merr.friction = 1.1
derps[i] = merr
end
for j = 1,4 do
local aa = derps[5]
local bb = derps[j]
local mup =    physics.joint(DISTANCE,aa,bb,aa.position,bb.position)
mup.frequency = 222/dist*squishy
for k = 1,4 do
if j~= k then
local aa = derps[j]
local bb = derps[k]
local mup =    physics.joint(DISTANCE,aa,bb,aa.position,bb.position)
if math.abs(j-k) == 2 then mup.frequency = 88/dist*squishy else
mup.frequency = 66/dist*squishy 
end
end
end
end
return derps
end
function drawjelli(derps)
for j = 1,5 do
for k = 1,5 do
if j~= k then
local aa = derps[j]
local bb = derps[k]
line(aa.x,aa.y,bb.x,bb.y)
end
end
end
end

Hello @Connorbot999. I see that you make your soft body from five particles in a square (one at its centre), with soft distance joints between all of the particles; and that you render the body by using fat lines between particles.

However, you double-up on the joints between the corners and on the lines that you render. I think you also run the risk of an automatic garbage collection destroying the floor EDGE and all the joints, because they are not coded to be preserved (unlike the particles, which are preserved in derps).

See, for example, the alternative code below:


supportedOrientations(LANDSCAPE_ANY)
function setup()
    size = 50
    floor = physics.body(EDGE, vec2(0, size), vec2(WIDTH, size))
    joints = {} -- This will hold the joints
    thingy = makeJelli(WIDTH/2, HEIGHT * 4/5, size, math.random(0, 90))
    stroke(35, 255, 0)
    strokeWidth(size * 1.5)
    -- collectgarbage()
end

function draw()
    background(0)
    drawJelli(thingy)
end

-- Touch the viewer to see the underlying structure of the goo ball
function touched(touch)
    if touch.state == ENDED then
        strokeWidth(size * 1.5)
    else
        strokeWidth(5)
    end
end      

function makeJelli(xx, yy, dist, angle)
    local rad = math.rad(angle)
    local poses = {
        vec2(-dist, -dist),
        vec2( dist, -dist),
        vec2( dist,  dist),
        vec2(-dist,  dist),
        vec2(0, 0)
        }
    local derps = {}
    for i = 1, 5 do
        local merr = physics.body(CIRCLE, 3)
        merr.position = poses[i]:rotate(rad) + vec2(xx, yy)
        merr.friction = 1.1
        derps[i] = merr
    end
    local cc = derps[5]
    for j = 1, 4 do
        local bb = derps[j]
        local mup = addJ(physics.joint(DISTANCE, cc, bb,
            cc.position, bb.position))
        mup.frequency = 7
        k = j % 4 + 1
        local aa = derps[k]
        local mup = addJ(physics.joint(DISTANCE, aa, bb,
            aa.position, bb.position))
        mup.frequency = 4
    end
    return derps
end

function drawJelli(derps)
    for j = 1, 3 do
        local aa = derps[j]
        for k = j + 1, 4 do
            local bb = derps[k]
            line(aa.x, aa.y, bb.x, bb.y)
        end
    end
end

-- Helper function
function addJ(joint)
    table.insert(joints, joint)
    return joint
end

A problem you may encounter is: What is to happen when more than one of your goo balls collide? That is why my goo balls here have linked thin rectangles around the edge.

@Connorbot999, is your code based on KMEB example ? I’m just curious! http://twolivesleft.com/Codea/Talk/discussion/1141/softbodies/p1

.@yelnats the code @Connorbot999 posted appears to be a slight modification of @KMEB’s example.

.@Connorbot999 it would be good if you could acknowledge this next time.

I jest you’d his Method?