三维力导图的艺术:构建力导图版本的动态人物关系网络

动态效果演示

在这里插入图片描述

实现功能亮点​

  • ​3D力导向布局​:节点自动排布,动态平衡
  • ​角色立绘展示​:支持动态加载PNG/JPG角色贴图
  • ​智能关系标注​:带箭头的连接线与悬浮文字标签
  • 鼠标拖拽旋转视角
  • 滚轮缩放浏览
  • 自适应窗口尺寸

重点代码解析

1. 数据加载与初始化

// 异步加载JSON关系数据
fetch('graph.json')
  .then(response => response.json())
  .then(data => {
    // 初始化力导向图
    const Graph = new ThreeForceGraph()
      .graphData(data)
      .nodeRelSize(16)  // 节点相对尺寸
      .nodeColor(() => '#ffffff'); // 统一节点颜色

关键技术点​:

  • fetch API实现异步数据加载
  • nodeRelSize控制节点碰撞体积
  • 颜色分配策略可扩展(如按角色阵营着色)

2. 节点立体渲染

// 创建包含立绘和标签的3D对象
nodeThreeObject(node => {
  const group = new THREE.Group();
  
  // 加载角色立绘
  textureLoader.load(node.img, texture => {
    const sprite = new THREE.Sprite(
      new THREE.SpriteMaterial({ map: texture })
    );
    sprite.scale.set(6 * aspect, 6, 1); // 保持贴图比例
    group.add(sprite);
  });

  // 创建Canvas文字标签
  const canvas = document.createElement('canvas');
  ctx.fillText(node.name, 10, 40); // 中文字体渲染
  const label = new THREE.Sprite(
    new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(canvas) })
  );
  group.add(label);
});

​创新设计​:

  • ​双图层叠加​:Sprite + Canvas标签组合
  • ​动态纹理加载​:按需加载角色图片
  • ​分辨率自适应​:Canvas尺寸(256x64)保证清晰度

3. 智能关系连线

// 动态更新连线位置
.linkPositionUpdate((obj, { start, end }) => {
  const dir = new THREE.Vector3().subVectors(end, start);
  
  // 线段动态延伸
  line.geometry.setFromPoints([new THREE.Vector3(0,0,0), dir]);

  // 箭头方向校准
  arrow.quaternion.setFromUnitVectors(
    new THREE.Vector3(0, 1, 0), // 初始朝向
    dirNormalized               // 目标朝向
  );

  // 关系标签居中定位
  label.position.copy(dirNormalized.clone().multiplyScalar(length/2));
});

数学原理​:

  • 向量减法计算节点间方向
  • 四元数实现3D空间旋转
  • 标量乘法实现标签居中

完整项目代码

<!DOCTYPE html>
<html lang="zh-cn">
<head>
  <meta charset="UTF-8">
  <title>3D人物关系图(修复版)</title>
  <style>
    body { margin: 0; overflow: hidden; }
    canvas { cursor: grab; }
    canvas:active { cursor: grabbing; }
  </style>
  <script type="importmap">
    {
      "imports": {
        "three": "./js/three.js/build/three.module.js",
        "three/addons/": "./js/three.js/examples/jsm/",
        "three-forcegraph": "https://esm.sh/three-forcegraph"
      }
    }
  </script>
</head>
<body>
<div id="graph-container"></div>

<script type="module">
import * as THREE from 'three';
import ThreeForceGraph from 'three-forcegraph';
import { TrackballControls } from 'three/addons/controls/TrackballControls.js';

// 加载数据
fetch('graph.json')
  .then(response => response.json())
  .then(data => {
    // 创建力导图实例
    const Graph = new ThreeForceGraph()
      .graphData(data)
      .nodeRelSize(16)
      .nodeResolution(32)
      .nodeColor(() => '#ffffff')
      .nodeThreeObject(node => {
        const group = new THREE.Group();

        // 加载立绘贴图
        const textureLoader = new THREE.TextureLoader();
        textureLoader.load(
          node.img,
          (texture) => {
            texture.colorSpace = THREE.SRGBColorSpace;
            if (texture.image?.width && texture.image?.height) {
              const aspect = texture.image.width / texture.image.height;
              const spriteMat = new THREE.SpriteMaterial({
                map: texture,
                transparent: true
              });
              const sprite = new THREE.Sprite(spriteMat);
              sprite.scale.set(6 * aspect, 6, 1);
              sprite.position.z = 3;
              group.add(sprite);
            }
          },
          undefined,
          (error) => console.error('纹理加载失败:', error)
        );

        // 创建文字标签
        const canvas = document.createElement('canvas');
        canvas.width = 256;
        canvas.height = 64;
        const ctx = canvas.getContext('2d');
        
        ctx.fillStyle = 'rgba(200, 220, 255, 0.8)';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.font = 'bold 24px "Microsoft YaHei"';
        ctx.fillStyle = 'black';
        ctx.fillText(node.name, 10, 40);

        const labelTexture = new THREE.CanvasTexture(canvas);
        const label = new THREE.Sprite(
          new THREE.SpriteMaterial({ 
            map: labelTexture,
            transparent: true
          })
        );
        label.scale.set(10, 2, 1);
        label.position.z = -3;
        group.add(label);

        return group;
      })
      .linkThreeObject(link => {
        const group = new THREE.Group();

        // 连线
        const line = new THREE.Line(
          new THREE.BufferGeometry().setFromPoints([
            new THREE.Vector3(0, 0, 0),
            new THREE.Vector3(1, 0, 0)
          ]),
          new THREE.LineBasicMaterial({
            color: 0xffffff,
            linewidth: 2
          })
        );
        group.add(line);

        // 箭头(底部在原点)
        const arrowMat = new THREE.MeshBasicMaterial({ color: 0xff5733 });
        const arrowGeom = new THREE.ConeGeometry(0.3, 1.0, 8);
        arrowGeom.translate(0, 0.5, 0); // 让底部在 y=0,顶点在 y=1
        const arrow = new THREE.Mesh(arrowGeom, arrowMat);
        arrow.position.set(0, 0, 0);
        group.add(arrow);

        // 关系文字
        if (link.relation) {
          const canvas = document.createElement('canvas');
          canvas.width = 256;
          canvas.height = 64;
          const ctx = canvas.getContext('2d');
          ctx.font = 'bold 28px "Microsoft YaHei"';
          ctx.fillStyle = 'rgba(255,255,255,0.8)';
          ctx.fillRect(0, 0, canvas.width, canvas.height);
          ctx.fillStyle = '#333';
          ctx.textAlign = 'center';
          ctx.textBaseline = 'middle';
          ctx.fillText(link.relation, canvas.width / 2, canvas.height / 2);

          const texture = new THREE.CanvasTexture(canvas);
          const label = new THREE.Sprite(
            new THREE.SpriteMaterial({ map: texture, transparent: true })
          );
          label.scale.set(8, 2, 1);
          group.add(label);
        }

        return group;
      })
      .linkPositionUpdate((obj, { start, end }) => {
        // 计算方向向量
        const dir = new THREE.Vector3().subVectors(end, start);
        const length = dir.length();
        const dirNormalized = dir.clone().normalize();

        // 更新线段
        const line = obj.children[0];
        line.geometry.setFromPoints([new THREE.Vector3(0, 0, 0), dir]);

        // 更新箭头
        const arrow = obj.children[1];
        arrow.position.copy(dir); // 直接放到线段终点
        arrow.quaternion.setFromUnitVectors(
          new THREE.Vector3(0, 1, 0),
          dirNormalized
        );

        // 更新关系文字
        if (obj.children[2]) {
          const label = obj.children[2];
          const labelPosition = dirNormalized.clone().multiplyScalar(length/2);
          label.position.copy(labelPosition.add(new THREE.Vector3(0, 0, 1)));
        }
      });

    // 创建场景
    const scene = new THREE.Scene();
    scene.add(Graph);
    scene.add(new THREE.AmbientLight(0xffffff, Math.PI));

    // 创建渲染器
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.setPixelRatio(window.devicePixelRatio);
    document.getElementById('graph-container').appendChild(renderer.domElement);

    // 创建相机
    const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);
    camera.position.z = Math.cbrt(data.nodes.length) * 100;
    camera.lookAt(0, 0, 0);

    // 添加控制器
    const controls = new TrackballControls(camera, renderer.domElement);
    controls.rotateSpeed = 2;
    controls.minDistance = 50;
    controls.maxDistance = 500;

    // 动画循环
    function animate() {
      requestAnimationFrame(animate);
      Graph.tickFrame();
      controls.update();
      renderer.render(scene, camera);
    }
    animate();

    // 窗口大小变化响应
    window.addEventListener('resize', () => {
      camera.aspect = window.innerWidth / window.innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(window.innerWidth, window.innerHeight);
      controls.handleResize();
    });
  })
  .catch(error => console.error('数据加载失败:', error));
</script>
</body>
</html>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

像素艺术家

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值