坤坤帮你搞懂坐标转换!!
昨天是疯狂星期四!很多同学问老菜喵3D游戏中,Canvas的坐标和3D世界中的坐标如何通过屏幕空间坐标相互转换。
坐标的基本概念
在 Cocos 引擎中,屏幕空间坐标、世界坐标和本地坐标是三种不同的坐标系,它们各自有不同的用途和特点:
屏幕空间坐标(Screen Space Coordinates):这是一个二维坐标系,用于确定元素在屏幕上的位置。它的原点(0,0)通常位于屏幕的左下角,最大值为屏幕的分辨率(例如,如果屏幕分辨率是1920x1080,那么右上角的屏幕空间坐标就是(1920,1080))。这个坐标系主要在处理用户输入(如点击或拖动)或在屏幕上显示UI元素时使用。
世界坐标(World Coordinates):这是一个三维坐标系,用于确定游戏世界中物体的位置。世界坐标系统中的每个点都有一个唯一的位置,无论摄像机在何处,无论相机如何移动或旋转,其在世界空间中的坐标都是固定的。
本地坐标(Local Coordinates):这也是一个三维坐标系,但它是相对于特定游戏对象的。在本地坐标系中,(0,0,0)总是表示对象的原点,其他坐标则表示相对于这个原点的位置。当你移动或旋转一个物体时,它的本地坐标将不会改变,但其世界坐标将会改变。编辑器中默认的坐标系就是本地坐标,有个特殊情况当本地坐标的父节点坐标(包括父节点的父节点)也都是(0,0,0),且不存在缩放和旋转,我们也可以使用本地坐标替代世界坐标,这时候本地坐标和世界坐标的值是一样的。
总的来说,屏幕空间坐标、世界坐标和本地坐标都是描述物体位置的方式,但它们是在不同的上下文和用途中使用的。
2D UI坐标转 3D 节点坐标
由于2D UI和3D 节点的相机不一样,这里的转换其实指的是视觉上,通过坐标转换,让3D 节点的位置在视觉上看起来一致。
这个Demo中比较有代表性的例子就是 3D的金币飞到了2D 节点的下,并增加金币数量。
我们来简单看下这段代码,先使用2D UI的相机把2D物体坐标转换了了屏幕空间坐标,再把屏幕空间坐标转换了3D相机下的世界坐标,随着3D相机的位移和缩放,这个转换过的世界坐标不是一个绝对坐标,会随着3D坐标的位移和缩放而发生改变。
/* 2D UI Node's wordposition to screen space pos*/
aa.cameras[1].worldToScreen(this._coinView.worldPosition, temp_V3_3);
const flyingPos = new Vec3();
/* screen space pos to 3d world position under this camera*/
aa.cameras[0].screenToWorld(temp_V3_3, flyingPos);
所以当我们进行金币飞行时候,为了节省性能,最好在金币飞行时候暂停镜头的跟随与移动。(这里给角色添加了一个举手的动画,并做了暂停)
接下来再飞行中,我们希望3D金币能飞到2D的金币精灵上面,这时候需要改变下观察3D金币的相机,我们额外创建一个金币相机,这个相机只能观察3D_UI场景,且优先级是2,模式是depth_only.
代码如下:
for (var i = bl - 1; i >= 0; i--) {
const coin = backCoins[i];
/* change layer to be visible to 3D coins' camera in order to be viewed upon the 2D camera*/
coin.parent = aa.layer3D[4];
coin.layer = Layers.Enum.UI_3D;
coin.setRotationFromEuler(90, 0, 0)
const delay = i * dt;
/* move and scale the coin */
tween(coin).delay(delay).to(0.5 + delay, { position: flyingPos, scale: coinScaled }, { easing: 'fade' }).call(() => {
aa.res.putNode(coin);
}).start();
}
整个2D UI坐标转 3D 节点坐标的流程就完成了。
重点如下:
2D相机负责把2D物体的世界坐标转换成屏幕空间坐标 3D相机负责把屏幕空间坐标转换成3D相机观察中的世界坐标 3D相机是透视相机时候需要放大3D节点,是正交相机时候则无需放大
3D节点 坐标转 2D 节点坐标
这个需要也是很常用的,常用于2D节点跟随3D角色,如
角色血条 伤害文字 角色状态
这里我们给坤坤添加一个金币数量的状态,并且可以跟随金币的数量而改变跟随高度。
updateCoinBar(backCoins: Node[]) {
if (this._coinBarViewComp) {
/* backcoins length */
const bl = backCoins.length;
Vec3.copy(temp_V3_1, this._playerPos);
temp_V3_1.y += 1.5 + (bl + 1) * 0.075;
aa.cameras[0].convertToUINode(temp_V3_1, aa.layer2D[4], temp_V3_1);
this._coinBarView.position = temp_V3_1;
if (this._currentCoins != bl) {
this._currentCoins = bl;
this._coinBarViewComp.coin = "Coins " + bl;
}
}
}
在上述代码中我们首先获取角色的位置和金币的数量,并动态计算出状态栏在3D空间内相对应的位置,然后通过3D相机的convertToUINode 转换成2D节点下的本地坐标。
引擎convertToUINode的方法也是先把3D坐标转换成该相机相对于的屏幕空间坐标,再把屏幕空间坐标转换成2D节点下的本地坐标。
public convertToUINode (wpos: Vec3 | Readonly<Vec3>, uiNode: Node, out?: Vec3): Vec3 {
if (!out) {
out = new Vec3();
}
if (!this._camera) { return out; }
this.worldToScreen(wpos, _temp_vec3_1);
const cmp = uiNode.getComponent('cc.UITransform') as UITransform;
const designSize = cclegacy.view.getVisibleSize();
const xoffset = _temp_vec3_1.x - this._camera.width * 0.5;
const yoffset = _temp_vec3_1.y - this._camera.height * 0.5;
_temp_vec3_1.x = xoffset / cclegacy.view.getScaleX() + designSize.width * 0.5;
_temp_vec3_1.y = yoffset / cclegacy.view.getScaleY() + designSize.height * 0.5;
if (cmp) {
cmp.convertToNodeSpaceAR(_temp_vec3_1, out);
}
return out;
}
当角色移动时候,金币状态也会跟随角色移动。
性能优化
分帧加载
创建屏幕金币的逻辑在update中执行,每帧只实例化一个金币
spawnCoins(dt) {
if (this._coins > 0) {
this._coins--;
this.createCoin();
}
this._time += dt;
if (this._time > this._interval) {
this._time = 0;
/* random coins' amount and the interval for generating*/
this._interval = 3 + 2 * Math.random();
if (aa.layer3D[2].children.length < 75) {
this._coins += Math.floor(6 + 6 * Math.random());
}
}
}
曼哈顿距离检测
由于没有使用物理引擎,这里直接使用了角色到金币的距离检测是否碰撞金币。
checkCoins() {
const coins = aa.layer3D[2].children;
const parent = aa.layer3D[3];
const length = coins.length;
for (var i = length - 1; i >= 0; i--) {
const coin = coins[i];
/* we use manhattanDis for better perf */
const dis = aa.utils.getMdisXZ(coin.position, this._playerPos)
if (dis < 0.5) {
coin.parent = parent;
coin.setRotationFromEuler(0, 0, 0)
}
}
}
这里用曼哈顿距离,检测角色在x和z轴上到金币的距离,由于没有使用开方,在金币数量少于100个情况下,整理的算法效率和物理引擎相差不大,且更轻量, 场景复杂的情况下可以考虑使用四叉树进一步优化。
getMdisXZ(p1: Vec3, p2: Vec3): number {
/* we use Manhattan dis for better performance */
const d = (Math.abs(p2.x - p1.x) + Math.abs(p2.z - p1.z));
return d;
}
复用和缓存
const temp_V3_1 = new Vec3();
const temp_V3_2 = new Vec3();
const temp_V3_3 = new Vec3();
const coinScaled = new Vec3(4.8, 4.8, 4.8);
金币逻辑类预先创建了3个常驻的Vec3向量,用于频繁的计算。
在update内的计算,尽量复用和缓存向量。
/* store player's pos */
Vec3.copy(this._playerPos, aa.global.player.position);
批量计算
moveCoins(dt, backCoins: Node[]){
let bl = backCoins.length;
/* coin move logic */
if (!aa.global.isMove && bl > 0) {
this.collectCoins(dt, backCoins);
} else {
if (bl >= 50) {
this.collectCoins(dt, backCoins);
} else {
for (var i = bl - 1; i >= 0; i--) {
const coin = backCoins[i];
Vec3.copy(temp_V3_1, this._playerPos);
temp_V3_1.y += 1.4 + (bl - i) * 0.075;
const t = dt * (Math.max(1, (19 - (bl - i) * 0.35)));
coin.position = Vec3.lerp(temp_V3_2, coin.position, temp_V3_1, t);
}
}
}
}
这里的金币逻辑在update内对金币数组进行了批量计算,在update中按顺序访问,并批量计算对CPU更友好,避免每个金币额外的函数开销。
获取本文Demo
关注老菜喵公众后,后台回复<金币>,即可获取本文Demo的下载链接,代码使用了AI辅助生成,极大的提高了开销效率(百度Comate免费申请试用)。
点击原文在线试玩