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:
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?
- 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.
- Separate
radiusX(horizontal) andradiusY(vertical). - Default
radiusX > radiusYnaturally elongates puffs horizontally. - By changing the aspect ratio, you can also create tall, towering storm clouds or flat stratus layers.
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.
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).
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:
center— SWPoint anchorpuffCountpuffWidth(radiusX)puffHeight(radiusY)spread(horizontal scatter scale)spreadV(vertical scatter scale)fillColor,strokeColorstrokeWeightshowCentersbreatheStaggerdriftSpeed(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)
drawOnGrid(grid)draw()_buildPuffs()(internal)
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
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" ✓
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!
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)
}
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(); }
}
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
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
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.
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.
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.
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.
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().
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:
- Pick the primitive: SWEllipse, because real clouds are horizontally elongated.
- Lay out manually: Place three puffs by hand and confirm it looks like a cloud.
- Use random offsets: Each puff draws a random (dx, dy, size); Spread H/V sliders scale the scatter without new randomness.
- Encapsulate: Wrap in a class; layout setters call
_buildPuffs();randomize()generates fresh offsets. - Animate (breathe): Delegate to each puff's
breathe()with a stagger offset for organic billowing motion. - Animate (drift + morph):
update()advancescenter.xwith 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.