Three.js 自定义shader实现线元素

文化   2024-10-16 07:00   日本  

补充渲染管线光栅化中插值的解释,帮助理解虚线和渐变线实现的逻辑

智驾场景地图上最重要的元素是啥?当属线元素了,比如下图的车道线、规划线和预测线等(下图来自百度 apollo 公开课件,有点糊凑合看吧)

一些调整

先把相机位置调一下,因为在我们认知习惯里z轴应该垂直向上,水平面则是x/y轴,这个时候在不翻转场景的情况下,可以调整相机的 up属性,使其向上位置朝着z轴正向,这样一来给我们造成的视觉效果就是z轴垂直向上

但实际上还是符合右手定则,此时的 x 轴、y 轴和 z 轴如下图所示(蓝色向上是 z 轴正方向,红色向左是 x 轴正方向,绿色向屏幕内是 y 轴正方向)。部分修改代码如下:

carema.up.set(001);
// 注意调下相机位置确保看到自车
camera.position.set(-0.441.4);

内置Line

其实 threejs 也有内置的线条几何体,比如我们可以用 Line 实现几段基础的道路线,并在自车前方加一条简单的规划线

// ...
const points = [];
points.push(new THREE.Vector3(0.4-200));
points.push(new THREE.Vector3(0.4200));
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({ color0xffffff });
const line = new THREE.Line(geometry, material);
line.position.z = 0.1;
this.scene.add(line);
const points2 = [];
points2.push(new THREE.Vector3(-0.8-200));
points2.push(new THREE.Vector3(-0.8200));
const geometry2 = new THREE.BufferGeometry().setFromPoints(points2);
const material2 = new THREE.LineBasicMaterial({ color0xffffff });
const line2 = new THREE.Line(geometry2, material2);
line2.position.z = 0.1;
this.scene.add(line2);
const points3 = [];
points3.push(new THREE.Vector3(-0.2-200));
points3.push(new THREE.Vector3(-0.2200));
const geometry3 = new THREE.BufferGeometry().setFromPoints(points3);
// 虚线材质
const material3 = new THREE.LineDashedMaterial({
  color0xffffff,
  dashSize1// 显示线段的大小,默认为3
  gapSize0.5// 间隙的大小,默认为1
});
const line3 = new THREE.Line(geometry3, material3);
line3.position.z = 0.1;
// 注意虚线必须调用这个函数
line3.computeLineDistances();
this.scene.add(line3);
// 自车规划线
const points4 = [];
points4.push(new THREE.Vector3(0-100));
points4.push(new THREE.Vector3(000));
const geometry4 = new THREE.BufferGeometry().setFromPoints(points4);
const material4 = new THREE.LineBasicMaterial({ color0xffff00 });
const line4 = new THREE.Line(geometry4, material4);
line4.position.z = 0.1;
this.scene.add(line4);

但这里发现规划线太细了,想要定义宽度 LineWidth 却发现没有效果,这里改用 Line2试试:

import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineGeometry, LineMaterial } from "three/examples/jsm/Addons.js";
// ...
// 规划线
const geometry4 = new LineGeometry();
geometry4.setPositions([0-100000]);
const material4 = new LineMaterial({
  resolutionnew THREE.Vector2(window.innerWidth, window.innerHeight),
  color0xffff00,
  linewidth20,
});
const line4 = new Line2(geometry4, material4);
line4.position.z = 0.1;
this.scene.add(line4);

先看下效果:

看起来会有点像圆柱体,而且不随视角远近而改变大小,但其实我们期望的效果只需要车道保持平行的二维固定长度的线。车道线主要是实线和虚线以及多种颜色的组合,乍一看内置元素都还能勉强实现这些,其他内置线元素还有:

  • LineSegments与THREE.Line类似,但是可以通过一系列的点创建出多段线,可以调节线条宽度粗细,这个在 第二篇 一开始实现立方体的时候有用来绘制边框
  • LineLoop 首尾相连的线 ,可以形成一个闭合的图形,但也没法设置宽度粗细
  • CatmullRomCurve3 创建平滑的三维曲线

但后面突然算法找到你,说我们希望做条规划线,而且是渐变色的,渐变范围在某些点之间,可以用来表示速度变化趋势,那这个时候,内置的线元素就难办了。ok总结一下,内置线元素有什么缺点:

  • 宽度定义比较难受
  • 实现虚线或者双线效果时处理数据会额外占用较多的CPU资源
  • 不支持渐变色、流光效果等

自定义Line

我们其实可以自定义 Line 元素和 shader 材质来解决上面的这些限制,需要用到之前的 BufferGeometry 自定义几何体和 shaderMaterial。这里得稍微了解下webgl的渲染管线(图片来自 threejs中文网):

shaderMaterial

上图可以看到,先后经历了顶点着色器(vertex shaders)和片元着色器(fragment shaders),shader 代码用GLSL语言编写,是在GPU中执行的,其实有时候我们可以把一部分CPU的工作交给GPU来提升应用的性能

GLSL入门可以参考

  • https://github.com/wshxbqq/GLSL-Card

这里有些属性要了解一下:

  • uniforms 传递给 shader 的参数,比如颜色值、透明度等
  • vertexShader 在顶点着色器中运行的代码片段
  • fragmentShader 在片元着色器中运行的代码片段

先画个双色的长方形看看:

// ...
const geometry = new THREE.PlaneGeometry(0.41);
const shader = new THREE.ShaderMaterial({
  uniforms: {
    uColor: {
      valuenew THREE.Color("#ffff00"),
    },
    uColor1: {
      valuenew THREE.Color("orange"),
    },
  },
  vertexShader`
    varying vec3 vPosition;
    void main() {
      vPosition = position;
      // 计算顶点的位置,投影矩阵*模型视图矩阵*模型顶点坐标
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }`
,
  fragmentShader`
    uniform vec3 uColor;
    uniform vec3 uColor1;
    varying vec3 vPosition;
    void main() { 
      gl_FragColor = vPosition.y < 0.0 ? vec4(uColor, 1.0) : vec4(uColor1, 1.0);
    }`
,
});
const plane = new THREE.Mesh(geometry, shader);
plane.position.y = -1;
plane.position.z = 0.1;
this.scene.add(plane);

实线

我们可以将线元素Line看成是一些三角形连接而成,然后宽度就是每个点往两边分别延伸一半,类似下图:

这里我们就需要先用 BufferGeometry 自定义 Line 元素,再把相关参数传入 shaderMaterial

先分别实现下基础的实线和虚线,老规矩先定下元素接口:

export interface ILine {
  points: number[];  // 点集,[x,y,z]
  color: string; // 颜色值
  width: number; // 线宽
  type: ELineType; // 线类型
};
export enum ELineType {
  Solid = 0,    // 实线
  Dash = 1,     // 虚线
  Gradual = 10// 渐变线
  // 还可以扩展到双线、虚实线结合等
}

封装 Line,这里用到了 polyline-normals这个库,我们需要借助它来计算顶点的法向量,通过这个法向量和宽度来计算得到两边的顶点。但是这个库还没支持 ESModule,需要 require 引入,需要多安装一个 vite-plugin-commonjs插件来支持,同时修改vite.config.js 如下:

// pnpm i polyline-normals
// pnpm i -D vite-plugin-commonjs
// vite.config.js
import commonjs from "vite-plugin-commonjs";
// ...
export default defineConfig({
  plugins: [react(), commonjs()],
  // ...
});

接下来封装一下自定义的 Line 元素,BufferGeometry 自定义几何体在上一篇有比较多的内容,可以自行参考::

// src/renderer/line.ts
// ...
const getNormals = require("polyline-normals");

class Line {
  scene = new THREE.Scene();

  constructor(scene: THREE.Scene) {
    this.scene = scene;
  }

  createGeometry(data: ILine, needDistance: boolean = false) {
    const { points } = data;
    const vertices: number[][] = [];
    const indices: number[] = [];
    const lineNormal: number[][] = [];
    const lineMiter: number[][] = [];
    const lineDistance: number[][] = [];
    const lineAllDistance: number[][] = [];
    const geometry = new THREE.BufferGeometry();
    // 计算各个点的法向量
    const normalsByPolyline = getNormals(points);
    let indicesIdx = 0;
    let index = 0;
    let distance = 0;
    points.forEach((point, i, list) => {
      const idx = index;
      if (i !== points.length - 1) {
        // 添加索引以形成两个三角形
        indices[indicesIdx++] = idx + 0;
        indices[indicesIdx++] = idx + 1;
        indices[indicesIdx++] = idx + 2;
        indices[indicesIdx++] = idx + 2;
        indices[indicesIdx++] = idx + 1;
        indices[indicesIdx++] = idx + 3;
      }
      // 这里不用先计算,后面直接在shader里面借助GPU计算就行
      vertices.push(point);
      vertices.push(point);
    });
    normalsByPolyline.forEach((item: any) => {
      const norm = item[0];
      const miter = item[1];
      lineNormal.push([norm[0], norm[1]], [norm[0], norm[1]]);
      lineMiter.push([-miter], [miter]);
    });
    geometry.setAttribute(
      "position",
      new THREE.Float32BufferAttribute(vertices.flat(), 3)
    );
    geometry.setAttribute(
      "lineNormal",
      new THREE.Float32BufferAttribute(lineNormal.flat(), 2)
    );
    geometry.setAttribute(
      "lineMiter",
      new THREE.Float32BufferAttribute(lineMiter.flat(), 1)
    );
    geometry.setIndex(new THREE.Uint16BufferAttribute(indices, 1));
    return geometry;
  }
  
  draw(data: ILine) {
    const { color = "#ffffff", width, type, endColor } = data;
    let geometry;
    let shader;
    switch (type) {
      // 绘制实线
      case ELineType.Solid: {
        geometry = this.createGeometry(data);
        shader = getSolidLineShader({
          width: width ?? 0.01,
          color: color,
        });
        break;
      }
      // 绘制虚线
      case ELineType.Dash: {
        // ...
        break;
      }
      // 绘制渐变线
      case ELineType.Gradual: {
        // ...
        break;
      }
      default:
        break;
    }
    const plane = new THREE.Mesh(geometry, shader);
    plane.position.z = 0.01;
    this.scene.add(plane);
  }
}

编写实线 shader 如下:

export function getSolidLineShader(option: any = {}) {
  const material = new THREE.ShaderMaterial({
    uniforms: {
      thickness: { value: option.width ?? 0.1 },
      opacity: { value: option.opacity ?? 1.0 },
      diffuse: { value: new THREE.Color(option.color) },
    },
    vertexShader: `
      uniform float thickness;
      attribute float lineMiter;
      attribute vec2 lineNormal;
      void main() { 
        // 通过法线和宽度计算得出线段中点对应的两个顶点
        vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);
        gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0);
      }
    `,
    fragmentShader: `
      uniform vec3 diffuse;
      uniform float opacity;
      void main() {
        gl_FragColor = vec4(diffuse, opacity);
      }
    `,
  });
  material.side = THREE.BackSide;
  material.transparent = true;
  return material;
}

虚线

虚线和实线是同一个 BufferGeometry,只不过接口要加一些参数,然后要另写一个 shader,主要是实现虚线的逻辑不一样,需要额外定义属性 lineDistance表示顶点距起点的累积直线距离,其他逻辑都类似实线

稍微解释下这个计算逻辑:

  • 比如实线3m,虚线2m,长度4m(累积直线距离)的点明显在虚线区域,做个取模 4%(3+2)=4
  • 这个时候算出4大于实线长度,说明在虚线区域,就将这个地方的点设置为透明
  • 同理如果是在2的区域,2小于实线长度,说明在实线区域,就正常填色

增加lineDistance属性,参考代码如下:

// src/renderer/line.ts
// ...
// 新增一个needDistance参数,主要用于虚线和渐变线
createGeometry(data: ILine, needDistance: boolean = false) {
    const lineDistance: number[][] = [];
    points.forEach((point, i, list) => {
      // ...
      if (needDistance) {
        let d = 0;
        if (i > 0) {
          // 计算两点之间的直线距离
          d = getPointsDistance(
            [point[0], point[1]],
            [list[i - 1][0], list[i - 1][1]]
          );
        }
        distance += d;
        lineDistance.push([distance], [distance]);
      }
    });
    if (needDistance) {
      geometry.setAttribute(
        "lineDistance",
        new THREE.Float32BufferAttribute(lineDistance.flat(), 1)
      );
    } 
}

接口变化如下:

export interface ILine {
  points: number[];  // 点集
  color: string;
  width: number;
  type: ELineType; // 默认是实线
  dashConfig?: {
    // 实线长度
    solidLength?: number;
    // 虚线长度
    dashLength?: number;
  }
};

编写虚线shader如下:

 // 用于画单色虚线
export function getDashedLineShader(option: any = {}{
  const material = new THREE.ShaderMaterial({
    uniforms: {
      thickness: { value: option.width ?? 0.1 },
      opacity: { value: option.opacity ?? 1.0 },
      diffuse: { valuenew THREE.Color(option.color) },
      // 虚线部分的长度
      dashLength: { value: option?.dashInfo?.dashLength ?? 1.0 },
      // 实线部分的长度
      solidLength: { value: option?.dashInfo?.solidLength ?? 2.0 },
    },
    vertexShader`
      uniform float thickness;
      attribute float lineMiter;
      attribute vec2 lineNormal;
      attribute float lineDistance;
      varying float lineU;

      void main() { 
        // 累积距离
        lineU = lineDistance;
        vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);
        gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0);
      }
    `
,
    fragmentShader`
      varying float lineU;
      uniform vec3 diffuse;
      uniform float opacity;
      uniform float dashLength;
      uniform float solidLength;

      void main() {
        // 取模
        float lineUMod = mod(lineU, dashLength + solidLength); 
        // lineUMod>solidLength则返回0.0,说明在实线区域;否则返回1.0,说明在虚线区域
        float dash = 1.0 - step(solidLength, lineUMod);
        gl_FragColor = vec4(diffuse * vec3(dash), dash * opacity); 
      }
    `
,
  });
  material.transparent = true;
  material.side = THREE.BackSide;
  return material;
}

渲染管线的流程中,在顶点着色器后会有光栅化的阶段,这个阶段会做插值处理,在上述代码中可以看到:

  • varying float lineU; 在顶点着色器中被声明为 varying 变量,并被赋予了 lineDistance 的值
  • 当顶点着色器为三角形的每个顶点执行时,lineU 会被设置为对应顶点的 lineDistance 值
  • 在光栅化阶段,会对这些顶点之间的 lineU 值进行插值计算,以生成三角形内部每个片元的 lineU 值
  • 这些插值后的 lineU 值随后被传递给片元着色器

所以在片元着色器中,每个片元其实会有独立的 lineU,它是经过插值计算后得到的相对距离值,这个插值确保了从顶点着色器到片元着色器的平滑过渡,使得我们可以基于这个累积距离来实现虚线或者下面会提到的渐变线

ok胜利近在眼前,目前这个针对直线支持最好,曲线的话,得多一些点集数据才能确保曲线平滑过渡,否则可能要用到贝塞尔曲线平滑一下...(不过一般来说也不会让前端去做平滑吧至少我没碰到)

渐变线

其实有两种方式,一种是发出多个线段,每个线段带一种颜色,造成一种“假”的渐变效果,其实有时候也足够满足算法需求了,这种相对简单点;第二种是给一段线段,然后在起点和终点之间做线性渐变。这里看下第二种咋实现,主要差异也是在接口和 shader 里,需要做下颜色的线性插值,可以借助之前的 lineDistance 和线段总长度 lineAllDistance 的比例来插值。lineAllDistance 是新增的属性,暂时先给每个点都加上,或许有大佬有更好的办法也可以给点建议,这里就不贴代码了,可以参考源码 ~

https://github.com/GitHubJackson/autopilot/blob/v0.1.4/src/renderer/line.ts

接口变化如下:

export interface ILine {
  points: number[];  // 点集
  color: string; // 拿来做起点颜色吧
  width: number;
  type: ELineType; // 默认是实线
  endColor?: string; // 渐变色,作为终点颜色,color是起点颜色
};

编写shader如下:

 // 渐变色
export function getGradientLineShader(option: any = {}) {
  const material = new THREE.ShaderMaterial({
    uniforms: {
      thickness: { value: option.width ?? 0.1 },
      opacity: { value: option.opacity ?? 1.0 },
      diffuse: { value: new THREE.Color(option.color) },
      endColor: { value: new THREE.Color(option.endColor) },
    },
    vertexShader: `
      uniform float thickness;
      attribute vec2 lineNormal;
      attribute float lineMiter;
      attribute float lineDistance;
      attribute float lineAllDistance;
      varying float lineU;
      varying float lineAll;

      void main() { 
        lineU = lineDistance;
        lineAll = lineAllDistance;
        vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);
        gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0);
      }
    `,
    fragmentShader: `
      // 累积长度
      varying float lineU;
      varying float lineAll;
      uniform float opacity;
      uniform vec3 diffuse; 
      uniform vec3 endColor; 

      void main() {
        vec3 aColor = (1.0-lineU/lineAll)*(diffuse-endColor)+endColor;
        gl_FragColor =vec4(aColor, opacity);  
      }
    `,
  });
  material.transparent = true;
  material.side = THREE.DoubleSide;
  return material;
}

数据自己mock的,想要线段更平滑就整多点数据吧,我表示很懒 ~

最后

仓库

https://github.com/GitHubJackson/autopilot/tree/v0.1.4


作者:_lucas

链接:https://juejin.cn/post/7412813889690107967

本文已获得作者授权,如需转载请联系作者!




感谢您的阅读      

在看点赞 好文不断  

初识Threejs
初识 Three.js 的奇妙世界,走进三维空间,与小编一起拥抱前端技术,涉及WebGL、WebGPU、Threejs、Shader、GIS、VR/AR、数字孪生、3D图形学等。
 最新文章