three.js实现3d球体树状结构布局——添加入场、出场、点击放大等动画
npm i three three-spritetext three.meshline
three-spritetext: 用来绘制文字。THREE.TextGeometry绘制文字存在模糊问题,而且转动camera时three-spritetext不需要手动处理让文字始终面向camera。
three.meshline: 用来绘制线。THREE.LineBasicMaterial绘制线存在linewidth无效问题。
下面是此次案例版本
"three": "^0.150.1"
"three-spritetext": "^1.8.0"
"three.meshline": "^1.4.0"
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import SpriteText from 'three-spritetext';
import { MeshLine, MeshLineMaterial } from 'three.meshline'
如题three.js实现3d球体树状结构布局是我们此次案例要实现的目标,那我们需要的实体有球、线、文本,以及场景、相机、渲染器、辅助线、灯光等实体。下面我们按照实体一步步来实现。
const createScene = () => {
return new THREE.Scene();
}
本例以透视投影相机PerspectiveCamera为例
const createPerspectiveCamera = ({ fov, aspect, near, far }) => {
// fov — 摄像机视锥体垂直视野角度
// aspect — 摄像机视锥体长宽比
// near — 摄像机视锥体近端面
// far — 摄像机视锥体远端面
return new THREE.PerspectiveCamera(fov, aspect, near, far);
}
const createWebGLRenderer = ({ dom, width, height }) => {
// renderDom — dom
// width — 渲染宽度 一般取domclientWidth
// height — 渲染高度 一般取clientHeight
if (width === undefined) {
width = dom.clientWidth;
}
if (height === undefined) {
height = dom.clientHeight;
}
const renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height);
dom.appendChild(renderer.domElement);
return renderer;
}
const createAxesHelper = (length) => {
return new THREE.AxesHelper(length)
}
const createAmbientLight = ({ color, intensity }) => {
// color - (可选参数)) 十六进制光照颜色。 缺省值 0xffffff (白色)。
// intensity - (可选参数) 光照强度。 缺省值 1。
return new THREE.AmbientLight(color, intensity);
}
// 环境光
const createPointLight = ({ color, intensity, distance, decay }) => {
// color - (可选参数)) 十六进制光照颜色。 缺省值 0xffffff (白色)。
// intensity - (可选参数) 光照强度。 缺省值 1。
// distance - 这个距离表示从光源到光照强度为0的位置。 当设置为0时,光永远不会消失(距离无穷大)。缺省值 0.
// decay - 沿着光照距离的衰退量。缺省值 2。
return new THREE.PointLight(color, intensity, distance, decay);
}
const createSphereGeometry = ({
radius,
widthSegments,
heightSegments,
phiStart,
phiLength,
thetaStart,
thetaLength
}) => {
/*
radius — 球体半径,默认为1。
widthSegments — 水平分段数(沿着经线分段),最小值为3,默认值为32。
heightSegments — 垂直分段数(沿着纬线分段),最小值为2,默认值为16。
phiStart — 指定水平(经线)起始角度,默认值为0。。
phiLength — 指定水平(经线)扫描角度的大小,默认值为 Math.PI * 2。
thetaStart — 指定垂直(纬线)起始角度,默认值为0。
thetaLength — 指定垂直(纬线)扫描角度大小,默认值为 Math.PI。
*/
return new THREE.SphereGeometry(
radius,
widthSegments,
heightSegments,
phiStart,
phiLength,
thetaStart,
thetaLength
);
}
当然也可使用其他材质,本例使用MeshLambertMaterial
const createMeshLambertMaterial = (data) => {
return new THREE.MeshLambertMaterial(data);
}
const createLineGeometry = (points) => {
const pointsVector3 = [];
for (let i = 0; i < points.length; i++) {
pointsVector3.push(new THREE.Vector3(points[i].x, points[i].y, points[i].z));
}
const geometry = new THREE.BufferGeometry().setFromPoints(pointsVector3);
const line = new MeshLine();
line.setGeometry(geometry);
return line
}
const createMeshLineMaterial = (data) => {
return new MeshLineMaterial({
lineWidth: data.linewidth,
color: data.color || "white",
dashArray: data.dashArray || 0,
transparent: true,
})
}
const createMesh = (geometry, materialBasic) => {
return new THREE.Mesh(geometry, materialBasic);
}
const createText = ({text, size, color}) => {
let textClass = new SpriteText(text, size);
textClass.color = color;
return textClass
}
const createControl = (camera, dom) => {
return new OrbitControls(camera, dom);
}
本案例以vue3编写
<script setup>
import { onMounted, ref, onBeforeUnmount, computed, reactive } from "vue";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import SpriteText from "three-spritetext";
import { MeshLine, MeshLineMaterial } from "three.meshline";
// 渲染dom
let treeDom = ref(null);
// 场景
const createScene = () => {
return new THREE.Scene();
};
// 相机(透视投影相机)
const createPerspectiveCamera = ({ fov, aspect, near, far }) => {
/*
fov — 摄像机视锥体垂直视野角度
aspect — 摄像机视锥体长宽比
near — 摄像机视锥体近端面
far — 摄像机视锥体远端面
*/
return new THREE.PerspectiveCamera(fov, aspect, near, far);
};
// 渲染器
const createWebGLRenderer = ({ dom, width, height }) => {
/*
renderDom — dom
width — 渲染宽度 一般取domclientWidth
height — 渲染高度 一般取clientHeight
*/
if (width === undefined) {
width = dom.clientWidth;
}
if (height === undefined) {
height = dom.clientHeight;
}
const renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height);
dom.appendChild(renderer.domElement);
return renderer;
};
// 辅助线
const createAxesHelper = (length) => {
return new THREE.AxesHelper(length);
};
// 环境光
const createAmbientLight = ({ color, intensity }) => {
// color - (可选参数)) 十六进制光照颜色。 缺省值 0xffffff (白色)。
// intensity - (可选参数) 光照强度。 缺省值 1。
return new THREE.AmbientLight(color, intensity);
};
// 点光
const createPointLight = ({ color, intensity, distance, decay }) => {
/*
color - (可选参数)) 十六进制光照颜色。 缺省值 0xffffff (白色)。
intensity - (可选参数) 光照强度。 缺省值 1。
distance - 这个距离表示从光源到光照强度为0的位置。 当设置为0时,光永远不会消失(距离无穷大)。缺省值 0.
decay - 沿着光照距离的衰退量。缺省值 2。
*/
return new THREE.PointLight(color, intensity, distance, decay);
};
// 球形几何体
const createSphereGeometry = ({
radius,
widthSegments,
heightSegments,
phiStart,
phiLength,
thetaStart,
thetaLength,
}) => {
/*
radius — 球体半径,默认为1。
widthSegments — 水平分段数(沿着经线分段),最小值为3,默认值为32。
heightSegments — 垂直分段数(沿着纬线分段),最小值为2,默认值为16。
phiStart — 指定水平(经线)起始角度,默认值为0。。
phiLength — 指定水平(经线)扫描角度的大小,默认值为 Math.PI * 2。
thetaStart — 指定垂直(纬线)起始角度,默认值为0。
thetaLength — 指定垂直(纬线)扫描角度大小,默认值为 Math.PI。
*/
return new THREE.SphereGeometry(
radius,
widthSegments,
heightSegments,
phiStart,
phiLength,
thetaStart,
thetaLength
);
};
// 球形几何体的材质
const createMeshLambertMaterial = (data) => {
return new THREE.MeshLambertMaterial(data);
};
// 线几何体
const createLineGeometry = (points) => {
const pointsVector3 = [];
for (let i = 0; i < points.length; i++) {
pointsVector3.push(
new THREE.Vector3(points[i].x, points[i].y, points[i].z)
);
}
const geometry = new THREE.BufferGeometry().setFromPoints(pointsVector3);
const line = new MeshLine();
line.setGeometry(geometry);
return line;
};
// 线几何体的材质
const createMeshLineMaterial = (data) => {
return new MeshLineMaterial({
lineWidth: data.linewidth,
color: data.color || "white",
dashArray: data.dashArray || 0,
transparent: true,
});
};
// 物体
const createMesh = (geometry, materialBasic) => {
return new THREE.Mesh(geometry, materialBasic);
};
// 文本
const createText = ({ text, size, color }) => {
let textClass = new SpriteText(text, size);
textClass.color = color;
return textClass;
};
// 轨道控制
const createControl = (camera, dom) => {
return new OrbitControls(camera, dom);
};
// 3d树布局算法 灵感来源 梯形/三角形转换为圆锥
const computedDataWeight = (data) => {
let weight = 0;
for (let i = 0; i < data.length; i++) {
let item = data[i];
if (item?.children?.length) {
item.weight = computedDataWeight(item.children);
weight += item.weight;
} else {
item.weight = 1;
weight += 1;
}
}
return weight;
};
const computedArcRArr = (data, pointInterval) => {
let ArcRArr = [];
let ArcData = [];
let ArcWeight = [];
formatTreeToArcData(data, ArcData);
for (let i = 0; i < ArcData.length; i++) {
let item = ArcData[i];
let weight = 0;
for (let j = 0; j < item.length; j++) {
weight += item[j].weight;
}
ArcWeight.push(weight);
}
let R = computedArcR(pointInterval, ArcWeight[0]);
// 半径算法
for (let i = 0; i < ArcData.length; i++) {
let item = ArcData[i];
if (ArcWeight[i] < ArcWeight[0]) {
// 不是完全层
ArcRArr.push(R);
} else {
if (item.length > 1) {
// 完全层
let DValue = 0;
item.forEach((weight) => {
DValue += Math.floor(weight.weight / 2);
});
ArcRArr.push(((ArcWeight[i] - DValue) / ArcWeight[i]) * R);
} else {
ArcRArr.push(0);
}
}
}
return { ArcRArr, R };
};
const formatTreeToArcData = (data, ArcData, deep = 0) => {
data.forEach((element) => {
if (!ArcData[deep]) {
ArcData[deep] = [];
}
ArcData[deep].push({
label: element.label,
point_uuid: element.point_uuid,
weight: element.weight,
});
if (element?.children?.length) {
formatTreeToArcData(element?.children, ArcData, deep + 1);
}
});
};
const computedArcR = (pointInterval, points) => {
if (points === 1) {
return pointInterval * 2;
}
let arcR =
pointInterval /
2 /
Math.cos((Math.PI / 180) * (((points - 2) * 180) / points / 2));
if (arcR < pointInterval) {
arcR = pointInterval * 2;
}
return arcR;
};
const computedTreeStyleAuto = (style, ArcRArr, R) => {
if (style.yr === "auto") {
style.yr = ArcRArr.length === 1 ? R : R / (ArcRArr.length - 1);
}
style.startPositionY =
((ArcRArr.length - 1) / 2) * style.yr + style.centerXYZ[1];
};
const computedPointPosition = (
data,
style,
ArcRArr,
startAngle = 0,
endAngle = Math.PI * 2,
deep = 0
) => {
let totalWight = 0;
for (let i = 0; i < data.length; i++) {
totalWight += data[i].weight;
}
let AngleScope = endAngle - startAngle;
let curAngle = startAngle;
for (let i = 0; i < data.length; i++) {
let item = data[i];
let ratioAngle = (item.weight / totalWight) * AngleScope;
item.position = {
x:
Math.sin(curAngle + ratioAngle / 2) * ArcRArr[deep] +
style.centerXYZ[0],
y:
style.startPositionY -
deep * (style.yr || 0) +
style.centerXYZ[1],
z:
Math.cos(curAngle + ratioAngle / 2) * ArcRArr[deep] +
style.centerXYZ[2],
};
if (item?.children?.length) {
computedPointPosition(
item?.children,
style,
ArcRArr,
curAngle,
curAngle + ratioAngle,
deep + 1
);
}
curAngle += ratioAngle;
}
};
// 计算camera初始位置
const computedCameraStyle = (style, dom, treeStyle, R) => {
if (style.position === "auto") {
style.position = {
x: 0,
y: treeStyle.yr * 1.5,
z: R * 3,
};
}
if (style.data === "auto") {
style.data = {
fov: 45,
aspect: dom.clientWidth / dom.clientHeight,
near: 0.1,
far: R * R,
};
}
if (style.lookAt === "auto") {
style.lookAt = JSON.parse(JSON.stringify(treeStyle.centerXYZ));
}
};
const treeStyle = {
centerXYZ: [0, 0, 0],
yr: "auto",
pointInterval: 10,
};
const cameraStyle = {
position: "auto",
data: "auto",
lookAt: "auto",
};
const sphereMeshStyle = {
geometry: {
radius: 1,
widthSegments: 320,
heightSegments: 160,
},
material: {
color: "#ffffff",
wireframe: false, //是否将几何体渲染为线框,默认值为false(即渲染为平面多边形)
},
};
const lineMeshStyle = {
material: {
color: "#ffffff",
linewidth: 0.2,
},
};
const textMeshStyle = {
material: {
size: 0.5,
color: "#ffffff",
},
};
let scene = null;
let camera = null;
let renderer = null;
const init = (rendererDom) => {
let data = [
{
name: "顶点",
children: [
{
name: "0",
children: [
{
name: "0-0",
children: [
{
name: "0-0-0",
},
{
name: "0-0-1",
children: [
{
name: "0-0-1-0",
},
],
},
{
name: "0-0-2",
},
],
},
{
name: "0-1",
},
{
name: "0-2",
children: [
{
name: "0-2-0",
},
{
name: "0-2-1",
},
],
},
],
},
{
name: "1",
children: [
{
name: "1-0",
},
{
name: "1-1",
},
{
name: "1-2",
children: [
{
name: "1-2-0",
},
{
name: "1-2-1",
},
],
},
],
},
{
name: "2",
children: [
{
name: "2-0",
children: [
{
name: "2-0-0",
},
{
name: "2-0-1",
},
{
name: "2-0-2",
},
],
},
{
name: "2-1",
children: [
{
name: "2-1-0",
},
{
name: "2-1-1",
},
],
},
{
name: "2-2",
},
],
},
]
}
];
computedDataWeight(data);
let { ArcRArr, R } = computedArcRArr(data, treeStyle.pointInterval);
computedTreeStyleAuto(treeStyle, ArcRArr, R);
computedPointPosition(data, treeStyle, ArcRArr);
computedCameraStyle(cameraStyle, rendererDom, treeStyle, R);
scene = createScene();
camera = createPerspectiveCamera(cameraStyle.data);
camera.position.set(
cameraStyle.position.x,
cameraStyle.position.y,
cameraStyle.position.z
);
camera.lookAt(
cameraStyle.lookAt[0],
cameraStyle.lookAt[1],
cameraStyle.lookAt[2]
);
renderer = createWebGLRenderer({
dom: rendererDom,
});
createControl(camera, rendererDom);
const axes = createAxesHelper(R);
scene.add(axes);
const ambientLight = createAmbientLight({ color: "#fff", intensity: 0.2 });
scene.add(ambientLight);
const pointLight = createPointLight({ color: "#fff", intensity: 1 });
pointLight.position.set(R * 10, R * 10, R * 10);
scene.add(pointLight);
const sphereGeometrys = [];
const textGeometrys = [];
const lineGeometrys = [];
initGeometrys(data, sphereGeometrys, textGeometrys, lineGeometrys);
scene.add(...sphereGeometrys);
scene.add(...textGeometrys);
scene.add(...lineGeometrys);
render();
};
const initGeometrys = (data, sphereGeometrys, textGeometrys, lineGeometrys, parentPosition) => {
for (let i = 0; i < data.length; i++) {
let item = data[i];
const geometry = createSphereGeometry(sphereMeshStyle.geometry);
const material = createMeshLambertMaterial(sphereMeshStyle.material);
const mesh = createMesh(geometry, material);
mesh.position.set(item.position.x, item.position.y, item.position.z);
sphereGeometrys.push(mesh);
const text = createText({
text: item.name,
size: textMeshStyle.material.size,
color: textMeshStyle.material.color,
});
text.position.x = item.position.x;
text.position.y = item.position.y + sphereMeshStyle.geometry.radius * 2;
text.position.z = item.position.z;
textGeometrys.push(text);
if (parentPosition) {
const lineGeometry = createLineGeometry([
parentPosition,
{ x: item.position.x, y: item.position.y, z: item.position.z },
]);
const lineMaterial = createMeshLineMaterial(lineMeshStyle.material);
const lineMesh = createMesh(lineGeometry, lineMaterial);
lineGeometrys.push(lineMesh);
}
if (item?.children?.length) {
initGeometrys(
item.children,
sphereGeometrys,
textGeometrys,
lineGeometrys,
{ x: item.position.x, y: item.position.y, z: item.position.z }
);
}
}
};
// 渲染
const render = () => {
//循环调用
requestAnimationFrame(render);
renderer.render(scene, camera);
};
onMounted(() => {
let rendererDom = treeDom.value;
init(rendererDom);
});
</script>
<template>
<div class="tree-new-page">
<div class="tree-new" ref="treeDom"></div>
</div>
</template>
<style scoped lang="scss">
.tree-new-page {
width: 100%;
height: 100%;
overflow: hidden;
background-color: #000;
position: relative;
.tree-new {
width: 100%;
height: 100%;
}
}
</style>
three.js官方文档
three-spritetext文档
three.meshline文档