0

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

RenaissanceProgrammer
  • 404
  • 1
  • 11
  • 30

2 Answers2

1

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.

gman
  • 100,619
  • 31
  • 269
  • 393
0

The answer I upvoted by gman is correct, however I found that I had to do a couple extra things because I was not using a standard geometry, rather using geometry made from an svg icon, and also the answer doesn't explain exactly how to create a texture in Three using a linear gradient definition that can have mutliple colors and a defined direction (things you need to kinda figure out).

These steps only pertain to those individuals who stumble across this questions and are likewise dealing with non-standard geometry and are confused how to make linear-gradient textures in Three:

Generate your gradient texture using canvas 2d. You will have to calculate the size of your face object before hand, for me I just did a bbox measurement or you can use whatever size you want, if you don't want exact gradient to face position matching.

function generateTextureGradient(size, x1, y1, x2, y2, colors){
    let width = size.width
    let height = size.height
    const canvas = new self.OffscreenCanvas(width, height)
    let context = canvas.getContext('2d')
    context.rect(0, 0, width, height)
    let gradient = context.createLinearGradient(x1, y1, x2, y2)
    for (let color of colors) {
        gradient.addColorStop(color.props.offset, color.props['stop-color'])
    }
    context.fillStyle = gradient
    context.fill()
    return canvas
}

Unwrap your geometry UVs, my svg geometry had 80 faces but 0 faceVertexes, use this loop to generate faceVertexUVs so Three understands how to lay down the texture unto the mesh.

for (var i = 0; i < geometry.faces.length; i++) {
        var face = geometry.faces[i];
        var faceUVs = geometry.faceVertexUvs[0][i] || [
            new Three.Vector2(),
            new Three.Vector2(),
            new Three.Vector2()
        ]
        var va = geometry.vertices[geometry.faces[i].a]
        var vb = geometry.vertices[geometry.faces[i].b]
        var vc = geometry.vertices[geometry.faces[i].c]
        var vab = new Three.Vector3().copy(vb).sub(va)
        var vac = new Three.Vector3().copy(vc).sub(va)
        //now we have 2 vectors to get the cross product of...
        var vcross = new Three.Vector3().copy(vab).cross(vac);
        //Find the largest axis of the plane normal...
        vcross.set(Math.abs(vcross.x), Math.abs(vcross.y), Math.abs(vcross.z))
        var majorAxis = vcross.x > vcross.y ? (vcross.x > vcross.z ? 'x' : vcross.y > vcross.z ? 'y' : vcross.y > vcross.z) : vcross.y > vcross.z ? 'y' : 'z'
        //Take the other two axis from the largest axis
        var uAxis = majorAxis == 'x' ? 'y' : majorAxis == 'y' ? 'x' : 'x';
        var vAxis = majorAxis == 'x' ? 'z' : majorAxis == 'y' ? 'z' : 'y';
        faceUVs[0].set(va[uAxis], va[vAxis])
        faceUVs[1].set(vb[uAxis], vb[vAxis])
        faceUVs[2].set(vc[uAxis], vc[vAxis])
        geometry.faceVertexUvs[0][i] = faceUVs
 }
geometry.elementsNeedUpdate = geometry.verticesNeedUpdate = geometry.uvsNeedUpdate = true;

finish it off with adding your texture to a material and then making your mesh

var texture = new Three.CanvasTexture(
    generateTextureGradient(
        {width, height},
        grad.x1,
        grad.y1,
        grad.x2,
        grad.y2,
        grad.colors
    )
)
var material = new Three.MeshBasicMaterial({
    side: Three.DoubleSide,
    map: texture,
    wireframe: false
})
var mesh = new Three.Mesh(geometry, material)
RenaissanceProgrammer
  • 404
  • 1
  • 11
  • 30
  • 1
    I'm glad you found a solution that works for you. Just FYI though: Gradients in SVG are always 256x1, 1 dimensional texture. See the code I linked to. The diagonal part comes from rotating the UVs, not from making a 2 dimensional texture – gman May 18 '20 at 21:35
  • good to know, i thought i had to provide the entire area space of the face for the gradient to be laid down, but it makes sense if it's only 1 dimension for linear gradient (i would imagine radial gradient is different?), for my case i think `context.createLinearGradient(x1, y1, x2, y2)` takse care of the direction without me having to think about rotating uvs, but wouldn't this need to have the proper width/height for the canvas to plot x1,2,y1,2 properly? – RenaissanceProgrammer May 18 '20 at 21:53
  • 1
    I'm not sure what your question about proper width/height means. As for radial gradients, you still only need a 1 dimensional gradient, you just need a shader that does something like `color = texture2D(gradientTexture, vec2(length(vUv), 0));` and otherwise again, everything else can be done by changing the texture matrix via offset, repeat, rotation. – gman May 18 '20 at 23:43
  • Awesome thank you I understand now. Just to clarify, what I meant was if you have a gradient from 1-256 and you have two boxes 100x100 and 300x300, I was confused how the 256 would stretch and squish without the dimension values (keep in mind my analysis and understanding is purely from my own context in using this) because for me I wouldn’t see a gradient but a solid mixed color for a while, but I believe this is where using the “unwrapFaceVertexs” helped me because those are what tells the texture how to stretch/squish into the face/mesh area and now I see a gradient – RenaissanceProgrammer May 19 '20 at 15:37