用于定向线性渐变的 WebGL 着色器

WebGL shader for directional linear gradient

我正致力于在 Three js 中实现定向线性渐变着色器。这是我第一次使用着色器,但我有一个起点:

    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);
            }
        `,

这非常适合 'bottom up' 线性渐变,其中 color1(红色)在底部,color2(紫色)在顶部。我想弄清楚如何旋转渐变的方向。我知道它需要编辑 void main() { 函数,但是我对所需的数学有点迷茫。

基本上我正在尝试重新实现 svg 渐变定义:

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>

所以我必须将视图框和 x1、y1、x2、y2 以及超过两种 "stop" 颜色的可能性变成制服和一些可行的逻辑

使用纹理。

显示使用纹理制作渐变。

作为证明,这通常是解决方案,here is a canvas 2d implementation in WebGL and here's the code in Skia,它在Chrome和Firefox中用于绘制SVG和Canvas2D渐变,并在Android中用于绘制整个系统UI.

然后,您可以通过操纵纹理坐标来偏移、旋转和缩放渐变的应用方式,就像任何其他纹理一样。在 three.js 中,您可以通过设置 texture.offsettexture.repeattexture.rotation 或更新几何体中的纹理坐标来实现。

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>

请注意,不幸的是 three.js 不会将纹理矩阵设置(偏移、重复、旋转)与纹理数据本身分开,这意味着您不能使用相同数据以不同方式使用渐变纹理。你必须为每一个制作一个独特的纹理。

您可以为每个几何体制作不同的纹理坐标,但这也不理想。

另一种解决方案是制作自己的着色器,它采用纹理矩阵并通过该矩阵传递偏移、重复、旋转,如果你想避免资源重复的话。

幸运的是 256x1 RGB 渐变纹理并没有那么大,所以我只制作多个渐变纹理而不用担心它。

gman 赞成的答案是正确的,但是我发现我必须做一些额外的事情,因为我没有使用标准几何图形,而是使用由 svg 图标制作的几何图形,而且答案也不是'我不会确切解释如何使用可以具有多种颜色和定义方向的线性渐变定义在三中创建纹理(您需要弄清楚的事情)。

这些步骤仅适用于那些偶然发现这个问题并且同样处理非标准几何并且对如何在三中制作线性渐变纹理感到困惑的人:

使用 canvas 2d 生成渐变纹理。你必须事先计算你的面部对象的大小,对我来说我只是做了一个 bbox 测量或者你可以使用你想要的任何大小,如果你不想要精确的梯度来匹配面部位置。

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
}

打开你的几何体 UV,我的 svg 几何体有 80 个面但 0 个面顶点,使用这个循环生成 faceVertexUV,所以三人知道如何将纹理放置到网格上。

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;

通过将纹理添加到 material 然后制作网格来完成它

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)