IOS-增强现实的--NET-开发者指南-全-

IOS 增强现实的 .NET 开发者指南(全)

原文:.NET Developer's Guide to Augmented Reality in iOS

协议:CC BY-NC-SA 4.0

一、设置您的环境

首先,我们需要确保你已经安装了一些你需要的东西;之后,我们可以开始编写基本的增强现实应用并将其部署到您的 iOS 设备上。

这是你需要的东西的清单:

  • 苹果身份证

  • 合适的 iOS 设备

  • 运行 macOS 的电脑

  • x mode(x mode)-x mode(x mode)-x mode(x mode)(x mode)(x mode)(x mode)(x mode)(x mode)(x mode)(x mode)

  • 用于 Mac 的 Visual Studio

苹果 ID

好消息是,你不需要注册付费的苹果开发者计划来将应用部署到你的 iOS 设备上;你只需要你的 Apple ID 就可以开始了。然而,如果你希望最终将应用发布到 App Store,你需要加入苹果开发者计划并支付费用。你可以在 https://developer.apple.com/programs/ 找到更多关于苹果开发者计划的信息。

合适的 iOS 设备

虽然 ARKit 自 iOS 11 以来就已经存在,但旧手机可能没有足够复杂的摄像头或 CPU 来使用 ARKit 的一些新功能,如身体遮挡。您至少需要一部 iPhone 6s 或更新的 iPhone 才能使用本书中的增强现实示例。

还值得一提的是,你需要合适的电缆将你的设备连接到你的 PC 或笔记本电脑,以便你可以从 Xcode 和 Visual Studio for Mac 部署应用。值得注意的是,经过一点设置后,还可以通过 Wi-Fi 从您的电脑上将您的应用部署到您的设备上,而无需线缆。

安装 Xcode

虽然我们将主要使用 Visual Studio for Mac 来创建本书中的增强现实应用,但由于其他原因,Xcode 也是必需的,以便在您的 iOS 设备上为我们的应用提供和安装代码签名证书。

如果你还没有安装 Xcode,可以从 App Store 安装(图 1-1 )。

img/499298_1_En_1_Fig1_HTML.jpg

图 1-1

从 App Store 下载并安装 Xcode

安装 Visual Studio for Mac

你还需要最新版本的 Visual Studio for Mac,在撰写本文时是 2019 年,你会很高兴听到我们将花大部分时间在它上面。我发现,由于 Visual Studio for Mac 是一个相当新的产品,它一直在不断更新和改进。

如果你是 Windows 上的 Visual Studio 的用户,你会注意到,虽然 Mac 上的 Visual Studio 与 Windows 上的 Visual Studio 相似,但它确实有一些差异;它们不是 100%等同的。

Visual Studio for Mac 有很多要求,其中最主要的是 Xcode ( https://docs.microsoft.com/en-us/visualstudio/productinfo/vs2019-system-requirements-mac )。

可以从 https://visualstudio.microsoft.com/vs/mac/ 安装 Visual Studio for Mac,如图 1-2 所示。

img/499298_1_En_1_Fig2_HTML.jpg

图 1-2

下载并安装 Visual Studio for Mac

在 Xcode 中创建新项目

一旦你安装了 Xcode 和 Visual Studio for Mac,让我们开始创建我们的第一个项目。如果您想知道为什么我们要从 Xcode 中的项目开始,那是因为我们需要在 Xcode 中创建一个空白应用,并将其部署到我们的设备上,以便将相关的代码签名证书部署到设备上。

启动 Xcode,选择“新建 Xcode 项目”,如图 1-3 所示。

img/499298_1_En_1_Fig3_HTML.jpg

图 1-3

在 Xcode 中创建新项目

第一步。选择项目模板

在下一个名为“为您的新项目选择模板”的屏幕上,当您选择模板时,选择“单视图应用”,然后单击“下一步”,如图 1-4 所示。

img/499298_1_En_1_Fig4_HTML.jpg

图 1-4

选择“单一视图应用”作为项目模板

第二步。提供项目详情

在下一个名为“为您的新项目选择选项”的屏幕上,在“产品名称”字段中为您的应用提供一个名称。在图 1-5 中,你可以看到我已经编造了一些细节。

如果您在使用 Apple ID 之前已经登录 Xcode,则您可能已经在“团队”栏中有了一个(个人团队)条目。如果没有,也不用担心。我们稍后将登录以生成团队。

您可以将语言和用户界面保留为默认值;此外,我们不会使用单元测试或 UI 测试,所以你最好不要检查它们。

img/499298_1_En_1_Fig5_HTML.jpg

图 1-5

提供项目选项详细信息

Note

请特别注意创建的包标识符,因为我们在 Visual Studio for Mac 中创建增强现实应用时将需要它。

在本例中,它是 AwesomeCompany.HelloWorldAR。

单击下一步。

第三步。提供项目位置

为您的项目选择一个位置。我通常会为此创建一个新文件夹。

img/499298_1_En_1_Fig6_HTML.jpg

图 1-6

为您的项目选择一个位置

单击创建。

第四步。查看新项目

您应该会看到 Xcode 中新创建的 Swift 项目,如图 1-7 所示。这个不用太担心。我们不会更改任何 Swift 代码!

img/499298_1_En_1_Fig7_HTML.jpg

图 1-7

您新创建的 Swift 项目

但是,我们将把该项目部署到我们的设备上,以生成和部署我们稍后需要的代码签名证书。

您会很高兴地听到,这是在我们能够专注于使用 C# 代码在 Visual Studio for Mac 中工作之前所需的最后一步。

如果您单击“播放”按钮或立即运行项目,而没有提前提供团队,构建将会失败。所以我们去选一个队吧。

第五步。选择一个团队或使用 Apple ID 登录

双击项目名称以打开项目设置,然后转到签名和功能部分。

如果列表中没有团队,请选择“添加帐户…”然后用你的 Apple ID 登录,如图 1-8 所示。

img/499298_1_En_1_Fig8_HTML.jpg

图 1-8

选择开发团队

第六步。更改部署目标

如果您现在运行项目,它将启动设备模拟器。我们不希望这样,所以请确保您的计算机通过适当的电缆连接到您的设备,然后将部署目标更改为您的设备名称(如图 1-9 所示)并单击播放或运行(确保您的设备已解锁)。

img/499298_1_En_1_Fig9_HTML.jpg

图 1-9

更改部署目标

Note

可以通过 Wi-Fi 设置调试和部署,无需在电脑和设备之间使用电缆。

第七步。信任开发商

如果您现在运行项目,它会将应用部署到设备;但是,如果您之前没有部署到您的设备,您可能会看到如图 1-10 所示的以下消息。别担心。这只是意味着我们需要在您的设备上执行一个简单的安全步骤。

img/499298_1_En_1_Fig10_HTML.jpg

图 1-10

信托开发商

为了在您的 iOS 设备上信任开发者和您的应用,您必须前往设置➤通用➤设备管理并选择开发者应用。

按下信任按钮并确认,如图 1-11 所示。

img/499298_1_En_1_Fig11_HTML.jpg

图 1-11

信任设备管理中的开发人员

如果你现在从 Xcode 运行这个应用,一切都按计划进行,你应该会在手机上看到默认的 Hello world 屏幕。

第八步。完成的

恭喜你。对于一些人来说,这可能是你第一次在你的设备上部署应用。你会很高兴知道我们不会在这个项目上做任何其他事情。但是,您可能需要此项目将证书重新部署到您的设备,这样我就不会删除它。把它放在你的机器上。

Note

个人代码签名仅持续 7 天,之后你需要将你的应用重新部署到你的设备,以使其再次工作。

Reminder

请务必记下步骤 2 中的包标识符,因为当我们在 Visual Studio for Mac 中创建应用时将需要它。

在 Visual Studio for Mac 中创建新项目

接下来,我们将创建我们的应用,它将包含我们在 Visual Studio for Mac 中的增强现实实验,并将其部署到我们的 iOS 设备上。

启动 Visual Studio for Mac 并选择“新建项目”。

第一步。创建新项目并选择项目类型

从模板类别列表中选择 iOS,然后选择单视图 App,如图 1-12 所示。

img/499298_1_En_1_Fig12_HTML.jpg

图 1-12

选择项目类型

第二步。提供应用详细信息

您将希望使用与您在 Xcode 应用中使用的相同的应用名称和组织标识符,以便捆绑包标识符与 Xcode 相同,如图 1-13 所示。这样就可以使用相同的代码签名证书将应用配置和部署到您的 iOS 设备上。

img/499298_1_En_1_Fig13_HTML.jpg

图 1-13

提供应用详细信息

第三步。提供项目详情

现在您需要提供项目名称、解决方案名称以及项目的位置,如图 1-14 所示。这些可以是您想要的任何位置,但请确保您为 Xcode 应用提供了不同的位置。

img/499298_1_En_1_Fig14_HTML.jpg

图 1-14

提供项目详情

单击创建。

第四步。选择部署设备并运行

在前面的步骤中创建您的项目后,您应该看到新创建的项目框架,如图 1-15 所示。

img/499298_1_En_1_Fig15_HTML.jpg

图 1-15

查看新项目

确保将部署目标更改为连接的 iOS 设备,并且该设备已解锁,然后运行项目。该设备应该运行应用,这是一个相当无聊的空白白屏。

恭喜你!你已经部署了你的第一个。NET iOS 项目下载到您的设备上。

值得注意的是,这个应用还没有任何增强现实功能;我们还没有为此编写代码。您创建的项目和部署的应用将承载我们将在本书中介绍的所有增强现实功能。

设置相机权限

我们将用于增强现实的新应用将需要使用您的摄像头,因此您需要在 projects Info.plist 文件中显式声明此权限。

您可以从下拉列表中选择“隐私-相机使用说明”,并提供您喜欢的任何信息,如图 1-16 所示。首次运行应用时会显示此消息,要求用户授予应用使用摄像头的权限。

img/499298_1_En_1_Fig16_HTML.jpg

图 1-16

设置相机权限

摘要

现在,您应该已经设置好了本地环境,并准备好开始体验增强现实。这正是我们将要做的,但首先让我们在下一章讨论增强现实和 ARKit 的一些基本概念。

二、基本概念

在这一章中,我们看一些基本概念,这些概念使使用 ARKit 的移动增强现实体验成为可能,并且您将很快使用它们来构建您的增强现实应用。

在继续之前,很好地理解这些基础知识是很重要的,因为这将对你有很大的帮助,然后再继续本书中的进一步主题,在那里我们将回头参考这些基础知识。只有在理解了这些基本概念之后,我们才能继续探索更高级的概念。

场景视图

在 ARKit 中,增强现实场景视图(ARSCNView)是所有神奇事情发生的地方。当一个ARSCNView的会话运行时,它将摄像机设置为视图的背景,并显示我们添加到场景顶部的任何东西。

在清单 2-1 中,您可以看到场景视图是在 ViewController 构造函数中创建的,并且可以设置一些初始属性(我们将在后面讨论)。然后这个场景视图被添加为当前视图的子视图。

img/499298_1_En_2_Fig1_HTML.jpg

图 2-1

坐标系统

ViewDidLoad事件中,我们也将场景视图的框架设置为这个视图的框架。

您将在整本书的所有 AR 示例中使用这个基本的设置/样板代码。

private readonly ARSCNView sceneView;

public ViewController(IntPtr handle) : base(handle)
{
    this.sceneView = new ARSCNView();
    {
        AutoenablesDefaultLighting = true

    };

    this.View.AddSubview(this.sceneView);
}

public override void ViewDidLoad()
{
    base.ViewDidLoad();
    this.sceneView.Frame = this.View.Frame;
}

Listing 2-1Creating the Scene View

会议

在调用Session.Run()方法之前,SceneView 中不会发生任何事情。一旦会话开始运行,它会做很多事情。

首先,它将相机设置为视图的背景。

然后,当你四处移动设备/相机时,它开始尝试了解你的即时环境,记录兴趣点及其在相机帧之间的相对位置,同时使用设备的陀螺仪和加速度计来了解设备的方向。这个有趣的名字叫做视觉惯性里程计,这就是当我们移动相机时,它如何能够理解环境并保持我们放入场景中的东西的位置。

它开始将不可见的Anchors放置在它发现的感兴趣的点上,并在你放置它们的位置覆盖你已经放置到场景中的任何 3D 对象。锚点(我们将在第三章 ??,“节点、几何体、材质和锚点”中详细讨论)是我们 AR 场景中的参考点,可以自动检测或手动放置在场景中。

当调用Session.Run()方法时,你必须提供一种类型的ARConfiguration,它定义了你想要在场景中使用的 AR 功能的类型,如清单 2-2 所示。根据会话中使用的配置类型和设置,它可能会根据在场景中检测到的内容(如平面、图像或面)表现不同。

public override void ViewDidAppear(bool animated)

{
   base.ViewDidAppear(animated);

    this.sceneView.Session.Run(
      new ARWorldTrackingConfiguration());
}

Listing 2-2Starting the SceneView Session

SceneKit

虽然 ARKit 使本书中提到的增强现实功能成为可能,但我们实际上将广泛使用 SceneKit(这是苹果的 3D 图形框架),包括将对象放置到我们的 AR 场景中。接下来的“尺寸”和“定位”部分都来自 SceneKit,节点、几何体和材质将在下一章讨论,动画将在第五章“动画”中讨论

如果您想知道 ARKit 在哪里结束,SceneKit 在哪里开始,下面的内容可能会有所帮助。

您通常可以分辨出哪些代码类型来自 ARKit 或 SceneKit,因为它们通常分别以 AR 或 SCN 为前缀。比如ARSCNView来自 ARKit,SCNNode来自 SceneKit。

配置

了解坐标系在 SceneKit 中的工作方式非常重要,这样您就可以在 AR 场景中定位自己,并能够在三维空间中围绕您的环境放置多个对象。

有三个维度你需要记住并习惯,X,Y,Z 如图 2-1 所示。其中 X 是从左到右,Y 是从下到上,Z 是从前到后。幸运的是,有一个内置的功能,你可以打开它,在你的应用中显示坐标轴。我们将在第四章的“内置增强现实指南”中介绍这一点

在清单 2-3 中,我们可以看到当设置一个对象的位置时,我们使用了一个SCNVector3的实例,并为 X、Y 和 Z 坐标提供浮点值,其中 1f 实际上是 1 米,0.1f 10 厘米,0.01f 1 厘米。

一旦我们创建了SCNVector3的实例,有效地声明了 3D 空间中的位置,我们就可以使用 nodes Position属性为它设置一个节点位置。

public override void ViewDidAppear(bool animated)
{
    base.ViewDidAppear(animated);

    this.sceneView.Session.Run(new
      ARWorldTrackingConfiguration());

    // Creates and assigns a position to a node
    // In this case it is setting it 1m above and 1m in front
    // of the devices initial position
    var position = new SCNVector3(0, 1f, -1f);
    var node = new SCNNode();
    node.Position = position;

    // Adds the node to the scene
    // (will be invisible as we haven't told it what
    // to look like yet)
   this.sceneView.Scene.RootNode.AddChildNode(node);
}

Listing 2-3Setting the position of an object in 3D space

可能值得注意的是,在将对象放置到场景中后,您可以随时更改对象的位置,只需用具有不同 X、Y 和 Z 坐标值的SCNVector3实例更新其Position属性即可。

Hint

我花了一段时间才想起来,要把一个物体放在我面前,我必须把它放在 Z 值为负的地方(也就是你面前)。把一个东西放在 Z 轴的正方向实际上是把它放在你的后面。多。很多次,我都在困惑地寻找一个我放在前面场景中的物体,而实际上它就在我后面!

世界起源

默认情况下,当您启动 AR 应用时,您的世界原点是应用启动时您的设备所在的点。默认世界原点的位置将是(0,0,0),其中 X、Y 和 Z 都是 0。不管你将你的设备相对于世界原点移动到哪里,你放置在场景中的所有物体都将相对于世界原点,而不是你设备的当前位置。也就是说,如果需要,在应用启动后,可以通过编程方式更改世界原点的位置。

在你创建的 AR 体验中,世界原点的位置是如此准确,以至于你甚至会注意到不同的视角,这取决于你在启动应用时是坐着还是站着。

值得注意的是,如果您在将对象添加到场景时没有显式设置对象的位置,它将被放置在这个世界原点(0,0,0)。

Hint

如果你在世界原点放置一个相当大的物体,你可能看不到它,因为除非你改变了你的物理位置,否则你实际上占据了与虚拟物体相同的空间。在这种情况下,你可能需要后退一步才能看到放在世界原点的东西,因为它会在你面前。或者,当在场景中放置某物时,给它一个-Z 值,使它放在你面前。

可以想象,世界原点很重要,因为它几乎成为了你的场景或 AR 体验的系绳或中心参考点。

世界对齐

当您的ARSession启动时,它的ARWorldTrackingConfiguration将使用一个特定的WorldAlignment值来默认确定应用中轴的设置和行为,以及它的初始方向。

这很重要,因为它将决定哪个方向是向前的(-Z),因此哪个方向是向左的(-X)和向右的(+X),以及哪个方向是向上的(Y),因此哪个方向是向下的(-Y)。

如果我们愿意,我们可以改变ARWorldTrackingConfiguration的默认属性WorldAlignment,也就是WorldAlignment.Gravity

除了重力之外,还有三种不同的WorldAlignment设置可以让你的轴以不同的方式工作。

重力

Y 轴平行于重力,即直下;当应用启动时,其他轴与设备的初始方向对齐。也就是说,-Z 是应用启动时设备面对的方向,-X 在左边,X 在右边,而+Z 在后面。

例如,使用此选项并将一个对象放置在坐标为(0,0,-1f)的场景中,会将其放置在应用启动时所面对的方向上距离世界原点 1 米的位置。如果您关闭应用,转向您面对的方向,然后再次启动应用,并再次将一个对象放入场景中的(0,0,-1f),它将出现在您现在面对的方向上距离世界原点 1 米处。

在大多数情况下,WorldAlignment = ARWorldAlignment.Gravity会给你的轴你想要的行为,所以我建议你暂时坚持使用它。

重力和航向

同样,Y 轴平行于重力,尽管这次 Z 轴对准南北,X 轴对准东西。也就是说,-Z 总是北,+Z 南,-X 西,+X 东的方向。

我想如果您正在构建某种导航功能,您可能会想要使用这个设置。使用这个设置可以有效地将你的轴变成一个指南针,使你的轴始终指向北、南、东、西。

例如,使用该选项并将一个对象放置在坐标为(0,0,-1f)的场景中,会将其放置在磁北方向距离世界原点 1 米处,因此将某个对象放置在(-1f,0,0)处会将其放置在当前位置以西 1 米处,依此类推。

对于GravityGravityAndHeading来说,沿 Y 轴的任何位置都与重力对齐,其中-Y 垂直向下朝向地球中心,而+Y 垂直向上远离地球中心。

照相机

该设置对GravityGravityHeading的作用非常不同。使用WorldAlignment. Camera设置场景的坐标系,以始终匹配摄像机的方向,因此使-Z 始终与您面对的方向对齐,-X 始终在您的左侧,Y 从摄像机向上。如何定位相机将对轴系产生影响,包括 Y 轴的对齐方向。

如果这些世界排列现在看起来很混乱,不要太担心。习惯它们的一个很好的方法是打开一个调试标志,该标志将 X、Y 和 Z 轴的视觉表示放置在场景的世界原点,这在您尝试第一次 AR 体验时非常有用。我们将在第四章“内置 AR 指南”中讨论如何做到这一点

大小

ARKit 中的尺寸(嗯,实际上 SceneKit 记得吗?)存储为浮点数据类型,其中 1f 值相当于 1 米,这意味着 0.1f 相当于 10 厘米,0.01f 相当于 1 厘米。记住这一点是很有用的,因为很容易把事情做得太大(你看不到它,因为你在里面!)或者太远。巧合的是,在 AR 场景中制作一些东西的大小和位置的动画可以产生很好的效果,我们将在第 5 “动画”中学习如何做

在图 2-2 中,我们可以看到盒子的相对尺寸分别为 1 厘米、10 厘米、50 米和 1 米,以及在清单 2-4 和 2-5 中创建它们的代码。

img/499298_1_En_2_Fig2_HTML.jpg

图 2-2

不同大小的虚拟对象

这一次,如 ViewDidAppear 方法中的清单 2-4 所示(我们将在本书中实现大部分 ar 代码),我们创建了不同大小的四个不同的 CubeNode 实例(一个我们从 SCNNode 继承的自定义类,可以在清单 2-5 中看到),并使用名为this.sceneView.Scene.RootNode.AddChildNode()的重要方法将它们添加到场景中。

我们将在下一章“节点、几何图形、材质和锚点”中更详细地讨论节点

public class CubeNode : SCNNode
{
    public CubeNode(float size, UIColor color)
    {
        var material = new SCNMaterial();
        material.Diffuse.Contents = color;

        var geometry = SCNBox.Create(size, size, size,0);
        geometry.Materials = new[] { material };

        var rootNode = new SCNNode();
        rootNode.Geometry = geometry;

        AddChildNode(rootNode);
    }
}

Listing 2-5CubeNode class

public override void ViewDidAppear(bool animated)
{
     base.ViewDidAppear(animated);

     this.sceneView.Session.Run(
       new ARWorldTrackingConfiguration());

     // 1cm
     var cubeNode1 = new CubeNode(0.01f, UIColor.Red);
     cubeNode1.Position = new SCNVector3(0, 0, 0);

     // 10cm

     var cubeNode2 = new CubeNode(0.1f, UIColor.Green);
     cubeNode2.Position = new SCNVector3(0.1f, 0, 0);

     // 50cm (0.5m)
     var cubeNode3
      = new CubeNode(0.5f, UIColor.Orange);
     cubeNode3.Position = new SCNVector3(0.5f, 0, 0);

     // 100cm (1m)
     var cubeNode4 = new CubeNode(1f, UIColor.Yellow);
     cubeNode4.Position = new SCNVector3(1.5f, 0, 0);

     this.sceneView.Scene.RootNode
        .AddChildNode(cubeNode1);
     this.sceneView.Scene.RootNode
        .AddChildNode(cubeNode2);
     this.sceneView.Scene.RootNode
        .AddChildNode(cubeNode3);
     this.sceneView.Scene.RootNode
        .AddChildNode(cubeNode4);
}

Listing 2-4Adding objects of different sizes 

配置

当您用ARSession.Run()开始一个会话时,您提供了一个ARConfiguration的实例。你希望你的增强现实应用拥有的能力和你希望它如何表现将决定你使用的配置类型。

例如,如果您想进行人脸检测,您可以向它传递一个ARFaceTrackingConfiguration实例以及一些配置变量,比如要跟踪的人脸数量。

这是我们将在本书后面看到的配置列表。

  • 启用世界跟踪,包括平面、图像和物体检测,我们在本书的大部分例子中都使用它。

  • ARFaceTrackingConfiguration启用面部跟踪,我们将在第十一章“面部跟踪和表情检测”中了解这一点

  • 启用身体追踪,我们将在第十六章“身体追踪”中了解这一点

摘要

现在,您应该已经很好地理解了启动增强现实会话以设置 AR 场景所必需的基本概念,并理解了一旦场景运行后如何找到场景周围的路,包括大小调整和轴、坐标以及定位系统。

在下一章,我们将会看到你可以在场景中放置的东西,包括节点、几何图形、材质和锚点。

三、节点、几何图形、材质和锚

在这一章中,我们将看看在增强现实体验中共同创造我们可以看到和互动的一切的构建模块。让我们开始添加东西到我们的 AR 场景中。

节点

在您的 AR 场景中,您几乎肯定会有一个或多个节点(SCNNode的实例)。默认情况下,这些节点没有任何形状或形式,因此看起来不像任何东西。我们通过应用几何图形来赋予它们形状,通过将材质应用于几何图形来赋予它们视觉外观。

你想知道你会用节点做什么?嗯,几乎所有的事情。例如,它可以简单到将显示图像的彩色 3D 球体或 2D 平面放置到场景中。这两项都是节点。

我们可以使用SCNVector3来指定节点的position,就像我们在第 2 “基本概念”中看到的那样;否则,当添加到场景中时,其默认位置将是世界原点(0,0,0)。

一个节点可以有许多子节点,这些子节点又有自己的子节点,依此类推。你想知道为什么要有子节点?嗯,如果你在一个场景中放置了 50 个节点,然后想改变所有 50 个节点的位置,你必须依次改变每个节点的位置。除非您创建一个节点,然后将这 50 个节点添加为该节点的子节点,然后您只需更改父节点的位置,子节点的相对位置就会相应地增加。

我喜欢把节点想象成乐高积木,每一块都有自己的形状、大小、外观和功能,它们本身是没用的,但是把它们放在一起,我们可以做出更好的东西,更复杂和有用的东西。

不透明

可以在一个节点上设置几个属性,包括Opacity,这是我喜欢使用的东西,即使只是微妙地使用。通过改变一个节点的不透明度,我们可以使它变得更不透明,反之亦然。

不透明度是一个浮动值,范围从 0f(完全透明)到 1f(完全不透明),默认情况下,节点的不透明度值将为 1f(完全不透明)。

在清单 3-1 中,你可以看到我们如何声明一个新的材质(SCNMaterial),在这个例子中是一个纯蓝色。然后,我们创建一个新的几何体(一种 2D 或 3D 形状),在本例中是一个高度、宽度和深度均为 1m 的盒子(SCNBox),并将材质分配给这个盒子,生成一个蓝色的盒子。然后我们创建一个新的节点(SCNNode),并将其几何图形设置为新的盒子。之后,我们设置节点的opacity为 0.5f,有效地使其 50%不透明。最后,我们通过调用this.sceneView.Scene.RootNode.AddChildNode()将节点添加到场景中。

// Create the Material
var material = new SCNMaterial();
material.Diffuse.Contents = UIColor.Blue;

// Create the Box Geometry and set its Material
var geometry = SCNBox.Create(1f, 1f, 1f, 0);
geometry.Materials = new[] { material };

// Create the Node and set its Geometry
var cubeNode = new SCNNode();
cubeNode.Geometry = geometry;

// Make the cube 50% opaque
cubeNode.Opacity = 0.5f;

// Add the Node to the Scene
// Remember, as we are not explicitly setting a position,
// The Node will appear at the WorldOrigin (0,0,0)
this.sceneView.Scene.RootNode.AddChildNode(cubeNode);

Listing 3-1Creating a simple node with shape, size, and color

不用担心,材质和几何图形将在接下来的章节中讨论。

几何

几何体是一个节点可以拥有的形状或网格,没有它们,我们的场景会非常无聊;事实上,如果没有它们,我们将只有一堆看不见的无形节点。几何图形可以是简单的形状或复杂的网格。在下面的部分中,您可以看到可供我们使用的不同类型的基本内置几何图形。

内置几何形状

有许多内置几何体形状可用于节点。但别担心。你不受这些基本形状的约束;你可以提供一个自定义的几何图形,或者在另一个工具中构建一个 3D 模型,并将其导入到你的应用中,我们将在第十三章“3D 模型”中对此进行讨论

清单 3-2 中的以下代码为节点创建了一个简单的长方体几何体,宽、高、深均为 10 厘米,然后在添加到场景中之前为其赋予红色材质。

var material = new SCNMaterial();
material.Diffuse.Contents = UIColor.Red;

var boxNode = new SCNNode();
boxNode.Geometry = SCNBox.Create(0.1f, 0.1f, 0.1f, 0);
boxNode.Geometry.Materials = new SCNMaterial[] { material };
this.sceneView.Scene.RootNode.AddChildNode(boxNode);

Listing 3-2Creating a simple 10 cm red cube

以下是我们可以使用的内置几何图形:

  • 这是一个 2D 的四边长方形或正方形;它们对于将图像放置在场景中的显示图像上或作为放置其他对象的表面非常有用。值得注意的是,您可以调整平面的CornerRadius属性,将那些尖角变成更柔和、更圆的角。

  • SCNBox–如果您选择使用相同的宽度、深度和高度值,您的箱子将像一个规则的立方体,或者通过使用不同的值,它可能更像一个扁平的邮政包裹。类似于一个SCNPlane,你可以把你的尖角变成更柔和、更圆的角,但是这次是通过改变盒子的ChamferRadius属性。

  • 一个球体,用于描绘像行星这样的东西。

  • SCNCylinder–实心圆柱形。

  • 圆环是甜甜圈或环形的一个花哨的词。

  • SCNCone–实心圆锥形,一端为圆形底座,另一端为一个点。

  • SCNTube–类似于SCNCylinder,除了这是一个空心管,像一根管子。

  • SCNText–您可以放置在场景中的 3D 文本,像大多数文本一样,您可以设置其字体和大小。

  • 就像埃及人建造的一样。

当调用其.Create()方法来定义形状的不同方面时,每个几何图形需要一组不同的参数。例如,SCNSphere.Create()只接受一个参数,即球体的半径,而SCNBox.Create()接受三个参数来定义其宽度、高度和深度。

图 3-1 显示了我们可以使用的上述不同类型的几何图形。

img/499298_1_En_3_Fig1_HTML.jpg

图 3-1

不同类型的内置几何图形

但是,即使在创建几何体并将其指定给节点后,也只有在创建并指定材质后才能看到它。所以我们最好看看如何使用材质。

材质

您可以将一种或多种材质(SCNMaterial的实例)应用到一个几何图形中,为其提供视觉外观。我们将特别关注如何给一个项目一个纯色或者用图片包装它。

纯色材质

你可以给一个几何体一个最基本的材质是纯色,如清单 3-3 所示,其中我们将材质的Diffuse属性的Contents属性设置为UIColor.Red

// Create the Material
var material = new SCNMaterial();
material.Diffuse.Contents = UIColor.Red;

// Create the Box Geometry and set its Material
var geometry = SCNBox.Create(1f, 1f, 1f, 0);
geometry.Materials = new[] { material };

// Create the Node and set its Geometry
var cubeNode = new SCNNode();
cubeNode.Geometry = geometry;

Listing 3-3Setting a material to be a solid color

你可能想知道为什么一个几何体接受一组材质;这是因为我们可以在几何体的不同面使用不同的材质。例如,如果我们声明六种不同的材质,每种材质使用不同的颜色,并在长方体几何体的数组中提供这六种材质,那么我们将得到一个具有六个不同颜色边的长方体。

图像材质

另一种可以赋予几何体的材质是图像。如果我们想在一幅图像中包裹一个几何图形,或者把一幅图像放在一个 2D 平面上,这是很有用的。注意这一次,我们设置了一个UIImage给材质扩散内容属性,如清单 3-4 所示。这个内容属性接受一些不同的类型,包括我们已经看到的UIColorUIImage

// Load the image
var image = UIImage.FromFile("img/pineapple.jpg");

// Create the Material
var material = new SCNMaterial();
material.Diffuse.Contents = image;
material.DoubleSided = true;

// Create the Plane Geometry and set its Material
var geometry = SCNPlane.Create(1f, 1f);
geometry.Materials = new[] { material };

// Create the Node and set its Geometry
var rootNode = new SCNNode();
rootNode.Geometry = geometry;

// Add the Node to the Scene
this.sceneView.Scene.RootNode.AddChildNode(rootNode);

Listing 3-4Setting a material to be an image

Hint

如果您不使用material.DoubleSided = true,那么您的几何图形可能只有在从某些角度查看时才可见。

值得一提的是,也可以使用包含透明度的 PNG 图像,并且会保持透明度。例如,如果您创建了一个包含一些文本的透明 PNG,并将该图像用作SCNPlane上的材质,您将只能看到浮动文本。这是一个非常有用和漂亮的效果。

材质填充模式

默认情况下,材质的填充模式是实心的。但是,您始终可以将填充模式更改为线条,以查看组成形状的网格。在清单 3-5 和图 3-2 中,你可以看到球体几何体的填充模式可以是实线或线条。

img/499298_1_En_3_Fig2_HTML.jpg

图 3-2

不同的材质填充模式

var material = new SCNMaterial();
material.Diffuse.Contents = colour;
material.FillMode = SCNFillMode.Lines;

Listing 3-5Material fill modes

锚点是自动检测或手动放置在场景中的参考点。例如,像我们在第十章“图像检测”中所做的那样进行图像检测时,ImageAnchor 会自动放置在场景中被检测图像的位置。它们有助于将我们的虚拟物体与现实世界联系起来。

我们将在本书中使用的锚包括

  • ARPlaneAnchor–代表场景中检测到的水平或垂直平面,我们将在第九章“平面检测”中使用,以帮助可视化墙壁、地板和表面。

  • ARImageAnchor–代表在场景中检测到的图像,我们将在第十章“图像检测”中使用,检测场景中预定义的图像。

  • ARFaceAnchor–代表场景中检测到的人脸,我们将在第十一章“人脸跟踪和表情检测”中使用,在这里我们可以向检测到的人脸几何图形添加其他节点,甚至检测一系列的面部表情。

  • ARObjectAnchor–代表场景中检测到的物体,我们将在第十五章“物体检测”中使用,在场景中检测到预定义的“扫描”3D 物体的形状。

  • ARBodyAnchor–代表场景中检测到的身体,我们将在第十六章“身体跟踪”中使用它来跟踪场景中身体的位置和方向。

锚点对于跟踪我们 AR 体验中感兴趣点的存在和位置至关重要。

要尝试的事情

使用本章中讨论的概念,如节点、几何形状和材质,并将它们与第二章“基本概念”中讨论的内容相结合,如定位和尺寸,你现在应该能够自己尝试一些事情。

这里有一些让你开始的想法。

用基本的几何图形和材质制作雪人。

首先创建一个节点,然后添加其他具有不同位置、大小和材质的基本几何体的节点来创建一个基本雪人。你可以从白色球体作为身体和头部开始,黑色球体作为眼睛,棕色球体作为按钮,黑色圆柱体作为帽子。

看看你能在场景中不同的地方放置多少物品。

现在你知道了如何在不同的地方放置物品,看看你能用一个大的fordo while循环在场景的不同位置放置多少个。你甚至可以使用Random,将它们放置在任意位置。

在场景中放置不同大小的物品。

感受一下虚拟的 1 厘米、10 厘米和 1 米的物体在场景中有多大。

在场景中放置不同颜色和不透明度的物品。

使用不同颜色的材质,创建不同颜色的节点,看看它们在不同的opacity值下是什么样子。

创建透明的 png,并将其用作几何材质。

创建一个透明的 PNG,给它添加一些大的厚文本,并使用该图像作为SCNPlane的素材,看看以这种方式使用透明 PNG 有多有效。

看你能做多大或多小的节点。

看看你能在场景中放置多小的一个物体而仍然能看到它;然后看你能在一个场景中放置多大的物品(对于后者,你可能需要把它放置在离你很远的地方;否则,如果你占据了与项目相同的空间,你就有被项目内部的风险。

摘要

我们已经讨论了 ARKit 中作为增强现实的物理构建块的节点,我们将大量使用这些节点,如何利用内置的几何形状,如何为它们提供视觉外观,以及如何将它们放置在场景中。

在下一章,我们将看看一些内置工具和指南,我们可以用它们来帮助开发和理解我们的增强现实场景。

四、内置 AR 指南

ARKit 附带了一些有用的内置指南和工具,可以在开发您的第一次增强现实体验时提供帮助。我们可以在设置场景时通过在SCNDebugOptions中设置它们的标志来启用其中一些。

显示特征点

我建议你在创建第一个应用时,打开标志来显示功能点。它有助于向您展示应用和相机对照明条件和表面的依赖程度。但是,在以后的应用中,您将很少需要打开此功能。

您可以通过设置清单 4-1 中所示的DebugOptions标志来启用它。

public ViewController(IntPtr handle) : base(handle)
{
    this.sceneView = new ARSCNView
    {
        DebugOptions = ARSCNDebugOptions.ShowFeaturePoints
    };

    this.View.AddSubview(this.sceneView);
}

Listing 4-1Enabling feature points in the code

ShowFeaturePoints调试被激活时,你会看到黄色的点出现在你场景的表面上,如图 4-1 所示。丰富的特征点意味着 ARKit 可以检测场景中的许多特征点。这很好,因为 ARKit 使用特征点来帮助保持虚拟对象在场景中的位置。

你会注意到,当打开ShowFeaturePoints并在光线不好的环境中或对着毫无特色的表面(如普通的墙壁或玻璃表面)运行你的应用时,黄点会少得多。这有助于确认,为了让您的应用以最佳方式运行,它应该在光线充足、功能丰富的环境中使用。

img/499298_1_En_4_Fig1_HTML.jpg

图 4-1

显示场景中的特征点有助于我们理解应用如何在场景中寻找兴趣点

显示世界原点和坐标轴

正如我们在第二章“基本概念”中介绍位置概念时简要提到的,可以打开显示世界原点 X、Y 和 Z 坐标轴的指南,如清单 4-2 所示。这可以帮助我们定位,提醒我们 X、Y 和 Z 轴在哪个方向,如图 4-2 所示。

由于轴显示在世界原点,它指示会话开始时设备的位置,即位置 0,0,0。请记住,添加到场景中的节点,如果没有给出具体的位置,将会出现在世界原点。

public ViewController(IntPtr handle) : base(handle)
{
    this.sceneView = new ARSCNView
    {
        DebugOptions = ARSCNDebugOptions.ShowWorldOrigin
    };

    this.View.AddSubview(this.sceneView);
}

Listing 4-2Enabling WorldOrigin helper

img/499298_1_En_4_Fig2_HTML.jpg

图 4-2

在世界原点显示坐标轴

请注意,您可以同时启用多个调试选项。例如,在清单 4-3 中,您可以看到我们在场景中显示了特征点和世界原点/轴。

public ViewController(IntPtr handle) : base(handle)
{
    this.sceneView = new ARSCNView

    {
        DebugOptions
        = ARSCNDebugOptions.ShowFeaturePoints |
          ARSCNDebugOptions.ShowWorldOrigin
    };

    this.View.AddSubview(this.sceneView);
}

Listing 4-3Enabling multiple debug options

显示统计数据

通过打开清单 4-4 中所示的ShowStatistics选项,并按下底部栏上的+按钮,当您的应用运行时,附加信息会显示在屏幕底部,如图 4-3 所示。统计视图显示了一些有用的信息,尤其是当你的应用运行缓慢或者不如你希望的那样流畅时。

img/499298_1_En_4_Fig3_HTML.jpg

图 4-3

显示统计数据提供了关于场景渲染所花费的工作量的信息

public ViewController(IntPtr handle) : base(handle)
{
    this.sceneView = new ARSCNView {
        ShowsStatistics = true
    };

    this.View.AddSubview(this.sceneView);
}

Listing 4-4Enabling Statistics in the code

统计视图以每秒帧数(fps)显示帧速率,以及视图的 GPU 使用情况。如果 fps 开始下降得太低,您将需要密切关注它;60 fps 是最大值,30 以上的值也是可以接受的。它还显示了场景中的节点数(菱形)和多边形数(三角形)。如果您的应用开始遇到性能问题,您可能希望显示统计数据来调查可能导致速度下降的原因。

教练覆盖

由于应用了解其周围环境以在场景中准确地运行和放置事物非常重要,因此,为了帮助实现这一点,您可以使用内置的指导覆盖,鼓励用户移动他们的相机,直到应用收集到足够的信息,以便能够准确地了解场景。您可以向您的应用添加一个教练覆盖图,如清单 4-5 所示。

public partial class ViewController : UIViewController, IARCoachingOverlayViewDelegate
{
    private readonly ARSCNView sceneView;
    ARCoachingOverlayView coachingOverlay;

    public ViewController(IntPtr handle) : base(handle)
    {
        this.sceneView = new ARSCNView();
        this.View.AddSubview(this.sceneView);
    }

    public override void ViewDidLoad()

    {
        base.ViewDidLoad();
        this.sceneView.Frame = this.View.Frame;
    }

    public override void ViewDidAppear(bool animated)
    {
        base.ViewDidAppear(animated);

        this.sceneView.Session.Run(new
           ARWorldTrackingConfiguration {
           PlaneDetection = ARPlaneDetection.Horizontal,
        });

        coachingOverlay = new ARCoachingOverlayView();
        coachingOverlay.Session = sceneView.Session;
        coachingOverlay.Delegate = this;
        coachingOverlay.ActivatesAutomatically = true;
        coachingOverlay.Goal = ARCoachingGoal.HorizontalPlane;
        coachingOverlay.TranslatesAutoresizingMaskIntoConstraints = false;

        sceneView.AddSubview(coachingOverlay);

        // Keeps the coaching overlay in the center of the screen

        var layoutConstraints = new NSLayoutConstraint[]
        {
            coachingOverlay.CenterXAnchor.ConstraintEqualTo(
               View.CenterXAnchor),
            coachingOverlay.CenterYAnchor.ConstraintEqualTo(
               View.CenterYAnchor),
            coachingOverlay.WidthAnchor.ConstraintEqualTo(
               View.WidthAnchor),
            coachingOverlay.HeightAnchor.ConstraintEqualTo(
               View.HeightAnchor),
        };

        NSLayoutConstraint.ActivateConstraints(
           layoutConstraints);
    }

    public override void ViewDidDisappear(bool animated)
    {
        base.ViewDidDisappear(animated);
        this.sceneView.Session.Pause();
    }

    public override void DidReceiveMemoryWarning()
    {
        base.DidReceiveMemoryWarning();
    }
}

Listing 4-5Enabling coaching overlay in code

结果如图 4-4 所示;一个透明的动画图像覆盖在屏幕上,鼓励用户移动手机;在它充分理解了这个场景之后,它就消失了。

img/499298_1_En_4_Fig4_HTML.jpg

图 4-4

教练覆盖图可以帮助指导用户实现一个目标(例如检测一架飞机)

摘要

当你第一次开始创建和熟悉增强现实体验时,其中一些内置指南可能会很有用,但当你开始发布和分发你的应用时,你几乎肯定会想禁用它们。

在下一章中,我们将会看到我最喜欢的、令人印象深刻的创造引人入胜的体验的特性之一,那就是动画,它对于给你的体验一种动态的感觉至关重要。

五、动画

让你的增强现实应用看起来令人印象深刻的一个简单方法是通过动画制作一个或多个节点来添加一点点运动。不然看起来会有点静态和做作。这可能就像淡入淡出节点或动画显示它们的位置或大小一样简单,幸运的是,这很容易做到。

从技术上讲,在 SceneKit 中,我们将使用一个叫做SCNAction的东西。但是因为我们要看的动作是激活我们的动画的,所以在本章中我将把动作称为动画。

动画不透明度

通过对场景中的一个或多个对象的不透明度进行动画处理,可以实现像淡入和淡出它们的外观这样的漂亮效果。清单 5-1 展示了如何将一个节点的不透明度从 0f(零不透明度)设置为 1f(完全不透明度)。

var material = new SCNMaterial();
material.Diffuse.Contents = UIColor.Blue;

var geometry = SCNSphere.Create(0.5f);
geometry.Materials = new[] { material };

var opacityAction = SCNAction.FadeOpacityTo(1f, 3);
var sphereNode = new SCNNode();
sphereNode.Geometry = geometry;
sphereNode.Opacity = 0f;
sphereNode.RunAction(opacityAction);
this.sceneView.Scene.RootNode.AddChildNode(sphereNode);

Listing 5-1Fading in a node from 0% opacity to 100% opacity over 3 seconds

将一个物品制作成动画是将物品引入你的虚拟环境的好方法,如图 5-1 所示。感觉比一眨眼突然出现的东西自然多了。

img/499298_1_En_5_Fig1_HTML.jpg

图 5-1

在 3 秒钟内将不透明度从 0f 更改为 1f

动画缩放

虽然设置对象比例(大小)的动画是可能的,但我建议只使用比例的微小变化来达到所需的效果。可以在 X、Y 和 Z 轴(或所有方向)上设置对象缩放的动画。清单 5-2 展示了如何在一秒钟内将一个节点的大小缩放到其原始大小的 10%。

var material = new SCNMaterial();
material.Diffuse.Contents = UIColor.Yellow;

var geometry = SCNSphere.Create(0.2f);
geometry.Materials = new[] { material };

var scaleAction = SCNAction.ScaleBy(0.1f, 1);
var sphereNode = new SCNNode();
sphereNode.Geometry = geometry;
sphereNode.RunAction(scaleAction);
this.sceneView.Scene.RootNode.AddChildNode(sphereNode);

Listing 5-2Decreasing a nodes size by 90% over a second

而图 5-2 显示的是收缩球体动画。

img/499298_1_En_5_Fig2_HTML.jpg

图 5-2

在 1 秒钟内将节点的比例更改为其原始大小的 10%

动画位置

可以将一个节点的位置从一个位置动画到另一个位置,这可以使用清单 5-3 中的代码来实现。您可能希望使用此动画来使节点离您更近或更远。

var material = new SCNMaterial();
material.Diffuse.Contents = UIColor.Blue;

var geometry = SCNSphere.Create(0.5f);
geometry.Materials = new[] { material };

var positionAction = SCNAction.MoveBy(new SCNVector3(0, 0.5f, 0f), 3);
var sphereNode = new SCNNode();
sphereNode.Geometry = geometry;
sphereNode.RunAction(positionAction);
this.sceneView.Scene.RootNode.AddChildNode(sphereNode);

Listing 5-3Moving a node’s position 0.5 meter in the Y axis over 3 seconds

制作节点位置的动画有助于我们将它们从场景中枯燥的静态对象变成动态移动的对象。

动画定向

想要旋转一个节点?要么降低几度,要么让它旋转?嗯,可以,如清单 5-4 所示。在我们的场景中旋转对象可以帮助显示它们具有一定的自由度,而不是完全静止的。

var material = new SCNMaterial();
material.Diffuse.Contents = UIColor.Green;

var geometry = SCNBox.Create(0.1f, 0.1f, 0.1f, 0);
geometry.Materials = new[] { material };

var rotateAction = SCNAction.RotateBy(
   0, (float)(Math.PI), 0, 3);

var cubeNode = new SCNNode();
cubeNode.RunAction(rotateAction);
this.sceneView.Scene.RootNode.AddChildNode(cubeNode);

Listing 5-4Rotating a node by 360 degrees over 3 seconds

结果是一个缓慢旋转的立方体,如图 5-3 所示。

img/499298_1_En_5_Fig3_HTML.jpg

图 5-3

在 3 秒内将立方体旋转 360 度

重复行为

在前面的例子中,动画默认运行一次。如果你愿意,很容易让它们运行预定的次数,如清单 5-5 所示,或者重复运行,如清单 5-6 所示。

var rotateAction = SCNAction.RotateBy(
   0, (float)(Math.PI), 0, 3);

var repeatRotationForever =
   SCNAction.RepeatActionForever(rotateAction);

sphereNode.RunAction(repeatRotationForever);

Listing 5-6Repeating a rotate action indefinitely

var rotateAction = SCNAction.RotateBy(
   0, (float)(Math.PI), 0, 3);

var repeatRotationFiveTimes =
   SCNAction.RepeatAction(rotateAction, 5);

sphereNode.RunAction(repeatRotationFiveTimes);

Listing 5-5Repeating a rotate action five times

动画放松

我喜欢把放松比作开车时的加速和减速。从静止状态开始,需要一段时间来达到你想要的速度,也需要一段时间来让车减速停下。这是放松。动画在不同的时间以不同的速度播放。缓动的替代方法是线性动画,其中动画的速度从头到尾都是恒定的。清单 5-7 展示了如何在你的动画中使用缓动。

你可能想知道什么时候你可能想使用宽松。就我个人而言,我认为它让动画看起来比默认的线性更“自然”。缓和的选项有EaseInEaseOutEaseInEaseOut,Linear

var opacityAction = SCNAction.FadeOpacityTo(1f, 3);
opacityAction.TimingMode = SCNActionTimingMode.EaseInEaseOut;
sphereNode.Opacity = 0f;
sphereNode.RunAction(opacityAction);

Listing 5-7Easing animations can make them look more natural than their linear counterparts

组合动画

要创建更有趣的动画,您可以用几种方式组合它们。例如,您可以淡入一个节点,同时将它向您移动(沿 Z 轴),同时使它增长(放大)。

您可以组合这些动画,使它们同时发生或按顺序发生,如清单 5-8 所示。

var opacityAction = SCNAction.FadeOpacityTo(1f, 1);
var scaleAction = SCNAction.ScaleBy(1.2f, 1);
var positionAction = SCNAction.MoveBy(
   new SCNVector3(0, 0, -0.1f), 1);

// Would run the actions all at the same time
var simultaneousActions = SCNAction.Group(new SCNAction[] {
       opacityAction, scaleAction, positionAction });

sphereNode.RunAction(simultaneousActions);

// Would run the actions one after another
var sequentialActions = SCNAction.Sequence(new SCNAction[] {
      opacityAction, scaleAction, positionAction });

sphereNode.RunAction(sequentialActions);

Listing 5-8You can group animations to play simultaneously or sequentially

因为 SCNAction。组()和操作。Sequence()返回 SCNAction,您可以继续将这些组和序列分组或排序到“其他”组和序列中。

等待

如果你想在动画之前或动画之间等待一会儿,你可以使用SCNAction.Wait(numberOfSeconds)来延迟你的动画序列。代码很简单,如清单 5-9 所示。

var waitAction = SCNAction.Wait(1);

Listing 5-9You can use wait actions to have even greater control over the timing of your animations

摘要

因此,到现在为止,您的思维应该已经在与移动、缩放和淡化场景中的节点的方式赛跑,以创建引人入胜、动态和有趣的 AR 体验。请记住,虽然巧妙使用动画是强大的,但太多的动画很容易让人不知所措。学会如何取得平衡取决于你。

在下一章中,我们将关注约束,它可以使节点更容易以特定的方式运行。听起来很神秘,对吧?好了,翻到下一页,让我们看看约束能为我们做些什么。

六、约束

在节点上使用约束允许我们以某种方式约束它们的行为。使用它们,您可以使节点,例如,总是面对摄像机或总是面对另一个节点,如果你愿意。

广告牌约束

我推测这种效应是以你作为一名汽车乘客在路过广告牌时看着它的经历命名的。

如果将该约束应用于节点,它将始终面向摄影机。如果你想知道为什么你可能需要它,想象一下如果你有一个标志或标签提供你想让用户总是能够看到的信息。这将是SCNBillboardConstraint的一个很好的用例。从清单 6-1 中可以看出,向节点添加约束非常简单。

var rootNode = new SCNNode
{
    Geometry = CreateGeometry(),
    Constraints = new[] { new SCNBillboardConstraint() }
};

Listing 6-1Have a node always face the camera using a SCNBillboardConstraint

动画

LookAtConstraint在某些方面与BillboardConstraint相似;然而,这个约束告诉节点总是看着(面对)一个特定的节点。

之前,我已经用这个让一些周围的节点“看着”一个中心不可见的节点,效果很好,如图 6-1 所示。

img/499298_1_En_6_Fig1_HTML.jpg

图 6-1

您可以使用 LookAtConstraints 来指向节点以查看其他节点

这个效果是使用清单 6-2 中所示的代码实现的。

var lookAtConstraint = SCNLookAtConstraint.Create(targetNode);
lookAtConstraint.GimbalLockEnabled = true;
imagePlaneNode.Constraints = new SCNConstraint[]
{
   lookAtConstraint
};

Listing 6-2Use “SCNLookAtConstraint” to make nodes always face another node

如果相机旋转,使用GimbalLockEnabled=true停止节点水平旋转。

其他约束

我们可以从 SceneKit 中使用许多其他更高级的约束;然而,它们超出了本入门书的范围。它们包括

  • SCNOrientationConstraint

  • SCNTransformConstraint

  • 约束条件

  • scnavoidoccluderconstrait

  • SCNAccelerationConstraint

  • SCNSliderConstraint

  • SCNReplicatorConstraint

  • SCNIKConstraint

要尝试的事情

玩 LookAtConstraint 。在世界原点放置一个没有几何体(因此不可见)的节点。将多个 2D 平面添加到其节点设置为查看世界原点节点的场景中。

玩广告牌约束。将多个 2D 平面添加到其节点有一个SCNBillboardConstraint的场景中,并注意它们如何总是面向相机。

摘要

SCNBillboardConstraintSCNLookAtConstraint约束是约束节点行为的有效方法,特别有用,因为它们意味着你不需要使用复杂的数学来计算达到相同效果所需的精确角度。

在下一章中,我们将看一下照明,乍看之下,它似乎并不那么重要,但如果不考虑到你的 AR 体验,它实际上可以使 AR 体验变得更好或更差。

七、照明设备

事实证明,在让我们的 AR 场景看起来逼真的时候,照明是极其重要的。例如,如果是一个明亮的日子,但我们在场景中放置了一个黑暗的物体,它看起来非常人工;反过来,同样的道理也适用于将一个非常亮的物体放在黑暗的环境中。因此,在可能的情况下,我们希望在场景中考虑真实世界的光照条件。

创建逼真的 AR 体验的另一个考虑因素是阴影。

如果在场景中将对象放置在光源(如太阳)和表面(如地板)之间,您的大脑会看到阴影。我们可以创建这些假阴影,使我们的场景看起来像在现实世界中一样。

自动添加默认照明

默认情况下,当ARSCNView.AutoenablesDefaultLighting的默认值为真时,“默认”照明会添加到场景视图中。这将在场景中放置一个全向光源,指向与相机相同的方向。这对于您最初的 AR 创建来说可能是好的,但是如果您想要对特定的照明实例进行更多的控制,您可能希望通过设置AutoenablesDefaultLighting=false来关闭它。

自动更新默认照明

我们可以使用ARSCNView.AutomaticallyUpdatesLighting属性将默认照明添加到试图模拟真实世界照明条件的场景中。因此,如果真实世界的光照发生变化,人造光也会发生变化。同样,这在默认情况下是正确的,如果你希望对场景中的照明有更多的控制,你可以设置AutomaticallyUpdatesLighting=false,如清单 7-1 所示。

public ViewController(IntPtr handle) : base(handle)
{
     this.sceneView = new ARSCNView
     {
          AutoenablesDefaultLighting = false,
          AutomaticallyUpdatesLighting = false
     };

     this.View.AddSubview(this.sceneView);
}

Listing 7-1A default light source is added to the scene, but you can turn it off if you want to have more control/add your own light sources

灯光类型

除了完全依赖默认照明,还可以通过向SCNNode添加一个SCNLight实例来在场景中放置一个或多个特定光源。

您可以使用以下不同类型的光源(SCNLight.Type):

  • 环境–向各个方向均匀发光。

  • 方向性–以均匀的强度向某个方向发射光线,因此其发射位置无关紧要。无论放在 10 厘米还是 1 米远的地方,看起来都一样。

  • 泛光灯–类似于定向灯,但是它的位置可以决定光线的强度。如果光源的距离在场景中很重要,请使用此选项。

  • 聚光灯–类似于泛光灯,但光的强度逐渐减弱,形成一个光锥。

在现实世界中,光线从多个表面反射,照亮一个区域。我们能模仿的最接近的方法是添加一个Ambient光源。那么为了更好的表现一些实际的光源,我们可以使用Directional灯。因此,向场景中添加多种类型的光源并不罕见。

清单 7-2 中的例子显示了一个平行光被添加到一个SCNNode中,并指向正下方,有效地照亮了放置在它下面的任何节点的顶部。

var light = SCNLight.Create();
light.LightType = SCNLightType.Directional;
light.Intensity = 2000f;
light.ShadowColor = UIColor.Black.ColorWithAlpha(0.5f);
light.ShadowRadius = 4;
light.ShadowSampleCount = 4;
light.CastsShadow = true;

var lightNode = new SCNNode();
lightNode.Light = light;
lightNode.EulerAngles = new SCNVector3((float)-Math.PI / 2, 0, 0);

Listing 7-2You create a light source and add it to a SCNNode

Note

如果场景中唯一的虚拟光源是平行光,任何平行于光源方向的表面都将是黑色的。

如果我们愿意,我们可以做一些聪明的事情,将这种光放置在场景中的其他节点上,大致模拟太阳,并在地面上的平面上投射阴影,如下一节所示。

添加阴影

让你的物体看起来像在场景中投射阴影一样简单,只需在物体上方添加一个光源(SCNLight)并在物体下方添加一个透明平面,作为阴影投射的表面,如图 7-1 所示。

img/499298_1_En_7_Fig1_HTML.jpg

图 7-1

由虚拟光源投射在虚拟立方体下面的虚拟平面上显示的虚拟阴影

有阴影和没有阴影的体验相差十万八千里。没有阴影,虚拟立方体看起来仍然存在于场景中,但是我们了解它的位置以及它离地面有多高的唯一方法是四处移动。然而,包括阴影在内会立即给我们一个更清晰的指示,告诉我们立方体的位置和它离地面的高度。

在清单 7-3 中,因为我们想要包含平面检测,所以我们使用了一个ARSCNViewDelegate,这一次,我们将让我们的 ViewController 实现它,并将我们的场景视图委托设置为类本身(this)。

ViewDidAppear中,我们在ARWorldTrackingConfiguration中启用水平面检测。我们也在创建一个平行光的实例,设置它的属性,比如强度、方向等等,然后创建一个SCNNode保持光线,然后将包含光线的节点放置在场景中。

然后,我们创建一个立方体形状的物体,并将其添加到场景中,确保将它放置在灯光节点的下方。

然后在DidUpdateNode方法中,我们确保探测到的飞机的光照模型的材质是SCNLightingModel.ShadowOnly,有效地使它对除了投射阴影之外的所有物体透明。

public partial class ViewController : UIViewController, IARSCNViewDelegate
    {
        private readonly ARSCNView sceneView;

        public ViewController(IntPtr handle) : base(handle)
        {
            this.sceneView = new ARSCNView
            {
                AutoenablesDefaultLighting = true,
                AutomaticallyUpdatesLighting = true,
                Delegate = this
            };

            this.View.AddSubview(this.sceneView);
        }

        public override void ViewDidLoad()
        {
            base.ViewDidLoad();
            this.sceneView.Frame = this.View.Frame;
        }

        public override void ViewDidAppear(bool animated)
        {
            base.ViewDidAppear(animated);

            var configuration
               = new ARWorldTrackingConfiguration
            {
                AutoFocusEnabled = true,
                PlaneDetection = ARPlaneDetection.Horizontal,
                LightEstimationEnabled = true,
                WorldAlignment = ARWorldAlignment.Gravity,
                EnvironmentTexturing =
                   AREnvironmentTexturing.Automatic
            };

            this.sceneView.Session.Run(configuration);

            var light = SCNLight.Create();
            light.LightType = SCNLightType.Directional;
            light.Intensity = 2000f;
            light.ShadowColor =
               UIColor.Black.ColorWithAlpha(0.5f);
            light.ShadowRadius = 4;
            light.ShadowSampleCount = 4;
            light.CastsShadow = true;

            var lightNode = new SCNNode();
            lightNode.Light = light;
            lightNode.EulerAngles

               = new SCNVector3((float)-Math.PI / 2, 0, 0);

            var cube = SCNBox.Create(0.1f, 0.1f, 0.1f, 0.02f);
            var metal = SCNMaterial.Create();
            metal.LightingModelName =
               SCNLightingModel.PhysicallyBased;
            metal.Roughness.Contents = new NSNumber(0.1);
            metal.Metalness.Contents = new NSNumber(1);
            cube.FirstMaterial = metal;

            var cubeNode = new SCNNode();
            cubeNode.Geometry = cube;
            cubeNode.CastsShadow = true;

       this.sceneView.Scene.RootNode
          .AddChildNode(lightNode);

       this.sceneView.Scene.RootNode
          .AddChildNode(cubeNode);
        }

        [Export("renderer:didUpdateNode:forAnchor:")]
        public void DidUpdateNode(ISCNSceneRenderer renderer,
           SCNNode node, ARAnchor anchor)
        {
            if (anchor is ARPlaneAnchor planeAnchor)
            {
                var plane =
                  ARSCNPlaneGeometry.Create(sceneView.Device);
                plane.Update(planeAnchor.Geometry);
                plane.FirstMaterial.LightingModelName =
                  SCNLightingModel.ShadowOnly;
                node.Geometry = plane;
                node.CastsShadow = false;
            }
        }
    }

Listing 7-3If you add a light source above other nodes in a scene, you can make them all cast a shadow, making the scene look more realistic

确保如果你在你的 ViewController 类上使用IARSCNViewDelegate而不是一个单独的类,你用[Export("renderer:didUpdateNode:forAnchor:")]来修饰DidUpdateNode方法,如清单 7-3 所示。这很容易忘记,因为我有很多次,想知道为什么我的影子没有出现。

Note

如果看不到任何阴影,请确保场景中的节点的CastsShadow属性设置为true

要尝试的事情

尝试不同的光源类型和照明特性。

尝试添加不同的光源到你的场景中(以及一些不同形状的节点),看看它们对它们有什么影响。尝试不同的光线强度和方向。尝试启用和禁用默认自动照明,以查看场景的效果。

投下阴影。

确保你能得到一个投射阴影的例子,最好是有多个物体,投射多个阴影,因为阴影确实能让场景看起来更真实。

摘要

虽然你可以创建增强现实体验而不考虑照明,事实上让 ARKit 甚至为你的场景添加默认照明,为了获得更真实的体验,你会想要自己手动控制场景中的照明。在指向不同方向的不同位置使用不同强度的不同类型的灯具。

正如我们所见,添加人工阴影为我们的体验增加了额外的可信度,因为我们希望现实世界中的物体能够投射阴影,所以让我们的虚拟物体在可能的情况下投射阴影是有意义的。

在下一章中,我们将会看到更多让用户参与到我们的体验中的方法,这次是使用视频和声音。

八、视频和声音

要为您的增强现实体验添加另一个互动维度,您可以将声音和视频融入到您的场景中。当它们是与场景中的项目交互的结果时,这尤其有效。

播放声音

播放声音是一件非常简单的事情;您只需使用AVAudioPlayer的一个实例,向它提供一个声音文件的位置(确保您已经将它添加到您的项目中),并调用.Play(),如清单 8-1 所示。

NSUrl songURL = new NSUrl($"Sounds/sound.mp3");
NSError err;
AVAudioPlayer player
    = new AVAudioPlayer(songURL, "Song", out err);
player.Volume = 0.5f;
player.FinishedPlaying += delegate {
    player = null;
};
player.Play();

Listing 8-1Playing sound in an AR scene

由于声音是反馈与应用交互的一种很好的方式,例如,如果你愿意,你可以在场景中按下SCNNode时播放声音。或者你可以在应用首次加载时播放声音。

播放视频

你必须看到它才会相信,因为它看起来有点可怕,但你可以在你的增强现实场景中播放视频,这几乎就像虚拟电视屏幕或显示器一样。

在这个例子中,我们需要使用一个SKVideoNodeSKScene来播放视频。

在清单 8-2 中,您可以看到我们使用SCNMaterial将视频放在 2D 的飞机上。由于这是一种材质,您可以在其他地方使用它,例如,在 3D 盒子的侧面。

public override void ViewDidAppear(bool animated)
{
    base.ViewDidAppear(animated);
    this.sceneView.Session.Run(new
       ARWorldTrackingConfiguration {
        LightEstimationEnabled = true,
        WorldAlignment = ARWorldAlignment.GravityAndHeading

       });
    var videoNode
      = new SKVideoNode("Videos/big-buck-bunny-wide.mp4");

    // Without this the video will be inverted upside down and
    // back to front
    videoNode.YScale = -1;
    videoNode.Play();

    var videoScene = new SKScene();
    videoScene.Size = new CoreGraphics.CGSize(640, 360);
    videoScene.ScaleMode = SKSceneScaleMode.AspectFill;
    videoNode.Position
      = new CoreGraphics.CGPoint(videoScene.Size.Width / 2,
         videoScene.Size.Height / 2);
    videoScene.AddChild(videoNode);

    // Set to be the same aspect ratio as the video itself
   //(1.77)
    var width = 0.5f;
    var length = 0.28f;

    var material = new SCNMaterial();
    material.Diffuse.Contents = videoScene;
    material.DoubleSided = true;

    var geometry = SCNPlane.Create(width, length);
    geometry.Materials = new[] { material };

    var planeNode = new SCNNode();
    planeNode.Geometry = geometry;
    planeNode.Position = new SCNVector3(0, 0, -0.5f);

    this.sceneView.Scene.RootNode.AddChildNode(planeNode);
}

Listing 8-2Playing video in an AR scene

在清单 8-2 中,您还会注意到我们必须使用 SceneKit 中的一些东西来播放视频,包括SKSceneSKVideoNode.

在图 8-1 中,你可以看到一架漂浮的 2D 飞机如何显示正在播放的视频。甚至有可能改变它的不透明度,使其半透明或产生阴影,如第 7 “照明”一章所讨论的

img/499298_1_En_8_Fig1_HTML.jpg

图 8-1

在漂浮的 2D 飞机上播放视频

要尝试的事情

同时播放多个视频。

看看能不能在多个平面节点上同时播放同一个视频文件;然后看看能不能在不同的节点上同时播放不同的视频文件。

看看有多少个节点可以同时完成这项工作。5?50?

在巨大的飞机上播放视频。

梦想过 80 英寸的电视吗?看看你能否通过在一架巨大的 2D 飞机上播放一个电影文件来重现这一场景。

摘要

在增强现实体验中使用声音有助于在用户与你的应用交互时提供听觉反馈,使用视频有助于吸引用户、娱乐用户或与用户交流。两者都为用户提供了更高水平的参与度。

在下一章中,我们将看看平面检测,它可以识别地面和墙壁等表面。一旦我们探测到这些表面,我们就可以用它们做一些有趣的事情。

九、平面检测

检测表面(如地板、墙壁和表面)的能力非常重要,因为这些决定了我们场景环境的约束,也使我们能够在其上放置东西。

这种 AR 功能的商业应用很有趣。一些企业已经使用它来检测墙壁,并将墙纸或图片等产品放在墙上,让客户在购买前预览家中的物品。

探测飞机

可以将平面检测设置为仅检测水平、垂直或水平和垂直平面。

在平面检测期间,随着相机四处移动并检测其环境的更多部分,它可以检测新的平面或更新其对已经检测到的平面的理解。

当检测到一个新的平面时,一个ARPlaneAnchor被放置在检测到的位置。该锚点保存有关检测到的平面的详细信息,如其类型(水平或垂直)、位置、方向、宽度和长度,并自动赋予一个唯一的 ID,以便与其他平面区分开来。

请记住,低光照条件和无特色或反射表面将阻碍 ARKit 探测飞机的能力。例如,ARKit 将很难检测到普通的白墙或灯光昏暗的房间中的墙壁。

记住飞机

在你的应用中跟踪探测到的飞机通常是需要的和有用的。在清单 9-2 以及检测平面的代码中,您将看到将检测到的平面存储在一个变量中的代码,以便于以后检索。

ARSCNViewDelegate(场景视图代理)

一般来说,创建一个专用的类(ARSCNViewDelegate的实例)来处理当不同的锚点被检测到并放置在场景中时触发的事件,例如,当平面、图像或人脸被检测到时。我们将在关于平面和图像检测以及面部跟踪的章节中进一步讨论这一点。

因此,为了启用平面检测,您需要设置您的场景视图的场景视图委托,如清单 9-1 和 9-2 所示。

public ViewController(IntPtr handle) : base(handle)
{
    this.sceneView = new ARSCNView
    {
        AutoenablesDefaultLighting = true,
        Delegate = new SceneViewDelegate()
    };

    this.View.AddSubview(this.sceneView);
}

Listing 9-1Setting a Delegate for the ARSCNView

Note

不使用单独的类作为场景视图委托,可以让 ViewController 类实现IARSCNViewDelegate并将委托设置为this(本身)。

public class SceneViewDelegate : ARSCNViewDelegate
{
    private readonly IDictionary<NSUuid, PlaneNode> planeNodes = new Dictionary<NSUuid, PlaneNode>();

    public override void DidAddNode(
       ISCNSceneRenderer renderer,
        SCNNode node, ARAnchor anchor)
    {
        if (anchor is ARPlaneAnchor planeAnchor)
        {
            UIColor colour;

            if(planeAnchor.Alignment == ARPlaneAnchorAlignment.Vertical) {
                colour = UIColor.Red;
            }
            else {
                colour = UIColor.Blue;
            }

            var planeNode = new PlaneNode(
               planeAnchor, colour);

            var angle = (float)(-Math.PI / 2);
            planeNode.EulerAngles
               = new SCNVector3(angle, 0, 0);

            node.AddChildNode(planeNode);
            this.planeNodes.Add(anchor.Identifier, planeNode);
        }
    }

    public override void DidRemoveNode(
       ISCNSceneRenderer renderer, SCNNode node,
       ARAnchor anchor)
    {
        if (anchor is ARPlaneAnchor planeAnchor) {
            this.planeNodes[anchor.Identifier].RemoveFromParentNode();
            this.planeNodes.Remove(anchor.Identifier);
        }
    }

    public override void DidUpdateNode(ISCNSceneRenderer renderer,
        SCNNode node, ARAnchor anchor)
    {
        if (anchor is ARPlaneAnchor planeAnchor) {
           this.planeNodes[anchor.Identifier]
              .Update(planeAnchor);
        }
    }
}

Listing 9-2The instance of ARSCNViewDelegate will detect and respond to events that are fired when new planes are detected or existing planes are updated

当在场景中检测到一个新的平面时,触发DidAddNode方法(并且相应的ARPlaneAnchor被添加到场景中)。当 ARKit 对现有探测平面的理解改变时,DidUpdateNode方法启动。就是平面比原来想象的要大,或者朝向不一样。我们可以向这些方法中的任何一个添加我们自己的定制代码,用这些信息做一些有趣的事情。

平面检测示例

清单 9-3 中显示了一个 ViewController 类的例子,它检测平面并根据平面是水平还是垂直在检测到的位置放置一个蓝色或红色的SCNPlane

    public partial class ViewController : UIViewController
    {
        private readonly ARSCNView sceneView;

        public ViewController(IntPtr handle) : base(handle)
        {
            this.sceneView = new ARSCNView
            {
                AutoenablesDefaultLighting = true,
                Delegate = new SceneViewDelegate()
            };

            this.View.AddSubview(this.sceneView);
        }

        public override void ViewDidLoad()
        {
            base.ViewDidLoad();
            this.sceneView.Frame = this.View.Frame;
        }

        public override void ViewDidAppear(bool animated)
        {
            base.ViewDidAppear(animated);

            this.sceneView.Session.Run(new ARWorldTrackingConfiguration

            {
                PlaneDetection = ARPlaneDetection.Horizontal | ARPlaneDetection.Vertical,
                LightEstimationEnabled = true,
                WorldAlignment = ARWorldAlignment.GravityAndHeading
            }, ARSessionRunOptions.ResetTracking | ARSessionRunOptions.RemoveExistingAnchors);
        }

        public override void ViewDidDisappear(bool animated)
        {
            base.ViewDidDisappear(animated);
            this.sceneView.Session.Pause();
        }
    }

internal class PlaneNode : SCNNode
    {
        private readonly SCNPlane planeGeometry;

        public PlaneNode(ARPlaneAnchor planeAnchor, UIColor colour)
        {
            Geometry = (planeGeometry = CreateGeometry(planeAnchor, colour));
        }

        public void Update(ARPlaneAnchor planeAnchor)
        {
            planeGeometry.Width = planeAnchor.Extent.X;
            planeGeometry.Height = planeAnchor.Extent.Z;

            Position = new SCNVector3(
                planeAnchor.Center.X,
                planeAnchor.Center.Y,
                planeAnchor.Center.Z);
        }

        private static SCNPlane CreateGeometry(ARPlaneAnchor planeAnchor, UIColor colour)
        {
            var material = new SCNMaterial();
            material.Diffuse.Contents = colour;
            material.DoubleSided = true;
            material.Transparency = 0.8f;

            var geometry = SCNPlane.Create(planeAnchor.Extent.X, planeAnchor.Extent.Z);
            geometry.Materials = new[] { material };

            return geometry;
        }
    }

public class SceneViewDelegate : ARSCNViewDelegate
    {
        private readonly IDictionary<NSUuid, PlaneNode> planeNodes = new Dictionary<NSUuid, PlaneNode>();

        public override void DidAddNode(
           ISCNSceneRenderer renderer, SCNNode node,
           ARAnchor anchor)
        {
            if (anchor is ARPlaneAnchor planeAnchor)
            {
                UIColor colour;

                if(planeAnchor.Alignment == ARPlaneAnchorAlignment.Vertical)
                {
                    colour = UIColor.Red;
                }
                else {
                    colour = UIColor.Blue;
                }

                var planeNode
                   = new PlaneNode(planeAnchor, colour);
                var angle = (float)(-Math.PI / 2);
                planeNode.EulerAngles
                   = new SCNVector3(angle, 0, 0);

                node.AddChildNode(planeNode);
                this.planeNodes.Add(anchor.Identifier, planeNode);
            }
        }

        public override void DidRemoveNode(
           ISCNSceneRenderer renderer, SCNNode node,
           ARAnchor anchor)
        {
            if (anchor is ARPlaneAnchor planeAnchor)
            {
                this.planeNodes[anchor.Identifier]
                   .RemoveFromParentNode();
                this.planeNodes.Remove(anchor.Identifier);
            }
        }

        public override void DidUpdateNode(
           ISCNSceneRenderer renderer, SCNNode node,
            ARAnchor anchor)
        {
            if (anchor is ARPlaneAnchor planeAnchor)
            {
               this.planeNodes[anchor.Identifier]
                  .Update(planeAnchor);
            }
        }
    }

Listing 9-3A full end-to-end example of plane detection

结果如图 9-1 所示。在地板与墙壁相接的地方,您可以看到检测到的垂直和水平平面的材质是如何分别变为红色和蓝色的。使用了不透明度,以便您仍然可以看到平面(墙或地板)。

img/499298_1_En_9_Fig1_HTML.jpg

图 9-1

区分检测到的水平面和垂直面

当然,正如第三章“节点、几何体、材质和锚点”中所讨论的,除了纯色,几何体材质也可以是图像。通过使用一个正方形的透明 PNG,并在被检测的平面上重复/平铺图像,可以很容易地实现如下图 9-2 所示的网格效果。

img/499298_1_En_9_Fig2_HTML.jpg

图 9-2

检测平面上使用的网格图像

关闭平面检测

平面检测可能是 CPU 密集型的;建议您一旦确定了想要的平面,就关闭平面检测,如清单 9-4 所示。

这可以通过简单地调用现有 SceneView 会话上的.Run()方法来完成,这一次将ARWorldTrackingConfigurationPlaneDetection设置为ARPlaneDetection.None

...

// Turn off plane detection
var configuration = new ARWorldTrackingConfiguration
{
    PlaneDetection = ARPlaneDetection.None,
    LightEstimationEnabled = true,
};

this.sceneView.Session.Run(configuration, ARSessionRunOptions.None)

;

...

Listing 9-4It is recommended to turn off plane detection when no longer needed

可能的应用

平面检测已经被许多企业成功使用。一些主要的家具零售商在其应用中使用它来检测地板,以允许用户在他们的客厅中放置他们家具的 3D 模型。一些壁纸和油漆零售商使用它来允许他们应用的用户预览特定壁纸或油漆在他们墙上的样子。

如果我们想让我们的虚拟物体在真实的表面上投射虚拟的阴影,就像我们在第七章“照明”中看到的那样,能够检测场景中的平面也很有用

就像 AR 的许多方面一样,你只需要发挥你的想象力,你应该有希望能够快速识别许多可能的应用。

要尝试的事情

现在您已经了解了平面检测的理论,您可以尝试以下方法来以不同的方式使用该功能。

识别检测到的垂直和水平平面,并在视觉上区分它们。

将检测到的水平和垂直平面设置为您选择的颜色,并调整不透明度。

使用在检测平面上有图像的材质。

与其给你检测到的平面几何体一个纯色,不如给它一个图像作为材质。我见过一个(平铺的)透明网格图像,用来给探测到的飞机一个有趣的外观。

关闭平面探测。

当不再需要时,练习关闭平面检测。如前所述,它是密集的,在你充分探测到你的飞机后,通常你不需要探测更多。

为您检测到的平面添加触摸交互。

阅读完第十二章“触摸手势和交互”后,回来给你检测到的平面添加触摸手势。可能改变它们的颜色或其他方面?

摘要

平面检测是一个需要理解的重要概念,因为它让你能够做许多有趣的事情,比如将物体放置在被检测的表面上。

继续我们 ARKit 内置检测能力的主题,在下一章,我们将看看图像检测,它允许我们识别场景中的预定义图像,并对它们做一些有趣的事情。

十、图像检测

图像检测是增强现实中最简单、有趣和有用的功能之一,ARKit 使它变得超级容易。

在这一章中,我们将看到如何使用 ARKit 来识别我们希望它检测的预定图像的位置。一旦我们确定了已识别图像的位置,我们就可以做额外的事情,如替换或添加它。以这种方式,图像通常被用作标记来识别 3D 空间中的位置。

将图像添加为应用资源

声明要检测的图像的一种方法是将它们与应用打包在一起。如果您在部署应用之前知道想要检测的图像,这将非常有用。

为此:

  1. Double-click the Assets.xcassets folder in Solution Explorer to see the following screen shown in Figure 10-1.

    img/499298_1_En_10_Fig1_HTML.jpg

    图 10-1

    Assets.xcassets 文件夹

  2. Click the bottom right green plus icon to bring up the “add” context menu and select “New AR Resource Group” to add a new AR Resource Group as shown in Figure 10-2.

    img/499298_1_En_10_Fig2_HTML.jpg

    图 10-2

    添加新的 AR 资源组

  3. Right-click the new AR Resource Group and choose “New AR Reference Image” as shown in Figure 10-3.

    img/499298_1_En_10_Fig3_HTML.jpg

    图 10-3

    添加新的 AR 参考图像

  4. Choose the image, provide its dimensions, and optionally rename it as shown in Figure 10-4.

    img/499298_1_En_10_Fig4_HTML.jpg

    图 10-4

    选择图像并提供尺寸

您在步骤 4 中指定的尺寸是图像在真实世界中显示的近似尺寸。您指定这些来帮助应用检测它。

检测图像

现在我们已经添加了我们想要在现实世界中检测的图像,我们需要编写代码来检测它们,并在我们的应用检测到它们时做一些有趣的事情。

正如你在清单 10-1 的构造函数中看到的,我们告诉我们的场景视图使用场景视图委托。这个类可以在清单 10-3 中看到,它有效地处理了图像检测事件。

public ViewController(IntPtr handle) : base(handle)
{
    this.sceneView = new ARSCNView
    {
        AutoenablesDefaultLighting = true,
        Delegate = new SceneViewDelegate()
    };

    this.View.AddSubview(this.sceneView);
}

Listing 10-1Setting a Scene View Delegate to use in the constructor

在清单 10-2 中,我们正在检索之前添加到图 10-4 中“AR 资源 AR 参考组”的图像,并将它们设置为我们想要检测的图像。

public override void ViewDidAppear(bool animated)
{
    base.ViewDidAppear(animated);

    var detectionImages = ARReferenceImage.GetReferenceImagesInGroup("AR Resources", null);

    this.sceneView.Session.Run(new ARWorldTrackingConfiguration
    {
        LightEstimationEnabled = true,
        WorldAlignment = ARWorldAlignment.GravityAndHeading,
        DetectionImages = detectionImages,
        MaximumNumberOfTrackedImages = 1

    });
}

Listing 10-2Declaring which images we wish to detect in the scene

在清单 10-3 中的 SceneViewDelegate 中,我们首先检查添加到场景中的锚点是否是一个ARImageAnchor。这将是我们的应用在相机视图中检测目标图像的结果。然后我们可以得到我们在图 10-4 中提供的参考图像的相应名称,这样我们就可以识别出检测到了哪张图像。

接下来,在本例中,我们要做的就是确定检测图像的尺寸,创建一个蓝色平面,并将其放置在检测图像的位置,有效地覆盖图像。

值得注意的是,一旦你在这个节点上放置了虚拟的东西,如果你改变了现实世界中检测到的图像的方向,你添加的平面的方向也会发生旋转。

这是一个非常酷的效果,显示了 ARKit 有多聪明;它能够识别检测到的图像的方向正在改变,并可以实时相应地改变虚拟节点的方向。

public class SceneViewDelegate : ARSCNViewDelegate
{
    public override void DidAddNode(
    ISCNSceneRenderer renderer, SCNNode node, ARAnchor anchor)
    {
        if (anchor is ARImageAnchor imageAnchor)
        {
            var detectedImage = imageAnchor.ReferenceImage;

            var width = detectedImage.PhysicalSize.Width;
            var length = detectedImage.PhysicalSize.Height;
            var planeNode = new PlaneNode(width, length, new SCNVector3(0, 0, 0), UIColor.Blue);

            float angle = (float)(-Math.PI / 2);
            planeNode.EulerAngles
               = new SCNVector3(angle, 0, 0);

            node.AddChildNode(planeNode);
        }
    }
}

Listing 10-3Scene View Delegate handles image detection events

在清单 10-4 中,我们可以看到一个简单的类来封装我们在清单 10-3 中使用的一个平面节点。

public class PlaneNode : SCNNode
{
    public PlaneNode(nfloat width, nfloat length,
       SCNVector3 position, UIColor colour)
    {
        var rootNode = new SCNNode
        {
            Geometry = CreateGeometry(width, length, colour),
            Position = position
        };

        AddChildNode(rootNode);
    }

    private static SCNGeometry CreateGeometry(
       nfloat width, nfloat length, UIColor colour)
    {
        var material = new SCNMaterial();
        material.Diffuse.Contents = colour;
        material.DoubleSided = false;

        var geometry = SCNPlane.Create(width, length);
        geometry.Materials = new[] { material };

        return geometry;
    }
}

Listing 10-4Our custom PlaneNode

动态添加要检测的图像

除了将您想要检测的图像与应用打包在一起之外,还可以在运行时动态添加要检测的图像。如果您不知道需要在编译时检测哪些图像,这尤其有用。

例如,您可以调用 Amazon API,返回畅销书籍封面的图像,并将这些图像添加到应用中进行检测。然后,当检测到那些书籍封面时,提供进一步的功能,例如在检测到的书籍旁边的 AR 中检索和显示评论信息。

要尝试的事情

既然您已经知道了如何检测场景中的预期图像,那么您可能希望尝试看看您可以使用该功能做些什么。这里有一些想法。

用另一幅图像替换检测到的图像。

检测到图像后,尝试将另一个图像放在检测到的图像上(显然是替换它)。

用视频替换检测到的图像。

检测到图像后,将视频放在检测到图像的位置并播放。参见第八章“视频和声音”,了解如何将视频添加到场景中。

在检测到图像的位置放置一个 3D 模型。

检测到图像后,在检测到图像的位置放置 3D 模型。参见第十三章“3D 模型”,了解如何将 3D 模型添加到场景中。

img/499298_1_En_10_Fig5_HTML.jpg

图 10-5

将 3D 模型放置在检测到的图像上

图像检测可用于创建一些有趣的效果,如图 10-5 所示,其显示了放置在检测图像顶部的 3D 模型,以及图 10-6 所示,其显示了添加在检测图像顶部的浮动图像。

img/499298_1_En_10_Fig6_HTML.jpg

图 10-6

将浮动图像添加到检测到的图像

摘要

图像检测是增强现实中一个非常有用的功能,营销人员经常使用它来为他们的产品添加 AR 体验。

ARKit 的另一个惊人特性是,它不仅可以跟踪人脸,还可以跟踪面部表情。我们将在下一章“面部跟踪和表情检测”中探讨这个问题

十一、人脸跟踪和表情检测

万一你认为 ARKit 提供的内置增强现实功能不够惊人,你不会相信你可以用面部跟踪和面部表情检测来做什么。通过使用前置摄像头,它可以跟踪多张脸,甚至是脸上的表情。

跟踪人脸

现成的 ARKit 让我们能够在一个场景中跟踪多达三张不同的脸。明确地说,这意味着检测人脸并在场景中跟随他们。

请注意,如果没有额外的编码,ARKit 无法识别这些面孔属于谁。ARKit 只能检测到在镜头前是张脸,而不是他们属于谁。

img/499298_1_En_11_Fig1_HTML.jpg

图 11-1

一个场景中最多可以跟踪三个面,并且可以检索和操纵它们的几何体

值得注意的是,较旧的 iOS 设备可能不支持面部跟踪。建议您检查一下ARFaceTrackingConfigurationIsSupported属性在尝试调用面部跟踪功能之前,如果您的设备不支持该功能,应用将在尝试调用该功能时崩溃并退出。如果不支持面部跟踪,您可能希望向用户显示一条消息,告诉他们这一点。

在清单 11-1 中,我们正在运行我们的会话,这次使用的是ARFaceTrackingConfiguration,它默认使用手机上的前置摄像头,允许我们跟踪场景中的人脸,如图 11-1 所示。

然后,我们使用场景视图委托来处理当检测到、移动或更改人脸时触发的事件。更具体地说,在下面的代码示例中,当在场景中检测到一个新的面部时(在相关位置放置一个ARFaceAnchor),我们将检索面部几何图形,并将其设置为放置在ARFaceAnchor位置的节点的几何图形,并将其设置为 80%不透明。

public partial class ViewController : UIViewController

{
    private readonly ARSCNView sceneView;

    public ViewController(IntPtr handle) : base(handle)
    {
        this.sceneView = new ARSCNView
        {
            AutoenablesDefaultLighting = true,
            Delegate = new SceneViewDelegate()
        };

        this.View.AddSubview(this.sceneView);
    }

    public override void ViewDidLoad()
    {
        base.ViewDidLoad();
        this.sceneView.Frame = this.View.Frame;
    }

    public override void ViewDidAppear(bool animated)
    {
        base.ViewDidAppear(animated);

        var faceTrackingConfiguration = new ARFaceTrackingConfiguration()
        {
                LightEstimationEnabled = true,
                MaximumNumberOfTrackedFaces = 1
        };

        this.sceneView.Session.Run(faceTrackingConfiguration);
    }

    public override void ViewDidDisappear(bool animated)
    {
        base.ViewDidDisappear(animated);
        this.sceneView.Session.Pause();
    }
}

public class SceneViewDelegate : ARSCNViewDelegate
{
    public override void DidAddNode(ISCNSceneRenderer renderer, SCNNode node, ARAnchor anchor)
    {
        if (anchor is ARFaceAnchor faceAnchor)
        {
            var faceGeometry = ARSCNFaceGeometry.Create(renderer.GetDevice());

            node.Geometry = faceGeometry;
            node.Opacity = 0.8f;
        }
    }

    public override void DidUpdateNode(ISCNSceneRenderer renderer, SCNNode node, ARAnchor anchor)
    {
        if (anchor is ARFaceAnchor)
        {
            var faceAnchor = anchor as ARFaceAnchor;
            var faceGeometry = node.Geometry as ARSCNFaceGeometry;
            faceGeometry.Update(faceAnchor.Geometry);
        }
    }
}

Listing 11-1Tracking people’s faces in the scene

如果我们愿意的话,我们可以用一张图片来代替面部几何图形,而不是使用平面纯色。例如,我们可以在某人的脸上放一张图片,让他们看起来像一个戴面具的超级英雄。

人们很容易怀疑这种功能在现实世界中的应用是否有用,而只是把它当成一种乐趣;然而,有一些成功的企业已经实施了这种类型的面部跟踪,并取得了很好的效果。例如,能够跟踪面部的方向,一些企业通过将不同风格的眼镜的 3D 模型添加到用户的面部几何形状上,来显示用户佩戴不同眼镜的样子。令人印象深刻的东西。

识别面部表情

除了跟踪场景中人脸的存在,我们还能够检测这些人脸上数量惊人的不同面部表情(事实上,超过 50 种不同的面部表情)。

在图 11-2 中,我使用了我们在第 3 “节点、几何图形、材质和锚点”中首次提到的material.FillMode = SCNFillMode.Lines,默认颜色为白色,然后,当检测到嘴部漏斗时,将线条颜色改为黄色。

img/499298_1_En_11_Fig2_HTML.jpg

图 11-2

可以检测到 50 多种不同的面部表情

利用SCNFillMode.Lines,我们真的可以看到 ARKit 是如何检测人脸轮廓的。毫不奇怪,它可以推断出许多面部表情。

这里有一个完整的可检测面部表情列表(我告诉过你有很多):

  • 左吊环,右吊环

  • 左,右

  • eyeLookInLeft,eyeLookInRight

  • 向左看,向右看

  • 向上看,向上看

  • 左眼睛,右眼睛

  • 左睁大眼睛,右睁大眼睛

  • 下巴向前

  • 左颚,右颚

  • jawOpen

  • 嘴巴闭上

  • 漏斗嘴

  • 噘嘴

  • 向左,向右

  • 嘴巴左,嘴巴右

  • 左嘴巴,右嘴巴

  • 左嘴巴,右嘴巴

  • 左口拉伸,右口拉伸

  • 下口辊,上口辊

  • 口交怒视者,口交怒视者

  • 口按左,口按右

  • 左下下,右下下

  • 左嘴巴,右嘴巴

  • 浏览器左,浏览器右

  • 浏览器升级

  • browOuterUpLeft

  • 棕色直立

  • 脸颊粉扑

  • 左脸颊,右脸颊

  • 鼻子左,鼻子右

  • 伸出舌头

每个表情的描述可以在苹果官方文档这里找到: https://developer.apple.com/documentation/arkit/arfaceanchor/blendshapelocation

ARKit 甚至允许我们同时跟踪多个表情(例如,右眼闭上和舌头伸出),以及跟踪这些表情的相对存在。例如,每个表达式都有一个介于 0 和 1 之间的浮点值,以表示该表达式完全不存在或完全存在,也就是说,跟踪舌头是完全不出来、有点出来还是完全不出来。

清单 11-2 展示了当我们开始会话时,我们如何设置和使用 ARFaceTrackingConfiguration。

public partial class ViewController : UIViewController
{
    private readonly ARSCNView sceneView;

    public ViewController(IntPtr handle) : base(handle)
    {
        this.sceneView = new ARSCNView
        {
            AutoenablesDefaultLighting = true,
            Delegate = new SceneViewDelegate()
        };

        this.View.AddSubview(this.sceneView);
    }

    public override void ViewDidLoad()
    {
        base.ViewDidLoad();
        this.sceneView.Frame = this.View.Frame;
    }

    public override void ViewDidAppear(bool animated)
    {
        base.ViewDidAppear(animated);

        var faceTrackingConfiguration = new
           ARFaceTrackingConfiguration()
        {
            LightEstimationEnabled = true,
            MaximumNumberOfTrackedFaces = 1
        };

        this.sceneView.Session.Run(faceTrackingConfiguration);
    }

    public override void ViewDidDisappear(bool animated)
    {
        base.ViewDidDisappear(animated);
        this.sceneView.Session.Pause();
    }
}

public class SceneViewDelegate : ARSCNViewDelegate
{
    public override void DidAddNode(ISCNSceneRenderer renderer, SCNNode node, ARAnchor anchor)
    {
        if (anchor is ARFaceAnchor)
        {
            var faceGeometry = ARSCNFaceGeometry.Create(renderer.GetDevice());
            node.Geometry = faceGeometry;
            node.Geometry.FirstMaterial.FillMode =
               SCNFillMode.Lines;
        }
    }

    public override void DidUpdateNode(ISCNSceneRenderer renderer, SCNNode node, ARAnchor anchor)
    {
        if (anchor is ARFaceAnchor)
        {
            var faceAnchor = anchor as ARFaceAnchor;
            var faceGeometry = node.Geometry as
               ARSCNFaceGeometry;
            var expressionThreshold = 0.5f;

            faceGeometry.Update(faceAnchor.Geometry);

            if (faceAnchor.BlendShapes.EyeBlinkLeft > expressionThreshold
                || faceAnchor.BlendShapes.EyeBlinkRight > expressionThreshold)
            {
                ChangeFaceColour(node, UIColor.Blue);
                return;
            }

            if (faceAnchor.BlendShapes.MouthSmileLeft > expressionThreshold
                || faceAnchor.BlendShapes.MouthSmileRight > expressionThreshold)
            {
                ChangeFaceColour(node, UIColor.SystemPinkColor);
                return;
            }

            if (faceAnchor.BlendShapes.EyeLookOutLeft > expressionThreshold
                || faceAnchor.BlendShapes.EyeLookOutRight > expressionThreshold)
            {
                ChangeFaceColour(node, UIColor.Magenta);
                return;
            }

            if (faceAnchor.BlendShapes.TongueOut > expressionThreshold)
            {
                ChangeFaceColour(node, UIColor.Red);
                return;
            }

            if (faceAnchor.BlendShapes.CheekPuff > expressionThreshold)
            {
                ChangeFaceColour(node, UIColor.Orange);
                return;
            }

            ChangeFaceColour(node, UIColor.White);
        }
    }

    private void ChangeFaceColour(SCNNode faceGeometry, UIColor colour)
    {
        var material = new SCNMaterial();
        material.Diffuse.Contents = colour;
        material.FillMode = SCNFillMode.Lines;

        faceGeometry.Geometry.FirstMaterial = material;
    }
}

Listing 11-2Recognizing a few of the facial expressions

注意在清单 11-2 中,我们使用SCNFillMode.Lines作为材质FillMode来更好地显示面部几何的轮廓。

要尝试的事情

向检测到的人脸添加新的节点和形状。

在 DidUpdateNode 方法中,为包含面部网格的节点创建并添加额外的节点(例如,形状或图像),例如,在某人的头上戴一顶基本的“帽子”,给他们留胡须,或显示包含此人信息的图像(在 2D 平面上)。

区别对待不同的被跟踪人脸。

如上所述,ARKit 可以同时跟踪多达三张脸。尝试为被跟踪的每张脸分配不同的颜色。

使用图像作为面部材质。

不要对检测到的面节点材质使用纯色,尝试对材质使用图像。有了正确的图像,你可以让用户的脸看起来像蜘蛛侠的面具或类似的东西。发挥你的想象力!

摘要

面部跟踪和面部表情检测允许我们通过在体验中涉及用户的面部来增强个性化。这种用例的范围可以从一点乐趣到预览可穿戴产品,这是增强现实应用的一个非常受欢迎的用例。

到目前为止,在本书中,我们已经看到了在场景中放置物品;在下一章中,当我们学习触摸手势和交互时,我们将看看我们如何与这些物体交互。

十二、触摸手势和交互

到目前为止,我们已经研究了向我们的增强现实场景添加虚拟对象的不同方法。如果你也能和他们互动,那不是很好吗?哦,等等。你可以,这就是我们将在本章中探讨的内容。

手势识别器

有许多预定义的触摸设备屏幕的方式可以自动转换为所谓的手势,并触发一个等效的UIGestureRecognizer。然后根据手势的类型,如果场景中的任何虚拟物品被触摸,它们可以被相应地操纵。

我们能够识别设备屏幕上的许多不同手势,在这一章中,我们将看看如何对它们做出反应。

  • 龙头

  • 辐状的

  • 少量

  • 偷窃

  • 商标出版社

我们还将看看如何改变这些手势的默认行为并扩展它们。例如,轻松地将轻击手势更改为双击手势,或者更改长按手势所需的按下时间。

你必须记住,设备屏幕是二维的,因此我们的手势是在 2D,所以有时有必要在代码中定义你想要在哪个轴上操作你的虚拟物品。例如,当使用向上或向下或向左或向右的平移手势时,在 3D 空间中沿着 Y 和 X 轴移动对象是有意义的,但是如何使用户能够沿着 Z 轴移动对象以使其更近或更远呢?您可能希望使用多个手势识别器来实现您想要的体验。

连接手势识别器

为了让我们的应用响应不同类型的触摸,我们需要告诉我们的 SceneView 监听我们希望它识别的手势,如清单 12-1 所示。

然后在本章后面的清单中,我们可以看看为这些类型的手势运行的示例代码。

public override void ViewDidAppear(bool animated)
{
    base.ViewDidAppear(animated);

    ...

    var panGesture = new UIPanGestureRecognizer(HandlePanGesture);
    this.sceneView.AddGestureRecognizer(panGesture);

    var rotateGesture = new UIRotationGestureRecognizer(HandleRotateGesture);
    this.sceneView.AddGestureRecognizer(rotateGesture);

    var pinchGesture = new UIPinchGestureRecognizer(HandlePinchGesture);
    this.sceneView.AddGestureRecognizer(pinchGesture);

    var tapGesture = new UITapGestureRecognizer(HandleTapGesture);
    this.sceneView.AddGestureRecognizer(tapGesture);

    var swipeGesture = new UISwipeGestureRecognizer(HandleSwipeGesture);
    this.sceneView.AddGestureRecognizer(swipeGesture);

    var longPressGesture = new UILongPressGestureRecognizer(HandleLongPressGesture);
    this.sceneView.AddGestureRecognizer(longPressGesture);

    ...
}

Listing 12-1We can tell our app to respond to a number of different gestures

开孔

我们可以检测屏幕上的点击是否触摸了场景中的虚拟对象,并做出相应的反应。例如,如果我们想让它成为双击手势识别器,我们还可以坚持最少的点击次数。在清单 12-2 中,当点击一个节点时,我们将它的颜色改为黑色。

private void HandleTapGesture(UITapGestureRecognizer sender)
{
    var areaTapped = sender.View as SCNView;
    var location = sender.LocationInView(areaTapped);
    var hitTestResults = areaTapped.HitTest(
       location, new SCNHitTestOptions());

    var hitTest = hitTestResults.FirstOrDefault();

    if (hitTest == null)
        return;

    var node = hitTest.Node;

    var material = new SCNMaterial();
    material.Diffuse.Contents = UIColor.Black;
    node.Geometry.FirstMaterial = material;
}

Listing 12-2Tap UIGestureRecognizer

如果您使用点击手势来“选择”场景中的虚拟对象,您可能想要做其他事情来表示它是“被选择的”,例如更改它的颜色或比例,例如,帮助表示被点击的对象具有焦点,并且您希望您的用户知道什么被点击/选择。

吝啬的

通过将两个手指放在屏幕上,将它们捏在一起或松开,我们可以放大或缩小你捏的虚拟物品。这可以通过使用清单 12-3 中所示的代码来实现。

Note

当处理如清单 12-2 所示的收缩手势和缩放节点时,有必要将发送者比例重置为 1,以避免异常行为。

private void HandlePinchGesture(UIPinchGestureRecognizer sender)
{
    var areaPinched = sender.View as SCNView;
    var location = sender.LocationInView(areaPinched);
    var hitTestResults = areaPinched.HitTest(
       location, new SCNHitTestOptions());

    var hitTest = hitTestResults.FirstOrDefault();

    if (hitTest == null)

        return;

    var node = hitTest.Node;

    var scaleX = (float)sender.Scale * node.Scale.X;
    var scaleY = (float)sender.Scale * node.Scale.Y;
    var scaleZ = (float)sender.Scale * node.Scale.Z;

    node.Scale = new SCNVector3(scaleX, scaleY, scaleZ);
    sender.Scale = 1;
}

Listing 12-3Pinch UIGestureRecognizer

收缩是缩放场景中项目的一种很好的方式。它通常用于在其他流行的应用中缩放项目或放大/缩小,因此用户以这种方式使用挤压会感觉很自然。

轮流

通过将两个手指放在屏幕上的虚拟物体上,顺时针或逆时针旋转它们的位置,我们可以在给定的轴上旋转虚拟物体。

在清单 12-4 中,当我们检测到这个手势时,我们在 Z 轴上旋转被触摸对象的方向。

private void HandleRotateGesture(UIRotationGestureRecognizer sender)
{
    var areaTouched = sender.View as SCNView;
    var location = sender.LocationInView(areaTouched);
    var hitTestResults = areaTouched.HitTest(
       location, new SCNHitTestOptions());

    var hitTest = hitTestResults.FirstOrDefault();

    if (hitTest == null)
        return;

    var node = hitTest.Node;

    newAngleZ = (float)(-sender.Rotation);
    newAngleZ += currentAngleZ;
    node.EulerAngles = new SCNVector3(node.EulerAngles.X,
       node.EulerAngles.Y, newAngleZ);
}

Listing 12-4Rotate UIGestureRecognizer

您可能需要尝试平移旋转手势来更改对象在不同轴上的方向,以获得正确的结果。

装鱼箱

通过将手指放在屏幕上的虚拟对象上,并在屏幕上向任意方向拖动它,然后释放,我们可以将一个项目从其原始位置沿给定的轴移动到新的位置。清单 12-5 展示了如何响应平移手势。

private void HandlePanGesture(UIPanGestureRecognizer sender)
{
    var areaPanned = sender.View as SCNView;
    var location = sender.LocationInView(areaPanned);
    var hitTestResults = areaPanned.HitTest(location,
       new SCNHitTestOptions());

    var hitTest = hitTestResults.FirstOrDefault();

    if (hitTest == null)
        return;

    var node = hitTest.Node;

    if (sender.State == UIGestureRecognizerState.Changed)
    {
        var translate = sender.TranslationInView(areaPanned);

        // Only allow movement vertically or horizontally
        // High values are used so that the movement is smooth
        node.LocalTranslate(
           new SCNVector3((float)translate.X / 10000f,
              (float)-translate.Y / 10000, 0.0f));
    }
}

Listing 12-5Pan UIGestureRecognizer

正如简介中提到的,我们只能使用触摸手势在二维空间(垂直和水平)与设备屏幕进行交互,因此当识别出平移手势时,我们需要选择要将对象移动到两个轴中的哪一个。

无论你是从侧面还是从上面看一个物体,都可以决定你想沿着哪个轴移动它们。

偷窃

通过将你的手指放在屏幕上的虚拟物体上,并在屏幕上垂直或水平滑动,我们可以让我们的虚拟物体对滑动做出反应。在清单 12-6 中,当在一个节点上检测到滑动手势时,它会将它变成粉红色。

private void HandleSwipeGesture(UISwipeGestureRecognizer sender)
{
    var areaSwiped = sender.View as SCNView;
    var location = sender.LocationInView(areaSwiped);
    var hitTestResults = areaSwiped.HitTest(
       location, new SCNHitTestOptions());

    var hitTest = hitTestResults.FirstOrDefault();

    if (hitTest == null)
        return;

    var node = hitTest.Node;

    var material = new SCNMaterial();
    material.Diffuse.Contents = UIColor.SystemPinkColor;
    node.Geometry.FirstMaterial = material;
}

Listing 12-6Swipe UIGestureRecognizer

滑动手势类似于快速平移手势,通常用于移除或消除其他应用中的内容,所以如果你愿意,你可以对你的应用执行相同的操作。

商标出版社

通过将你的手指放在屏幕上的一个虚拟物体上并保持在那里,我们可以让我们的虚拟物体对长按手势做出响应。在清单 12-7 中,当在一个节点上检测到长按手势时,它会把它变成橙色。

private void HandleLongPressGesture(UILongPressGestureRecognizer sender)
{
    var areaPressed = sender.View as SCNView;
    var location = sender.LocationInView(areaPressed);
    var hitTestResults = areaPressed.HitTest(
       location, new SCNHitTestOptions());

    var hitTest = hitTestResults.FirstOrDefault();

    if (hitTest == null)
        return;

    var node = hitTest.Node;

    var material = new SCNMaterial();
    material.Diffuse.Contents = UIColor.Orange;
    node.Geometry.FirstMaterial = material;
}

Listing 12-7Long Press UIGestureRecognizer

你可以使用长按作为一种“特殊选择”的方式,以区别于简单的“点击”手势。

可以更改MinimumPressDuration,这是被认为是长时间按下并发射所需要的秒数,默认为 0.5。

要尝试的事情

触摸互动是我们在增强现实体验中更多的触觉互动。你可以根据自己的需要对它们进行微调。

将触摸手势识别器添加到您的应用中。

尝试将点击、旋转、平移、滑动和长按触摸手势识别器添加到您的应用中,并让它们以不同的方式操纵场景中的对象。

更改长按手势的 MinimumPressDuration。

尝试将触发长按手势所需的MinimumPressDuration从默认的 0.5 秒更改为 2 秒。

更改手势所需的最少手指数。

尝试使用NumberOfTouchesRequired属性强制两个或更多手指参与到手势中。默认情况下,它是 1。

需要双击条件才能激活点击手势。

您可以将点击手势识别器上的NumberOfTapsRequired属性更改为 2,以将点击手势识别器更改为双击识别器。你不需要太多的想象力就能知道如何实现三击手势识别器。

摘要

您现在应该知道如何以几种不同的方式与您放入 AR 体验中的任何项目进行交互,包括在 3D 空间中移动它们。诀窍是让你的交互变得直观,并以用户期望的方式表现。

到目前为止,我们一直在场景中放置简单的形状和图像;在下一章,我们将看看如何在我们的场景中放置更多有趣的物体,3D 模型。

十三、三维模型

在这一章中,我们将看看如何在你的增强现实场景中使用现有的 3D 模型,并讨论流行的免费 3D 工具“Blender”以及如何使用它来创建你自己的 3D 模型。

我们已经看到,SceneKit 允许我们使用七八种不同的原始 3D 模型,如盒子、球体、圆柱体、平面等,但它们相当有限,也不令人兴奋。通过使用现有的 3D 模型,甚至创建我们自己的模型,我们可以使我们的增强现实体验更加令人印象深刻和迷人。

导入 3D 模型

幸运的是,很容易将现有的 3D 模型导入到场景中,并且 SceneKit/ARKit 支持几种 3D 文件格式。

我们的场景中可以使用以下 3D 模型格式:

  • 。航空学博士(doctorofaeronautics 的缩写)

  • . usz

  • 。美国农业部

  • 。美元和。美国显示器联盟

  • 。rcproject 和。现实

  • 。obj 和。合并晶体管逻辑

  • 。字母表

  • 。使用

  • 。标准模板库(Standard Template Library 的缩写)

  • 。视交叉上核(Suprachiasmatic Nucleus 的缩写)

越来越多的网站和创作者专注于预制 3D 模型。我发现 free3d.com 是一个寻找免费和便宜的预制 3D 模型的好地方。

在清单 13-1 中,我们可以看到将 3D 模型导入场景是多么简单。

值得注意的是,一旦 3D 模型作为SCNNode添加到场景中,它就像任何其他的SCNNode一样,因此我们可以更改它的位置、比例、方向、材质等等。事实上,有时导入的 3D 模型对于我们的场景来说太大了,所以我们需要在它们适合我们的场景之前改变节点的比例。

当然,你可以将我们在前面章节中提到的其他效果与你的 3D 模型结合起来。例如,您可以使用动画让 3D 模型缓慢旋转,或设置其不透明度,使其略微透明或淡入场景。

从文件中检索 3D 模型时,您必须记住的一件事是,您需要通过名称从文件中检索您希望检索的特定节点,如清单 13-1 所示。幸运的是,Xcode 在确定你想要添加的节点名称时非常有用,如图 13-1 所示。

img/499298_1_En_13_Fig1_HTML.jpg

图 13-1

如果您不知道根节点的名称,Xcode 对于查找它非常有用

public override void ViewDidAppear(bool animated)
{
    base.ViewDidAppear(animated);

    this.sceneView.Session.Run(
       new ARWorldTrackingConfiguration());

    SCNScene sceneFromFile = SCNScene.FromFile(
        "art.scnassets/tree.dae");

    SCNNode model = sceneFromFile.RootNode.FindChildNode(
        childName:"SomeChildName", recursively: true);

    // How to scale or position the node model if needed
    model.Scale = new SCNVector3(0.2f, 0.2f, 0.2f);
    model.Position = new SCNVector3(0, -0.2f, 0);

    this.sceneView.Scene.RootNode.AddChildNode(model);
}

Listing 13-1Adding a 3D model to a scene

再次重申,如果您拥有 3D 模型文件,但不知道根节点的名称,那么如果您在 Xcode 中打开该文件,您应该能够在模型的各个部分周围单击,并导航场景图形以找到根节点名称。

在 Blender 中创建您自己的 3D 模型

如果你想在增强现实体验中创建自己的 3D 模型,我强烈建议你考虑学习如何使用一种叫做 Blender 的 3D 建模工具。这是我自己也在慢慢学习的东西。

首先,它是一个免费的工具,不仅功能强大,同时对于愿意花时间学习它的初学者来说也很容易使用,并且越来越受欢迎。事实上,许多电影工作室已经开始使用 Blender 来创建 3D 模型和效果,而不是使用昂贵的行业标准替代品。网上也有很多关于如何创建各种 3D 模型的教程,从甜甜圈到家具到城堡和汽车。

例如,如图 13-2 所示,使用 Blender 和一个名为“BlenderGIS”的插件,我们可以生成从谷歌地图返回的任何地形的 3D 模型,然后将其导出并在我们的 AR 体验中使用。

img/499298_1_En_13_Fig2_HTML.jpg

图 13-2

在我们的 AR 体验中使用 Blender 的 3D 模型会给人留下非常深刻的印象

你可以在图 13-2 中看到,这个例子也使用了阴影(来自第七章“照明”)来帮助用户理解它离地面有多高,让它看起来更真实。

Note

无论您是创建、导出和导入自己的 3D 模型,还是获取和使用预先制作的 3D 模型,如果该模型带有纹理(通常是一个或多个图像文件),您都需要确保将其与 3D 模型一起打包。3D 模型文件通常会相对引用图像纹理文件的位置,因此它们通常需要存储在同一文件夹中,或者至少相对于 3D 模型文件所在的位置。

添加阴影,动画,并使互动

至此,我们已经介绍了一些可以与 3D 模型结合使用的其他概念。我们可以添加照明和阴影,使三维模型看起来更真实。我们可以使用动画来使 3D 模型更加动态。

要尝试的事情

你可以整天在你的增强现实场景中摆弄 3D 模型;然而,这里有一些你可以尝试的方法。

将预先制作的 3D 模型添加到您的应用中。

获取受支持的 3D 模型文件,将其添加到项目中,并将其放置在场景中。

在 Blender 中创建一个简单的模型,并在你的应用中使用它。

在 Blender 中创建一个基本模型;不一定要复杂。然后将其导出为支持的文件类型,添加到项目中,然后在场景中使用。

使用触摸手势与场景中的 3D 模型互动。

让您的 3D 模型响应第十二章“触摸手势和交互”中讨论的触摸交互

向您的 3D 模型添加动画。

使用第五章“动画”中讨论的一些动画(动作)来制作场景中 3D 模型的比例、位置或不透明度的动画。

使用带有图像检测的 3D 模型。

尝试将 3D 模型添加到在场景中检测到的图像。您会注意到,如果您旋转检测到的图像的方向,3D 模型的方向也会发生类似的变化。

摘要

除了我们之前了解的基本 3D 形状,您现在应该知道如何在 AR 体验中添加和使用更复杂的 3D 模型。你可以获得预制的,甚至可以使用像 Blender 这样的 3D 建模工具来构建和使用你自己的。

现在我们的场景中有了各种各样的模型和形状,我们应该想办法让它们通过使用模拟物理来模拟彼此之间的交互以及它们的物理环境。如果你认为这听起来很复杂,不要担心。ARKit 有一些内置的物理能力,所以我们不必担心数学和复杂性,我们将在下一章“物理”中看到

十四、物理学

SceneKit 为我们提供的另一个惊人的东西是物理引擎,我们可以在增强现实体验中使用它。这意味着我们可以给场景中放置的物品赋予相互交互的能力,就像它们是真实物体时你所期望的那样。

我们可以通过在 SCNNodes 上设置SCNNode.PhysicsBody属性来做到这一点。

给物品一个坚硬的结构

我们可以给我们的节点一些视觉外观之外的虚拟物质,这样东西就可以和它碰撞,就像它是固体一样,如清单 14-1 所示。

我们通过调用PhysicsBody = SCNPhysicsBody.CreateKinematicBody()将节点的物理实体设置为实体。

var material = new SCNMaterial();
material.Diffuse.Contents = UIColor.DarkGray;

var geometry = SCNPlane.Create(width, length);
geometry.Materials = new[] { material };

var planeNode = new SCNNode
{
    Geometry = geometry,
    PhysicsBody = SCNPhysicsBody.CreateKinematicBody(),
    EulerAngles = new SCNVector3((float)(-Math.PI / 2), 0, 0)
};

Listing 14-1Making a 2D plane rigid

一旦我们做到了这一点,如果其他具有实体的节点试图占据相同的空间,就会与它发生冲突,更重要的是,我们可以在这个固体平面的顶部放置物品。

对物体施加重力

我们可以让物品模拟受重力影响,也就是说,被直接拉下来,直到它们碰到另一个虚拟物品而停止,这个虚拟物品已经被赋予了一个刚体,就像我们之前在图 14-1 中所做的那样。

为了让一个节点像重力一样被拉下来,我们可以将它的PhysicsBody设置为一个动态体PhysicsBody = SCNPhysicsBody.CreateDynamicBody(),如清单 14-2 所示。

如果你在你的场景和节点中使用重力,我建议你也使用一个运动体作为一种物理表面或地板;否则,你会发现你的节点会从屏幕上掉到地球的中心!事实上,当它们远离视野时,它们会继续下降(但仍会使用应用内存!).

通过在下面放置一个动力学平面,我们可以阻止这种不寻常的和不希望的行为,并模仿一些更像真实世界的体验。

var material = new SCNMaterial();
material.Diffuse.Contents = UIColor.Green;

var size = 0.05f;
var geometry = SCNBox.Create(size, size, size, 0);
geometry.Materials = new[] { material };

var cubeNode = new SCNNode
{
    Geometry = geometry,
    PhysicsBody = SCNPhysicsBody.CreateDynamicBody(),
};

Listing 14-2Making a cube effected by gravity

结合重力和固体物体

在下面的例子中(列表 14-3 ),我们在场景中放置一个 2D 平面,并赋予它一个坚固的物理属性。然后,我们在 2D 平面上方产卵,它们受到重力的影响,被拉向下方,直到它们撞击并停在坚固的 2D 平面上。

public partial class ViewController : UIViewController
    {
        private readonly ARSCNView sceneView;

        public ViewController(IntPtr handle) : base(handle)
        {
            this.sceneView = new ARSCNView();
            this.View.AddSubview(this.sceneView);
        }

        public override void ViewDidLoad()
        {
            base.ViewDidLoad();
            this.sceneView.Frame = this.View.Frame;
        }

        public override void ViewDidAppear(bool animated)
        {
            base.ViewDidAppear(animated);

            this.sceneView.Session.Run(new ARWorldTrackingConfiguration
            {
                LightEstimationEnabled = true,
                WorldAlignment = ARWorldAlignment.Gravity
            });

            var planeNode = new PlaneNode(width:0.5f, length:0.5f, UIColor.DarkGray);

            this.sceneView.Scene.RootNode.AddChildNode(planeNode);
        }

        public override void TouchesEnded(NSSet touches, UIEvent evt)
        {
            base.TouchesEnded(touches, evt);

            if (!(touches.AnyObject is UITouch touch))
                return;

            var point = touch.LocationInView(this.sceneView);
            var hits = this.sceneView.HitTest(point, new SCNHitTestOptions());
            var hit = hits.FirstOrDefault();

            if (hit == null)

                return;

            var node = hit.Node;

            if (node == null)
                return;

            var cubeNode = new CubeNode(0.05f, UIColor.Green)
            {
                Position = new SCNVector3(
                    hit.WorldCoordinates.X,
                    hit.WorldCoordinates.Y + 0.1f,
                    hit.WorldCoordinates.Z
                )
            };

            this.sceneView.Scene.RootNode.AddChildNode(cubeNode);
        }

        public override void ViewDidDisappear(bool animated)
        {
            base.ViewDidDisappear(animated);
            this.sceneView.Session.Pause();
        }

        public override void DidReceiveMemoryWarning()
        {
            base.DidReceiveMemoryWarning();
        }
    }

    public class PlaneNode : SCNNode
    {
        public PlaneNode(float width, float length, UIColor color)

        {
            Geometry = CreateGeometry(width, length, color);
            PhysicsBody = SCNPhysicsBody.CreateKinematicBody();
            EulerAngles = new SCNVector3((float)(-Math.PI / 2), 0, 0);
        }

        private static SCNGeometry CreateGeometry(float width, float length, UIColor color)
        {
            var material = new SCNMaterial();
            material.Diffuse.Contents = color;
            material.DoubleSided = true;

            var geometry = SCNPlane.Create(width, length);
            geometry.Materials = new[] { material };
            return geometry;
        }
    }

    public class CubeNode : SCNNode
    {
        public CubeNode(float size, UIColor color)
        {
            Geometry = CreateGeometry(size, color);
            Position = new SCNVector3(0, size / 2, 0);
            PhysicsBody = SCNPhysicsBody.CreateDynamicBody();
        }

        private static SCNGeometry CreateGeometry(float size, UIColor color)
        {
            var material = new SCNMaterial();
            material.Diffuse.Contents = color;

            var geometry = SCNBox.Create(size, size, size, 0);
            geometry.Materials = new[] { material };
            return geometry;
        }
    }

Listing 14-3Using gravity to drop solid cubes onto a solid 2D plane

img/499298_1_En_14_Fig1_HTML.jpg

图 14-1

将固体立方体放到固体平面上

施加力

除了将基础物理应用于我们的节点,如重力,给它们一个坚固的结构,并允许它们相互接触,我们还可以对它们施加一个力。

在清单 14-4 中,我们将一个单独的盒子放在一个平面上,当接触盒子节点时,对它施加一个大的力,推动它向前离开平面。您可以尝试应用力的大小,看看节点在被触摸时会受到怎样的影响。

public partial class ViewController : UIViewController
    {
        private readonly ARSCNView sceneView;

        public ViewController(IntPtr handle) : base(handle)
        {
            this.sceneView = new ARSCNView();
            this.View.AddSubview(this.sceneView);
        }

        public override void ViewDidLoad()
        {
            base.ViewDidLoad();
            this.sceneView.Frame = this.View.Frame;
        }

        public override void ViewDidAppear(bool animated)
        {
            base.ViewDidAppear(animated);

            this.sceneView.Session.Run(new ARWorldTrackingConfiguration
            {
                LightEstimationEnabled = true,
                WorldAlignment = ARWorldAlignment.Gravity,
            });

            var planeNode = new PlaneNode(width: 0.3f, length: 0.3f, UIColor.LightGray);
            this.sceneView.Scene.RootNode.AddChildNode(planeNode);

            SCNNode boxNode = new SCNNode();

            var boxMaterial = new SCNMaterial();
            boxMaterial.Diffuse.Contents = UIColor.Blue;

            var boxGeometry = SCNBox.Create(0.04f, 0.06f, 0.04f, 0f);
            boxNode.Geometry = boxGeometry;
            boxNode.Geometry.FirstMaterial = boxMaterial;
            boxNode.PhysicsBody = SCNPhysicsBody.CreateDynamicBody();
            boxNode.Position = new SCNVector3(0.0f, 0.05f, 0.0f);

            this.sceneView.Scene.RootNode.AddChildNode(boxNode);
        }

        public override void TouchesEnded(NSSet touches, UIEvent evt)

        {
            base.TouchesEnded(touches, evt);

            if (!(touches.AnyObject is UITouch touch))
                return;

            var point = touch.LocationInView(this.sceneView);
            var hits = this.sceneView.HitTest(point, new SCNHitTestOptions());
            var hit = hits.FirstOrDefault();

            if (hit == null)
                return;

            var node = hit.Node;

            if (node == null)
                return;

            var forcePower = 10;
            var pointOfView = this.sceneView.PointOfView;
            var transform = pointOfView.Transform;
            var orientation = new SCNVector3(-transform.M31, -transform.M32, -transform.M33);

            node.PhysicsBody.ApplyForce(
                new SCNVector3(
                    orientation.X * forcePower,
                    orientation.Y * forcePower,
                    orientation.Z * forcePower), true);
        }

        public override void ViewDidDisappear(bool animated)
        {
            base.ViewDidDisappear(animated);
            this.sceneView.Session.Pause();
        }

        public override void DidReceiveMemoryWarning()
        {
            base.DidReceiveMemoryWarning();
        }
    }

    public class PlaneNode : SCNNode
    {
        public PlaneNode(float width, float length, UIColor color)
        {
            Geometry = CreateGeometry(width, length, color);
            PhysicsBody = SCNPhysicsBody.CreateKinematicBody();
            EulerAngles = new SCNVector3((float)(-Math.PI / 2), 0, 0);
        }

        private static SCNGeometry CreateGeometry(float width, float length, UIColor color)
        {
            var material = new SCNMaterial();
            material.Diffuse.Contents = color;
            material.DoubleSided = true;

            var geometry = SCNPlane.Create(width, length);
            geometry.Materials = new[] { material };
            return geometry;
        }
    }

    public class CubeNode : SCNNode
    {
        public CubeNode(float size, UIColor color)
        {
            Geometry = CreateGeometry(size, color);
            Position = new SCNVector3(0, size / 2, 0);
            PhysicsBody = SCNPhysicsBody.CreateDynamicBody();
        }

        private static SCNGeometry CreateGeometry(float size, UIColor color)
        {
            var material = new SCNMaterial();
            material.Diffuse.Contents = color;

            var geometry = SCNBox.Create(size, size, size, 0);
            geometry.Materials = new[] { material };
            return geometry;
        }
    }

Listing 14-4Apply force to an object in Augmented Reality

在 SceneKit 中有很多与物理相关的变量可以改变,包括质量和摩擦力。通过改变这些值,您将改变场景中的项目受物理影响的方式。

Note

同样,我们可以对一个物体施加力,我们也可以对一个物体施加力矩,也就是说,使一个物体绕轴旋转。你可以通过调用SCNPhysicsBody.ApplyTorque()来实现。

要尝试的事情

这里有一些不同的东西,你可以在 ARKit 中尝试和学习物理。

尝试改变摩擦力、质量和其他物理属性 .

尝试改变场景中对象的一些属性,包括它们的摩擦力和质量,看看这会如何影响它们在场景中的行为。

使用 ApplyForce()来发射物体。

在不同的方向和其他物体上玩射击游戏。看看能不能打翻其他物体。

使用不同形状的物体。

不要只使用立方体,例如,看看球体如何从斜面滚下。

使用 ApplyTorque()对物体施加扭矩。

查看对具有不同物理属性的不同形状的物体施加扭矩时它们的行为。

摘要

SceneKit 为我们提供了一个非常复杂的物理引擎。让场景中的项目像真实物体一样响应交互,可以为您的 AR 体验增加另一个层次的真实感。可以看到一些游戏是如何很好的利用 ARKit 内置的物理引擎的。

在前面的章节中,我们已经看到了如何使用 ARKit 进行图像检测、人脸检测和平面检测。在下一章,我们将看看如何识别场景中的 3D 物体。听起来不可能?好吧,让我们用物体检测来找出答案。

十五、目标检测

之前,在第十章“图像检测”中,我们讨论了如何让我们的 AR 移动应用在我们的场景中检测到预定义的 2D 图像时识别并做出响应。同样,我们可以让我们的应用响应预定义的 3D 对象。这是一个比 2D 图像识别更复杂的过程;然而,ARKit 使这成为可能。我们需要做的就是将功能整合在一起。

这个过程需要两个部分,第一部分是让用户能够使用应用扫描 3D 对象并存储其一些“空间数据”,第二部分是再次使用空间数据来检测场景中的对象。

虽然本章给出了这个概念的概述,但是演示这个概念所需的代码太长了,无法完整地包括进来。幸运的是,微软已经创建了一个开源的 Xamarin 身体检测样本应用,我们可以下载并试用。

本章中讨论和显示的示例应用和屏幕截图来自以下 Microsoft Xamarin.iOS 扫描应用示例:

https://docs.microsoft.com/en-us/samples/xamarin/ios-samples/ios12-scanninganddetecting3dobjects/

扫描和保存对象空间数据

在扫描期间,会话使用了一个ARObjectScanningConfiguration配置实例,如清单 15-1 所示。

var configuration = new ARObjectScanningConfiguration();
sceneView.Session.Run(configuration);

Listing 15-1Using ARObjectScanningConfiguration

运行示例应用时,您会看到在扫描阶段,一个边界框被用来表示我们希望扫描的 3D 对象应位于的区域,如图 15-1 所示。默认情况下,它会检测一个水平面,并将边界框的底部放在它的顶部。使用捏合和平移触摸手势可以增加边界框的大小和位置。

img/499298_1_En_15_Fig1_HTML.jpg

图 15-1

将边界框放置在要扫描的对象周围

如果您对 3D 对象位于边界框内感到满意,请按“扫描”按钮存储空间数据以备后用。在扫描过程中,该应用要求你在物体周围移动,以便从不同角度进行扫描和后续识别。这个从不同角度扫描的过程使得包围盒的壁变得坚固,如图 15-2 所示。当您对从足够多的不同角度扫描了对象感到满意时,请按“完成”。

img/499298_1_En_15_Fig2_HTML.jpg

图 15-2

从多个方向扫描物体

扫描完成后,扫描的对象将作为一个ARReferenceObject保存在应用中,供以后参考。

识别扫描的对象

为了识别场景中的 3D 对象,我们需要检索(或至少引用)之前扫描和保存的 3D 对象的空间数据,并使用它来允许应用检测任何与之匹配的对象。

当你准备好了,按下应用中的“测试”按钮,这将开始检测你在场景中扫描的 3D 对象。

如果在场景中检测到物体(使用清单 15-2 中的代码),应用会通知你并告诉你检测它花了多长时间(在我看来相当快),如图 15-3 所示。

public override void DidAddNode(ISCNSceneRenderer renderer, SCNNode node, ARAnchor anchor)
{
    if (anchor != null && anchor is ARObjectAnchor)
    {
        var objectAnchor = anchor as ARObjectAnchor;
        if (objectAnchor.ReferenceObject == referenceObject)
        {
            // Successful detection, do something
        }
    }
}

Listing 15-2The code that fires when the object is detected

一旦成功检测到对象,我们可以做任何事情,我们可以显示如图 15-3 所示的消息,或者我们可以在检测到的对象上或旁边添加额外的节点。

img/499298_1_En_15_Fig3_HTML.png

图 15-3

成功检测到对象

要尝试的事情

这里有一些使用对象检测的想法。

扫描并存储多个对象。

看看你能否扫描和存储多个不同的对象。

扫描产品,并在成功检测后检索/显示产品信息。

扫描并保存产品(如毛绒玩具)的 3D 特征;然后,当检测到它时,在它旁边显示附加信息,如产品详细信息、描述、价格等。

扫描某人的头部,看看识别的准确度如何。

尝试扫描某人的头部,看看物体检测是否可以识别它。

看看你能扫描和探测多大/多小的物体。

尝试扫描非常小或非常大的对象,看看对象检测在处理非常小或非常大的对象时是否有约束。

改变边框的颜色。

尝试更改用于扫描和检测的边界框的颜色或其他方面。

摘要

ARKit 中内置的对象检测功能继续显示 ARKit 是多么多样和强大,并为我们之前看到的 2D 图像检测增加了另一个维度,打开了一系列有趣的用例。

继续在我们的场景中检测有趣的主题,在下一章,我们将看看身体检测,我们将看到 ARKit 如何确定一个人在场景中的位置和方向。

十六、身体跟踪

谈到人,以及我们在第十一章“面部跟踪和表情检测”中看到的检测和跟踪面部,我们还可以使用 ARKit 实时检测场景中的身体,包括身体不同部位的方向。这被称为身体跟踪,它使我们能够不跟踪主要身体关节的位置,达到很高的精确度。

检测场景中的身体

我们将看看 ARKit 如何能够检测场景中人体及其各种关节的存在,然后将它们覆盖在 3D 空间中检测到的身体上。但是你问我们到底在追踪什么?

跟踪以下关节的位置:

  • 根部(臀部中心)

  • 左手

  • 右手

  • 勒弗福德

  • 右脚

  • 左肩膀

  • 右肩

这些值来自枚举ARSkeletonJointName

在被检测的ARSkeleton3D对象中可以引用许多其他关节(图 16-1 共显示 92 个)。但是,只跟踪前面的关节,因此其他关节是根据这些跟踪关节的位置推断出来的。

事实上,为了获得我们将迭代的所有 92 个联合名称的完整列表,我们将使用从调用ARSkeletonDefinition.DefaultBody3DSkeletonDefinition.JointNames返回的string[]

img/499298_1_En_16_Fig1_HTML.jpg

图 16-1

组成一个骨骼的所有 92 个关节的名称

为了在我们的场景中启用身体跟踪,我们在运行 ARSession 时使用了一个ARBodyTrackingConfiguration,如清单 16-1 所示。

Note

除了这些关节,如果我们愿意,我们还可以推断这些关节之间的路径,并绘制直线,从而创建一个骨骼的可视化。

public BodyDetectionViewController()
{
    this.sceneView = new ARSCNView
    {
        AutoenablesDefaultLighting = true,
        Delegate = new SceneViewDelegate()
    };

    this.View.AddSubview(this.sceneView);
}

...

public override void ViewDidAppear(bool animated)
{
    base.ViewDidAppear(animated);

    var bodyTrackingConfiguration
        = new ARBodyTrackingConfiguration()
    {
        WorldAlignment = ARWorldAlignment.Gravity

    };

    this.sceneView.Session.Run(bodyTrackingConfiguration);
}

Listing 16-1Using ARBodyTrackingConfiguration and declaring the SceneViewDelegate

当在场景中检测到尸体时,会在相关位置放置一个ARBodyAnchor。我们可以将我们的自定义代码添加到ARSCNViewDelegate上的DidAddNodeDidUpdateNode方法中,如清单 16-2 所示。

正如你在清单 16-2 中看到的,我们已经声明了一个从SCNNode继承而来的JointNode类来表示我们想要放置在场景中的关节节点。当我们在DidAddNode中检测到这些关节节点时,我们使用关节名称作为关键字将它们存储在字典中。当DidUpdateNode被解雇时,如果我们检测到他们的位置已经改变,我们就通过调用.Update(SCNVector3 position)来更新他们的位置。

我们有一个创建球体来表示关节的方法,叫做MakeJoint(string jointName),这个方法非常简单,类似于我们之前看到的创建基本颜色形状的例子。

更复杂的方法GetJointPosition(ARBodyAnchor bodyAnchor, string jointName)是获取检测到的ARBodyAnchor并计算,然后返回 jointName 引用的关节的位置。它通过确定从身体锚点的根位置(始终是臀部的中心)偏移的请求关节来实现这一点。我们还使用了一个扩展方法,将一个NMatrix4转换成一个SCNMatrix4

最终结果显示场景中有 92 个球体排列在与检测到的身体相同的方向上。这些球体的方向和位置随着被跟踪物体的方向和位置的实时变化而变化。

public class SceneViewDelegate : ARSCNViewDelegate
{
    Dictionary<string, JointNode> joints
       = new Dictionary<string, JointNode>();

        public override void DidAddNode(
           ISCNSceneRenderer renderer, SCNNode node,
           ARAnchor anchor)
        {
            if (!(anchor is ARBodyAnchor bodyAnchor))
                return;

            foreach (var jointName in ARSkeletonDefinition.DefaultBody3DSkeletonDefinition.JointNames)
            {
                JointNode jointNode = MakeJoint(jointName);

                var jointPosition = GetJointPosition(bodyAnchor, jointName);
                jointNode.Position = jointPosition;

                if (!joints.ContainsKey(jointName))
                {
                    node.AddChildNode(jointNode);
                    joints.Add(jointName, jointNode);
                }
            }
        }

        public override void DidUpdateNode(
           ISCNSceneRenderer renderer, SCNNode node,
              ARAnchor anchor)
        {
            if (!(anchor is ARBodyAnchor bodyAnchor))
                return;

            foreach (var jointName in ARSkeletonDefinition.DefaultBody3DSkeletonDefinition.JointNames)
            {
                var jointPosition = GetJointPosition(bodyAnchor, jointName);

                if (joints.ContainsKey(jointName))
                {
                    joints[jointName].Update(jointPosition);
                }
            }
        }

        private SCNVector3 GetJointPosition(
           ARBodyAnchor bodyAnchor, string jointName)
        {
            NMatrix4 jointTransform = bodyAnchor.Skeleton.GetModelTransform((NSString)jointName);
            return new SCNVector3(jointTransform.Column3);
        }

        private JointNode MakeJoint(string jointName)
        {
            var jointNode = new JointNode();

            var material = new SCNMaterial();
            material.Diffuse.Contents =
               GetJointColour(jointName);

            var jointGeometry =
               SCNSphere.Create(GetJointRadius(jointName));
            jointGeometry.FirstMaterial = material;
            jointNode.Geometry = jointGeometry;

            return jointNode;
        }

        private UIColor GetJointColour(string jointName)
        {
            switch (jointName)
            {
                case "root":
                case "left_foot_joint":
                case "right_foot_joint":
                case "left_leg_joint":
                case "right_leg_joint":
                case "left_hand_joint":
                case "right_hand_joint":
                case "left_arm_joint":
                case "right_arm_joint":
                case "left_forearm_joint":
                case "right_forearm_joint":
                case "head_joint":
                    return UIColor.Green;
            }

            return UIColor.White;
        }

        private float GetJointRadius(string jointName)
        {
            switch (jointName)
            {
                case "root":
                case "left_foot_joint":
                case "right_foot_joint":
                case "left_leg_joint":
                case "right_leg_joint":
                case "left_hand_joint":
                case "right_hand_joint":
                case "left_arm_joint":
                case "right_arm_joint":
                case "left_forearm_joint":
                case "right_forearm_joint":
                case "head_joint":
                    return 0.04f;
            }

            if (jointName.Contains("hand"))
                return 0.01f;

            return 0.02f;
        }
    }

public class JointNode : SCNNode
    {
        public void Update(SCNVector3 position)
        {
            this.Position = position;
        }
    }
}

Listing 16-2Detecting and updating body joint positions

结果如图 16-2 所示。被跟踪身体的主要关节被跟踪并显示为绿色球体,其他推断的次要关节显示为白色节点。

像往常一样,ARKit 在现实世界中跟踪事物的准确性取决于充足的照明。为了让 ARKit 有最好的机会跟踪场景中的尸体,请确保环境光线充足。

img/499298_1_En_16_Fig2_HTML.jpg

图 16-2

使用节点显示被跟踪实体的方向

捕捉身体动作

身体跟踪的一个用途是转换被跟踪身体的检测到的运动和位置,并在人形 3D 模型(称为装备)上模拟它们,以便如果您移动手臂,3D 模型的手臂也以相同的方式移动。这需要创建一个具有各种活动关节的 3D 模型,并将其导入到应用中,这超出了本书的范围,但可以在图 16-3 中看到。

要了解有关使用身体跟踪的模型装配的更多信息,请参见 Apple 的文档( https://developer.apple.com/documentation/arkit/rigging_a_model_for_motion_capture )。

img/499298_1_En_16_Fig3_HTML.jpg

图 16-3

身体跟踪示例的索具

潜在应用

因为我们可以检测主要关节的位置以及它们彼此之间的相对位置,所以我们可以推断场景中身体各个部分的角度。我见过这种技术用于自动检测用户坐在办公桌前时是否无精打采,以帮助防止脊椎受到不必要的压力,并帮助避免背痛。

能够检测重复的身体运动使身体检测成为跟踪俯卧撑和深蹲等运动的一种很好的方式。

要尝试的事情

这里有一些你可以在实施身体追踪时自己尝试的事情。

改变代表关节的节点的颜色、大小和不透明度。

尝试用不同的方式表示关节节点。

添加触摸手势,帮助识别按压时的关节。

使用你的触摸手势知识,这样当你触摸一个节点时,它会在屏幕上显示它的名称。

尝试装配一个 3D 模型来复制你的动作。

查找如何使用适当的 3D 骨架模型,并将其装配到被跟踪的身体上,使其模拟场景中身体的运动。

向被跟踪的主体添加额外的节点。

使用现成的几何形状、图像或 3D 模型的组合来将附加节点添加到被跟踪的身体。例如,你可以在头部节点的位置放置一个球形的表情头像。

在关节间添加直线,打造骨架效果。

当您知道主要关节和次要关节的位置和名称时,您可以尝试在它们之间创建线条(或细长的盒子/圆柱体)。

摘要

如果你已经做到了这一步,那么你现在可能已经知道如何利用 arKit 的大量增强现实功能,并能够做出一些相当出色的 AR 体验。

一旦你制作了你的杀手级 AR 应用,你可能希望通过应用商店与世界分享它,所以在下一章也是最后一章,我们将看看“发布到应用商店”

十七、发布到应用商店

正如一开始所承诺的,我们在本书中看到的一切都可以在没有苹果开发者帐户的情况下进行实验,并放入你的应用和部署到你的手机上。

也就是说,如果你创造的东西准备好与世界其他地方分享,你会想把它放在 App Store 里让其他人下载和安装。要做到这一点,你需要一个苹果开发者账户,你需要按照本章概述的步骤操作。

App Store 提交待办事项列表

在这一章中,我们将介绍将您的应用放入 App Store 的过程。该过程由多个阶段组成:

  • 为应用设置图标。

  • 设置启动屏幕(可选)。

  • 设置应用 ID 和授权。

  • 创建并安装 App Store 预置描述文件。

  • 更新内部版本配置。

  • 构建您的应用并提交给 Apple。

为应用设置图标

因为您的应用的图标将在各种不同的地方使用,所以您需要提供几种不同大小的图标。

您的图标将以不同尺寸出现在以下位置:

  • 应用商店

  • 通知

  • 设置

  • 聚光灯

要提供不同大小的图标,打开Assets.xcassets并为 IconImage 资源提供图像。见图 17-1 。

img/499298_1_En_17_Fig1_HTML.jpg

图 17-1

为 Assets.xcassets 文件夹中的 AppIcon 资源提供图像

设置启动屏幕图像

应用的启动屏幕是在启动应用后立即看到的屏幕,但在您最初看到应用的主页之前,默认情况下它是一个空白的白色屏幕。幸运的是,如果你选择这样做,改变是非常容易的。这是我推荐的,因为它相对简单,可以帮助用户体验你的应用。

如上所述,你可以选择覆盖应用的默认空白启动屏幕(LaunchScreen.storyboard)。一旦你打开LaunchScreen.storyboard,你就可以改变它的背景颜色,给它添加标签和图像,如图 17-2 所示。如果您选择更改默认设置,当您的应用启动时,更新后的启动屏幕将显示在您的主应用之前。

img/499298_1_En_17_Fig2_HTML.jpg

图 17-2

您可以自定义应用的启动屏幕

设置应用 ID 和授权

在进一步操作之前,您需要为您的应用创建一个应用 ID。你可以在苹果开发者门户网站 https://developer.apple.com 上这样做,为了能够做到这一点,你需要一个苹果开发者账户,在撰写本文时这个账户的价格是 79 英镑。

此外,如果您还没有 Apple ID,您需要先在 https://appleid.apple.com/account 创建一个。

当你登录你的苹果开发者账户时,你应该会看到如图 17-3 所示的页面。

img/499298_1_En_17_Fig3_HTML.jpg

图 17-3

您的 Apple 开发者帐户

好的,假设你现在已经有了你的苹果开发者账户并且已经登录,进入如图 17-4 所示的证书、id 和档案。

img/499298_1_En_17_Fig4_HTML.jpg

图 17-4

开发人员帐户的标识符部分

我们将以应用 ID 的形式为我们的应用创建一个新的标识符,因此单击标识符标题旁边的+按钮开始为我们的应用创建一个新的标识符。

如图 17-5 所示,从标识符列表中选择应用 id,然后按继续。

img/499298_1_En_17_Fig5_HTML.jpg

图 17-5

开始注册新的标识符

在下一个屏幕上,选择的应用 ID,在我们的例子中是一个应用,因此从如图 17-6 所示的选项中选择应用,然后按继续。

img/499298_1_En_17_Fig6_HTML.jpg

图 17-6

选择我们使用应用 ID 的目的

在下一个屏幕中,提供描述捆绑包 ID ,然后从列表中选择您的应用使用的任何设备功能,如图 17-7 所示,然后按继续。

img/499298_1_En_17_Fig7_HTML.jpg

图 17-7

提供您的应用 ID 信息

在下一个屏幕上,您有机会在注册前确认应用 ID 详情,如图 17-8 所示。准备就绪后,按继续,然后按注册。

img/499298_1_En_17_Fig8_HTML.jpg

图 17-8

注册前请确认您的应用 ID 详细信息

恭喜你!您已经创建了您的第一个应用!好吧,反正是 App ID。别担心。我们很快就会好好利用它。

创建并安装 App Store 预置描述文件

为了将您的应用发布到 App Store,您需要在电脑上创建、安装和使用适当的分发预置描述文件。这些预置描述文件包含用于签署您的应用的证书、应用 ID 及其安装位置的相关信息。

要为您的应用创建和安装预置描述文件,请再次前往 Apple Developer Portal 中的证书、id&描述文件部分。

这一次,转到配置文件部分。从这里,您将看到任何现有的开发或发布概要文件,并且可以创建新的。

在 Profiles 部分,点击 Profiles 标题旁边的+按钮,如图 17-9 所示。

img/499298_1_En_17_Fig9_HTML.jpg

图 17-9

开发和分布概况

然后在注册一个新的预置描述文件页面,在分发部分,选择如图 17-10 所示的 App Store,点击继续。

img/499298_1_En_17_Fig10_HTML.jpg

图 17-10

注册新的分发预置描述文件

在下一个屏幕上,从下拉列表中选择您的应用 ID,如图 17-11 所示,然后按继续。

img/499298_1_En_17_Fig11_HTML.jpg

图 17-11

选择预置描述文件适用的应用

如图 17-12 所示,从下一个屏幕中选择证书,然后按继续。

img/499298_1_En_17_Fig12_HTML.jpg

图 17-12

选择证书

如图 17-13 所示,在下一个屏幕上为配置文件提供一个名称,然后按生成。

img/499298_1_En_17_Fig13_HTML.jpg

图 17-13

提供预配概要文件的名称

最后,如图 17-14 所示,下载并双击您生成的预置描述文件,将其安装到您的电脑上。

img/499298_1_En_17_Fig14_HTML.jpg

图 17-14

下载并安装预置描述文件

唷,现在您已经成功地将一个分发预置描述文件安装到您的计算机上,该描述文件可用于将您的应用放入 App Store。

现在,让我们开始构建我们希望在下一部分中上传的应用版本。

更新内部版本发布配置

在我们构建提交到 App Store 的应用之前,我们还需要做一些事情,包括分配我们在上一节中创建的预置描述文件。

打开Info.plist文件并转到应用选项卡。它可能看起来有点像这样。如图 17-15 所示,确定手动提供作为签约方案

img/499298_1_En_17_Fig15_HTML.jpg

图 17-15

确保签名使用手动设置

接下来,打开你的项目选项,进入构建IOS 构建。在此页面上,将配置更改为发布并将平台更改为电话,并确保所有其他设置如下图 17-16 所示。

img/499298_1_En_17_Fig16_HTML.jpg

图 17-16

设置 iOS 构建设置

接下来进入 iOS 捆绑签名部分,如图 17-17 所示。

img/499298_1_En_17_Fig17_HTML.jpg

图 17-17

设置 iOS 捆绑包签名设置

  • 配置设置为发布,将平台设置为 iPhone

  • 签约身份应该是分发(自动)

  • 配置文件应该是您在上一步中创建的文件。

    注意您将只能在Info.plist文件中看到捆绑包 ID 与应用捆绑包 ID 相匹配的预置描述文件。

您的项目现在应该可以构建和发布了。但首先,我们需要准备好应用商店方面的东西,以接收应用的上传。

在 App Store Connect 中设置应用

您必须先在 App Store Connect 中配置应用,然后才能将应用提交给 Apple 进行审查。App Store Connect 是一个在线门户,用于管理您在 App Store 中的 iOS 应用,可在 https://appstoreconnect.apple.com/ 找到。

我们需要在 App Store Connect 中做很多事情,包括

  • 提供将出现在商店中的应用名称

  • 选择捆绑 ID

  • 提供描述、关键字、类别

  • 提供截图

  • 申报价格和供货情况

App Store Connect 的主屏幕如下图 17-18 所示。

img/499298_1_En_17_Fig18_HTML.jpg

图 17-18

应用商店连接

进入我的应用,点击应用标题旁边的蓝色圆圈+按钮创建一个新的应用,并提供你的应用的详细信息,如图 17-19 所示。

img/499298_1_En_17_Fig19_HTML.jpg

图 17-19

从应用部分创建新应用

一旦您在 App Store Connect 中创建了一个应用,您应该会看到如图 17-20 所示的屏幕,您可以在其中提供更多详细信息。

img/499298_1_En_17_Fig20_HTML.jpg

图 17-20

您未发布的应用草稿

在图 17-21 所示的定价和可用性部分,您可以设置您希望对您的应用收取多少费用。

img/499298_1_En_17_Fig21_HTML.jpg

图 17-21

提供定价信息

一般信息部分,你应该提供你的应用的主要类别次要类别以及副标题,以帮助人们搜索像你这样的应用,并给他们最好的机会偶然发现你的应用。参见图 17-22 。

您还需要为应用设置内容权限,确认您对应用中的任何内容拥有权限。

img/499298_1_En_17_Fig22_HTML.jpg

图 17-22

提供一般应用信息

但是不要按提交以供审查,因为您将需要创建和上传一个构建并将其与您的初始发布相关联。为此,我们需要回到 Visual Studio for Mac,我们将在下一节看到。

构建应用并提交给苹果

现在您已经在 App Store Connect 中设置了您的应用,您需要最终构建并提交您的应用。

在 Visual Studio for Mac 中选择发布版本配置,如图 17-23 所示。

img/499298_1_En_17_Fig23_HTML.jpg

图 17-23

发布生成配置的设置

然后从构建菜单中选择归档发布,如图 17-24 所示。这会将您的应用打包到一个归档文件中,以备上传。

img/499298_1_En_17_Fig24_HTML.jpg

图 17-24

存档您的应用以进行发布

一旦创建了档案,点击图 17-25 所示的签署并分发按钮。

img/499298_1_En_17_Fig25_HTML.jpg

图 17-25

归档创建后

在选择 iOS 分发渠道屏幕上,选择 App Store 并按下一步,如图 17-26 所示。

img/499298_1_En_17_Fig26_HTML.jpg

图 17-26

选择分销渠道

在下一个界面中,当选择目的地时,选择上传,然后下一步,如图 17-27 所示。

img/499298_1_En_17_Fig27_HTML.jpg

图 17-27

选择目的地

在如图 17-28 所示的下一个预置描述文件屏幕中,选择所需的预置描述文件(如果您有多个预置描述文件),然后按下一步。

img/499298_1_En_17_Fig28_HTML.jpg

图 17-28

选择相关的预配配置文件

在下一个屏幕上,您将被要求提供一些凭证以启用与 App Store Connect 的通信,如图 17-29 所示。

img/499298_1_En_17_Fig29_HTML.jpg

图 17-29

提供 App Store 的通信细节

现在你可能想知道这个应用特定的密码到底是什么。我当然有。

原来你必须在 https://appleid.apple.com 创建一个专用的 app 密码,如图 17-30 所示。

img/499298_1_En_17_Fig30_HTML.jpg

图 17-30

提供 App Store 的通信细节

生成特定于应用的密码后,输入您的 Apple ID 用户名和密码,然后按下一步。

之后,正如你可以从图 17-31 所示的下一个屏幕中猜到的,你终于准备好发布应用了。按下出版

img/499298_1_En_17_Fig31_HTML.jpg

图 17-31

准备发布您的应用

点击发布后,系统会要求您选择保存 ipa 文件的位置,之后,您的应用将被上传到 App Store Connect,如果成功,系统会通知您发布成功,如图 17-32 所示。

img/499298_1_En_17_Fig32_HTML.jpg

图 17-32

成功将您的应用发布到应用商店

您会注意到您的应用的状态将变为“等待审查”你现在只需要等待苹果公司的审查团队对你的应用进行自动和手动检查。如果苹果有任何方式拒绝你的应用,如侵犯版权或不明确的许可请求,你的应用将被拒绝,你会得到反馈。如果发生这种情况,您将能够对您的应用进行相关更改,并重新提交以获得批准。

一旦苹果公司成功批准你的应用,它将很快出现在应用商店。

摘要

嗯,就是这样。现在,您已经拥有了所需的一切,不仅可以开发一些令人印象深刻且有用的增强现实体验,还可以与世界分享它们。接下来你选择做什么取决于你自己。

增强现实在未来几年将变得越来越受欢迎,arKit 允许我们利用开箱即用的丰富功能来提供令人惊叹的 AR 体验,这一点现在应该很明显。

你所能创造的经历只受到你想象力的约束。

祝你好运,玩得开心。

posted @ 2024-08-10 19:04  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报