3D人物关系图开发实战:Three.js实现自动旋转可视化图谱(附完整代码)
3D人物关系图开发实战:Three.js实现自动旋转可视化图谱
- 效果
- 核心解析
- 场景初始化
- 自动旋转控制器
- 节点创建(带图片和标签)
- 关系连线
- 动画循环
- 数据格式说明
- 代码
效果
本文将带您使用Three.js实现一个带自动旋转功能的3D人物关系图谱,核心功能包括:
- 三维空间布局:人物节点环形排列
- 动态关系线:带箭头的红色连线和悬浮关系标签
- 交互控制:支持鼠标拖拽、缩放视角
- 自动旋转:场景持续缓慢旋转,增强视觉效果
- 自适应窗口:响应式布局适配不同屏幕
核心解析
场景初始化
// 创建基础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);
自动旋转控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.autoRotate = true; // 启用自动旋转
controls.autoRotateSpeed = 1.0; // 旋转速度
controls.enableDamping = true; // 阻尼惯性效果
controls.dampingFactor = 0.05; // 阻尼系数
节点创建(带图片和标签)
// 加载角色立绘
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); // 绘制姓名
关系连线
const arrowHelper = new THREE.ArrowHelper(direction, sourcePos,length,0xDC143C, // 红色箭头headLength,headWidth
);
动画循环
function animate() {requestAnimationFrame(animate);controls.update(); // 持续更新控制器renderer.render(scene, camera);
}
数据格式说明
创建无名小村.json文件:
{"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": "救治"}]
}
代码
<!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; // 箭头头部宽度,可调整// 创建 ArrowHelperconst 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; // <--- 可能需要减小 scaleFactorsprite.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>