查看原文
其他

坤坤帮你搞懂坐标转换!!

真爱粉 老菜喵
2024-10-10

昨天是疯狂星期四!很多同学问老菜喵3D游戏中,Canvas的坐标和3D世界中的坐标如何通过屏幕空间坐标相互转换。老菜喵请出了我们的老演员坤坤,让我们用一个吃金币的小游戏(小游戏结尾获取下载链接,模型来自真ikun,与老菜喵无关,商用后果自付~),一文搞懂空间坐标转换

坐标的基本概念

在 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.(2D相机的优先级是1,这个相机渲染的画面会在2D之上) 最后,由于我们使用的3D主相机是透视相机,和正常人眼观察的情况一样,符合近大远小的规律,我们2D节点在屏幕边缘,转换的屏幕坐标再到3D世界坐标,所处的位置也会比较边缘,为了让视觉上一致,我们给金币添加了一个tween动画,让金币的scale也慢慢变大。

代码如下:

        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(9000)
            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(000)
           }
       }
   }

这里用曼哈顿距离,检测角色在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.84.84.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免费申请试用)。


点击原文在线试玩

修改于
继续滑动看下一个
老菜喵
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存