Search code examples
three.jsglslshaderwebgllinear-gradients

WebGL shader for directional linear gradient


I'm working on implementing a directional linear gradient shader in Three js. It's my first time working with shaders, but I have a starting point here:

    uniforms: {
            color1: {
                value: new Three.Color('red')
            },
            color2: {
                value: new Three.Color('purple')
            },
            bboxMin: {
                value: geometry.boundingBox.min
            },
            bboxMax: {
                value: geometry.boundingBox.max
            }
        },
        vertexShader: `
            uniform vec3 bboxMin;
            uniform vec3 bboxMax;

            varying vec2 vUv;

            void main() {

                vUv.y = (position.y - bboxMin.y) / (bboxMax.y - bboxMin.y);

                gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
            }
        `,
        fragmentShader: `
            uniform vec3 color1;
            uniform vec3 color2;

            varying vec2 vUv;

            void main() {

                gl_FragColor = vec4(mix(color1, color2, vUv.y), 1.0);
            }
        `,

this works great for a 'bottom up' linear gradien where color1 (red) is on the bottom and color2(purple) is on the top. I'm trying to figure out how to rotate the direction the gradient is going in. I know it requires editing the void main() { function, however I'm a bit lost as to the needed math.

Basically i'm trying to reimplement svg gradient definitions:

viewBox="0 0 115.23 322.27">
  <defs>
    <linearGradient id="linear-gradient" x1="115.95" y1="302.98" x2="76.08" y2="143.47" gradientUnits="userSpaceOnUse">
      <stop offset="0" stop-color="#8e0000"/>
      <stop offset="1" stop-color="#42210b"/>
    </linearGradient>

So I have to turn the viewbox, and x1,y1,x2,y2, and the possibility for more than two "stop" colors into uniforms and some kinda logic that works


Solution

  • Use a texture.

    This answer shows making gradients using a texture.

    As proof this is generally the solution, here is a canvas 2d implementation in WebGL and here's the code in Skia, which is used in Chrome and Firefox to draw SVG and Canvas2D gradients, and used in Android to draw the entire system UI.

    You can then offset, rotate, and scale how the gradient is applied just like any other texture, by manipulating the texture coordinates. In three.js you can do that by setting texture.offset, texture.repeat, and texture.rotation or by updating the texture coordinates in the geometry.

    body {
      margin: 0;
    }
    #c {
      width: 100vw;
      height: 100vh;
      display: block;
    }
    <canvas id="c"></canvas>
    <script type="module">
    import * as THREE from 'https://threejsfundamentals.org/threejs/resources/threejs/r115/build/three.module.js';
    
    function main() {
      const canvas = document.querySelector('#c');
      const renderer = new THREE.WebGLRenderer({canvas});
    
      const fov = 75;
      const aspect = 2;  // the canvas default
      const near = 0.1;
      const far = 5;
      const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
      camera.position.z = 2;
    
      const scene = new THREE.Scene();
    
      const geometry = new THREE.PlaneBufferGeometry(1, 1);
    
      const tempColor = new THREE.Color();
      function get255BasedColor(color) {
        tempColor.set(color);
        return tempColor.toArray().map(v => v * 255);
      }
      
      function makeRampTexture(stops) {
        // let's just always make the ramps 256x1
        const res = 256;
        const data = new Uint8Array(res * 3);
        const mixedColor = new THREE.Color();
        
        let prevX = 0;
        for (let ndx = 1; ndx < stops.length; ++ndx) {
          const nextX = Math.min(stops[ndx].position * res, res - 1);
          if (nextX > prevX) {
            const color0 = stops[ndx - 1].color;
            const color1 = stops[ndx].color;
            const diff = nextX - prevX;
            for (let x = prevX; x <= nextX; ++x) {
              const u = (x - prevX) / diff;
              mixedColor.copy(color0);
              mixedColor.lerp(color1, u);
              data.set(get255BasedColor(mixedColor), x * 3);
            }
          }
          prevX = nextX;
        }
        
        return new THREE.DataTexture(data, res, 1, THREE.RGBFormat);
      }
      
    
      function makeInstance(geometry, x, scale, rot) {
        const texture = makeRampTexture([
          { position: 0, color: new THREE.Color('red'), },
          { position: 0.7, color: new THREE.Color('yellow'), },
          { position: 1, color: new THREE.Color('blue'), },
        ]);
        texture.repeat.set(1 / scale, 1 / scale);
        texture.rotation = rot;
    
        const material = new THREE.MeshBasicMaterial({map: texture});
    
        const cube = new THREE.Mesh(geometry, material);
        scene.add(cube);
    
        cube.position.x = x;
    
        return cube;
      }
    
      const cubes = [
        makeInstance(geometry,  0, 1, 0),
        makeInstance(geometry, -1.1, 1.42, Math.PI / 4),
        makeInstance(geometry,  1.1, 1, Math.PI / 2),
      ];
    
      function resizeRendererToDisplaySize(renderer) {
        const canvas = renderer.domElement;
        const width = canvas.clientWidth;
        const height = canvas.clientHeight;
        const needResize = canvas.width !== width || canvas.height !== height;
        if (needResize) {
          renderer.setSize(width, height, false);
        }
        return needResize;
      }
    
      function render(time) {
        time *= 0.001;
    
        if (resizeRendererToDisplaySize(renderer)) {
          const canvas = renderer.domElement;
          camera.aspect = canvas.clientWidth / canvas.clientHeight;
          camera.updateProjectionMatrix();
        }
    
        renderer.render(scene, camera);
    
        requestAnimationFrame(render);
      }
    
      requestAnimationFrame(render);
    }
    
    main();
    </script>

    note that it's unfortunate but three.js does not separate texture matrix settings (offset, repeat, rotation) from the texture's data itself which means you can not use the gradient texture in different ways using the same data. You have to make a unique texture for each one.

    You can instead make different texture coordinates per geometry but that's also less than ideal.

    Another solution would be to make your own shader that takes a texture matrix and pass offset, repeat, rotation, via that matrix if you wanted to avoid the resource duplication.

    Fortunately a 256x1 RGB gradient texture is just not that big so I'd just make multiple gradients textures and not worry about it.