
在使用 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 类,可以方便地实现带宽度的线,同时也能支持更多效果(如虚线、渐变色等)。