Making SWCloud

How we designed a SketchWaveJS class from scratch

Building a cloud from overlapping ellipses — a case study in class design

Starting Point: What do we actually need?

A cloud is one of the most recognizable shapes in nature, yet surprisingly simple to approximate geometrically. Before writing a single line of code, we ask a key design question:

Design Question: What is the simplest set of existing shapes that, when combined, produces something that looks like a cloud?

The answer: overlapping ellipses. A handful of horizontally-elongated ellipses, scattered and overlapping around a central anchor, gives a convincing cumulus cloud silhouette.

Since SketchWaveJS already has SWEllipse — a full-featured ellipse class with fill, stroke, rotation, and breathing animation — we can build SWCloud as a composite class that owns and manages an internal array of SWEllipse instances. This is the composition over from-scratch approach.

Why SWEllipse and Not SWDisk?

SketchWaveJS already has SWDisk (perfect circles). Why choose SWEllipse instead?

SWDisk (circle)
  • Radius is the same in all directions.
  • Stacking circles gives a blob-like, rounded cloud — fine, but not quite right.
  • Real clouds spread out horizontally more than vertically.
SWEllipse ✔
  • Separate radiusX (horizontal) and radiusY (vertical).
  • Default radiusX > radiusY naturally elongates puffs horizontally.
  • By changing the aspect ratio, you can also create tall, towering storm clouds or flat stratus layers.
Design Decision: Use SWEllipse as the cloud's building block. Default puffWidth (radiusX) = 1.8 and puffHeight (radiusY) = 1.0 gives a natural 1.8:1 aspect ratio, mimicking a real cloud lobe.

Puff Layout: The Idea

The first attempt was a parabolic layout: arrange puffs in a row, lift the center puff highest, taper sizes toward the edges. It worked geometrically, but the result looked too predictable — more like a croissant arch than a cloud.

Observation: Real clouds are irregular. No two clouds look alike. A formula that produces the same symmetric arch every time is the wrong model.

The better approach: give each puff a random offset from the cloud center. Each puff draws a random (dx, dy) from the range −1..+1, and that offset is then scaled by two independent magnitudes — Spread H (horizontal) and Spread V (vertical) — before being multiplied by the puff's base dimensions:

  // For each puff i, draw a random normalized offset:
  offset.dx   = random(-1, +1)        // horizontal scatter, -1 .. +1
  offset.dy   = random(-1, +1)        // vertical   scatter, -1 .. +1
  offset.size = random(0.70, 1.00)    // per-puff size variation

  // Convert to user coords at draw time:
  px = center.x + offset.dx * spreadH * puffWidth
  py = center.y + offset.dy * spreadV * puffHeight
  rX = puffWidth  * offset.size
  rY = puffHeight * offset.size

Adjusting the spread sliders scales the existing offsets — no new random numbers are drawn. This means you can explore tighter or looser arrangements of the same cloud shape. To get an entirely new arrangement, call randomize() (or press z in the demo).

Insight: Storing the offsets separately from the puff positions means the class can scale, rebuild, and reset without losing the random character of a particular cloud. reset() restores the original offsets, so the cloud returns to its exact starting shape.

Class Structure: What Should SWCloud Know?

Before writing the class, we list what properties and methods it needs:

Properties
  • center — SWPoint anchor
  • puffCount
  • puffWidth (radiusX)
  • puffHeight (radiusY)
  • spread (horizontal scatter scale)
  • spreadV (vertical scatter scale)
  • fillColor, strokeColor
  • strokeWeight
  • showCenters
  • breatheStagger
  • driftSpeed (u/s; + = right)
  • noiseAmp (Perlin displacement)
  • noiseSpeed (noise time scale)
  • wrapGap (off-canvas overshoot)
  • _offsets[] (random dx/dy/size per puff)
  • originalOffsets[] (for reset)
  • _puffs[] (SWEllipse array)
Rendering
  • drawOnGrid(grid)
  • draw()
  • _buildPuffs() (internal)
Setters & Animation
  • setPuffCount(n)
  • setPuffWidth(w)
  • setPuffHeight(h)
  • setSpread(s)
  • setSpreadV(v)
  • setCenter(x,y)
  • setFillColor(col)
  • setStrokeColor(col)
  • setStrokeWeight(w)
  • setShowCenters(bool)
  • setDriftSpeed(v)
  • setNoiseAmp(v)
  • setNoiseSpeed(v)
  • setWrapGap(v)
  • breathe(sinX,sinY,t)
  • update(t, deltaT, …)
  • randomize()
  • reset()

Building It Step by Step

Step 1 — Single ellipse (proof of concept)

Before writing a class, verify the concept: draw one SWEllipse with radiusX > radiusY and confirm it looks like a cloud puff.

// Proof of concept — single puff
const center = new SWPoint(0, 0);
const fill   = new SWColor(200, 12, 98, 90, "cloudFill");
const stroke = new SWColor(230, 30, 60, 100, "cloudStroke");

const puff = new SWEllipse(center, 1.8, 1.0, fill, { strokeColor: stroke, strokeWeight: 1 });
puff.drawOnGrid(grid);  // draws a wide, shallow ellipse — our first "puff" ✓
Step 2 — Three puffs placed manually

Place three ellipses by hand to see what a cloud composed of puffs looks like. The center puff goes slightly higher than the two flanking puffs.

// Three puffs by hand — left, center (lifted), right
const mkPuff = (x, y, rX, rY) =>
    new SWEllipse(
        new SWPoint(x, y), rX, rY,
        new SWColor(200, 12, 98, 90, "puff"),
        { strokeColor: new SWColor(230, 30, 60, 100, "border"), strokeWeight: 1 }
    );

const puffs = [
    mkPuff(-2.0, 0.0, 1.2, 0.65),   // left  — smaller, lower
    mkPuff( 0.0, 1.0, 1.8, 1.00),   // center — larger, higher
    mkPuff(+2.0, 0.0, 1.2, 0.65),   // right — smaller, lower
];

puffs.forEach(p => p.drawOnGrid(grid));  // 🌤️ Looks like a cloud!
Step 3 — Replace the formula with random offsets

The three-puff manual layout worked visually, but a deterministic formula for N puffs produced a predictable symmetric arch — like a croissant, not a cloud. The fix: give each puff a random normalized offset (dx, dy in −1..+1) and store those offsets in an array so they can be reused, scaled, and reset.

// _generateOffsets(n) — called once at construction, and again by randomize()
function _generateOffsets(n) {
    this._offsets = [];
    if (n === 1) {
        this._offsets.push({ dx: 0, dy: 0, size: 1.0 });  // single puff always centered
        return;
    }
    for (let i = 0; i < n; i++) {
        this._offsets.push({
            dx:   Math.random() * 2 - 1,        // -1 .. +1  horizontal scatter
            dy:   Math.random() * 2 - 1,        // -1 .. +1  vertical   scatter
            size: 0.70 + Math.random() * 0.30,  // 0.70 .. 1.00  size variation
        });
    }
}

// _buildPuffs() — converts offsets to SWEllipse positions
for (let i = 0; i < N; i++) {
    const off = this._offsets[i];
    const px  = center.x + off.dx * spreadH * puffWidth;
    const py  = center.y + off.dy * spreadV * puffHeight;
    const rX  = puffWidth  * off.size;
    const rY  = puffHeight * off.size;
    // ... create SWEllipse at (px, py) with radii (rX, rY)
}
Step 4 — Wrap it in a class

Move the offset generation and puff building into a class. The constructor calls _generateOffsets() once, then _buildPuffs(). Layout-affecting setters call _buildPuffs() (reusing existing offsets). randomize() calls _generateOffsets() then _buildPuffs() to get a fresh random shape.

class SWCloud {
    constructor(center, options = {}) {
        this.center      = center;
        this.puffCount   = options.puffCount  ?? 5;
        this.puffWidth   = options.puffWidth  ?? 1.8;
        this.puffHeight  = options.puffHeight ?? 1.0;
        this.spread      = options.spread     ?? 1.0;   // horizontal scatter scale
        this.spreadV     = options.spreadV    ?? 0.6;   // vertical   scatter scale
        this.fillColor   = options.fillColor  ? SWColor.copy(options.fillColor)  : undefined;
        this.strokeColor = options.strokeColor ? SWColor.copy(options.strokeColor) : undefined;
        this.strokeWeight = options.strokeWeight ?? 1;
        this.showCenters  = options.showCenters  ?? false;
        this.breatheStagger = options.breatheStagger ?? 0.3;

        // Keep originals for reset() including the random offsets
        this.originalCenter     = new SWPoint(center.x, center.y);
        this.originalPuffCount  = this.puffCount;
        this.originalPuffWidth  = this.puffWidth;
        this.originalPuffHeight = this.puffHeight;
        this.originalSpread     = this.spread;
        this.originalSpreadV    = this.spreadV;

        this._offsets = [];
        this._generateOffsets(this.puffCount);
        this.originalOffsets = this._offsets.map(o => ({ ...o })); // snapshot

        this._puffs = [];
        this._buildPuffs();
    }

    _generateOffsets(n) { /* random dx/dy/size per puff */ }
    _buildPuffs()       { /* convert offsets to SWEllipse array */ }

    drawOnGrid(grid) { for (const p of this._puffs) p.drawOnGrid(grid); }
    draw()           { for (const p of this._puffs) p.draw();           }

    setPuffCount(n)  { this.puffCount  = Math.max(1, Math.round(n)); this._generateOffsets(this.puffCount); this._buildPuffs(); }
    setPuffWidth(w)  { this.puffWidth  = w; this._buildPuffs(); }
    setPuffHeight(h) { this.puffHeight = h; this._buildPuffs(); }
    setSpread(s)     { this.spread     = s; this._buildPuffs(); }
    setSpreadV(v)    { this.spreadV    = v; this._buildPuffs(); }
    setCenter(x, y)  { this.center.x  = x; this.center.y = y; this._buildPuffs(); }
    randomize()      { this._generateOffsets(this.puffCount); this._buildPuffs(); }
}
Step 5 — Adding the breathe animation (with stagger)

SWEllipse already knows how to breathe (scale its radii via a SWSinusoid). SWCloud delegates to each puff's breathe() method, but offsets the time by i × stagger so adjacent puffs are out of phase — producing a billowing, organic motion.

// Inside SWCloud — breathe delegates to each puff with a phase stagger
breathe(sinusoidX, sinusoidY, t) {
    for (let i = 0; i < this._puffs.length; i++) {
        // Each puff is (i × stagger) seconds ahead in its breath cycle
        this._puffs[i].breathe(sinusoidX, sinusoidY, t + i * this.breatheStagger);
    }
}

// In the p5 sketch — wiring up breathe:
// 1. Create the sinusoid (oscillates between 0.7× and 1.3× original size)
breatheSinusoid = SWSinusoid.copy(UNIT_SINUSOID);
breatheSinusoid.setPeriod(3.0);
breatheSinusoid.adjustWaveUsingExtrema(0.70, 1.30);

// 2. Call each frame (in draw()):
const bt = breatheElapsed + (millis()/1000 - breatheStartTime);
cloud.breathe(breatheSinusoid, breatheSinusoid, bt);  // same sinusoid for X and Y
Step 6 — Adding drift and Perlin-noise morphing

A single update(t, deltaT, …) method handles both animations each frame — no rebuild, no new objects. Drift advances center.x by driftSpeed × deltaT and wraps the cloud when it exits the canvas. On each wrap the cloud reappears on the opposite side at a slightly different height (a random nudge of ±1.5× puffHeight, clamped so the cloud always remains fully visible). Noise Morph then repositions every puff by adding an independent Perlin-noise displacement — each puff samples a different region of the noise field via prime-number multipliers on its index i.

// Inside SWCloud.update() — called once per frame from draw()
update(t, deltaT, wrapLeft, wrapRight, doDrift, doNoise, wrapTop, wrapBottom) {

    // 1. Drift: advance center.x and wrap with a vertical nudge
    if (doDrift) {
        this.center.x += this.driftSpeed * deltaT;
        const margin   = this.wrapGap;    // user-controlled gap past the canvas edge
        const maxNudge = this.puffHeight * 1.5;
        const nudge    = (Math.random() * 2 - 1) * maxNudge;    // random ± each wrap
        const halfH    = this.puffHeight * Math.max(this.spreadV, 0.5) * (this.puffCount * 0.3 + 1);

        if (this.driftSpeed >= 0 && this.center.x - margin > wrapRight) {
            this.center.x = wrapLeft - margin;
            this.center.y = Math.min(wrapTop    - halfH,
                            Math.max(wrapBottom + halfH, this.center.y + nudge));
        } else if (this.driftSpeed < 0 && this.center.x + margin < wrapLeft) {
            this.center.x = wrapRight + margin;
            this.center.y = Math.min(wrapTop    - halfH,
                            Math.max(wrapBottom + halfH, this.center.y + nudge));
        }
    }

    // 2. Reposition each puff: base offset + optional Perlin-noise displacement
    for (let i = 0; i < this._puffs.length; i++) {
        const off   = this._offsets[i];
        const baseX = this.center.x + off.dx * this.spread  * this.puffWidth;
        const baseY = this.center.y + off.dy * this.spreadV * this.puffHeight;
        if (doNoise) {
            // Prime multipliers on i give each puff a distinct slice of the noise field
            const nx = (noise(i * 1.73,      t * this.noiseSpeed) * 2 - 1) * this.noiseAmp;
            const ny = (noise(i * 3.17 + 50, t * this.noiseSpeed) * 2 - 1) * this.noiseAmp * 0.5;
            this._puffs[i].center.x = baseX + nx;
            this._puffs[i].center.y = baseY + ny;
        } else {
            this._puffs[i].center.x = baseX;
            this._puffs[i].center.y = baseY;
        }
    }
}

// In the p5 sketch — call once per frame when either animation is active:
if ((shouldDrift || shouldNoiseMorph) && cloud) {
    cloud.update(t, deltaT, grid.UL.x, grid.LR.x, shouldDrift, shouldNoiseMorph,
                 grid.UL.y, grid.LR.y);
}

Key Design Decisions

Rebuild vs. Update

When the user changes puffWidth, spread, spreadV, or center, we call _buildPuffs() which creates an entirely new set of SWEllipse objects using the existing random offsets.

Why? It keeps the code simple. The alternative — updating each puff's position in-place — requires duplicating the placement logic in every setter. Rebuilding is negligible at 30 fps for ≤ 9 puffs.

Color changes: no rebuild

When the user changes fill/stroke color or stroke weight, we iterate the existing _puffs[] array and update each puff's property directly — no rebuild needed.

Why? Color changes don't affect layout, so rebuilding would throw away the original radii stored in each puff (used by the breathe animation for scaling). Direct updates are safer.

Changing puffCount = new offsets

When the user changes puffCount, we call _generateOffsets(n) to get a fresh set of random offsets for the new count — then immediately _buildPuffs(). All other layout setters (setSpread, setPuffWidth, etc.) reuse the existing offsets so the cloud shape is preserved while only the scale changes.

reset() restores the original random shape

At construction, the random offsets are deep-copied into originalOffsets[]. When reset() is called, those copies are restored so the cloud returns to its exact original appearance — not a new random arrangement. Use randomize() when you want a brand-new layout.

update() writes positions in-place

Drift and noise morph update each puff's center.x/y directly every frame without calling _buildPuffs(). This avoids creating new SWEllipse objects at 30 fps.

Why? The base position is recalculated from _offsets[] each frame anyway, so in-place writes are both correct and fast. Rebuilding would also lose the original radii needed by breathe().

Vertical nudge on wrap

Each time the cloud wraps from one edge to the other, a random vertical offset (±1.5× puffHeight) is added to center.y. The result is clamped so the cloud always stays at least one cloud-height inside the top and bottom canvas bounds.

Why? A cloud that reappears at exactly the same height on every pass looks mechanical. The nudge makes the path vary naturally without any extra state.

Using SWCloud in Your Own Sketch

Load the dependencies in the correct order:

<script src="shapeClasses/swSinusoid.js"></script>
<script src="shapeClasses/swColor.js"></script>
<script src="shapeClasses/swPoint.js"></script>
<script src="shapeClasses/swGrid.js"></script>
<script src="shapeClasses/swEllipse.js"></script>   <!-- SWCloud depends on SWEllipse -->
<script src="shapeClasses/swCloud.js"></script>

Minimal usage — a white cloud on a sky-blue canvas:

let grid, cloud;

function setup() {
    createCanvas(500, 400);
    colorMode(HSB, 360, 100, 100, 100);
    initializeSWColors();   // from swColor.js — initializes predefined SW colors

    // Grid: user coords −10..+10
    grid = new SWGrid({ UL: new SWPoint(-10, 10), LR: new SWPoint(10, -10) });

    // Default white-ish cloud centered at (0, 0)
    cloud = new SWCloud(
        new SWPoint(0, 0),
        {
            puffCount:    5,
            puffWidth:    1.8,
            puffHeight:   1.0,
            spread:       1.0,   // horizontal scatter scale (0 = all stacked)
            spreadV:      0.6,   // vertical   scatter scale
            fillColor:    new SWColor(200, 8, 99, 95, "white"),
            strokeColor:  new SWColor(220, 25, 65, 100, "border"),
            strokeWeight: 1,
        }
    );
}

function draw() {
    background(200, 55, 88);   // sky blue
    grid.draw();
    cloud.drawOnGrid(grid);
}

Summary

SWCloud was built in six conceptual steps:

  1. Pick the primitive: SWEllipse, because real clouds are horizontally elongated.
  2. Lay out manually: Place three puffs by hand and confirm it looks like a cloud.
  3. Use random offsets: Each puff draws a random (dx, dy, size); Spread H/V sliders scale the scatter without new randomness.
  4. Encapsulate: Wrap in a class; layout setters call _buildPuffs(); randomize() generates fresh offsets.
  5. Animate (breathe): Delegate to each puff's breathe() with a stagger offset for organic billowing motion.
  6. Animate (drift + morph): update() advances center.x with wrap-around (plus a random vertical nudge each pass) and repositions each puff using independent Perlin-noise offsets for an ever-changing cloud shape.

The result is a reusable, configurable cloud class that integrates naturally into the SketchWaveJS ecosystem, following the same conventions as SWDisk, SWEllipse, SWArch, and every other SW shape: drawOnGrid(), draw(), setX() setters, reset(), and consistent SWColor.copy() usage. Animations are layered independently — breathe, drift, and noise morph can each be toggled on or off without affecting the others.