Procedural Ink Splotches with SVG
I recently built an interactive tool for generating ink splotch SVG paths. What started as a simple blob generator evolved into a surprisingly deep exploration of procedural generation techniques. This article breaks down the key algorithms and approaches that make the splotches look organic and realistic.
The Challenge
Ink splotches are deceptively complex. A real ink splatter has:
- An irregular central blob with organic edges
- Drips of varying shapes (tapered, bulbous, pinched)
- Satellite droplets scattered around the impact point
- Fine spray particles from the initial impact
- Thin threads connecting separated droplets
- Splatter rays shooting outward
The goal was to generate all of these procedurally, with enough randomness to look natural but enough control to be useful as a design tool.
Seeded Randomness
The foundation of any procedural generator is controlled randomness. I used a simple linear congruential generator (LCG) that produces deterministic sequences from a seed value:
function createRandom(seed: number) {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) % 4294967296;
return state / 4294967296;
};
}This approach has two major benefits:
- Reproducibility: The same seed always produces the same splotch, making it easy to save and share configurations.
- Predictable consumption: Each call to
random()advances the state exactly once, which becomes critical when generating complex shapes.
The RNG Consumption Trap
Early on, I hit a subtle bug where all drips appeared at the same angle. The culprit? I was calling random() inside a width-calculation function that ran per-segment. This consumed unpredictable amounts of RNG state, throwing off subsequent angle calculations.
The fix was to pre-generate all random values at the start of each drip loop:
for (let i = 0; i < numDrips; i++) {
// Pre-generate ALL random values for this drip
const angle = random() * Math.PI * 2;
const lengthRand = random();
const shapeTypeRand = random();
// Pre-generate per-segment randoms
const wobbleRands: number[] = [];
for (let j = 0; j < 12; j++) {
wobbleRands.push(random());
}
// Now use these values deterministically...
}Hermite Basis Interpolation
The secret to organic-looking curves is Hermite interpolation. Unlike Bezier curves where you define control points that the curve doesn't pass through, Hermite splines pass directly through each point while maintaining smooth tangents.
For the splotch generator, I used Hermite interpolation with Catmull-Rom style tangents, where the tangent at each point is automatically computed from its neighbors. This creates smooth, continuous curves with minimal configuration—just pass in your control points and get back an organic shape.
Catmull-Rom Tangents
Hermite interpolation requires a tangent vector at each point, but manually specifying tangents for every control point would be tedious. Catmull-Rom tangents solve this by deriving each tangent from the neighboring points:
function computeTangent(
points: Point[],
index: number,
closed: boolean,
tension: number = 0.5,
): Point {
const n = points.length;
let prev: Point, next: Point;
if (closed) {
// Wrap around for closed curves
prev = points[(index - 1 + n) % n];
next = points[(index + 1) % n];
} else {
// Clamp to endpoints for open curves
prev = index > 0 ? points[index - 1] : points[index];
next = index < n - 1 ? points[index + 1] : points[index];
}
return {
x: (next.x - prev.x) * tension,
y: (next.y - prev.y) * tension,
};
}The tangent at point B points from A toward C (its neighbors), scaled by a tension factor. This ensures:
- Automatic smoothness: The curve flows naturally through each point without manual tuning
- C1 continuity: Adjacent segments share tangents at their connection points—no visible seams
- Intuitive control: The tension parameter (typically 0.5) controls how tightly the curve follows the control points
Lower tension values (0.2–0.3) create tighter curves that hug the points closely. Higher values (0.5–0.7) produce looser, more flowing curves. For ink splotches, a tension around 0.5 gives the right balance between organic flow and responsiveness to the random control points.
Generating Closed Shapes
For filled shapes like the blob and drips, I needed to generate closed SVG paths from a set of control points. The approach differs for symmetrical shapes (blob, satellites) versus asymmetrical ones (drips, drizzles).
Closed Loops (Blob, Satellites)
For the central blob, I generate points in polar coordinates with random radius variation, then convert to Cartesian:
for (let i = 0; i < numPoints; i++) {
const angle = (i / numPoints) * Math.PI * 2;
const radiusVariation = (random() - 0.5) * 2 * bumpAmplitude;
const radius = baseRadius + radiusVariation;
points.push({ angle, radius });
}
const cartesianPoints = points.map((p) => ({
x: 50 + Math.cos(p.angle) * p.radius,
y: 50 + Math.sin(p.angle) * p.radius,
}));Then I run Hermite interpolation around the loop, sampling multiple points per segment to create a smooth path:
function hermiteClosedPath(
points: Point[],
samplesPerSegment: number,
tension: number,
): string {
const tangents = points.map((_, i) =>
computeTangent(points, i, true, tension),
);
const allPoints: Point[] = [];
for (let i = 0; i < points.length; i++) {
const p0 = points[i];
const p1 = points[(i + 1) % points.length];
const m0 = tangents[i];
const m1 = tangents[(i + 1) % points.length];
for (let j = 0; j < samplesPerSegment; j++) {
const t = j / samplesPerSegment;
allPoints.push(hermiteInterpolate(p0, p1, m0, m1, t));
}
}
// Build SVG path
let path = `M${allPoints[0].x.toFixed(1)},${allPoints[0].y.toFixed(1)} `;
for (let i = 1; i < allPoints.length; i++) {
path += `L${allPoints[i].x.toFixed(1)},${allPoints[i].y.toFixed(1)} `;
}
return path + "Z";
}Two-Sided Shapes (Drips, Drizzles)
Drips have distinct left and right edges that meet at a point. I generate these as two separate point arrays, then combine them into a closed shape:
function hermiteClosedShape(
rightSide: Point[],
leftSide: Point[],
samplesPerSegment: number,
tension: number,
): string {
// Interpolate right side (going down)
const rightInterpolated = interpolateOpenPath(
rightSide,
samplesPerSegment,
tension,
);
// Interpolate left side (reversed, going up)
const leftReversed = [...leftSide].reverse();
const leftInterpolated = interpolateOpenPath(
leftReversed,
samplesPerSegment,
tension,
);
// Combine: right side down, then left side up
let path = `M${rightInterpolated[0].x},${rightInterpolated[0].y} `;
for (const pt of rightInterpolated.slice(1)) {
path += `L${pt.x},${pt.y} `;
}
for (const pt of leftInterpolated) {
path += `L${pt.x},${pt.y} `;
}
return path + "Z";
}Drip Shape Variety
Real ink drips have diverse shapes. I implemented five distinct profiles:
1. Classic Taper
Simple linear narrowing to a point:
w = 1 - t * 0.85;2. Bulb End
Narrows then bulges at the tip, like a droplet about to fall:
w = 1 - t * 0.8;
if (t > 0.7) {
const bulbT = (t - 0.7) / 0.3;
w += bulbSize * Math.sin(bulbT * Math.PI);
}3. Pinched Middle
Hourglass shape using a Gaussian dip:
const distFromPinch = Math.abs(t - pinchPosition);
w = 1 - t * 0.5;
w -=
pinchAmount *
Math.exp(-(distFromPinch * distFromPinch) / (pinchWidth * pinchWidth));4. Teardrop
Fat base with quick quadratic taper:
if (t < 0.3) {
w = 1 + fatness * Math.sin(((t / 0.3) * Math.PI) / 2);
} else {
const taperT = (t - 0.3) / 0.7;
w = (1 + fatness) * (1 - taperT * taperT);
}5. Splatter
Irregular width with multiple Gaussian bulges:
w = 1 - t * 0.5;
for (let b = 0; b < numBulges; b++) {
const bulgePos = 0.2 + (b / numBulges) * 0.6;
const dist = t - bulgePos;
w += bulgeSize * Math.exp(-(dist * dist) / 0.01);
}Each drip randomly selects a shape type, creating natural variety without manual intervention.
Drizzles: Zig-Zag Paths
Drizzles simulate the meandering path of ink flowing across a surface. The key is generating a zig-zag centerline, then building the shape around it:
let cumulativeWiggle = 0;
for (let j = 0; j <= numSegments; j++) {
const t = j / numSegments;
// Base position along flow direction
let centerX = startX + Math.cos(angle) * length * t;
let centerY = startY + Math.sin(angle) * length * t;
// Add zig-zag perpendicular to flow
if (j > 0 && j < numSegments) {
const direction = wiggleDirections[j]; // Pre-generated: 1 or -1
const magnitude = wiggleMagnitudes[j]; // Pre-generated: 0.5-1.0
const dampening = 1 - t * 0.5; // Reduce wiggle toward tip
// Cumulative drift for flowing effect
cumulativeWiggle += direction * wiggleAmount * magnitude * dampening * 0.3;
// High-frequency zig-zag
const zigzag = direction * wiggleAmount * magnitude * dampening * 0.7;
centerX += Math.cos(perpAngle) * (cumulativeWiggle + zigzag);
centerY += Math.sin(perpAngle) * (cumulativeWiggle + zigzag);
}
}The cumulative wiggle creates a drifting effect, while the instantaneous zig-zag adds sharp direction changes.
Connecting Threads
Threads are thin ink strands connecting satellites back to the main blob. The challenge is creating a natural curve that tapers at both ends:
// Generate curved centerline
for (let j = 0; j <= numPoints; j++) {
const t = j / numPoints;
const midBulge = Math.sin(t * Math.PI) * curveOffset;
threadPoints.push({
x: startX + (endX - startX) * t + perpX * midBulge,
y: startY + (endY - startY) * t + perpY * midBulge,
});
}
// Width tapers at both ends (thickest in middle)
const taper = Math.sin(t * Math.PI) * threadWidth;The sin(t * PI) creates a smooth taper that goes from zero at the start, peaks in the middle, and returns to zero at the end.
Asymmetric Blob Stretch
Real ink splotches often have directional bias from the angle of impact. I implemented this as an affine transformation:
// Rotate to align with stretch axis
const rx = dx * Math.cos(-stretchAngle) - dy * Math.sin(-stretchAngle);
const ry = dx * Math.sin(-stretchAngle) + dy * Math.cos(-stretchAngle);
// Apply stretch along x axis
const sx = rx * (1 + stretchAmount);
const sy = ry;
// Rotate back
const fx = sx * Math.cos(stretchAngle) - sy * Math.sin(stretchAngle);
const fy = sx * Math.sin(stretchAngle) + sy * Math.cos(stretchAngle);This rotate-scale-rotate pattern is a common technique for applying directional transformations.
SVG Path Optimization
The final output is a single SVG path string combining all elements. Each shape is rendered as a separate <path> element rather than combining them, which avoids fill-rule issues when shapes overlap:
<svg viewBox="0 0 100 100">
<path
v-for="(path, index) in generatedPaths"
:key="index"
:d="path"
fill="currentColor"
/>
</svg>Early on, I tried combining all paths with different fill-rule values, but overlapping shapes would create holes. Separate paths solved this cleanly.
Lessons Learned
Pre-generate randomness: When using seeded RNG, consume values in a predictable order. Never call
random()in nested loops or conditional branches.Hermite > Bezier for organic shapes: When you want curves that pass through specific points, Hermite interpolation with Catmull-Rom tangents produces natural results with minimal tuning.
Width profiles create character: The same path structure with different width functions produces dramatically different shapes. Gaussian functions are great for bulges and pinches.
Separate paths avoid fill conflicts: Don't try to combine overlapping filled shapes into a single path. The complexity isn't worth it.
Expose the right parameters: Users don't need to control everything. The parameters that matter are the ones that change the character of the output, not the implementation details.
The Splotch Generator is the result of iterating on these techniques. Try it out and see what kinds of ink splotches you can create.