Three.js 中实现带宽度的线

在使用 Three.js 进行 3D 场景开发时,常常会遇到绘制具有一定宽度线条的需求。然而,Three.js 中默认使用的 THREE.Line、THREE.LineSegments 等类是基于 WebGL 原生的 gl.LINE_STRIP 或 gl.LINES 进行绘制的。受 WebGL 的限制,无论设置多大的 linewidth,最终呈现出来的线宽往往固定为 1 像素。下面将详细介绍解决这一问题的两种常用方法。

为了解决这个问题,目前常用的方法主要有两种:

基于 CPU 的几何计算

这种方式的核心思想是将一条线段向两端进行拓宽。具体步骤如下:

  • 将一条线段向两端进行拓宽,也就是通过计算每个顶点的法向量来生成两份偏移的顶点数据。
  • 然后用这些顶点构造一个面(Mesh),通过自定义着色器或者常规材质渲染出来,就可以模拟出任意宽度的线条。

优点是能够灵活处理拐点和端点的连接,效果较好。

缺点是需要额外的 CPU 运算,并且每次缩放或更新时都可能需要重新计算顶点数据,对性能有一定影响。

js 复制代码
<template>
  <div ref="container" class="three-container"></div>
</template>

<script setup>
defineOptions({
  name: 'Line2',
});

import { ref, onMounted, onUnmounted } from 'vue';
import * as THREE from 'three';

const container = ref(null);

// 初始化变量
let scene, camera, renderer, mesh;

function initThree() {
  // 创建场景
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0xeeeeee);

  // 创建相机
  camera = new THREE.OrthographicCamera(
    window.innerWidth / -2,
    window.innerWidth / 2,
    window.innerHeight / 2,
    window.innerHeight / -2,
    1,
    1000,
  );
  camera.position.z = 5;

  // 创建渲染器
  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);

  // 添加到DOM
  container.value.appendChild(renderer.domElement);

  // 折线坐标
  const points = [
    new THREE.Vector3(-200, -50, 0),
    new THREE.Vector3(-100, 50, 0),
    new THREE.Vector3(0, -50, 0),
    new THREE.Vector3(100, 50, 0),
    new THREE.Vector3(200, -50, 0),
  ];

  // 线宽参数
  const lineWidth = 30;

  // 生成挤压线段的几何体
  const geometry = createExtrudedLineGeometry(points, lineWidth);

  // 材质
  const material = new THREE.ShaderMaterial({
    vertexShader: `
      varying vec2 vUv;
      
      void main() {
        vUv = uv;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }
    `,
    fragmentShader: `
      varying vec2 vUv;
      
      void main() {
        gl_FragColor = vec4(1.0, 0.5, 0.0, 1.0);
      }
    `,
    side: THREE.DoubleSide,
  });

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

  const geometry1 = new THREE.BufferGeometry().setFromPoints(points);
  const material1 = new THREE.LineBasicMaterial({ color: 0x00ff00 });
  const line1 = new THREE.Line(geometry1, material1);
  scene.add(line1);

  // 添加线框网格显示
  const wireframeMaterial = new THREE.MeshBasicMaterial({
    color: 0x000000,
    wireframe: true,
    transparent: true,
    opacity: 0.3,
  });
  const wireframe = new THREE.Mesh(geometry, wireframeMaterial);
  scene.add(wireframe);
}

// 创建挤压线段几何体的函数
function createExtrudedLineGeometry(points, lineWidth) {
  if (points.length < 2) return new THREE.BufferGeometry();

  const positions = [];
  const indices = [];
  const halfThick = lineWidth / 2;

  // 为每个点计算挤压方向
  const extrudeDirections = [];

  // 首先计算每个线段的法线方向
  const segmentNormals = [];
  for (let i = 0; i < points.length - 1; i++) {
    const p1 = points[i];
    const p2 = points[i + 1];

    // 计算线段方向
    const direction = new THREE.Vector3().subVectors(p2, p1).normalize();

    // 计算法线 (垂直于线段方向)
    const normal = new THREE.Vector3(-direction.y, direction.x, 0).normalize();
    segmentNormals.push(normal);
  }

  // 计算每个点的挤压方向
  for (let i = 0; i < points.length; i++) {
    let extrudeDir;

    if (i === 0) {
      // 起始点使用第一个线段的法线
      extrudeDir = segmentNormals[0].clone().multiplyScalar(halfThick);
    } else if (i === points.length - 1) {
      // 结束点使用最后一个线段的法线
      extrudeDir = segmentNormals[segmentNormals.length - 1].clone().multiplyScalar(halfThick);
    } else {
      // 中间点 - 计算相邻两个线段的挤压方向
      const v1 = new THREE.Vector3().subVectors(points[i], points[i - 1]).normalize();
      const v2 = new THREE.Vector3().subVectors(points[i], points[i + 1]).normalize();

      // 计算合并方向
      extrudeDir = new THREE.Vector3().addVectors(v1, v2).normalize();

      // 计算法线方向
      const norm = new THREE.Vector3(-v1.y, v1.x, 0).normalize();

      // 计算缩放因子 - 基于法线和挤压方向的点积
      const cos = norm.dot(extrudeDir);

      // 避免除以零或接近零的值
      if (Math.abs(cos) > 0.01) {
        // 调整挤压长度
        extrudeDir.multiplyScalar(halfThick / cos);
      } else {
        // 如果角度接近90度,使用默认挤压
        extrudeDir = norm.clone().multiplyScalar(halfThick);
      }
    }

    extrudeDirections.push(extrudeDir);
  }

  // 创建线段的顶点
  for (let i = 0; i < points.length; i++) {
    const point = points[i];
    const extrudeDir = extrudeDirections[i];

    // 外侧顶点
    const outerPoint = new THREE.Vector3().addVectors(point, extrudeDir);
    positions.push(outerPoint.x, outerPoint.y, outerPoint.z);

    // 内侧顶点
    const innerPoint = new THREE.Vector3().subVectors(point, extrudeDir);
    positions.push(innerPoint.x, innerPoint.y, innerPoint.z);
  }

  // 创建三角形索引
  for (let i = 0; i < points.length - 1; i++) {
    const base = i * 2;

    // 两个三角形组成一个四边形段
    indices.push(base, base + 1, base + 2);
    indices.push(base + 1, base + 3, base + 2);
  }

  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  geometry.setIndex(indices);

  return geometry;
}

function animate() {
  requestAnimationFrame(animate);

  renderer.render(scene, camera);
}

// 处理窗口大小变化
function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
}

// 初始化
onMounted(() => {
  initThree();
  animate();
  window.addEventListener('resize', onWindowResize);
});

// 清理
onUnmounted(() => {
  window.removeEventListener('resize', onWindowResize);
  container.value.removeChild(renderer.domElement);
  scene.dispose();
  renderer.dispose();
});
</script>

<style scoped>
.three-container {
  width: 100%;
  height: calc(100vh - 50px);
  margin: 0;
  overflow: hidden;
}
</style>

上面采用向两侧挤压的方式实现,具体的实现思路如下:

  • 计算每个线段的法线。
  • 计算每个点的挤压向量,每个方向上的挤压向量的长度可能不同,折线连接处挤压向量需要使用三角函数计算。
  • 根据挤压向量来处理每个顶点,将原来的一个顶点,按挤压向量和挤压向量的反方向外偏移,得到两个点。
  • 将新生成的点渲染成面就得到了带宽度的线。

效果如下:

基于 GPU 着色器的方法

Three.js 官方提供了一种更高效的方式,即使用扩展包中提供的类来绘制宽线。

目前推荐的方案是使用:

  • LineGeometry:用来存储线段的顶点数据(支持使用一维数组设置顶点坐标)。
  • LineMaterial:支持设置 linewidth 属性,可以控制线条宽度。
  • Line2:这是一个基于上述几类的对象,能够利用自定义着色器在 GPU 上绘制出具有宽度的线条。

这种方式不仅能绘制实线,还可以实现虚线、颜色渐变等效果,并且利用 GPU 渲染,性能较好。

下面是一个简单示例代码:

js 复制代码
import * as THREE from 'three';
import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js';
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js';
import { Line2 } from 'three/examples/jsm/lines/Line2.js';

// 定义顶点数据(这里是一个简单的两点直线)
const positions = [0, 0, 0, 10, 0, 0];

// 创建 LineGeometry 并设置顶点数据
const geometry = new LineGeometry();
geometry.setPositions(positions);

// 创建 LineMaterial 并设置颜色、宽度
const material = new LineMaterial({
  color: 0xff0000,
  linewidth: 2, // 宽度单位为像素
  dashed: false,
  transparent: true,
});

// 设置材质的分辨率(必须设置,否则宽度计算不正确)
material.resolution.set(window.innerWidth, window.innerHeight);

// 用 LineGeometry 和 LineMaterial 创建 Line2 对象
const line = new Line2(geometry, material);
line.computeLineDistances(); // 如果使用虚线,此步骤必不可少

// 添加到场景中
scene.add(line);

在上述代码中,Line2 及其相关类解决了常规 THREE.Line 不支持宽线的问题,通过 GPU 着色器实现了高效的宽线渲染。这种方法在实际应用中非常流行,而且有不少开源的扩展库(如 MeshLine)也采用类似原理来实现更复杂的线条效果。

如果上面的方法不能满足我们的需求,如果需求自定义实现。

js 复制代码
<template>
  <div ref="container1" class="three-container"></div>
</template>

<script setup>
defineOptions({
  name: 'Line2',
});

import { ref, onMounted, onUnmounted } from 'vue';
import * as THREE from 'three';

const container1 = ref(null);

// 初始化变量
let scene, camera, renderer, mesh, controls;

function initThree() {
  // 创建场景
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0xeeeeee);

  // 创建相机
  camera = new THREE.OrthographicCamera(
    window.innerWidth / -2,
    window.innerWidth / 2,
    window.innerHeight / 2,
    window.innerHeight / -2,
    1,
    1000,
  );
  camera.position.z = 5;

  // 创建渲染器
  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);

  // 添加到DOM
  container1.value.appendChild(renderer.domElement);

  // 折线
  const points = [
    new THREE.Vector3(-200, -50, 0),
    new THREE.Vector3(-100, 50, 0),
    new THREE.Vector3(0, -50, 0),
    new THREE.Vector3(100, 50, 0),
    new THREE.Vector3(200, -50, 0),
  ];

  // 线宽参数
  const lineWidth = 30;

  // 生成线段几何体
  const geometry = createLineGeometry(points);

  // 着色器材质
  const material = new THREE.ShaderMaterial({
    uniforms: {
      lineWidth: { value: lineWidth },
    },
    vertexShader: `
      attribute vec3 center;
      attribute vec3 previous;
      attribute vec3 next;
      attribute float side;
      
      uniform float lineWidth;
      
      varying vec2 vUv;
      
      vec3 calculateNormal(vec3 dir) {
        return normalize(vec3(-dir.y, dir.x, 0.0));
      }
      
      void main() {
        vec3 prevDir = normalize(center - previous);
        vec3 nextDir = normalize(next - center);
        
        // 计算法线方向
        vec3 normal;
        
        // 特殊情况处理:起点和终点
        if(distance(previous, center) < 0.0001) {
          // 起点 - 使用下一段的法线
          normal = calculateNormal(nextDir);
        } else if(distance(next, center) < 0.0001) {
          // 终点 - 使用前一段的法线
          normal = calculateNormal(prevDir);
        } else {
          // 计算前后段的法线
          vec3 prevNormal = calculateNormal(prevDir);
          vec3 nextNormal = calculateNormal(nextDir);
          
          // 计算角平分线方向
          vec3 bisector = normalize(prevDir + nextDir);
          
          // 计算垂直于角平分线的法线
          normal = calculateNormal(bisector);
          
          // 计算夹角的一半
          float cosTheta = dot(prevDir, nextDir);
          float angle = acos(clamp(cosTheta, -1.0f, 1.0f));
          float halfAngle = angle / 2.0f;
          
          // 避免除以零
          if(halfAngle > 0.001) {
            // 根据夹角调整法线长度
            normal = normal / sin(halfAngle);
          }
        }
        
        // 标准化法线并应用线宽
        normal = normal * lineWidth * 0.5;
        
        // 应用偏移 - 根据side属性确定方向
        vec3 offset = normal * side;
        vec3 extrudedPosition = center + offset;
        
        // 设置UV坐标
        vUv = vec2(0.5 + side * 0.5, 0.0);
        
        // 输出最终位置
        gl_Position = projectionMatrix * modelViewMatrix * vec4(extrudedPosition, 1.0);
      }
    `,
    fragmentShader: `
      varying vec2 vUv;
      
      void main() {
        gl_FragColor = vec4(1.0, 0.5, 0.0, 1.0);
      }
    `,
    side: THREE.DoubleSide,
  });

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

  // 添加线框网格显示
  const wireframeMaterial = new THREE.MeshBasicMaterial({
    color: 0x000000,
    wireframe: true,
    transparent: true,
    opacity: 0.3,
  });
  const wireframe = new THREE.Mesh(geometry, wireframeMaterial);
  scene.add(wireframe);
}

// 创建线段几何体的函数 - 将计算移至顶点着色器
function createLineGeometry(points) {
  if (points.length < 2) return new THREE.BufferGeometry();

  const positions = [];
  const centers = [];
  const prevPoints = [];
  const nextPoints = [];
  const sides = [];
  const indices = [];

  // 为每个点创建两个顶点(线的两侧)
  for (let i = 0; i < points.length; i++) {
    const point = points[i];

    // 确定前一个点和后一个点
    // 对于起点和终点,创建虚拟点以确保方向正确
    let prev, next;

    if (i === 0) {
      // 起点 - 创建一个虚拟的前一个点,保持方向一致
      const dir = new THREE.Vector3().subVectors(points[1], point).normalize();
      prev = new THREE.Vector3().copy(point).sub(dir);
    } else {
      prev = points[i - 1];
    }

    if (i === points.length - 1) {
      // 终点 - 创建一个虚拟的后一个点,保持方向一致
      const dir = new THREE.Vector3().subVectors(point, points[i - 1]).normalize();
      next = new THREE.Vector3().copy(point).add(dir);
    } else {
      next = points[i + 1];
    }

    // 左侧顶点
    positions.push(point.x, point.y, point.z);
    centers.push(point.x, point.y, point.z);
    prevPoints.push(prev.x, prev.y, prev.z);
    nextPoints.push(next.x, next.y, next.z);
    sides.push(-1);

    // 右侧顶点
    positions.push(point.x, point.y, point.z);
    centers.push(point.x, point.y, point.z);
    prevPoints.push(prev.x, prev.y, prev.z);
    nextPoints.push(next.x, next.y, next.z);
    sides.push(1);
  }

  // 创建三角形索引
  for (let i = 0; i < points.length - 1; i++) {
    const base = i * 2;
    // 两个三角形组成一个四边形段
    indices.push(base, base + 1, base + 2);
    indices.push(base + 1, base + 3, base + 2);
  }

  const geometry = new THREE.BufferGeometry();

  // 设置几何体属性
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
  geometry.setAttribute('center', new THREE.Float32BufferAttribute(centers, 3));
  geometry.setAttribute('previous', new THREE.Float32BufferAttribute(prevPoints, 3));
  geometry.setAttribute('next', new THREE.Float32BufferAttribute(nextPoints, 3));
  geometry.setAttribute('side', new THREE.Float32BufferAttribute(sides, 1));

  geometry.setIndex(indices);

  return geometry;
}

function animate() {
  requestAnimationFrame(animate);

  renderer.render(scene, camera);
}

// 处理窗口大小变化
function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
}

// 初始化
onMounted(() => {
  initThree();
  animate();
  window.addEventListener('resize', onWindowResize);
});

// 清理
onUnmounted(() => {
  window.removeEventListener('resize', onWindowResize);
  container1.value.removeChild(renderer.domElement);
  scene.dispose();
  renderer.dispose();
});
</script>

<style scoped>
.three-container {
  width: 100%;
  height: calc(100vh - 50px);
  margin: 0;
  overflow: hidden;
}
</style>

实现的思路如下:

  • 和上面 cup 中实现思路基本类似,也是基于法向量向外扩充实现的,着色器中计算顶点的法向量,至少需要两个点生成一个向量,如果计算折线处的法向量,就需要三个点生成两个向量,再生成夹角处的向量。但一条线的首尾点中是没有前一个点或后一个点的,这个时候就需要在 cpu 中生成一个虚拟点。
  • 而着色器只能处理一个点,线上同一个点要传两次,并使用 side 标识,分别向两个方向偏移。
  • 计算偏移量的方法和上面 cpu 中计算的方法类似

效果如下:

总结

以上只是简单的实现思路,现实场景中带宽度线的实现会更加复杂。比如折线的角度太小时,用上面的方法在折角处会非常奇怪,就需要在折线处多加点来实现。在现实场景中,端点处和折角处可能需要圆角的效果,就需要特殊处理。

对上面的内容总结一下。

  • 原生限制:由于 WebGL 的底层限制,传统的线条对象(THREE.Line 等) linewidth 属性无效,始终为 1 像素。
  • 两种思路:可以通过 CPU 计算几何面来模拟宽线,或采用 GPU 着色器技术(如 Line2 系列)来实现。
  • 推荐方案:利用 Three.js 提供的 LineGeometry、LineMaterial 和 Line2 类,可以方便地实现带宽度的线,同时也能支持更多效果(如虚线、渐变色等)。
相关推荐
程序员的世界你不懂3 分钟前
(9)-Fiddler抓包-Fiddler如何设置捕获Https会话
前端·https·fiddler
MoFe18 分钟前
【.net core】天地图坐标转换为高德地图坐标(WGS84 坐标转 GCJ02 坐标)
java·前端·.netcore
去旅行、在路上38 分钟前
chrome使用手机调试触屏web
前端·chrome
Aphasia3111 小时前
模式验证库——zod
前端·react.js
lexiangqicheng2 小时前
es6+和css3新增的特性有哪些
前端·es6·css3
拉不动的猪2 小时前
都25年啦,还有谁分不清双向绑定原理,响应式原理、v-model实现原理
前端·javascript·vue.js
烛阴3 小时前
Python枚举类Enum超详细入门与进阶全攻略
前端·python
孟孟~3 小时前
npm run dev 报错:Error: error:0308010C:digital envelope routines::unsupported
前端·npm·node.js
孟孟~3 小时前
npm install 报错:npm error: ...node_modules\deasync npm error command failed
前端·npm·node.js
狂炫一碗大米饭3 小时前
一文打通TypeScript 泛型
前端·javascript·typescript