<canvas id="canvas"></canvas>

<h1>What's behind the curtain ?</h1>
<p>This funny interactive curtain is made with ThreeJS, polylines and verlet constraints (adapted from verlet-js).</p>
<p>Feel free to play with conf (<em>nx</em>, <em>gravity</em>...)</p>

<p class="collection">
<a href="https://codepen.io/collection/AGZywR" target="_blank">WebGL Collection</a>
</p>
body, html {
  height: 100%;  
}
body {
  margin: 0;
  font-family: 'Montserrat', sans-serif;
  background-image: radial-gradient(circle, #ffffff, #aaaaaa);
}
canvas {
  position: fixed;
  top: 0;
  bottom: 0;
  z-index: 1;
}
h1 {
  font-size: 50px;
  text-align: center;
  width: 60%;
  margin: 0 auto;
  padding-top: 10%;
}
p {
  font-size: 30px;
  text-align: center;
  width: 50%;
  margin: 1em auto 0;
}


.collection {
  position: fixed;
  z-index: 1000;
  width: auto;
  bottom: 0;
  right: 0;
  margin: 15px;
  padding: 0;
  font-family: 'Montserrat', sans-serif;
  font-size: 16px;
}

.collection a {
  display: inline-block;
  background: #fff;
  opacity: 0.4;
  transition: opacity 0.7s;
  margin-left: 5px;
  padding: 5px;
  text-decoration: none;
  text-transform: uppercase;
  font-weight: bold;
  color: #000;
}

.collection a:hover {
  opacity: 1;
}
function App() {
  const conf = {
    el: 'canvas',
    gravity: -0.2,
    nx: 100,
    ny: 40,
    size: 1.5,
    stiffness: 5,
    mouseRadius: 10,
    mouseStrength: 0.4
  };

  let renderer, scene, camera;
  let width, height;
  const { randFloat: rnd, randFloatSpread: rndFS } = THREE.Math;

  const mouse = new THREE.Vector2(), oldMouse = new THREE.Vector2();
  const verlet = new VerletJS(), polylines = [];
  const uCx = { value: 0 }, uCy = { value: 0 };

  init();

  function init() {
    renderer = new THREE.WebGLRenderer({ canvas: document.getElementById(conf.el), antialias: true, alpha: true });
    camera = new THREE.PerspectiveCamera();

    verlet.width = 256;
    verlet.height = 256;

    updateSize();
    window.addEventListener('resize', updateSize, false);

    initScene();
    initListeners();
    animate();
  }

  function initScene() {
    scene = new THREE.Scene();
    verlet.gravity = new Vec2(0, conf.gravity);
    
    const loader = new THREE.TextureLoader();
    // loader.load('https://klevron.github.io/codepen/misc/curtain.jpg', texture => {
    //   initCurtain(texture);
    // })
    initCurtain();
  }

  function initCurtain() {
    const material = new THREE.ShaderMaterial({
      transparent: true,
      uniforms: {
        uCx, uCy,
        // tDiffuse: { value: texture },
        uSize: { value: conf.size / conf.nx }
      },
      vertexShader: `
        uniform float uCx;
        uniform float uCy;
        uniform float uSize;
        attribute vec3 color;
        attribute vec3 next;
        attribute vec3 prev;
        attribute float side;

        varying vec2 vUv;
        varying vec4 vColor;

        void main() {
          vUv = uv;
          vColor = vec4(color, 0.5 + smoothstep(0.0, 0.5, uv.y) * 0.5);

          vec3 pos = vec3(position.x * uCx, position.y * uCy, 0.0);
          vec2 sprev = vec2(prev.x * uCx, prev.y * uCy);
          vec2 snext = vec2(next.x * uCx, next.y * uCy);

          vec2 tangent = normalize(snext - sprev);
          vec2 normal = vec2(-tangent.y, tangent.x);

          float dist = length(snext - sprev);
          normal *= smoothstep(0.0, 0.02, dist);

          normal *= uSize;// * (1.0 - uv.y);
          pos.xy -= normal * side;

          gl_Position = vec4(pos, 1.0);
        }
      `,
      fragmentShader: `
        // uniform sampler2D tDiffuse;
        varying vec2 vUv;
        varying vec4 vColor;
        void main() {
          // vec4 tex = texture2D(tDiffuse, vUv);
          // tex.a = 0.95;
          // gl_FragColor = tex;
          gl_FragColor = vColor;
        }
      `
    });

    const dx = verlet.width / conf.nx, dy = -verlet.height / (conf.ny - 1);
    const ox = -dx * (conf.nx / 2 - 0.5), oy = verlet.height / 2 - dy / 2;
    // const cscale = chroma.scale([chroma.random(), chroma.random()]);
    // const cscale = chroma.scale([0x09256f, 0x6efec8]);
    const cscale = chroma.scale([0x051924, 0xc00a1c]);
    for (let i = 0; i < conf.nx; i++) {
      const points = [];
      const vpoints = [];
      for (let j = 0; j < conf.ny; j++) {
        const x = ox + i * dx, y = oy + j * dy;
        points.push(new THREE.Vector3(x, y, 0));
        vpoints.push(new Vec2(x, y));
      }
      const polyline = new Polyline({ points, color1: cscale(rnd(0, 1)), color2: cscale(rnd(0, 1)), uvx: (i + 1) / conf.nx, uvdx: conf.size / conf.nx });
      polylines.push(polyline);

      polyline.segment = verlet.lineSegments(vpoints, conf.stiffness);
      polyline.segment.pin(0);
      // polyline.segment.particles.forEach(p => { p.pos.x += rndFS(5); });

      const mesh = new THREE.Mesh(polyline.geometry, material);
      scene.add(mesh);
    }

    for (let i = 0; i < verlet.width; i++) {
      const ox = -verlet.width / 2;
      setTimeout(() => {
        _move(new THREE.Vector2(ox + i, 0), new THREE.Vector2(ox + i + 1, 0));
      }, i * 15);
    }
  }

  function updatePoints() {
    polylines.forEach(line => {
      for (let i = 0; i < line.points.length; i++) {
        const p = line.segment.particles[i].pos;
        line.points[i].x = p.x;
        line.points[i].y = p.y;
      }
      line.updateGeometry();
    });
  }

  function updateColors() {
    const c1 = chroma.random(), c2 = chroma.random();
    const cscale = chroma.scale([c1, c2]);
    console.log(c1.hex(), c2.hex());
    // #21a25f #a0fa42
    // #09256f #6efec8
    polylines.forEach(line => {
      line.color1 = cscale(rnd(0, 1));
      line.color2 = cscale(rnd(0, 1));
      const cscale1 = chroma.scale([line.color1, line.color2]);
      const colors = line.geometry.attributes.color.array;
      const c = new THREE.Color();
      for (let i = 0; i < line.count; i++) {
        c.set(cscale1(i / line.count).hex());
        c.toArray(colors, (i * 2) * 3);
        c.toArray(colors, (i * 2 + 1) * 3);
      }
      line.geometry.attributes.color.needsUpdate = true;
    });
  }

  function animate() {
    verlet.frame(16);
    updatePoints();
    renderer.render(scene, camera);
    requestAnimationFrame(animate);
  }

  function initListeners() {
    if ('ontouchstart' in window) {
      document.body.addEventListener('touchstart', updateMouse, false);
      document.body.addEventListener('touchmove', move, false);
    } else {
      document.body.addEventListener('mouseenter', updateMouse, false);
      document.body.addEventListener('mousemove', move, false);
    }
    document.body.addEventListener('click', updateColors, false);
  }

  function move(e) {
    updateMouse(e);
    _move(oldMouse, mouse);
  }

  function _move(oV, nV) {
    const v1 = new THREE.Vector2(), v2 = new THREE.Vector2();
    polylines.forEach(line => {
      for (let i = 0; i < line.points.length; i++) {
        const p = line.segment.particles[i].pos;
        const l = v1.copy(oV).sub(v2.set(p.x, p.y)).length();
        if (l < conf.mouseRadius) {
          v1.copy(nV).sub(oV).multiplyScalar(conf.mouseStrength);
          p.x += v1.x; p.y += v1.y;
        }
      }
    });
  }

  function updateMouse(e) {
    if (e.changedTouches && e.changedTouches.length) {
      e.x = e.changedTouches[0].pageX;
      e.y = e.changedTouches[0].pageY;
    }
    if (e.x === undefined) {
      e.x = e.pageX;
      e.y = e.pageY;
    }

    oldMouse.copy(mouse);
    mouse.set(
      (e.x - width / 2) * verlet.width / width,
      (height / 2 - e.y) * verlet.height / height
    );
  }

  function updateSize() {
    width = window.innerWidth;
    height = window.innerHeight;
    uCx.value = 2 / verlet.width; uCy.value = 2 / verlet.height;
    renderer.setSize(width, height);
    // camera.aspect = width / height;
    // camera.updateProjectionMatrix();
  }
}

// adapted from https://github.com/oframe/ogl/blob/master/src/extras/Polyline.js
const Polyline = (function () {
  const tmp = new THREE.Vector3();

  class Polyline {
    constructor(params) {
      const { points, color1, color2, uvx, uvdx } = params;
      this.points = points;
      this.count = points.length;
      this.color1 = color1; this.color2 = color2;
      this.uvx = uvx; this.uvdx = uvdx;
      this.init();
      this.updateGeometry();
    }

    init() {
      // const cscale = chroma.scale([chroma.random(), chroma.random()]);
      const cscale = chroma.scale([this.color1, this.color2]);
      this.geometry = new THREE.BufferGeometry();
      this.position = new Float32Array(this.count * 3 * 2);
      this.prev = new Float32Array(this.count * 3 * 2);
      this.next = new Float32Array(this.count * 3 * 2);
      const side = new Float32Array(this.count * 1 * 2);
      const uv = new Float32Array(this.count * 2 * 2);
      const color = new Float32Array(this.count * 3 * 2);
      const index = new Uint16Array((this.count - 1) * 3 * 2);

      const c = new THREE.Color();
      for (let i = 0; i < this.count; i++) {
        const i2 = i * 2;
        side.set([-1, 1], i2);
        const v = 1 - i / (this.count - 1);
        // uv.set([0, v, 1, v], i * 4);
        uv.set([this.uvx, v, this.uvx - this.uvdx, v], i * 4);

        c.set(cscale(v).hex());
        c.toArray(color, i2 * 3);
        c.toArray(color, (i2 + 1) * 3);

        if (i === this.count - 1) continue;
        index.set([i2 + 0, i2 + 1, i2 + 2], (i2 + 0) * 3);
        index.set([i2 + 2, i2 + 1, i2 + 3], (i2 + 1) * 3);
      }

      this.geometry.setAttribute('position', new THREE.BufferAttribute(this.position, 3));
      this.geometry.setAttribute('color', new THREE.BufferAttribute(color, 3));
      this.geometry.setAttribute('prev', new THREE.BufferAttribute(this.prev, 3));
      this.geometry.setAttribute('next', new THREE.BufferAttribute(this.next, 3));
      this.geometry.setAttribute('side', new THREE.BufferAttribute(side, 1));
      this.geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
      this.geometry.setIndex(new THREE.BufferAttribute(index, 1));
    }

    updateGeometry() {
      this.points.forEach((p, i) => {
        p.toArray(this.position, i * 3 * 2);
        p.toArray(this.position, i * 3 * 2 + 3);

        if (!i) {
          tmp.copy(p).sub(this.points[i + 1]).add(p);
          tmp.toArray(this.prev, i * 3 * 2);
          tmp.toArray(this.prev, i * 3 * 2 + 3);
        } else {
          p.toArray(this.next, (i - 1) * 3 * 2);
          p.toArray(this.next, (i - 1) * 3 * 2 + 3);
        }

        if (i === this.points.length - 1) {
          tmp.copy(p).sub(this.points[i - 1]).add(p);
          tmp.toArray(this.next, i * 3 * 2);
          tmp.toArray(this.next, i * 3 * 2 + 3);
        } else {
          p.toArray(this.prev, (i + 1) * 3 * 2);
          p.toArray(this.prev, (i + 1) * 3 * 2 + 3);
        }
      });

      this.geometry.attributes.position.needsUpdate = true;
      this.geometry.attributes.prev.needsUpdate = true;
      this.geometry.attributes.next.needsUpdate = true;
    }
  }

  return Polyline;
})();

App();
View Compiled

External CSS

  1. https://fonts.googleapis.com/css2?family=Montserrat:wght@400;900&amp;display=swap
  2. https://codepen.io/soju22/pen/4aeaa568d7a4f3459bcde0e70b10f35a.css

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/three.js/110/three.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.0.3/chroma.min.js
  3. https://klevron.github.io/codepen/lib/verlet-1.0.0.min.js