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.
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
switchgradient 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
Float32Arraydiffered 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.
| 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.