36
Plasma: Domain Warp
drag to stir, arrows to add octaves
idle
145 lines · three
view source
// Domain-warped fBm plasma — full-screen fragment shader.
//
// Three nested fBm layers (each is a sum of 2D value-noise octaves):
// warp1 = fBm(uv)
// warp2 = fBm(uv + warp1)
// value = fBm(uv + warp2)
// Mapped through an Inigo Quilez cosine palette so the colors drift slowly
// over time.
//
// Interaction:
// - drag : stir the warp field (mouse becomes a warp anchor;
// amplification falls off with distance)
// - scroll / arrow keys : change octave count in [2, 6]
//
// Implementation notes:
// - One full-screen quad on an OrthographicCamera. All visual work lives in
// the fragment shader.
// - Octave count is a uniform but the loop is unrolled at OCTAVE_MAX with a
// branch — WebGL1 (three r128) doesn't allow dynamic loop bounds.
// - Canvas is at CSS resolution (matches sandbox.html policy). On a Retina
// MacBook Air this comfortably holds 60 fps even at OCTAVE_MAX = 6.
const OCTAVE_MIN = 2;
const OCTAVE_MAX = 6;
let scene, camera, renderer;
let mesh, material;
let octaves = 3;
let mouseUV = new THREE.Vector2(0.5, 0.5); // current pointer in UV space
let mouseAnchor = new THREE.Vector2(0.5, 0.5); // smoothed warp anchor
let mouseStrength = 0; // 0..1, ramps while dragging
let lastWheelChange = 0; // throttle wheel ticks
let cachedW = 1, cachedH = 1;
const VERT = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
// Value-noise (cheap, branchless, no permutation table needed) — same
// flavor as fract(sin(dot)) hashing. Good enough for visual plasma; would
// banding-fail for anything physical, but here we're stacking three layers
// of fBm so artifacts vanish.
const FRAG = `
precision highp float;
varying vec2 vUv;
uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uMouse; // UV-normalized warp anchor
uniform float uMouseAmp; // 0..1 strength
uniform int uOctaves; // 2..6
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
// Quintic smoothstep — same as classic Perlin's fade.
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p, int oct) {
float v = 0.0;
float amp = 0.5;
float freq = 1.0;
// Unrolled because WebGL1 forbids non-constant loop bounds.
for (int i = 0; i < ${OCTAVE_MAX}; i++) {
if (i >= oct) break;
v += amp * vnoise(p * freq);
freq *= 2.02; // slight off-integer to break grid alignment
amp *= 0.5;
}
return v;
}
// Inigo Quilez cosine palette: c = a + b * cos(2π (c·t + d))
vec3 palette(float t) {
vec3 a = vec3(0.50, 0.50, 0.50);
vec3 b = vec3(0.50, 0.50, 0.50);
vec3 c = vec3(1.00, 1.00, 1.00);
vec3 d = vec3(0.00, 0.33, 0.67);
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
// Aspect-correct UV so the noise doesn't stretch on widescreen.
vec2 uv = vUv;
float aspect = uResolution.x / uResolution.y;
vec2 p = vec2((uv.x - 0.5) * aspect, uv.y - 0.5) * 3.0;
// Slow time drift so the field is alive even with no input.
float t = uTime * 0.08;
// Distance from the mouse anchor (also in aspect-corrected space) becomes
// an extra warp amplifier — strongest near the cursor, decays with falloff.
vec2 mp = vec2((uMouse.x - 0.5) * aspect, uMouse.y - 0.5) * 3.0;
float md = length(p - mp);
float pull = exp(-md * md * 0.6) * uMouseAmp;
// First warp: fBm offset.
vec2 q = vec2(
fbm(p + vec2(0.0, t), uOctaves),
fbm(p + vec2(5.2, 1.3 - t), uOctaves)
);
// Second warp, biased toward the mouse anchor. The (mp - p) push is what
// makes dragging visibly stir the field — it shoves the second-warp input
// toward the cursor.
vec2 r = vec2(
fbm(p + 4.0 * q + vec2(1.7, 9.2) + (mp - p) * pull, uOctaves),
fbm(p + 4.0 * q + vec2(8.3, 2.8) - (mp - p) * pull, uOctaves)
);
// Final value sample.
float f = fbm(p + 4.0 * r, uOctaves);
// Palette drift.
vec3 col = palette(f + t * 0.25);
// Small vignette so the edges read as a field, not a clipped texture.
vec2 cv = uv - 0.5;
float vig = smoothstep(0.95, 0.25, dot(cv, cv) * 1.6);
col *= mix(0.6, 1.0, vig);
gl_FragColor = vec4(col, 1.0);
}
`;
function init({ scene: s, renderer: r, width, height }) {
scene = new THREE.Scene();
// A full-screen quad is easiest with an ortho camera at [-1,1] in clip space.
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
renderer = r;
renderer.setClearColor(0x000000, 1);
cachedW = width;
cachedH = height;
material = new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FRAG,
uniforms: {
uTime: { value: 0 },
uResolution: { value: new THREE.Vector2(width, height) },
uMouse: { value: new THREE.Vector2(0.5, 0.5) },
uMouseAmp: { value: 0 },
uOctaves: { value: octaves },
},
depthTest: false,
depthWrite: false,
});
// PlaneGeometry(2, 2) covers clip space exactly under an identity vertex
// shader; we still feed it through the OrthographicCamera so three doesn't
// complain about culling.
const geom = new THREE.PlaneGeometry(2, 2);
mesh = new THREE.Mesh(geom, material);
scene.add(mesh);
return { scene, camera };
}
function tick({ dt, time, width, height, input }) {
// Resize handling — cheap; only updates the uniform when dimensions actually
// change. Renderer is resized by the host loop for us via setSize when our
// ctxObj reports new width/height, but uResolution is ours to keep in sync.
if (width !== cachedW || height !== cachedH) {
cachedW = width; cachedH = height;
material.uniforms.uResolution.value.set(width, height);
renderer.setSize(width, height, false);
}
// --- Octave control: wheel + arrow keys -------------------------------
// Wheel: throttle so a single physical scroll-tick doesn't flip through
// multiple octave levels.
if (Math.abs(input.wheelY) > 0 && time - lastWheelChange > 0.12) {
if (input.wheelY > 0) octaves = Math.max(OCTAVE_MIN, octaves - 1);
else octaves = Math.min(OCTAVE_MAX, octaves + 1);
lastWheelChange = time;
}
if (input.justPressed("ArrowUp") || input.justPressed("ArrowRight") ||
input.justPressed("+") || input.justPressed("=")) {
octaves = Math.min(OCTAVE_MAX, octaves + 1);
}
if (input.justPressed("ArrowDown") || input.justPressed("ArrowLeft") ||
input.justPressed("-")) {
octaves = Math.max(OCTAVE_MIN, octaves - 1);
}
material.uniforms.uOctaves.value = octaves;
// --- Mouse → warp anchor ----------------------------------------------
// Convert pixel coords to UV (origin top-left in input, but our shader
// treats v=0 as bottom; we keep UV in [0,1] either way — the noise field
// doesn't care about orientation, and the anchor falloff is radial).
if (typeof input.mouseX === "number" && width > 0 && height > 0) {
mouseUV.set(
Math.max(0, Math.min(1, input.mouseX / width)),
Math.max(0, Math.min(1, 1 - input.mouseY / height)),
);
}
// While dragging, the anchor chases the cursor and strength ramps up.
// On release, strength decays so the field gently settles. This is what
// sells the "stir" — without the decay you get a snap-back.
const targetStrength = input.mouseDown ? 1.0 : 0.0;
const ease = Math.min(1, dt * (input.mouseDown ? 6 : 1.5));
mouseStrength += (targetStrength - mouseStrength) * ease;
mouseAnchor.x += (mouseUV.x - mouseAnchor.x) * Math.min(1, dt * 4);
mouseAnchor.y += (mouseUV.y - mouseAnchor.y) * Math.min(1, dt * 4);
material.uniforms.uMouse.value.copy(mouseAnchor);
material.uniforms.uMouseAmp.value = mouseStrength;
material.uniforms.uTime.value = time;
renderer.render(scene, camera);
return { scene, camera };
}
Comments (0)
Log in to comment.