@vgpu/wgsl-std4 symbolsView source ↗

Symbols in this topic

@vgpu/wgsl-std/noise/perlin

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.

Import

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

Signature

WGSL
1
2
3
4
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

ParamTypeRequiredDefaultNotes
positionvec2f / vec3fSample 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).
octavesi32Number 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.
lacunarityf32Frequency 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).
gainf32Amplitude 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):

fnmax |value|typical σmax slopecorners hashed
perlin2d0.9937 (bound 0.99996)0.305≈2.744 × pcg2d
perlin3d0.9560 (bound 0.99956)0.260≈2.458 × 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

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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"));
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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"));
TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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:

    offsetcorrelation
    (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.