大多数浏览器和
Developer App 均支持流媒体播放。
-
探索 RealityKit 调试器
了解 RealityKit 调试器,并探索这款新工具如何帮助你检查空间 App 的实体层次结构、调试异常转换、查找缺失的媒体,以及检测代码的哪些部分导致系统出现了问题。
章节
- 0:00 - Introduction
- 0:23 - Agenda
- 0:53 - Prepare for the Journey
- 1:41 - Meet the RealityKit debugger
- 2:40 - Transform the BOTanist
- 4:02 - Traverse hierarchy issues
- 7:20 - Address bad behaviors
- 10:52 - Find what's missing
- 18:44 - Embrace uniqueness
- 22:34 - Wrap up
资源
相关视频
WWDC24
-
下载
嗨 我叫 Jeremiah 我制作的开发者工具可以帮助你们 制作出色的空间 App 和游戏 今天 我想谈谈你的 RealityKit App 中可能会出现的 一些常见错误 在这个过程中 我将向你介绍 RealityKit 调试器 一款可帮助你捕捉错误的新工具 首先我们将快速了解一下 RealityKit 调试器 然后使用它来检查 App 并追踪一些错误 我们将遍历实体层次结构 寻找意想不到的转换 通过公开组件中的错误 纠正系统中的不良行为 当我们克服一些渲染问题时 我们将发现丢失的内容 最后我将分享一些技巧和窍门 帮助你根据 App 的独特性 来调整 RealityKit 调试器 准备好了吗 让我们开始吧 利用 RealityKit 你可以 打造令人惊叹的 3D App 并将它们部署到 iOS、 macOS 和 visionOS 上 比如你可能已经 见过的 BOT-anist 示例 里面有一些照顾植物的可爱机器人 但整天都忙忙碌碌 背着那些硕大的背包 可是件苦差事 有时候 即使是机器人 也需要一个休闲放松的地方 在这个空间里与朋友相聚 享用高品质的油 与其他机器人共舞 尽情释放压力 在本讲座中 我们将在 BOT-anist 示例的 基础上增加休闲模式 我一直在制作一个原型 把花园变成一个俱乐部 但我还没有准备好开始营业 因为仍然有一堆错误在横行 让我们使用 RealityKit 调试器来跟踪它们
RealityKit 调试器会给 正在运行的 App 拍摄 3D 快照 并将它载入到 Xcode 中供你探索 在屏幕底部的调试区域 点按“Capture Entity Hierarchy”按钮开始拍摄
快照拍摄完后 捕获的 RealityKit 场景 会在左侧的调试导航器中列出
选择一个场景 就会在相邻的大纲视图中 显示它的实体层次结构
它还会重建 3D 视口中的内容
在层次结构或视口中选择一个实体 就会在右侧的检查器中 显示实体的属性 以及实体组件的属性
还有一个检查器可显示当前 选定层次结构的统计数据 RealityKit 调试器 适合你现有的 Xcode 工作流程 并能提供新的见解 使你的 3D 开发体验 更高效、更愉快 现在 有了我们的新工具 让我们去修复这个俱乐部吧
用于转换示例的代码补丁比较长 因此 如果你想跟着一起操作 请下载 ClubView Swift 文件 将它拖放到 Xcode 项目中 然后包含在目标中 接下来我们需要做两个小改动 首先我们为俱乐部定义 一个新的立体场景 并将这个场景添加到 BOT-anist App 的主体
其次 我们需要一个按钮 来打开俱乐部 我将它添加到了 RobotView 的 主体中 紧邻现有的 “Start Planting”按钮 现在我们可以在 visionOS 模拟器 中构建并运行 App
App 启动后将打开机器人视图 我们可以点按迪斯科灯球 进入俱乐部 而不是创建机器人
我们可以使用相机控件将镜头拉近
我修改了场景中的许多现有素材 比如将花盆变成传送器 我还从头开始生成了一些新实体 比如迪斯科灯球 这个灯球目前看起来有点变形 我们来检查一下场景并找出原因
在 Xcode 中 使用调试区域的 这个按钮启动 RealityKit 调试器
在深入探究之前 让我们花点时间考虑一下 “转换”层次结构的工作原理 当你在 3D 场景中放置内容时 需要设置位置、方向和比例 你可能会遇到的一个常见错误是 内容没有出现在你设置的位置 发生这种情况通常是因为 实体的最终位置实际上是 它自身的转换以及 它所有上级转换共同作用的结果 这通常会导致某一实体展现出 你原本只想应用于单一实体的转换 我怀疑我们的迪斯科灯球就是这样 让我们使用 RealityKit 调试器来确认一下
在调试导航器中展开场景包装器 然后选择 RealityKit 内容 这将在调试器中打开这个场景
这时我们可以隐藏导航器 和调试区域 以便有更多空间进行操作
在视口中 连按两下迪斯科灯球 将它选中并置于中心位置
主视口显示的是实体 在场景中的样子 并应用了它上级的转换 在视口中选择一个实体 也会在实体层次结构 和实体检查器中选择这个实体 我们当前选择的实体 名为“Outline” 这个实体显示了 迪斯科灯球上的线条 在实体检查器中 有一个较小的辅助视口 通过这个视口可预览 实体的 ModelComponent 它没有应用任何转换 在预览中 “Outline”实体 没有失真 因此问题不是由它的网格造成的 此外 在预览窗口下方 可以看到这个实体的转换组件 它的统一缩放值为 1 因此实体本身并没有失真 这个问题很可能是 从它的上级那里继承下来的 让我们遍历这个层次结构 以找出异常转换 在实体层次结构中点按父级 也就是“Background”实体 在检查器中 预览视口 和 Transform 组件都显示 这个实体也没有产生失真 在层次结构中 我们来点按它的父级 也就是“Support”实体
在检查器中 我们会发现“Support”实体的 Transform 组件的 Y 轴上 有较大的缩放比例值 事实上 我们缩放这个实体 以实现所需的形状并没有错 这里的错误在于我们无意中 将这个缩放应用到了 它的所有子代上
让“Support”和”Background“ 成为同级 而不是父级和子级 便可解决问题
它们在场景中看起来仍然是相连的 但“support”的转换 将不再影响灯球 让我们重新运行 App 进入俱乐部 并查看效果
使用 RealityKit 调试器 遍历场景层次结构 我们能够找到导致实体变形的 异常转换 然后通过更改它的父级 来修复这个问题 现在我们可以尽情地玩了 转换后的俱乐部开始有模有样了 现在是时候让这些对象中的 某些对象栩栩如生了 RealityKit 采用实体 组件系统 (ECS) 方法 来管理对象和行为 我们通过为实体分配各种 可保存数据的组件 来描述实体的特征 然后 我们构建系统 以对拥有特定组件 的实体执行更新 如果一个实体的组件 配置错误或缺失 那么系统的行为将不可预测 让我们回到俱乐部 给大家看一个示例
我把俱乐部里所有的 花盆都变成了传送器 传送系统本应开始生成机器人 却一直没有出现 让我们进入调试器找出原因
让我先解释一下 传送系统的工作原理
系统将数据储存在控制中心组件中 每次更新 系统都会减小倒计时值 当倒计时数值达到 0 时 它就会找到场景中具有 TeleporterComponent 的所有实体 并随机选择一个 然后在这个位置生成一个机器人 计数器将重置 这个过程会一直重复 直到俱乐部满员为止 让我们切换到调试器 检查这些组件
首先我怀疑自己忘记了 在 Teleporter 实体中添加 Teleporter 组件 这将导致我们永远找不到传送器 因此无处生成机器人 使用 RealityKit 调试器 很容易确认这一点 在实体层次结构中 展开 Bot Club 和 Teleportation Center 然后连按两下第一个传送器
在实体检查器中 注意实际上有一个传送器组件 让我们检查一下另外两个传送器 以确保万无一失
它们也有自己的传送器组件 所以 问题可能出在控制中心 在层次结构中 选择父实体 即 Teleportation Center
我们来了解一下 控制中心组件的属性
让我印象深刻的是倒计时值 请注意 它与初始值一致 RealityKit 调试器会捕获 按下暂停键时的 App 状态 因此如果我们的系统正常工作 这个值应该会发生变化 由于某种原因 控制中心 组件没有更新 让我们检查代码 找出原因
每次更新时 我都会减小控制中心组件 中的倒计时值 然后我... 啊 我本来想说我把 更新后的组件保存回了实体 但看起来我忘了这一步 让我们添加缺少的步骤 然后重新运行 App 这是一个常见错误 修改后的组件需要分配回实体 让我们切换到模拟器 看看是否解决了这个问题
通过 RealityKit 调试器 我们追踪并修复了 一个表现不佳的系统 现在我们可以回到俱乐部等待访客 我真希望能尽快有顾客来 这地方的租金 简直是天文数字 嘿 我们的第一位访客来了
我们现在有机器人传送进来 但有一个问题 柜台上应该有几瓶油 但现在看不到了 我以为我已经补货了 如果我们不尽快找到它们 这些机器人就会停止跳舞 这个地方就会陷入停顿 我怀疑它们被渲染器隐藏了 我来解释一下 像 RealityKit 这样的 3D 渲染器 在一定程度上是通过 对渲染内容进行选择来实现性能的 例如 有些东西可能离得很远 或者离得太近 被其他内容遮挡 或者不透明度设置得太低 它可能在寻找 ARKit 锚点 甚至完全丢失了素材 在所有这些情况和许多其他情况下 我们的内容将不会被渲染 而找出原因往往 需要一个排除的过程
使用 Reality Compose Pro 准备、测试和打包素材 便可避免出现这些问题 但如果最终还是丢失了内容 RealityKit 调试器 可以帮你找到原因 现在让我们切换到调试器 尝试解决瓶子丢失的问题
连按两下视口中的柜台 调整摄像头 来一个漂亮的特写
嗯 看起来不错 柜台上应该有九瓶绿色的上等油 但目前只有一瓶 甚至这瓶也渲染有误 让我们找出原因 在实体层次结构中 展开 Counter 和 BottleGroup 然后选择第一个瓶子
即使实体本身被其他实体遮挡 实体选中并突出显示后也是可见的 在本例中 它显示了这个瓶子是存在的 但它在柜台下面 这一点可以从检查器中得到证实 在检查器中 瓶子的 Transform 组件 在 Y 方向上有一个负值 这个问题很容易解决 我们稍后再做修正 现在我们继续研究下一个问题
选择层次结构中的瓶子 2
请注意 视图中没有对选中内容突出显示 如果我们连按两下 层次结构中的实体 就会将摄像头对准它
哦 它偏离范围太远了 事实上 正如黄色方框所示 它已经超出了 我们的场景范围 因此 它将被渲染器截断 永远不会显示出来 和第一个瓶子一样 这里的解决方案 也是纠正转换
让我们继续 在层次结构中连按两下 第三个瓶子 选择并聚焦它
哇 瓶子太大了 我们实际上就在瓶子里面 组成网格的三角形 通常只有一面可见 因此 从网格内部 通常根本看不到它 缩小这个对象的比例 可以解决我们的问题 现在我们离柜台 已经很远了 所以在层次结构中 让我们连按两下瓶子编号 4 快速返回
请注意 在层次结构中 瓶子 4 旁边有一个图标 这让我们知道实体处于非活跃状态 非活跃状态的实体不会被渲染 检查它的组件可以 帮助我们发现问题所在
与前面提到的瓶子不同 这个瓶子包含 OutOfStock 组件 我用这个组件来标记 缺货物品并将它们隐藏 因此 这个组件没有被渲染 实际上是我们想要的行为
我们继续处理瓶子 5
这个瓶子的检查器 显示了另一个意想不到的组件 一个 Anchoring 组件 这实际上是早期原型中 的一些旧代码 我想在俱乐部中添加晚餐服务 看来我在移除这个功能时 忘记移除这一组件了 如果场景中存在 Anchoring 组件 但没有匹配的 ARKit 锚点 实体就无法被渲染
我们来继续处理瓶子 6
在视口中 没有选择轮廓 只有一个轴 这意味着实体上没有模型组件 我们也可以在检查器中 确认这一点 也许它载入失败了 也许 我们把它附加到了错误的实体上 从这里我们无法判断 因此需要稍后检查我们的代码 但我们知道问题出在哪里 也知道该从哪里入手
接下来看看瓶子 7
这个瓶子在主视口和 预览视口中都不可见 这意味着很可能是 ModelComponent 问题 主视口中选择的形状看起来很准确 这让我怀疑网格没有问题 问题出在材质上 让我们展开并检查 ModelComponent 中材质的属性
这种材质设置为半透明的 不透明度阈值也都设置为 1 我的错误配置实际上是告诉引擎 模型中不透明度 小于 1 的任何部分 都不应被渲染 但同时也将模型的所有部分 都设置为不透明度小于 1 结果是整个瓶子都看不见了
编号为 8 的下一个瓶子 实际上是可见的 嗯部分可见 在检查器中 我没有发现任何明显的错误 在这种情况下 RealityKit 调试器为我们提供了 一些额外的显示 帮助我们发现可能会错过的问题
在预览视口中 使用最右侧的下拉菜单 以更改渲染模式 让我们选择第一个选项 以直观呈现法线 这会使用每个点的法线值 为对象上色 法线值表示表面所面向的方向 用于光照和渲染计算 乍看起来这可能令人望而生畏 但如果我们在这个瓶子和已知良好 的瓶子 (比如瓶子 1) 之间进行切换
我们可以观察到网格有点乱
类似这样的错误通常出现在 导入的素材资源中 需要在 3D 内容创作工具中 加以修复
好了 还有一步 选择瓶子 9 哦没有瓶子 9 我可能忘记将它添加到场景中了 我可以通过层次结构视图 底部的筛选栏 来确认这一点 并仅显示名称包含 BT 的实体
不出所料 它不在场景中
我们在这里快速解决了很多问题 所以我来简要总结一下 最初的几个瓶子我们看不到 因为它们的变换导致它们 被遮挡、截断或内外翻转 一个瓶子被隐藏 是因为我们停用了它 而另一个瓶子被隐藏 是因为它缺少了锚点 有一个瓶子的网格破损了 有一个瓶子的网格缺失了 还有一个瓶子因为配置了 错误的材质而隐形了 还有一个我们 忘记添加到场景中了 由于拷贝和粘贴的原因 我的瓶子创建代码中有很多错误
所以我打算用一个 创建循环来代替它 直接在代码中定位和设置 3D 素材并非易事 所以我建议尽可能 在 Reality Composer Pro 中准备场景布局 我们重新运行 App 并进入俱乐部
使用调试器中的各种工具 我们找到了丢失的瓶子 现在 我们的柜台上已经装满了油 接下来我们就可以顺利进行了
从异常转换 一直到组件配置错误 以及渲染难题 我们已经介绍了许多 在构建自己的 App 时 可能会遇到的常见问题 但是 App 的许多部分 都是独一无二的 随着复杂性的增加 调试这些部分所面临的挑战 也越来越大 但是你可以利用 ECS 的灵活性 来定制 RealityKit 调试器体验 我来演示一下具体方法 这就是我们的 Dance System 它是通过放置在舞池周围 的一系列隐形吸引器实体 来工作的 当一个新人传送到俱乐部时 它就会被一个空置的吸引器锁定 每更新一次 它就会被吸引得更近 一旦我们的朋友到达吸引器 激励器就会启动 他们就会开始跳舞 但是你可能已经注意到了 不对劲的地方 一个机器人传送了进来 但它只是站在那里 并没有向我们的吸引器移动 让我们在这个系统中加入 一些 RealityKit 调试功能
首先 我们将在场景中 添加基本的模型实体 把不可见的吸引器可视化 我们将为每个实体添加 一个自定组件 以存储我们希望在检查器中 显示的值 例如吸引器状态 我们将把它们归类到 一个不可见的实体中 这样 在游戏过程中就不会显示任何内容 我们还将给这个父实体 添加一个自定组件 以显示有关整个系统的信息
这是代码的简化版本 完整版本已包含在 ClubView swift 文件中 看起来很多 但这只是使用了 标准的 RealityKit 实体、组件和系统 你可能会注意到唯一奇怪的地方 那就是我把所有这些都放在了 调试编译代码块中 这样可以确保代码 不会被编译到发布的 App 中 也意味着我不必担心性能问题 有了新的调试系统 现在让我们运行 App 等待机器人出现 然后进入调试器
在层次结构中找到并选择 “ Dance System entity” 请注意 调试组件的属性 已出现在检查器中 RealityKit 调试器 可以显示 App 中 经常使用的大多数类型 你可以直接使用它 例如显示柜台 也可以使用更有创意的方式 例如通过将它存储 为 UIImage 属性 来显示 swift 图表 这个新的调试组件可以帮助我们 发现 Dance System 中的问题 我们所有的吸引器都处于吸引状态 这是不可能的 我们还可以通过可视化 来观察这个问题 在实体层次结构中辅助点按 “ Dance System entity” 就可以打开上下文菜单 并切换可见性状态
我们的可视化效果 向我们显示了每个吸引器的状态 事实上它们都是橙色的 这也是我们用来 表示吸引状态的颜色 一个机器人只能被一个吸引器锁定 当机器人传送进来时 我们可以用 Newcomer 组件来标记 当它被一个吸引器锁定时 我们就会移除这个标记 让我们选择其中 一个调试可视化组件 来检查它的调试组件
我们将这个组件设置为 储存对目标机器人的引用 RealityKit 调试器 会将它转换为链接 让我们点按它 找到我们的目标
检查机器人的组件会发现问题所在 它仍带有 newcomer 组件 而这个组件本应在 首次锁定它时被移除 由于它没有被移除 所以每个吸引器 都会找到这个相同的机器人 并试图吸引它 机器人因面临过多选择而停滞不动 确定问题后 我们现在可以前往代码来修复它
在 Dance System 中 当目标设置完成后 我应该移除 Newcomer 组件 但我没有这样做 我们添加相应的代码 来完成这个操作 然后重新运行 App
这类错误很容易修复 但追踪起来却很困难 尤其是当我们增加系统的 复杂性和 App 的规模时 不过 通过利用这些相同的 系统来构建可视化效果 并使用自定组件来添加检查器 我们可以确保开发者体验 与我们为玩家 和机器人构建的体验 一样愉快 最后 让我们打开俱乐部 享受成功的喜悦
在 RealityKit 调试器的帮助下 我们跟踪并修复了 实体层次结构和组件中的问题 利用 ECS 的灵活性 我们添加了可视化和自定检查器 这样我们就能更好地调试 App 中独特的部分 我们在本次讲座中介绍了很多内容 现在 就像我们的机器人朋友一样 我要去休息一下了
-
-
2:45 - ClubView
/* Abstract: The full club patch. SwiftUI view, state, extensions and helpers. */ import SwiftUI import RealityKit import OSLog import BOTanistAssets import Combine import Charts struct ClubView: View { @State var state = ClubViewState() var body: some View { ZStack { RealityView { content in state.loadEnvironment() state.rootEntity.scale = SIMD3<Float>(repeating: 0.5) content.add(state.rootEntity) } update: { updateContent in if !state.doorSupervisor.doorsOpen { state.transformIntoClub(content: updateContent) } } } } } @Observable @MainActor final public class ClubViewState: Sendable { let rootEntity = Entity() private var loadedEnvironmentRoot: Entity? private var robotRevolutionController: Entity? private var host: Entity? private(set) var doorSupervisor: DoorSupervisor { get { rootEntity.components[DoorSupervisor.self]! } set { rootEntity.components[DoorSupervisor.self] = newValue } } init() { RevolvingSystem.registerSystem() HoverSystem.registerSystem() TeleportationSystem.registerSystem() DanceMotivationSystem.registerSystem() rootEntity.name = "The B0T Club" rootEntity.components[DoorSupervisor.self] = DoorSupervisor(capacity: 9) } /// Load the existing garden assets func loadEnvironment() { guard loadedEnvironmentRoot == nil else { return } if let environment = try? Entity.load(named: "scenes/volume", in: BOTanistAssetsBundle) { environment.name = "Environment" self.loadedEnvironmentRoot = environment rootEntity.addChild(environment) } } /// Renovate the loaded environment to build our club func transformIntoClub(content: RealityViewContent) { guard !doorSupervisor.doorsOpen else { return } // Build a teleportation center and use it to spawn robots addTeleportationCenterToTheClub() // Haphazardly clean up the space by hiding anything un-club-like hideStuffInTheEnvironment() // Polish that floor and add some spin addRevolvingDanceFloorToTheClub() // Keep the robots moving in an orderly fashion addRobotRevolutionControllerToTheClub() // Install some attractors to entice robots to the dance floor addDanceFloorAttractors() // Set the mood addSpotlightsToTheClub() // Stock up on oil to keep the moves smooth addCounterToTheClub() // And add a huge Disco Ball, because... addDiscoBallToTheClub() // Let the party begin openDoors() } /// Construct a Teleportation Center and add it to the Club's root entity private func addTeleportationCenterToTheClub() { let teleportationCenter = Entity() teleportationCenter.name = "Teleportation Center" rootEntity.addChild(teleportationCenter) // Liven up the planters to look more like teleporters let positions: [SIMD3<Float>] = [[0.128, 0, 0.14], [-0.255, 0, 0.23], [0.05, 0, -0.17]] let colors: [(UIColor, UIColor)] = [(.green, .yellow), (.magenta, .purple), (.cyan, .blue)] for index in 0...2 { if let teleporter = rejigPlanter(identifier: String(index + 1), position: positions[index], colors: colors[index]) { teleportationCenter.addChild(teleporter) } } // Create a Control Center and provide a closure to handle robot spawning let teleportationControlCenter = ControlCenterComponent( initialValue: 10, interval: 5, rootEntity: rootEntity) { teleporter in self.spawnRobot(from: teleporter) self.countVisitor() // Have the host say hello if let hostCharacter = self.host?.components[AutomatonControl.self]?.character { hostCharacter.transitionToAndPlayAnimation(.idle) hostCharacter.transitionToAndPlayAnimation(.wave) } } // Assign the new control center component to the teleportation center entity teleportationCenter.components[ControlCenterComponent.self] = teleportationControlCenter } /// Transforms the visuals of the planters to look more teleporter-y private func rejigPlanter(identifier: String, position: SIMD3<Float>, colors: (UIColor, UIColor)) -> Entity? { if let rim = rootEntity.findEntity(named: "heroPlanter_rim_\(identifier)"), let dirt = rootEntity.findEntity(named: "dirt_hero_\(identifier)"), let rimModelComponent = rim.components[ModelComponent.self], var dirtModelComponent = dirt.components[ModelComponent.self] { // Apply the luminous material from the rims to the dirt (trust me it will look cool). dirtModelComponent.materials = rimModelComponent.materials dirt.components[OpacityComponent.self] = OpacityComponent(opacity: 0.7) dirt.components[ModelComponent.self] = dirtModelComponent } // Make a teleporter container entity let teleporter = Entity() teleporter.name = "Teleporter-T\(identifier)" teleporter.position = position teleporter.components[TeleporterComponent.self] = TeleporterComponent() // Add a particle emitter let radius: Float = 0.035 var particleEmitter = ParticleEmitterComponent.Presets.teleporter particleEmitter.emitterShapeSize = .init(repeating: radius) particleEmitter.mainEmitter.color = .constant(.random(a: colors.0, b: colors.1)) let particleEntity = Entity() particleEntity.orientation = .init(angle: -.pi / 2, axis: [1, 0, 0]) particleEntity.components[ParticleEmitterComponent.self] = particleEmitter particleEntity.name = "Photons" particleEntity.scale = .init(repeating: 1) teleporter.addChild(particleEntity) #if DEBUG // Add a debug marker in case we want to visually inspect this in the RealityKit Debugger teleporter.addDebugMarker(radius: radius, color: colors.0) #endif return teleporter } /// adds a random robot to the club root, positioned at the provided point private func spawnRobot(from spawnPoint: Entity) { guard let robotCharacter = randomRobot() else { logger.error("Robot creation malfunction 🤖💥") return } let guest = Entity() guest.addChild(robotCharacter.characterParent) guest.position = spawnPoint.position(relativeTo: rootEntity) guest.components[Newcomer.self] = Newcomer() guest.components[AutomatonControl.self] = AutomatonControl(character: robotCharacter) rootEntity.addChild(guest) // Play a little flashy burst on the particle emitter if let particles = spawnPoint.findEntity(named: "Photons") { var component = particles.components[ParticleEmitterComponent.self] component?.burst() particles.components[ParticleEmitterComponent.self] = component } } /// misuses AppState as a robot factory - don't try this at home, or do, but don't ship it! private func randomRobot() -> RobotCharacter? { let robotMaker = AppState() // Use offsets from the loaded animation rig, with some random parts guard let skeleton = robotMaker.robotData.meshes[.body]?.findEntity(named: "rig_grp") as? ModelEntity else { logger.error("Failed to find a robot animation rig... all dancing in cancelled ❌🕺") return nil } robotMaker.randomizeSelectedRobot() guard let head = robotMaker.robotData.meshes[.head]?.clone(recursive: true), let body = robotMaker.robotData.meshes[.body]?.clone(recursive: true), let backpack = robotMaker.robotData.meshes[.backpack]?.clone(recursive: true) else { fatalError() } let robotCharacter = RobotCharacter( head: head, body: body, backpack: backpack, appState: robotMaker, headOffset: skeleton.pins["head"]?.position, backpackOffset: skeleton.pins["backpack"]?.position ) // Pick a random robot name from the sequence robotCharacter.characterParent.name = RobotNames.next // Remove the character controller and animation state, as we'll manually control these robotCharacter.characterParent.components[CharacterControllerComponent.self] = nil AnimationState.handlers.removeAll() // The robots are here to chill, so actually, let's put their backpacks in the cloakroom backpack.removeFromParent() // Say Hi robotCharacter.transitionToAndPlayAnimation(.wave) return robotCharacter } /// Update capacity when we have a visitor private func countVisitor() { var management = self.doorSupervisor management.visitorCount += 1 self.doorSupervisor = management } /// Find and hide a bunch of stuff in the loaded environment private func hideStuffInTheEnvironment() { // We used the RealityKit Debugger to identify the names of things we want to hide in the club ["setDressing", "MovementBoundaries", "planter_side", "planter_Hero", "planter_Hero_1", "planter_Hero_2", "PlantLightGroup", "PlantLightGroup_1", "PlantLightGroup_2", "SidePlanterLights", "pipe_2", "pipe_3", "dirt_coffeeBerry_1", "dirt_coffeeBerry_2", "dirt_coffeeBerry_3", "dirt_side"].forEach { name in if let entity = rootEntity.findEntity(named: name) { entity.removeFromParent() } } } /// Repurpose some existing bits in the environment to create a makeshift revolving dance floor - if it looks like dirt, that's because it is private func addRevolvingDanceFloorToTheClub() { guard let dirtFloor = loadedEnvironmentRoot?.findEntity(named: "dirt_end") else { return } // Add a revolving container entity let revolvingDanceFloor = Entity() revolvingDanceFloor.name = "Revolving Dance Floor" revolvingDanceFloor.scale = [1, 1, 1] revolvingDanceFloor.position = [0, 0.181, 0] revolvingDanceFloor.components[RevolvingComponent.self] = RevolvingComponent(relativeTo: rootEntity) // Polish up the dirt floor let geometry = dirtFloor.clone(recursive: false) geometry.name = "Dirt Floor" geometry.transform = .identity geometry.position = [0, 0, 0] geometry.scale = dirtFloor.scale(relativeTo: rootEntity) let polish = geometry.clone(recursive: false) polish.name = "Polish Layer" polish.position = [0, 0.0004, 0] if var modelComponent = geometry.components[ModelComponent.self] { var polishedFloorMaterial = PhysicallyBasedMaterial() polishedFloorMaterial.baseColor = .init(tint: .gray) polishedFloorMaterial.roughness = .init(floatLiteral: 0.2) polishedFloorMaterial.metallic = .init(floatLiteral: 0.8) polishedFloorMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.5)) polishedFloorMaterial.clearcoat = .init(floatLiteral: 0.4) modelComponent.materials = [polishedFloorMaterial] polish.components[ModelComponent.self] = modelComponent } // Add it to the revolving container revolvingDanceFloor.addChild(geometry) revolvingDanceFloor.addChild(polish) rootEntity.addChild(revolvingDanceFloor) } /// Creates a revolving container entity to keep robots moving in sync with the dance floor private func addRobotRevolutionControllerToTheClub() { let robotRevolutionController = Entity() robotRevolutionController.name = "Robot Revolution Controller" robotRevolutionController.components[RevolvingComponent.self] = RevolvingComponent(relativeTo: rootEntity) rootEntity.addChild(robotRevolutionController) self.robotRevolutionController = robotRevolutionController } /// Add invisible attractors to the dance floor to position and control robots private func addDanceFloorAttractors() { guard let robotRevolutionController else { logger.error("The Robot Revolution Controller is missing 😱") return } // Add a few dance spots on the outside of the club that we know don't obstruct the furniture let staticAttractors = Entity() staticAttractors.name = "Static Attractors" let placementRadius: Float = 0.25 let outerRadius = placementRadius * 0.8 addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 10), placementRadius: outerRadius, name: "Static-A1", variation: 0) addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 90), placementRadius: outerRadius, name: "Static-A2", variation: 0) addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 130), placementRadius: outerRadius, name: "Static-A3", variation: 0) addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 240), placementRadius: outerRadius, name: "Static-A4", variation: 0) addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 325), placementRadius: outerRadius, name: "Static-A5", variation: 0) rootEntity.addChild(staticAttractors) // The remaining center attractors are on the revolving dance floor and can be more randomly positioned let innerRingCapacity = doorSupervisor.capacity - 5 let revolvingAttractors = Entity() revolvingAttractors.name = "Revolving Attractors" addDanceFloorAttractors(to: revolvingAttractors, count: innerRingCapacity, placementRadius: placementRadius * 0.3, namePrefix: "Revolving") robotRevolutionController.addChild(revolvingAttractors) #if DEBUG // Add some debug visualizations let debugRoot = Entity() debugRoot.name = "[Debug] Dance System" debugRoot.isEnabled = false debugRoot.components[DanceSystemDebugComponent.self] = DanceSystemDebugComponent() rootEntity.addChild(debugRoot) let allAttractors = Array(staticAttractors.children) + Array(revolvingAttractors.children) // Create a new visualization for each attractor allAttractors.forEach { attractor in if let visualization = Entity.makeDebugMarker(height: 0.08, radius: 0.03, enabled: true) { guard let attractorComponent = attractor.components[AttractorComponent.self] else { return } let debugComponent = AttractorDebugComponent(state: attractorComponent.state, attractor: attractor) visualization.position = [0, 0.04, 0] visualization.components[AttractorDebugComponent.self] = debugComponent debugRoot.addChild(visualization) } } #endif } /// Add multiple dance floor attractors along the circumference of a circle with the specified placementRadius private func addDanceFloorAttractors(to danceFloor: Entity, count: Int, placementRadius: Float, namePrefix: String, variation: Float = 0.005) { let angleIncrements = 360 / count for offset in 0..<count { let angle = Angle2D(degrees: Double(angleIncrements * offset)) let name = "\(namePrefix)-A\(offset + 1)" addDanceFloorAttractor(to: danceFloor, angle: angle, placementRadius: placementRadius, name: name, variation: variation) } } /// Adds a single dance floor attractor at a point on the circumference of a circle with the specified placementRadius private func addDanceFloorAttractor(to danceFloor: Entity, angle: Angle2D, placementRadius: Float, name: String, variation: Float = 0.005) { let attractor = Entity() attractor.name = name attractor.components[AttractorComponent.self] = AttractorComponent(club: rootEntity) attractor.position = pointOnCircumference(angle: angle, radius: placementRadius, variation: variation) danceFloor.addChild(attractor) } /// Adds some revolving spot lights to the club private func addSpotlightsToTheClub() { let placementRadius: Float = 0.5 let lightsWrapper = Entity() lightsWrapper.name = "Light Rig" let magentaLight = SpotLight() magentaLight.light.color = .magenta magentaLight.light.intensity = 500 var lightPosition = pointOnCircumference(angle: Angle2D(degrees: 0), radius: placementRadius, y: 0.5) magentaLight.look(at: .zero, from: lightPosition, relativeTo: rootEntity) lightsWrapper.addChild(magentaLight) let greenLight = magentaLight.clone(recursive: true) greenLight.light.color = .green lightPosition = pointOnCircumference(angle: Angle2D(degrees: 120), radius: placementRadius, y: 0.5) greenLight.look(at: .zero, from: lightPosition, relativeTo: rootEntity) lightsWrapper.addChild(greenLight) let cyanLight = magentaLight.clone(recursive: true) cyanLight.light.color = .cyan lightPosition = pointOnCircumference(angle: Angle2D(degrees: 240), radius: placementRadius, y: 0.5) cyanLight.look(at: .zero, from: lightPosition, relativeTo: rootEntity) lightsWrapper.addChild(cyanLight) lightsWrapper.components[RevolvingComponent.self] = RevolvingComponent(speed: -0.2, relativeTo: rootEntity) rootEntity.addChild(lightsWrapper) } /// Repurpose some planters to make a counter and stocks with a premium aged oil, and a friendly host private func addCounterToTheClub() { guard let planter = rootEntity.findEntity(named: "planter_big"), let dirt = rootEntity.findEntity(named: "dirt_big") else { logger.error("Making the counter failed... too much dancing may now cause rust 🤖") return } // Group into a container entity let counter = Entity() counter.name = "Counter" counter.position = [0.333, 0.05, -0.09] rootEntity.addChild(counter) // Repurpose existing assets let counterGeometry = Entity() counterGeometry.name = "Counter Geometry" counterGeometry.addChild(planter, preservingWorldTransform: true) counterGeometry.addChild(dirt, preservingWorldTransform: true) counterGeometry.scale = [2, 6, 2] counterGeometry.position = [-0.3335, -0.15, 0.09] counter.addChild(counterGeometry) var counterTopMaterial = PhysicallyBasedMaterial() counterTopMaterial.baseColor = .init(tint: .white) counterTopMaterial.roughness = .init(floatLiteral: 0) counterTopMaterial.metallic = .init(floatLiteral: 1) dirt.components[ModelComponent.self]?.materials = [counterTopMaterial] dirt.position += [0, 0.001, 0] // Add a fancy hover rail if let rim = rootEntity.findEntity(named: "bottom_rim_1") { let hoverRailing = rim.clone(recursive: true) hoverRailing.name = "Hover Railing" hoverRailing.position = [0, 0.1, 0] hoverRailing.scale = rim.scale(relativeTo: rootEntity) * 0.5 hoverRailing.components[HoverComponent.self] = HoverComponent(from: hoverRailing.position, to: hoverRailing.position + [0, -0.03, 0]) counter.addChild(hoverRailing) } // Add some bottles to the counter let bottles = stockBottles(placementRadius: 0.045) counter.addChild(bottles) // Hide any out of stock items for bottle in bottles.children { bottle.isEnabled = bottle.components[OutOfStockComponent.self] == nil } // Add a friendly host addHostToTheCounter(counter) } /// Adds 9 green bottles of the finest aged oil to the counter (assuming we have them in stock) private func stockBottles(placementRadius: Float) -> Entity { let bottleRadius: Float = 0.003 let bottleHeight: Float = 0.022 let angleIncrement: Float = -12 let outOfStockBrands: Set = [3] // Make a wrapper entity let bottleGroup = Entity() bottleGroup.name = "Bottle Group" bottleGroup.position = [0, 0.04, 0] bottleGroup.orientation = .init(angle: 180 * (.pi / 180), axis: [0, 1, 0]) // Make a nice green material var bottleMaterial = PhysicallyBasedMaterial() bottleMaterial.baseColor = .init(tint: .green) bottleMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.5)) // A simple cylinder mesh let bottleMesh = MeshResource.generateCylinder(height: bottleHeight, radius: bottleRadius) // Error 1: Content occluded let bottle1 = Entity() bottle1.name = "BT1" bottle1.position = pointOnCircumference(angle: .zero, radius: placementRadius, y: -0.03) bottle1.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle1) // Error 2: Content clipped let bottle2 = Entity() bottle2.name = "BT2" bottle2.position = pointOnCircumference(angle: Angle2D(degrees: angleIncrement), radius: 1.6, y: bottleHeight / 2) bottle2.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle2) // Error 3: Content inside out let bottle3 = Entity() bottle3.name = "BT3" bottle3.position = pointOnCircumference(angle: Angle2D(degrees: 2 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) bottle3.scale = .init(repeating: 650) bottle3.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle3) // Error 4: Content not enabled let bottle4 = Entity() bottle4.name = "BT4" bottle4.position = pointOnCircumference(angle: Angle2D(degrees: 3 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) bottle4.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottle4.components[OutOfStockComponent.self] = OutOfStockComponent() bottleGroup.addChild(bottle4) // Error 5: Content not anchored let bottle5 = Entity() bottle5.name = "BT5" bottle5.position = pointOnCircumference(angle: Angle2D(degrees: 4 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) bottle5.components[AnchoringComponent.self] = AnchoringComponent(.plane(.horizontal, classification: .table, minimumBounds: .zero)) bottle5.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle5) // Error 6: Content missing a mesh let bottle6 = Entity() bottle6.name = "BT6" bottle6.position = pointOnCircumference(angle: Angle2D(degrees: 5 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) bottle5.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle6) // Error 7: Content's material misconfigured let bottle7 = Entity() bottle7.name = "BT7" bottle7.position = pointOnCircumference(angle: Angle2D(degrees: 6 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) var simplifiedBottleMaterial = UnlitMaterial(color: .green.withAlphaComponent(0.5)) simplifiedBottleMaterial.opacityThreshold = 1 bottle7.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [simplifiedBottleMaterial]) bottleGroup.addChild(bottle7) // Error 8: Content has a broken mesh let alternativeMesh = MeshResource.generateAbnormalCylinder(height: bottleHeight, radius: bottleRadius) let bottle8 = Entity() bottle8.name = "BT8" bottle8.position = pointOnCircumference(angle: Angle2D(degrees: 7 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) bottle8.scale = [bottle8.scale.x, bottle8.scale.y, -bottle8.scale.z] bottleMaterial.opacityThreshold = 0 bottle8.components[ModelComponent.self] = ModelComponent(mesh: alternativeMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle8) // Error 9: Content not added to the scene hierarchy let bottle9 = Entity() bottle9.name = "BT9" bottle9.position = pointOnCircumference(angle: Angle2D(degrees: 8 * angleIncrement), radius: placementRadius, y: bottleHeight / 2) bottle9.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial]) bottleGroup.addChild(bottle8) // FIXME: Bottles are missing from the counter return bottleGroup } /// Add a host robot to the counter private func addHostToTheCounter(_ counter: Entity) { // Make a clone of our hero BOTanist let robotMaker = AppState() guard let skeleton = robotMaker.robotData.meshes[.body]?.findEntity(named: "rig_grp") as? ModelEntity else { fatalError() } // But use the hover body to best complement the counter robotMaker.setMesh(part: .body, name: "body3") guard let head = robotMaker.robotData.meshes[.head]?.clone(recursive: true), let body = robotMaker.robotData.meshes[.body]?.clone(recursive: true), let backpack = robotMaker.robotData.meshes[.backpack]?.clone(recursive: true) else { fatalError() } let robotCharacter = RobotCharacter( head: head, body: body, backpack: backpack, appState: robotMaker, headOffset: skeleton.pins["head"]?.position, backpackOffset: skeleton.pins["backpack"]?.position ) // Remove the character controller and animation state, as we'll manually control these AnimationState.handlers.removeAll() robotCharacter.characterParent.components[CharacterControllerComponent.self] = nil // Take off that heavy backpack backpack.removeFromParent() // Setup our host using the character and add it to the counter let host = Entity() host.name = "Host" host.orientation = .init(angle: 300 * (.pi / 180), axis: [0, 1, 0]) host.position = [0, 0.005, 0] host.components[AutomatonControl.self] = AutomatonControl(character: robotCharacter) host.addChild(robotCharacter.characterParent) counter.addChild(host) // Have them say Hi robotCharacter.transitionToAndPlayAnimation(.wave) // Save a reference so they can wave later when other bots enter self.host = host } /// Generates a disco ball looking entity, makes it revolve and hover, and adds it to the club private func addDiscoBallToTheClub() { // Add the top level revolving, hovering disco ball entity let discoBall = Entity() discoBall.name = "Disco Ball" discoBall.position = [-0.305, 0.17, 0.02] discoBall.components[RevolvingComponent.self] = RevolvingComponent(speed: -0.02, relativeTo: rootEntity) discoBall.components[HoverComponent.self] = HoverComponent(from: discoBall.position, to: discoBall.position + [0, 0.02, 0]) rootEntity.addChild(discoBall) // Add a support beam to hold the disco ball var supportMaterial = PhysicallyBasedMaterial() supportMaterial.baseColor = .init(tint: .lightGray) supportMaterial.roughness = .init(floatLiteral: 0.8) supportMaterial.metallic = .init(floatLiteral: 0.8) let support = ModelEntity(mesh: .generateCylinder(height: 0.01, radius: 0.01), materials: [supportMaterial]) support.scale = [0.2, 1.8, 0.2] support.position = [0, 0.05, 0] support.name = "Support" discoBall.addChild(support) // Add the shiny ball that is the base of our disco ball var backgroundMaterial = PhysicallyBasedMaterial() backgroundMaterial.baseColor = .init(tint: .lightGray) backgroundMaterial.roughness = .init(floatLiteral: 0) backgroundMaterial.metallic = .init(floatLiteral: 1) let background = ModelEntity(mesh: .generateSphere(radius: 0.05), materials: [backgroundMaterial]) background.name = "Background" // FIXME: Unintentionally inheriting an ancestor's transformation support.addChild(background) // Add some detailed lines on top of the background var lineMaterial = PhysicallyBasedMaterial() lineMaterial.baseColor = .init(tint: .lightGray) lineMaterial.sheen = .init(tint: .lightGray) lineMaterial.emissiveColor = .init(color: .lightGray) lineMaterial.emissiveIntensity = 1 lineMaterial.triangleFillMode = .lines let ballOutline = ModelEntity(mesh: .generateSphere(radius: 0.0505), materials: [lineMaterial]) ballOutline.name = "Outline" background.addChild(ballOutline) } /// Marks the club as ready private func openDoors() { var management = self.doorSupervisor management.doorsOpen = true self.doorSupervisor = management } /// finds a point along the edge of a circle on an XZ-plane, given a radius and y value. Optionally applies some variance. private func pointOnCircumference(angle: Angle2D, radius: Float, variation: Float = 0, y: Float = 0) -> SIMD3<Float> { .init( x: (Float(cos(angle)) * radius) + .random(in: -variation...variation), y: y, z: (Float(sin(angle)) * radius) + .random(in: -variation...variation) ) } } // MARK: Club Management /// Manages club capacity and ready state struct DoorSupervisor: Component { let capacity: Int var doorsOpen = false var visitorCount = 0 var hasCapacity: Bool { visitorCount < capacity } } /// Tag to indicate if a retail item is in stock struct OutOfStockComponent: Component {} // MARK: Revolution Control /// Works with the RevolvingSystem to apply a continuous rotation to an entity struct RevolvingComponent: Component { var speed: Float var angle: Float var axis: SIMD3<Float> var relativeTo: Entity? init(speed: Float = 0.05, initialAngle: Float = 0, axis: SIMD3<Float> = [0, 1, 0], relativeTo: Entity? = nil) { self.speed = speed self.angle = initialAngle self.axis = axis self.relativeTo = relativeTo } } /// Works with the RevolvingComponent to apply a continuous rotation to an entity @MainActor class RevolvingSystem: System { private static let query = EntityQuery(where: .has(RevolvingComponent.self)) required init(scene: RealityKit.Scene) {} func update(context: SceneUpdateContext) { for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) { if var revolvingComponent = entity.components[RevolvingComponent.self] { let relativeTo = revolvingComponent.relativeTo revolvingComponent.angle += .pi * Float(context.deltaTime) * revolvingComponent.speed entity.setOrientation(.init(angle: revolvingComponent.angle, axis: revolvingComponent.axis), relativeTo: relativeTo) entity.components[RevolvingComponent.self] = revolvingComponent } } } } // MARK: Hover Control /// Works with the HoverSystem to apply a continuous levitation like bounce to an entity struct HoverComponent: Component { var speed: Float var angle: Float var from: SIMD3<Float> var to: SIMD3<Float> init(speed: Float = 0.06, angle: Float = 0, from: SIMD3<Float>, to: SIMD3<Float>) { self.speed = speed self.angle = angle self.from = from self.to = to } } /// Works with the HoverComponent to apply a continuous levitation like bounce to an entity @MainActor class HoverSystem: System { private static let query = EntityQuery(where: .has(HoverComponent.self)) required init(scene: RealityKit.Scene) {} func update(context: SceneUpdateContext) { for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) { if var hoverComponent = entity.components[HoverComponent.self] { hoverComponent.angle += .pi * Float(context.deltaTime) * hoverComponent.speed let range = hoverComponent.to - hoverComponent.from let proportion = (sin(hoverComponent.angle) + 1) / 2 entity.position = hoverComponent.from + (proportion * range) entity.components[HoverComponent.self] = hoverComponent } } } } // MARK: Robot Parts /// A wrapper around a Robot Character that is actually used as an Automaton struct AutomatonControl: Component { var character: RobotCharacter } extension RobotCharacter { /// manually control the animation transition of a single robot instance func transitionToAndPlayAnimation(_ animationState: AnimationState) { if self.animationState.transition(to: animationState) { playAnimation(animationState) } } } /// A collection of shuffled robot names for our Automatons @MainActor enum RobotNames { static var count: Int = 0 static var next: String { count += 1 return "Robo-v\(count)" } } // MARK: Teleportation /// Works with the TeleportationSystem to control spawning across all teleporters struct ControlCenterComponent: Component { typealias SpawnHandler = (Entity) -> Void var initialValue: TimeInterval var interval: TimeInterval var countdown: TimeInterval var rootEntity: Entity var _spawnHandler: SpawnHandler init(initialValue: TimeInterval, interval: TimeInterval, rootEntity: Entity, spawnHandler: @escaping SpawnHandler) { self.initialValue = initialValue self.interval = interval self.countdown = initialValue self.rootEntity = rootEntity self._spawnHandler = spawnHandler } } /// Represents a single Teleporter in the TeleportationSystem struct TeleporterComponent: Component {} /// Works with the ControlCenterComponent to control spawning across all teleporters @MainActor class TeleportationSystem: System { private static let controlCenterQuery = EntityQuery(where: .has(ControlCenterComponent.self)) private static let teleporterQuery = EntityQuery(where: .has(TeleporterComponent.self)) private static let robotQuery = EntityQuery(where: .has(AutomatonControl.self)) required init(scene: RealityKit.Scene) {} func update(context: SceneUpdateContext) { for entity in context.entities(matching: Self.controlCenterQuery, updatingSystemWhen: .rendering) { update(controlCenter: entity, context: context) } } private func safeToUse(teleporter: Entity, context: SceneUpdateContext) -> Bool { let someBotIsStandingToClose = context.entities(matching: Self.robotQuery, updatingSystemWhen: .rendering) .contains { entity in distance(entity.position(relativeTo: nil), teleporter.position(relativeTo: nil)) < 0.02 } return !someBotIsStandingToClose } private func update(controlCenter controlCenterEntity: Entity, context: SceneUpdateContext) { guard var controlCenter = controlCenterEntity.components[ControlCenterComponent.self], let clubManager = controlCenter.rootEntity.components[DoorSupervisor.self], clubManager.hasCapacity else { return } // 1. Decrease countdown, and activate if it reaches zero controlCenter.countdown -= context.deltaTime if controlCenter.countdown <= 0 { // 2. Find all the active teleporters and pick a random one if let teleporter = context.entities(matching: Self.teleporterQuery, updatingSystemWhen: .rendering).shuffled().first { // 3. If no other robots are in the way, pass it to the designated spawn method if safeToUse(teleporter: teleporter, context: context) { controlCenter._spawnHandler(teleporter) } } // 4. Set the delay till the next spawn event controlCenter.countdown = controlCenter.interval } // FIXME: Control Center is not being updated } } extension ParticleEmitterComponent.Presets { /// Makes a particle emitter component that looks like a teleporter fileprivate static var teleporter: ParticleEmitterComponent { var particleEmitter = ParticleEmitterComponent.Presets.rain particleEmitter.birthLocation = .surface particleEmitter.emitterShape = .torus particleEmitter.particlesInheritTransform = false particleEmitter.fieldSimulationSpace = .global particleEmitter.speed = 0.07 particleEmitter.speedVariation = 0.03 particleEmitter.radialAmount = 360 particleEmitter.torusInnerRadius = 0.001 particleEmitter.emissionDirection = [0, 1, 0] particleEmitter.spawnedEmitter = nil particleEmitter.burstCount = 5000 particleEmitter.mainEmitter.opacityCurve = .linearFadeOut particleEmitter.mainEmitter.birthRate = 50 particleEmitter.mainEmitter.birthRateVariation = 10 particleEmitter.mainEmitter.lifeSpan = 0.5 particleEmitter.mainEmitter.lifeSpanVariation = 0.01 particleEmitter.mainEmitter.size = 0.001 particleEmitter.mainEmitter.sizeVariation = 0.0005 particleEmitter.mainEmitter.sizeMultiplierAtEndOfLifespan = 0.01 particleEmitter.mainEmitter.stretchFactor = 10 particleEmitter.mainEmitter.noiseStrength = 0 particleEmitter.mainEmitter.spreadingAngle = 0 particleEmitter.mainEmitter.angle = 0 particleEmitter.spawnedEmitter = nil return particleEmitter } } // MARK: Dancing /// Represents a single Attractor in the DanceMotivationSystem struct AttractorComponent: Component { enum State { case vacant case attracting case motivating } private(set) var state: State = .vacant var target: Entity? var walkSpeed: Float = 0.1 var interval: TimeInterval = 5 var countdown: TimeInterval = 5 var club: Entity? var isVacant: Bool { if case .vacant = state { return true } return false } mutating func setTarget(_ target: Entity) { self.target = target self.state = .attracting } mutating func targetReached() { self.state = .motivating } } /// Represents a single Robot in the DanceMotivationSystem struct Newcomer: Component {} /// Works with the DanceMotivationSystem to provide additional Debug information to the RealityKit Debugger struct DanceSystemDebugComponent: Component { var states: UIImage? = nil var vacant: Int = 0 var attracting: Int = 0 var motivating: Int = 0 } /// Provides additional Debug information about a single Attractor in the DanceMotivationSystem to the RealityKit Debugger struct AttractorDebugComponent: Component { var state: AttractorComponent.State var attractor: Entity var robot: Entity? } /// Manages the states of dance floor attractors, the movement of robots and the relationships between them @MainActor class DanceMotivationSystem: System { private static let attractorQuery = EntityQuery(where: .has(AttractorComponent.self)) private static let targetQuery = EntityQuery(where: .has(Newcomer.self)) private static let clubbersQuery = EntityQuery(where: .has(AutomatonControl.self)) private static let debugRootQuery = EntityQuery(where: .has(DanceSystemDebugComponent.self)) private static let debugVisualizationsQuery = EntityQuery(where: .has(AttractorDebugComponent.self)) required init(scene: RealityKit.Scene) {} func update(context: SceneUpdateContext) { // 1. Check for newcomers at the club who could be enticed to come and dance for visitor in context.entities(matching: Self.targetQuery, updatingSystemWhen: .rendering) { // 2. Randomly pick an attractor guard let attractor = context.entities(matching: Self.attractorQuery, updatingSystemWhen: .rendering) .filter({ $0.components[AttractorComponent.self]?.isVacant ?? false }) .randomElement() else { return } // 3. Start attracting the visitor var attractorComponent = attractor.components[AttractorComponent.self]! attractorComponent.setTarget(visitor) attractor.components[AttractorComponent.self] = attractorComponent // FIXME: Stop attractors competing over the same bot } // Let the attractors do their thing and attract visitors to come and dance for attractor in context.entities(matching: Self.attractorQuery, updatingSystemWhen: .rendering) { guard var attractorComponent = attractor.components[AttractorComponent.self] else { continue } switch attractorComponent.state { case .attracting: if let updatedAttractorComponent = attractRobot(attractor: attractor, deltaTime: Float(context.deltaTime)) { attractorComponent = updatedAttractorComponent } case .motivating: if let updatedAttractorComponent = motivateRobot(attractor: attractor, context: context) { attractorComponent = updatedAttractorComponent } default: break } // save changes attractor.components[AttractorComponent.self] = attractorComponent } #if DEBUG updateDebugInfo(context: context) #endif } private func attractRobot(attractor: Entity, deltaTime: Float) -> AttractorComponent? { guard var attractorComponent = attractor.components[AttractorComponent.self], case .attracting = attractorComponent.state, let target = attractorComponent.target, let robotCharacter = target.components[AutomatonControl.self]?.character else { return nil } // robots wave when they first arrive, make sure that is completed first before moving var transitionAnimationTo: AnimationState? switch robotCharacter.animationState { case .wave: transitionAnimationTo = .idle case .idle: transitionAnimationTo = .walkLoop case .walkLoop: transitionAnimationTo = nil default: return attractorComponent } if let transitionAnimationTo { if robotCharacter.animationState.transition(to: transitionAnimationTo) { robotCharacter.playAnimation(robotCharacter.animationState) } } // Convert the robot and target positions into the same coordinate system let targetPosition = target.position(relativeTo: attractorComponent.club) var danceSpotPosition = attractor.position(relativeTo: attractorComponent.club) danceSpotPosition.y = targetPosition.y let movementVector = danceSpotPosition - targetPosition let normalizedMovement = movementVector / length(movementVector) let move = normalizedMovement * deltaTime * attractorComponent.walkSpeed target.setPosition(targetPosition + move, relativeTo: attractorComponent.club) robotCharacter.characterModel.look(at: robotCharacter.characterModel.position - normalizedMovement, from: robotCharacter.characterModel.position, relativeTo: robotCharacter.characterParent) // If the target is more or less in position then attach to the dance spot and change state to motivating if distance(danceSpotPosition, target.position(relativeTo: attractorComponent.club)) < 0.005 { attractor.addChild(target, preservingWorldTransform: true) // Start Dancing robotCharacter.transitionToAndPlayAnimation(.celebrate) // Update attractor state attractorComponent.targetReached() } return attractorComponent } private func motivateRobot(attractor: Entity, context: SceneUpdateContext) -> AttractorComponent? { guard var attractorComponent = attractor.components[AttractorComponent.self], case .motivating = attractorComponent.state, let target = attractorComponent.target, let robotCharacter = target.components[AutomatonControl.self]?.character else { return nil } attractorComponent.countdown -= context.deltaTime if attractorComponent.countdown <= 0 { // Turn to face a random fellow clubber if let friend = Array(context.entities(matching: Self.clubbersQuery, updatingSystemWhen: .rendering)).randomElement() { let friendsPosition = friend.position(relativeTo: robotCharacter.characterParent) robotCharacter.characterModel.look(at: friendsPosition, from: robotCharacter.characterModel.position, relativeTo: robotCharacter.characterParent) // TODO: remove me print("🔥 friendsPosition \(friendsPosition) targetPosition \(robotCharacter.characterModel.position)") } attractorComponent.countdown = attractorComponent.interval } return attractorComponent } #if DEBUG let vacantColor = UnlitMaterial.BaseColor(tint: .yellow.withAlphaComponent(0.5)) let attractingColor = UnlitMaterial.BaseColor(tint: .orange.withAlphaComponent(0.5)) let motivatingColor = UnlitMaterial.BaseColor(tint: .red.withAlphaComponent(0.5)) private func updateDebugInfo(context: SceneUpdateContext) { var vacantCount: Int = 0 var attractingCount: Int = 0 var motivatingCount: Int = 0 context.entities(matching: Self.debugVisualizationsQuery, updatingSystemWhen: .rendering).forEach { visualization in guard let visualizationComponent = visualization.components[AttractorDebugComponent.self], let attractorComponent = visualizationComponent.attractor.components[AttractorComponent.self] else { return } updateVisualizationEntity(visualization, relativeTo: attractorComponent.club) switch attractorComponent.state { case .vacant: vacantCount += 1 case .attracting: attractingCount += 1 case .motivating: motivatingCount += 1 } } context.entities(matching: Self.debugRootQuery, updatingSystemWhen: .rendering).forEach { debugRoot in if var debugComponent = debugRoot.components[DanceSystemDebugComponent.self] { debugComponent.vacant = vacantCount debugComponent.attracting = attractingCount debugComponent.motivating = motivatingCount debugComponent.states = makeChart(vacantCount: vacantCount, attractingCount: attractingCount, motivatingCount: motivatingCount) debugRoot.components[DanceSystemDebugComponent.self] = debugComponent } } } private func updateVisualizationEntity(_ visualization: Entity, relativeTo root: Entity?) { guard var visualizationComponent = visualization.components[AttractorDebugComponent.self], let attractorComponent = visualizationComponent.attractor.components[AttractorComponent.self] else { return } // Update the position var position = visualizationComponent.attractor.position(relativeTo: root) position.y = visualization.position.y visualization.setPosition(position, relativeTo: root) // Update the state visualizationComponent.state = attractorComponent.state visualization.name = "[Debug] \(visualizationComponent.attractor.name) (\(attractorComponent.state))" // Update the base material color to signify the attractor state if var modelComponent = visualization.components[ModelComponent.self], var material = modelComponent.materials.first as? UnlitMaterial { switch attractorComponent.state { case .vacant: material.color = vacantColor case .attracting: material.color = attractingColor case .motivating: material.color = motivatingColor } modelComponent.materials = [material] visualization.components[ModelComponent.self] = modelComponent } // Update the target visualizationComponent.robot = attractorComponent.target visualization.components[AttractorDebugComponent.self] = visualizationComponent } private func makeChart(vacantCount: Int, attractingCount: Int, motivatingCount: Int) -> UIImage? { ImageRenderer(content: chartView(vacantCount: vacantCount, attractingCount: attractingCount, motivatingCount: motivatingCount)).uiImage } private func chartView(vacantCount: Int, attractingCount: Int, motivatingCount: Int) -> some View { Chart( [ (name: "Vacant", count: vacantCount), (name: "Attracting", count: attractingCount), (name: "Motivating", count: motivatingCount) ], id: \.name) { name, count in SectorMark( angle: .value("Value", count), angularInset: 1.5 ) .cornerRadius(5) .foregroundStyle(by: .value("Name", name)) } .chartLegend(.hidden) .chartForegroundStyleScale(["Vacant": .yellow, "Attracting": .orange, "Motivating": .red]) .frame(width: 1024, height: 1024) } #endif } // MARK: Debug Helpers extension Entity { /// creates an semi-transparent entity that can be useful in debug invisible entities in the RealityKit Debugger static func makeDebugMarker(name: String? = nil, height: Float, radius: Float, color: UIColor = .white, enabled: Bool = false) -> Entity? { #if DEBUG var debugMaterial = UnlitMaterial() debugMaterial.color = .init(tint: color) debugMaterial.blending = .transparent(opacity: 0.7) let marker = ModelEntity(mesh: .generateCylinder(height: height, radius: radius), materials: [debugMaterial]) if let name { marker.name = name } marker.isEnabled = enabled return marker #else return nil #endif } /// adds an semi-transparent child entity that can be useful in debug invisible entities in the RealityKit Debugger @discardableResult func addDebugMarker(name: String? = nil, height: Float? = nil, radius: Float? = nil, color: UIColor = .white, enabled: Bool = false) -> Entity? { #if DEBUG var markerRadius: Float if radius != nil { markerRadius = radius! } else { // If no provided radius then calculate from the visual bounds let extents = visualBounds(relativeTo: nil).extents let boundingXZRadius = max(extents.x, extents.z) / 2 if boundingXZRadius.isNormal { markerRadius = boundingXZRadius } else { // If no visual bounds then use a default radius of 1cm markerRadius = 0.01 * scale(relativeTo: nil).max() } } // If no provided height then use a default value of 10cm let markerHeight = height ?? 0.1 * scale(relativeTo: nil).max() let name = name ?? "[Debug] \(self.name)" if let marker = Entity.makeDebugMarker(name: name, height: markerHeight, radius: markerRadius, color: color, enabled: enabled) { marker.position = [0, markerHeight / 2, 0] addChild(marker) return marker } #endif return nil } } // MARK: Demo Helpers extension MeshResource { /// Generates an cylinder with all the normals facing downwards. Probably has no uses other than demo'ing a broken mesh. static func generateAbnormalCylinder(height: Float, radius: Float) -> MeshResource { let meshResource = MeshResource.generateCylinder(height: height, radius: radius) var contents = meshResource.contents let models = contents.models.map { model in var model = model let parts = model.parts.map { part in var part = part part.normals = part.normals.map { normals in let transformedNormals: [SIMD3<Float>] = normals.map { _ in [0, -1, 0] } return MeshBuffer(transformedNormals) } return part } model.parts = MeshPartCollection(parts) return model } contents.models = MeshModelCollection(models) try? meshResource.replace(with: contents) return meshResource } }
-
3:02 - Add a volumetric club scene
WindowGroup(id: "RobotClub") { GeometryReader3D { geometry in ClubView() .volumeBaseplateVisibility(.visible) .environment(appState) .scaleEffect(geometry.size.width / initialVolumeSize.width) } .onAppear { dismissWindow(id: "RobotCreation") } } .windowStyle(.volumetric) .defaultWorldScaling(.dynamic) .defaultSize(initialVolumeSize)
-
3:09 - Add a button to open the club
VStack { Button("🪩") { openWindow(id: "RobotClub") } .padding() Spacer() } .padding([.trailing, .top])
-
6:50 - FIX: Unintentionally inheriting an ancestor's transformation
discoBall.addChild(background)
-
10:18 - FIX: Control Center is not being updated
// 5. Save updated component back to the entity controlCenterEntity.components[ControlCenterComponent.self] = controlCenter
-
18:15 - FIX: Stocking bottles
private func stockBottles(placementRadius: Float) -> Entity { let bottleRadius: Float = 0.003 let bottleHeight: Float = 0.022 let angleIncrement: Float = -12 let outOfStockBrands: Set = [3] // Make a wrapper entity let bottleGroup = Entity() bottleGroup.name = "Bottle Group" bottleGroup.position = [0, 0.04, 0] bottleGroup.orientation = .init(angle: 180 * (.pi / 180), axis: [0, 1, 0]) // Make a nice green material var bottleMaterial = PhysicallyBasedMaterial() bottleMaterial.baseColor = .init(tint: .green) bottleMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.5)) for i in 0..<9 { let angle = Angle2D(degrees: angleIncrement * Float(i)) let bottleMesh = MeshResource.generateCylinder(height: bottleHeight, radius: bottleRadius) let bottle = ModelEntity(mesh: bottleMesh, materials: [bottleMaterial]) bottle.name = "BT\(i)" bottle.position = pointOnCircumference(angle: angle, radius: placementRadius, y: bottleHeight / 2) if outOfStockBrands.contains(i) { bottle.components[OutOfStockComponent.self] = OutOfStockComponent() } bottleGroup.addChild(bottle) } return bottleGroup }
-
22:48 - FIX: Attractors
// 4. Untag them as a Newcomer visitor.components[Newcomer.self] = nil
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。