36

Plasma: Domain Warp

drag to stir, arrows to add octaves

Domain-warped fBm: \(f(uv) = \text{fbm}(uv + \text{fbm}(uv + \text{fbm}(uv)))\). Each layer warps the next, producing turbulent, paint-like color fields. Palette drifts slowly so the texture feels alive.

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.