3D人物关系图开发实战:Three.js实现自动旋转可视化图谱(附完整代码)

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>
相关推荐
chilling heart3 分钟前
JAVA---继承
java·开发语言·学习
黄雪超5 分钟前
JVM——JVM是怎么实现invokedynamic的?
java·开发语言·jvm
绿龙术士19 分钟前
C#与西门子PLC通信:S7NetPlus和HslCommunication使用指南
开发语言·c#
小满zs24 分钟前
React-router v7 第七章(导航)
javascript·react.js·ecmascript
xiaolang_8616_wjl27 分钟前
c++_2011 NOIP 普及组 (1)
开发语言·数据结构·c++·算法·c++20
若水晴空初如梦40 分钟前
QT聊天项目DAY07
开发语言·qt
Luna_Lovegood_00143 分钟前
Qt QGraphicsScene 的用法
开发语言·qt
月忆36444 分钟前
Go语言接口实现面对对象的三大特征
开发语言·后端·golang
o0向阳而生0o1 小时前
35、C# 中的反射(Reflection)
开发语言·c#·.net
程序员曼布1 小时前
RabbitMQ 深度解析:从核心组件到复杂应用场景
java·开发语言·后端·rabbitmq