纯干货!一文搞懂 Cocos Creator 3.0 坐标转换原理
Cocos Creator 3.0 如何将世界坐标转屏幕坐标?Creator 3D 怎么将 3D 坐标转化到 Canvas 上面?触摸的屏幕坐标如何转世界坐标?怎么把 Canvas 下的节点坐标转换为 3D 场景坐标?
在 Cocos 中文社区上,不时能看到和“坐标转换”有关的讨论帖。不论是 Cocos Creator 3D 版本,还是 2D 和 3D 产品融合后的 3.0 版本,都有大量开发者对这方面的操作存在困扰。今天就用一篇文章让大家轻松搞懂坐标转换原理。
大纲总览
PART 1. 屏幕坐标
PART 2. UI 触点坐标
PART 3. 不同坐标之间的转换
PART 4. 射线检测
PART 1
屏幕坐标
在 Cocos Creator 3.0 游戏运行时显示的画布大小就是屏幕区域,屏幕坐标是从画布的左下角为原点开始计算,可以通过 view.getCanvasSize 接口获取屏幕尺寸。
知道屏幕起点和屏幕尺寸,此时心里就可以大概估算出点击屏幕时候的触点位置值。触摸事件刚好可以帮助验证,通过 systemEvent 来监听:
1 // 监听系统触摸移动事件
2 onEnable () {
3 systemEvent.on(SystemEventType.TOUCH_MOVE, this._touchMove, this);
4 }
5
6 _touchMove(touch: Touch, event: EventTouch){
7 // 打印获取到的屏幕触点位置
8 console.log(touch.getLocation());
9 }
点击并移动后就能看出坐标值的走向,触点越往右,x 值越大,越往上,y 值越大。
在 Cocos Creator 3.0 中,3D 相机在不设置 viewport 的情况下,尺寸始终跟屏幕尺寸一样大小,这一点很重要,因为后面的内容都离不开相机。
PART 2
UI 触点坐标
UI 最大的特点就是适配与交互。在看 UI 文档时,可能会获取到几个跟适配最直接的有关联的内容,比如设计分辨率、布局组件(Layout),对齐组件(Widget),多分辨率适配方案等。其中,与坐标直接有关联的就是设计分辨率与多分辨率适配方案,这在官方文档多分辨率适配方案上都有提及,我在此处根据这个知识快速带大家了解一下适配规则是如何影响 UI 内容呈现。
1
UI 多分辨率适配方案
在 UI 制作时,通常会先确定 UI 设计的大小,称之为设计分辨率。设计分辨率和适配模式决定了运行时 UI 内容尺寸(UI 根节点 Canvas 的尺寸)。在这里必须明确知道的条件就是设计分辨率和设备分辨率(屏幕分辨率)肯定是固定不变的,在这个条件下,了解一下适合大多数游戏开发的两种模式,分别是适配宽度(fit width)和适配高度(fit height)。
在这用一个例子做说明,假设设计分辨率是 960x640,UI 上有一个和设计分辨率一样大小的背景(没有添加 Widget)外加四个始终紧贴 UI 内容区域四角的精灵(四个精灵都带有 Widget 组件,分别是上、下、左、右紧贴 UI 内容区域四角)。
当选用的适配方案是适配高度,屏幕分辨率是 1334x750,此时引擎就会将设计分辨率的高度自动撑满屏幕高度,也就是将场景图像放大到 750/640=1.1718 倍,但是放大后的宽度 960*1.1718=1125 依然小于屏幕宽度 1334,这样就容易出现黑边(如下图背景没有铺满屏幕)。这种方式肯定是大多数游戏开发者不想看到的情况,因此,引擎会根据 Widget 组件计算将宽度自动“铺满”屏幕(如下图四个精灵)。
当然,这样的方式也有弊端,就是背景如果完全适应设计分辨率大小(添加了适配组件 Widget,并且上下左右同时适配 UI 内容区域)会出现一定的拉伸。此处,由于拉伸比例较小,所以看的不是很明显,感兴趣的朋友可自行验证。
反过来,如果采用的屏幕分辨率是 960x720,场景图像放大到 720/640=1.125,放大后的宽度为 1.125*960=1080,大于屏幕宽度 960,这样就会出现内容裁切的现象(如下图背景)。因此,引擎会根据 Widget 组件计算将宽度自动“限制”在屏幕内(如下图四个精灵)。这种方式的弊端就是背景如果完全适应设计分辨率大小会出现挤压。
适配宽度也是同样原理。明白了适配原理,就可以根据不同平台选择合适的适配方案。
2
UI 触点获取
在 UI 上,大家应该最关心的是,如何根据触点位置设置 UI 元素。
对于 Creator 2D 用户来说,获取触点信息的方法是在事件监听回调里,通过 event.getLocation() 获取触点信息,这里的触点信息是屏幕触点根据 UI 内容区域计算出来的;而在 Creator 3.0 里,屏幕和 UI 是完全区分开的,用户可以在没有 UI 的情况下点击屏幕获取触点信息。因此,获取屏幕触点,是通过 event.getLocation()。而希望同样按照 Creator 2D 的方式获取“屏幕触点”坐标,则可以通过 event.getUILocation() 的方式获取。event.getUILocation() 获取到的触点信息,也可以用来直接设置 UI 元素的世界坐标(因为 UI 上的每像素等价于 3D 上的每单位,所以可以根据这个坐标点直接设置 UI 元素的世界坐标)。
1const location = touch.getLocation();
2console.log(`screen touch pos: ${location}`);
3const locationUI = touch.getUILocation();
4console.log(`UI touch pos: ${locationUI}`);
1const pos = new Vec3(locationUI.x, locationUI.y, 0);
2// 此处的 sprite 就是屏幕上的白色图片的引用
3this.sprite.setWorldPosition(pos);
当然,对于渲染原理比较清楚的同学,也可以直接通过屏幕坐标与 UI 相机的方式来处理,具体原理请参考下方的屏幕坐标与 3D 节点世界坐标互转。
1// 此脚本挂载在 Canvas 节点身上
2
3// 获取屏幕坐标
4const location = touch.getLocation();
5const screenPos = new Vec3(location.x, location.y, 0);
6const pos = new Vec3();
7const canvas = this.getComponent(Canvas)!;
8// 获取 Canvas 关联相机的相机数据
9const uiCamera= canvas.cameraComponent?.camera!;
10// 利用相机数据对象将屏幕点转换成世界坐标下的值
11uiCamera.screenToWorld(pos, screenPos);
12this.sprite.setWorldPosition(pos);
13
14注意:此处是因为 UI 没有深度信息,因此可以直接设置世界坐标,将 z 值固定为 0。如果是 3D 节点,还要考虑深度。
15在预计 3.4 以及之后的版本会统一用相机来转换,大家就不需要理解 UI 触点这个概念,之后也会提供相对应的转换方式使用文章,望悉知。
PART 3
不同坐标之间的转换
了解了屏幕坐标、UI 坐标以及相机,接下来就可以做更多坐标之间的转换。首先再说明一下本地坐标与世界坐标之间的关系:
世界坐标的中心点(也称之为原点)是节点在场景下创建的,位于节点树最外层并且它的 positon 属性值全部都是 0 的节点所处的位置,就是世界坐标的中心点。所有节点通过 node.getWorldPosition 接口获取到的值,都是对于该位置的偏移值,也可以理解为绝对差值。而本地坐标是相对于父节点的偏移。假设节点 A 的世界坐标为 (0,10,0),节点 B 是节点 A 的直接子节点,节点 B 的本地坐标是 (0,10,0),节点 A 和 B 都没有旋转和缩放,那么节点 B 的世界坐标是多少?
答案显而易见是(0, 20, 0)。节点 B 的坐标是本地坐标,本地坐标是相对于父节点的偏移,也就是 B 相对于 A 在 y 值上偏移 10,A 的世界坐标 y 值为 10,也就是相对于世界原点偏 y 值偏移了 10,那么可以计算出,B 相对于世界原点 y 值偏移了 10+10=20。
1
屏幕坐标与 3D 节点世界坐标互转
在进行 3D 节点位置转换到屏幕坐标这项工作之前,先要了解一下 3D 物体最终是如何渲染到屏幕上的。
首先,所有的物体都需要被相机照射才能显示,因此,物体需要在相机的视距框内,在这里需要做一次世界空间转相机空间的操作。其次,在相机空间下还需要做深度检测等操作,最后,将空间内容投影到屏幕。这部分怎么理解呢?看下图:
在这里用透视相机举例,这是透视相机的俯视图,透视相机近平面值是 1,远平面值是 1000。相机的可视范围是从近平面到远平面,从图上可以看出,越接近近平面,也就是深度值越小的横切面面积越小,反之越大。不同面积的横切面最终铺满绘制到同一个画布上,就会出现近大远小的效果,同时,不同切面之间也会经过深度检测实现视野近的物体遮挡视野远的物体。最后的呈现就有点类似于用相机拍照片这么一个过程,将所有立体的东西都“拍扁”。知道了物体如何绘制到屏幕上,反过来是不是也很容易呢?
其实并没有那么容易。因为平面的东西变得立体需要经过很多细节化处理。在这里通常需要处理的最直接的一点就是深度还原。根据画面的呈现,决定它原来应该处于的深度,因此,在做屏幕触点转 3D 节点世界坐标的时候,可以指定深度。深度的范围如上图是近平面到远平面之间,也就是 1 - 1000,在数值上采用的是归一化的值 0 - 1。在默认情况下,如果不指定深度,那么物体转换后的位置是在近平面位置,如果是 0.5,那么则是距离相机 500 左右的位置。
接下来,通过下方代码,就能轻松实现屏幕坐标与 3D 节点世界坐标的互相转换:
1// 3D 相机引用
2@property(Camera)
3camera: Camera = null!;
4
5@property(MeshRenderer)
6meshRenderer: MeshRenderer= null!;
7
8onEnable () {
9 systemEvent.on(SystemEventType.TOUCH_START, this._touchStart, this);
10}
11
12_touchStart (touch: Touch, event: EventTouch) {
13 // 获取 3D 相机里的相机数据
14 const camera = this.camera.camera;
15 const pos = new Vec3();
16
17 // 1. 3D 节点世界坐标转屏幕坐标
18 const wpos = this.meshRenderer.node.worldPosition;
19 // 将 3D 节点的世界坐标转换到屏幕坐标
20 camera.worldToScreen(pos, wpos);
21
22 // 2. 屏幕坐标转 3D 节点世界坐标
23 // 获取当前触点在屏幕上的坐标
24 const location = touch.getLocation();
25 // 注意此处的 z 值。将近平面到远平面之间的距离归一化到 0-1 之间的值。
26 // 如果值是 0.5,那么转换后的世界坐标值则是在相机近平面到远平面的中心切面的位置
27 const screenPos = new Vec3(location.x, location.y, 0.5);
28 camera.screenToWorld(pos, screenPos);
29}
为了便于观察,此处将相机的远平面调整为 20。
2
3D 节点之间的坐标转换
1// 3D 节点 nodeB 本地坐标转换到 3D 节点 nodeA 本地坐标
2const out = new Vec3();
3const tempMat4 = new Mat4();
4const nodeAWorldMat4 = nodeA.getWorldMatrix();
5Mat4.invert(tempMat4, nodeAWorldMat4);
6Vec3.transformMat4(out, nodeB.worldPosition, tempMat4);
3
屏幕坐标转 UI 触点坐标
在这里还是要明确一下,UI 触点坐标就是屏幕触点根据 UI 内容区域计算出来的值,也就是 UI 世界坐标值。这个概念本身不太好理解,也显得繁杂,因此,引擎组也会在之后的版本里改善这种转换方式,直接通过相机来处理。但是在 3.4 之前的版本还需要用,所以此处直接做一个简单的总结:
在监听节点事件触发的回调里执行 event.getUILocation 获得到的值是触点信息是屏幕触点根据 UI 内容区域计算出来的值,可以直接用于设置 UI 节点的世界坐标。如果有设计需求是需要点击屏幕设置 UI 节点的,就直接用该方法。
1const locationUI = touch.getUILocation();
2const pos = new Vec3(locationUI.x, locationUI.y, 0);
3this.sprite.setWorldPosition(pos);
4
UI 不同节点之间的坐标转换
与 Creator 2D 不同,UI 节点的尺寸和锚点信息不再在节点身上,而是每一个 UI 节点都会持有一个 UITransform 组件,因此,有关 UI 节点之间的变换 API 都在该组件身上。当获取到屏幕触点转换后的 UI 节点世界坐标之后,就可以根据该坐标转换到不同 UI 节点的本地坐标。
● 屏幕触点坐标转换到 UI 节点本地坐标
1// 假设此处有两个节点,nodeA 和它的字节的 nodeB,点击屏幕设置 nodeB 的坐标
2
3// 屏幕触点根据 UI 内容区域计算出来的值
4const locationUI = touch.getUILocation();
5const uiSpaceWorldPos = new Vec3(locationUI.x, locationUI.y, 0);
6const nodeAUITrans = nodeA.getComponent(UITransform)!;
7// 转换到 nodeA 节点下的本地坐标(该值也就是相对于 nodeA 的值)
8nodeAUITrans.convertToNodeSpaceAR(uiSpaceWorldPos, pos);
9nodeB.position = pos;
● UI 节点之间坐标互转
1// 假设此处有两个节点,nodeA 和 nodeB,它们既不为兄弟也不存在父子关系
2const nodeBUITrans = nodeB.getComponent(UITransform)!;
3// 最终偏移值存储在 pos 上
4const pos = new Vec3();
5// 获取 nodeA 相对于 nodeB 的偏移
6nodeBUITrans.convertToNodeSpaceAR(nodeA.worldPosition, pos);
7nodeA.parent = nodeB;
8nodeA.position = pos;
9
10// 如果此处希望取与自身带有一定偏移的点做转换,可以采用如下方法:
11// 相对于 nodeA x 轴偏移 10 个单位
12const offset = new Vec3(10, 0, 0);
13const nodeAUITrans = nodeA.getComponent(UITransform)!;
14// 将偏移后的值转换到世界坐标
15nodeAUITrans.convertToWorldSpaceAR(offset, pos);
16// 获取相对于 nodeA 在 x 轴上偏移 10 个单位的点,转换到相对于 nodeB 的偏移
17nodeBUITrans.convertToNodeSpaceAR(pos, pos);
18nodeA.parent = nodeB;
19nodeA.position = pos;
5
3D 节点世界坐标转 UI 节点本地坐标
上面的内容已经铺垫很多了,这里直接上代码,原理就是将 3D 节点的世界坐标转换到屏幕坐标再转换到 UI 节点的本地坐标。
1// 假设 3D 节点 cube,UI 节点 spriteA,
2const out = new Vec3();
3const wpos = cube.worldPosition;
4// 直接调用相机的转换接口完成整个操作
5// 此处 out 获取的值是“相对于”spriteA偏移的值
6camera.convertToUINode(wpos, spriteA, out);
7const node = new Node();
8// 本地坐标才是相对于父节点的偏移,因此,需要明确好转换后的父节点,否则就会出现转换一直不正确的现象
9node.parent = spriteA;
10node.position = out;
PART 4
射线检测
在开始说射线检测怎么用之前,先要说说为什么要有射线检测。很多 Creator 2D 同学开发游戏的时候,都知道节点身上有一个 “Size” 属性,代表这个节点的尺寸。点击屏幕的时候,在大多数情况下引擎通过触点与节点的位置和 “Size” 计算是否该节点被点击到。监听方法采用节点事件,在节点被点击到时触发回调,代码如下:
1this.node.on(Node.EventType.TOUCH_MOVE, this._touchMove, this);
但是在 Creator 3.0 3D 节点上使用该方法是行不通的,因为 3D 节点没有所谓的 “Size” 属性,在不同的深度下,物体所呈现的大小是不一样的。因此,物体是否被点击到是无法简单通过位置和 “Size” 计算。在这里就需要使用到射线检测功能。
射线检测简单来说就是可以指定一个起点 A 和一个终点 B,引擎会发射一条由 A 到 B 的射线,收集并返回与射线碰撞到的所有对象各自的位置、法线等信息,这样就能知道点击到的对象是谁,以及它们分别如何处理。Cocos Creator 3.0 提供了 3 种创建射线的方法:
1import { geometry } from 'cc';
2const { ray } = geometry;
3
4// 方法 1. 通过起点 + 方向的方式
5
6// 构造一条从(0,-1,0)出发,指向 Y 轴的射线
7// 前三个参数是起点,后三个参数是方向
8const outRay = new ray(0, -1, 0, 0, 1, 0);
9// 或者
10const outRay2 = ray.create(0, -1, 0, 0, 1, 0);
11
12// 方法 2. 通过起点 + 射线上的另一点
13
14// 构造一条从原点出发,指向 Z 轴的射线
15const outRay = new ray();
16geometry.ray.fromPoints(outRay, Vec3.ZERO, Vec3.UNIT_Z);
17
18// 方法 3. 用相机构造一条从相机原点到屏幕某点发射出的射线
19
20// 假设此处已经关联上一个相机
21const cameraCom: Camera;
22const outRay = new ray();
23// 获得一条途径屏幕坐标(0,0)发射出的一条射线
24// 前两个参数是屏幕坐标
25cameraCom.screenPointToRay(0, 0, outRay);
从上述射线创建的方法来看,如果要点击屏幕判断是否点击到某个对象,采用的是方法 3。细心的朋友可能想问,为啥判断点击屏幕是否点击到某个 3D 对象需要用到相机?接下来,我在场景里用两个相机照射同一个胶囊体,大概就能解释清楚这里面的关系。
此时,你就会清楚的发现,即使游戏场景里只有一个胶囊体,但是如果用两个不同拍摄角度的相机照射,是会在画布上看到两个物体 A 的。此时,点击区域 1,胶囊体究竟是否被点击到了?点击区域 2,胶囊体究竟是否被点击到了?根本无法判定。
如果指定相机判断,是不是就明确了许多。用相机 A 的视角判断,点击区域 1,没有点击到胶囊体;用相机 B 的视角判断,点击区域 1,点击到了胶囊体。可以通过下方代码验证:
1import { _decorator, Component, systemEvent, SystemEventType, Touch, EventTouch, Camera, geometry, MeshRenderer } from 'cc';
2const { ccclass, property } = _decorator;
3const { Ray, intersect } = geometry;
4const { rayModel } = intersect;
5
6@ccclass('TestPrint')
7export class TestPrint extends Component {
8 // 指定相机
9 @property(Camera)
10 camera: Camera = null!;
11
12 // 指定模型的渲染组件
13 @property(MeshRenderer)
14 meshRenderer: MeshRenderer= null!;
15
16 onEnable () {
17 systemEvent.on(SystemEventType.TOUCH_START, this._touchStart, this)
18 }
19
20 _touchStart(touch: Touch, event: EventTouch){
21 const location = touch.getLocation();
22 const ray = new Ray();
23 // 创建一条连接触点位置与相机位置的射线
24 this.camera.screenPointToRay(location.x, location.y, ray);
25 // 获取到渲染组件身上用于存储渲染数据的 model 做射线检测
26 const raycast = rayModel(ray, this.meshRenderer.model!);
27 // 返回值只有 0 与 !0,0 就是没检测到
28 if(raycast > 0){
29 console.log('capsule clicked');
30 }
31 }
32}
注:两个相机照射的内容如果都需要在画布上呈现,那么其中一个相机的 ClearFlags 需要为 DEPTH_ONLY。
以上内容就是关于坐标转换有关的全部内容,在此处说明到的有关射线检测的内容相对较浅,我们会在近期推出更全面的射线检测视频教程,欢迎戳文末【阅读原文】关注bilibili “Cocos 引擎官方”。