美文网首页
ARKit 看这一篇就够了

ARKit 看这一篇就够了

作者: 无字教科书 | 来源:发表于2022-12-20 10:25 被阅读0次

    @[TOC](ARKit 编程指南)

    **欢迎转载----转载请注明出处**

    源码 Github 链接 欢迎Star
    如果 Github 不能访问,示例程序也可在CSDN下载 点我下载项目

    前置条件

    在学习ARKit 前,应当有一定的矩阵知识基础方便快速上手和理解背后的逻辑。
    如果你对 Metal 有过了解和学习,那么你可以轻松的使用ARKit
    如果你想要了解并学习 Metal 请点击本人的另一篇文章 Metal 编程指南
    如果没有渲染方面的知识也没有关系,希望你能回顾一下 iOS 中关于 Layer 的 transform 动画

    坐标转换

    当我们展示 ARKit 时,只能使用 ARSCNViewCAMetalLayerCAEAGLLayer

    ARSCNView 继承自 SCNView 关于 SCNView 网上文章有很多 主要是用来加载 3D 模型的。也是在初学者入门时最适合使用的一种 View,自带了 ARSession 和 SCNScene 可以不用去额外处理相机流。关于 SCNView 下面有更详细的使用。
    CAMetalLayer 定义在 QuartzCore.framework 中,继承于 CALayer。它管理 Metal Texture Pool,并且负责渲染 MTLTexture 到窗口。我们可以将 ARSession 的相机流以 Texture 形式贴在屏幕上。
    CAEAGLLayer 同样继承自 CALayer,主要是在使用 openGLES 时使用,渲染的原理和使用 CAMetalLayer 一样。

    在这三种View 中,他们的坐标系都和我们平时使用的不一样。
    总的来说就是我们屏幕中的一个点比如(X=100,Y=100)在这些View中是不存在的,他们的坐标系为 (-1,1)
    因此如果我们需要添加一个模型或添加一个点则应该把 屏幕坐标系转换为 它们对应的坐标系。

    下图表示了如果从 UIKit 坐标转为 Metal 空间坐标

    坐标转换.png

    规范化的设备坐标使用右手坐标系X向右,Y向下,Z朝向自己并映射到视口中的位置。这些坐标与视口大小无关。 Z 值指向远离摄像机的位置(进入屏幕)。

    
    // 我们可以使用一段代码来验证坐标转换
    float uikitX = 375;
    float uikitY = 667;
    float ratioX = 1.0 / 375;
    float ratioY = 1.0 / 667;
    float resultX = (2.0 *  uikitX * ratioX) - 1.0;
    float resultY = (2.0 * -uikitY * ratioY) + 1.0;
    
    NSLog(@"x=%.2f y=%.2f",resultX, resultY);
    // x=1.00 y=-1.00
    
    

    3D模型

    当我们了解了坐标系的变化后就可以尝试加载一些模型了。
    平时看到的 AR 场景都很炫酷,那是因为模型做的好看,和我们写的代码关系不大~
    首先在 SceneKit 中,系统为我们提供了很多的 SCNGeometry,如果你使用过 Unity、UE5、GritGene 等渲染引擎应该对 Geometry 不陌生,做为基础的几何图形,他们构型了一个个复杂的3维模型。
    我们先尝试使用 SCNView 来加载一个 SCNBox注:SCNBox 继承自SCNGeometry,简单而言就是一个正方体
    关于 SCNView 的介绍可以自行查看官方文档,SCNView 中由SCNScene做为展示,每个模型都是一个节点,将节点添加到 SCNScene 的 rootNode 中即可。

    @interface DeWuViewController ()
    @property (nonatomic,strong) SCNView *scene;
    @end
    
    @implementation DeWuViewController
    // 初始化一个 SCNView 
    - (void)viewDidLoad {
        [super viewDidLoad];
        self.title = @"得物试衣间";
        self.view.backgroundColor = UIColor.whiteColor;
        
        self.scene = [[SCNView alloc] initWithFrame:
                      CGRectMake(0, 88, self.view.frame.size.width, 200)];
        self.scene.backgroundColor = UIColor.lightGrayColor;
        self.scene.allowsCameraControl = true;
        SCNScene *rootScene = [SCNScene scene];
        self.scene.scene = rootScene;
        [self.view addSubview:self.scene];
    }
    @end
    

    创建完成后看到的界面应该如下图


    001.png

    我们继续创建一个正方体,加载到屏幕中。

    - (void)addBoxGeometry
    {
        SCNBox *box = [SCNBox new];
        SCNNode *boxNode = [SCNNode nodeWithGeometry:box];
        [self.scene.scene.rootNode addChildNode:boxNode];
    }
    

    此时我们看到应该如下图所示,正方体的高度默认为 scene 的高度。


    002.png

    现在已经创建了一个正方体,它可能看起来不像,但不管你信不信,它确实是一个正方体!
    如果我们打开了 SCNView 的 allowsCameraControl 属性,滑动屏幕应该能看到正方体。
    我们可以通过设置 scene.rootNode.position 来使其变的小一些,但是肯定不是最佳的方法。因此我们需要了解矩阵的作用。
    如果你做过 3D游戏、Metal、GLES。经常会看到一个 4×4 大小的矩阵,有四列和四行。
    SceneKit 中,4x4矩阵使用的是 SCNMatrix4

    typedef struct SCNMatrix4 {
        float m11, m12, m13, m14;
        float m21, m22, m23, m24;
        float m31, m32, m33, m34;
        float m41, m42, m43, m44;
    } SCNMatrix4;
    

    使用矩阵,可以通过三种方式转换对象:
    翻译:沿 x、y 和 z 轴移动对象。
    旋转:围绕任意轴旋转对象。
    缩放:沿任意轴更改对象大小。

    除此之外,还应该了解 投影变换
    投影变换将节点的坐标从相机坐标转换为归一化坐标。根据您使用的投影类型,您将获得不同的效果。 如果你想了解更多请阅读 Metal 编程指南

    举个简单例子:
    如果我们希望图形绕着 X 轴旋转 则公式为:

    [1 0 0 0]
    [0 cos(-X Angle) -sin(-X Angle) 0]
    [0 sin(-X Angle) cos(-X Angle) 0]
    [0 0 0 1]
    

    同理 沿着 Y 或 Z 使用对应的公式即可。我们并不是在讲数学的知识,所以在 SceneKit 中,系统已经帮我们做好了方法的封装,直接调用 SCNMatrix4MakeRotation 即可获得旋转后的 4x4 矩阵。 (如果你想了解矩阵的应用也可以查看我的博客 Metal 编程指南
    当我们知道原理后,为正方体设置一个角度即可。

    - (void)addBoxGeometry
    {
        SCNBox *box = [SCNBox new];
        SCNNode *boxNode = [SCNNode nodeWithGeometry:box];
        boxNode.transform = SCNMatrix4MakeRotation(M_PI/2, 1, 1, 1);
        [self.scene.scene.rootNode addChildNode:boxNode];
    }
    

    这时我们应该能看到一个正方体了,由于 SCNBox 默认为白色,如果你想设置它的颜色或为它做纹理贴图应该使用 SCNMaterial 关于 SCNMaterial 会在下面继续说明,如果你学习了 Metal 那么可以理解它是一个片元着色器
    有兴趣的可以自行搜索 SCNMaterial 学习
    我们为 正方体设置一张图片,在来看看效果。

    - (void)addBoxGeometry
    {
       SCNBox *box = [SCNBox new];
       SCNMaterial *material = box.materials.firstObject;
       UIImage *img = [UIImage imageNamed:@"bricks"];
       material.diffuse.contents = img;
       SCNNode *boxNode = [SCNNode nodeWithGeometry:box];
       boxNode.transform = SCNMatrix4MakeRotation(M_PI/2, 1, 1, 1);
       
       [self.scene.scene.rootNode addChildNode:boxNode];
    }
    
    003.png
    004.png

    现在,我们可以尝试去加载由专业建模师做好的 3D模型了。
    关于 SCNScene 可直接加载的模型请查看 官方文档 SCNSceneSource

    由于模型一般比较大,使用异步线程来进行加载

    - (void)addAppleWatch
    {
        dispatch_queue_t _loadQueue = dispatch_queue_create("load_assets", DISPATCH_QUEUE_SERIAL);
        dispatch_async(_loadQueue, ^{
            SCNScene *scene = [SCNScene sceneNamed:@"AppleWatch.usdz"];
            SCNNode *watchNode = scene.rootNode.childNodes[0];
            SCNNode *watchRoot = [SCNNode node];
            // position 代表模型加载的位置
            // position.x 控制左右
            // position.y 控制上下
            // position.z 控制前后
            watchRoot.position = SCNVector3Make(-0.5, -0.8, -0.5);
            watchRoot.scale = SCNVector3Make(0.2, 0.2, 0.2);
            [watchRoot addChildNode:watchNode];
            [self.scene.scene.rootNode addChildNode:watchRoot];
        });
    }
    

    完成加载后如下图


    005.png

    平面监测和绘制

    本次演示的平面监测使用 ARSCNView 前文提到过,ARSCNView 本身自带了 ARSession 不用额外去创建,不过在示例代码中有 使用 Metal 渲染的代码。包括横竖屏切换时纹理的裁剪。源码 Github 链接 欢迎Star

    plane001.jpeg plane002.jpeg

    本节主要是讲通过 ARSession 找到平面并在平面上绘制一个正方形。
    对于 ARSCNView 的一些属性请查看文档
    本节和后续章节都是通过 ARSession 的回调来获取 ARAnchor 信息,因此对于展示的 View 选择方面并不是很重要。

    - (void)viewDidLoad
    {
        [super viewDidLoad];
        self.view.backgroundColor = UIColor.blackColor;
        self.sceneView = [[ARSCNView alloc] initWithFrame:[UIScreen mainScreen].bounds];
        self.sceneView.session.delegate = self;
        self.sceneView.scene = [SCNScene new];
        // 设置debug 当找到特征点时 在屏幕中显示出来
        self.sceneView.debugOptions = ARSCNDebugOptionShowFeaturePoints;
        [self.view addSubview:self.sceneView];
    }
    

    初始化 ARSCNView 后,我们在页面出现是运行 ARSession
    ARSession 作为 ARKit 的核心组件,管理并配置和运行不同的增强现实技术。
    就运行而言,可以配置 平面检测、图片识别、人脸检测、骨骼检测、射线检测等..... 因此如果可以的话,应该对它要有一定的学习。
    回到平面识别,ARConfiguration 作为运行的基类,我们所有的检测都是由它的子类的配置
    平面识别:ARPositionalTrackingConfiguration
    图片识别:ARImageTrackingConfiguration
    骨骼识别:ARBodyTrackingConfiguration
    等...

    - (void)viewWillAppear:(BOOL)animated
    {
        [super viewWillAppear:animated];
        // ARPositionalTrackingConfiguration 是标准的平面识别类  但是是在 iOS13 时才推出
        // 在 iOS11 有AR时 就有了 ARWorldTrackingConfiguration 使用 WorldTracking 也能检测到平面
        // 只是精度没有 ARPositionalTrackingConfiguration 高。 如果你只在 iOS13 以上运行可以考虑使用 PositionalTracking
        ARWorldTrackingConfiguration *config = [ARWorldTrackingConfiguration new];
        config.planeDetection = ARPlaneDetectionHorizontal;
        [self.sceneView.session runWithConfiguration:config];
    }
    

    我们在初始化 ARSCNView 时为 ARSession 设置了代理。ARSession 当检测到 Anchor 时会进行回调,下列为ARSession 的回调方法

    - (void)session:(ARSession *)session didAddAnchors:(NSArray<__kindof ARAnchor*>*)anchors;
    - (void)session:(ARSession *)session didUpdateAnchors:(NSArray<__kindof ARAnchor*>*)anchors;
    - (void)session:(ARSession *)session didRemoveAnchors:(NSArray<__kindof ARAnchor*>*)anchors;

    通过方法名也能知道 当有 Anchor 时,会先进行 didAddAnchors: 方法告诉我们检测到了平面,当我们在转动摄像头时随着检测面的不断扩大,平面也会变大,因此会在 didUpdateAnchors: 告知我们那个面变大或变小了。最后如果一个地方我们长时间不用摄像头对准或两个平面合并成一个了,那么这个平面会消失,ARSession 通过 didRemoveAnchors: 方法告知我们那个平面没有了。

    /// 检测到了平面

    - (void)session:(ARSession *)session didAddAnchors:(NSArray<__kindof ARAnchor *> *)anchors
    {
        for (ARAnchor *anchor in anchors)
        {
            // 返回的是 ARAnchor 基类 因此需要额外判断一下是不是 平面 ARPlaneAnchor
            if ([anchor isKindOfClass:[ARPlaneAnchor class]])
            {
                ARPlaneAnchor *pAnchor = (ARPlaneAnchor*)anchor;
                // 此时已经获取到了平面 我们创建一个 SCNPlane 
                // SCNPlane 和 上节用到的 SCNBox 一样 都是继承自 SCNGeometry 
                // 对于平面而言 肯定是有大小的,因此在初始化时要设置平面的大小 这个大小在 ARPlaneAnchor 中已经有了
                SCNPlane *geometry;
                if (@available(iOS 16.0, *)) {
                    // 在 iOS16 中 新增了 planeExtent 属性 表明了平面的宽高
                    geometry = [SCNPlane planeWithWidth:pAnchor.planeExtent.width height:pAnchor.planeExtent.height];
                } else {
                    // 在 iOS16 以前 只有 extent 属性 用来获取屏幕的大小 X 表示宽 Z表示高
                    geometry = [SCNPlane planeWithWidth:pAnchor.extent.x height:pAnchor.extent.z];
                }
                // 为平面设置一个纹理 和上节对 正方体一样都是由 SCNMaterial 来设置的
                SCNMaterial *material = geometry.materials.firstObject;
                UIImage *img = [UIImage imageNamed:@"bricks"];
                // contents 可以是图片、颜色或通过URL 加载的任何能被转为 Texture 的材质
                material.diffuse.contents = img;
                SCNNode *planeNode = [SCNNode nodeWithGeometry:geometry];
                // 为平面设置一个名称 方便后续查找 和 UIView.tag 一样
                // 每个 ARAnchor 都有一个 identifier 用来标识唯一性
                planeNode.name = pAnchor.identifier.UUIDString;
                // ARAnchor 中的 transform 属性用来表示 这个 Anchor 所在的位置和姿态
                // 我们设置一个方法 取出它对应的 X Y Z 
                // 注意 这个位置 是相对于 世界坐标系而言的位置,
                // 因此我们设置的属性应该是 worldPosition 而不是 position
                SCNVector3 pos = ExtractTranslation(pAnchor.transform);
                planeNode.worldPosition = pos;
    
                // 你也可以先试着不用设置下面这一行 来看看效果
                // SCNPlane 默认是竖着的 因此 我们需要让它沿着 X 轴旋转一次 达到我们想要的效果
                planeNode.transform = SCNMatrix4MakeRotation(-M_PI / 2.0, 1, 0, 0);
                
                [self.sceneView.scene.rootNode addChildNode:planeNode];
                
                // 在检测到的平面上放一个 正方体 
                SCNBox *box = [SCNBox boxWithWidth:0.2 height:0.2 length:0.2 chamferRadius:0];
                SCNNode *boxNode = [SCNNode nodeWithGeometry:box];
                // 这里我们将 Y 加0.15 使正方体看着能更加贴近平面
                boxNode.worldPosition = SCNVector3Make(pos.x, pos.y+0.15, pos.z);
                boxNode.scale = SCNVector3Make(0.5, 0.5, 0.5);
                
                [self.sceneView.scene.rootNode addChildNode:boxNode];
            }
        }
    }
    // 获取 4x4 矩阵中的 pos
    SCNVector3 ExtractTranslation(const simd_float4x4& t)
    {
        return SCNVector3Make(t.columns[3][0], t.columns[3][1], t.columns[3][2]);
    }
    

    /// 平面更新

    - (void)session:(ARSession *)session didUpdateAnchors:(NSArray<__kindof ARAnchor *> *)anchors
    {
        for (ARAnchor *anchor in anchors)
        {
            if ([anchor isKindOfClass:[ARPlaneAnchor class]])
            {
                ARPlaneAnchor *pAnchor = (ARPlaneAnchor*)anchor;
                // 通过 平面的 identifier 找到 对应平面
                SCNPlane *plane = [self findPlaneWith:pAnchor.identifier.UUIDString];
                if (!plane)
                {
                    return;
                }
               // 更新平面的 宽高
                if (@available(iOS 16.0, *)) {
                    plane.width = pAnchor.planeExtent.width;
                    plane.height = pAnchor.planeExtent.height;
                } else {
                    plane.width = pAnchor.extent.x;
                    plane.height = pAnchor.extent.z;
                }
            }
        }
    }
    
    - (SCNPlane*)findPlaneWith:(NSString*)uuid
    {
        for (SCNNode *childNode in self.sceneView.scene.rootNode.childNodes) {
            if ([childNode.geometry isKindOfClass:[SCNPlane class]])
            {
                if ([childNode.name isEqualToString:uuid])
                {
                    return (SCNPlane*)childNode.geometry;
                }
            }
        }
        return nil;
    }
    

    /// 平面移除

    - (void)session:(ARSession *)session didRemoveAnchors:(NSArray<__kindof ARAnchor *> *)anchors
    {
        for (ARAnchor *anchor in anchors)
        {
            if ([anchor isKindOfClass:[ARPlaneAnchor class]])
            {
                // 当我们收到平面被移除的通知时 应该将包含平面的节点找到并移除它
                // 因此创建一个方法 通过之前设置的name 来找到节点 并删除
                ARPlaneAnchor *pAnchor = (ARPlaneAnchor*)anchor;
                SCNNode *node = [self findNodeWith:pAnchor.identifier.UUIDString];
                if (!node)
                    return;
                [node removeFromParentNode];
            }
        }
    }
    
    - (SCNNode*)findNodeWith:(NSString*)uuid
    {
        for (SCNNode *childNode in self.sceneView.scene.rootNode.childNodes) {
            if ([childNode.geometry isKindOfClass:[SCNPlane class]])
            {
                if ([childNode.name isEqualToString:uuid])
                {
                    return childNode;
                }
            }
        }
        return nil;
    }
    

    骨骼监测

    骨骼监测的 ARConfiguration 为 ARBodyTrackingConfiguration
    如果你想了解它的属性可以查看 ARKit 中的定义。
    当我们使用 ARBodyTrackingConfiguration 来运行 ARSession 时当找到人体也会进行和平面监测一样的回调,此时 ARAnchor 为 ARBodyAnchor,特征点中包含的骨骼的所有数据,需要注意的是 ARBodyAnchor 中的数据都是3D数据,如果我们希望获取骨骼的2D数据 应该使用 ARSession 的 didUpdateFrame: 回调方法

    当监测到骨骼时由 didAddAnchors 方法进行回调通知,注意回调中的 Anchor 类型为 ARBodyAnchor 代表一个整体,里面包含了头、手、脚、肩等91个特征点。

    body01.png body02.png body03.png
    在检测到人体后,ARBodyAnchor 不会在变,当我们移动摄像头切换到下一个人物时,
    对于 ARBodyAnchor 而言,只是其中的骨骼位置发生了变化,Anchor 还是同一个。
    因此需要我们去追踪  ARBodyAnchor 的 isTracked 来判断人体是否离开了摄像机。
    

    AR穿戴

    w001.png w002.png

    简单使用骨骼监测,获取到用户关键骨骼的信息,在通过矩阵获取姿态将模型贴在上面。

    图片追踪

    AR导航

    使用 ARKit 的一些常见问题及解决方案

    请参考 GitHub 中的代码

    相关文章

      网友评论

          本文标题:ARKit 看这一篇就够了

          本文链接:https://www.haomeiwen.com/subject/iaiuqdtx.html