Knife Through Mountains: A Procedural Shape Technique

When generating procedural shapes, there's an elegant technique I call "knife through mountains." Imagine a mountain range viewed from the side - jagged peaks and valleys stretching across the horizon. Now imagine slicing horizontally through it with a knife. The resulting silhouette is organic, irregular, and natural-looking.

Try the Demo

The Core Idea

The technique works by generating 1D terrain (heights that vary along a line) and then "slicing" through it at a threshold level. Points where the terrain is above the threshold contribute to the shape; points below create indentations.

For a blob-like shape, we wrap this terrain around a circle. Each angle around the center has a terrain height, and the slice determines the radius at that angle.

radius = baseRadius + (terrainHeight - threshold) × amplitude

When terrainHeight > threshold, the shape bulges outward. When terrainHeight < threshold, it indents inward.

Generating the Terrain

The terrain needs to look natural - smooth in places but with irregular features. The key is layering multiple "octaves" of randomness, each at a different frequency and amplitude.

Control Points and Interpolation

Start with a set of random control points around the circle:

typescript
const numPoints = 6; // Base roughness
const heights: number[] = [];
for (let i = 0; i < numPoints; i++) {
  heights.push(random()); // 0 to 1
}

To get the height at any angle, interpolate between the nearest control points using cosine interpolation for smooth curves:

typescript
function getHeight(angle: number): number {
  const normalizedAngle = angle / (Math.PI * 2); // 0 to 1
  const index = normalizedAngle * numPoints;
  const i0 = Math.floor(index) % numPoints;
  const i1 = (i0 + 1) % numPoints;
  const t = index - Math.floor(index);

  // Cosine interpolation for smooth transitions
  const smoothT = (1 - Math.cos(t * Math.PI)) / 2;
  return heights[i0] * (1 - smoothT) + heights[i1] * smoothT;
}

Cosine interpolation produces smoother results than linear interpolation. The curve eases in and out of each control point rather than making sharp turns.

Adding Octaves for Detail

A single layer of control points produces smooth, blobby shapes. To add natural-looking detail, layer multiple octaves:

typescript
function generateTerrain(
  seed: number,
  numOctaves: number,
  baseRoughness: number,
) {
  const random = createSeededRandom(seed);
  const octaves: { heights: number[]; numPoints: number }[] = [];

  for (let oct = 0; oct < numOctaves; oct++) {
    // Each octave doubles the frequency
    const numPoints = baseRoughness * Math.pow(2, oct);
    const heights: number[] = [];

    for (let i = 0; i < numPoints; i++) {
      heights.push(random());
    }

    octaves.push({ heights, numPoints });
  }

  return octaves;
}

When sampling the terrain, combine all octaves with decreasing influence:

typescript
function getTerrainHeight(angle: number, octaves: Octave[]): number {
  let totalHeight = 0;
  let amplitude = 1;

  for (const octave of octaves) {
    const height = interpolateOctave(angle, octave);
    totalHeight += height * amplitude;
    amplitude *= 0.5; // Each octave contributes half as much
  }

  // Normalize to 0-1 range
  const maxPossible = 2 * (1 - Math.pow(0.5, octaves.length));
  return totalHeight / maxPossible;
}

This is the same principle behind fractal noise and terrain generation:

  • Octave 1 (6 points): Large-scale shape - major bulges and indentations
  • Octave 2 (12 points): Medium detail - secondary bumps
  • Octave 3 (24 points): Fine detail - small irregularities
  • Octave 4 (48 points): Micro detail - subtle texture

Each layer adds detail at a smaller scale while preserving the overall form.

The Slice

With terrain generated, the slice converts heights to radii:

typescript
const centerX = 50;
const centerY = 50;
const points: Point[] = [];

for (let i = 0; i < resolution; i++) {
  const angle = (i / resolution) * Math.PI * 2;
  const terrainHeight = getTerrainHeight(angle, octaves);

  // The slice
  const heightDiff = terrainHeight - threshold;
  const radius = baseRadius + heightDiff * amplitude * 2;

  points.push({
    x: centerX + Math.cos(angle) * radius,
    y: centerY + Math.sin(angle) * radius,
  });
}

The threshold parameter is the knife height. Changing it shifts the entire silhouette:

  • Low threshold (0.2): Most terrain is "above" the knife, creating a large, bulbous shape
  • Mid threshold (0.5): Balanced - some parts bulge out, some indent
  • High threshold (0.8): Most terrain is "below" the knife, creating a small shape with spiky protrusions where peaks poke through

Converting to SVG

The points form a closed polygon. For a basic implementation, connect them with line segments:

typescript
let path = `M${points[0].x},${points[0].y}`;
for (let i = 1; i < points.length; i++) {
  path += ` L${points[i].x},${points[i].y}`;
}
path += " Z";

With sufficient resolution (64-128 points), the line segments are small enough to appear smooth. For even smoother results, you could use Catmull-Rom splines or cubic bezier curves, but high resolution usually suffices.

Parameters and Their Effects

The technique has intuitive parameters:

ParameterEffect
SeedDifferent random terrain - completely changes the shape
RoughnessBase control points - more = more peaks and valleys
OctavesDetail layers - more = finer, more natural-looking edges
AmplitudeHow much radius varies - higher = more dramatic bulges/indents
ThresholdKnife height - shifts between bulbous and spiky shapes
Base RadiusMinimum size - the shape's overall scale

Why This Works

The technique produces natural-looking shapes because it mimics how natural boundaries form. Coastlines, cloud edges, and organic forms often result from threshold processes - where some continuous field (elevation, moisture, density) crosses a critical value.

The multi-octave terrain provides self-similarity across scales, which is characteristic of natural forms. Large features have smaller features on top of them, which have even smaller features, and so on.

Variations

Multiple Shapes

Generate several shapes at different thresholds from the same terrain:

typescript
const thresholds = [0.3, 0.5, 0.7];
const paths = thresholds.map((t) => generateShape(terrain, t));

This creates nested, concentric-ish shapes that share a visual relationship.

Asymmetric Terrain

Weight the octaves differently on different sides:

typescript
const eastAmplitude = angle > Math.PI ? 1.2 : 0.8;
totalHeight += height * amplitude * eastAmplitude;

This creates directional bias, useful for shapes that should look wind-blown or stretched.

Animated Threshold

Animate the threshold over time to create a "breathing" or "pulsing" effect:

typescript
const threshold = 0.5 + Math.sin(time * 0.5) * 0.2;

The shape smoothly morphs between bulbous and spiky states.

Comparison to Other Techniques

vs. Perlin/Simplex noise contours: The knife-through-mountains technique is simpler to implement and understand. True 2D noise fields can create more complex shapes (islands, archipelagos, holes), but require marching squares or similar algorithms to extract contours.

vs. Polar coordinate blobs: Simple polar blobs (radius = baseRadius + noise(angle)) are a special case of this technique with one octave. Adding multiple octaves and the threshold concept gives more control and variety.

vs. Metaballs/implicit surfaces: Metaballs create smooth, blobby shapes through field composition. They're better for shapes that should merge and split. The terrain slice technique is better for single, irregular shapes with natural-looking edges.

Implementation Notes

Seeded Randomness

Use a seeded random number generator so the same seed always produces the same shape:

typescript
function createRandom(seed: number) {
  let state = seed;
  return () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
}

This simple LCG (Linear Congruential Generator) is fast and sufficient for this use case.

Pre-generate Random Values

Generate all random values upfront before using them. This prevents the RNG consumption order from affecting results when parameters change:

typescript
// Good: pre-generate all values
const octaveData = computed(() => {
  const random = createRandom(seed.value);
  // Generate all heights here...
});

// Then use octaveData in the shape generation

Resolution vs. File Size

Higher resolution means more points in the SVG path, which increases file size. For most uses, 64-128 points is a good balance. For very large shapes or close-up viewing, consider 256.

Conclusion

The knife-through-mountains technique is a simple, intuitive way to generate organic shapes. By layering octaves of interpolated random values and slicing at a threshold, you get natural-looking silhouettes with predictable, tweakable parameters.

The mental model - a knife cutting through a mountain range - makes the parameters intuitive: roughness controls how many mountains, octaves add detail, amplitude controls how tall they are, and threshold is where you cut.

Try the interactive demo to see how each parameter affects the result.