How long does it take to generate a terrain chunk with noise?

A 128x128 chunk of 4-octave noise took 0.30 ms to 1.92 ms in Node on an Apple M3, depending on the noise. Simplex never beat branch-free Perlin, a switch statement made 8 octaves 4.7x slower, and calling one chunk loop with two noise functions made it 3x slower.

How long does it take to generate a terrain chunk with noise?

A 128x128 terrain chunk of 4-octave fBm took between 0.30 ms (value noise) and 1.92 ms (simplex) in Node on an Apple M3, so 8 to 56 of them fit in a 16.67 ms frame. In procedural terrain generation, one chunk is never the problem. A player sprinting across a chunk border with an 8-chunk load radius asks for 17 at once. That burst took 12.8 ms at 8 octaves with branch-free Perlin and 73.5 ms with simplex — and the cheapest fix was not a worker, it was two lines of code.

Hardware: Apple M3, 8 cores, 16 GB, macOS 26.4.1. Node v23.5.0, no packages; value, Perlin and simplex noise written by hand. Timings are medians of 15 to 400 runs after warm-up. Other benchmarks were running on this machine at the same time (load average 2.4–5.0), so trust ratios over absolute milliseconds.

This is the cost of procedural terrain generation, not of storing the result. For that, see how to store a big tilemap.

The short answer

  • One chunk fits easily. 128x128 samples, 4 octaves, each noise timed in a fresh Node process: value 0.295 ms, Perlin with a lookup-table gradient 0.396 ms, textbook Perlin 0.673 ms, simplex 1.917 ms.
  • Simplex was slower than branch-free Perlin at every setting measured. At 128x128 it was 3.7x slower at 1 octave and 5.7x slower at 8 octaves than branch-free Perlin. The C-benchmark reputation did not survive JavaScript in 2D.
  • The textbook switch gradient made octaves superlinear. Perlin went from 1 to 8 octaves in 27.9x the time. The same gradients as a table lookup, with bit-identical output, took 5.9x. At 8 octaves that is 3.545 ms against 0.753 ms.
  • A chunk loop shared between noise types ran about 3x slower. Branch-free Perlin went from 0.396 ms to 1.044 ms once the same chunk() function had also been called with value and simplex noise. Value noise went from 0.295 ms to 0.883 ms. My own first benchmark had this bug.
  • A worker costs about 0.03 ms per chunk. Transfer and structured copy of a 65,536-byte Float32Array differed by 12 µs. Spawning the worker took 10.5 ms, so spawn it once.

What exactly was generated?

Each chunk is fractal Brownian motion (fBm): octaves layers of noise, each at double the frequency and half the amplitude of the last, normalised to roughly -1..1. A chunk always spans four base-frequency lattice cells, so raising the resolution adds samples without changing what the terrain looks like.

for (let o = 0; o < octaves; o++) { sum += amp * noise(wx * f, wy * f); amp *= 0.5; f *= 2; }

The noises before timing anything, from 1,000,000 random single-octave samples in the benchmark's implementation:

Noise Min Max Mean Std dev
Value (quintic fade) -0.982 1.000 -0.0383 0.458
Perlin (8 gradients) -0.998 0.994 -0.0002 0.260
Simplex (Gustavson) -0.998 0.998 -0.0005 0.444

All three stay inside -1..1 and centre near zero. Value noise's -0.038 is its 256 random lattice values not averaging to exactly zero. Perlin's spread is 0.26 against simplex's 0.44, so swapping one for the other changes your mountain heights unless you rescale. Chunks tile cleanly. Across the border between two 256x256, 8-octave chunks, the largest jump was 0.028 (value), 0.034 (Perlin) and 0.111 (simplex). Each is smaller than the largest jump between neighbours inside one chunk: 0.079, 0.051 and 0.163.

How long does one terrain chunk take?

Median milliseconds per chunk, Float32Array output. Every row ran in its own fresh Node process; the next section explains why that matters.

Resolution Octaves Value Perlin (switch) Perlin (lookup) Simplex
64x64 1 0.025 0.036 0.031 0.112
64x64 4 0.076 0.269 0.099 0.496
64x64 8 0.130 0.988 0.181 0.992
128x128 1 0.098 0.127 0.127 0.469
128x128 4 0.295 0.673 0.396 1.917
128x128 8 0.513 3.545 0.753 4.323
256x256 1 0.379 0.490 0.516 1.557
256x256 4 1.212 2.321 1.576 7.091
256x256 8 2.092 11.543 2.923 15.465

Chunks per 16.67 ms frame, calculated from the table:

Chunk Value Perlin (switch) Perlin (lookup) Simplex
128x128, 4 octaves 56.5 24.8 42.1 8.7
128x128, 8 octaves 32.5 4.7 22.1 3.9
256x256, 8 octaves 8.0 1.4 5.7 1.1

Resolution is the predictable knob. Doubling the side quadruples the samples, and time followed at 3.3–4.4x for every noise except switch Perlin. Noise type is the biggest knob. Simplex was 4.8x branch-free Perlin at 128x128, 4 octaves. Value noise was fastest everywhere, but only 1.2–1.5x ahead of lookup Perlin, and it is known to look blockier; I measured speed, not looks. Octaves are the knob with a trap in it.

Why do more octaves cost more than they should?

Going from 1 to 8 octaves at 128x128 multiplies samples by 8. Value noise took 5.2x the time and lookup Perlin 5.9x — less than 8x, because the loop overhead is shared. Textbook Perlin took 27.9x. The difference is one function:

function grad(h, x, y) {
  switch (h & 7) { case 0: return x + y; case 1: return -x + y; /* ... */ }
}

At octave 1, 128 samples cross four lattice cells, so about 32 samples in a row hit the same cell and the same case. The CPU's branch predictor learns it. At octave 8 the frequency is 128x higher, every sample lands in a new cell with a random gradient, and the branch becomes a coin flip.

To check, I held the octave count at one and changed only how many lattice cells each sample step crossed. Nanoseconds per sample, 128x128, median of 60, all in one process:

Cells per sample step 0.031 0.25 1.37 5.3
Value noise 3.09 2.60 2.60 2.60
Perlin, switch gradient 11.46 26.42 35.08 37.23
Perlin, lookup-table gradient 13.01 13.56 13.06 13.53
Simplex 20.59 19.38 22.04 21.13

Same octave, same code, 3.25x slower purely from sample spacing. That is the octave slowdown. The lookup version replaces the switch with two 8-entry Float64Arrays:

const GXT = new Float64Array([1, -1, 1, -1, 1, -1, 0, 0]), GYT = new Float64Array([1, 1, -1, -1, 0, 0, 1, -1]);
const a = PERM[PERM[X] + Y] & 7;   // ...then GXT[a] * xf + GYT[a] * yf

Over 100,000 samples its output differed from the switch version by exactly 0. It costs nothing at 1 octave (0.127 ms both) and is 4.7x faster at 8.

The switch version was also the least stable thing I measured. The same 128x128, 4-octave chunk took 0.673 ms, 1.57 ms and 3.05 ms in three fresh processes that differed in warm-up length and in which noise ran first. The lookup version measured 0.337–0.396 ms across the same kind of runs.

Is simplex noise faster than Perlin in JavaScript?

No, not in 2D at these sizes. The usual argument is corner count: 2D simplex evaluates 3 corners where Perlin evaluates 4, and the gap widens in 3D and 4D. Here simplex was slower than branch-free Perlin at every size and octave count in the fresh-process table, and slower than or tied with (64x64, 8 octaves: 0.992 ms against 0.988 ms) the switch version — 3.7x behind lookup Perlin at 128x128, 1 octave, and 5.7x behind at 8 octaves.

My first benchmark, all in one process, showed simplex beating switch Perlin at 8 octaves: 3.808 ms against 4.585 ms. That crossover was an artefact of the switch misbehaving and of the next problem. If you want a gradient noise in 2D JavaScript, a branch-free Perlin was the faster choice here.

Why did the same noise get 3x slower?

This is the result I did not expect, and it invalidated my first run. In that run, one generic chunk(noise, …) function was called with value noise first, then Perlin, then simplex. Value noise was timed while chunk() had only ever seen one noise function. The others were timed after it had seen several.

A call site that has seen several different functions is harder for V8 to optimise than one that has only ever seen one. I did not inspect the compiled code, but the cost is easy to measure. Each fresh process below warmed chunk() with the listed noises first:

Chunk loop has seen 128x128, 4 oct 256x256, 8 oct Slowdown
Value noise only 0.295 ms 2.092 ms
Value, after Perlin and simplex 0.883 ms 6.700 ms 3.0–3.2x
Lookup Perlin only 0.396 ms 2.923 ms
Lookup Perlin, after value and simplex 1.044 ms 8.331 ms 2.6–2.9x

Simplex barely moved in the same test (1.791 ms alone, 1.769 ms after the others). The practical rule: give each noise type its own chunk loop instead of passing the noise in as a callback. In a real engine the octave loop, the biome blend and the erosion pass are all tempting places to pass a function. Each one is a candidate for this.

Does Float32Array beat an array of arrays?

Not for generation time. Same 4-octave Perlin chunk at 128x128, one process:

Output Round 1 Round 2
Float32Array, reused 1.375 ms 1.382 ms
Float32Array, new each chunk 1.377 ms 1.370 ms
Plain flat Array 1.500 ms 1.417 ms
Array of 128 row arrays 1.439 ms 1.414 ms

Within 9%, and the order changed between rounds. A round before these, not shown, put the reused Float32Array at 2.982 ms, twice the others — JIT warm-up landing on whichever variant ran first. The noise dominates; writing 16,384 numbers does not. The case for Float32Array is what comes after: it is one allocation, and it can be transferred to a worker without copying. An array of arrays cannot.

Does generation fit in a frame at sprint speed?

These are calculations, not measurements. Assume chunks are 32 m square, the player sprints at 10 m/s, and chunks stay loaded 8 chunks in every direction. Walking straight across one border adds a new row of 2×8+1 = 17 chunks; crossing at a corner adds 33. At 10 m/s a border comes every 3.2 s.

A sprint across one chunk border requests 17 chunks at once, 33 on a diagonal
128x128 chunk, 8 octaves One chunk Burst of 17 Burst of 33
Value 0.513 ms 8.7 ms 16.9 ms
Perlin, lookup 0.753 ms 12.8 ms 24.8 ms
Perlin, lookup, shared loop 2.036 ms 34.6 ms 67.2 ms
Perlin, switch 3.545 ms 60.3 ms 117.0 ms
Simplex 4.323 ms 73.5 ms 142.7 ms

The sustained rate is 17 chunks per 3.2 s, 5.3 a second. Even for simplex that is 23 ms of CPU per second. Hitches at chunk borders are almost never a throughput problem. They come from doing a second's worth of work in one frame.

So the first fix is a budget, not a thread: generate at most N chunks per frame, nearest first. At one simplex chunk per frame, the row of 17 is done in 17 frames, 0.28 s, long before the next border. It is the same idea as capping steps per frame in a fixed-timestep loop. The second fix is a worker.

What does a worker_thread cost per chunk?

A worker running the generator, 128x128 Float32Array posted back, median of 300:

Message Round trip Inside the worker Overhead
Empty buffer, structured copy 0.022 ms 0.002 ms 0.020 ms
Empty buffer, transferred 0.010 ms 0.001 ms 0.009 ms
4-oct switch Perlin chunk, copy 1.542 ms 1.515 ms 0.027 ms
4-oct switch Perlin chunk, transfer 1.428 ms 1.400 ms 0.028 ms
8-oct switch Perlin chunk, transfer 4.598 ms 4.554 ms 0.044 ms

Messaging added 0.03–0.04 ms per chunk. Transfer saved 12 µs over copying 65 KB, and a bare structuredClone of the same array took 3.7 µs, so the copy is not the cost at this size. Transfer anyway: it is free, and it scales. Large typed arrays are where structuredClone is at its best, as structuredClone vs JSON found on a 10,000-element numeric array.

The cost that is not per chunk is startup. Spawning a worker took 10.5 ms to come online and 14.8 ms to return its first chunk. A worker per chunk costs more than the chunk. Spawn a pool when the world loads.

Check it yourself

Node 18+, no dependencies, one CommonJS file, about 15 seconds. It prints noise ranges, the per-process timing table including the shared-loop rows, the calculated sprint bursts, and a worker round trip. Save it as terrain-chunk.cjs so it runs as CommonJS even inside a "type": "module" project.

// terrain-chunk.cjs — node terrain-chunk.cjs   (Node 18+, no dependencies, ~20 s)
'use strict';
const { Worker, isMainThread, parentPort } = require('worker_threads');
const { execFileSync } = require('child_process');
const { performance } = require('perf_hooks');

// ---- noise, by hand -------------------------------------------------------
let seed = 1337;
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
const P = new Uint8Array(256).map((_, i) => i);
for (let i = 255; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [P[i], P[j]] = [P[j], P[i]]; }
const PERM = new Uint8Array(512).map((_, i) => P[i & 255]);
const VAL = new Float64Array(256).map(() => rnd() * 2 - 1);
const fade = t => t * t * t * (t * (t * 6 - 15) + 10);

function value(x, y) {
  const xi = Math.floor(x), yi = Math.floor(y), u = fade(x - xi), v = fade(y - yi), X = xi & 255, Y = yi & 255;
  const a = VAL[PERM[PERM[X] + Y]], b = VAL[PERM[PERM[X + 1] + Y]];
  const c = VAL[PERM[PERM[X] + Y + 1]], d = VAL[PERM[PERM[X + 1] + Y + 1]];
  const ab = a + u * (b - a); return ab + v * (c + u * (d - c) - ab);
}
function grad(h, x, y) { // the textbook switch
  switch (h & 7) { case 0: return x + y; case 1: return -x + y; case 2: return x - y; case 3: return -x - y;
    case 4: return x; case 5: return -x; case 6: return y; default: return -y; }
}
const GXT = new Float64Array([1, -1, 1, -1, 1, -1, 0, 0]), GYT = new Float64Array([1, 1, -1, -1, 0, 0, 1, -1]);
function perlin(x, y) {
  const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi, X = xi & 255, Y = yi & 255;
  const u = fade(xf), v = fade(yf);
  const x1 = grad(PERM[PERM[X] + Y], xf, yf), x2 = grad(PERM[PERM[X + 1] + Y], xf - 1, yf);
  const y1 = grad(PERM[PERM[X] + Y + 1], xf, yf - 1), y2 = grad(PERM[PERM[X + 1] + Y + 1], xf - 1, yf - 1);
  const l1 = x1 + u * (x2 - x1); return l1 + v * (y1 + u * (y2 - y1) - l1);
}
function perlinLUT(x, y) { // same gradients as a table lookup: no branch
  const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi, X = xi & 255, Y = yi & 255;
  const u = fade(xf), v = fade(yf);
  const a = PERM[PERM[X] + Y] & 7, b = PERM[PERM[X + 1] + Y] & 7, c = PERM[PERM[X] + Y + 1] & 7, d = PERM[PERM[X + 1] + Y + 1] & 7;
  const x1 = GXT[a] * xf + GYT[a] * yf, x2 = GXT[b] * (xf - 1) + GYT[b] * yf;
  const y1 = GXT[c] * xf + GYT[c] * (yf - 1), y2 = GXT[d] * (xf - 1) + GYT[d] * (yf - 1);
  const l1 = x1 + u * (x2 - x1); return l1 + v * (y1 + u * (y2 - y1) - l1);
}
const F2 = 0.5 * (Math.sqrt(3) - 1), G2 = (3 - Math.sqrt(3)) / 6;
const SGX = [1, -1, 1, -1, 1, -1, 0, 0, 1, -1, 1, -1], SGY = [1, 1, -1, -1, 0, 0, 1, -1, 0, 0, 0, 0];
function simplex(xin, yin) {
  const s = (xin + yin) * F2, i = Math.floor(xin + s), j = Math.floor(yin + s), t = (i + j) * G2;
  const x0 = xin - (i - t), y0 = yin - (j - t), i1 = x0 > y0 ? 1 : 0, j1 = 1 - i1;
  const x1 = x0 - i1 + G2, y1 = y0 - j1 + G2, x2 = x0 - 1 + 2 * G2, y2 = y0 - 1 + 2 * G2, ii = i & 255, jj = j & 255;
  let n = 0, t0 = 0.5 - x0 * x0 - y0 * y0, t1 = 0.5 - x1 * x1 - y1 * y1, t2 = 0.5 - x2 * x2 - y2 * y2;
  if (t0 >= 0) { const g = PERM[ii + PERM[jj]] % 12; t0 *= t0; n += t0 * t0 * (SGX[g] * x0 + SGY[g] * y0); }
  if (t1 >= 0) { const g = PERM[ii + i1 + PERM[jj + j1]] % 12; t1 *= t1; n += t1 * t1 * (SGX[g] * x1 + SGY[g] * y1); }
  if (t2 >= 0) { const g = PERM[ii + 1 + PERM[jj + 1]] % 12; t2 *= t2; n += t2 * t2 * (SGX[g] * x2 + SGY[g] * y2); }
  return 70 * n;
}
const NOISE = { value, perlin, perlinLUT, simplex };

// ---- one chunk of fBm: always 4 base cells wide, res x res samples --------
function chunk(noise, res, octaves, cx, out) {
  let norm = 0; for (let o = 0; o < octaves; o++) norm += 0.5 ** o;
  for (let j = 0; j < res; j++) for (let i = 0; i < res; i++) {
    const wx = (cx + i / res) * 4, wy = (j / res) * 4;
    let sum = 0, amp = 1, f = 1;
    for (let o = 0; o < octaves; o++) { sum += amp * noise(wx * f, wy * f); amp *= 0.5; f *= 2; }
    out[j * res + i] = sum / norm;
  }
  return out;
}
const median = fn => { // warm up for 150 ms, then median of >= 15 runs
  let t0 = performance.now(), k = 0; while (k < 10 || performance.now() - t0 < 150) fn(k++);
  const t = []; t0 = performance.now();
  while (t.length < 15 || (performance.now() - t0 < 120 && t.length < 300)) { const a = performance.now(); fn(t.length); t.push(performance.now() - a); }
  return t.sort((a, b) => a - b)[t.length >> 1]; };

if (!isMainThread) { // worker: generate, then send back by transfer
  parentPort.on('message', ({ res, oct, k }) => {
    const buf = chunk(NOISE.perlinLUT, res, oct, k, new Float32Array(res * res));
    parentPort.postMessage(buf, [buf.buffer]);
  });
  return;
}

if (process.argv[2] === 'time') { // child: one noise, optionally after chunk() has seen others
  const [, , , name, others = ''] = process.argv;
  for (const o of others.split(',').filter(Boolean)) for (let k = 0; k < 30; k++) chunk(NOISE[o], 128, 4, k, new Float32Array(16384));
  const rows = [];
  for (const res of [64, 128, 256]) { const out = new Float32Array(res * res);
    rows.push([res, ...[1, 4, 8].map(oct => median(k => chunk(NOISE[name], res, oct, k, out)))]); }
  console.log(JSON.stringify(rows));
  return;
}

console.log(`node ${process.version}\n\n## sanity: 1e6 random single-octave samples`);
for (const [n, f] of Object.entries(NOISE)) {
  let mn = Infinity, mx = -Infinity, sum = 0;
  for (let i = 0; i < 1e6; i++) { const v = f(rnd() * 1000 - 500, rnd() * 1000 - 500); mn = Math.min(mn, v); mx = Math.max(mx, v); sum += v; }
  console.log(`${n.padEnd(10)} min ${mn.toFixed(3)}  max ${mx.toFixed(3)}  mean ${(sum / 1e6).toFixed(4)}`);
}

console.log('\n## ms per chunk (median), each row in a fresh process');
console.log('noise                        res    1 oct    4 oct    8 oct');
const runs = [['value'], ['perlin'], ['perlinLUT'], ['simplex'], ['value', 'perlin,simplex'], ['perlinLUT', 'value,simplex']];
const at = {};
for (const [name, others] of runs) {
  const label = others ? `${name} (after ${others})` : name;
  const rows = JSON.parse(execFileSync(process.execPath, [__filename, 'time', name, others || ''], { encoding: 'utf8' }));
  for (const [res, ...ms] of rows) {
    at[`${label}${res}`] = ms;
    console.log(`${label.padEnd(28)} ${String(res).padStart(4)} ${ms.map(m => m.toFixed(3).padStart(8)).join(' ')}`);
  }
}

console.log('\n## calculated: 32 m chunks, 10 m/s sprint, load radius 8 -> 17 new chunks per border');
for (const n of ['value', 'perlin', 'perlinLUT', 'simplex']) {
  const [, o4, o8] = at[`${n}128`];
  console.log(`${n.padEnd(10)} 128x128: burst of 17 = ${(17 * o4).toFixed(1)} ms at 4 oct, ${(17 * o8).toFixed(1)} ms at 8 oct`);
}

const w = new Worker(__filename);
const call = msg => new Promise(r => { w.once('message', r); w.postMessage(msg); });
(async () => {
  const empty = [], full = [];
  for (let i = 0; i < 50; i++) await call({ res: 128, oct: 4, k: i });
  for (let i = 0; i < 200; i++) { const a = performance.now(); await call({ res: 128, oct: 4, k: i }); full.push(performance.now() - a); }
  const med = a => a.sort((x, y) => x - y)[a.length >> 1];
  console.log(`\n## worker, 128x128 perlinLUT 4 oct, transferred: round trip ${med(full).toFixed(3)} ms`);
  await w.terminate();
})();

On the machine above, node terrain-chunk.cjs:

node v23.5.0

## sanity: 1e6 random single-octave samples
value      min -1.000  max 0.999  mean -0.0063
perlin     min -0.970  max 0.976  mean 0.0001
perlinLUT  min -0.982  max 0.984  mean 0.0001
simplex    min -0.998  max 0.998  mean 0.0004

## ms per chunk (median), each row in a fresh process
noise                        res    1 oct    4 oct    8 oct
value                          64    0.025    0.076    0.130
value                         128    0.098    0.295    0.513
value                         256    0.379    1.212    2.092
perlin                         64    0.036    0.269    0.988
perlin                        128    0.127    0.673    3.545
perlin                        256    0.490    2.321   11.543
perlinLUT                      64    0.031    0.099    0.181
perlinLUT                     128    0.127    0.396    0.753
perlinLUT                     256    0.516    1.576    2.923
simplex                        64    0.112    0.496    0.992
simplex                       128    0.469    1.917    4.323
simplex                       256    1.557    7.091   15.465
value (after perlin,simplex)   64    0.057    0.226    0.415
value (after perlin,simplex)  128    0.257    0.883    1.750
value (after perlin,simplex)  256    0.984    3.468    6.700
perlinLUT (after value,simplex)   64    0.064    0.309    0.563
perlinLUT (after value,simplex)  128    0.281    1.044    2.036
perlinLUT (after value,simplex)  256    1.053    3.891    8.331

## calculated: 32 m chunks, 10 m/s sprint, load radius 8 -> 17 new chunks per border
value      128x128: burst of 17 = 5.0 ms at 4 oct, 8.7 ms at 8 oct
perlin     128x128: burst of 17 = 11.4 ms at 4 oct, 60.3 ms at 8 oct
perlinLUT  128x128: burst of 17 = 6.7 ms at 4 oct, 12.8 ms at 8 oct
simplex    128x128: burst of 17 = 32.6 ms at 4 oct, 73.5 ms at 8 oct

## worker, 128x128 perlinLUT 4 oct, transferred: round trip 0.365 ms

The sanity lines differ from the table near the top because this file uses a different seeded generator. The timings are the ones in the main table.

What this does not cover

2D heightmaps in one JavaScript engine. No 3D density noise for caves, which multiplies samples by the chunk's height; no meshing, which often costs more than the noise; no GPU. The results that should transfer are the shapes: the branch-predictor trap in octave-heavy noise, and the cost of passing a hot function around as a value.

These are the budgets we are measuring for the game engine we are building, which is not released yet.