---
title: @vgpu/wgsl-std/noise/perlin
description: Improved Perlin gradient noise (2D/3D) plus amplitude-normalized fBM, for clouds, terrain, water and plasma. Smooth, band-limited, and **guaranteed to stay inside `(-1, 1)`** — unlike the widespread `2.2 *` normalization, nothing here ever needs clamping. Import this when you want a smooth field; import `@vgpu/wgsl-std/noise` (Voronoi) when you want cells.
---

# @vgpu/wgsl-std/noise/perlin



## Import

```wgsl
import { fbmPerlin2d, fbmPerlin3d, perlin2d, perlin3d } from "@vgpu/wgsl-std/noise/perlin";
```

## Signature

```wgsl
export fn perlin2d(position: vec2f) -> f32;
export fn perlin3d(position: vec3f) -> f32;
export fn fbmPerlin2d(position: vec2f, octaves: i32, lacunarity: f32, gain: f32) -> f32;
export fn fbmPerlin3d(position: vec3f, octaves: i32, lacunarity: f32, gain: f32) -> f32;
```

## Parameters

| Param      | Type              | Required | Default | Notes                                                                                                                                                                                                                                       |
| ---------- | ----------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| position   | `vec2f` / `vec3f` | ✔        | —       | Sample position in noise space. One unit is one lattice cell, so scale the input to choose the feature size (`perlin3d(worldPosition * 0.35)`). There is no seed parameter — offset `position` instead (see Notes).                         |
| octaves    | `i32`             | ✔        | —       | Number of fBM octaves. **Silently clamped to `[1, 16]`**: `0` and negatives behave as `1`, values above `16` as `16`. The clamp bounds shader cost and keeps a garbage value (from a uniform, say) from hanging the GPU.                    |
| lacunarity | `f32`             | ✔        | —       | Frequency multiplier per octave; `2.0` is the usual choice. Not clamped. Irrational-ish values (`2.17`) hide the lattice better than exact powers of two. Effective coordinate of the last octave is `position * lacunarity^(octaves - 1)`. |
| gain       | `f32`             | ✔        | —       | Amplitude multiplier per octave; `0.5` is the usual choice. **Silently clamped to `[0, 1]`**, because a negative gain would break the range proof. `gain = 0` reduces fBM to a single octave.                                               |

**Returns:** `f32` in the open interval `(-1, 1)` for every finite input, for all four functions. This is a proof, not an observation: the quintic blend is a convex combination of the corner gradient dot products, and each normalizer is strictly below `1 / sup`. fBM divides by the sum of its amplitudes, so `|sum| <= weight` keeps the same bound across octaves.

Measured over 1e6 random samples (`packages/wgsl-std/tests/perlin.test.ts` pins these):

| fn         | max \|value\|          | typical σ | max slope | corners hashed |
| ---------- | ---------------------- | --------- | --------- | -------------- |
| `perlin2d` | 0.9937 (bound 0.99996) | 0.305     | ≈2.74     | 4 × `pcg2d`    |
| `perlin3d` | 0.9560 (bound 0.99956) | 0.260     | ≈2.45     | 8 × `pcg3d`    |

`perlin2d` and `perlin3d` are **exactly `0.0` at every integer lattice point** (`perlin3d(vec3f(3.0, -1.0, 8.0)) == 0.0`). That is inherent to gradient noise, not a bug: if your field looks like a zero grid, you are sampling integers — offset by a fraction of a cell.

**Throws:** These WGSL declarations do not throw. `resolveShader()` can still throw `VGPU-WGSL-SYM-NOEXPORT` for a misspelled import (note this module is `@vgpu/wgsl-std/noise/perlin`, *not* `@vgpu/wgsl-std/noise`), `VGPU-WGSL-PKG-NOTFOUND` if the package cannot be resolved, or validation errors such as `VGPU-WGSL-NAGA-UNKNOWN` if caller WGSL is invalid. This module also reaches `@vgpu/wgsl-std/hash`, so that package export must resolve too.

## Examples

```ts
const remapWgsl = `
import { perlin3d } from "@vgpu/wgsl-std/noise/perlin";
import { remap, saturate } from "@vgpu/wgsl-std/math";

// The field is (-1, 1) with sigma ~= 0.26, so remap the band you actually care
// about instead of saturating: saturate() would throw away half the signal and
// flatten everything above 1.0 that the field never reaches anyway.
fn fogDensity(worldPosition: vec3f, time: f32) -> f32 {
  let field = perlin3d(worldPosition * 0.35 + vec3f(0.0, 0.0, time * 0.15));
  return saturate(remap(-0.4, 0.6, 0.0, 1.0, field));
}
`;

console.log(remapWgsl.includes("fogDensity"));
```

```ts
const cloudsWgsl = `
import { fbmPerlin3d } from "@vgpu/wgsl-std/noise/perlin";
import { remap, saturate } from "@vgpu/wgsl-std/math";

// Animated clouds: FBM over a domain-warped field.
// Offsets are >= 2 units apart so the three warp components are decorrelated.
fn cloudCoverage(position: vec3f, time: f32) -> f32 {
  let drift = vec3f(position.xy + vec2f(time * 0.02, 0.0), position.z + time * 0.05);

  // Domain warp: displace the sample point with a low-frequency field.
  let warp = vec3f(
    fbmPerlin3d(drift, 3, 2.0, 0.5),
    fbmPerlin3d(drift + vec3f(37.2, 11.7, 5.3), 3, 2.0, 0.5),
    fbmPerlin3d(drift + vec3f(-19.4, 42.1, 23.8), 3, 2.0, 0.5),
  );

  // Detail octaves on the warped domain, then shape coverage.
  let field = fbmPerlin3d(drift + warp * 0.45, 5, 2.0, 0.5);
  let coverage = saturate(remap(-0.15, 0.65, 0.0, 1.0, field));
  return coverage * coverage * (3.0 - 2.0 * coverage);
}
`;

console.log(cloudsWgsl.includes("cloudCoverage"));
```

```ts
const shapingWgsl = `
import { perlin3d } from "@vgpu/wgsl-std/noise/perlin";

// Turbulence (billowy): sum |noise| per octave -> [0, 1).
// Must be per-octave; abs(fbmPerlin3d(...)) is NOT the same thing.
fn turbulence3d(position: vec3f, octaves: i32) -> f32 {
  var sum = 0.0; var amplitude = 1.0; var weight = 0.0; var sample = position;
  for (var i = 0; i < octaves; i = i + 1) {
    sum = sum + amplitude * abs(perlin3d(sample));
    weight = weight + amplitude;
    sample = sample * 2.0;
    amplitude = amplitude * 0.5;
  }
  return sum / weight;
}

// Ridged (terrain): replace abs(n) with (1.0 - abs(n)) squared, per octave.
fn ridged3d(position: vec3f, octaves: i32) -> f32 {
  var sum = 0.0; var amplitude = 1.0; var weight = 0.0; var sample = position;
  for (var i = 0; i < octaves; i = i + 1) {
    let ridge = 1.0 - abs(perlin3d(sample));
    sum = sum + amplitude * ridge * ridge;
    weight = weight + amplitude;
    sample = sample * 2.0;
    amplitude = amplitude * 0.5;
  }
  return sum / weight;
}

// Normals: finite differences on the final field are adequate; analytic
// derivatives are not part of this release.
`;

console.log(shapingWgsl.includes("turbulence3d"));
```

## Notes

* **Range and shaping.** Output is `(-1, 1)`, never clipped, but `σ ≈ 0.305` (2D) / `0.260` (3D) means extremes are rare: over 1e6 samples the largest magnitudes seen were 0.9937 and 0.9560. Use `remap` from `@vgpu/wgsl-std/math` to stretch the band you care about; reaching for `saturate` alone discards the entire negative half of the field.
* **Zero on the lattice.** `perlin2d`/`perlin3d` return exactly `0.0` at integer coordinates. Sample at cell fractions, and remember that `fbmPerlin*` inherits this at octave 1 only (later octaves land off-lattice).
* **Seeding is an input offset — there is no `seed` argument.** Measured Pearson correlation of `perlin3d(p)` against `perlin3d(p + offset)` over 2e5 samples:

  | offset         | correlation |
  | -------------- | ----------- |
  | `(0.5, 0, 0)`  | 0.400       |
  | `(1, 0, 0)`    | −0.043      |
  | `(2, 0, 0)`    | 0.0017      |
  | `(17, 0, 0)`   | −0.0004     |
  | `(101, 53, 7)` | 0.0035      |

  So **any offset of ≥ 2.0 units on some axis decorrelates**; sub-cell offsets do not (`+0.5` still correlates at 0.40). Keep offsets in the tens–thousands, not 1e6, because of the f32 mantissa.
* **Period and f32 domain.** The gradient hash covers 2³² cells, so there is no visible tiling (compare the period-289 float `permute` hashes found in GLSL ports). The practical limit is f32 precision, not the period: `position - floor(position)` is quantized to \~2⁻²⁴·|p|, so detail bands visibly past `|p| ≈ 1e4` and vanishes entirely at `|p| ≥ 2²³` (the field is then exactly 0, because every sample lands on a lattice point). fBM multiplies the effective coordinate by `lacunarity^(octaves - 1)` — 6 octaves at `lacunarity = 2.0` reaches 32× your input.
* **Animating with time.** Feeding an unbounded `time` into the third axis works, but it walks the coordinate toward the f32 limit above, so a long-running session eventually looks blocky. Wrap or scale time (`time * 0.05`, reset periodically), or animate a 2D field's offset instead.
* **Cost model.** `perlin2d` = 4 `pcg2d` hashes, `perlin3d` = 8 `pcg3d` hashes. A 6-octave `fbmPerlin3d` is 48 hashes per sample, and the domain-warped clouds recipe above is ≈4× that (three warp calls plus the detail call). Prefer `fbmPerlin2d` when the third axis only carries animation, and measure before shipping a fullscreen 6-octave 3D field.
* **Determinism.** Gradient selection goes through the integer `pcg2d`/`pcg3d` hashes and the arithmetic uses no `sin`/`cos`/`sqrt`/`inverseSqrt`/`pow`, whose accuracy is implementation-defined. Which gradient a cell gets is therefore bit-identical on every backend; only the final interpolation may differ by a few ulp. The test suite compares real GPU output against an f32-exact CPU reference at 1e-5.
* **Purity.** This module declares no `@group`, no `@binding`, no overrides, no entry points and no hidden state. It imports pure hash helpers plus a private gradient core shared with the simplex module.
* **Credit.** Algorithmic reference: Ken Perlin, *Improving Noise* (SIGGRAPH 2002) — quintic fade and the 12 cube-edge gradient set. No code was copied from any implementation; the gradients are re-derived on this package's own `pcg2d`/`pcg3d` hashes.
* **See also:** `@vgpu/wgsl-std/noise/simplex` (same shape, \~2.5× the slope and \~1.7× the σ at the same input scale — scale `position` by \~0.4–0.5 when migrating, and it hashes 4 corners instead of 8 in 3D), `@vgpu/wgsl-std/noise` (Voronoi/cellular, for cells rather than smooth fields), `@vgpu/wgsl-std/math` (`remap`, `saturate`), `@vgpu/wgsl-std/hash`, `resolveShader`.


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)