动态效果演示

实现功能亮点:
- 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>

1912

被折叠的 条评论
为什么被折叠?



