当前位置: 首页 > news >正文

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>

相关文章:

  • C++ - 数据容器之 forward_list(创建与初始化、元素访问、容量判断、元素遍历、添加元素、删除元素)
  • 方案解读:华为-智慧园区数字平台技术方案【附全文阅读】
  • systemd-notify(linux服务状态通知消息)
  • 如何基于HAL库进行STM32开发
  • 模拟SIP终端向Freeswitch注册用户
  • 一键部署自己的私域直播
  • 具身系列——PPO算法实现CartPole游戏(强化学习)
  • operator 可以根据需要重载 == 运算符进行比较
  • Cadence高速系统设计工具
  • 0基础 | STM32 | TB6612电机驱动使用
  • DeepSeek辅助学术写作之提交和出版以及评审过程分析提示词分享祝你顺利毕业~
  • 肥胖风险的多类预测——CatBoost模型的89%
  • Y1——树状数组入门
  • 每天一道面试题@第五天
  • 推理能力:五一模型大放送
  • C# 运算符重载深度解析:从基础到高阶实践
  • 第3章 Python 3 基础语法001
  • 大模型:解码人工智能的算力革命与边界突破
  • Go反射-通过反射调用结构体的方法(带入参)
  • Spring 容器相关的核心注解​
  • 9金收官!跳水世界杯总决赛朱子锋、程子龙包揽男子10米台冠亚军
  • 苏州一直升机坠落致1死4伤,事故调查正展开
  • 人民日报钟声:国际社会应共同维护科学溯源的正确方向
  • 美乌矿产协议预计最早于今日签署
  • 国际锐评:菲律宾“狐假虎威”把戏害的是谁?
  • 华夏银行一季度营收降逾17%、净利降逾14%,公允价值变动损失逾24亿