想象你在一个漆黑的房间里,想要让观众看清一件精美的雕塑。你需要在正确的位置放一盏射灯、给雕塑搭一个旋转的展示台、让观众能绕着雕塑走动、甚至伸手推动它旋转。Three.js 就是你手里的这套“舞台搭建工具箱”——只不过舞台从现实世界搬进了浏览器。 传统的二维图表像是贴在墙上的海报,信息再多也只能平铺展开。而 3D 可视化像是把海报变成了一个可以走进的展厅:数据点有了空间位置,指标的高低变成了立体山峰,关系网络变成了交错的光带。这篇文章会带你用 Three.js 从零搭建这样一个“数字展厅”:先铺好舞台地面(场景),装好观众席视角(相机),打开灯光(光照),让演员登台(几何体与模型),最后给演员写好剧本(数据绑定)——让静态的数据真正地“活”起来。
掌握 Three.js 3D 可视化,前端开发者能把冰冷的数据表格变成有冲击力的交互展厅,为数字孪生、智慧园区大屏等场景提供下一代数据呈现方案,大幅提升技术汇报与产品演示的感染力。
可视化演示
动态演示用Three.js打造浏览器的核心机制,通过采集/分析/决策/反馈四种模式展示关键概念与交互流程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
用Three.js打造浏览器:Thre Three.js的场景图与渲染 相机系统与OrbitContr 核心机制可视化 用Three.js打造浏览器整体架构与关键组件
搭建3D舞台
你还记得小学文艺汇演的舞台吗?老师先让工人铺好地板、挂好幕布,再请调音师把音响调试到位——在一切都准备好之前,演员是不能上台的。Three.js 的世界是一样的:第一步不是急着画图形,而是先把这个“虚拟剧院”的地基打牢。
这个地基由两个大件组成。第一件是场景(Scene),它是所有物体共同生活的空间——相当于整个舞台的物理范围。第二件是渲染器(Renderer),它像一个放映机的镜头,负责把舞台上的画面“拍摄”下来并投影到浏览器屏幕上。你创建了一个场景、放进一个渲染器,再配上舞台地板(背景色),地基就打好了。
不过,地基打好之后还缺一个“放映员”——他需要不停地把舞台画面投到屏幕上。渲染循环就是这个放映员:它用 requestAnimationFrame 让画面以每秒约 60 帧的速度持续刷新。如果没有这个循环,你只能看到一张静止的照片;有了它,舞台上的演员才能真正“动”起来。
渲染循环这个“放映员”一旦上岗,舞台就活了。地基搭好了,下一步该给舞台安装观众席的“眼睛”——也就是相机了。
requestAnimationFrame 自动维持这个循环。- 每秒渲染帧数表示观众看到的画面刷新速度,单位是 fps(frames per second,帧每秒)。
\text{每秒渲染帧数}用文字描述了这个单位。 - 1000ms1 秒等于 1000 毫秒,是总时间池。
- Δt(每帧渲染耗时)渲染器完成一帧画面所需的时间,包括所有物体位置计算、光照计算和像素绘制。用
\Delta t_{\text{每帧渲染耗时}}标明它是“每帧”的时间。 - 两者相除用总时间除以单个帧的时间,就得到每秒能渲染多少帧。
const scene = new THREE.Scene();const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);const renderer = new THREE.WebGLRenderer();renderer.setSize(window.innerWidth, window.innerHeight);document.body.appendChild(renderer.domElement);
控制观众视角
假设你站在舞台前面,想看清楚演员的表情,你得往前走两步;想看清整个舞台的布局,你得退到后排。相机在 Three.js 里的角色,就是这双“观众的眼睛”——它决定你的观众从哪个位置、以什么角度看到这个 3D 世界。
Three.js 提供了两种最常见的“眼睛”。一种是透视相机(PerspectiveCamera),它模拟真实人眼,有“近大远小”的效果——就像你看远处的山很小、走近了才发现它巨大无比。另一种是正交相机(OrthographicCamera),它没有透视变形,物体无论远近大小都一样,适合看工程图纸或 2.5D 地图。
有了合适的“眼睛”,还得让观众能自由地转动头部。轨道控制器(OrbitControls) 就像给观众装了一个万向转椅:按住鼠标左键拖动可以旋转视角,滚动滚轮可以拉近拉远,按住右键可以平移——观众想看哪里就看哪里。
数据的空间感,全靠这双“眼睛”来呈现。
- 透视投影说明这个公式描述的是透视相机将 3D 坐标投影到 2D 屏幕的过程。
- (x',y')带撇号的坐标表示物体最终在屏幕上显示的位置,
\begin{pmatrix} x' \\ y' \end{pmatrix}表示这是一个二维向量。 - f(焦距)
f是相机的焦距,焦距越大,视野越窄,物体显得越大,\frac{f}{z}将其放在分子上。 - z(深度)
z是物体到相机的距离。它在分母上,意味着 z 越大,缩放越小。 - 乘以原始坐标整个分数作用在
\begin{pmatrix} x \\ y \end{pmatrix}上,将原始坐标缩放得到屏幕坐标。
点亮舞台灯光
如果你在黑暗中看一个演员,你只能看到他的轮廓,完全看不清服装的细节。舞台美术师知道:灯光比演员更重要。Three.js 里的光照系统同样如此——数据模型做得再精细,没有合适的光,画面也会平淡如纸。
Three.js 提供了几种“灯”。环境光(AmbientLight) 像剧场的壁灯,均匀地照亮所有角落,没有明显的明暗对比,让物体不至于一片漆黑。平行光(DirectionalLight) 像太阳,光线是平行的,能产生清晰的阴影,适合模拟户外场景。点光源(PointLight) 像舞台上的追光灯,从一个点向四周发散,近处亮、远处暗。
有了光,物体还需要“衣服”来反射光——这就是材质(Material)。MeshBasicMaterial 像一件不反光的棉布衣,不受光照影响,只显示自己的颜色;MeshLambertMaterial 像哑光面料,能吸收部分光;MeshPhongMaterial 像丝绸或金属,带有高光反射,看起来更有质感。选对材质,数据点的“颜值”会大幅提升。
灯光打好了,演员该登场了。
- I(表面)
I_{\text{表面}}表示物体表面实际接收到的光照强度。 - I(光源)
I_{\text{光源}}表示光源本身的发光强度。 - distance²
\text{distance}^2是物体到光源距离的平方。它在分母上,说明距离一变大,光强急剧下降。 - 整体关系分子是光源强度,分母是距离平方,两者相除得到最终的表面亮度。
邀请演员登场
舞台、观众席、灯光都准备好了,现在轮到主角登场。这个主角就是几何体(Geometry)。Three.js 内置了各种“身形”——球形(Sphere)、立方体(Box)、圆柱体(Cylinder)——就像服装店里的基本款模特,你直接拿起就能用。
但光有身形还不够,演员还得穿上衣服才能见人。在 Three.js 里,网格对象(Mesh) 就是“身形 + 服装”的组合:它将一个几何体(身形)和一个材质(服装)打包成一个可以被放入场景的对象。你可以把它比作出场单上的完整角色卡:
当数据的形状超过简单的几何体时,比如要展示一台精密的发动机模型,就得让专业的建模师在 Blender 或 C4D 里雕好模型,再以 GLTF/OBJ 格式导入到 Three.js 中。这就像是从服装店直接请来一位穿着成衣的演员——你不需要亲手缝制每件衣服,只需要加载他的“档案”。
布景完成,演员已就位,剧场的准备工作全部结束。但要让这场演出真正有“剧情”,还得让演员按照剧本表演——这就是数据绑定。
- Mesh(网格对象)
\text{Mesh}表示 scene 中可渲染的最小完整单位,它是最终被渲染器绘制的对象。 - Geometry(几何体)
\text{Geometry}定义物体的几何形状,即顶点坐标、面的连接方式等信息。 - ⊕(组合符号)
\oplus表示“组合”,Mesh 不是几何和材质的简单叠加,而是有机地组合成一个新对象。 - Material(材质)
\text{Material}决定了物体表面如何与光交互——是哑光、高光还是发光。
const geometry = new THREE.BoxGeometry(1, 1, 1);const material = new THREE.MeshPhongMaterial({ color: 0x818cf8 });const cube = new THREE.Mesh(geometry, material);scene.add(cube);
编写剧本台词
一个 3D 场景就像一场舞台剧:演员有台词,灯光有提示,道具要配合。在数据可视化里,剧本就是你的数据。Three.js 中的数据绑定,本质上就是回答这样一个问题:数据的每一个数值,该控制舞台上的什么参数?
最常见的映射方式是“数据控制柱子的高度”。销售额高,柱子就高;客户量多,球体就大。这种映射的数学基础是归一化——先把数值范围缩放到一个合适的比例尺上,防止数据大的把舞台捅破,数据小的看不见。
除了高度,数据还可以映射到颜色和大小。在散点图中,每个点是一个小球体,点的颜色可以表示类别(红色警戒、绿色健康),点的大小可以表示权重。这一思路与我们之前在 Canvas 2D 条形图中熟悉的“数据到视觉编码”完全一致,只是 Three.js 把它扩展到了三维空间。
当观众用鼠标点击场景中的一个数据点时,Three.js 需要用射线检测(Raycaster) 来判定鼠标的二维坐标与哪个 3D 物体相交——这相当于“顺着观众的视线发射一束看不见的光,看看它碰到了哪位演员”。
剧本写好了,但舞台上的演出应该是活的。
- P(t)
\mathbf{P}(t)表示射线在前进 t 个单位后所到达的空间位置,是一个三维向量。 - O(起点)
\mathbf{O}是射线的起点,在拾取场景中它通常是相机的位置坐标。 - t(步长)
t是一个非负的标量参数,表示射线传播的距离,数学上每取一个 t 就得到射线上一个点。 - D(方向)
\mathbf{D}是射线的单位方向向量,决定射线朝哪个方向发射。整个公式就是一个参数化射线方程。
const normalizedValue = (value - min) / (max - min) * maxHeight;让数据活起来
静态的 3D 场景只是一张漂亮的截图。真正的“可视化”要能让观众感受到数据的生命力——柱子缓缓升起、数据点轻轻浮动、视角平滑旋转。这就是 Three.js 动画体系要解决的问题。
前面提到的 渲染循环 在这里发挥关键作用:动画的本质,就是在每一帧渲染前微调场景中各物体的属性(位置、角度、透明度等),以“每一帧都变一点”的方式累计出运动的错觉。这与你小时候在书本角落画小人、快速翻页形成动画的原理完全一样——但 Three.js 替你完成了那本“书”。
为了让运动平滑而非跳变,需要引入 插值(Interpolation)。比如一个数据点要从位置 A 移动到位置 B,我们不希望它瞬间闪现,而是希望它在 1 秒内匀速地滑过去——于是每一帧计算这次移动的中间位置,呈现出一条平滑的轨迹。
有了渲染循环和插值这两个“时间魔法师”,我们还能给场景加上交互——用户点击一个数据柱子,柱子自动旋转一圈或展开详细面板。Three.js 的交互事件体系让用户不再是“被动看戏的观众”,而是能“走上舞台参与表演的嘉宾”。
至此,一套完整的 Three.js 3D 数据可视化方案已经成型。
- P(t)
\mathbf{P}(t)表示 t 时刻插值点的位置。 - (1-t)·A当 t=0 时,
(1-t)\cdot\mathbf{A}= A,即起点生效;当 t=1 时这一项为 0,起点不再影响结果。 - t·B当 t=1 时,整个式子等于 B;当 t=0 时此项为 0。
- t ∈ [0,1]
t \in [0, 1]表示 t 的取值范围,超出这个范围就不叫插值而叫外推了。
AI 应用场景
智慧园区数字孪生大屏
阶段:数据呈现与决策支持
解决的问题:传统园区管理用二维平面图展示楼宇、人流、能耗数据,信息层级不直观,运维人员难以快速定位异常区域。用 Three.js 构建园区 3D 数字孪生模型后,每栋楼的能耗、温度、人流量实时映射为 3D 柱子的高度和颜色,异常区域一目了然。
具体案例:某科技园区将园区内 12 栋楼的实时水电数据绑定到 Three.js 搭建的 3D 模型楼宇上,楼顶的柱状体随能耗升高而长高,绿色到红色渐变表示能耗等级。运维人员在 3D 场景中点击任意楼层即可下钻到楼层平面控制台。结合我们在“编写剧本台词”中学到的射线拾取技术,点击一栋楼会高亮显示并弹出详单。
AI 训练数据分布演示
阶段:数据探索与特征分析
解决的问题:做机器学习时,面对高维数据我们常常“看不见”它们的分布。把高维特征用 PCA 或 t-SNE 降维到三维空间后,用 Three.js 散点图展示聚类效果,数据分布变得直观可感。
具体案例:AI 团队在训练图像分类模型时,用 Three.js 构建了 MNIST 手写数字的 3D 特征分布图。每个数字样本是一个小球,颜色代表预测类别,小球之间的距离代表特征相似度。模型训练过程中,团队发现某些类别的小球混杂在一起,定位到是数据标注错误导致了分类边界模糊,修正后模型准确率提升了 4%。
电商 3D 商品展示
阶段:产品表达与用户交互
解决的问题:传统电商商品展示依赖二维照片,用户无法真实感知商品形状。用 Three.js 加载 GLTF 模型,实现 360 度旋转查看和材质切换,让用户在购买前“把商品拿在手里端详”。
具体案例:某家具电商平台用 Three.js 搭建了 3D 展厅,用户可以通过轨道控制器(就是“观众的万向转椅”)旋转沙发模型、切换不同布料材质(切换材质),避免实际购买后“色差”和“尺寸误差”的退换货痛点。接入 AI 家具摆放推荐后,用户还能将 3D 沙发拖入自家照片中看效果。
Python 演示
import numpy as npimport matplotlib.pyplot as plt def normalize(arr, new_min=0, new_max=100): """把数据归一到 [new_min, new_max] 区间,模拟 Three.js 坐标映射""" arr_min, arr_max = arr.min(), arr.max() if arr_max == arr_min: return np.full_like(arr, (new_min + new_max) / 2) return (arr - arr_min) / (arr_max - arr_min) * (new_max - new_min) + new_min def generate_sales_data(n_points=50): """随机生成模拟的销售数据""" rng = np.random.default_rng(42) return { 'sales': rng.integers(50, 500, n_points), 'profit': rng.integers(5, 100, n_points), 'city_code': rng.integers(0, 5, n_points), } def build_3d_scene(data): """把数据映射成 3D 场景中的位置、大小、颜色""" sales_norm = normalize(data['sales'], 0, 100) profit_norm = normalize(data['profit'], 0, 100) city_idx = data['city_code'] colors = plt.cm.viridis(city_idx / city_idx.max()) sizes = 20 + sales_norm * 3 fig = plt.figure(figsize=(10, 8)) ax = fig.add_subplot(111, projection='3d') ax.scatter(sales_norm, profit_norm, city_idx * 10, c=colors, s=sizes, alpha=0.7) ax.set_xlabel('Sales (0-100)') ax.set_ylabel('Profit (0-100)') ax.set_zlabel('City Code') ax.set_title('3D Sales Data Visualization (Simulating Three.js Scene)') return fig, ax if __name__ == '__main__': data = generate_sales_data() fig, ax = build_3d_scene(data) plt.show()
def normalize(arr, new_min=0, new_max=100):归一化函数把任意数值范围的数据缩放到目标区间,对应 Three.js 场景中把业务数据映射到三维坐标时的缩放逻辑。(arr - arr_min) / (arr_max - arr_min) * (new_max - new_min) + new_min标准最大最小归一化公式:先计算数据在原始范围的比例,再按比例搬到新区间。rng.integers(50, 500, n_points)生成 50 个城市的模拟销售额数据,该数据后续映射为 3D 场景中散点的位置。sales_norm = normalize(data['sales'], 0, 100)把原始销售额映射到 0-100 的“场景坐标空间”,对应 Three.js 中z = normalize(value)的操作。sizes = 20 + sales_norm * 3保持销售额同时控制点大小,演示“一个数据维度驱动多个视觉通道”的思路。ax.scatter(sales_norm, profit_norm, city_idx * 10, c=colors, s=sizes, alpha=0.7)核心的 3D 散点绘制函数:参数 c 和 s 分别绑定颜色与大小,等价于 Three.js 中的Mesh颜色与缩放属性。plt.cm.viridis(city_idx / city_idx.max())将城市编号映射为颜色值,对应 Three.js 中“数据类别→材质颜色”的编码方式。
常见误区
误区我一直分不清 Scene、Camera、Renderer 到底有什么区别,觉得它们都是“画布”的一部分,随便用哪个都能画东西。
正解这三者分工完全不同:Scene 是放物体的舞台(容器),Camera 是拍舞台的摄像机(视角),Renderer 是把摄像机看到的画面画到屏幕上的放映机(绘制工具)。缺少任何一个,画面都出不来。用剧场的比喻来说:场景是舞台的空间范围,相机是摄影师的机位,渲染器是摄影师按下快门后冲洗照片的过程。
为何常见:初学时看到官方示例里三行代码几乎固定不变,觉得它们是一个“固定搭配”,于是把它们混杂在一起。没意识到它们各自负责生命周期中完全不同的阶段。
误区DirectionalLight 和 PointLight 都是灯光,效果应该差不多,只是名字不一样而已。
正解区别巨大。平行光的光线是平行的,光源可以想象在无限远处,全场照度均匀,适合模拟太阳;点光源的光线是发散的,从某个点向外扩散,有强烈的距离衰减。用舞台比喻:平行光是头顶上方一排均匀的平板灯,全场亮暗一致;点光是追光灯,只照亮舞台上的一点,越远越暗。
为何常见:因为日常生活中“灯”这个字太过笼统,我们不会区分灯在物理上造成的亮暗差异。而在 Three.js 中,每种光的数学模型不同,渲染效果天差地别。
误区我在网上下载了一个 GLTF 模型,用 `load` 方法加载后以为它会自动出现在屏幕上,结果一片空白。
正解加载只是“把演员请到后台”,你必须做两件事才能让他上场:第一,把加载到的场景(gltf.scene)添加到你的主 Scene 中;第二,确认你的相机位置和大小能“看到”他。另外,很多模型本身自带材质,但模型过大或居中设置不当,也会导致加载成功后因为缩放比例和相机位置问题而看不到。
为何常见:把“加载成功”和“渲染可见”混为一谈。真实世界的排练也一样:演员到了后台不代表他已经站在舞台中央,导演还要安排他上场并调整他的站位。
误区我把销售额数据直接赋给柱子的高度,为什么不显示 5000 那么高的柱子?
正解Three.js 使用的三维坐标系单位是相对值,不是真实物理单位。5000 与场景中相机的位置(通常 z 在 5-100 之间)不匹配,会飞出视野。必须先做归一化——把数据映射到 0-1 或 0-100 的场景单位区间,再乘以期望的最大高度。就像你在 PPT 中放入一张巨大的照片,必须缩放它才能适应画布。
为何常见:新手容易把数据值直接当成坐标值使用,忽略了坐标系需要一个适配数据范围的缩放过程。
误区我应该给所有物体都添加动画,这样场景看起来更炫酷!于是每一帧更新几十个物体的旋转和位置,结果页面卡得要死。
正解动画循环本身很简单,但每一帧要执行的更新操作越少,性能越好。每帧更新大量对象的属性、调用 render,都会占用 GPU/CPU。好的做法是:能不动的物体就不动,只对必要的对象做更新;复杂的模型可以预烘焙到纹理上;需要大量动画时考虑使用 GPU 粒子系统。
为何常见:被炫酷的 3D 效果吸引,以为“越多越强”,忘记每帧都是性能预算有限的实时渲染。真实演出也一样:舞台上所有特效同时开火,反而会让观众眼花缭乱,还会导致设备过载。
面试题
Q1. 在 Three.js 中,Scene(场景)的主要职责是什么?
- A.负责绘制像素到屏幕上
- B.存储物体、灯光和相机← 正确答案
- C.计算物体的物理运动规律
- D.创建纹理和材质资源
答案:B。Scene 是存放物体的容器,相当于舞台空间,它包含所有网格、灯光和相机,渲染器从场景中读取需要绘制的内容。A 是渲染器的职责,C 是物理引擎的工作,D 是纹理加载器的工作。
Q2. 在 Three.js 中,OrthographicCamera 与 PerspectiveCamera 的主要区别是什么?
- A.前者有背景色,后者没有
- B.前者没有近大远小效果← 正确答案
- C.前者支持阴影,后者不支持
- D.前者只能用于正交三维图
答案:B。正交相机没有透视变形,物体大小不随距离变化,工程图常用;透视相机模拟人眼,有近大远小效果。A、C、D 均为干扰项,阴影与相机类型无关。
Q3. requestAnimationFrame 在 Three.js 渲染循环中的主要作用是什么?
- A.将画面输出到外部显示器
- B.在下一帧刷新前调用回调函数← 正确答案
- C.处理鼠标和键盘的输入事件
- D.计算场景中物体的碰撞结果
答案:B。requestAnimationFrame 在浏览器准备刷新画面时调用传入的回调,让动画循环以屏幕刷新率运行,从而保证流畅。A 是浏览器渲染管线的工作,C 是事件系统,D 是物理引擎的职责。
Q4. 下列哪种光源最适合模拟户外阳光直射的明亮效果?
- A.环境光 AmbientLight
- B.平行光 DirectionalLight← 正确答案
- C.点光源 PointLight
- D.聚光灯 SpotLight
答案:B。平行光的光线互相平行,来自无限远处,模拟太阳光非常合适。环境光均匀照亮所有表面、没有方向性;点光源从一点向外发散,有距离衰减;聚光灯呈锥形照射,适合局部强调。
Q5. 将一个网格对象成功添加到场景后,还需要加入什么才能让它显示出来?
- A.AxesHelper 坐标轴辅助
- B.Camera 和 Renderer← 正确答案
- C.Raycaster 射线检测
- D.BufferGeometry 缓存几何体
答案:B。场景中有了物体之后,必须有相机决定“从哪个角度拍”、渲染器决定“怎么画到屏幕上”,两者缺一不可。A 是辅助工具,C 是交互拾取工具,D 是几何体内部结构,都不直接影响渲染。
Q6. 关于 Three.js 中数据与视觉通道的映射,下列说法正确的是哪些?
- A.数据可以映射为物体的大小← 正确答案
- B.数据可以映射为物体的颜色← 正确答案
- C.数据可以映射改变物体的材质← 正确答案
- D.数据必须映射为物体的位置
答案:ABC。Three.js 数据绑定的灵活性体现在多种视觉通道:大小、位置、颜色、透明度、材质属性等都可以被数据驱动。D 过于绝对,位置只是其中一种可能的映射方式,并非“必须”,所以排除。
Q7. Three.js 中,Raycaster 主要的功能是什么?
- A.优化场景渲染性能
- B.检测射线与物体的相交← 正确答案
- C.计算阴影的投射方向
- D.生成程序化纹理贴图
答案:B。Raycaster 从某个点沿方向发射一条射线,检测它是否与场景中的网格相交。拾取就是利用这个技术判断鼠标点击是否命中某个 3D 物体。A 是渲染优化器,C 是阴影计算,D 是纹理生成,都与射线无关。
Q8. 以下哪些做法能提升 Three.js 场景的渲染性能?
- A.在动画循环中动态改变所有光源的位置
- B.仅对需要更新的物体修改属性← 正确答案
- C.对静态场景关闭重新渲染← 正确答案
- D.使用大分辨率纹理给每个物体贴图
答案:BC。动画循环中每帧只应更新真正变化的物体;如果场景完全静止,可以只渲染一帧,省略重复渲染。A 中每帧移动所有光源会大幅增加计算量;D 大纹理占用显存,反而降低性能。
Q9. 在 Three.js 中,关于 MeshPhongMaterial 与 MeshLambertMaterial 的异同,下列描述正确的有哪些?
- A.Phong 材质支持高光反射← 正确答案
- B.Lambert 材质受光照影响← 正确答案
- C.Phong 材质不受光照影响
- D.Lambert 材质计算量通常更小← 正确答案
答案:ABD。MeshPhongMaterial 支持镜面高光,能呈现光泽感;MeshLambertMaterial 只计算漫反射,没有高光,因此计算量较小。C 错误——Phong 材质也受光照影响,只是多了高光分量。
Q10. 线性插值公式 P(t) = (1-t)·A + t·B 中,当 t=0.5 时,P 位于什么位置?
- A.线段 AB 的三分之一处
- B.线段 AB 的中点← 正确答案
- C.靠近 B 点四分之一处
- D.线段 AB 延长线之外
答案:B。当 t=0.5 时,P 等于 A 和 B 各占一半的加权平均,正好是线段 AB 的中点。t 从 0 到 1 线性变化时,P 从 A 匀速移动到 B,这是平滑动画的基础。其他比例分数均不符合 t=0.5 的语义。