拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于Three.js与实时金融数据的3D飞行可视化项目实战

1. 项目概述当股市行情“飞”起来最近在和一些做量化交易的朋友聊天发现一个挺有意思的现象大家已经不满足于在二维的K线图里“看图说话”了总想找点更直观、更刺激的方式来感受市场的脉搏。于是一个脑洞大开的想法就诞生了——把股票市场的实时数据映射到一个三维的虚拟飞行场景里让一架飞机在由行情数据构成的“天空”中穿梭。这就是“Flying Stock Market Plane!”这个项目的核心。简单来说它就是一个数据可视化项目但玩法很不一样。它不是传统的折线图或蜡烛图而是把抽象的股票价格、成交量、涨跌幅这些数字转化成了你可以“驾驶”飞机去探索的立体地形、天气和障碍物。想象一下你关注的股票大涨对应的“山峰”就拔地而起成交量暴增天空就电闪雷鸣板块轮动整个地貌都在实时变化。你不再是那个盯着屏幕、被动接受信息的投资者而是成了一个在数据世界里翱翔的飞行员用最直观的感官去“触摸”市场的热度与冰冷。这个项目适合谁呢首先肯定是量化交易员、金融数据分析师和对市场可视化有更高要求的投资者。它能提供一种全新的、全局性的市场感知方式帮助发现传统图表中不易察觉的相关性或异常波动。其次对于数据可视化开发者、游戏化应用设计者这也是一个绝佳的跨界实践案例展示了如何将枯燥的金融数据与沉浸式的游戏引擎结合。最后哪怕你只是个对编程和金融市场都感兴趣的技术爱好者跟着这个项目走一遍也能学到从数据接口调用、实时处理到3D图形渲染的一整套硬核技能。2. 核心设计思路从数字到世界的构建逻辑2.1 数据源与映射规则设计项目的根基是数据。我们不可能手动输入数据必须接入实时或历史的金融市场数据流。常见的免费数据源有雅虎财经通过yfinance库、Alpha Vantage API或者一些券商提供的开源接口。对于A股可以考虑使用akshare或tushare这类库。这里我选择yfinance因为它覆盖广、免费且稳定。拿到数据后最关键的一步是设计一套映射规则把二维的时间序列数据“翻译”成三维世界的元素。这不是随便映射需要符合直觉让飞行体验能真实反映市场状态。我的设计规则如下地形高度 - 股票价格这是最直观的映射。以某只股票例如苹果AAPL的分钟级收盘价为基准。将一段时间窗口比如过去60分钟的价格序列归一化到某个高度范围如0到1000单位。当前最新价格点就是飞机正下方的地形高度。价格陡峭上涨会形成悬崖峭壁缓慢下跌则是绵长的下坡。地形颜色与纹理 - 涨跌幅与波动率单纯的高度不够。我们用颜色来增强信息。例如涨幅超过2%的区域渲染为炽热的红色或金色跌幅超过2%的区域渲染为冰冷的蓝色或深灰色。波动率标准差大的区域地面纹理可以更粗糙甚至出现裂痕暗示风险较高。天气系统 - 成交量与市场情绪成交量是市场的“能量”。我们可以把成交量映射为天气。低成交量对应晴空万里成交量温和放大出现薄雾或云层成交量急剧放大放量则触发雷暴、暴雨、强风等恶劣天气影响飞机的操控性和能见度。空中障碍物飞鸟、陨石- 突发新闻与事件通过接入新闻API如金融信息聚合服务抓取关于该公司的实时新闻标题。利好消息生成奖励道具如金色光环飞过可加速利空消息或重大风险事件则生成障碍物如黑色飞鸟群或陨石飞机需要规避。多飞机与航线 - 板块与关联性如果我们不止映射一只股票呢我们可以让多架飞机不同颜色或模型同时飞行每架代表一只股票或一个ETF。它们的航线即价格序列在三维空间中会交错。当两只股票价格走势高度相关时它们的飞行航线会非常接近甚至平行当出现背离时航线会分道扬镳。这为观察板块联动和个股异动提供了上帝视角。注意映射规则的设计需要反复调试。直接使用原始价格会导致地形起伏过于剧烈飞机像在坐过山车体验很差。通常需要对价格序列进行平滑处理如简单移动平均并对变化率进行缩放。核心原则是既要保留关键趋势特征又要保证飞行的可操作性和视觉舒适度。2.2 技术栈选型与架构要实现这样一个实时、交互式的3D可视化项目技术栈的选择至关重要。经过权衡我选择了以下组合它在功能强大和开发效率之间取得了很好的平衡后端/数据处理 (Python)核心库Pandas,NumPy。用于高效获取、清洗、计算和转换金融时间序列数据。数据获取yfinance或akshare。实时推送WebSocket(可使用websockets库) 或Socket.IO。用于将处理好的数据流实时推送到前端。新闻事件Requests库定期轮询新闻API或使用支持WebSocket的新闻流服务。前端/3D渲染 (JavaScript)3D引擎Three.js。这是项目的核心。Three.js是一个强大的Web端3D库完全基于WebGL无需插件浏览器直接运行。它提供了创建场景、相机、灯光、几何体、材质、加载模型、处理用户交互的一整套工具社区活跃资源丰富。物理与碰撞Cannon.js或Ammo.js。Three.js本身不包含复杂的物理引擎。为了让飞机有重力、惯性能与地形和障碍物发生真实的碰撞需要集成一个物理引擎。Cannon.js轻量且与Three.js集成较好。UI与控制原生HTML/CSS/JavaScript或使用轻量级框架如Vue.js/React来构建控制面板用于选择股票、调整映射参数、显示飞行数据等。网络通信使用原生WebSocket或Socket.IO客户端库与后端建立连接接收实时数据流。整个架构是典型的前后端分离。后端Python服务作为“数据泵”和“计算中心”定期拉取市场数据按照映射规则进行计算生成描述三维世界状态的数据包JSON格式然后通过WebSocket主动推送给所有连接的客户端。前端Three.js应用在接收到数据后动态更新场景中的地形网格顶点高度、改变材质颜色、生成/移除天气粒子和障碍物并驱动物理引擎更新飞机的位置和状态。用户通过键盘WASD控制方向空格上升等或游戏手柄操控飞机视角跟随飞机移动。3. 核心模块实现详解3.1 动态地形生成系统地形是这个世界的基础。我们不可能预先生成一个巨大的静态地图因为数据是源源不断到来的。我的策略是采用“滚动地形”或“无限地形”的概念。1. 地形网格创建使用Three.js的PlaneGeometry创建一个足够长但宽度有限的平面网格作为我们的“飞行走廊”。比如长度对应60个时间点过去60分钟宽度对应一个固定的值。将网格的细分程度widthSegments和heightSegments设置得高一些这样地形起伏会更平滑。// 创建地形几何体 const terrainWidth 100; // 地形宽度单位 const terrainLength 600; // 地形长度对应时间窗口 const segmentsW 60; // 宽度分段数 const segmentsL 360; // 长度分段数越高地形细节越丰富性能开销越大 const geometry new THREE.PlaneGeometry(terrainWidth, terrainLength, segmentsW, segmentsL); geometry.rotateX(-Math.PI / 2); // 让平面立起来作为地面 // 创建材质 const material new THREE.MeshStandardMaterial({ color: 0x808080, roughness: 0.8, metalness: 0.2, side: THREE.DoubleSide // 双面渲染防止从下方看穿 }); const terrainMesh new THREE.Mesh(geometry, material); scene.add(terrainMesh);2. 顶点高度更新这是最核心的部分。我们维护一个数组存储最近N个时间点的价格数据已归一化。每当从后端收到一个新的数据点将新点加入数组尾部并移除数组头部最旧的点实现滚动效果。遍历地形几何体的所有顶点。每个顶点在长度方向Z轴上对应一个时间索引在宽度方向X轴上有一个偏移。顶点的高度Y坐标由对应时间点的价格值决定。为了增加地形的自然感可以在宽度方向叠加一个基于Perlin噪声的微小扰动让地面不是完全平坦的“墙”而是有起伏的“山脉”。更新完顶点位置后必须调用geometry.verticesNeedUpdate true;和geometry.computeVertexNormals();。前者告诉Three.js顶点数据已变需要重绘后者重新计算法线确保灯光照射效果正确。function updateTerrain(priceDataArray) { const vertices terrainMesh.geometry.attributes.position.array; const timeSegments segmentsL 1; // 时间轴上的顶点数 const widthSegments segmentsW 1; // 宽度轴上的顶点数 for (let i 0; i timeSegments; i) { // 获取当前时间点对应的价格可能需要进行插值 const priceIndex Math.floor((i / timeSegments) * priceDataArray.length); const baseHeight priceDataArray[priceIndex] * heightScale; // heightScale是高度缩放因子 for (let j 0; j widthSegments; j) { const vertexIndex (i * widthSegments j) * 3; // 每个顶点有x, y, z三个分量 // x 坐标保持不变或加一点噪声 // vertices[vertexIndex] ...; // y 坐标 基础价格高度 噪声 const widthFactor (j / widthSegments - 0.5) * 2; // -1 到 1 const noise simplex.noise2D(i * 0.1, j * 0.1) * 5; // 使用噪声函数 vertices[vertexIndex 1] baseHeight noise; // z 坐标根据索引线性变化 // vertices[vertexIndex 2] ...; } } terrainMesh.geometry.attributes.position.needsUpdate true; terrainMesh.geometry.computeVertexNormals(); }3. 材质颜色动态变化根据当前价格区域的涨跌幅动态混合地形材质的颜色。可以在着色器Shader中实现以获得最佳性能但为了简单起见我们可以分区更新。将地形沿长度方向分成若干段每段根据该区域的平均涨跌幅通过material.color.setHSL(hue, saturation, lightness)来设置颜色。例如大涨设红色HSL hue 0大跌设蓝色HSL hue 0.6。实操心得地形更新的频率需要谨慎控制。如果每个数据点如每秒都更新全部顶点性能压力会很大。一个优化策略是“增量更新”只更新最前方即将进入视野的新地形区块并回收已经飞过的区块将其重置并放到队列末尾等待填充新数据形成一个循环缓冲区。这能极大减少每帧的计算量。3.2 飞机操控与物理集成飞机是整个体验的焦点它的操控手感必须真实而有趣。1. 飞机模型与设置可以从免费资源网站如Sketchfab找一个低多边形的飞机模型使用Three.js的GLTFLoader加载。将模型添加到场景中并为其创建一个Cannon.js的刚体Rigid Body。import * as THREE from three; import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js; import * as CANNON from cannon-es; // 使用cannon-esCannon.js的ES模块版本 const loader new GLTFLoader(); let airplaneModel; const airplaneScale 0.5; const airplaneMass 10; loader.load(models/airplane.glb, (gltf) { airplaneModel gltf.scene; airplaneModel.scale.set(airplaneScale, airplaneScale, airplaneScale); scene.add(airplaneModel); // 创建物理刚体简化为一个长方体形状 const airplaneShape new CANNON.Box(new CANNON.Vec3(2, 1, 4)); // 根据模型大小调整 const airplaneBody new CANNON.Body({ mass: airplaneMass, position: new CANNON.Vec3(0, 50, 0), // 初始位置 shape: airplaneShape }); world.addBody(airplaneBody); // world 是Cannon.js物理世界实例 // 将Three.js模型的位置和旋转与物理刚体同步 function syncPhysics() { if (airplaneModel airplaneBody) { airplaneModel.position.copy(airplaneBody.position); airplaneModel.quaternion.copy(airplaneBody.quaternion); } } // 在动画循环中调用 syncPhysics });2. 操控逻辑监听键盘事件将按键状态如KeyW,KeyA,KeyS,KeyD,Space,ShiftLeft转化为对飞机刚体施加的力和扭矩。W/S在飞机局部坐标系的前向轴Z轴负方向Three.js中相机朝向施加向前/后的力控制加速和减速。A/D施加绕飞机局部Y轴的扭矩控制左右转向偏航Yaw。空格/Shift在飞机局部坐标系的上轴Y轴施加向上/下的力控制爬升和下降。鼠标移动可以映射为绕飞机局部X轴俯仰Pitch和Z轴滚转Roll的扭矩实现更灵活的飞行模拟。施加力和扭矩时必须考虑飞机的当前朝向。Cannon.js的刚体提供了vectorToLocalFrame和vectorToWorldFrame方法来在局部和世界坐标系间转换向量。const forceStrength 100; const torqueStrength 50; function applyControls(keyState) { if (!airplaneBody) return; const force new CANNON.Vec3(0, 0, 0); const torque new CANNON.Vec3(0, 0, 0); // 前向/后向力 (局部Z轴) if (keyState[KeyW]) { force.z - forceStrength; } if (keyState[KeyS]) { force.z forceStrength * 0.5; // 减速力小一些 } // 升力/降力 (局部Y轴) if (keyState[Space]) { force.y forceStrength; } if (keyState[ShiftLeft]) { force.y - forceStrength; } // 将局部力转换到世界坐标系并施加 const worldForce airplaneBody.vectorToWorldFrame(force); airplaneBody.applyForce(worldForce, airplaneBody.position); // 转向扭矩 (绕局部Y轴) if (keyState[KeyA]) { torque.y - torqueStrength; } if (keyState[KeyD]) { torque.y torqueStrength; } // 将局部扭矩转换到世界坐标系并施加 const worldTorque airplaneBody.vectorToWorldFrame(torque); airplaneBody.applyTorque(worldTorque); }3. 相机跟随为了让玩家有良好的飞行视角相机应该平滑地跟随飞机。可以使用Three.js的THREE.Object3D作为一个“相机容器”将其放置在飞机后方上方的一个偏移位置并使用THREE.Quaternion.slerp球面线性插值或THREE.Vector3.lerp线性插值来实现平滑的跟随避免镜头剧烈抖动。const cameraOffset new THREE.Vector3(0, 5, -15); // 相机相对于飞机的偏移 const cameraContainer new THREE.Object3D(); scene.add(cameraContainer); cameraContainer.add(camera); // 将相机放入容器 function updateCamera() { if (airplaneModel) { // 计算相机容器的目标位置和旋转基于飞机 const targetPosition airplaneModel.position.clone().add(cameraOffset.clone().applyQuaternion(airplaneModel.quaternion)); const targetQuaternion airplaneModel.quaternion.clone(); // 平滑插值 cameraContainer.position.lerp(targetPosition, 0.1); // 0.1是平滑系数越小越平滑 cameraContainer.quaternion.slerp(targetQuaternion, 0.1); } } // 在动画循环中调用 updateCamera3.3 天气与事件系统实现天气和事件是让世界“活”起来的关键反馈层。1. 粒子系统实现天气Three.js的THREE.Points或THREE.Sprite非常适合用来制作粒子效果表现雨、雪、雾。雨/雪创建大量的小粒子使用简单的方形贴图或自定义着色器让它们从天空中的某个区域随机生成并受重力影响向下坠落。粒子的生成速率和密度与成交量数据成正比。当成交量激增时提高粒子发射率并可能将粒子颜色调暗、尺寸调大模拟暴雨。雾Three.js有内置的雾效THREE.Fog或THREE.FogExp2。我们可以根据成交量数据动态调整雾的浓度fog.density和颜色。低成交量时雾淡或无雾高成交量时雾变浓颜色也可以偏向灰色模拟压抑感。闪电可以通过瞬间大幅提高场景环境光亮度并播放一个全屏闪白或紫蓝色的覆盖层使用THREE.ShaderMaterial制作简单后处理效果来模拟。闪电的触发可以与“放量下跌”这类极端事件绑定。2. 事件障碍物生成障碍物如飞鸟群或陨石可以用简单的几何体球体、锥体或加载低模模型来代表。当后端推送来一个利空新闻事件时在飞机前方一定距离、随机水平位置生成一个障碍物组。为每个障碍物创建物理刚体质量设为0成为静态或运动学刚体避免被飞机撞飞并设置一个向飞机方向缓慢移动的速度。在Three.js场景中添加对应的网格模型。关键是要实现碰撞检测。在物理世界的world的postStep事件或每一帧更新中检查飞机刚体是否与任何障碍物刚体发生了碰撞Cannon.js有接触材料的概念可以设置碰撞回调。一旦碰撞发生触发效果比如屏幕震动、飞机失控旋转、速度骤降、显示“受损”UI等。// 简化的障碍物生成与碰撞检测逻辑 const obstacles []; function spawnObstacle(type, position) { // 1. 创建Three.js网格 let geometry, material; if (type bird) { geometry new THREE.SphereGeometry(1, 8, 8); material new THREE.MeshBasicMaterial({ color: 0x000000 }); } // ... 其他类型 const mesh new THREE.Mesh(geometry, material); mesh.position.copy(position); scene.add(mesh); // 2. 创建Cannon.js刚体 const body new CANNON.Body({ mass: 0, // 静态物体 position: new CANNON.Vec3(position.x, position.y, position.z), shape: new CANNON.Sphere(1) }); world.addBody(body); // 3. 存储引用 obstacles.push({ mesh, body, type }); } // 在物理世界更新后检查碰撞 world.addEventListener(postStep, () { for (const obstacle of obstacles) { // 简单的距离检测实际应用应使用物理引擎的接触事件 const distance airplaneBody.position.distanceTo(obstacle.body.position); if (distance 5) { // 碰撞阈值 handleCollision(obstacle.type); // 移除障碍物 world.removeBody(obstacle.body); scene.remove(obstacle.mesh); const index obstacles.indexOf(obstacle); if (index -1) obstacles.splice(index, 1); break; } } });4. 性能优化与实战调试4.1 性能瓶颈分析与解决当场景中地形顶点数过多、粒子数量巨大、物理计算复杂时帧率FPS会明显下降。以下是我在开发中遇到的主要瓶颈和解决方案地形网格性能问题高细分度的PlaneGeometry顶点数(segmentsW1)*(segmentsL1)轻易过万每帧更新所有顶点并重计算法线消耗巨大。解决降低细分度在视觉可接受的范围内减少segmentsW和segmentsL。长度方向的分段数可以比宽度方向更密因为飞行主要沿长度方向。增量更新如前所述只更新飞机前方即将进入视野的一小段地形。使用BufferGeometryPlaneGeometry内部就是BufferGeometry确保我们直接操作attributes.position.array是正确的。避免频繁创建新的几何体对象。考虑LOD层次细节对于远离相机的部分地形可以使用顶点数更少的简化版本网格。Three.js 有THREE.LOD对象但动态地形上实现较复杂可作为进阶优化。粒子系统性能问题成千上万的雨滴/雪花粒子每个都是一个独立的Sprite或Points中的顶点对GPU是挑战。解决使用单个Points对象将所有雨滴粒子数据位置、大小、生命周期存储在一个THREE.BufferGeometry的 attributes 中并使用一个THREE.PointsMaterial配合自定义着色器Shader来渲染。这样只需一次绘制调用Draw Call。在着色器中完成动画粒子的运动下落、生命周期消失计算放在顶点着色器和片元着色器中完成充分利用GPU并行能力解放CPU。控制总数根据成交量动态调整活跃粒子数量上限设置合理的距离裁剪Frustum Culling。物理引擎性能问题Cannon.js中刚体数量过多尤其是地形碰撞体如果也精确建模的话或单次时间步长time step计算复杂会拖慢主线程。解决简化碰撞形状飞机和障碍物使用简单的几何体球体、长方体、胶囊体作为碰撞形状避免使用复杂的三角网格Trimesh。地形碰撞优化不要为整个地形网格创建精确的Trimesh碰撞体。可以采用“代理碰撞体”方法在飞机下方根据当前地形高度数据动态生成一个或多个简单的长方体或胶囊体作为“地面”只处理与这些简单体的碰撞。或者直接使用“射线检测”Raycasting来判断飞机离地高度模拟地面碰撞效果这比完整的物理碰撞开销小得多。调整物理更新频率不一定需要每帧通常60Hz都更新物理世界。可以以较低的固定频率如30Hz更新物理并通过插值来平滑渲染位置。JavaScript执行效率使用Web Worker将繁重的数据计算如价格序列的平滑处理、顶点高度的批量计算放到Web Worker线程中避免阻塞主线程的渲染和交互。避免垃圾回收压力在动画循环中避免频繁创建和销毁对象如new THREE.Vector3()。可以复用对象池Object Pool。例如为经常使用的临时向量和矩阵创建全局变量在函数内重复赋值使用而不是每次都新建。4.2 常见问题与调试技巧实录在开发过程中我踩过不少坑这里记录几个典型问题和解决方法问题1飞机操控手感“飘”或“迟钝”。排查这通常是物理参数设置不当或力/扭矩施加方式有问题。解决调整刚体质量mass和阻尼linearDamping,angularDamping质量太轻会飘太重则迟钝。线性阻尼和角度阻尼可以模拟空气阻力让飞机运动更自然。从较小的阻尼值开始调试如0.05。检查力的施加点applyForce的第二个参数是力的作用点在世界坐标系中。通常我们施加在刚体质心body.position上。如果作用点偏离质心会产生额外的扭矩导致飞机乱转。确保使用局部坐标系施加力和扭矩前一定要将局部坐标系的向量转换到世界坐标系。直接用世界坐标系的向量会导致操控方向错乱。调试视图开启Cannon.js的调试渲染将物理刚体的形状用线框画出来可以直观看到碰撞形状和刚体位置确认是否与视觉模型对齐。问题2地形更新导致画面卡顿。排查使用浏览器开发者工具的Performance面板录制一段时间查看火焰图Flame Chart找到耗时最长的函数。解决大概率是updateTerrain函数或geometry.computeVertexNormals()耗时过长。实施增量更新这是最有效的办法。降低更新频率不一定每个数据点都触发完整地形更新。可以积累几个点或定时如每500毫秒更新一次。将计算移入Worker将顶点数据的计算部分放入Web Worker。问题3飞机穿地或地形闪烁。排查穿地是碰撞检测失效地形闪烁Z-fighting是深度缓冲Z-Buffer精度问题。解决穿地确保物理“地面”的高度与视觉地形高度同步更新。如果使用射线检测增加检测射线的长度和频率。检查飞机刚体是否因为受力过大或碰撞响应设置问题被“砸”进地面。地形闪烁相邻的两个三角形因为距离相机太远或几乎共面深度值相差极小GPU无法确定谁在前谁在后。解决方法稍微增大地形网格的polygonOffset属性或确保相机近裁剪面near不要设置得太小远裁剪面far不要设置得太大。问题4从后端接收数据延迟高或不稳定。排查网络延迟或后端数据处理瓶颈。解决前端增加缓冲队列不要收到一个数据点就立刻更新世界。将数据点放入一个队列前端以一个稳定的速率如每秒60次从队列中取出并消费。这可以平滑因网络抖动带来的卡顿。数据压缩后端发送的JSON数据包可以压缩gzip减少传输量。差分更新如果地形不是全部更新后端可以只发送变化的部分如最新几个顶点的高度而不是整个数组。使用二进制协议如果数据量极大可以考虑使用像MessagePack或直接发送二进制ArrayBuffer替代JSON解析效率更高。问题5在多飞机模式下视角混乱。解决这是UI/UX设计问题。可以提供多种视角模式跟随视角玩家控制其中一架飞机相机跟随它。自由视角玩家可以自由在3D空间中移动相机俯瞰整个“数据景观”。画中画主视角跟随主机小窗口显示其他重要飞机的尾随视角或地图位置。切换控制允许玩家在飞行中随时切换控制哪一架飞机。这个项目从构思到实现是一个不断在创意、技术和性能之间寻找平衡的过程。最大的收获不是做出了一个酷炫的演示而是深刻理解了如何将抽象的、多维的数据流通过一套精心设计的规则转化为可感知、可交互、甚至带有情感色彩的沉浸式体验。它不仅仅是一个可视化工具更是一个理解复杂系统的“感官延伸”。当你驾驶飞机艰难地爬升一段陡峭的“价格悬崖”或是在“成交量风暴”中颠簸穿行时你对市场波动的感受会比看任何数字图表都更加直接和深刻。这或许就是数据游戏化的魅力所在。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门