ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ECharts 3D饼图实现方案:基于surface参数方程的完整实战指南

ECharts 3D饼图实现方案:基于surface参数方程的完整实战指南 年前接了一个大屏项目需求很直接“要一个高级点的饼图”。我第一反应是这不简单吗ECharts 里type: pie就完事了。结果在主数据可视化平台上一搜“3D饼图”示例发现官方根本没有pie3D这个系列我试着写了个type: pie3D控制台直接告诉我 is not a series。翻了一圈 echarts-gl 的文档官方提供的3D系列只有 scatter3D、bar3D、line3D、surface、graph3D 这些唯独没有现成的 3D 饼图。所以“用 ECharts 绘制3D饼图”这件事本质上就是拿 echarts-gl 的 surface 系列去“拼”一个饼图出来。这篇文章我会把整个思路从头讲一遍从扇区几何拆解、参数方程怎么写到完整可运行的代码再到 tooltip、标签、图例这些交互上的坑最后给一套我在实际项目里总结出来的选型建议。适合已经会基本 ECharts、想在大屏项目里上3D效果的读者。1. 先说实话ECharts 没有“3D饼图”这个系列社区方案全靠 echarts-gl 拼面1.1 echarts-gl 到底给了我们什么echarts-gl 是 ECharts 官方的 3D 扩展库它给 ECharts 增加了三维场景能力但它的定位更像是一个“3D 基础积木箱”。里面提供的系列类型有scatter3D3D 散点图bar3D3D 柱状图line3D3D 折线图surface3D 曲面graph3D3D 关系图globe / map3D3D 地球和地图可以看到确实没有“饼图”。但surface给了我们很强的自由度它支持参数方程parametric equation也就是说你可以用两个参数 u、v 去描述一个曲面上的三维坐标 (x, y, z)。只要能把一个扇区用数学方程写出来就能把它画出来。这里插一句网上搜“echarts 3D饼图”能找到不少效果图但很多都是截图代码要么不完整要么依赖老版本。真正能落到自己项目里的核心就是“用 surface 参数方程画扇区”这一个思路。1.2 为什么是用 surface 而不是别的系列有人可能会问能不能用 bar3D 来拼一个扇区当然不行bar3D 只能画长方体柱体无法模拟圆弧。能不能用 scatter3D 点阵去填出一个扇形柱体可以但点的密度、颜色、性能都是问题而且边缘锯齿很严重。surface 是最合适的因为饼图扇区本质上是一个“扇形柱体”顶面是一个扇形平面底面也是一个扇形平面外边缘是一段圆弧面左右两侧是矩形平面。这四个面都可以用参数方程干净地表达出来而且 surface 支持每个系列单独设置颜色方便我们给每个扇区不同的配色。所以社区里所有能跑的真·3D 饼图底层基本都是同一套逻辑一个扇区 几个 surface 系列叠加。我自己也是在对比了自定义系列渲染、three.js 原生绘制之后才确定用这个方案最稳。2. 一个扇区就是一个“扇形柱体”几何拆解与参数方程2.1 从圆柱坐标理解扇区的四个面在做代码之前先把几何想明白。饼图放在三维空间里可以想象成一张圆形的饼从桌面z0被抬高了 H 个单位于是它变成了一个“扁圆柱体”。把这个扁圆柱体切成若干个扇形就得到了每个扇区。每个扇区由这几个面围成顶面z H 处的一个扇形平面底面z 0 处的一个扇形平面外侧面半径 R 处的圆弧面左切面 / 右切面角度边界处的两个矩形平面如果是一个实心饼图内半径是 0所以不存在“内侧面”如果是环形饼图玫瑰图内半径大于 0那就还需要一个内侧圆弧面。2.2 顶面、底面、外侧面的参数方程surface 系列开启parametric: true后u、v 两个参数的默认取值范围都是 [-1, 1]。我们需要把 [-1, 1] 映射到我们需要的角度和半径上。以顶面为例设扇区起始角度为 startAngle结束角度为 endAngle外半径为 R高度为 H则顶面可以这样表达u 控制角度方向theta startAngle (u 1) / 2 * (endAngle - startAngle)v 控制半径方向r (v 1) / 2 * R坐标x r * cos(theta)y r * sin(theta)z H底面写法和顶面几乎一样只是z 0。外侧面稍有不同它需要 u 控制高度方向v 控制角度方向u 控制高度z (u 1) / 2 * Hv 控制角度theta startAngle (v 1) / 2 * (endAngle - startAngle)坐标x R * cos(theta)y R * sin(theta)z z这样四个面里有三个已经能写出来了。左右切面的参数方程也不难就是固定角度、半径从 0 到 R、高度从 0 到 H但实际项目中我一般不去画它们。2.3 我为什么放弃左右切面改用扇区间隙很多第一次做3D饼图的人会把所有面都画出来包括左右切面。但渲染之后会发现相邻两个扇区的切面重合在一起容易出现“黑缝”或“闪面”并且切面本身在大多数视角下都被顶面或外侧面挡住属于纯浪费性能。与其画切面不如在数据分配角度的时候给每个扇区之间留一点空隙。比如让相邻扇区之间空出 2 度这样每个扇区都变成了独立的一块视觉上更像被切开的一块饼反而更立体、更精致。这也是我在实际项目里试过多次后留下的经验不画切面用扇区间隙代替。这个小小的决定会让整体效果干净很多。3. 上手写代码一张能跑的 3D 饼图3.1 版本与依赖echarts 5 echarts-gl 2.0.9先确认版本搭配这一步非常关键。echarts-gl 在 2.0 版本之后才正式支持 ECharts 5如果你用的是 echarts 5那么 echarts-gl 也请使用 2.0.9 或更高版本。用 CDN 引入时顺序必须是先 echarts再 echarts-glscript srchttps://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js/script script srchttps://cdn.jsdelivr.net/npm/echarts-gl2.0.9/dist/echarts-gl.min.js/script如果你用 npm 的方式npm install echarts5 echarts-gl2然后在入口文件里import * as echarts from echarts; import echarts-gl;版本不对的典型报错是“series.surface not exists”或者初始化的时候直接抛错排查方向基本就是看 echarts-gl 和 echarts 的版本匹配。3.2 角度分配先让每个扇区拿准自己该占多少度饼图的数据值总和对应 360 度。因为我们要在扇区之间留空隙所以不能简单让每个扇区的角度等于 value / total * 360而是要先从 360 度里“扣掉”全部间隙占用的角度再按比例分配。这里我用弧度制间隙 GAP 设为 2 度换算成弧度是2 * Math.PI / 180。假设有 n 个扇区那么所有扇区实际占用的总角度就是2 * Math.PI - GAP * n每个扇区的角度为span (item.value / total) * (2 * Math.PI - GAP * n)为了方便理解我写了一个完整的buildPie3DOption函数里面用curAngle记录当前累计角度。为了让整体在视觉上左右对称第一个扇区从-Math.PI / 2 GAP / 2开始而不是直接从 12 点方向开始。这样最后一个扇区和第一个扇区之间的空隙也会均匀分布。3.3 扇区工厂用参数方程生成面接下来是最核心的函数buildSector它接收一个扇区的数据项、起始角度、结束角度、半径和高度返回两个 surface 系列的配置顶面和外侧面。为了让顶面和侧面有颜色层次我用了一个简单的方法顶面颜色在原颜色基础上往白色方向混合 20% 的白色让它更亮外侧面颜色往黑色方向混合 25% 的黑色让它更深这样不用依赖光照也能在视觉上形成立体感。下面先给出颜色工具函数function hexToRgb(hex) { const h hex.replace(#, ); return { r: parseInt(h.substring(0, 2), 16), g: parseInt(h.substring(2, 4), 16), b: parseInt(h.substring(4, 6), 16) }; } function mixColor(hex, mixHex, weight) { const c hexToRgb(hex); const m hexToRgb(mixHex); return rgb( Math.round(c.r * (1 - weight) m.r * weight) , Math.round(c.g * (1 - weight) m.g * weight) , Math.round(c.b * (1 - weight) m.b * weight) ); } const lighten (hex, w) mixColor(hex, #ffffff, w); const darken (hex, w) mixColor(hex, #000000, w);然后buildSector的代码function buildSector(item, startAngle, endAngle, R, H, showBottom) { const span endAngle - startAngle; const list []; // 顶面 list.push({ name: item.name, type: surface, parametric: true, silent: true, shading: color, itemStyle: { color: lighten(item.color, 0.2) }, equation: { x: (u, v) { const theta startAngle (u 1) / 2 * span; const r (v 1) / 2 * R * 1.005; return r * Math.cos(theta); }, y: (u, v) { const theta startAngle (u 1) / 2 * span; const r (v 1) / 2 * R * 1.005; return r * Math.sin(theta); }, z: () H } }); // 外侧面 list.push({ name: item.name, type: surface, parametric: true, silent: true, shading: color, itemStyle: { color: darken(item.color, 0.25) }, equation: { x: (u, v) R * 1.005 * Math.cos(startAngle (v 1) / 2 * span), y: (u, v) R * 1.005 * Math.sin(startAngle (v 1) / 2 * span), z: (u) (u 1) / 2 * H } }); // 底面默认不启用 if (showBottom) { list.push({ name: item.name, type: surface, parametric: true, silent: true, shading: color, itemStyle: { color: darken(item.color, 0.4) }, equation: { x: (u, v) { const theta startAngle (u 1) / 2 * span; const r (v 1) / 2 * R * 1.005; return r * Math.cos(theta); }, y: (u, v) { const theta startAngle (u 1) / 2 * span; const r (v 1) / 2 * R * 1.005; return r * Math.sin(theta); }, z: () 0 } }); } return list; }这里有一个小细节我在顶面和外侧面的半径上都乘了1.005目的是让两个面在边缘处微微“交叠”避免因为浮点精度问题在拼接处出现细缝。这种微小的外扩几乎看不出来但能有效消除渲染裂缝。3.4 完整可运行的 HTML 示例把上面这些函数串起来再加上标签和视图控制就可以得到一张能跑起来的3D饼图。我直接给一份完整的 HTML你可以保存成.html文件浏览器打开就能看到效果。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / titleECharts 3D 饼图示例/title style html, body { margin: 0; height: 100%; background: #0f1a2e; } #chart { width: 100%; height: 100%; } /style /head body div idchart/div script srchttps://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js/script script srchttps://cdn.jsdelivr.net/npm/echarts-gl2.0.9/dist/echarts-gl.min.js/script script function hexToRgb(hex) { hex hex.replace(#, ); return { r: parseInt(hex.substring(0, 2), 16), g: parseInt(hex.substring(2, 4), 16), b: parseInt(hex.substring(4, 6), 16) }; } function mixColor(hex, mixHex, weight) { const c hexToRgb(hex); const m hexToRgb(mixHex); return rgb( Math.round(c.r * (1 - weight) m.r * weight) , Math.round(c.g * (1 - weight) m.g * weight) , Math.round(c.b * (1 - weight) m.b * weight) ); } const lighten (hex, w) mixColor(hex, #ffffff, w); const darken (hex, w) mixColor(hex, #000000, w); function buildSector(item, startAngle, endAngle, R, H, showBottom) { const span endAngle - startAngle; const list []; list.push({ name: item.name, type: surface, parametric: true, silent: true, shading: color, itemStyle: { color: lighten(item.color, 0.2) }, equation: { x: (u, v) { const theta startAngle (u 1) / 2 * span; const r (v 1) / 2 * R * 1.005; return r * Math.cos(theta); }, y: (u, v) { const theta startAngle (u 1) / 2 * span; const r (v 1) / 2 * R * 1.005; return r * Math.sin(theta); }, z: () H } }); list.push({ name: item.name, type: surface, parametric: true, silent: true, shading: color, itemStyle: { color: darken(item.color, 0.25) }, equation: { x: (u, v) R * 1.005 * Math.cos(startAngle (v 1) / 2 * span), y: (u, v) R * 1.005 * Math.sin(startAngle (v 1) / 2 * span), z: (u) (u 1) / 2 * H } }); if (showBottom) { list.push({ name: item.name, type: surface, parametric: true, silent: true, shading: color, itemStyle: { color: darken(item.color, 0.4) }, equation: { x: (u, v) { const theta startAngle (u 1) / 2 * span; const r (v 1) / 2 * R * 1.005; return r * Math.cos(theta); }, y: (u, v) { const theta startAngle (u 1) / 2 * span; const r (v 1) / 2 * R * 1.005; return r * Math.sin(theta); }, z: () 0 } }); } return list; } function buildPie3DOption(data) { const R 60; const H 18; const GAP 2 * Math.PI / 180; const total data.reduce((s, d) s d.value, 0); const n data.length; const series []; const valueMap {}; data.forEach(d (valueMap[d.name] d.value)); let curAngle -Math.PI / 2 GAP / 2; data.forEach(item { const span (item.value / total) * (2 * Math.PI - GAP * n); const start curAngle; const end curAngle span; series.push(...buildSector(item, start, end, R, H, false)); curAngle end GAP; }); let angleCursor -Math.PI / 2 GAP / 2; data.forEach(item { const span (item.value / total) * (2 * Math.PI - GAP * n); const mid angleCursor span / 2; const labelR R * 0.55; const cx labelR * Math.cos(mid); const cy labelR * Math.sin(mid); const cz H 4; series.push({ name: item.name, type: scatter3D, data: [{ name: item.name, value: [cx, cy, cz] }], symbol: circle, symbolSize: 0.5, itemStyle: { color: rgba(0,0,0,0) }, tooltip: { show: false }, label: { show: true, distance: 2, formatter: p p.name \n valueMap[p.name], color: #fff, fontSize: 14 } }); angleCursor angleCursor span GAP; }); return { tooltip: { show: true, formatter: params { return params.seriesName br/访问量 (valueMap[params.seriesName] ?? -); } }, xAxis3D: { type: value }, yAxis3D: { type: value }, zAxis3D: { type: value }, grid3D: { show: false, boxWidth: 180, boxHeight: 180, boxDepth: 180, axisLine: { show: false }, axisPointer: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, viewControl: { alpha: 35, beta: -35, distance: 220, autoRotate: false, rotateSensitivity: 1, zoomSensitivity: 0.6 } }, series: series }; } const data [ { name: 直接访问, value: 335, color: #5470c6 }, { name: 搜索引擎, value: 310, color: #91cc75 }, { name: 外部链接, value: 234, color: #fac858 }, { name: 邮件营销, value: 135, color: #ee6666 }, { name: 社交媒体, value: 148, color: #73c0de } ]; const chart echarts.init(document.getElementById(chart)); chart.setOption(buildPie3DOption(data)); window.addEventListener(resize, () chart.resize()); /script /body /html运行之后你应该能看到一个具有厚度感的3D饼图鼠标在扇区上移动会显示扇区名称和数值按住鼠标左键可以旋转视角滚动滚轮可以缩放。4. 视觉调优从“能转的几何体”到“能上大屏的图”4.1 顶面亮、侧面暗不用光照也能有立体感很多做3D图的人第一反应是开光照但我不建议一上来就开。surface 系列的光照配置比较复杂而且不同光照方向、不同分量会让扇区之间的明暗关系不可控。更实用的办法是人工指定明暗顶面是主要观看面颜色调亮侧面试图体现厚度颜色调深。上面代码里已经用lighten和darken实现了这一点。如果你希望能有真实光照的质感可以把shading: color改成shading: lambert然后在 grid3D 里加上光照配置grid3D: { light: { main: { intensity: 1.2, shadow: false }, ambient: { intensity: 0.5 } } }但要注意改用 lambert 之后顶面和侧面的亮度会受光照方向影响你手动调出的深浅可能和光照叠加后效果过曝或过暗。我的习惯是先用shading: color把整体配色做到位再根据现场效果决定要不要换光照。4.2 间隙、高度和半径控制“肉”的厚薄3D饼图好不好看很大程度取决于三个参数半径 R、高度 H、扇区间隙 GAP。R 决定了饼图在画布上的大小R 60 在默认 900x600 容器里比较合适。如果容器更大可以按比例调大。H 决定了饼的厚度。H 太小看不出3DH 太大又像个小圆柱体不像是饼图。我一般让 H 控制在 R 的四分之一到三分之一之间比如 R 60H 取 15~20 比较舒服。GAP 我固定为 2 度。间隙太小扇区之间分不出边界间隙太大整体看上去会碎成好几块。建议不要超过 4 度。另外如果想让扇区之间完全不留缝隙只要把 GAP 设为 0 即可。但前面说过这时候相邻的切面边界可能在渲染时出现细缝所以真不建议设 0。4.3 标签用 scatter3D 挂在扇区上方3D 饼图的标签是个麻烦事原生 pie 的 label 配置在 surface 上不生效。我的解法是每个扇区单独加一个scatter3D系列数据点放在扇区顶面的中心上方。代码里我用const labelR R * 0.55; const mid (start end) / 2; const cx labelR * Math.cos(mid); const cy labelR * Math.sin(mid); const cz H 4;labelR取外半径的 0.55 倍刚好落在扇区扇形部分的重心附近视觉上比较居中。cz H 4让标签悬浮在顶面之上不会被曲面遮挡。scatter3D 的symbolSize设为 0.5itemStyle.color设为透明这样用户看不到散点点只能看到 label 文字。label 的 formatter 用换行把名称和数值拼在一起效果类似“搜索引擎\n310”。如果你不想显示数值只保留名称把\n后面那段去掉就行。4.4 视角和自动旋转的参数建议viewControl是控制3D视角的核心。我推荐一组大屏项目里比较稳的参数viewControl: { alpha: 35, beta: -35, distance: 220, autoRotate: false, rotateSensitivity: 1, zoomSensitivity: 0.6 }alpha是观察角0 表示平视90 表示正俯视。35 左右能同时看到顶面和外侧面立体感最明显。beta是水平旋转角-35 会让饼图微微侧过来比完全正对更有设计感。distance是相机距离根据容器大小调整。容器较大时可以调到 250~300。autoRotate开启后会自动旋转适合大屏无人交互的场景但如果需要鼠标操作或展示 tooltip建议关掉否则鼠标还没移过去图就转走了。5. 交互与 tooltip最能劝退人的部分这里给一套能落地的折中方案5.1 surface 自带 tooltip 的实测效果很多教程会告诉你 surface 系列不能触发 tooltip其实是不准确的。surface 作为 ECharts 的一种系列本身是支持 tooltip 事件的只是体验和2D饼图不太一样。上面完整的示例代码里我给所有 surface 系列设置了相同的 name也就是扇区名当鼠标在曲面上移动时tooltip 的 formatter 拿到的params.seriesName就是扇区名称然后数值可以通过valueMap查出来。实际打开页面试一下鼠标扫过顶面时tooltip 是可以弹出来的。但要注意两个限制tooltip 触发的“热区”是曲面的网格点不是整个几何体。如果扇区之间留了间隙间隙处没有热区。每个扇区由两个 surface 系列组成鼠标在这个扇区的顶面和侧面移动时触发的是同一个 seriesName所以从用户交互角度看不出区别这反而是优点。如果你的场景只是展示对 tooltip 要求不高surface 自带的 tooltip 足够用了。如果项目 PM 强烈要求“鼠标移到某个扇区上其他扇区变灰只有当前扇区高亮”那 surface 自带的交互就不够了。5.2 固定视角 透明2D饼图交互层我在项目里用的一个比较“脏”但非常有效的方案是在3D饼图之上叠加一个普通的2D pie 系列把所有 itemStyle 的透明度设为 0.01用它来承接鼠标事件。要注意2D饼图必须和3D投影大致对齐。所以这个方案的前提是关闭旋转和缩放viewControl: { alpha: 35, beta: -35, distance: 220, rotateSensitivity: 0, zoomSensitivity: 0 }然后额外添加一个 series{ type: pie, radius: 55%, center: [50%, 50%], data: data.map(item ({ name: item.name, value: item.value, itemStyle: { opacity: 0.01, color: item.color }, emphasis: { itemStyle: { opacity: 0.01 } } })), tooltip: { trigger: item } }这里radius和center需要你手动微调让2D扇区和3D扇区的投影大致重合。因为3D饼图有透视倾斜严格来说投影不是一个正圆但观察角 alpha 固定之后眼睛对偏差的容忍度比较高微调到“看着差不多”即可。这个方案的好处是tooltip、legend、点击事件全都用回2D原生的非常稳定。坏处是它只能用于固定视角不能旋转否则透明2D层和3D层会错位。5.3 点击高亮通过 chart.on 修改扇区颜色如果你希望在真·3D模式下点击某个扇区可以做高亮可以监听图表的点击事件拿到扇区名称后用 setOption 把这个扇区所有 surface 的颜色改成高亮色同时把其他扇区的颜色压暗。大致思路chart.on(click, params { const name params.seriesName; const newSeries []; // 遍历当前 option 里的 series // 遇到 name 等于目标名称的 surface颜色调亮 // 遇到 name 不等于目标名称的 surface颜色压暗 chart.setOption({ series: newSeries }); });因为每个扇区由多个 surface 系列组成setOption 会根据 series 的 index 进行合并所以这里操作起来要小心最好给每个 surface 系列设置固定的id比如id: sector- item.name -top这样在 setOption 时就能精确定位。这个方法比透明2D层更“真3D”但需要自己维护高亮状态代码量会多不少。如果你只是给客户演示用透明2D层就够了没必要在这上面花太多时间。6. 别硬刚 3D伪3D方案与大屏选型建议6.1 2D饼图偏移加阴影的“伪厚度”做法不是所有大屏都需要真正的3D视角旋转。很多时候需求方说的“3D饼图”其实只是要“看起来有立体感”。这种情况下一个更轻量、更可控的方案是用两个2D饼图叠加。底层饼图作为“厚度”把 center 稍微向下或向右偏移几个像素颜色用深色先渲染。顶层饼图正常渲染和底层错开一点点视觉上就形成了厚度。还可以在顶层饼图上设置itemStyle: { shadowBlur: 10, shadowColor: rgba(0, 0, 0, 0.3) }这样边缘带一点阴影立体感更强。这种方案的优点是 tooltip、图例、动画全部原生支持没有任何版本兼容风险也不存在“鼠标点不到”的问题。缺点是它不能旋转换一个角度就露馅了。6.2 真3D vs 伪3D的取舍清单我在不同项目里分别用过真3D和伪3D总结下来可以按这个标准选需求特征推荐方案大屏只是静态展示偶尔有人操作真3D surface配合自动旋转视觉效果震撼需要稳定的 tooltip / 点击高亮 / 图例联动伪3D或固定视角 透明2D交互层视角需要自由旋转且交互要精确考虑直接用 three.js 画饼图ECharts 只负责图例移动端 / 低配电脑伪3D真3D 的 surface 渲染性能消耗较大扇区超过10个真3D 会明显卡顿建议伪3D真3D 的方案在视觉冲击力上确实强但交互和性能的代价是实打实的。尤其是“扇区多”的情况每个扇区两个 surface 系列总共十几个 series在低端机上旋转起来帧率会很难看。6.3 版本、性能与大屏部署的避坑汇总最后把我在实践里踩过的坑集中说一下版本问题echarts-gl 2.0.9 和 echarts 5.x 搭配是我验证过最稳的组合。如果用 npm 安装后出现报错优先检查是不是装成了 echarts-gl 1.x。CDN 白屏如果直接用 script 标签引入必须保证 echarts-gl 的 js 文件在 echarts 之后加载。顺序反了ECharts 实例上根本没有surface等3D系列。surface 数量surface 系列的采样网格密度是内部控制的每个曲面的计算量都不小。一个扇区尽量控制在两个面顶面外侧面底面能省就省。底面在默认视角下完全看不到没必要为它多开一个 series。透明渲染顺序如果给 surface 设置了 opacity 半透明要注意3D场景中的透明物体会出现遮挡关系错乱大屏项目里尽量避免给扇区加透明度。想要深色背景配合直接给容器加背景色不要让扇区本身透下去。resize 之后变形3D 图表在容器尺寸变化后viewControl 的 distance 看起来可能会不适应。可以在 resize 之后重新 setOption或者根据容器宽度动态计算 distance。标签重叠扇区多或数值差大的时候scatter3D 标签容易挤在一起。可以用label.formatter只显示百分比而不显示名称或者手动调整 labelR把标签放到扇区外部。我个人现在做这类需求时心里基本有个固定判断流程先问这个饼图要不要旋转要不要精确的 tooltip 联动。要精确交互就直接走伪3D路线省心纯粹追求视觉冲击力就用真3D surface但把扇区数量控制在 6 个以内标签用 scatter3D视角固定到一个好看的角度再开个自动旋转。这样既能给客户展示“3D效果”又不会把自己坑在无休止的交互调试里。
返回列表