interop
Use sample(position, time) to supply values to a renderer. The examples below use them for three.js vertices, audio parameters and CSS easing curves.
Sampling values
Everything on this page rests on a single call:
const s = VanillaWaves.createSampler({ shift: true, group: 'gentle', range: [-1, 1] });
s.sample(position, time); // number in, number out
Pass t in seconds yourself. You can freeze it, run it backwards or advance it faster than real time. Use the returned value for a property in your renderer.
Why it matters. The wave dialect contains a set of 35 named shapes with periods, groups, and a shift engine that morphs between them. Everything a renderer gains from that comes through sample(), which you can use without the DOM engine.
Load the module
For a renderer that uses ES modules, import the math from waves-core.mjs:
// the module route - same file works in the browser, in Node and in bundlers
import { createSampler, wave, list } from './waves-core.mjs';
import * as THREE from 'three';
const s = createSampler({ shift: true, group: 'gentle', range: [-1, 1] });
That file holds no math of its own. It imports waves-core.js for its side effect and re-exports the result, so there is exactly one copy of the canonical formulas without duplicating them in the module. It also leaves the globals in place, so both loading routes can be used on one page.
On the cdn. Since v0.3.1 the module entry ships on jsDelivr: import { createSampler, wave, list } from 'https://cdn.jsdelivr.net/gh/seb-prjcts-be/vanilla.waves@v0.3.1/waves-core.mjs'; Or copy it next to your code for a fully local, zero-dependency setup.
The classic route still works and is what you want when the DOM engine comes along, since the engine is browser-only and self-initialising. Load the script first, read the global inside your module:
<script src="waves-core.js"></script>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from 'three';
const W = globalThis.VanillaWaves; // globals are visible inside modules
</script>
Reach for waves-core rather than the full bundle when the DOM engine is not going to be used: it is the math alone, with no shared loop and no [data-wv] scanning.
Node takes the same module entry, which is what makes an adapter testable without a browser, a GPU or a renderer:
import { createSampler } from '../vanilla.waves/waves-core.mjs'; // 35 waves, ready
Separate sampling from rendering
An adapter can keep the sampling separate from renderer code. It takes a position and time and returns plain numbers; the renderer decides how to use them.
// wave-field.js - imports nothing, knows no renderer export function createWaveField(W, opts = {}) { const a = W.createSampler({ seed: 7, shift: true, group: 'gentle', range: [-1, 1] }); const b = W.createSampler({ seed: 19, shift: true, group: 'gentle', range: [-1, 1], phase: 1.7 }); return { sample(x, z, t, out = {}) { const u = a.sample(x * 0.62 + z * 0.31, t); const v = b.sample(z * 0.62 - x * 0.31, t); const angle = (u * 0.78 + v * 0.22) * Math.PI; const y = v * 0.32; // tilt const flat = Math.sqrt(Math.max(0, 1 - y * y)); // keep it unit length out.x = Math.cos(angle) * flat; out.y = y; out.z = Math.sin(angle) * flat; return out; }, get waveName() { return a.waveName; }, get target() { return a.targetName || ''; } }; }
Keeping that boundary gives you a few useful options:
It is testable in Node. Normalisation, finiteness, bounds and reproducibility can all be asserted without a canvas. A field that returns
NaNat one grid position is a silent visual glitch in a renderer and a failing test in Node.It outlives the renderer. The same field can drive line segments today and particles, meshes, cameras, lights or shader uniforms tomorrow, with no change to the wave code.
You can show the current wave. Exposing
waveNameandtargetNamemeans the scene can show which formula is playing and which one is coming, which helps you identify the formulas in a moving scene.
Companion experiment. three.waves, a companion experiment, folds two samplers into one normalised 3D direction and drives 576 line segments through a single BufferGeometry. Its adapter has no three.js import at all and is covered by Node tests for normalisation, reproducibility, shift and hard height bounds. Measured there: 600 updates of 576 vectors in 125 ms, about 0.21 ms per full field update, wave math only.
three.js: displaced geometry
The plainest useful case. Walk a BufferAttribute, read one sampler per vertex, flag it dirty. Position enters as the first argument, time as the second, so the line both has a shape along its length and moves.
const N = 200;
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(N * 3);
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
scene.add(new THREE.Line(geo, new THREE.LineBasicMaterial({ color: 0x00b3a4 })));
const s = W.createSampler({ shift: true, group: 'gentle', range: [-1.2, 1.2] });
function frame(t) {
for (let i = 0; i < N; i++) {
pos[i*3] = (i / (N - 1)) * 12 - 6; // x across the line
pos[i*3 + 1] = s.sample(i * 0.6, t); // y from the wave
pos[i*3 + 2] = 0;
}
geo.attributes.position.needsUpdate = true;
geo.computeBoundingSphere(); // or frustum culling misjudges it
}
Reuse one Float32Array and one sampler; do not rebuild geometry per frame. Mark the attribute with setUsage(THREE.DynamicDrawUsage) when it changes every frame.
three.js: closing ring
Use the closing group to sample a ring while shifting between waves.
A ring needs its first and last samples to meet. Sweep an integer multiple of the wave's period. For shifting rings, the 'closing' pool provides 19 waves with a shared base period, so you can use sampler.period × lobes throughout the transition. The periods are measured approximations.
const SEG = 240, LOBES = 6;
const ring = W.createSampler({ shift: true, group: 'closing', range: [-0.6, 0.6] });
function frame(t) {
const sweep = ring.period * LOBES; // read the sweep off the sampler
for (let i = 0; i <= SEG; i++) {
const u = i / SEG;
const ang = u * Math.PI * 2;
const r = 2 + ring.sample(u * sweep, t);
pos[i*3] = Math.cos(ang) * r;
pos[i*3 + 1] = 0;
pos[i*3 + 2] = Math.sin(ang) * r;
}
geo.attributes.position.needsUpdate = true;
}
The recorded ring test used radius 2 and 120 frames of active shifting. Its largest gap between the first and last vertex was 1.3e-5 world units. A raw-sampler comparison over 30 seconds of shifting at amplitude 30 recorded a largest seam error of 0.005 with group: 'closing' and 28.7 with group: 'all'. These are results for those test conditions.
Anything swept and closed inherits this: rings, tubes, lathe profiles, torus cross-sections, orbit paths, extrusions along a loop.
Experimental. Period values may drift by about 0.001 in minor versions. Use these approximate periods for visual work. They are not suitable for plotters or CNC.
three.js: an instanced 2D field
Build a 2D field with two samplers and a loop. You choose how the axes combine, then pass the values to an InstancedMesh to draw instances together.
const GX = 16, GZ = 16;
const mesh = new THREE.InstancedMesh(
new THREE.BoxGeometry(0.4, 1, 0.4),
new THREE.MeshStandardMaterial({ color: 0x00b3a4 }),
GX * GZ
);
const sx = W.createSampler({ seed: 7, shift: true, group: 'gentle', range: [0, 1] });
const sz = W.createSampler({ seed: 21, shift: true, group: 'gentle', range: [0, 1] });
const dummy = new THREE.Object3D();
function frame(t) {
let i = 0;
for (let gz = 0; gz < GZ; gz++) for (let gx = 0; gx < GX; gx++, i++) {
const h = 0.2 + sx.sample(gx * 1.5, t) * sz.sample(gz * 1.5, t) * 3;
dummy.position.set(gx - GX/2 + 0.5, h/2, gz - GZ/2 + 0.5);
dummy.scale.set(1, h, 1);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
}
Multiplying the two axes gives interference and standing-wave patterns; adding them gives soft diagonal ridges. Both are one character apart, so try each.
three.js: the wave as a GPU lookup
For large meshes, you can sample the wave into a small texture and let the shader read it. In this example, the CPU does 256 wave evaluations per frame rather than one per vertex.
const LUT = 256;
const data = new Float32Array(LUT);
const tex = new THREE.DataTexture(data, LUT, 1, THREE.RedFormat, THREE.FloatType);
const s = W.createSampler({ wave: 'mountain peaks', range: [0, 1] });
const mat = new THREE.ShaderMaterial({
uniforms: { uLut: { value: tex } },
vertexShader: `varying vec2 vUv;
void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `uniform sampler2D uLut; varying vec2 vUv;
void main(){
float v = texture2D(uLut, vec2(vUv.x, 0.5)).r;
gl_FragColor = vec4(vec3(step(vUv.y, v)), 1.0);
}`
});
function frame(t) {
for (let k = 0; k < LUT; k++) data[k] = s.sample(k * 0.7, t);
tex.needsUpdate = true; // one small upload per frame
}
Bake once instead of every frame when the wave is frozen (shift: false and a fixed t), and the per-frame cost drops to zero. This is also how a wave reaches a vertex shader: read the LUT in the vertex stage and displace there.
Other runtimes
Nothing above is specific to 3D. Any host with a number-shaped input is a candidate, and some of these fit the dialect unusually well.
| Host | The seam | |
|---|---|---|
| Web Audio | A wave is an LFO. Fill a Float32Array from the sampler and hand it to AudioParam.setValueCurveAtTime() to steer a cutoff, detune or gain over a fixed span. Same idea in Tone.js. | |
| CSS easing | Bake N samples into a linear() timing function once, and the animation runs with no JavaScript at all afterwards. A wave becomes an easing curve. | |
| CSS variables | el.style.setProperty('--wv', s.sample(0, t)) and let stylesheets decide what it means. One JS write, many visual consequences. | |
| GSAP | Use it inside a modifiers function or a ticker callback so GSAP owns the timeline and the wave owns the shaping. | |
| Canvas 2D / Pixi | Identical to the three.js loop without the geometry step: sample per element, write a coordinate, a radius or a colour. | |
| SVG / D3 | Feed a path generator, or produce plausible placeholder series for skeleton charts that move instead of shimmering. | |
| Physics | Matter.js and friends: a wave as a force over time, or as the path of a moving platform or attractor. | |
| Node, build time | No renderer at all. Bake SVG paths, LUT textures or static frames ahead of time, so the browser ships zero wave code. | |
The tell. If a host exposes a number you can write every frame, or an array it will interpolate for you, the sampler already fits. Reach for the adapter rule before the second one of these.
Module exports and limits
waves-core.mjs is a shim of about a dozen lines. It contains no formulas: it imports waves-core.js for its side effect and re-exports what that installs.
import './waves-core.js'; const W = globalThis.VanillaWaves; export const wave = W.wave; export const createSampler = W.createSampler; export const list = W.list; export const data = W.data; export const count = W.count; export default W;
That shape is deliberate. Re-exporting rather than re-implementing means there is one copy of the canonical math in the repo, without a second set of formulas in the module. The recorded runtime comparison found that the detached exports are bit-identical to the global over a thousand calls, and Node and the browser return the same digits for the same input.
| Works | Detail | |
|---|---|---|
| Browser ESM | import { createSampler } from './waves-core.mjs' | |
| Node | Same import. waves-core.js loads as CommonJS there; the globalThis assignment is identical either way. | |
| Bundlers | Resolves as a normal module. No build step: unlike the bundles, this file is written, not generated. | |
| Globals | Still installed, so the module route and the <script> route can coexist on one page. | |
There is no npm package, so import ... from 'vanilla.waves' does not resolve by itself. Import by path or provide an import map. The exports come from one frozen object, so a bundler cannot tree-shake individual unused waves.
Not included. The DOM engine is not part of the module entry. It is browser-only and initialises itself on DOMContentLoaded, so load vanilla.waves.js with a <script> tag when you want register, init and destroy.