
raychart 这套东西最初并不是一个规划出来的项目。它源自一个很朴素的场景公司业务侧要在大屏之外做一套移动端监控面板要求图表立体起来——销售数据要能像一栋栋楼那样立在手机屏幕里区域分布要能让用户用手指拨着转着看趋势曲线最好还有一点未来感。当时团队的第一反应是上 ECharts GL 或者直接怼 Three.js但试了一圈发现要么移动端性能和交互细节差点意思要么跟 Vue 生态的整合非常别扭。于是就有了 raychart 这个项目——一套基于 Vue 的 3D 图表可视化方案整个研发过程把移动端优先从一句口号变成了真实的产品和技术约束。这篇文章我会把从交互设计、渲染架构到性能优化的完整路线梳理出来里面有踩坑的完整链路也有可以直接抄走的技术决策希望能给正在做同类事情的朋友一些参考。1. raychart 的由来为什么放弃现成方案自己造一套 3D 图表轮子在做技术选型之前我们先把需求掰开揉碎看了一遍。表面上需求是3D 图表但拆开之后你会发现它其实是三个完全不同的技术命题数据如何转换成 3D 场景里的几何体、用户如何用手指自然地操作这些几何体、以及移动端浏览器如何撑住复杂的 WebGL 渲染。1.1 传统图表库的边界到底在哪里团队里最早被拿来验证的是 ECharts GL。它的优势很明显API 熟悉、文档齐全、从二维切换到三维基本零成本。但实际操作下来问题集中在两个地方。第一是交互的定制性不够ECharts GL 在移动端的手势体系基本是PC 鼠标行为平移过来的滚轮缩放对应到手机上是双指捏合旋转的灵敏度、惯性衰减这些都要自己去 hack一旦 hack 过头又容易跟图表库内部的坐标系逻辑打架。第二是性能剖面不太适合移动端ECharts GL 的场景管理偏向多场景隔离当图表数量上来之后GPU 资源的复用效率让我不太满意。也考虑过 Three.js 直接裸写。Three.js 的渲染能力毋庸置疑但图表不是原始模型它需要一个数据映射层语义化的 API比如给我画一个柱状图数据是这几组颜色是这个渐变需要图例、坐标轴提示、Tooltip 这类图表语义组件还得跟 Vue 的响应式系统配合。直接用 Three.js 相当于什么都从零开始那项目周期就不是按周算而是按月算了。1.2 选型对比后自研的底气从哪来最终的选型判断其实基于一张简单粗暴的对比表方案交互定制性Vue 集成成本移动端性能预期二次开发成本ECharts GL低内部坐标系耦合深中等有现成 Vue 封装中整体渲染管理偏重低但靠自己也没多快Three.js 裸写高但全部自己来高需要自建封装高完全取决于自己怎么优化高自研 raychart极高按照我们的产品需求来低直接从 Vue 响应式出发可控专为移动端裁剪中但一次投入持续复用我当时的判断是如果产品标准化程度高、业务模型单一那直接用现成方案没问题但我们的业务场景是多个内部 BI 面板图表形态会持续演进与其花 80% 的精力去适配一个通用库的边界不如花 60% 的精力自己把控一条足够窄但足够深的路径。这个决策有个重要前置条件团队里必须有人吃透 WebGL 的核心管线。不是会调 Three.js API 那种程度而是清楚顶点着色器和片元着色器各自在做什么、draw call 的成本模型、GPU 状态切换的代价。后面的事实也证明如果这块地基不稳自研基本就是给自己挖坑。2. 移动端优先的产品决策从交互到视觉的全面重构移动端优先这句话说出来很轻巧但落到设计决策上几乎每一个环节都要跟习惯反着来。2.1 手势体系的重新设计桌面端的 3D 图表交互大多是鼠标拖拽旋转、滚轮缩放。到了手机上单指拖拽和双指捏合是最自然的手势组合但天然有个冲突单指操作到底是旋转图表还是选中某个数据点我们最初的版本给了旋转优先级结果用户想点看具体数值时总被旋转动作干扰产生非常强的挫败感。后来参考了移动端地图类应用的处理逻辑把交互链路拆成了时序判断触摸开始时不立即处理先记录初始坐标和时间戳。如果 200ms 内位移小于 10px判定为点击意图进入选中逻辑。如果位移超过阈值判定为旋转意图进入图表操作逻辑。双指触控直接锁定为缩放/旋转同时屏蔽点击判定。这套规则在代码里用一个状态机管理每个手势周期只有一次判定避免在参数传递时混淆。实际测试下来用户的误触率明显下降上手成本低了很多。还处理了一个很容易忽略的细节图表旋转时的阻尼和惯性。移动端的惯性比桌面端更重要因为你不能依靠精确制动用户就习惯甩一下让图表转起来。我们在每一帧计算角速度并施加指数衰减衰减系数经过真机调优iOS 上取 0.92 左右、Android 低端机上取 0.86 左右才顺滑这个系数最终做成了可配置参数。2.2 移动端的视觉适配与渲染分辨率移动端的屏幕尺寸、DPRdevicePixelRatio千差万别2D 图表时代随手把 canvas 宽高乘上 DPR 就完事了3D 场景里同样要做但代价完全不同——渲染分辨率翻倍意味着 GPU 片元着色器的负载直接翻倍。我们针对 DPR 做了一档自适应降级策略设备类型最大 DPR 上限说明iOS 高配A14 Bionic 及以上3完整场景iOS 普通 / 中端 Android2关闭部分后处理效果低端 Android3000元以下档位1.5降低抗锯齿等级关闭辉光这里的核心思路是不让渲染分辨率无限追高。手机屏幕人眼感知细腻度的上限其实相对有限DPR 从 2 提到 3 带来的观感差异远小于 GPU 温度的上升。降级做得平滑且隐蔽——用户不会觉得画面变渣了只是没那么锐利但帧率始终稳得住。2.3 用帧率预算倒推开发策略移动端优先还有一层容易忽略的含义开发时所有功能都必须先过一遍帧率预算。我们给移动端定的目标是连续交互场景下 45fps 下限、60fps 期待值。为此专门搭了一个性能看板页面里任意时刻按下悬浮按钮会看到渲染帧耗时、JS 主线程耗时、GPU 绘制耗时三组数据。有意思的是这个帧率预算的思维方式会倒逼你重新审视很多设计。比如图表是否需要实时响应数据变化我们的做法是实时模式只在前台活跃时开启切到后台立刻暂停渲染循环再比如折线图点数太多默认抽稀到一个视觉密度足够但实际顶点数很小的量级。所有这些取舍在帧率预算这个框架下讨论都会变得非常清晰这个功能值多少毫秒的渲染时间值不值不值就砍。3. 渲染架构的升级从 Canvas 到 WebGL 的管线改造raychart 迭代中的关键一步是把渲染层从纯 Canvas 2D 升级到 WebGL。这里不是简单换个绘制 API而是整个场景管理、坐标体系、数据流转方式的系统性改造。3.1 3D 场景管理的底层设计WebGL 下所有东西本质上都是三角形但业务层需要的是一棵方便操作的对象树。我们的场景结构是这样组织的顶层是 Scene管理全局光照参数、背景色和绘制顺序。每个图表是一个 ChartNode内部包含自己独立的坐标系统。ChartNode 下面再挂具体的几何体节点例如柱状图的柱体、折线图的线段、散点图的点精灵。每个节点只保存自身的变换矩阵位置、旋转、缩放渲染循环里由 Scene 统一做矩阵级联计算。这样业务侧在配置一个柱状图时完全不需要关心 WebGL 的顶点数据到底是什么——只需要说这里有十根柱子高度从这几组得出颜色渐变方向是从这根到那根。矩阵级联有一个容易出错的地方移动端频繁旋转时浮点误差会累积导致图表时间久了会抖动或者慢慢漂移。后来在每次矩阵计算完成后做了归一化处理同时把层级深度控制在三层以内基本杜绝了这个问题。3.2 图表数据与 3D 坐标的映射逻辑把数据映射成 3D 坐标是图表库区别于游戏引擎的核心。raychart 对图表类型做了统一抽象先计算每个数据点的归一化坐标在 0-1 的盒子空间里再通过一个可插拔的布局函数映射到真实场景坐标。柱状图和折线图使用直角坐标系映射而饼图和雷达图则使用极坐标系与直角坐标系的变换。这里贴一段核心的直角坐标映射示意function mapToCartesian(dataPoint: DataPoint, layout: LayoutConfig): Vec3 { const x layout.origin[0] dataPoint.categoryIndex * layout.stepX; const y layout.origin[1] dataPoint.value * layout.valueScale; const z layout.origin[2] (dataPoint.seriesIndex % layout.depthCount) * layout.stepZ; return new Vec3(x, y, z); }初版映射逻辑做过一个错误决策把数值直接映射到世界坐标导致不同图表间切换时相机参数稍变一点画面里柱子就顶天立地。后来统一改为先归一化、后缩放的两段式映射同时引入一个场景包围盒计算再反过来调整相机位置和 far 裁剪面。做完这步图表的整体观感稳定了很多。3.3 Vue 响应式系统与渲染循环的解耦Vue 的响应式系统处理 UI 状态更新是强项但如果把数据变化直接同步到 WebGL 渲染循环里一定会出问题——响应式更新的频率可能远高于渲染帧率造成一帧内的重复计算反过来让帧率反而下降。拿到的解法是单帧合并更新。Vue 组件里任何数据变化只触发一个标记置位watch( () [props.data, props.layout], () { updateNeeded true; scheduleRender(); }, { deep: true } ); function scheduleRender() { if (renderScheduled) return; renderScheduled true; requestAnimationFrame(() { if (updateNeeded) { updateChartGeometry(); updateNeeded false; } renderScene(); renderScheduled false; }); }这套模式的关键收益是无论你一个 tick 里改了十个数据字段最终 WebGL 也只在下一帧做一次场景更新。在整个图表做动画过渡时内部用线性插值后续升级到了平滑阻尼曲线驱动几何体变化把 Vue 侧完全隔离在动画循环之外。4. 性能优化链路从卡顿到流畅的系统化排查任何一个 WebGL 项目做到后面核心考验都是性能。raychart 在移动端经历过一次从卡成幻灯片到稳 60fps的系统升级这个过程我认为比最终结果更有价值。4.1 卡顿问题定位先分清瓶颈在哪端很多人一上来就问draw call 是不是太多了实际上移动端卡顿的原因有好多层盲目优化约等于蒙眼开车。我们的排查链路基本固定为四步Chrome DevTools Performance 录制一段交互操作观察主线程耗时和 GPU 耗时占比。用渲染器的内部计数器记录当前帧的 draw call 数量和三角形顶点数。在降级模式下DPR 调低、关闭后处理跑同一段操作如果帧率大幅回升说明瓶颈在 GPU 负载。反过来把绘制逻辑代码里所有 CPU 计算打点计时如果 CPU 峰值在 60% 以上但渲染指标正常说明瓶颈在 JS 主线程。这套方法贵在把模糊的卡变成精确的哪一个环节耗时高。4.2 绘制批次与显存优化的实际落地定位之后发现第一版最大的瓶颈是 draw call 太多。柱状图有几百根柱子每根柱子两个三角形但每一根作为一个独立绘制批次提交。移动端低端机对 draw call 的容忍度极低实测超过 300 个 draw call 就能感到明显掉帧。优化方案很明确把同类型、同材质的几何体合并。所有柱子合并成一个 geometry buffer每根柱子对应一组 instance 属性位置、高度、颜色用 WebGL 的 instanced drawing 一次批量绘制// 伪代码示意instanced 绘制批量柱体 const instanceData bars.map((bar) ({ position: bar.position, scale: bar.height, color: bar.color, })); bindInstancedBuffer(instanceData); gl.drawElementsInstanced(gl.TRIANGLES, barIndexCount, gl.UNSIGNED_SHORT, 0, bars.length);一次升级下来典型场景 500 根柱子的 draw call 从 500 次降到 4-5 次。同时配合一个几何体缓存的策略数据没变时完全不重新上传顶点数据只更新 instance 缓冲。4.3 内存与 GC 压力治理WebGL 项目还有一个隐形杀手显存和内存的频繁分配。最初 debug 发现有一定几率出现掉帧到个位数卡顿现场还伴随内存曲线锯齿状跳动。根源在对象池没有用好。旧版代码在数据更新时直接重新创建整个顶点数组、颜色数组和索引数组老数据交给浏览器 GC 去回收。这在桌面端可能无所谓移动端 Safari 和部分 Android WebView 的 GC 运作非常不规律回收时可能直接把帧率砸穿。治理手段是引入对象池和双缓冲固定大小的 Float32Array 预分配不做动态扩容。数据更新时写入待渲染缓冲区然后交换缓冲。所有 Vec3、Quaternion 这类临时向量对象复用内存池绝不 new 临时变量。优化后内存曲线变成了平滑的直线GC 触发明显减少。这步看起来不起眼但恰恰是移动端 web 3D 项目里最容易在后期翻车的隐患。4.4 后处理效果的降级开关最初为了炫给图表加了辉光效果和径向模糊的视觉反馈。在桌面端没有任何问题拿到 iPhone 13 上也还算流畅但 Android 中端机一开辉光帧率直接掉穿。后来统一做了一组质量档位质量档位后处理效果抗锯齿应用场景低关闭FXAA 简化版低端 Android机顶盒老旧设备中简化辉光MSAA 2x中端 Android普通 WebView高完整辉光 景深MSAA 4xiOS / 旗舰 Android降级并不是全有能力检测而是结合了 UA 解析和首帧渲染耗时两个信号加载时先采样性能再看效果。5. 移动端实测踩坑记录那些文档里不会写的问题这套渲染管线在开发调试阶段真正折磨人的往往不是架构设计而是大量细碎的真机兼容问题。5.1 iOS 与 Android 的 GPU 差异一个典型现象是同一个渐变色材质在 iOS 上颜色过渡细腻自然在部分 Android 机上却出现明显的色带banding。这是因为 Android 低端设备的 GPU 在片元着色器默认使用低精度浮点数mediump float时渐变计算精确度不足。解决办法是在着色器里对关键变量强制声明高精度precision highp float; uniform vec3 uBaseColor; uniform vec3 uTopColor;同时在 Android 上渐变需要加轻微抖动dithering肉眼几乎不可感知但色带完全消除。iOS 和 Android 的 WebGL 实现差异远比你想象的要多这条经验基本适用于所有 WebGL 跨端项目。5.2 触摸手势与图表交互的冲突前面设计手势体系时已经说了单指旋转和点选的冲突实际测试还有更边缘的情况手机浏览器下拉刷新手势在页面滚动到顶部时会把触摸事件劫持走图表旋转到一半突然卡住。另外双指捏合缩放会被浏览器解读为整页缩放导致图表和页面一起放大非常糟糕。最终通过给图表触摸区域阻止默认行为解决canvasElement.addEventListener( touchstart, (e) { if (e.target canvasElement) { e.preventDefault(); } }, { passive: false } );这里要特别提醒{ passive: false }在 iOS Safari 上才真正生效没写的话浏览器可能忽略preventDefault。这个坑花掉了一个下午的时间去爬。5.3 低端 Android 的 GPU 过载崩溃在测试到 3000 元的 Android 机器时遇到过 WebGL context 直接丢失、页面白屏的情况。由高分辨率纹理工序再叠加全景大场景旋转操作造成的 GPU 过载会触发浏览器保护机制canvas 被强制清空且无法自动恢复。处理办法是监听webglcontextlost事件手动降级重渲染canvas.addEventListener(webglcontextlost, (e) { e.preventDefault(); // 释放资源 降到低质量档位 重建 context rebuildRenderer({ qualityLevel: QualityLevel.LOW }); });此后还做了主动守护——当累计 GPU 渲染帧耗时的 P95 超过 40ms 且连续持续 60 帧自动弹出降级提示并一键切换低质量档位避免让用户碰到白屏。5.4 PBR 材质的高昂代价初版为了视觉上有高级感给图表应用了基于物理的 PBR 材质——有金属度、粗糙度贴图、环境映射iPhone 上看着确实惊艳。但 Android 中低端机身上 PBR 的计算开销让渲染时长翻倍最后全部改回传统 Phong 光照模型并增加钢性高光来模拟质感效果在 90% 的设备上接近 PBR但性能开销只有 PBR 的一半。这个决策很实用主义但也让我意识到3D 图表的核心是表现数据不是渲染珠宝。6. 关于 raychart 后续演进的一点想法raychart 走到现在从最初的内部工具演变成了一个可复用的 3D 图表解决方案。仅从技术角度我认为最有价值的一笔投入是把移动端的优化约束前置到设计阶段——每一处交互设计、每一个视觉效果都先问一句它值多少个 draw call、多少毫秒 GPU 时间。想清楚这些后面所有优化都不再是临场救火而是按计划执行。最后分享一个小技巧如果条件允许务必准备一两台低端 Android 真机常驻测试位性能预算和降级逻辑的验证远不是 DevTools 模拟器能替代的屏幕上模拟的 DPR 与真机的 GPU 行为完全是两种东西。身边放台真机你会少走很多弯路。