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
import { fbmPerlin2d, fbmPerlin3d, perlin2d, perlin3d } from "@vgpu/wgsl-std/noise/perlin";Signature
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
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"));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"));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. Useremapfrom@vgpu/wgsl-std/mathto stretch the band you care about; reaching forsaturatealone discards the entire negative half of the field. -
Zero on the lattice.
perlin2d/perlin3dreturn exactly0.0at integer coordinates. Sample at cell fractions, and remember thatfbmPerlin*inherits this at octave 1 only (later octaves land off-lattice). -
Seeding is an input offset — there is no
seedargument. Measured Pearson correlation ofperlin3d(p)againstperlin3d(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.5still 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
permutehashes 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| ≈ 1e4and vanishes entirely at|p| ≥ 2²³(the field is then exactly 0, because every sample lands on a lattice point). fBM multiplies the effective coordinate bylacunarity^(octaves - 1)— 6 octaves atlacunarity = 2.0reaches 32× your input. -
Animating with time. Feeding an unbounded
timeinto 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= 4pcg2dhashes,perlin3d= 8pcg3dhashes. A 6-octavefbmPerlin3dis 48 hashes per sample, and the domain-warped clouds recipe above is ≈4× that (three warp calls plus the detail call). PreferfbmPerlin2dwhen the third axis only carries animation, and measure before shipping a fullscreen 6-octave 3D field. -
Determinism. Gradient selection goes through the integer
pcg2d/pcg3dhashes and the arithmetic uses nosin/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/pcg3dhashes. -
See also:
@vgpu/wgsl-std/noise/simplex(same shape, ~2.5× the slope and ~1.7× the σ at the same input scale — scalepositionby ~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.