3D人物关系图开发实战:Three.js实现自动旋转可视化图谱
效果

本文将带您使用Three.js实现一个带自动旋转功能的3D人物关系图谱,核心功能包括:
- 三维空间布局:人物节点环形排列
- 动态关系线:带箭头的红色连线和悬浮关系标签
- 交互控制:支持鼠标拖拽、缩放视角
- 自动旋转:场景持续缓慢旋转,增强视觉效果
- 自适应窗口:响应式布局适配不同屏幕
核心解析
场景初始化
html
// 创建基础Three.js场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf8f8f8);
// 透视相机配置
const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);
camera.position.set(0, 15, 30); // 初始视角
// 渲染器配置
const renderer = new THREE.WebGLRenderer({antialias: true});
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
自动旋转控制器
html
const controls = new OrbitControls(camera, renderer.domElement);
controls.autoRotate = true; // 启用自动旋转
controls.autoRotateSpeed = 1.0; // 旋转速度
controls.enableDamping = true; // 阻尼惯性效果
controls.dampingFactor = 0.05; // 阻尼系数
节点创建(带图片和标签)
html
// 加载角色立绘
const textureLoader = new THREE.TextureLoader();
textureLoader.load(node.img, (texture) => {
const sprite = new THREE.Sprite(material);
sprite.scale.set(baseWidth, baseHeight, 1); // 保持图片比例
});
// 创建信息标签
const canvas = document.createElement('canvas');
ctx.font = 'bold 24px "Microsoft YaHei"'; // 中文字体支持
ctx.fillText(node.name, 10, 30); // 绘制姓名
关系连线
html
const arrowHelper = new THREE.ArrowHelper(
direction,
sourcePos,
length,
0xDC143C, // 红色箭头
headLength,
headWidth
);
动画循环
html
function animate() {
requestAnimationFrame(animate);
controls.update(); // 持续更新控制器
renderer.render(scene, camera);
}
数据格式说明
创建无名小村.json文件:
bash
{
"nodes": [
{
"id": 1,
"name": "花四娘",
"img": "img/role1.png",
"description": "无名客栈的老板娘,有一手好厨艺,性格泼辣、刚柔并济。一个人苦苦经营无名客栈,据包打听说追她的人能排到无名小村村口,却不见有得她芳心的。"
},
{
"id": 2,
"name": "洪小七",
"img": "img/role1.png",
"description": "游荡在无名小村里的小乞丐,整日游手好闲,凭借着小偷小摸的本事,这才勉强过上饥一顿饱一顿的日子。"
},
{
"id": 3,
"name": "包打听",
"img": "img/role1.png",
"description": "无名小村里游手好闲的年轻人,平日里到处打听八卦,靠着打听到的小道消息和人换点小钱为生"
},
{
"id": 4,
"name": "白头翁",
"img": "img/role1.png",
"description": "年少时一心钻研学医,本想医术大成后救济天下人,奈何天赋不够,蹉跎半生仍旧只知皮毛,只能沦为乡里郎中,治一些风寒小病糊口。"
},
{
"id": 5,
"name": "王大锤",
"img": "img/role1.png",
"description": "无名小村的铁匠,力大如牛,有一手顶尖的打铁技巧。原先是琅琊剑阁大弟子,年轻时人称"玉面干将",但是现在完全看不出来。因为某些事情离开剑阁,来到无名小村隐姓埋名。"
},
{
"id": 6,
"name": "刘十八",
"img": "img/role1.png",
"description": "无名小村的猎户,打猎技术高超。昔日是守卫楚襄城的杨将军部下,杨家军解散后逃到无名小村躲避,从此隐姓埋名,以打猎为生。"
},
{
"id": 7,
"name": "采石匠",
"img": "img/role1.png",
"description": "村里的采石匠,挖矿为生。"
},
{
"id": 8,
"name": "屠户",
"img": "img/role1.png",
"description": "卖肉的屠户,白白胖胖,营养过剩。"
},
{
"id": 9,
"name": "小花",
"img": "img/role1.png",
"description": "樵夫女儿,喜欢猜字谜。"
},
{
"id": 10,
"name": "小白",
"img": "img/role1.png",
"description": "樵夫的儿子,喜欢打猎。"
},
{
"id": 11,
"name": "小丫",
"img": "img/role1.png",
"description": "刘十八女儿,喜欢玩捉迷藏。"
},
{
"id": 12,
"name": "樵夫",
"img": "img/role1.png",
"description": "村里的樵夫,以砍伐木材为生。"
},
{
"id": 13,
"name": "村长",
"img": "img/role1.png",
"description": "一村之长,老秀才,守护着无名小村的秘密。"
},
{
"id": 14,
"name": "小宝",
"img": "img/role1.png",
"description": "村长三代,梦想是成为大侠。"
},
{
"id": 15,
"name": "货郎",
"img": "img/role1.png",
"description": "走南闯北,贩卖各种物品的货郎。"
},
{
"id": 16,
"name": "燕歌行",
"img": "img/role1.png",
"description": "在无名小村非要拉着收徒的怪老头,原本以为只是个不正经的老头,真实身份是老魔头楚狂生的师弟、九流门的真正创建者之一。因被仇家暗算导致武功尽失,经脉尽毁。如今已经治愈旧伤、功力恢复,准备去完成自己的毕生心愿。"
}
],
"links": [
{
"source": 5,
"target": 6,
"relation": "不和"
},
{
"source": 6,
"target": 11,
"relation": "父亲"
},
{
"source": 6,
"target": 8,
"relation": "供货"
},
{
"source": 1,
"target": 5,
"relation": "债主"
},
{
"source": 3,
"target": 1,
"relation": "暗恋"
},
{
"source": 13,
"target": 14,
"relation": "爷爷"
},
{
"source": 12,
"target": 9,
"relation": "父亲"
},
{
"source": 4,
"target": 6,
"relation": "救治"
}
]
}
代码
html
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<title>3D人物关系图(自动旋转版)</title>
<style>
body { margin: 0; }
canvas { display: block; }
</style>
<!-- importmap 配置 -->
<script type="importmap">
{
"imports": {
"three": "./js/three.js/build/three.module.js",
"three/addons/": "./js/three.js/examples/jsm/"
}
}
</script>
</head>
<body>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
fetch('无名小村.json')
.then(response => response.json())
.then(data => {
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf8f8f8);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({antialias: true});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.NoToneMapping;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
// 控制器配置(新增自动旋转参数)
const controls = new OrbitControls(camera, renderer.domElement);
controls.autoRotate = true; // 启用自动旋转
controls.autoRotateSpeed = 1.0; // 旋转速度(默认1.0)
controls.enableDamping = true; // 启用阻尼惯性
controls.dampingFactor = 0.05; // 阻尼系数
controls.minDistance = 15; // 最小缩放距离
controls.maxDistance = 50; // 最大缩放距离
// 调整初始摄像机位置(新增斜视角)
camera.position.set(0, 15, 30); // x, y, z坐标
controls.update();
// 节点布局(简单环形)
const radius = 10;
const nodeMeshes = {};
data.nodes.forEach((node, i) => {
const angle = (i / data.nodes.length) * Math.PI * 2;
const x = radius * Math.cos(angle);
const y = radius * Math.sin(angle);
const z = (Math.random() - 0.5) * 5;
node.position = {x, y, z};
// 立绘图片节点
const textureLoader = new THREE.TextureLoader();
textureLoader.load(node.img, (texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
premultipliedAlpha: false,
blending: THREE.NormalBlending,
depthWrite: false,
depthTest: true,
sizeAttenuation: true,
color: 0xffffff
});
material.toneMapped = false;
const sprite = new THREE.Sprite(material);
sprite.position.set(x, y, z);
// --- 根据图片实际尺寸计算缩放比例 ---
const image = texture.image;
if (image) {
const aspectRatio = image.naturalWidth / image.naturalHeight;
// 定义一个基础高度(例如,所有立绘在场景中的基础高度为 4 个单位)
const baseHeight = 4;
// 根据宽高比计算宽度
const baseWidth = baseHeight * aspectRatio;
sprite.scale.set(baseWidth, baseHeight, 1);
} else {
// 如果图片尺寸信息获取失败,使用默认值
sprite.scale.set(3, 4, 1);
}
// --- 缩放计算结束 ---
scene.add(sprite);
nodeMeshes[node.id] = sprite;
});
// 节点标签
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 128;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(200,220,255,0.8)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 名称
ctx.font = 'bold 24px "Microsoft YaHei", "微软雅黑", sans-serif';
ctx.fillStyle = 'black';
ctx.fillText(node.name, 10, 30);
// 描述
ctx.font = 'bold 16px "Microsoft YaHei", "微软雅黑", sans-serif';
const description = node.description || "暂无人物介绍";
const maxWidth = 240;
const lineHeight = 20;
let textY = 60;
for (let i = 0; i < description.length; i += 20) {
const chunk = description.substr(i, 20);
ctx.fillText(chunk, 10, textY);
textY += lineHeight;
if (textY > canvas.height - 10) break;
}
const labelTexture = new THREE.CanvasTexture(canvas);
const labelMaterial = new THREE.SpriteMaterial({
map: labelTexture,
transparent: true,
depthTest: false,
depthWrite: false
});
const labelSprite = new THREE.Sprite(labelMaterial);
labelSprite.scale.set(5, 3, 1);
// 将 y + 3.5 修改为 y - 3.5 (或其他负值)
labelSprite.position.set(x, y - 3.5, z);
scene.add(labelSprite);
});
// 绘制连线和关系标签
data.links.forEach(link => {
const sourceNode = data.nodes.find(n => n.id === link.source);
const targetNode = data.nodes.find(n => n.id === link.target);
// 确保节点和位置存在
if (!sourceNode || !sourceNode.position || !targetNode || !targetNode.position) {
console.warn('Skipping link due to missing node or position:', link);
return;
}
const sourcePos = new THREE.Vector3(sourceNode.position.x, sourceNode.position.y, sourceNode.position.z);
const targetPos = new THREE.Vector3(targetNode.position.x, targetNode.position.y, targetNode.position.z);
// --- 绘制带箭头的连线 ---
const direction = new THREE.Vector3().subVectors(targetPos, sourcePos);
const length = direction.length(); // 获取向量长度,即连线长度
direction.normalize(); // 标准化方向向量
// 定义箭头参数
const arrowColor = 0xDC143C;
const headLength = 1; // 箭头头部长度,可调整
const headWidth = 0.5; // 箭头头部宽度,可调整
// 创建 ArrowHelper
const arrowHelper = new THREE.ArrowHelper(
direction, // 箭头方向(标准化向量)
sourcePos, // 箭头起点
length, // 箭头总长度(从起点到终点)
arrowColor, // 箭头颜色
headLength, // 箭头头部长度
headWidth // 箭头头部宽度
);
scene.add(arrowHelper);
// --- 箭头连线结束 ---
// --- 添加关系标签 ---
const relationText = link.relation || ''; // 获取关系文本,如果不存在则为空
if (relationText) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 增大字体大小
const fontSize = 24; // <--- 增大字体
context.font = `bold ${fontSize}px Arial`;
const textWidth = context.measureText(relationText).width;
// 根据文本内容调整Canvas大小,并增加更多边距
const padding = 20; // <--- 增加边距
canvas.width = textWidth + padding * 2;
canvas.height = fontSize + padding; // 上下边距可以少一点
// 重新设置字体和样式
context.font = `bold ${fontSize}px Arial`; // <--- 保持一致
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = 'white';
context.textAlign = 'center';
context.textBaseline = 'middle';
// 绘制文本位置也要相应调整
context.fillText(relationText, canvas.width / 2, canvas.height / 2);
const texture = new THREE.CanvasTexture(canvas);
// 可以尝试不同的过滤方式,但通常提高分辨率效果更好
// texture.minFilter = THREE.LinearFilter;
// texture.magFilter = THREE.LinearFilter; // 或者 THREE.NearestFilter 看效果
const spriteMaterial = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false,
sizeAttenuation: true // 确保 Sprite 大小随距离变化
});
const sprite = new THREE.Sprite(spriteMaterial);
// 计算标签位置(线段中点稍微偏移一点)
const midPoint = new THREE.Vector3().addVectors(sourcePos, targetPos).multiplyScalar(0.5);
midPoint.y += 0.5; // 稍微向上偏移
sprite.position.copy(midPoint);
// 可能需要重新调整 scaleFactor 以匹配新的 Canvas 尺寸和字体大小
const scaleFactor = 0.05; // <--- 可能需要减小 scaleFactor
sprite.scale.set(canvas.width * scaleFactor, canvas.height * scaleFactor, 1.0);
scene.add(sprite);
}
// --- 关系标签结束 ---
});
// 渲染循环(新增自动旋转逻辑)
function animate() {
requestAnimationFrame(animate);
controls.update(); // 必须调用才能启用自动旋转
renderer.render(scene, camera);
}
animate();
// 窗口大小自适应
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
});
</script>
</body>
</html>