很多人第一次接触Laya3D,心里想的第一件事往往不是渲染,不是Shader,而是怎么把一个物体从A点挪到B点。这个需求听着基础,其实卡住过不少人。我在刚开始接触Laya3D时,也是从“创建一个Cube、让它移动到某个点”开始入门的。借着这个很基础但很关键的题目,我把自己在实际项目里摸出来的经验整理出来,把Laya3D里的坐标、Transform、移动方案一次说透,希望能帮你少走弯路。
先说结论:物体移动到某一点,本质上就两件事——确定“目标点在哪里”,然后持续修改“物体的位置”。但Laya3D里涉及坐标系、世界坐标与局部坐标、帧循环、插值算法等内容,一环扣一环,任何一处理解不到位,代码跑起来都会出现“人到不了”“跑太快”“转过头了”之类的怪问题。下面按我的理解顺序,从基础到实操,一步一步拆开讲。
1. Laya3D基础:先搞懂3D空间再谈移动
1.1 坐标系和Vector3:位置在引擎里到底是什么
3D场景中,任何一个物体的位置都用三维向量表示,就是大家熟悉的(x, y, z)。在Laya3D里这个数据结构叫Laya.Vector3。它有三个分量x、y、z,用来表示一个点在三维坐标系里的位置。
这里有个容易混淆的地方:Vector3既能表示“位置点”,也能表示“位移向量”或“方向向量”。当你写“移动到某一点”时,你操作的是位置点;当你计算“从A点到B点的方向”时,你用的是向量减法得到的结果,本质上是一个位移量。同一个Vector3,含义完全不同。建议在心里把这两个概念分开,写代码时就不容易乱。
常见坐标系轴向记忆方式:Y轴是向上的方向,XZ构成水平面。至于Z轴正方向是朝屏幕里还是朝屏幕外,不同引擎不一样,而且相机一旋转就全变了,所以不要死记Z轴方向,要以相机和物体之间的相对关系为准。想定位物体时,直接想“X指左右、Y指上下、Z指前后”就够了。
1.2 场景、相机、物体:3D世界的三件套
Laya3D里一个可运行的最小世界,至少要有三样东西:场景、相机、物体。
场景是容器,所有3D物体都挂在场景下面。相机的角色是“眼睛”,没有相机,你再怎么移动物体,屏幕上也不会显示任何画面。物体则是你操作的主角,比如一个Cube、一个角色模型。
以代码创建一个最简单场景为例:
// 初始化3D环境 Laya3D.init(0, 0); // 创建场景 let scene = Laya.stage.addChild(new Laya.Scene3D()); // 创建相机 let camera = new Laya.Camera(); camera.transform.position = new Laya.Vector3(0, 3, 10); camera.transform.lookAt(new Laya.Vector3(0, 0, 0), new Laya.Vector3(0, 1, 0)); scene.addChild(camera); // 创建灯光,没有灯光的话物体只能看到轮廓,效果很怪 let light = new Laya.DirectionLight(); light.transform.position = new Laya.Vector3(0, 5, 0); light.transform.rotate(new Laya.Vector3(-45, 0, 0), true, false); scene.addChild(light); // 创建物体:一个边长1米的正方体 let cube = scene.addChild(new Laya.MeshSprite3D(Laya.PrimitiveMesh.createBox(1, 1, 1)));这段代码里我特意给相机加了lookAt,让相机看向原点,这样Cube刚好在画面中央。新手最容易在这里犯的错是:相机建了但角度不对,物体明明在场景里,却怎么都看不到。所以你先跑通这段最小场景,确认Cube显示在画面里,再考虑移动逻辑。
提示:不同版本Laya的API名称略有差异,比如高版本里可能用
Sprite3D或Mesh替代MeshSprite3D。写代码时以你当前安装版本的API文档为准,思路才是共通的。
1.3 Transform:物体身上那张“坐标名片”
移动一个物体,本质上改的是它的transform,而不是直接改模型顶点数据。每个3D物体都挂了一个Transform3D组件,它记录了物体的位置、旋转、缩放信息。在代码里,操作对象长这样:
cube.transform.position = new Laya.Vector3(3, 0, 0);这一行代码的意思是把Cube放在世界坐标的(3, 0, 0)处。这里有一个新手误区:有人会尝试直接给cube.position赋值,但在Laya3D里,MeshSprite3D并不直接暴露position,标准写法一定是通过transform来操作。
理解了这个之后,移动问题就简化成了:在一个合适的时机里,不断修改transform.position,让它从当前值逐步逼近目标值。
2. 动手移动前必须搞清楚的三个关键概念
2.1 position、localPosition、translate:三个容易混的兄弟
在Laya3D中,和位置相关的API主要有三个:transform.position、transform.localPosition、transform.translate(...)。很多奇怪的移动问题,都是这三个概念混用导致的。
我整理了一个对比表:
| 操作方式 | 本质 | 特点 | 典型使用场景 |
|---|---|---|---|
transform.position | 设置世界坐标 | 赋值后物体直接到目标点,瞬间完成 | 初始化、传送、重置位置 |
transform.localPosition | 设置局部坐标 | 相对父节点而言,父节点变了它也跟着变 | 子物体相对父物体的位置 |
transform.translate(...) | 相对平移 | 在现有位置上叠加位移,不直接指定终点 | 每帧前进、朝某个方向移动 |
为什么有世界坐标和局部坐标的区别?举个例子:一辆车是父物体,车上的方向盘是子物体。方向盘的localPosition是相对车身的,比如(0, 1, 0)表示在车顶上方1米。车开走之后,方向盘的世界坐标变了,但它的局部坐标没变。移动子物体时,如果不希望受父物体影响,就去动position;如果希望跟随父物体相对移动,就动localPosition。
还有一个隐蔽的坑:transform.translate默认是在局部坐标空间做平移。也就是说,如果物体旋转过,translate的“前”和“左”可能和你预期的不一样。纯直线移动不建议用translate,直接操作position更可控。
2.2 移动的“发动机”从哪来
在3D引擎里,所有连续运动都是逐帧驱动出来的。所谓“移动到某一点”,如果你仔细观察,其实是物体在一帧一帧地改变位置,只是帧率够高,看起来是连续的。
Laya3D里最常用的驱动方式是Laya.timer.frameLoop:
Laya.timer.frameLoop(1, this, this.onUpdate); private onUpdate(): void { // 每次进入这里,就代表新的一帧开始了 let delta = Laya.timer.delta / 1000; // 距离上一帧的时间,单位秒 // 在这里做移动逻辑 }delta非常关键,它表示上一帧到这一帧经过了多少毫秒。之所以要除以1000转成秒,是因为我们设定速度时习惯用“米/秒”。如果不乘delta,直接每帧加一个固定值,那么同样是移动,60帧的电脑上物体每秒移动60个固定值,144帧的电脑上就是144个固定值,物体速度直接翻倍。这是一个非常典型的帧率相关Bug。
2.3 目标点别和当前位置共用同一个引用
cube.transform.position返回的其实是一个内部对象引用。如果你这样写:
let startPos = cube.transform.position;然后执行cube.transform.position = new Laya.Vector3(5, 0, 0);,你会发现startPos的值也跟着变成了(5,0,0)。因为startPos指向的还是原来的那个对象,而赋值操作可能改变了对象内容。正确做法是克隆一份:
let startPos = cube.transform.position.clone();这个小问题在写移动逻辑时会频繁出现,尤其是保存起点、计算距离等场景。凡是需要“取当前位置的副本”时,都记得加clone(),能省掉很多莫名其妙的Bug。
3. 物体移动到某一点的四种主流方案
3.1 方案一:直接赋值,瞬间到达
最简单暴力的一种,设置目标点并直接把位置改成它:
cube.transform.position = new Laya.Vector3(10, 0, 0);这个方案适合“传送”“重置棋子位置”等不需要中间过程的场景。优点是一个赋值搞定,缺点是没有过程。如果你需要物体从当前位置“走”过去,光用这个方案就不行了。
有一点要提醒:如果场景里挂了物理引擎,直接改position相当于把物体“瞬移”过去,可能造成穿墙、穿透等物理异常。物理相关项目里要慎用。
3.2 方案二:匀速直线移动,最通用也最可控
这是“移动到某一点”最经典的做法,思路就三步:
先算方向,再归一化,最后乘速度和时间。
方向向量 = 目标点 - 当前点。这个方向向量的长度其实是当前点到目标点的距离。如果直接拿这个向量去位移,那么离目标越近,移动速度就越慢,看起来像急刹车。所以要把它归一化成长度为1的单位方向向量,再乘以速度,这样物体就以恒定速度前进。
核心代码:
// 假设 speed 是移动速度,单位 米/秒 let speed = 5; let delta = Laya.timer.delta / 1000; // 1. 计算方向 let current = cube.transform.position.clone(); let targetPos = new Laya.Vector3(10, 0, 0); let dir = new Laya.Vector3(); Laya.Vector3.subtract(targetPos, current, dir); // 2. 判断是否到达 let distance = Laya.Vector3.length(dir); if (distance < 0.05) { cube.transform.position = targetPos; return; } // 3. 归一化方向 Laya.Vector3.normalize(dir, dir); // 4. 计算这一帧的位移 let offset = new Laya.Vector3(); offset.x = dir.x * speed * delta; offset.y = dir.y * speed * delta; offset.z = dir.z * speed * delta; // 5. 更新位置 cube.transform.position = new Laya.Vector3( current.x + offset.x, current.y + offset.y, current.z + offset.z );这里停止判定不能用“距离等于0”,因为浮点数运算和帧率波动,物体几乎不可能刚好落在目标点上,很可能在目标点附近来回抖动。设一个阈值,比如0.05米,距离小于它就算到达,并把位置直接吸附到目标点。
注意:归一化前先判断距离是否接近0,否则除0会产生NaN(Not a Number),物体坐标一旦变成NaN就彻底废了,控制台还会报坐标值异常。
3.3 方案三:带缓动效果的插值移动
有些场景需要“在精确的2秒内,从起点到终点”,而且希望过程带缓动,比如先慢后快、先快后慢。此时不要再按速度计算,而是用进度插值。
核心逻辑是:
progress = 已经过时间 / 总时长 当前位置 = 起点 + (终点 - 起点) * 缓动函数(progress)代码示例:
let startPos = cube.transform.position.clone(); let targetPos = new Laya.Vector3(10, 0, 0); let duration = 3; // 3秒到达 let elapsed = 0; Laya.timer.frameLoop(1, this, () => { elapsed += Laya.timer.delta / 1000; let progress = Math.min(elapsed / duration, 1); // easeOutCubic:先快后慢,接近终点时逐渐减速 let eased = 1 - Math.pow(1 - progress, 3); let out = new Laya.Vector3(); out.x = startPos.x + (targetPos.x - startPos.x) * eased; out.y = startPos.y + (targetPos.y - startPos.y) * eased; out.z = startPos.z + (targetPos.z - startPos.z) * eased; cube.transform.position = out; if (progress >= 1) { // 到达,可以在这里做后续逻辑 } });如果你不想手写插值,Laya的数学库里通常有Vector3.lerp,可以直接调用。但手写也不复杂,而且方便套用各种缓动函数。常用的缓动公式不多,记一个easeOutCubic、一个easeInOutQuad基本够用。
这个方案的优点是时间精确可控,不会因为距离远就“跑很久”,适合镜头移动、剧情演出、关卡动画等场景。缺点是如果你中途改变目标点,就绪状态处理起来稍麻烦,需要重新记录起点和用时。
3.4 方案四:像角色一样边转边走向目标
前面几种方案都是“平移”,物体角度保持不变。但游戏角色走向目标点,通常先转向目标,然后再前进。
在Laya3D里可以让物体“看向”目标点:
cube.transform.lookAt(targetPos, new Laya.Vector3(0, 1, 0));lookAt第二个参数是“朝上的参考方向”,一般传Y轴正方向(0,1,0),这样物体会保持头顶朝上,不会倾斜。
转向完成之后,如果想让物体沿着自身前方移动,只需要把“前进”用translate叠加到自己坐标空间里:
let speed = 3; let delta = Laya.timer.delta / 1000; let forward = new Laya.Vector3(0, 0, 1); // 物体自身Z轴正方向 let step = new Laya.Vector3(0, 0, speed * delta); cube.transform.translate(step, true, false);这里的难点在于:模型的前方向不一定是Z轴正方向。美术建模时可能把主方向放在Z轴负方向,或者X轴方向。你需要根据实际模型调forward为(0,0,-1)或(1,0,0)。如果不确定,就先用方案二那种直线移动,同时每帧lookAt目标点,效果上也是“边转边走”,只是没有“沿自身前方移动”的物理感。但作为入门,已经足够。
4. 完整示例:让Cube在3秒内从起点到达目标点
把前面几种方案串起来,做一个实际能跑的最小Demo。我要做一个红色Cube,从原点(0,0,0)出发,3秒后到达(10,0,0),使用easeOutCubic缓动。
4.1 搭一个最小场景
步骤如下:
- 初始化Laya3D
- 创建场景
- 添加相机,让相机从侧面斜视,可以看到物体移动全过程
- 添加方向光
- 创建Cube
- 创建目标点标记(我用一个小球放在目标点,方便观察)
代码如下:
Laya3D.init(0, 0); Laya.stage.scaleMode = Laya.Stage.SCALE_FULL; Laya.stage.screenMode = Laya.Stage.SCREEN_NONE; let scene = Laya.stage.addChild(new Laya.Scene3D()); let camera = new Laya.Camera(0, 0.1, 100); camera.transform.position = new Laya.Vector3(0, 5, 12); camera.transform.lookAt(new Laya.Vector3(5, 0, 0), new Laya.Vector3(0, 1, 0)); scene.addChild(camera); let light = new Laya.DirectionLight(); light.transform.position = new Laya.Vector3(0, 8, 0); light.transform.rotate(new Laya.Vector3(-60, 0, 0), true, false); scene.addChild(light); // 移动的主角 let cube = scene.addChild(new Laya.MeshSprite3D(Laya.PrimitiveMesh.createBox(1, 1, 1))); cube.transform.position = new Laya.Vector3(0, 0, 0); // 目标点标记:小一点的球体 let targetSphere = scene.addChild(new Laya.MeshSprite3D(Laya.PrimitiveMesh.createSphere(0.3))); targetSphere.transform.position = new Laya.Vector3(10, 0, 0);4.2 编写移动逻辑
在移动类里维护起点、目标、计时器:
class MoveDemo { private _cube: Laya.MeshSprite3D; private _startPos: Laya.Vector3; private _targetPos: Laya.Vector3 = new Laya.Vector3(10, 0, 0); private _elapsed: number = 0; private _duration: number = 3; constructor(cube: Laya.MeshSprite3D) { this._cube = cube; this._startPos = cube.transform.position.clone(); Laya.timer.frameLoop(1, this, this.onUpdate); } private onUpdate(): void { if (this._elapsed >= this._duration) return; this._elapsed += Laya.timer.delta / 1000; let progress = Math.min(this._elapsed / this._duration, 1); let eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic let out = new Laya.Vector3(); out.x = this._startPos.x + (this._targetPos.x - this._startPos.x) * eased; out.y = this._startPos.y + (this._targetPos.y - this._startPos.y) * eased; out.z = this._startPos.z + (this._targetPos.z - this._startPos.z) * eased; this._cube.transform.position = out; if (progress >= 1) { // 到达目标,清理帧循环 Laya.timer.clear(this, this.onUpdate); console.log("到达目标点"); } } } // 创建并启动 new MoveDemo(cube);这个Demo跑起来后,你会看到Cube前半段速度较快,后半段明显减速,最后稳稳停在球体旁边。这就是easeOutCubic的实际效果。
4.3 参数调整实验
你可以试着改几个值观察变化:
- 把
_duration改成1秒,速度更快,更容易看清缓动差异。 - 把
eased改成progress,就变成匀速直线移动。 - 把缓动函数换成
progress * progress,得到先慢后快的easeIn效果。
这一步的体验很重要。不看实际画面,光靠文字理解缓动公式,很难建立直观感受。建议自己跑一遍,印象会深很多。
5. 常见问题与排查手册
这部分是我最想写的。实际开发中,“移动一个物体”看着简单,但报起错来花样百出。下面都是我在项目里真踩过的坑,整理成了一张速查表。
| 现象 | 可能原因 | 排查思路与解决 |
|---|---|---|
| 设置了position但画面没变化 | 物体没有加入场景,或改错了transform | 检查scene.addChild是否执行,确认操作的是transform.position |
| 越走越快或越走越慢 | 没乘delta,或者delta除错了单位 | 统一用Laya.timer.delta / 1000,再看速度大小是否符合预期 |
| 到达目标点附近后反复抽搐 | 停止判定用了distance == 0 | 改成distance < 0.05之类阈值,并直接吸附到目标点 |
| 物体坐标变成NaN,无法回复 | 方向向量长度为0时做了归一化 | 归一化前先判断距离,小于阈值就停止 |
| 用translate移动后方向不对 | 默认在局部坐标空间移动,物体旋转后方向跟着变了 | 确认是否要传isLocal=false;纯直线移动直接改position |
| 背景一片黑,只有轮廓 | 忘了加方向光,或灯光角度不正确 | 添加DirectionLight,调整朝场景中心的方向 |
| 相机里看不到物体 | 相机位置和朝向不对,或者物体在相机背后 | 用lookAt让相机看向场景中心,先跑最小Demo |
| 存了起点后起点会自己变 | 直接引用transform.position对象,没clone | 保存起点时统一用.clone() |
除了表格里的问题,再送你一个调试技巧:把目标点做成一个独立的小球或十字模型,放在目标坐标上。人眼对三维空间的距离感很弱,有个参照物后,物体是否到达、方向是否对,一眼就能看出来。比单纯打日志直观太多。
6. 从Demo到工程:移动逻辑的工程化实践
6.1 内存压力:别在每帧里new Vector3
这点特别容易忽视。你用一个速度表达式,每帧都新建几个Vector3,当时没问题,但游戏运行几分钟后,GC(垃圾回收)一卡顿,整个画面就会掉帧。尤其是手机上,GC引发的小卡顿特别明显。
我现在的习惯是:在组件里定义几个临时成员变量,复用来存方向、位移、结果。
private _tempDir: Laya.Vector3 = new Laya.Vector3(); private _tempOffset: Laya.Vector3 = new Laya.Vector3(); private _tempOut: Laya.Vector3 = new Laya.Vector3();每帧逻辑里只修改这些临时变量的值,不再创建新对象。表面上看没节省多少,实际跑久了差距很大。
6.2 多物体移动时,别各写各的循环
一个项目里如果同时有十几个、几十个物体都要移动到某个点,每个物体都在自己内部注册一个frameLoop,回调数量会膨胀,性能会下降,而且逻辑分散很难定位问题。
更好的做法是写一个统一的移动管理器,维护一个移动对象列表,每个列表项包含:
- 要移动的物体或Transform
- 起点、终点、速度或时长
- 缓动类型
- 到达后的回调
在管理器的唯一一个frameLoop里遍历列表,统一更新。这样新增一个移动任务只加一条数据,不需要注册新的循环。这个结构看着简单,后续要扩展队列移动、链式移动、路径移动都方便。
6.3 直接改position和物理引擎的关系
如果你只是做纯视觉移动,直接改position没有太大问题。但一旦场景里挂了物理系统,比如碰撞体、刚体,直接改position会绕过物理引擎的碰撞检测,导致物体穿模、碰撞体位置与实际位置不同步等问题。
在物理项目中,让一个物体移动,更合理的做法是设置它的线速度,或者通过物理约束来推动它。即使是非物理项目,如果物体要被射线检测、被其他物体的碰撞挡住,也要考虑是否在移动前做射线探测,手动限制可移动范围。
6.4 从“移动到某一点”还能扩展出什么
“移动到某一点”是最基础的运动需求,但它可以延伸出很多进阶内容:
- 沿贝塞尔曲线路径移动:把中间点换成多个控制点,用曲线插值替代直线插值。
- 寻路移动:把目标点交给A*网格寻路系统,物体绕开障碍走向终点。
- 平滑转向:LookAt直接转向会导致转向角度突变,更真实的做法是用四元数插值让转向过渡自然。
- 移动与动画配合:角色移动过程中播放走路动画,停下时切换待机动画。
这些看似复杂的需求,底层仍然是“每帧更新transform.position/rotation”。把基础方案吃透,后面遇到它们,你只需要在“更新位置”这一步换算法即可。
最后再分享一点个人体会:新手学Laya3D移动,不建议一上来就抄一个看着很高级的角色控制脚本。你先用最简单的方式让Cube动起来,然后亲手把以下几种方式各写一遍:瞬移、匀速直线、缓动曲线、边转边走。每种都实际跑一跑,观察运动过程有什么不同。整个过程花不了多长时间,但对坐标、帧循环、插值这几个核心概念的理解,会比你背十篇教程都管用。移动功能是所有3D交互的基石,这块打稳了,后面做相机跟随、怪物追踪、NPC导航都会顺很多。