安卓纸板-VR-项目-全-

安卓纸板 VR 项目(全)

原文:zh.annas-archive.org/md5/94E6723D45DBCC15CF10E16526443AE5

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

谷歌纸板是一种低成本、入门级的媒介,用于体验虚拟 3D 环境。它的应用与移动智能手机应用程序本身一样广泛和多样。本书为您提供了使用原生 Java SDK 为谷歌纸板实现各种有趣项目的机会。目的是教育您最佳实践和方法,以制作适用于设备及其预期用户的纸板兼容移动 VR 应用,并指导您制作高质量的内容。

本书涵盖的内容

第一章,“每个人的虚拟现实”,定义了谷歌纸板,探讨了它,并讨论了它的用途以及它如何适应虚拟现实设备的范围。

第二章,“骨架纸板项目”,审查了安卓纸板应用程序的结构,介绍了 Android Studio,并通过引入纸板 Java SDK 帮助您构建一个起始纸板项目。

第三章,“纸板盒”,讨论了如何从头开始构建一个基于谷歌的宝藏猎人示例的纸板安卓应用程序,其中包括 3D 立方体模型、变换、立体摄像机视图和头部旋转。本章还包括对 3D 几何、Open GL ES、着色器、矩阵数学和渲染管线的讨论。

第四章,“启动器大堂”,帮助您构建一个应用程序,用于在手机上启动其他纸板应用。这个项目不使用 3D 图形,而是在屏幕空间中模拟立体视图,并实现了凝视选择。

第五章,“RenderBox 引擎”,向您展示了如何创建一个小型图形引擎,用于通过将低级别的 OpenGL ES API 调用抽象为一套MaterialRenderObjectComponentTransform类来构建新的纸板 VR 应用程序。该库将在后续项目中被使用和进一步开发。

第六章,“太阳系”,通过添加太阳光源、具有纹理映射材料和着色器的球形行星,以及它们在太阳系轨道上的动画和银河星空,构建了一个太阳系模拟科学项目。

第七章,“360 度画廊”,帮助您构建一个用于常规和 360 度照片的媒体查看器,并帮助您将手机相机文件夹中的照片加载到缩略图图像网格中,并使用凝视选择来选择要查看的照片。它还讨论了如何添加进程线程以改善用户体验,并支持 Android 意图以查看来自其他应用程序的图像。

第八章,“3D 模型查看器”,帮助您构建一个用于 OBJ 文件格式的 3D 模型的查看器,使用我们的 RenderBox 库进行渲染。它还向您展示了如何通过移动头部来交互控制模型的视图。

第九章,“音乐可视化器”,构建了一个基于手机当前音频播放器的波形和 FFT 数据进行动画的 VR 音乐可视化器。我们实现了一个通用架构,用于添加新的可视化,包括几何动画和动态纹理着色器。然后,我们添加了一个迷幻轨迹模式和多个并发可视化,随机过渡进出。

您需要什么来阅读本书

在整本书中,我们使用 Android Studio IDE 开发环境来编写和构建 Android 应用程序。您可以免费下载 Android Studio,如第二章,“骨架纸板项目”中所述。您需要一部安卓手机来运行和测试您的项目。强烈建议您拥有一个谷歌纸板查看器,以体验立体虚拟现实中的应用程序。

本书适合谁

本书适用于对学习和开发使用 Google Cardboard 原生 SDK 的 Google Cardboard 应用程序感兴趣的 Android 开发人员。我们假设读者对 Android 开发和 Java 语言有一定了解,但可能对 3D 图形、虚拟现实和 Google Cardboard 还不熟悉。初学者开发人员或不熟悉 Android SDK 的人可能会发现本书难以入门。那些没有 Android 背景的人可能更适合使用 Unity 等游戏引擎创建 Cardboard 应用程序。

惯例

在本书中,您将找到一些区分不同信息类型的文本样式。以下是一些示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“编辑MainActivity Java 类,使其扩展CardboardActivity并实现CardboardView.StereoRenderer。”

代码块设置如下:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        CardboardView cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
        cardboardView.setRenderer(this);
        setCardboardView(cardboardView);
    }

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        CardboardView cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
 cardboardView.setRenderer(this);
 setCardboardView(cardboardView);
    }

任何命令行输入或输出均按以下方式编写:

git clone https://github.com/googlesamples/cardboard-java.git

新术语重要单词以粗体显示。屏幕上看到的单词,例如菜单或对话框中的单词,以这种方式出现在文本中:“在 Android Studio 中,选择文件|新建|新建模块…。选择导入.JAR/.AAR 包。”

注意

警告或重要提示以这样的框出现。

提示

技巧和窍门看起来像这样。

第一章:每个人的虚拟现实

欢迎来到令人兴奋的虚拟现实世界!我们相信,作为一名安卓开发者,您想要立即开始构建可以使用谷歌纸板查看的酷东西。然后您的用户只需将他们的智能手机放入观看器中,就可以进入您的虚拟创作。在本书的其余部分中,让我们在代码和技术方面深入讨论之前,让我们先来看看 VR、谷歌纸板及其安卓 SDK 的外部结构。在本章中,我们将讨论以下主题:

  • 为什么叫纸板?

  • 虚拟现实设备的范围

  • VR 的入口

  • 低端 VR 的价值

  • 卡片硬件

  • 配置您的纸板观看器

  • 为纸板开发应用程序

  • VR 最佳实践概述

为什么叫纸板?

一切始于 2014 年初,当时谷歌员工大卫·科兹和达米安·亨利在业余时间为安卓智能手机制作了一个简单而廉价的立体观看器。他们设计了一个可以用普通纸板制作的设备,再加上一些适合眼睛的镜片,以及一个触发按钮“点击”的机制。这个观看器真的是用纸板做的。他们编写了一个软件,可以呈现一个分屏的 3D 场景:一个视图给左眼,另一个视图,带有偏移,给右眼。透过这个设备,你会真正感受到对计算机生成场景的 3D 沉浸。它奏效了!该项目随后被提议并批准为“20%项目”(员工可以每周工作一天进行创新),得到资金支持,并有其他员工加入。

注意

关于纸板诞生背后的故事的两个“权威”来源如下:

事实上,纸板效果非常好,以至于谷歌决定继续前进,将该项目提升到下一个级别,并在几个月后在 2014 年的谷歌 I/O 上向公众发布。下图显示了一个典型的未组装的谷歌纸板套件:

为什么叫纸板?

自问世以来,谷歌纸板一直对黑客、业余爱好者和专业开发者都是开放的。谷歌开源了观看器设计,任何人都可以下载图纸并制作自己的观看器,可以用披萨盒或者任何他们周围有的东西。甚至可以开展业务,直接向消费者出售预制套件。下图显示了一个已组装的纸板观看器:

为什么叫纸板?

纸板项目还包括一个软件开发工具包SDK),可以轻松构建 VR 应用程序。谷歌已经不断改进了软件,包括一个本地的 Java SDK 以及一个用于 Unity 3D 游戏引擎的插件(unity3d.com/)。

自纸板发布以来,已经开发了大量的应用程序,并在谷歌 Play 商店上提供。在 2015 年的谷歌 I/O 上,2.0 版本推出了升级设计、改进软件和对苹果 iOS 的支持。

谷歌纸板在市场上的形象迅速从一个几乎可笑的玩具发展成为某些类型的 3D 内容和 VR 体验的严肃新媒体设备。谷歌自己的纸板演示应用程序已经从谷歌 Play 商店下载了数百万次。《纽约时报》在 2015 年 11 月 8 日的星期日发行的一期中分发了大约一百万个纸板观看器。

纸板适用于查看 360 度照片和玩低保真度的 3D VR 游戏。它几乎可以被任何人普遍接触,因为它可以在任何安卓或 iOS 智能手机上运行。

开发者现在正在将 3D VR 内容直接整合到 Android 应用中。Google Cardboard 是一种体验虚拟现实的方式,它将会长期存在。

VR 设备的谱系

和大多数技术一样,虚拟现实产品也有一个从最简单和最便宜到非常先进的产品的谱系。

老式立体镜

Cardboard 处于 VR 设备谱系的低端。如果考虑你小时候玩过的 ViewMaster,甚至是 1876 年的历史性立体镜观看器(B.W. Kilborn & Co, New Hampshire 州的 Littleton),你甚至可以再低一些,如下图所示:

老式立体镜

在这些老式的观看器中,一对照片为左右眼显示两个分离的视图,略微偏移以创建视差。这会让大脑误以为它正在看到一个真正的三维视图。设备中包含了每只眼睛的独立镜片,让你可以轻松地聚焦在照片上。

同样,渲染这些并排的立体视图是 Google Cardboard 应用的首要任务。(借助他们的传统,Mattel 最近发布了与 Cardboard 兼容的 ViewMaster 品牌 VR 观看器,使用智能手机,可以在www.view-master.com/找到)。

Cardboard 是移动 VR

Cardboard 相对于立体观看器的明显优势,就像数字照片相对于传统照片的优势一样。数字媒体可以在我们的智能手机内动态存储、加载和操作。这本身就是一个强大的飞跃。

除此之外,Cardboard 还利用手机中的运动传感器,当你左右或上下转头时,图像会相应调整,有效地消除了图像的传统边框。构图是传统视觉媒体的一个非常重要的部分,比如绘画、摄影和电影摄影。几个世纪以来,艺术家和导演们一直在使用这个矩形框架建立视觉语言。

然而,在 VR 中并非如此。当你在 VR 中移动头部时,你的视线方向会改变,场景会随之更新,就好像摄像机随着你的旋转而旋转,提供了完全沉浸式的视图。你可以水平旋转 360 度,左右观看,垂直旋转 180 度,上下观看。换句话说,你可以随意观看任何地方。在 VR 中没有框架!(尽管你的外围视野可能会受到光学和显示器尺寸的限制,这些决定了设备的视野范围)。因此,设计考虑可能更类似于雕塑、环形剧场,甚至是建筑设计。我们需要考虑整个空间,让游客沉浸其中。

Google Cardboard 设备只是一个用来放置智能手机的外壳。它使用智能手机的技术,包括以下内容:

  • 显示

  • CPU(主处理器)

  • GPU(图形处理器)

  • IMU(运动传感器)

  • 磁力计和/或触摸屏(触发传感器)

我们稍后会详细讨论这一切是如何运作的。

使用移动智能手机进行 VR 意味着有很多好处,比如易于使用,但也有一些烦人的限制,比如有限的电池寿命,较慢的图形处理,以及较低的精度/更高的延迟运动传感器。

三星 Gear VR 是一款比简单的 Cardboard 观看器更智能的移动 VR 头盔。基于 Android,但不兼容 Cardboard 应用(只能与三星手机的特定型号配合使用),它有一个内置的更高精度 IMU(运动传感器),增加了头部运动跟踪的准确性,并有助于减少更新显示时的运动到像素延迟。它还经过人体工程学设计,可以更长时间地使用,并配备了一个带子。

桌面 VR 及更多

在消费者虚拟现实设备的高端是 Oculus Rift、HTC Vive 和索尼 PlayStation VR 等产品。这些产品之所以能做到 Cardboard 无法做到的事情,是因为它们不受智能手机能力的限制。有时被称为“桌面 VR”,这些设备是连接到外部 PC 或游戏机的头戴式显示器HMD)。

在桌面 VR 上,桌面强大的 CPU 和 GPU 进行实际计算和图形渲染,并将结果发送到 HMD。此外,HMD 具有更高质量的运动传感器和其他功能,有助于在更新显示时减少延迟,比如每秒 90 帧(FPS)。我们将在本书中了解到,减少延迟和保持高 FPS 对所有 VR 开发以及 Cardboard 上的用户舒适度都是重要的关注点。

桌面 VR 设备还增加了位置跟踪。Cardboard 设备可以检测 X、Y 和 Z 轴上的旋转运动,但不幸的是它无法检测位置运动(例如沿着这些轴的滑动)。Rift、Vive 和 PSVR 可以。例如,Rift 使用外部摄像头通过 HMD 上的红外灯来跟踪位置(外部跟踪)。另一方面,Vive 使用 HMD 上的传感器来跟踪房间中放置的一对激光发射器的位置(内部跟踪)。Vive 还使用这个系统来跟踪一对手柄的位置和旋转。这两种策略都能实现类似的结果。用户在被跟踪的空间内有更大的自由度,同时在虚拟空间内移动。Cardboard 无法做到这一点。

请注意,创新不断被引入。很可能,在某个时候,Cardboard 将包含位置跟踪功能。例如,我们知道谷歌的 Project Tango 使用传感器、陀螺仪和对物理空间的认知来实现视觉惯性测距(VIO),从而为移动应用提供运动和位置跟踪。参考developers.google.com/project-tango/overview/concepts。移动设备公司,如 LG 和三星,正在努力研究如何实现移动位置跟踪,但(在撰写本文时)尚不存在通用的、低延迟的解决方案。谷歌的 Project Tango 显示出一些希望,但尚不能实现流畅、舒适的 VR 体验所需的像素延迟。延迟过大会让你感到不适!

在非常高端的是成千上万甚至数百万美元的工业和军用级系统,这些不是消费者设备,我相信它们可以做一些非常棒的事情。我可以告诉你更多,但那样我就得杀了你。这些解决方案自上世纪 80 年代以来就已经存在。VR 并不是新的——消费者 VR 是新的。

VR 设备的光谱在下图中有所体现:

桌面 VR 及更高级别

当我们为 Cardboard 开发时,重要的是要记住它相对于其他 VR 设备能做什么,不能做什么。Cardboard 可以显示立体视图。Cardboard 可以跟踪头部的旋转运动。它不能进行位置跟踪。它在图形处理能力、内存和电池寿命方面存在限制。

VR 的入口

在它上市的短时间内,这一代消费者虚拟现实已经表现出自己是瞬间引人入胜、沉浸式、娱乐性强,对于试用过的几乎每个人来说都是“改变游戏规则”的产品。谷歌 Cardboard 特别容易获得,使用门槛很低。你只需要一部智能手机,一个低成本的 Cardboard 观看器(低至 5 美元),以及从 Google Play(或者 iOS 的 Apple App Store)下载的免费应用程序。

谷歌 Cardboard 被称为 VR 的通道,也许是指大麻作为更危险的非法药物滥用的“通道药物”?我们可以玩一下这个类比,尽管有些颓废。也许 Cardboard 会让你略尝 VR 的潜力。你会想要更多。然后再多一些。这将帮助你满足对更好、更快、更强烈和更沉浸式虚拟体验的渴望,这些只能在更高端的 VR 设备中找到。也许在这一点上,也许就没有回头的余地了;你上瘾了!

然而,作为 Rift 用户,我仍然喜欢 Cardboard。它快速。它容易。它有趣。而且真的有效,只要我运行适合该设备的应用程序。

在假期拜访家人时,我在背包里带了一个 Cardboard 观看器。每个人都很喜欢。我的许多亲戚甚至都没有通过标准的谷歌 Cardboard 演示应用程序,尤其是它的 360 度照片查看器。那足够吸引人,让他们一段时间内感到愉快。其他人则玩了一两个游戏,或者更多。他们想继续玩并尝试新的体验。也许这只是新奇。或者,也许这是这种新媒体的本质。关键是,谷歌 Cardboard 提供了一种令人沉浸的体验,令人愉快,有用,而且非常容易获得。简而言之,它很棒。

然后,向他们展示 HTC Vive 或 Oculus Rift。天哪!那真的太棒了!好吧,对于这本书,我们不是来讨论更高端的 VR 设备,只是与 Cardboard 进行对比,并保持透视。

一旦你尝试了桌面 VR,再回到移动 VR 会很难吗?有些人这样说。但这几乎是愚蠢的。事实是它们确实是两种不同的东西。

正如前面讨论的,桌面 VR 配备了更高的处理能力和其他高保真功能,而移动 VR 受到智能手机的限制。如果开发人员试图直接将桌面 VR 应用程序移植到移动设备,你很可能会感到失望。

最好将每个视为一个独立的媒体。就像桌面应用程序或游戏机游戏不同于但类似于移动应用程序一样。设计标准可能是相似的但不同。技术是相似的但不同。用户期望是相似的但不同。移动 VR 可能类似于桌面 VR,但它是不同的。

注意

为了强调 Cardboard 与桌面 VR 设备的不同,值得指出谷歌已经将以下内容写入了他们的制造商规格和指南中:

“不要在您的观看器中包括头带。当用户用手将 Cardboard 贴在脸上时,他们的头部旋转速度受到躯干旋转速度的限制(比颈部旋转速度慢得多)。这减少了由渲染/IMU 延迟引起的“VR 晕动病”的机会,并增加了 VR 的沉浸感。”

这意味着 Cardboard 应用程序应该设计为更短、更简单、更固定的体验。在本书中,我们将阐明这些和其他提示和最佳实践,当你为移动 VR 媒体开发时。

现在让我们考虑 Cardboard 是通往 VR 的其他方式。

我们预测 Android 将继续成为未来虚拟现实的主要平台。越来越多的技术将被塞进智能手机。而这项技术将包括对 VR 有利的特性:

  • 更快的处理器和移动 GPU

  • 更高分辨率的屏幕

  • 更高精度的运动传感器

  • 优化的图形管线

  • 更好的软件

  • 更多的 VR 应用程序

移动 VR 不会让位给桌面 VR;甚至可能最终取代它。

此外,我们很快将看到专门的移动 VR 头显,内置智能手机的功能,而无需支付无线通信合同的费用。不需要使用自己的手机。不会再因为来电或通知而在虚拟现实中被打断。不再因为需要接听重要电话或者使用手机而节约电池寿命。所有这些专用的 VR 设备可能都是基于 Android 的。

低端 VR 的价值

与此同时,Android 和 Google Cardboard 已经出现在我们的手机上,放在我们的口袋里,我们的家里,办公室,甚至我们的学校里。

例如,Google Expeditions 是 Google 的 Cardboard 教育项目(www.google.com/edu/expeditions/),它允许 K-12 学生进行虚拟实地考察,去“校车无法到达的地方”,就像他们所说的,“环游世界,登陆火星表面,潜入珊瑚礁,或者回到过去。”套件包括 Cardboard 观看器和每个班级学生的 Android 手机,以及老师的 Android 平板电脑。它们通过网络连接。老师可以引导学生进行虚拟实地考察,提供增强内容,并创造远远超出教科书或课堂视频的学习体验,如下图所示:

低端 VR 的价值

在另一个创意营销的例子中,2015 年夏天,Kellogg's 开始销售 Nutri-Grain 零食棒,包装盒可以变成 Google Cardboard 观看器。这与一个应用程序相连,显示各种极限运动 360 度视频(www.engadget.com/2015/09/09/cereal-box-vr-headset/),如下图所示:

低端 VR 的价值

整个互联网可以被视为一个全球发布和媒体分发网络。它是一个由超链接页面、文本、图像、音乐、视频、JSON 数据、网络服务等组成的网络。它也充斥着 360 度照片和视频。还有越来越多的三维内容和虚拟世界。你会考虑写一个今天不显示图像的 Android 应用吗?可能不会。你的应用程序很可能也需要支持声音文件、视频或其他媒体。所以请注意。支持 Cardboard 的三维内容正在迅速到来。你现在可能对阅读这本书感兴趣,因为 VR 看起来很有趣。但很快,这可能会成为你下一个应用程序的客户驱动需求。

一些流行的 Cardboard 应用类型的例子包括:

还有更多;成千上万。最受欢迎的应用已经有数十万次下载(Cardboard 演示应用本身已经有数百万次下载)。

本书中的项目是您今天可以自己构建的不同类型的 Cardboard 应用程序的示例。

Cardware!

让我们来看看不同的 Cardboard 设备。种类繁多。

显然,原始的谷歌设计实际上是用硬纸板制成的。制造商也效仿,直接向消费者提供硬纸板 Cardboard 产品,如 Unofficial Cardboard,DODOCase 和 IAmCardboard 等品牌是最早的。

谷歌免费提供规格和原理图(参见www.google.com/get/cardboard/manufacturers/)。例如,2.0 版查看器外壳原理图如下所示:

Cardware!

基本的查看器设计包括一个外壳、两个镜片和一个输入机制。与 Google Cardboard 兼容认证计划表示特定的查看器产品符合谷歌的标准,并且与 Cardboard 应用程序配合良好。

查看器外壳可以由任何材料制成:硬纸板、塑料、泡沫、铝等。它应该轻便,并且能够很好地阻挡环境光。

镜片(I/O 2015 版)是 34 毫米直径的非球面单镜头,视场角为 80 度,还有其他指定参数。

输入触发器(“点击器”)可以是几种替代机制之一。最简单的是没有,用户必须直接用手指触摸智能手机屏幕来触发点击。这可能不太方便,因为手机放在查看器外壳内,但它可以工作。许多查看器只包括一个孔,可以伸进手指。另外,原始的 Cardboard 使用了一个小环状磁铁,固定在查看器外部,由嵌入式圆形磁铁固定在位。用户可以滑动环状磁铁,手机的磁力计会感应到磁场的变化,并被软件识别为“点击”。这种设计并不总是可靠,因为磁力计的位置在手机之间有所不同。此外,使用这种方法,更难以检测“按住”交互,这意味着在您的应用程序中只有一种类型的用户输入“事件”可供使用。

Cardboard 2.0 版引入了一个由导电“条”和粘贴在基于 Cardboard 的“锤子”上的“枕头”构成的按钮输入。当按钮被按下时,用户的体电荷被传递到智能手机屏幕上,就好像他直接用手指触摸屏幕一样。这个巧妙的解决方案避免了不可靠的磁力计解决方案,而是使用了手机的原生触摸屏输入,尽管是间接的。

值得一提的是,由于您的智能手机支持蓝牙,可以使用手持蓝牙控制器与您的 Cardboard 应用程序配对。这不是 Cardboard 规格的一部分,需要一些额外的配置:使用第三方输入处理程序或应用程序内置的控制器支持。下图显示了一个迷你蓝牙控制器:

Cardware!

Cardboard 观众不一定是用硬纸板制成的。塑料观众可能会相对昂贵。虽然它们比硬纸板更坚固,但它们基本上具有相同的设计(组装)。一些设备允许您调整镜片到屏幕的距离和/或您的眼睛之间的距离(IPD 或瞳距)。蔡司 VR One、Homido 和 Sunnypeak 设备是最早流行的设备之一。

一些制造商已经超越了 Cardboard 设计(打趣),创新并不一定符合 Google 的规格,但提供了超越 Cardboard 设计的功能。一个显著的例子是 Wearality 观众(www.wearality.com/),它包括一个拥有专利的 150 度视场(FOV)双菲涅耳透镜。它非常便携,可以像一副太阳镜一样折叠起来。Wearality 观众的预发布版本如下图所示:

Cardware!

配置您的 Cardboard 观众

由于 Cardboard 设备种类繁多,镜片距离、视场、畸变等方面存在差异,Cardboard 应用必须配置为特定设备的属性。Google 也提供了解决方案。每个 Cardboard 观众都配有一个独特的 QR 码和/或 NFC 芯片,您可以扫描以配置该设备的软件。如果您有兴趣校准自己的设备或自定义参数,请查看www.google.com/get/cardboard/viewerprofilegenerator/上的配置文件生成工具。

要将手机配置为特定的 Cardboard 观众,请打开标准的 Google Cardboard 应用,并选择屏幕底部中心部分显示的设置图标,如下图所示:

配置您的 Cardboard 观众

然后将相机对准您特定的 Cardboard 观众的 QR 码:

配置您的 Cardboard 观众

您的手机现在已配置为特定的 Cardboard 观众参数。

为 Cardboard 开发应用

在撰写本书时,Google 为 Cardboard 提供了两个 SDK:

首先让我们考虑 Unity 选项。

使用 Unity

Unity(unity3d.com/)是一款流行的功能齐全的 3D 游戏引擎,支持在多种平台上构建游戏,从 PlayStation 和 XBox 到 Windows 和 Mac(还有 Linux!),再到 Android 和 iOS。

Unity 由许多独立的工具组成,集成到一个统一的可视化编辑器中。它包括用于图形、物理、脚本、网络、音频、动画、UI 等的工具。它包括先进的计算机图形渲染、着色、纹理、粒子和照明,提供各种优化性能和调整图形质量的选项,适用于 2D 和 3D。如果这还不够,Unity 还拥有一个庞大的资产商店,充斥着由其庞大的开发者社区创建的模型、脚本、工具和其他资产。

Cardboard SDK for Unity 提供了一个插件包,您可以将其导入 Unity 编辑器,其中包含预制对象、C#脚本和其他资产。该包为您提供了在虚拟 3D 场景中添加立体摄像机并将项目构建为 Android(和 iOS)上的 Cardboard 应用所需的内容。Unity 计划将 Cardboard SDK 直接集成到引擎中,这意味着通过在构建设置中勾选一个框即可添加对 Cardboard 的支持。

注意

如果您有兴趣了解如何使用 Unity 构建 Cardboard 的 VR 应用程序,请查看 Packt Publishing 的另一本书《Unity 虚拟现实项目》(https://www.packtpub.com/game-development/unity-virtual-reality-projects)。

原生开发

那么,为什么不只是使用 Unity 进行 Cardboard 开发呢?好问题。这取决于您想要做什么。当然,如果您的项目需要 Unity 的所有功能和特性,那就是这样做的方式。

但代价是什么?伟大的力量伴随着伟大的责任(本·帕克说)。学起来很快,但要精通需要一生的时间(围棋大师说)。但说真的,Unity 是一个强大的引擎,可能对许多应用程序来说过于强大。要充分利用,您可能需要额外的建模、动画、关卡设计、图形和游戏机制方面的专业知识。

使用 Unity 构建的 Cardboard 应用程序体积庞大。为 Android 构建的空的 Unity 场景生成一个最小为 23 兆字节的.apk 文件。相比之下,在第二章中我们构建的简单的原生 Cardboard 应用程序.apk 文件骨架 Cardboard 项目不到 1 兆字节。

随着这种庞大的应用程序大小,加载时间可能会很长,可能超过几秒钟。它会影响内存使用和电池使用。除非您已经购买了 Unity Android 许可证,否则您的应用程序总是以Made With Unity启动画面开始。这些可能不是您可以接受的限制。

一般来说,您离硬件越近,您的应用程序性能就会越好。当您直接为 Android 编写时,您可以直接访问设备的功能,对内存和其他资源有更多的控制,并有更多的定制和优化机会。这就是为什么原生移动应用程序往往优于移动 Web 应用程序。

最后,使用原生 Android 和 Java 开发的最好原因可能是最简单的。您现在就想构建一些东西!如果您已经是 Android 开发人员,那就使用您已经知道和喜爱的东西!从这里到那里走最直接的道路。

如果您熟悉 Android 开发,那么 Cardboard 开发将会很自然。使用 Cardboard SDK for Android,您可以使用基于 Jet Brains 的 InteliJ IDEA 的 Android Studio IDE(集成开发环境)进行 Java 编程。

正如我们将在本书中看到的那样,您的 Cardboard Android 应用程序与其他 Android 应用程序一样,包括清单、资源和 Java 代码。与任何 Android 应用程序一样,您将实现一个MainActivity类,但您的类将扩展CardboardActivity并实现CardboardView.StereoRenderer。您的应用程序将利用 OpenGL ES 2.0 图形、着色器和 3D 矩阵数学。它将负责在每一帧更新显示,也就是说,根据用户在特定时间片段所看的方向重新渲染您的 3D 场景。在 VR 中尤为重要,但在任何 3D 图形环境中,都要根据显示器允许的速度重新渲染新的帧,通常为 60 FPS。您的应用程序将通过 Cardboard 触发器和/或凝视控制处理用户输入。我们将在接下来的章节中详细介绍所有这些主题。

这就是您的应用程序需要做的。但是,仍然有更多细枝末节的细节必须处理才能使 VR 工作。正如 Google Cardboard SDK 指南中所指出的(https://developers.google.com/cardboard/android/),SDK 简化了许多这些常见的 VR 开发任务,包括以下内容:

  • 镜头畸变校正

  • 头部跟踪

  • 3D 校准

  • 并排渲染

  • 立体几何配置

  • 用户输入事件处理

SDK 提供了处理这些任务的功能。

构建和部署应用程序进行开发、调试、分析和最终发布到 Google Play 也遵循您可能已经熟悉的相同的 Android 工作流程。这很酷。

当然,构建应用程序不仅仅是简单地按照示例进行。我们将探讨诸如使用数据驱动的几何模型、抽象着色器和 OpenGL ES API 调用以及使用凝视选择构建用户界面元素等技术。除此之外,还有一些重要的建议最佳实践,可以使您的 VR 体验更加流畅,并避免常见的错误。

VR 最佳实践概述

每天都有更多的关于为 VR 设计和开发时的 dos 和 don'ts 的发现和撰写。Google 提供了一些资源,以帮助开发人员构建出色的 VR 体验,包括以下内容:

VR 晕动病是一种真实的症状和虚拟现实中的一个关注点,部分原因是屏幕更新的滞后或延迟,当您移动头部时。您的大脑期望您周围的世界与您的实际运动完全同步变化。任何可察觉的延迟都会让您感到不舒服,至少会让您可能感到恶心。通过更快地渲染每一帧来减少延迟,以保持推荐的每秒帧数。桌面 VR 应用程序要求保持 90FPS 的高标准,由自定义 HMD 屏幕实现。在移动设备上,屏幕硬件通常将刷新率限制在 60FPS,或在最坏的情况下为 30FPS。

VR 晕动病和其他用户不适的原因还有其他,可以通过遵循这些设计准则来减轻:

  • 始终保持头部跟踪。如果虚拟世界似乎冻结或暂停,这可能会让用户感到不适。

  • 在 3D 虚拟空间中显示用户界面元素,如标题和按钮。如果以 2D 形式呈现,它们似乎会“粘在您的脸上”,让您感到不舒服。

  • 在场景之间过渡时,淡出到黑色。切换场景会让人感到非常迷茫。淡出到白色可能会让用户感到不舒服。

  • 用户应该在应用程序内保持对其移动的控制。自己启动摄像机运动的某些东西有助于减少晕动病。尽量避免“人为”旋转摄像机。

  • 避免加速和减速。作为人类,我们感受到加速,但不感受到恒定速度。如果在应用程序内移动摄像机,请保持恒定速度。过山车很有趣,但即使在现实生活中,它们也会让你感到不舒服。

  • 让用户保持稳定。在虚拟空间中漂浮可能会让您感到不适,而感觉自己站在地面上或坐在驾驶舱中则会提供稳定感。

  • 保持 UI 元素(如按钮和准星光标)与眼睛的合理距离。如果物体太近,用户可能需要斜视,并可能会感到眼睛紧张。一些太近的物体可能根本不会汇聚,导致“双重视觉”。

虚拟现实的应用程序在其他方面也不同于传统的 Android 应用程序,例如:

  • 当从 2D 应用程序转换为 VR 时,建议您为用户提供一个头戴式设备图标,用户可以点击,如下图所示:VR 最佳实践概述

  • 要退出 VR,用户可以点击系统栏中的返回按钮(如果有)或主页按钮。Cardboard 示例应用程序使用“向上倾斜”手势返回到主菜单,这是一个很好的方法,如果您想允许“返回”输入而不强迫用户从设备中取出手机。

  • 确保您的应用程序以全屏模式运行(而不是在 Android 的 Lights Out 模式下运行)。

  • 不要执行任何会向用户显示 2D 对话框的 API 调用。用户将被迫从观看设备中取出手机以做出回应。

  • 提供音频和触觉(振动)反馈以传达信息,并指示应用程序已识别用户输入。

所以,假设您已经完成了您的精彩 Cardboard 应用程序,并且准备发布。现在怎么办?您可以在AndroidManifest文件中放入一行标记应用程序为 Cardboard 应用程序。Google 的 Cardboard 应用程序包括一个用于查找 Cardboard 应用程序的 Google Play 商店浏览器。然后,就像为任何普通的 Android 应用程序一样发布它。

摘要

在本章中,我们首先定义了 Google Cardboard,并看到它如何适应消费者虚拟现实设备的范围。然后,我们将 Cardboard 与更高端的 VR 设备进行对比,如 Oculus Rift、HTC Vive 和 PlayStation VR,提出低端 VR 作为一种独立媒介的观点。市场上有各种 Cardboard 观看设备,我们看了如何使用 QR 码为您的观看设备配置智能手机。我们谈了一些关于为 Cardboard 开发的内容,并考虑了使用 Unity 3D 游戏引擎与使用 Cardboard SDK 编写 Java 原生 Android 应用程序的原因和不原因。最后,我们快速调查了开发 VR 时的许多设计考虑因素,我们将在本书中更详细地讨论,包括如何避免晕动病和如何将 Cardboard 与 Android 应用程序整合的技巧。

在下一章中,我们开始编码。耶!为了一个共同的参考点,我们将花一点时间介绍 Android Studio IDE 并审查 Cardboard Android 类。然后,我们将一起构建一个简单的 Cardboard 应用程序,为本书中其他项目的结构和功能奠定基础。

第二章:Cardboard 项目的骨架

在本章中,你将学习如何构建一个 Cardboard 项目的骨架,这可以成为本书中其他项目的起点。我们将首先介绍 Android Studio、Cardboard SDK 和 Java 编程。我们希望确保你对工具和 Android 项目有所了解。然后,我们将指导你设置一个新的 Cardboard 项目,这样我们就不需要在每个项目中重复这些细节。如果这些内容对你来说已经很熟悉了,太好了!你可能可以略过它。在本章中,我们将涵盖以下主题:

  • 一个 Android 应用程序中有什么?

  • Android 项目结构

  • 开始使用 Android Studio

  • 创建一个新的 Cardboard 项目

  • 添加 Cardboard Java SDK

  • 编辑清单、布局和MainActivity

  • 构建和运行应用程序

一个 Android 应用程序中有什么?

对于我们的项目,我们将使用强大的 Android Studio IDE(集成开发环境)来构建在 Android 设备上运行的 Google Cardboard 虚拟现实应用程序。哇哦! Android Studio 在一个平台下整合了许多不同的工具和流程。

开发 Android 应用程序的所有辛勤工作的结果是一个 Android 应用程序包或.apk文件,通过 Google Play 商店或其他方式分发给用户。这个文件会安装在他们的 Android 设备上。

我们马上就会跳到 Android Studio 本身。然而,为了阐明这里发生了什么,让我们先考虑这个最终结果.apk文件。它到底是什么?我们是如何得到它的?了解构建过程将有所帮助。

记住这一点,为了好玩和获得视角,让我们从最后开始,从 APK 文件通过构建管道到我们的应用源代码。

APK 文件

APK 文件实际上是一堆不同文件的压缩包,包括编译后的 Java 代码和非编译资源,比如图片。

APK 文件是为特定的 Android 目标版本构建的,但它也指示了一个最低版本。一般来说,为较旧版本的 Android 构建的应用程序将在更新的 Android 版本上运行,但反之则不然。然而,为较旧版本的 Android 构建意味着新功能将不可用于该应用程序。你需要选择支持你需要的功能的最低 Android 版本,以便能够针对尽可能多的设备。或者,如果出于性能原因,你想要支持较小的设备子集,你可能会选择一个人为设定的较高的最低 API 版本。

在 Android Studio 中构建项目并创建 APK 文件,你需要点击Build 菜单选项并选择Make Project(或者点击绿色箭头图标来构建、部署和在设备上或Android 虚拟设备AVD)中运行应用程序),这将启动 Gradle 构建过程。你可以构建一个版本来开发和调试,或者构建一个更优化的发布版本的应用程序。你可以通过点击Build菜单并选择Select Build Variant...来选择这样做。

Gradle 构建过程

Android Studio 使用一个名为Gradle的工具从项目文件中构建 APK 文件。以下是从 Android 文档中获取的 Gradle 构建过程的流程图(developer.android.com/sdk/installing/studio-build.html)。实际上,大部分图示细节对我们来说并不重要。重要的是看到这么多部分以及它们如何组合在一起。

Gradle 构建过程

在前面图表的最底部方框中,您可以看到构建的结果是一个经过签名和对齐的.apk文件,这是我们应用程序的最终版本,已经从之前的构建过程中编译(从源代码转换)、压缩(压缩)和签名(用于认证)。最后一步,zipalign,将压缩资源沿着 4 字节边界对齐,以便在运行时快速访问它们。基本上,这最后一步使应用程序加载更快。

在图表的中间,您将看到.apk(未签名,未压缩)文件是由.dex文件、编译的 Java 类和其他资源(如图像和媒体文件)组装而成。

.dex文件是 Java 代码,已经编译成在您设备上的Dalvik 虚拟机DVM)上运行的格式(Dalvik 字节码)。这是您程序的可执行文件。您在模块构建中包含的任何第三方库和编译的 Java 源代码文件(.class)都会被转换为.dex文件,以便打包到最终的.apk文件中。

如果这对您来说是新的,不要太在意细节。重要的是,我们将在我们的 Google Cardboard 项目中使用许多不同的文件。了解它们在构建过程中的使用情况将对我们有所帮助。

例如,带有 Cardboard SDK 的common.aar文件(二进制 Android 库存档)是我们将使用的第三方库之一。您项目的res/目录的内容,例如layout/activity_main.xml,会通过Android 资产打包工具(aapt)进行处理。

一个 Java 编译器

.dex文件的输入是什么?Java 编译器将 Java 语言源代码生成包含字节码的.dex文件。通过参考前面的 Gradle 构建流程图,在图表的顶部,您将看到 Java 编译器的输入包括以下内容:

  • 您应用程序的 Java 源代码

  • 您应用程序的 XML 资源,例如使用aapt编译的AndroidManifest.xml文件,并用于生成R.java文件

  • 您的应用程序的 Java 接口(Android 接口定义语言.aidl文件),使用aidl工具编译

在本书的其余部分,我们将大量讨论这些源代码文件。那就是你写的东西!那就是你施展魔法的地方!那就是我们程序员生活的世界。

现在让我们来看看你的 Android 项目源代码的目录结构。

Android 项目结构

您的 Android 项目的根目录包含各种文件和子目录。或者,我应该说,您的 Android 项目的根文件夹包含各种文件和子文件夹哈哈。在本书中,我们将在整个过程中交替使用“文件夹”和“目录”这两个词,就像 Android Studio 似乎也在做的一样(实际上,这是有区别的,如stackoverflow.com/questions/29454427/new-directory-vs-new-folder-in-android-studio中所讨论的那样)。

如 Android 层次结构所示,在以下示例 Cardboard 项目中,根目录包含一个app/子目录,该子目录又包含以下子目录:

  • app/manifests/:这包含了指定应用程序组件(包括活动(UI)、设备权限和其他配置)的AndroidManifest.xml清单文件

  • app/java/:这包含了实现应用程序MainActivity和其他类的应用程序 Java 文件的子文件夹

  • app/res/:这包含了包括布局 XML 定义文件、值定义(strings.xmlstyles.xml等)、图标和其他资源文件在内的资源子文件夹!Android 项目结构

这些目录与前面 Gradle 构建过程图表最上面一行中的方框相对应并不是巧合;它们提供了要通过 Java 编译器运行的源文件。

此外,在根目录下有 Gradle 脚本,不需要直接编辑,因为 Android Studio IDE 提供了方便的对话框来管理设置。在某些情况下,直接修改这些文件可能更容易。

请注意层次结构窗格左上角有一个选项卡选择菜单。在前面的屏幕截图中,它设置为Android,只显示 Android 特定的文件。还有其他视图可能也很有用,比如Project,它列出了项目根目录下的所有文件和子目录,如下一个屏幕截图所示,用于同一个应用程序。Project层次结构显示文件的实际文件系统结构。其他层次结构会人为地重新构造项目,以便更容易处理。

Android 项目结构

提示

您可能需要在Android视图和Project视图之间切换。

开始使用 Android Studio

在为 Android 开发 Cardboard 应用程序时,有很多东西需要跟踪,包括所有文件和文件夹、Java 类和对象、函数和变量。您需要一个正确组织的 Java 程序结构和有效的语言语法。您需要设置选项并管理进程以构建和调试应用程序。哇!

谢天谢地,我们有 Android Studio,一个功能强大的IDE集成开发环境)。它是基于 JetBrains 的 IntelliJ IDEA 构建的,后者是一套受欢迎的智能 Java 开发工具套件。

它是智能的,因为它在您编写代码时实际上会给出相关的建议(Ctrl + Space),帮助在相关引用和文件之间导航(Ctrl + BAlt + F7),并自动执行重构操作,比如重命名类或方法(Alt + Enter)。在某些方面,它可能知道您正在尝试做什么,即使您自己不知道。多么聪明啊!

安装 Android Studio

如果您的开发机器上尚未安装 Android Studio,您还在等什么?前往 Android 开发者页面(developer.android.com/develop/index.html)并将其下载到您的系统。它适用于 Windows、Mac OS X 或 Linux。您可以安装完整的 Android Studio 软件包,而不仅仅是 SDK 工具。然后,遵循安装说明。

Android Studio 用户界面

Android Studio 有很多功能。在大多数情况下,我们将在实例的帮助下进行解释。但让我们花点时间来回顾一些功能,特别是与 Cardboard 开发相关的功能。只要确保在需要时阅读 Android 开发工具页面上提供的文档(developer.android.com/tools/studio/index.html)。

对于初学者来说,Android Studio 的用户界面可能看起来令人生畏。而默认界面只是开始;编辑器主题和布局可以根据您的喜好进行自定义。更糟糕的是,随着新版本的发布,界面往往会发生变化,因此教程可能会显得过时。虽然这可能会使您在特定场合难以找到所需的内容,但基本功能并没有发生太大变化。在大多数情况下,Android 应用程序就是 Android 应用程序。我们在本书中使用的是 Windows 版的 Android Studio 2.1(尽管一些屏幕截图来自早期版本,但界面基本相同)。

注意

在使用 Android Studio 时,您可能会收到新的更新通知。我们建议您不要在项目进行中升级,除非您确实需要新的改进。即便如此,确保您有备份以防兼容性问题。

让我们简要地浏览一下 Android Studio 窗口,如下图所示:

Android Studio 用户界面

Android Studio 的菜单有:

  • 顶部是主菜单栏(#1),其中包含下拉菜单和拉出菜单,几乎包括所有可用功能。

  • 在菜单栏下方是一个方便的主工具栏(#2),其中包含常用功能的快捷方式。将鼠标悬停在图标上会显示工具提示,说明其功能。

  • 工具栏下方是主编辑窗格(#3)。当没有文件打开时,它会显示没有打开的文件。当打开多个文件时,主编辑窗格在顶部有选项卡。

  • 层次结构导航器窗格位于左侧(#4)。

  • 层次结构导航器窗格在左侧有选项卡(垂直选项卡,#5),用于在项目的各种视图之间进行选择。

注意

请注意层次结构窗格左上角的选择菜单。在前面的截图中,它设置为Android,只显示特定于 Android 的文件。还有其他视图可能也很有用,比如项目,它显示项目根目录下的所有文件和子目录,就像前面提到的那样。

  • 底部是另一个工具栏(#6),用于选择您可能需要的其他动态工具,包括终端窗口、构建消息、调试信息,甚至待办事项列表。也许最重要的是 Android Monitor 的logcat选项卡,它提供了一个窗口,用于收集和查看系统调试输出的 Android 日志系统。

注意

对于您来说,注意可调试应用程序下拉菜单、日志级别logcat中的其他过滤器将是有帮助的,以便过滤掉会使您难以找到所需输出的“日志垃圾”。另外,请注意,即使在高端计算机上使用快速 CPU,这个日志视图也会使 Android Studio 变得非常缓慢。建议您在不使用时隐藏此视图,特别是如果您打开了多个 Android Studio 实例。

  • 每个窗格的角落中的控件通常用于管理 IDE 窗格本身。

浏览一下 Android Studio 提供的各种不同功能会很有趣。要了解更多,请单击帮助 | 帮助主题菜单项(或工具栏上的?图标)以打开 IntelliJ IDEA 帮助文档(www.jetbrains.com/idea/help/intellij-idea.html)。

请记住,Android Studio 是建立在 IntelliJ IDE 之上的,它不仅可以用于 Android 开发。因此,这里有很多功能;有些您可能永远不会使用;其他一些您可能需要,但可能需要搜索。

提示

这里有一个建议:伴随着强大的力量而来的是巨大的责任(我以前在哪里听过这句话?)。实际上,对于如此多的用户界面功能,一点点的专注会很有用(是的,我刚刚编造了这句话)。当您需要使用时,专注于您需要使用的功能,不要为其他细节而烦恼。

在我们继续之前,让我们来看一下主菜单栏。它看起来像下面的截图:

Android Studio 用户界面

从左到右阅读,菜单项的组织方式与应用程序开发过程本身有些类似:创建、编辑、重构、构建、调试和管理。

  • 文件:这些是项目文件和设置

  • 编辑:这包括剪切、复制、粘贴和宏选项等

  • 视图:这允许我们查看窗口、工具栏和 UI 模式

  • 导航:这指的是基于内容的文件之间的导航

  • 代码:这些是代码编辑的快捷方式

  • 分析:这用于检查和分析代码中的错误和低效。

  • 重构:用于跨语义相关文件编辑代码

  • 构建:构建项目

  • 运行:用于运行和调试

  • 工具:这是与外部和第三方工具进行交互的界面。

  • VCS:指的是版本控制(即git)命令

  • 窗口:管理 IDE 用户界面

  • 帮助:包括文档和帮助链接

现在,是不是很可怕?

如果您还没有这样做,您可能希望尝试构建来自 Google Developers 网站 Android SDK 入门页面的 Cardboard Android 演示应用程序(参考developers.google.com/cardboard/android/get-started)。

在撰写本书时,演示应用程序称为寻宝,并且有关如何从其 GitHub 存储库克隆项目的说明。只需克隆它,打开 Android Studio,然后点击绿色播放按钮进行构建和运行。入门页面的其余部分将引导您了解解释关键元素的代码。

太酷了!在下一章中,我们将从头开始并重建几乎相同的项目。

创建一个新的 Cardboard 项目

安装了 Android Studio 后,让我们创建一个新项目。这是本书中任何项目都会遵循的步骤。我们只需创建一个空的框架,并确保它可以构建和运行:

  1. 打开 IDE 后,您将看到一个欢迎屏幕,如下图所示:创建一个新的 Cardboard 项目

  2. 选择开始一个新的 Android Studio 项目,然后会出现新项目屏幕,如下所示:创建一个新的 Cardboard 项目

  3. 填写您的应用程序名称,例如Skeleton,和您的公司域,例如cardbookvr.com。您还可以更改项目位置。然后,点击“下一步”:创建一个新的 Cardboard 项目

  4. 在“目标 Android 设备”屏幕上,确保“手机和平板电脑”复选框已选中。在“最低 SDK”中,选择“API 19:Android 4.4(KitKat)”。然后,点击“下一步”:创建一个新的 Cardboard 项目

  5. 在“为移动添加活动”屏幕上,选择“空活动”。我们将从头开始构建这个项目。然后,点击“下一步”:创建一个新的 Cardboard 项目

  6. 保留建议的名称MainActivity。然后,点击“完成”。

您全新的项目将在 Studio 上显示。如果需要,按Alt + 1打开项目视图(Mac 上为Command + 1)。

添加 Cardboard Java SDK

现在是将 Cardboard SDK 库.aar文件添加到您的项目中的好时机。在本书的基本项目中,您需要的库(撰写时为 v0.7)是:

  • common.aar

  • core.aar

注意

请注意,SDK 包括我们在本书中未使用但对您的项目可能有用的其他库。audio.aar文件用于支持空间音频。panowidgetvideowidget库用于希望进入 VR 的 2D 应用程序,例如查看 360 度图像或视频。

在撰写本文时,要获取 Cardboard Android SDK 客户端库,您可以克隆cardboard-java GitHub 存储库,如 Google Developers Cardboard 入门页面上所述的那样,developers.google.com/cardboard/android/get-started#start_your_own_project上的开始您自己的项目主题。通过运行以下命令克隆cardboard-java GitHub 存储库:

git clone https://github.com/googlesamples/cardboard-java.git

要使用与此处使用的相同 SDK 版本 0.7 的确切提交,checkout提交:

git checkout 67051a25dcabbd7661422a59224ce6c414affdbc -b sdk07

或者,SDK 0.7 库文件包含在 Packt Publishing 的每个下载项目的.zip文件中,并且在本书的 GitHub 项目中github.com/cardbookvr

一旦您在本地拥有库的副本,请确保在文件系统中找到它们。要将库添加到我们的项目中,请执行以下步骤:

  1. 对于所需的每个库,创建新模块。在 Android Studio 中,选择文件|新建|新模块...。选择导入.JAR/.AAR 包添加 Cardboard Java SDK

  2. 找到其中一个 AAR 并导入它。添加 Cardboard Java SDK

  3. 通过导航到文件|项目****结构|模块(在左侧)|应用程序(您的应用程序名称)|依赖项|+|模块依赖项,将新模块作为主应用程序的依赖项添加进去:添加 Cardboard Java SDK

现在我们可以在我们的应用程序中使用 Cardboard SDK。

AndroidManifest.xml 文件

新的空应用程序包括一些默认文件,包括manifests/AndroidManifest.xml文件(如果您已激活Android视图。在Project视图中,它在app/src/main)。每个应用程序必须在其清单目录中有一个AndroidManifest.xml文件,告诉 Android 系统运行应用程序代码所需的内容,以及其他元数据。

注意

有关此信息的更多信息,请访问developer.android.com/guide/topics/manifest/manifest-intro.html

让我们首先设置这个。在编辑器中打开您的AndroidManifest.xml文件。修改它以读取如下内容:

<?xml version="1.0" encoding="utf-8"?>
<manifest 
    package="com.cardbookvr.skeleton" >

   <uses-permission android:name="android.permission.NFC" />
   <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.VIBRATE" />

    <uses-sdk android:minSdkVersion="16" 
    android:targetSdkVersion="19"/>
    <uses-feature android:glEsVersion="0x00020000" android:required="true" />
    <uses-feature android:name="android.hardware.sensor.accelerometer" android:required="true"/>
    <uses-feature android:name="android.hardware.sensor.gyroscope" android:required="true"/>

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <activity
            android:name=".MainActivity"

            android:screenOrientation="landscape"
            android:configChanges="orientation|keyboardHidden|screenSize" >

            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
                <category android:name="com.google.intent.category.CARDBOARD" />
            </intent-filter>
        </activity>
    </application>

</manifest>

在前面的清单中显示的软件包名称package="com.cardbookvr.skeleton"可能与您的项目不同。<uses-permission>标签表示项目可能正在使用 NFC 传感器,Cardboard SDK 可以使用该传感器来检测已插入 Cardboard 查看器设备的智能手机。互联网和读/写存储权限是 SDK 下载、读取和写入配置设置选项所需的。我们需要做更多工作来正确处理权限,但这将在另一个文件中进行讨论。

<uses-feature>标签指定我们将使用 OpenGL ES 2.0 图形处理库(developer.android.com/guide/topics/graphics/opengl.html)。

还强烈建议包括加速计和陀螺仪传感器uses-feature标签。太多用户的手机缺少这两个传感器中的一个或两个。当应用程序无法正确跟踪他们的头部运动时,他们可能会认为是应用程序的问题而不是他们的手机的问题。在<application>标签(在创建文件时生成的默认属性)中,有一个名为.MainActivity<activity>定义和屏幕设置。在这里,我们将android:screenOrientation属性指定为我们的 Cardboard 应用程序使用正常(左)横向方向。我们还指定android:configChanges,表示活动将自行处理。

这些和其他属性设置可能会根据您的应用程序要求而变化。例如,使用android:screenOrientation="sensorLandscape"将允许基于手机传感器的正常或反向横向方向(并在屏幕翻转时触发onSurfaceChanged回调)。

我们在<intent-filter>标签中指定了我们的intent元数据。在 Android 中,intent是一种消息对象,用于促进应用程序组件之间的通信。它还可以用于查询已安装的应用程序并匹配某些意图过滤器,如在应用程序清单文件中定义的那样。例如,想要拍照的应用程序将广播一个带有ACTION_IMAGE_CAPTURE动作过滤器的意图。操作系统将响应一个包含可以响应此类动作的活动的已安装应用程序列表。

定义了MainActivity类之后,我们将指定它可以响应标准的MAIN动作并匹配LAUNCHER类别。MAIN表示此活动是应用程序的入口点;也就是说,当您启动应用程序时,将创建此活动。LAUNCHER表示应用程序应该出现在主屏幕的启动器中,作为顶级应用程序。

我们添加了一个意图,以便此活动也匹配CARDBOARD类别,因为我们希望其他应用程序将其视为 Cardboard 应用程序!

Google 在 Android 6.0 Marshmallow(API 23)中对权限系统进行了重大更改。虽然您仍然必须在AndroidManifest.xml文件中包含您想要的权限,但现在您还必须调用一个特殊的 API 函数来在运行时请求权限。这样做有很多原因,但其想法是给用户更精细的控制应用程序权限,并避免在安装和运行时请求长列表的权限。这一新功能还允许用户在授予权限后有选择地撤销权限。这对用户来说很好,但对我们应用程序开发人员来说很不幸,因为这意味着当我们需要访问这些受保护的功能时,我们需要做更多的工作。基本上,您需要引入一个步骤来检查特定权限是否已被授予,并在没有授予时提示用户。一旦用户授予权限,将调用回调方法,然后您可以自由地执行需要权限的任何操作。或者,如果权限一直被授予,您可以继续使用受限功能。

在撰写本文时,我们的项目代码和当前版本的 Cardboard SDK 尚未实现这个新的权限系统。相反,我们将强制 Android Studio 针对较旧版本的 SDK(API 22)构建我们的项目,以便我们绕过新功能。未来,Android 可能会破坏与旧权限系统的向后兼容性。但是,您可以在 Android 文档中阅读有关如何使用新权限系统的非常清晰的指南(参见developer.android.com/training/permissions/requesting.html)。我们希望在在线 GitHub 存储库中解决这个问题和任何未来问题,但请记住,文本中的代码和提供的 zip 文件可能无法在最新版本的 Android 上运行。这就是软件维护的性质。

让我们将这个解决方法应用到针对 SDK 版本 22 的构建中。很可能您刚刚安装了 Android Studio 2.1 或更高版本,其中包含 SDK 23 或更高版本。每当您创建一个新项目时,Android Studio 确实会询问您想要针对哪个最低 SDK 版本,但不会让您选择用于编译的 SDK。这没关系,因为我们可以在build.gradle文件中手动设置这一点。不要害怕;构建工具集很庞大且复杂,但我们只是稍微调整了项目设置。请记住,您的项目中有几个build.gradle文件。每个文件都将位于文件系统中相应的模块文件夹中,并且将在项目视图的 Gradle 脚本部分中相应地标记。我们要修改app模块的build.gradle。将其修改为如下所示:

apply plugin: 'com.android.application'

android {
    compileSdkVersion 22
    ...

    defaultConfig {
        minSdkVersion 19
        targetSdkVersion 22
        ...
    }
    ...
}

dependencies {
    compile 'com.android.support:appcompat-v7:22.1.0'
    ...
}

重要的更改是 compileSdkVersion、minSdkVersion、targetSdkVersion 以及依赖项中的最后一个,在那里我们更改了我们链接到的支持存储库的版本。从技术上讲,我们可以完全消除这种依赖关系,但项目模板包括了一堆对它的引用,这些引用很难删除。然而,如果我们保留默认设置,Gradle 很可能会因为版本不匹配而向我们抱怨。一旦您进行了这些更改,编辑器顶部应该会出现一个黄色的条,上面有一个写着立即同步的链接。立即同步。如果幸运的话,Gradle 同步将成功完成,您就可以继续愉快地进行下去了。如果不幸的话,您可能会缺少 SDK 平台或其他依赖项。消息窗口应该有可点击的链接,可以适当地安装和更新 Android 系统。如果遇到错误,请尝试重新启动 Android Studio。

从这一点开始,您可能希望避免更新 Android Studio 或您的 SDK 平台版本。特别注意当您在另一台计算机上导入项目或在更新 Android Studio 后发生的情况。您可能需要让 IDE 操作您的 Gradle 文件,并且它可能会修改您的编译版本。这个权限问题很隐蔽,它只会在运行时在运行 6.0 及以上版本的手机上显露出来。您的应用程序可能在运行旧版本的 Android 的设备上看起来运行良好,但实际上在新设备上可能会遇到麻烦。

activity_main.xml 文件

我们的应用程序需要一个布局,我们将在其中定义一个画布来绘制我们的图形。Android Studio 创建的新项目在app/res/layout/文件夹中创建了一个默认的布局文件(使用 Android 视图或app/src/main/res/layout使用项目视图)。找到activity_main.xml文件并双击打开进行编辑。

在 Android Studio 编辑器中,布局文件有两种视图:设计文本,通过窗格左下角的选项卡进行选择。如果选择了设计视图选项卡,您将看到一个交互式编辑器,其中包括一个模拟的智能手机图像,左侧是 UI 组件的调色板,右侧是属性编辑器。我们不会使用这个视图。如果需要,选择activity_main.xml编辑窗格底部的文本选项卡以使用文本模式。

Cardboard 应用程序应该在全屏上运行,因此我们会删除任何填充。我们还将删除默认的我们不打算使用的TextView。而是用CardboardView来替换它,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.google.vrtoolkit.cardboard.CardboardView
        android:id="@+id/cardboard_view"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:layout_alignParentTop="true"
        android:layout_alignParentLeft="true" />

</RelativeLayout>

AndroidManifest.xml文件引用了名为MainActivity的主要活动。现在让我们来看看。

MainActivity 类

使用Empty Activity生成的默认项目还创建了一个默认的MainActivity.java文件。在层次结构窗格中,找到包含名为com.cardbookvr.skeleton的子目录的app/java/目录。

注意

请注意,这与androidTest版本的目录不同,我们不使用那个!(根据您创建项目时给定的实际项目和域名,您的名称可能会有所不同。)

在这个文件夹中,双击MainActivity.java文件以进行编辑。默认文件如下所示:

package com.cardbookvr.skeleton;

import ...

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

您应该注意到的第一件事是扩展AppCompatActivity类(或ActionBarActivity)以使用内置的 Android 操作栏。我们不需要这个。我们将把活动定义为扩展CardboardActivity并实现CardboardView.StereoRenderer接口。修改代码中的类声明行,如下所示:

public class MainActivity extends CardboardActivity implements CardboardView.StereoRenderer {

由于这是一个 Google Cardboard 应用程序,我们需要将MainActivity类定义为 SDK 提供的CardboardActivity类的子类。我们使用extends关键字来实现这一点。

MainActivity还需要实现至少一个被定义为CardboardView.StereoRender的立体渲染器接口。我们使用implements关键字来实现这一点。

Android Studio 的一个好处是在编写代码时为你自动完成工作。当你输入extends CardboardActivity时,IDE 会自动在文件顶部添加CardboardActivity类的import语句。当你输入implements CardboardView.StereoRenderer时,它会添加一个import语句到CardboardView类。

随着我们继续添加代码,Android Studio 将识别出我们需要额外的导入语句,并自动为我们添加它们。因此,我不会在接下来的代码中显示import语句。偶尔它可能会找到错误的引用,例如,在你的库中有多个CameraMatrix类时,你需要将其解析为正确的引用。

现在我们将用一些函数存根填充MainActivity类的主体,这些函数是我们将需要的。我们使用的CardboardView.StereoRenderer接口定义了许多抽象方法,我们可以重写这些方法,如 Android API 参考中对该接口的文档所述(参见developers.google.com/cardboard/android/latest/reference/com/google/vrtoolkit/cardboard/CardboardView.StereoRenderer)。

在 Studio 中可以通过多种方式快速完成。可以使用智能感知上下文菜单(灯泡图标)或转到代码 | 实现方法…(或Ctrl + I)。将光标放在红色错误下划线处,按Alt + Enter,你也可以达到同样的目标。现在就做吧。系统会要求你确认要实现的方法,如下面的截图所示:

MainActivity 类

确保所有都被选中,然后点击确定

以下方法的存根将被添加到MainActivity类中:

  • onSurfaceCreated:在表面被创建或重新创建时调用此方法。它应该创建需要显示图形的缓冲区和变量。

  • onNewFrame:在准备绘制新帧时调用此方法。它应该更新从一个帧到下一个帧变化的应用程序数据,比如动画。

  • onDrawEye:为当前相机视点渲染一个眼睛的场景(每帧调用两次,除非你有三只眼睛!)。

  • onFinishFrame:在帧完成之前调用此方法。

  • onRenderShutdown:当渲染器线程关闭时调用此方法(很少使用)。

  • onSurfaceChanged:当表面尺寸发生变化时(例如检测到纵向/横向旋转)调用此方法。

我按照 Cardboard Android 应用程序的生命周期顺序列出了这些方法。

@Override指令表示这些函数最初是在CardboardView.StereoRenderer接口中定义的,我们在这里的MainActivity类中替换(覆盖)它们。

默认的onCreate

所有 Android 活动都公开一个onCreate()方法,在活动第一次创建时调用。这是你应该做所有正常的静态设置和绑定的地方。立体渲染器接口和 Cardboard 活动类是 Cardboard SDK 的基础。

默认的onCreate方法对父活动进行了标准的onCreate调用。然后,它将activity_main布局注册为当前内容视图。

通过添加CardboadView实例来编辑onCreate(),如下所示:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        CardboardView cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
        cardboardView.setRenderer(this);
        setCardboardView(cardboardView);
    }

为了设置应用程序的CardboardView实例,我们通过在activity_main.xml中给定的资源 ID 查找其实例,然后使用一些函数调用设置它。

这个对象将对显示进行立体渲染,所以我们调用setRenderer(this)来指定它作为StereoRenderer接口方法的接收者。

注意

请注意,您的活动不必实现该接口。您可以让任何类定义这些方法,比如我们将在本书后面看到的抽象渲染器。

然后我们通过调用setCardboardView(cardboardView)CardboardView类与这个活动关联起来,这样我们就能接收到任何必需的生命周期通知,包括StereoRenderer接口方法,比如onSurfaceCreatedonDrawEye

构建和运行

让我们构建并运行它:

  1. 转到Run | Run 'app',或者简单地使用工具栏上的绿色三角形Run图标。

  2. 如果您进行了更改,Gradle 将进行构建。

  3. 选择 Android Studio 窗口底部的Gradle Console选项卡以查看 Gradle 构建消息。然后,假设一切顺利,APK 将安装在您连接的手机上(连接并打开了吗?)。

  4. 选择底部的Run选项卡以查看上传和启动消息。

您不应该收到任何构建错误。但当然,该应用实际上并没有做任何事情或在屏幕上绘制任何东西。嗯,这并不完全正确!通过CardboardView.StereoRenderer,Cardboard SDK 提供了一个带有垂直线和齿轮图标的立体分屏,如下截图所示:

构建和运行

垂直线将用于在 Cardboard 查看器设备上正确放置您的手机。

齿轮图标打开标准配置设置实用程序,其中包括扫描 QR 码以配置 SDK 以适应镜片和您特定设备的其他物理属性的功能(如第一章中所解释的,“每个人的虚拟现实”,在“配置 Cardboard 查看器”部分)。

现在,我们已经为 Android 构建了一个 Google Cardboard 应用的框架。您将遵循类似的步骤来启动本书中的每个项目。

摘要

在本章中,我们研究了 Android 上 Cardboard 应用的结构以及涉及的许多文件,包括 Java 源代码、XML 清单、.aar库和最终构建的 APK,该 APK 在您的 Android 设备上运行。我们安装并简要介绍了 Android Studio 开发环境。然后,我们将引导您完成创建新的 Android 项目、添加 Cardboard Java SDK 以及定义AndroidManifest.xml文件和布局,以及一个存根的MainActivity Java 类文件的步骤。在本书中,您将遵循类似的步骤来启动每个 Cardboard 项目。

在下一章中,我们将从头开始构建一个名为CardboardBox的 Google Cardboard 项目,其中包含一些简单几何图形(三角形和立方体)、3D 变换和渲染图形到您的 Cardboard 设备的着色器。

第三章:Cardboard Box

还记得小时候开心地玩纸板盒吗?这个项目甚至可能比那更有趣!我们的第一个 Cardboard 项目将是一个简单的场景,有一个盒子(一个几何立方体),一个三角形,还有一点用户交互。我们称之为“CardboardBox”。懂了吗?

具体来说,我们将创建一个新项目,构建一个简单的应用程序,只绘制一个三角形,然后增强该应用程序以绘制阴影的 3D 立方体,并通过在观察时突出显示立方体来说明一些用户交互。

在本章中,您将会:

  • 创建一个新的 Cardboard 项目

  • 向场景添加三角形对象,包括几何、简单着色器和渲染缓冲区

  • 使用 3D 相机、透视和头部旋转

  • 使用模型变换

  • 制作和绘制立方体对象

  • 添加光源和阴影

  • 旋转立方体

  • 添加地板

  • 突出显示用户正在查看的对象

本章中的项目源自 Google Cardboard 团队提供的一个示例应用程序,名为寻宝游戏。最初,我们考虑让您简单地下载寻宝游戏,然后我们会在代码中引导您解释其工作原理。相反,我们决定从头开始构建一个类似的项目,并在进行过程中进行解释。这也减轻了谷歌在本书出版后更改或甚至替换该项目的可能性。

该项目的源代码可以在 Packt Publishing 网站和 GitHub 上找到,网址为github.com/cardbookvr/cardboardbox(每个主题作为单独的提交)。

Android SDK 版本对于您的成品应用程序很重要,但您的桌面环境也可以以多种方式设置。我们之前提到,我们使用 Android Studio 2.1 构建了本书中的项目。我们还使用了 Java SDK 版本 8(1.8)。对于您来说,安装这个版本很重要(您可以并排安装许多版本),以便导入项目。与任何开发环境一样,对 Java 或 Android Studio 所做的任何更改可能会在将来“破坏”导入过程,但实际的源代码应该可以编译和运行多年。

创建一个新项目

如果您想了解有关这些步骤的更多详细信息和解释,请参考第二章中的创建新的 Cardboard 项目部分,骨架 Cardboard 项目,并跟随那里进行:

  1. 打开 Android Studio,创建一个新项目。让我们将其命名为CardboardBox,并针对Android 4.4 KitKat (API 19)使用空活动

  2. 将 Cardboard SDK 的common.aarcore.aar库文件作为新模块添加到项目中,使用文件 | 新建 | 新建模块...

  3. 将库模块设置为项目应用程序的依赖项,使用文件 | 项目结构

  4. 根据第二章中的说明编辑AndroidManifest.xml文件,骨架 Cardboard 项目,要小心保留此项目的package名称。

  5. 根据第二章中的说明编辑build.gradle文件,骨架 Cardboard 项目,以便编译 SDK 22。

  6. 根据第二章中的说明编辑activity_main.xml布局文件,骨架 Cardboard 项目

  7. 编辑MainActivity Java 类,使其extends CardboardActivityimplement CardboardView.StereoRenderer。修改类声明行如下:

public class MainActivity extends CardboardActivity implements CardboardView.StereoRenderer {
  1. 添加接口的存根方法覆盖(使用智能实现方法或按下Ctrl + I)。

  2. MainActivity类的顶部,添加以下注释作为我们将在此项目中创建的变量的占位符:

CardboardView.StereoRenderer {
   private static final String TAG = "MainActivity";

   // Scene variables
   // Model variables
   // Viewing variables
   // Rendering variables
  1. 最后,通过以下方式编辑onCreate(),添加CardboadView实例:
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        CardboardView cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
        cardboardView.setRenderer(this);
        setCardboardView(cardboardView);  
    }

你好,三角形!

让我们在场景中添加一个三角形。是的,我知道三角形甚至不是一个盒子。然而,我们将从非常简单的提示开始。三角形是所有 3D 图形的基本构件,也是 OpenGL 可以渲染的最简单的形状(即以三角形模式)。

引入几何图形

在继续之前,让我们谈谈几何图形。

虚拟现实在很大程度上是关于创建 3D 场景。复杂的模型被组织为具有顶点、面和网格的三维数据,形成可以按层次组装成更复杂模型的对象。目前,我们采用了一个非常简单的方法——一个由三个顶点组成的三角形,存储为一个简单的 Java 数组。

三角形由三个顶点组成(这就是为什么它被称为三角形!)。我们将把我们的三角形定义为顶部(0.0, 0.6),左下角(-0.5, -0.3),右下角(0.5, -0.3)。第一个顶点是三角形的最顶点,具有X=0.0,因此它位于中心,Y=0.6向上。

顶点的顺序或三角形的绕组非常重要,因为它指示了三角形的正面方向。OpenGL 驱动程序希望它以逆时针方向绕组,如下图所示:

引入几何图形

如果顶点是顺时针定义的,着色器将假定三角形朝向相反方向,远离摄像头,因此不可见且不会被渲染。这是一种优化称为剔除,它允许渲染管线轻松丢弃在物体背面的几何图形。也就是说,如果对摄像头不可见,甚至不要尝试绘制它。话虽如此,您可以设置各种剔除模式,选择只渲染正面、背面或两者。

请参阅learnopengl.com/#!Advanced-OpenGL/Face-culling上的创作共用来源。

提示

OpenGL 编程指南,作者 Dave Shreiner,Graham Sellers,John M. Kessenich,Bill Licea-Kane,“按照惯例,在屏幕上顶点逆时针顺序出现的多边形被称为正面朝向”。这是由全局状态模式确定的,默认值为GL_CCWwww.opengl.org/wiki/Face_Culling)。

三维点或顶点是用xyz坐标值定义的。例如,在 3D 空间中,三角形由三个顶点组成,每个顶点都有xyz值。

我们的三角形位于与屏幕平行的平面上。当我们在场景中添加 3D 视图(本章后面会介绍)时,我们需要一个z坐标将其放置在 3D 空间中。为了预期,我们将三角形设置在Z=-1平面上。OpenGL 中的默认摄像头位于原点(0,0,0),并朝着负z轴方向。换句话说,场景中的物体朝着正z轴向摄像头看。我们将三角形放置在离摄像头一单位远的地方,这样我们就可以在Z=-1.0处看到它。

三角形变量

将以下代码片段添加到MainActivity类的顶部:

    // Model variables
    private static final int COORDS_PER_VERTEX = 3;
    private static float triCoords[] = {
        // in counter-clockwise order
        0.0f,  0.6f, -1.0f, // top
       -0.5f, -0.3f, -1.0f, // bottom left
        0.5f, -0.3f, -1.0f  // bottom right
    };

    private final int triVertexCount = triCoords.length / COORDS_PER_VERTEX;
    // yellow-ish color
    private float triColor[] = { 0.8f, 0.6f, 0.2f, 0.0f }; 
    private FloatBuffer triVerticesBuffer;

我们的三角形坐标被分配给triCoords数组。所有顶点都在 3D 空间中,每个顶点(COORDS_PER_VERTEX)有三个坐标(xyz)。预先计算的triVertexCount变量是三角形triCoords数组的长度,除以COORDS_PER_VERTEX。我们还为我们的三角形定义了一个任意的triColor值,由 R、G、B 和 A 值(红色、绿色、蓝色和 alpha(透明度))组成。triVerticesBuffer变量将在绘制代码中使用。

对于刚接触 Java 编程的人来说,你可能也会对变量类型感到困惑。整数声明为 int,浮点数声明为 float。这里的所有变量都被声明为 private,这意味着它们只能在这个类定义内部可见和使用。被声明为 static 的变量将在类的多个实例之间共享数据。被声明为 final 的变量是不可变的,一旦初始化就不会改变。

onSurfaceCreated

这个活动代码的目的是在 Android 设备显示器上绘制东西。我们通过 OpenGL 图形库来实现这一点,它会绘制到一个表面上,一个内存缓冲区,你可以通过渲染管线绘制图形。

活动创建后(onCreate),会创建一个表面并调用 onSurfaceCreated。它有几个责任,包括初始化场景和编译着色器。它还通过为顶点缓冲区分配内存、绑定纹理和初始化渲染管线句柄来准备渲染。

这是一个方法,我们将把它分成几个私有方法,接下来我们将编写这些方法:

    @Override
    public void onSurfaceCreated(EGLConfig eglConfig) {
        initializeScene();
        compileShaders();
        prepareRenderingTriangle();
    }

在这一点上,场景中没有什么需要初始化的:

private void initializeScene() {
}

让我们继续讨论着色器和渲染。

介绍 OpenGL ES 2.0

现在是介绍 图形管线 的好时机。当 Cardboard 应用在屏幕上绘制 3D 图形时,它会将渲染交给一个单独的图形处理器(GPU)。Android 和我们的 Cardboard 应用使用 OpenGL ES 2.0 标准图形库。

OpenGL 是应用程序与图形驱动程序交互的规范。你可以说它是一长串在图形硬件中执行操作的函数调用。硬件供应商编写他们的驱动程序以符合最新的规范,而一些中间件,比如 Google,在这种情况下创建了一个库,它连接到驱动程序函数,以提供你可以从任何语言中调用的方法签名(通常是 Java、C++ 或 C#)。

OpenGL ES 是 OpenGL 的移动版,也称为嵌入式系统。它遵循与 OpenGL 相同的设计模式,但其版本历史非常不同。不同版本的 OpenGL ES 甚至同一版本的不同实现都需要不同的方法来绘制 3D 图形。因此,你的代码在 OpenGL ES 1.0、2.0 和 3.0 之间可能会有很大的不同。值得庆幸的是,大部分重大变化发生在版本 1 和 2 之间,Cardboard SDK 设定为使用 2.0。CardboardView 接口也与普通的 GLSurfaceView 稍有不同。

在屏幕上绘制图形,OpenGL 需要两个基本的东西:

  • 定义如何绘制形状的图形程序,或 着色器(有时可以互换使用)

  • 定义正在绘制的数据,或 缓冲区

还有一些参数,用于指定变换矩阵、颜色、向量等。你可能熟悉游戏循环的概念,这是一种设置游戏环境并启动一个循环的基本模式,该循环运行一些游戏逻辑,渲染屏幕,并在半规律的时间间隔内重复,直到游戏暂停或程序退出。CardboardView 为我们设置了游戏循环,基本上,我们只需要实现接口方法。

关于着色器的更多信息:至少我们需要一个顶点着色器和一个片段着色器。顶点着色器负责将对象的顶点从世界空间(它们在世界中的位置)转换到屏幕空间(它们应该在屏幕上绘制的位置)。

片段着色器在形状占据的每个像素上调用(由光栅函数确定),并返回绘制的颜色。每个着色器都是一个单一的函数,伴随着一些可以用作输入的属性。

OpenGL 将一组函数(即顶点和片段)编译成一个程序。有时,整个程序被称为着色器,但这是一种俚语,假设需要多个函数或着色器才能完全绘制一个对象。程序及其所有参数的值有时会被称为材质,因为它完全描述了它绘制的表面的材质。

着色器很酷。但是,在程序设置数据缓冲区并进行大量绘制调用之前,它们不会做任何事情。

绘制调用由顶点缓冲对象VBO)、将用于绘制的着色器、指定应用于对象的变换的参数数量、用于绘制的纹理和任何其他着色器参数组成。

VBO 是指用于描述对象形状的任何和所有数据。一个非常基本的对象(例如三角形)只需要一个顶点数组。顶点按顺序读取,每三个空间位置定义一个三角形。稍微更高级的形状使用顶点数组和索引数组,定义了以什么顺序绘制哪些顶点。使用索引缓冲区,可以重复使用多个顶点。

虽然 OpenGL 可以绘制多种形状类型(点、线、三角形和四边形),但我们假设所有形状都是三角形。这既是性能优化,也是方便之处。如果我们想要一个四边形,我们可以绘制两个三角形。如果我们想要一条线,我们可以绘制一个非常长而细的四边形。如果我们想要一个点,我们可以绘制一个微小的三角形。这样,不仅可以将 OpenGL 保留在三角形模式下,还可以以完全相同的方式处理所有 VBO。理想情况下,您希望您的渲染代码完全不受其渲染对象的影响。

总结:

  • OpenGL 图形库的目的是让我们访问 GPU 硬件,然后根据场景中的几何图形在屏幕上绘制像素。这是通过渲染管线实现的,其中数据经过一系列着色器的转换和传递。

  • 着色器是一个小程序,它接受某些输入并生成相应的输出,具体取决于管线的阶段。

  • 作为一个程序,着色器是用一种特殊的类似于 C 的语言编写的。源代码经过编译后可以在 Android 设备的 GPU 上高效运行。

例如,顶点着色器处理单个顶点的处理,输出每个顶点的变换版本。另一个步骤是对几何图形进行光栅化,之后片段着色器接收光栅片段并输出彩色像素。

注意

我们将在后面讨论 OpenGL 渲染管线,并且您可以在www.opengl.org/wiki/Rendering_Pipeline_Overview上阅读相关内容。

您还可以在developer.android.com/guide/topics/graphics/opengl.html上查看 Android OpenGL ES API 指南。

暂时不要太担心这个问题,让我们跟着走就好。

注意:GPU 驱动程序实际上是根据每个驱动程序来实现整个 OpenGL 库的。这意味着 NVIDIA(或在这种情况下,可能是 Qualcomm 或 ARM)的某个人编写了编译您的着色器和读取您的缓冲区的代码。OpenGL 是关于这个 API 应该如何工作的规范。在我们的情况下,这是 Android 的 GL 类的一部分。

简单着色器

现在,我们将在MainActivity类的末尾添加以下函数。

   /**
     * Utility method for compiling a OpenGL shader.
     *
     * @param type - Vertex or fragment shader type.
     * @param resId - int containing the resource ID of the shader code file.
     * @return - Returns an id for the shader.
     */
    private int loadShader(int type, int resId){
        String code = readRawTextFile(resId);
        int shader = GLES20.glCreateShader(type);

        // add the source code to the shader and compile it
        GLES20.glShaderSource(shader, code);
        GLES20.glCompileShader(shader);

        return shader;
    }

    /**
     * Converts a raw text file into a string.
     *
     * @param resId The resource ID of the raw text file about to be turned into a shader.
     * @return The content of the text file, or null in case of error.
     */
    private String readRawTextFile(int resId) {
        InputStream inputStream = getResources().openRawResource(resId);
        try {
            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line).append("\n");
            }
            reader.close();
            return sb.toString();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

我们将调用loadShader来加载一个着色器程序(通过readRawTextFile)并对其进行编译。这段代码在其他项目中也会很有用。

现在,我们将在res/raw/simple_vertex.shaderres/raw/simple_fragment.shader文件中编写一些简单的着色器。

在 Android Studio 左侧的项目文件层次结构视图中,定位app/res/资源文件夹,右键单击它,转到新建 | Android 资源目录。在新资源目录对话框中,从资源类型:中选择Raw,然后单击确定

右键单击新的raw文件夹,转到新建 | 文件,并将其命名为simple_vertex.shader。添加以下代码:

attribute vec4 a_Position;
void main() {
    gl_Position = a_Position;
}

同样,对于片段着色器,右键单击raw文件夹,转到新建 | 文件,并将其命名为simple_fragment.shader。添加以下代码:

precision mediump float;
uniform vec4 u_Color;
void main() {
    gl_FragColor = u_Color;
}

基本上,这些是恒等函数。顶点着色器通过给定的顶点,片段着色器通过给定的颜色。

注意我们声明的参数的名称:simple_vertex中的属性名为a_Positionsimple_fragment中的统一变量名为u_Color。我们将从MainActivity onSurfaceCreated方法中设置这些属性。属性是每个顶点的属性,当我们为它们分配缓冲区时,它们必须都是相等长度的数组。您将遇到的其他属性是顶点法线、纹理坐标和顶点颜色。统一变量将用于指定适用于整个材质的信息,例如在这种情况下,应用于整个表面的固体颜色。

另外,注意gl_FragColorgl_Position变量是 OpenGL 正在寻找你设置的内置变量名称。把它们想象成着色器函数的返回值。还有其他内置的输出变量,我们稍后会看到。

compileShaders 方法

现在我们准备实现onSurfaceCreated调用的compileShaders方法。

MainActivity的顶部添加以下变量:

    // Rendering variables
    private int simpleVertexShader;
    private int simpleFragmentShader;

实现compileShaders,如下:

    private void compileShaders() {
        simpleVertexShader = loadShader(GLES20.GL_VERTEX_SHADER, R.raw.simple_vertex);
        simpleFragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, R.raw.simple_fragment);
    }

prepareRenderingTriangle 方法

onSurfaceCreated方法通过为顶点缓冲区分配内存,创建 OpenGL 程序和初始化渲染管道句柄来准备渲染。现在我们将为我们的三角形形状执行此操作。

MainActivity的顶部添加以下变量:

    // Rendering variables
    private int triProgram;
    private int triPositionParam;
    private int triColorParam;

以下是函数的框架:

    private void prepareRenderingTriangle() {
        // Allocate buffers
        // Create GL program
        // Get shader params
    }

我们需要准备一些内存缓冲区,当每帧被渲染时,它们将被传递给 OpenGL。这是我们的三角形和简单着色器的第一次尝试;现在我们只需要一个顶点缓冲区:

        // Allocate buffers
        // initialize vertex byte buffer for shape coordinates (4 bytes per float)
        ByteBuffer bb = ByteBuffer.allocateDirect(triCoords.length * 4);
        // use the device hardware's native byte order
        bb.order(ByteOrder.nativeOrder());

        // create a floating point buffer from the ByteBuffer
        triVerticesBuffer = bb.asFloatBuffer();
        // add the coordinates to the FloatBuffer
        triVerticesBuffer.put(triCoords);
        // set the buffer to read the first coordinate
        triVerticesBuffer.position(0);

这五行代码导致了triVerticesBuffer值的设置,如下所示:

  • 分配一个足够大的ByteBuffer,以容纳我们的三角形坐标值。

  • 二进制数据被排列以匹配硬件的本机字节顺序

  • 为浮点格式化缓冲区,并将其分配给我们的FloatBuffer顶点缓冲区

  • 三角形数据被放入其中,然后我们将缓冲区光标位置重置到开头

接下来,我们构建 OpenGL ES 程序可执行文件。使用glCreateProgram创建一个空的 OpenGL ES 程序,并将其 ID 分配为triProgram。这个 ID 也将在其他方法中使用。我们将任何着色器附加到程序中,然后使用glLinkProgram构建可执行文件:

        // Create GL program
        // create empty OpenGL ES Program
        triProgram = GLES20.glCreateProgram();
        // add the vertex shader to program
        GLES20.glAttachShader(triProgram, simpleVertexShader);
        // add the fragment shader to program
        GLES20.glAttachShader(triProgram, simpleFragmentShader);
        // build OpenGL ES program executable
        GLES20.glLinkProgram(triProgram);
        // set program as current
        GLES20.glUseProgram(triProgram);

最后,我们获得了渲染管道的句柄。调用glGetAttribLocationa_Position检索顶点缓冲区参数的位置,glEnableVertexAttribArray允许访问它,并调用glGetUniformLocationu_Color检索颜色组件的位置。一旦我们到达onDrawEye,我们会很高兴我们这样做了:

        // Get shader params
        // get handle to vertex shader's a_Position member
        triPositionParam = GLES20.glGetAttribLocation(triProgram, "a_Position");
        // enable a handle to the triangle vertices
        GLES20.glEnableVertexAttribArray(triPositionParam);
        // get handle to fragment shader's u_Color member
        triColorParam = GLES20.glGetUniformLocation(triProgram, "u_Color");

因此,我们在这个函数中隔离了准备绘制三角形模型所需的代码。首先,它为顶点设置了缓冲区。然后,它创建了一个 GL 程序,附加了它将使用的着色器。然后,我们获得了在着色器中使用的参数的句柄,用于绘制。

onDrawEye

准备,设置和开始! 如果您认为我们迄今为止所写的内容是“准备就绪”部分,那么现在我们要做“开始”部分! 也就是说,应用程序启动并创建活动,调用onCreate。 创建表面并调用onSurfaceCreated来设置缓冲区和着色器。 现在,随着应用程序的运行,每帧都会更新显示。 开始吧!

CardboardView.StereoRenderer接口委托这些方法。 我们可以处理onNewFrame(稍后会处理)。 现在,我们只需实现onDrawEye方法,该方法将从眼睛的角度绘制内容。 此方法将被调用两次,每只眼睛一次。

现在,onDrawEye所需要做的就是渲染我们可爱的三角形。 尽管如此,我们将其拆分为一个单独的函数(稍后会有意义):

    @Override
    public void onDrawEye(Eye eye) {
        drawTriangle();
    }

    private void drawTriangle() {
        // Add program to OpenGL ES environment
        GLES20.glUseProgram(triProgram);

        // Prepare the coordinate data
        GLES20.glVertexAttribPointer(triPositionParam, COORDS_PER_VERTEX,
                GLES20.GL_FLOAT, false, 0, triVerticesBuffer);

        // Set color for drawing
        GLES20.glUniform4fv(triColorParam, 1, triColor, 0);

        // Draw the model
        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, triVertexCount);
    }

我们需要通过调用glUseProgram来指定我们使用的着色器程序。 调用glVertexAttribPointer将我们的顶点缓冲区设置到管道中。 我们还使用glUniform4fv来设置颜色(4fv指的是我们的统一变量是一个具有四个浮点数的向量)。 然后,我们使用glDrawArrays来实际绘制。

构建和运行

就是这样。 耶哈! 这并不那么糟糕,是吧? 实际上,如果您熟悉 Android 开发和 OpenGL,您可能已经轻松完成了这一步。

让我们构建并运行它。 转到运行 | 运行'app',或者只需使用工具栏上的绿色三角形运行图标。

Gradle 将执行其构建操作。 选择 Android Studio 窗口底部的Gradle 控制台选项卡以查看 Gradle 构建消息。 然后,假设一切顺利,APK 文件将安装在您连接的手机上(连接并打开了,对吧?)。 选择底部的运行选项卡以查看上传和启动消息。

这就是它显示的内容:

构建和运行

实际上,它看起来有点像万圣节南瓜雕刻! 阴森。 但在 VR 中,您只会看到一个单独的三角形。

请注意,虽然三角形的顶点坐标定义了直线边缘,但CardboardView以桶形畸变呈现它,以补偿头盔中的透镜光学。 此外,左图像与右图像不同,每只眼睛一个。 当您将手机插入 Google Cardboard 头盔时,左右立体视图将显示为一个三角形,浮在空间中,边缘笔直。

太棒了! 我们刚刚从头开始为 Android 构建了一个简单的 Cardboard 应用程序。 与任何 Android 应用程序一样,需要定义许多不同的部分才能使基本功能正常运行,包括AndroidManifest.xmlactivity_main.xmlMainActivity.java文件。

希望一切都按计划进行。 像一个好的程序员一样,您可能在进行增量更改后构建和运行应用程序,以解决语法错误和未处理的异常。 稍后,我们将调用 GLError 函数来检查来自 OpenGL 的错误信息。 一如既往,要密切关注 logcat 中的错误(尝试过滤正在运行的应用程序)和变量名称。 您的着色器可能存在语法错误,导致编译失败,或者在尝试访问句柄时,属性/统一名称可能存在拼写错误。 这些问题不会导致任何编译时错误(着色器在运行时编译),并且您的应用程序将运行,但可能不会渲染任何内容。

3D 相机,透视和头部旋转

尽管这很棒(哈哈),但我们的应用有点无聊,不太像 Cardboard。 具体来说,它是立体的(双视图)并具有透镜畸变,但它还不是 3D 透视视图,也不会随着您的头部移动。 我们现在要修复这个问题。

欢迎来到矩阵

在谈论为虚拟现实开发时,我们不能不谈论用于 3D 计算机图形的矩阵数学。

什么是矩阵?答案就在那里,Neo,它正在寻找你,如果你愿意,它会找到你。没错,是时候了解矩阵了。一切都将不同。你的视角即将改变。

我们正在构建一个三维场景。空间中的每个位置由 X、Y 和 Z 坐标描述。场景中的物体可以由 X、Y 和 Z 顶点构成。通过移动、缩放和/或旋转其顶点,可以对物体进行变换。这种变换可以用一个包含 16 个浮点值的矩阵来数学表示(每行四个浮点数)。

矩阵可以通过相乘来组合。例如,如果你有一个表示对象缩放(比例)的矩阵和另一个用于重新定位(平移)的矩阵,那么你可以通过将两者相乘来创建第三个矩阵,表示缩放和重新定位。但是,你不能只使用原始的*运算符。另外,需要注意的是,与简单的标量乘法不同,矩阵乘法不是可交换的。换句话说,我们知道a * b = b * a。然而,对于矩阵 A 和 B,AB ≠ BA!Matrix Android 类库提供了执行矩阵运算的函数。以下是一个例子:

// allocate the matrix arrays
float scale[] = new float[16];
float translate[] = new float[16];
float scaleAndTranslate[] = new float[16];

// initialize to Identity
Matrix.setIdentityM(scale, 0);
Matrix.setIdentityM(translate, 0);

// scale by 2, move by 5 in Z
Matrix.scaleM(scale, 0, 2.0, 2.0, 2.0);
Matrix.translateM(translate, 0, 0, 0.0, 0.0, 5.0);

// combine them with a matrix multiply
Matrix.multipyMM(scaleAndTranslate, 0, translate, 0, scale, 0);

需要注意的是,由于矩阵乘法的工作方式,将向量乘以结果矩阵将产生与首先将其乘以缩放矩阵(右侧)相同的效果,然后将其乘以平移矩阵(左侧)。这与你可能期望的相反。

注意

Matrix API 的文档可以在developer.android.com/reference/android/opengl/Matrix.html找到。

这些矩阵的东西将被大量使用。值得在这里提到的一点是精度损失。如果你反复缩放和平移组合矩阵,可能会出现与实际值的“漂移”,因为浮点计算由于四舍五入而丢失信息。这不仅是计算机图形的问题,也是银行和比特币挖掘的问题!(还记得电影《办公空间》吗?)

这种矩阵数学的一个基本用途是立即将场景转换为用户视角的屏幕图像(投影)。

在 Cardboard 虚拟现实应用中,为了从特定视角渲染场景,我们考虑一个朝向特定方向的摄像机。摄像机像任何其他物体一样具有 X、Y 和 Z 位置,并旋转到其视角方向。在虚拟现实中,当你转动头部时,Cardboard SDK 读取手机中的运动传感器,确定当前的头部姿势(视角和角度),并给你的应用程序相应的变换矩阵。

事实上,在虚拟现实中,对于每一帧,我们渲染两个稍微不同的透视视图:每只眼睛一个,偏移了实际的眼睛间距(瞳距)。

此外,在虚拟现实中,我们希望使用透视投影(而不是等距投影)来渲染场景,以便靠近你的物体比远处的物体更大。这也可以用 4x4 矩阵来表示。

我们可以将这些变换组合起来,将它们相乘以获得modelViewProjection矩阵:

modelViewProjection = modelTransform X camera  X  eyeView  X  perspectiveProjection

完整的modelViewProjection(MVP)变换矩阵是任何模型变换(例如,在场景中缩放或定位模型)与摄像机视角和透视投影的组合。

当 OpenGL 开始绘制一个对象时,顶点着色器可以使用modelViewProjection矩阵来渲染几何图形。整个场景从用户的视角绘制,朝向他的头部指向,每只眼睛都有透视投影,通过你的 Cardboard 观看器呈现立体效果。虚拟现实 MVP FTW!

MVP 顶点着色器

我们之前编写的超级简单的顶点着色器并不会变换每个顶点;它只是将它传递到管道的下一步。现在,我们希望它能够具有 3D 感知能力,并使用我们的modelViewProjection(MVP)变换矩阵。创建一个着色器来处理它。

在层次结构视图中,右键单击app/res/raw文件夹,转到新建 | 文件,输入名称mvp_vertex.shader,然后单击确定。编写以下代码:

uniform mat4 u_MVP;
attribute vec4 a_Position;
void main() {
   gl_Position = u_MVP * a_Position;
}

这个着色器几乎和simple_vertex一样,但是通过u_MVP矩阵来变换每个顶点。(请注意,虽然在 Java 中用*来乘矩阵和向量是不起作用的,但在着色器代码中是可以的!)

compleShaders函数中的着色器资源替换为使用R.raw.mvp_vertex

simpleVertexShader = loadShader(GLES20.GL_VERTEX_SHADER, R.raw.mvp_vertex)

设置透视视图矩阵

为了将摄像机和视图添加到我们的场景中,我们定义了一些变量。在MainActivity.java文件中,在MainActivity类的开头添加以下代码:

// Viewing variables
private static final float Z_NEAR = 0.1f;
private static final float Z_FAR = 100.0f;
private static final float CAMERA_Z = 0.01f;

private float[] camera;
private float[] view;
private float[] modelViewProjection;

// Rendering variables
private int triMVPMatrixParam;

Z_NEARZ_FAR常量定义了后面用于计算摄像机眼睛的透视投影的深度平面。CAMERA_Z将是摄像机的位置(例如,在 X=0.0,Y=0.0 和 Z=0.01 处)。

triMVPMatrixParam变量将用于在我们改进的着色器中设置模型变换矩阵。

cameraviewmodelViewProjection矩阵将是 4x4 矩阵(16 个浮点数的数组),用于透视计算。

onCreate中,我们初始化了cameraviewmodelViewProjection矩阵:

    protected void onCreate(Bundle savedInstanceState) {
        //...

        camera = new float[16];
        view = new float[16];
        modelViewProjection = new float[16];
    }

prepareRenderingTriangle中,我们初始化了triMVPMatrixParam变量:

// get handle to shape's transformation matrix
triMVPMatrixParam = GLES20.glGetUniformLocation(triProgram, "u_MVP");

提示

OpenGL 中的默认摄像机位于原点(0,0,0),并朝向负Z轴。换句话说,场景中的物体朝着摄像机的正Z轴。为了将它们放在摄像机前面,给它们一个带有一些负 Z 值的位置。

在 3D 图形世界中有一个长期存在的(且毫无意义的)关于哪个轴是上的争论。我们可以在某种程度上都同意X轴是左右移动的,但Y轴是上下移动的,还是Z轴是呢?许多软件选择Z作为上下方向,并将Y定义为指向屏幕内外。另一方面,Cardboard SDK、Unity、Maya 和许多其他软件选择了相反的方式。如果你把坐标平面想象成在图纸上绘制,那么这取决于你把纸放在哪里。如果你把图形想象成从上面往下看,或者在白板上绘制,那么Y就是垂直轴。如果图形放在你面前的桌子上,那么缺失的 Z轴就是垂直的,指向上下。无论如何,Cardboard SDK,因此本书中的项目,将 Z 视为前后轴。

透视渲染

现在,设置好了,我们现在可以处理每一帧重新绘制屏幕的工作。

首先,设置摄像机位置。它可以像在onCreate中那样定义一次。但是,在 VR 应用程序中,场景中的摄像机位置通常会发生变化,因此我们需要在每一帧中重置它。

在新的一帧开始时,首先要做的是重置摄像机矩阵,使其指向一个通用的正面方向。定义onNewFrame方法如下:

    @Override
    public void onNewFrame(HeadTransform headTransform) {
        // Build the camera matrix and apply it to the ModelView.
        Matrix.setLookAtM(camera, 0, 0.0f, 0.0f, CAMERA_Z, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f);
    }

提示

注意,当你写Matrix时,Android Studio 会想要自动导入包。确保你选择的导入是android.opengl.Matrix,而不是其他矩阵库,比如android.graphic.Matrix

现在,当需要从每只眼睛的视角绘制场景时,我们会计算透视视图矩阵。修改onDrawEye如下:

    public void onDrawEye(Eye eye) {
        GLES20.glEnable(GLES20.GL_DEPTH_TEST);
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);

        // Apply the eye transformation to the camera
        Matrix.multiplyMM(view, 0, eye.getEyeView(), 0, camera, 0);

        // Get the perspective transformation
        float[] perspective = eye.getPerspective(Z_NEAR, Z_FAR);

        // Apply perspective transformation to the view, and draw
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, view, 0);

        drawTriangle();
    }

我们添加的前两行重置了 OpenGL 深度缓冲区。当渲染 3D 场景时,除了每个像素的颜色外,OpenGL 还会跟踪占据该像素的对象与眼睛的距离。如果为另一个对象渲染相同的像素,深度缓冲区将知道它是否应该可见(更近)或忽略(更远)。 (或者,也许颜色以某种方式组合在一起,例如透明度)。我们在渲染每只眼睛的任何几何图形之前清除缓冲区。实际上,也清除了屏幕上实际看到的颜色缓冲区。否则,在这种情况下,您最终会用纯色填满整个屏幕。

现在,让我们继续进行查看转换。onDrawEye接收当前的Eye对象,该对象描述了眼睛的立体渲染细节。特别是,eye.getEyeView()方法返回一个包括头部跟踪旋转、位置移动和瞳距移动的变换矩阵。换句话说,眼睛在场景中的位置以及它所看的方向。尽管 Cardboard 不提供位置跟踪,但眼睛的位置会发生变化,以模拟虚拟头部。您的眼睛不会围绕中心轴旋转,而是您的头部围绕颈部旋转,这是眼睛的一定距离。因此,当 Cardboard SDK 检测到方向变化时,两个虚拟摄像头会在场景中移动,就好像它们是实际头部中的实际眼睛一样。

我们需要一个代表该眼睛位置的摄像机透视视图的变换。如前所述,这是如下计算的:

modelViewProjection = modelTransform  X  camera  X  eyeView  X  perspectiveProjection

我们将camera乘以眼睛视图变换(getEyeView),然后将结果乘以透视投影变换(getPerspective)。目前,我们不对三角形模型本身进行变换,而是将modelTransform矩阵排除在外。

结果(modelViewProjection)被传递给 OpenGL,供渲染管线中的着色器使用(通过glUniformMatrix4fv)。然后,我们绘制我们的东西(通过之前写的glDrawArrays)。

现在,我们需要将视图矩阵传递给着色器程序。在drawTriangle方法中,添加如下内容:

    private void drawTriangle() {
        // Add program to OpenGL ES environment
        GLES20.glUseProgram(triProgram);

        // Pass the MVP transformation to the shader
        GLES20.glUniformMatrix4fv(triMVPMatrixParam, 1, false, modelViewProjection, 0);

        // . . .

构建和运行

让我们构建并运行它。转到运行 | 运行'app',或者直接使用工具栏上的绿色三角形运行图标。现在,移动手机将改变与您的视图方向同步的显示。将手机插入 Google Cardboard 查看器中,就像 VR 一样(有点像)。

请注意,如果您的手机在应用程序启动时平放在桌子上,则我们场景中的摄像头将面向三角形的正下方而不是向前。更糟糕的是,当您拿起手机时,中性方向可能不会正对着您的前方。因此,每次在本书中运行应用程序时,先拿起手机,这样您就可以在 VR 中向前看,或者将手机支撑在位置上(我个人使用的是 Gekkopod,可在gekkopod.com/上购买)。

另外,请确保您的手机在设置对话框中未设置为锁定竖屏

重新定位三角形

我们的矩阵技术确实让我们走得更远了。

我想把三角形移到一边。我们将通过设置另一个变换矩阵来实现这一点,然后在绘制时将其用于模型。

添加两个名为triTransformtriView的新矩阵:

    // Model variables
    private float[] triTransform;

    // Viewing variables
    private float[] triView;

onCreate中初始化它们:

        triTransform = new float[16];
        triView = new float[16];

让我们在initializeScene方法中设置定位三角形的模型矩阵(由onSurfaceCreated调用)。我们将其在 X 轴上偏移 5 个单位,并在 Z 轴上向后偏移 5 个单位。在initializeScene中添加以下代码:

       // Position the triangle
        Matrix.setIdentityM(triTransform, 0);
        Matrix.translateM(triTransform, 0, 5, 0, -5);

最后,我们使用模型矩阵在onDrawEye中构建modelViewProjection矩阵。修改onDrawEye如下:

    public void onDrawEye(Eye eye) {
        ...
        // Apply perspective transformation to the view, and draw
        Matrix.multiplyMM(triView, 0, view, 0, triTransform, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, triView, 0);
        drawTriangle();
    }

构建并运行它。现在,您将看到三角形离得更远,偏向一侧。

注意

再次总结一下:modelViewProjection矩阵是三角形位置变换(triTransform)、摄像机位置和方向(camera)、基于手机运动传感器的CardboardView当前眼睛的视点(eye.getEyeView)以及perspective投影的组合。这个 MVP 矩阵被传递给顶点着色器,以确定在屏幕上绘制三角形时的实际位置。

你好,立方体!

在 3D 空间中漂浮的平面三角形可能很惊人,但与我们接下来要做的事情相比,简直不值一提:一个 3D 立方体!

立方体模型数据

为了保持示例简单,三角形只有三个顶点,声明在MainActivity类中。现在,我们将引入更复杂的几何形状。我们将把它放在一个名为Cube的类中。

好吧,它只是由八个不同的顶点组成的立方体,形成了六个面,对吧?

好吧,GPU 更喜欢渲染三角形而不是四边形,因此将每个面细分为两个三角形;总共有 12 个三角形。要单独定义每个三角形,总共需要 36 个顶点,带有适当的绕组方向,定义我们的模型,如CUBE_COORDS中所示。为什么不只定义八个顶点并重用它们?我们稍后会告诉你如何做。

注意

请记住,我们始终需要小心顶点的绕组顺序(逆时针),以便每个三角形的可见面朝外。

在 Android Studio 中,在左侧的 Android 项目层次结构窗格中,找到您的 Java 代码文件夹(例如com.cardbookvr.cardboardbox)。右键单击它,然后转到新建 | Java 类。然后,设置名称:Cube,然后单击确定。然后,编辑文件,如下所示(请记住,本书项目的代码可以从出版商网站和书籍的公共 GitHub 存储库中下载):

package com.cardbookvr.cardboardbox;

public class Cube {

    public static final float[] CUBE_COORDS = new float[] {
        // Front face
        -1.0f, 1.0f, 1.0f,
        -1.0f, -1.0f, 1.0f,
        1.0f, 1.0f, 1.0f,
        -1.0f, -1.0f, 1.0f,
        1.0f, -1.0f, 1.0f,
        1.0f, 1.0f, 1.0f,

        // Right face
        1.0f, 1.0f, 1.0f,
        1.0f, -1.0f, 1.0f,
        1.0f, 1.0f, -1.0f,
        1.0f, -1.0f, 1.0f,
        1.0f, -1.0f, -1.0f,
        1.0f, 1.0f, -1.0f,

        // Back face
        1.0f, 1.0f, -1.0f,
        1.0f, -1.0f, -1.0f,
        -1.0f, 1.0f, -1.0f,
        1.0f, -1.0f, -1.0f,
        -1.0f, -1.0f, -1.0f,
        -1.0f, 1.0f, -1.0f,

        // Left face
        -1.0f, 1.0f, -1.0f,
        -1.0f, -1.0f, -1.0f,
        -1.0f, 1.0f, 1.0f,
        -1.0f, -1.0f, -1.0f,
        -1.0f, -1.0f, 1.0f,
        -1.0f, 1.0f, 1.0f,

        // Top face
        -1.0f, 1.0f, -1.0f,
        -1.0f, 1.0f, 1.0f,
        1.0f, 1.0f, -1.0f,
        -1.0f, 1.0f, 1.0f,
        1.0f, 1.0f, 1.0f,
        1.0f, 1.0f, -1.0f,

        // Bottom face
        1.0f, -1.0f, -1.0f,
        1.0f, -1.0f, 1.0f,
        -1.0f, -1.0f, -1.0f,
        1.0f, -1.0f, 1.0f,
        -1.0f, -1.0f, 1.0f,
        -1.0f, -1.0f, -1.0f,
    };
}

立方体代码

返回MainActivity文件,我们将只是复制/粘贴/编辑三角形代码,并将其重用于立方体。显然,这并不理想,一旦我们看到一个好的模式,我们可以将其中一些抽象出来成为可重用的方法。此外,我们将使用与三角形相同的着色器,然后在下一节中,我们将用更好的光照模型替换它们。也就是说,我们将实现光照或 2D 艺术家可能称之为着色的东西,这是我们到目前为止还没有做的。

与三角形一样,我们声明了一堆我们将需要的变量。顶点数显然应该来自新的Cube.CUBE_COORDS数组:

    // Model variables
    private static float cubeCoords[] = Cube.CUBE_COORDS;
    private final int cubeVertexCount = cubeCoords.length / COORDS_PER_VERTEX;
    private float cubeColor[] = { 0.8f, 0.6f, 0.2f, 0.0f }; // yellow-ish
    private float[] cubeTransform;
    private float cubeDistance = 5f;

    // Viewing variables
    private float[] cubeView;

    // Rendering variables
    private FloatBuffer cubeVerticesBuffer;
    private int cubeProgram;
    private int cubePositionParam;
    private int cubeColorParam;
    private int cubeMVPMatrixParam;

将以下代码添加到onCreate中:

        cubeTransform = new float[16];
        cubeView = new float[16];

将以下代码添加到onSurfaceCreated中:

        prepareRenderingCube();

编写prepareRenderingCube方法,如下所示:

private void prepareRenderingCube() {
        // Allocate buffers
        ByteBuffer bb = ByteBuffer.allocateDirect(cubeCoords.length * 4);
        bb.order(ByteOrder.nativeOrder());
        cubeVerticesBuffer = bb.asFloatBuffer();
        cubeVerticesBuffer.put(cubeCoords);
        cubeVerticesBuffer.position(0);

        // Create GL program
        cubeProgram = GLES20.glCreateProgram();
        GLES20.glAttachShader(cubeProgram, simpleVertexShader);
        GLES20.glAttachShader(cubeProgram, simpleFragmentShader);
        GLES20.glLinkProgram(cubeProgram);
        GLES20.glUseProgram(cubeProgram);

        // Get shader params
        cubePositionParam = GLES20.glGetAttribLocation(cubeProgram, "a_Position");
        cubeColorParam = GLES20.glGetUniformLocation(cubeProgram, "u_Color");
        cubeMVPMatrixParam = GLES20.glGetUniformLocation(cubeProgram, "u_MVP");

        // Enable arrays
        GLES20.glEnableVertexAttribArray(cubePositionParam);
    }

我们将把立方体定位在 5 个单位之外,并在对角轴(1,1,0)上旋转 30 度。没有旋转,我们只会看到正面的正方形。将以下代码添加到initializeScene中:

        // Rotate and position the cube
        Matrix.setIdentityM(cubeTransform, 0);
        Matrix.translateM(cubeTransform, 0, 0, 0, -cubeDistance);
        Matrix.rotateM(cubeTransform, 0, 30, 1, 1, 0);

将以下代码添加到onDrawEye中以计算 MVP 矩阵,包括cubeTransform矩阵,然后绘制立方体:

        Matrix.multiplyMM(cubeView, 0, view, 0, cubeTransform, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, cubeView, 0);
        drawCube();

编写drawCube方法,它与drawTri方法非常相似,如下所示:

    private void drawCube() {
        GLES20.glUseProgram(cubeProgram);
        GLES20.glUniformMatrix4fv(cubeMVPMatrixParam, 1, false, modelViewProjection, 0);
        GLES20.glVertexAttribPointer(cubePositionParam, COORDS_PER_VERTEX,
                GLES20.GL_FLOAT, false, 0, cubeVerticesBuffer);
        GLES20.glUniform4fv(cubeColorParam, 1, cubeColor, 0);
        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, cubeVertexCount);
    }

构建并运行它。现在您将看到立方体的 3D 视图,如下截图所示。它需要着色。

Cube code

光照和着色

我们需要在场景中引入光源并提供一个将使用它的着色器。为此,立方体需要额外的数据,定义每个顶点的法向量和颜色。

注意

顶点颜色并不总是需要用于着色,但在我们的情况下,渐变非常微妙,不同颜色的面将帮助您区分立方体的边缘。我们还将在顶点着色器中进行着色计算,这是一种更快的方法(顶点比光栅像素少),但对于球体等光滑对象效果不佳。要进行顶点光照,您需要在管道中使用顶点颜色,因此对这些颜色做点什么也是有意义的。在这种情况下,我们选择立方体的每个面使用不同的颜色。在本书的后面,您将看到像素级光照的示例以及它带来的差异。

现在我们将构建应用程序来处理我们的光照立方体。我们将通过执行以下步骤来完成:

  • 编写并编译用于光照的新着色器

  • 生成和定义立方体顶点法线矢量和颜色

  • 为渲染分配和设置数据缓冲区

  • 定义和设置用于渲染的光源

  • 生成和设置用于渲染的变换矩阵

添加着色器

让我们编写一个增强的顶点着色器,可以使用模型的光源和顶点法线。

在项目层次结构中的app/res/raw文件夹上右键单击,转到新建 | 文件,并命名为light_vertex.shader。添加以下代码:

uniform mat4 u_MVP;
uniform mat4 u_MVMatrix;
uniform vec3 u_LightPos;

attribute vec4 a_Position;
attribute vec4 a_Color;
attribute vec3 a_Normal;

const float ONE = 1.0;
const float COEFF = 0.00001;

varying vec4 v_Color;

void main() {
   vec3 modelViewVertex = vec3(u_MVMatrix * a_Position);
   vec3 modelViewNormal = vec3(u_MVMatrix * vec4(a_Normal, 0.0));

   float distance = length(u_LightPos - modelViewVertex);
   vec3 lightVector = normalize(u_LightPos - modelViewVertex);
   float diffuse = max(dot(modelViewNormal, lightVector), 0.5);

   diffuse = diffuse * (ONE / (ONE + (COEFF * distance * distance)));
   v_Color = a_Color * diffuse;
   gl_Position = u_MVP * a_Position;
}

不要详细介绍编写光照着色器的细节,您可以看到顶点颜色是根据与光线和表面之间的角度以及光源与顶点之间的距离相关的公式计算的。请注意,我们还引入了ModelView矩阵以及 MVP 矩阵。这意味着您需要访问流程的两个步骤,并且在完成后不能覆盖/丢弃 MV 矩阵。

请注意,我们使用了一个小优化。数字文字(例如,1.0)使用统一空间,在某些硬件上可能会导致问题,因此我们改为声明常量(参考stackoverflow.com/questions/13963765/declaring-constants-instead-of-literals-in-vertex-shader-standard-practice-or)。

与早期简单着色器相比,此着色器中要设置的变量更多,用于光照计算。我们将把这些发送到绘制方法中。

我们还需要一个略有不同的片段着色器。在项目层次结构中的raw文件夹上右键单击,转到新建 | 文件,并命名为passthrough_fragment.shader。添加以下代码:

precision mediump float;
varying vec4 v_Color;

void main() {
    gl_FragColor = v_Color;
}

片段着色器与简单着色器的唯一区别在于,我们用 varying vec4 v_Color替换了 uniform vec4 u_Color,因为颜色现在是从管道中的顶点着色器传递的。现在顶点着色器获得了一个颜色数组缓冲区。这是我们需要在设置/绘制代码中解决的新问题。

然后,在MainActivity中添加这些变量:

    // Rendering variables
    private int lightVertexShader;
    private int passthroughFragmentShader;

compileShaders方法中编译着色器:

        lightVertexShader = loadShader(GLES20.GL_VERTEX_SHADER,
                R.raw.light_vertex);
        passthroughFragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER,
                R.raw.passthrough_fragment);

立方体法线和颜色

立方体的每个面朝向不同的方向,与面垂直。矢量是 XYZ 坐标。将其归一化为长度为 1 的矢量可用于指示此方向,并称为法向量

我们传递给 OpenGL 的几何图形是由顶点定义的,而不是面。因此,我们需要为面的每个顶点提供一个法向量,如下图所示。严格来说,并非给定面上的所有顶点都必须面向同一方向。这在一种称为平滑着色的技术中使用,其中光照计算给出了曲面的错觉,而不是平面的错觉。我们将对每个面使用相同的法线(硬边缘),这也节省了我们在指定法线数据时的时间。我们的数组只需要指定六个矢量,可以扩展为 36 个法向量的缓冲区。颜色值也是如此。

立方体法线和颜色

每个顶点也有一个颜色。假设立方体的每个面都是一个实色,我们可以将该面的每个顶点分配相同的颜色。在Cube.java文件中,添加以下代码:

    public static final float[] CUBE_COLORS_FACES = new float[] {
        // Front, green
        0f, 0.53f, 0.27f, 1.0f,
        // Right, blue
        0.0f, 0.34f, 0.90f, 1.0f,
        // Back, also green
        0f, 0.53f, 0.27f, 1.0f,
        // Left, also blue
        0.0f, 0.34f, 0.90f, 1.0f,
        // Top, red
        0.84f,  0.18f,  0.13f, 1.0f,
        // Bottom, also red
        0.84f,  0.18f,  0.13f, 1.0f,
    };

    public static final float[] CUBE_NORMALS_FACES = new float[] {
        // Front face
        0.0f, 0.0f, 1.0f,
        // Right face
        1.0f, 0.0f, 0.0f,
        // Back face
        0.0f, 0.0f, -1.0f,
        // Left face
        -1.0f, 0.0f, 0.0f,
        // Top face
        0.0f, 1.0f, 0.0f,
        // Bottom face
        0.0f, -1.0f, 0.0f,
    };

对于立方体的每个面,我们定义了一个实色(CUBE_COLORS_FACES)和一个法向量(CUBE_NORMALS_FACES)。

现在,编写一个可重复使用的方法cubeFacesToArray,以生成MainActivity中实际需要的浮点数组。将以下代码添加到您的Cube类中:

    /**
     * Utility method for generating float arrays for cube faces
     *
     * @param model - float[] array of values per face.
     * @param coords_per_vertex - int number of coordinates per vertex.
     * @return - Returns float array of coordinates for triangulated cube faces.
     *               6 faces X 6 points X coords_per_vertex
     */
    public static float[] cubeFacesToArray(float[] model, int coords_per_vertex) {
        float coords[] = new float[6 * 6 * coords_per_vertex];
        int index = 0;
        for (int iFace=0; iFace < 6; iFace++) {
            for (int iVertex=0; iVertex < 6; iVertex++) {
                for (int iCoord=0; iCoord < coords_per_vertex; iCoord++) {
                    coords[index] = model[iFace*coords_per_vertex + iCoord];
                    index++;
                }
            }
        }
        return coords;
    }

将这些数据添加到MainActivity中的其他变量中,如下所示:

    // Model variables
    private static float cubeCoords[] = Cube.CUBE_COORDS;
    private static float cubeColors[] = Cube.cubeFacesToArray(Cube.CUBE_COLORS_FACES, 4);
    private static float cubeNormals[] = Cube.cubeFacesToArray(Cube.CUBE_NORMALS_FACES, 3);

您还可以删除private float cubeColor[]的声明,因为现在不再需要它。

有了法向量和颜色,着色器可以计算对象占据的每个像素的值。

准备顶点缓冲区

渲染管道要求我们为顶点、法向量和颜色设置内存缓冲区。我们已经有了顶点缓冲区,现在需要添加其他缓冲区。

添加变量,如下所示:

    // Rendering variables
    private FloatBuffer cubeVerticesBuffer;
    private FloatBuffer cubeColorsBuffer;
    private FloatBuffer cubeNormalsBuffer;

准备缓冲区,并将以下代码添加到prepareRenderingCube方法(从onSurfaceCreated调用)。 (这是完整的prepareRenderingCube方法的前半部分):

    private void prepareRenderingCube() {
        // Allocate buffers
        ByteBuffer bb = ByteBuffer.allocateDirect(cubeCoords.length * 4);
        bb.order(ByteOrder.nativeOrder());
        cubeVerticesBuffer = bb.asFloatBuffer();
        cubeVerticesBuffer.put(cubeCoords);
        cubeVerticesBuffer.position(0);

        ByteBuffer bbColors = ByteBuffer.allocateDirect(cubeColors.length * 4);
 bbColors.order(ByteOrder.nativeOrder());
 cubeColorsBuffer = bbColors.asFloatBuffer();
 cubeColorsBuffer.put(cubeColors);
 cubeColorsBuffer.position(0);

 ByteBuffer bbNormals = ByteBuffer.allocateDirect(cubeNormals.length * 4);
 bbNormals.order(ByteOrder.nativeOrder());
 cubeNormalsBuffer = bbNormals.asFloatBuffer();
 cubeNormalsBuffer.put(cubeNormalParam);
 cubeNormalsBuffer.position(0);

        // Create GL program

准备着色器

已经定义了lighting_vertex着色器,我们需要添加参数句柄来使用它。在MainActivity类的顶部,添加四个变量到光照着色器参数:

    // Rendering variables
    private int cubeNormalParam;
    private int cubeModelViewParam;
    private int cubeLightPosParam;

prepareRenderingCube方法中(由onSurfaceCreated调用),附加lightVertexShaderpassthroughFragmentShader着色器,而不是简单的着色器,获取着色器参数,并启用数组,使其现在读取如下。(这是prepareRenderingCube的后半部分,从前一节继续):

        // Create GL program
        cubeProgram = GLES20.glCreateProgram();
        GLES20.glAttachShader(cubeProgram, lightVertexShader);
        GLES20.glAttachShader(cubeProgram, passthroughFragmentShader);
        GLES20.glLinkProgram(cubeProgram);
        GLES20.glUseProgram(cubeProgram);

        // Get shader params
        cubeModelViewParam = GLES20.glGetUniformLocation(cubeProgram, "u_MVMatrix");
        cubeMVPMatrixParam = GLES20.glGetUniformLocation(cubeProgram, "u_MVP");
        cubeLightPosParam = GLES20.glGetUniformLocation(cubeProgram, "u_LightPos");

        cubePositionParam = GLES20.glGetAttribLocation(cubeProgram, "a_Position");
        cubeNormalParam = GLES20.glGetAttribLocation(cubeProgram, "a_Normal");
 cubeColorParam = GLES20.glGetAttribLocation(cubeProgram, "a_Color");

        // Enable arrays
        GLES20.glEnableVertexAttribArray(cubePositionParam);
        GLES20.glEnableVertexAttribArray(cubeNormalParam);
 GLES20.glEnableVertexAttribArray(cubeColorParam);

如果您参考我们之前编写的着色器代码,您会注意到这些对glGetUniformLocationglGetAttribLocation的调用对应于那些脚本中声明的uniformattribute参数,包括cubeColorParamu_Color到现在的a_Color的更改。OpenGL 不需要这种重命名,但它有助于我们区分顶点属性和统一性。

引用数组缓冲区的着色器属性必须启用。

添加光源

接下来,我们将在场景中添加一个光源,并在绘制时告诉着色器它的位置。光源将被放置在用户的正上方。

MainActivity的顶部,添加光源位置的变量:

    // Scene variables
    // light positioned just above the user
    private static final float[] LIGHT_POS_IN_WORLD_SPACE = new float[] { 0.0f, 2.0f, 0.0f, 1.0f };
    private final float[] lightPosInEyeSpace = new float[4];

通过添加以下代码到onDrawEye来计算光的位置:

        // Apply the eye transformation to the camera
        Matrix.multiplyMM(view, 0, eye.getEyeView(), 0, camera, 0);

        // Calculate position of the light
        Matrix.multiplyMV(lightPosInEyeSpace, 0, view, 0, LIGHT_POS_IN_WORLD_SPACE, 0);

请注意,我们使用view矩阵(眼睛view * camera)使用Matrix.multiplyMV函数将光的位置转换为当前视图空间。

现在,我们只需告诉着色器光源的位置和它所需的视图矩阵。修改drawCube方法(由onDrawEye调用),如下所示:

    private void drawCube() {
        GLES20.glUseProgram(cubeProgram);

        // Set the light position in the shader
 GLES20.glUniform3fv(cubeLightPosParam, 1, lightPosInEyeSpace, 0);

        // Set the ModelView in the shader, used to calculate lighting
 GLES20.glUniformMatrix4fv(cubeModelViewParam, 1, false, cubeView, 0);

        GLES20.glUniformMatrix4fv(cubeMVPMatrixParam, 1, false, modelViewProjection, 0);

        GLES20.glVertexAttribPointer(cubePositionParam, COORDS_PER_VERTEX,
                GLES20.GL_FLOAT, false, 0, cubeVerticesBuffer);
        GLES20.glVertexAttribPointer(cubeNormalParam, 3, GLES20.GL_FLOAT, false, 0,
 cubeNormalsBuffer);
 GLES20.glVertexAttribPointer(cubeColorParam, 4, GLES20.GL_FLOAT, false, 0,
 cubeColorsBuffer);

        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, cubeVertexCount);
    }

构建和运行应用程序

我们现在准备好了。构建并运行应用程序时,您将看到类似以下截图的屏幕:

![构建和运行应用程序](img/B05144_03_05.jpg)

旋转立方体

下一步很快。让我们让立方体旋转。这是通过在每帧中稍微旋转cubeTransform矩阵来实现的。我们可以为此定义一个TIME_DELTA值。添加静态变量,如下所示:

    // Viewing variables
    private static final float TIME_DELTA = 0.3f;

然后,修改每帧的cubeTransform,并将以下代码添加到onNewFrame方法:

Matrix.rotateM(cubeTransform, 0, TIME_DELTA, 0.5f, 0.5f, 1.0f);

Matrix.rotateM函数根据角度和轴向量对变换矩阵应用旋转。在这种情况下,我们围绕轴向量(0.5,0.5,1)旋转TIME_DELTA的角度。严格来说,您应该提供一个归一化的轴,但重要的是向量的方向而不是大小。

构建并运行它。现在立方体正在旋转。令人惊叹!

你好,地板!

在虚拟现实中,有一种脚踏实地的感觉可能很重要。感觉像站着(或坐着)要比像一个无身体的眼球漂浮在空间中更舒服得多。因此,让我们在场景中添加一个地板。

现在这应该更加熟悉了。我们将有一个类似于立方体的着色器、模型和渲染管道。所以,我们将不做太多解释,就这样做吧。

着色器

地板将使用我们的light_shader进行一些小修改和一个新的片段着色器。

通过添加v_Grid变量来修改light_vertex.shader,如下所示:

uniform mat4 u_Model;
uniform mat4 u_MVP;
uniform mat4 u_MVMatrix;
uniform vec3 u_LightPos;

attribute vec4 a_Position;
attribute vec4 a_Color;
attribute vec3 a_Normal;

varying vec4 v_Color;
varying vec3 v_Grid;

const float ONE = 1.0;
const float COEFF = 0.00001;

void main() {
 v_Grid = vec3(u_Model * a_Position);

    vec3 modelViewVertex = vec3(u_MVMatrix * a_Position);
    vec3 modelViewNormal = vec3(u_MVMatrix * vec4(a_Normal, 0.0));

    float distance = length(u_LightPos - modelViewVertex);
    vec3 lightVector = normalize(u_LightPos - modelViewVertex);
    float diffuse = max(dot(modelViewNormal, lightVector), 0.5);

    diffuse = diffuse * (ONE / (ONE + (COEFF * distance * distance)));
    v_Color = a_Color * diffuse;
    gl_Position = u_MVP * a_Position;
}

app/res/raw中创建一个名为grid_fragment.shader的新着色器,如下所示:

precision mediump float;
varying vec4 v_Color;
varying vec3 v_Grid;

void main() {
    float depth = gl_FragCoord.z / gl_FragCoord.w; // Calculate world-space distance.

    if ((mod(abs(v_Grid.x), 10.0) < 0.1) || (mod(abs(v_Grid.z), 10.0) < 0.1)) {
        gl_FragColor = max(0.0, (90.0-depth) / 90.0) * vec4(1.0, 1.0, 1.0, 1.0)
                + min(1.0, depth / 90.0) * v_Color;
    } else {
        gl_FragColor = v_Color;
    }
}

这可能看起来很复杂,但我们所做的只是在一个纯色着色器上绘制一些网格线。if语句将检测我们是否在 10 的倍数的 0.1 单位内。如果是,我们将绘制一个颜色,介于白色(1,1,1,1)和v_Color之间,根据该像素的深度或其与相机的距离。gl_FragCoord是一个内置值,它给出了我们在窗口空间中渲染的像素的位置,以及深度缓冲区(z)中的值,该值将在范围[0,1]内。第四个参数w本质上是相机绘制距离的倒数,当与深度值结合时,给出了像素的世界空间深度。v_Grid变量实际上已经让我们根据顶点着色器中引入的本地顶点位置和模型矩阵,访问了当前像素的世界空间位置。

MainActivity中,添加一个新的片段着色器变量:

    // Rendering variables
    private int gridFragmentShader;

compileShaders方法中编译着色器,如下所示:

        gridFragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER,
                R.raw.grid_fragment);

地板模型数据

在项目中创建一个名为Floor的新的 Java 文件。添加地板平面坐标、法线和颜色:

    public static final float[] FLOOR_COORDS = new float[] {
        200f, 0, -200f,
        -200f, 0, -200f,
        -200f, 0, 200f,
        200f, 0, -200f,
        -200f, 0, 200f,
        200f, 0, 200f,
    };

    public static final float[] FLOOR_NORMALS = new float[] {
        0.0f, 1.0f, 0.0f,
        0.0f, 1.0f, 0.0f,
        0.0f, 1.0f, 0.0f,
        0.0f, 1.0f, 0.0f,
        0.0f, 1.0f, 0.0f,
        0.0f, 1.0f, 0.0f,
    };

    public static final float[] FLOOR_COLORS = new float[] {
            0.0f, 0.34f, 0.90f, 1.0f,
            0.0f, 0.34f, 0.90f, 1.0f,
            0.0f, 0.34f, 0.90f, 1.0f,
            0.0f, 0.34f, 0.90f, 1.0f,
            0.0f, 0.34f, 0.90f, 1.0f,
            0.0f, 0.34f, 0.90f, 1.0f,
    };

变量

将我们需要的所有变量添加到MainActivity中:

    // Model variables
    private static float floorCoords[] = Floor.FLOOR_COORDS;
    private static float floorColors[] = Floor.FLOOR_COLORS;
    private static float floorNormals[] = Floor.FLOOR_NORMALS;
    private final int floorVertexCount = floorCoords.length / COORDS_PER_VERTEX;
    private float[] floorTransform;
    private float floorDepth = 20f;

    // Viewing variables
    private float[] floorView;

    // Rendering variables
    private int gridFragmentShader;

    private FloatBuffer floorVerticesBuffer;
    private FloatBuffer floorColorsBuffer;
    private FloatBuffer floorNormalsBuffer;
    private int floorProgram;
    private int floorPositionParam;
    private int floorColorParam;
    private int floorMVPMatrixParam;
    private int floorNormalParam;
    private int floorModelParam;
    private int floorModelViewParam;
    private int floorLightPosParam;

onCreate

onCreate中分配矩阵:

        floorTransform = new float[16];
        floorView = new float[16];

onSurfaceCreated

onSufraceCreated中添加对prepareRenderingFloor的调用,我们将其编写如下:

        prepareRenderingFloor();

initializeScene

initializeScene方法中设置floorTransform矩阵:

        // Position the floor
        Matrix.setIdentityM(floorTransform, 0);
        Matrix.translateM(floorTransform, 0, 0, -floorDepth, 0);

prepareRenderingFloor

这是完整的prepareRenderingFloor方法:

    private void prepareRenderingFloor() {
        // Allocate buffers
        ByteBuffer bb = ByteBuffer.allocateDirect(floorCoords.length * 4);
        bb.order(ByteOrder.nativeOrder());
        floorVerticesBuffer = bb.asFloatBuffer();
        floorVerticesBuffer.put(floorCoords);
        floorVerticesBuffer.position(0);

        ByteBuffer bbColors = ByteBuffer.allocateDirect(floorColors.length * 4);
        bbColors.order(ByteOrder.nativeOrder());
        floorColorsBuffer = bbColors.asFloatBuffer();
        floorColorsBuffer.put(floorColors);
        floorColorsBuffer.position(0);

        ByteBuffer bbNormals = ByteBuffer.allocateDirect(floorNormals.length * 4);
        bbNormals.order(ByteOrder.nativeOrder());
        floorNormalsBuffer = bbNormals.asFloatBuffer();
        floorNormalsBuffer.put(floorNormals);
        floorNormalsBuffer.position(0);

        // Create GL program
        floorProgram = GLES20.glCreateProgram();
        GLES20.glAttachShader(floorProgram, lightVertexShader);
        GLES20.glAttachShader(floorProgram, gridFragmentShader);
        GLES20.glLinkProgram(floorProgram);
        GLES20.glUseProgram(floorProgram);

        // Get shader params
        floorPositionParam = GLES20.glGetAttribLocation(floorProgram, "a_Position");
        floorNormalParam = GLES20.glGetAttribLocation(floorProgram, "a_Normal");
        floorColorParam = GLES20.glGetAttribLocation(floorProgram, "a_Color");

        floorModelParam = GLES20.glGetUniformLocation(floorProgram, "u_Model");
        floorModelViewParam = GLES20.glGetUniformLocation(floorProgram, "u_MVMatrix");
        floorMVPMatrixParam = GLES20.glGetUniformLocation(floorProgram, "u_MVP");
        floorLightPosParam = GLES20.glGetUniformLocation(floorProgram, "u_LightPos");

        // Enable arrays
        GLES20.glEnableVertexAttribArray(floorPositionParam);
        GLES20.glEnableVertexAttribArray(floorNormalParam);
        GLES20.glEnableVertexAttribArray(floorColorParam);
    }

onDrawEye

onDrawEye中计算 MVP 并绘制地板:

        Matrix.multiplyMM(floorView, 0, view, 0, floorTransform, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, floorView, 0);
        drawFloor();

绘制地板

定义一个drawFloor方法,如下所示:

    private void drawFloor() {
        GLES20.glUseProgram(floorProgram);
        GLES20.glUniform3fv(floorLightPosParam, 1, lightPosInEyeSpace, 0);
        GLES20.glUniformMatrix4fv(floorModelParam, 1, false, floorTransform, 0);
        GLES20.glUniformMatrix4fv(floorModelViewParam, 1, false, floorView, 0);
        GLES20.glUniformMatrix4fv(floorMVPMatrixParam, 1, false, modelViewProjection, 0);
        GLES20.glVertexAttribPointer(floorPositionParam, COORDS_PER_VERTEX,
                GLES20.GL_FLOAT, false, 0, floorVerticesBuffer);
        GLES20.glVertexAttribPointer(floorNormalParam, 3, GLES20.GL_FLOAT, false, 0,
                floorNormalsBuffer);
        GLES20.glVertexAttribPointer(floorColorParam, 4, GLES20.GL_FLOAT, false, 0,
                floorColorsBuffer);
        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, floorVertexCount);
    }

构建并运行它。现在它看起来像以下的截图:

drawFloor

哇!

嘿,看这个!

在项目的最后部分,我们添加了一个功能,当您看着一个物体(立方体)时,它会用不同的颜色进行高亮显示。

这是通过CardboardView接口方法onNewFrame来实现的,该方法传递当前头部变换信息。

isLookingAtObject 方法

让我们从最有趣的部分开始。我们将从 Google 的寻宝演示中借用isLookingAtObject方法。它通过计算对象在眼睛空间中的位置来检查用户是否正在看着一个对象,并在用户看着对象时返回 true。在MainActivity中添加以下代码:

/**
     * Check if user is looking at object by calculating where the object is in eye-space.
     *
     * @return true if the user is looking at the object.
     */
    private boolean isLookingAtObject(float[] modelView, float[] modelTransform) {
        float[] initVec = { 0, 0, 0, 1.0f };
        float[] objPositionVec = new float[4];

        // Convert object space to camera space. Use the headView from onNewFrame.
        Matrix.multiplyMM(modelView, 0, headView, 0, modelTransform, 0);
        Matrix.multiplyMV(objPositionVec, 0, modelView, 0, initVec, 0);

        float pitch = (float) Math.atan2(objPositionVec[1], -objPositionVec[2]);
        float yaw = (float) Math.atan2(objPositionVec[0], -objPositionVec[2]);

        return Math.abs(pitch) < PITCH_LIMIT && Math.abs(yaw) < YAW_LIMIT;
    }

该方法接受两个参数:我们要测试的对象的modelViewmodelTransform变换矩阵。它还引用了headView类变量,我们将在onNewFrame中设置。

一个更精确的方法是从相机向场景中的方向发射一条射线,并确定它是否与场景中的任何几何体相交。这将非常有效,但也非常消耗计算资源。

相反,这个函数采用了更简单的方法,甚至不使用对象的几何形状。它使用对象的视图变换来确定对象距离屏幕中心有多远,并测试该向量的角度是否在一个狭窄的范围内(PITCH_LIMITYAW_LIMIT)。是的,我知道,人们获得博士学位来想出这些东西!

让我们按照以下方式定义我们需要的变量:

    // Viewing variables
    private static final float YAW_LIMIT = 0.12f;
    private static final float PITCH_LIMIT = 0.12f;

    private float[] headView;

onCreate中分配headView

        headView = new float[16];

在每一帧新的headView值。在onNewFrame中添加以下代码:

        headTransform.getHeadView(headView, 0);

然后,修改drawCube以检查用户是否正在看着立方体,并决定使用哪种颜色:

        if (isLookingAtObject(cubeView, cubeTransform)) {
            GLES20.glVertexAttribPointer(cubeColorParam, 4, GLES20.GL_FLOAT, false, 0,
                    cubeFoundColorsBuffer);
        } else {
            GLES20.glVertexAttribPointer(cubeColorParam, 4, GLES20.GL_FLOAT, false, 0,
                    cubeColorsBuffer);
        }

就是这样!除了一个(微小的)细节:我们需要第二组顶点颜色用于突出显示模式。我们将通过使用相同的黄色绘制所有面来突出显示立方体。为了实现这一点,需要进行一些更改。

Cube中,添加以下 RGBA 值:

    public static final float[] CUBE_FOUND_COLORS_FACES = new float[] {
        // Same yellow for front, right, back, left, top, bottom faces
        1.0f,  0.65f, 0.0f, 1.0f,
        1.0f,  0.65f, 0.0f, 1.0f,
        1.0f,  0.65f, 0.0f, 1.0f,
        1.0f,  0.65f, 0.0f, 1.0f,
        1.0f,  0.65f, 0.0f, 1.0f,
        1.0f,  0.65f, 0.0f, 1.0f,
    };

MainActivity中,添加这些变量:

    // Model variables
    private static float cubeFoundColors[] = Cube.cubeFacesToArray(Cube.CUBE_FOUND_COLORS_FACES, 4);

    // Rendering variables
    private FloatBuffer cubeFoundColorsBuffer;

将以下代码添加到prepareRenderingCube方法中:

        ByteBuffer bbFoundColors = ByteBuffer.allocateDirect(cubeFoundColors.length * 4);
        bbFoundColors.order(ByteOrder.nativeOrder());
        cubeFoundColorsBuffer = bbFoundColors.asFloatBuffer();
        cubeFoundColorsBuffer.put(cubeFoundColors);
        cubeFoundColorsBuffer.position(0);

构建并运行它。当你直接看着立方体时,它会被突出显示。

提示

如果立方体不那么接近,可能会更有趣和具有挑战性。尝试将cubeDistance设置为12f

就像寻宝演示一样,尝试每次看着它时设置一个新的随机立方体位置。现在,你有了一个游戏!

摘要

在本章中,我们从头开始构建了一个 Cardboard Android 应用,从一个新项目开始,逐渐添加 Java 代码。在我们的第一个构建中,我们有一个三角形的立体视图,你可以在 Google Cardboard 头盔中看到。

然后我们添加了模型变换、3D 摄像机视图、透视和头部旋转变换,并讨论了一些关于矩阵数学的内容。我们建立了一个立方体的 3D 模型,然后创建了着色器程序,使用光源来渲染带有阴影的立方体。我们还为立方体添加了动画,并添加了一个地板网格。最后,我们添加了一个功能,当用户看着立方体时,它会被突出显示。

在这个过程中,我们享受了关于 3D 几何、OpenGL、着色器、用于渲染管线的几何法线和数据缓冲区的良好讨论。我们还开始思考如何将代码中的常见模式抽象为可重用的方法。

在下一章中,我们将采用不同的方法来使用 Android 布局视图进行立体渲染,构建一个有用的“虚拟大厅”,可以用作 3D 菜单系统或通往其他世界的门户。

第四章:启动器大堂

该项目创建了一个硬纸板 VR 应用程序,可用于启动设备上安装的其他硬纸板应用程序。我们将其称为LauncherLobby。当您打开 LauncherLobby 时,您将看到最多 24 个图标水平排列。当您向右或向左转动头部时,图标会滚动,就好像它们在圆柱体内部一样。您可以通过凝视其图标并拉动硬纸板触发器来打开应用程序。

对于这个项目,我们采用了一种最小化的方法来创建立体视图。该项目使用标准的 Android ViewGroup 布局来模拟视差,并简单地将图像在每只眼睛中向左或向右移动,从而产生视差视觉效果。我们不使用 3D 图形。我们不直接使用 OpenGL,尽管大多数现代版本的 Android 都使用 OpenGL 来渲染视图。事实上,我们几乎完全不使用硬纸板 SDK;我们只使用它来绘制分屏叠加层并获取头部方向。然而,视图布局和图像移动逻辑是从 Google 的 Treasure Hunt 示例中派生的(在那里用于绘制文本叠加)。

这种方法的优点是多方面的。它说明了即使没有高级图形、矩阵数学、渲染引擎和物理学,也可以构建硬纸板应用程序。当然,这些通常是必需的,但在这种情况下,它们不是。如果您有 Android 开发经验,这里使用的类和模式可能会特别熟悉。这个项目演示了硬纸板 VR,至少只需要一个硬纸板 SDK 头部变换和一个分屏布局,就可以生成一个立体应用程序。

实际上,我们选择了这种方法,以便我们可以使用 Android 的 TextView。在 3D 中渲染任意文本实际上相当复杂(尽管肯定是可能的),因此为了简单起见,我们将此项目限制为 2D 视图和 Android 布局。

要构建项目,我们首先将带您了解一些基础知识,将文本字符串和图标图像放在屏幕上,并以立体方式查看它们。然后,我们将设计一个虚拟屏幕,它的工作原理就像展开的圆柱体内部。水平转动头部就像在这个虚拟屏幕上进行平移。屏幕将被分成插槽,每个插槽包含一个硬纸板应用程序的图标和名称。凝视并点击其中一个插槽将启动相应的应用程序。如果您曾经使用过硬纸板示例应用程序(在撰写时如此称呼),这个界面将很熟悉。

在本章中,我们将涵盖以下主题:

  • 创建一个新的硬纸板项目

  • 添加一个Hello Virtual World文本叠加

  • 使用虚拟屏幕空间

  • 响应头部查看

  • 向视图添加图标

  • 列出已安装的硬纸板应用程序

  • 突出显示当前应用程序快捷方式

  • 使用触发器选择并启动应用程序

此项目的源代码可以在 Packt Publishing 网站和 GitHub 上找到,网址为github.com/cardbookvr/launcherlobby(每个主题作为单独的提交)。

创建一个新项目

如果您想了解更多细节并解释这些步骤,请参考第二章中的创建新的硬纸板项目部分,骨架硬纸板项目,并在那里跟随:

  1. 打开 Android Studio,创建一个新项目。让我们将其命名为LauncherLobby,并以Android 4.4 KitKat (API 19)为目标,使用空活动

  2. 将硬纸板 SDK 的common.aarcore.aar库文件作为新模块添加到您的项目中,使用文件 | 新建 | 新建模块...

  3. 将库模块设置为项目应用程序的依赖项,使用文件 | 项目结构

  4. 编辑AndroidManifest.xml文件,如第二章中所述,骨架硬纸板项目,要小心保留此项目的package名称。

  5. 编辑build.gradle文件,如第二章中所述,骨架 Cardboard 项目,以编译 SDK 22。

  6. 编辑activity_main.xml布局文件,如第二章中所述,骨架 Cardboard 项目

  7. 编辑MainActivity Java 类,使其扩展CardboardActivity并实现CardboardView.StereoRenderer。修改类声明行如下:

public class MainActivity extends CardboardActivity implements CardboardView.StereoRenderer {
  1. 为接口添加存根方法覆盖(使用智能感知实现方法或按下Ctrl + I)。

  2. 最后,通过添加以下内容来编辑onCreate()中的CardboadView实例:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        CardboardView cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
        cardboardView.setRenderer(this);
        setCardboardView(cardboardView);  
    }

添加 Hello Virtual World 文本覆盖

首先,我们只是在屏幕上放一些文本,您可能会用它作为对用户的提示消息,或者用它来显示有信息内容的抬头显示HUD)。我们将逐步以小步骤实现这个:

  1. 创建一个带有一些文本的简单覆盖视图。

  2. 将其居中显示在屏幕上。

  3. 为立体观看添加视差。

一个简单的文本覆盖

首先,我们将以简单的方式添加一些覆盖文本,而不是立体地显示在屏幕上。这将是我们对OverlayView类的初始实现。

打开activity_main.xml文件,并添加以下行以将OverlayView添加到您的布局中:

<.OverlayView
   android:id="@+id/overlay"
   android:layout_width="fill_parent"
   android:layout_height="fill_parent"
   android:layout_alignParentLeft="true"
   android:layout_alignParentTop="true" />

请注意,我们只用.OverlayView引用了OverlayView类。如果您的视图类与MainActivity类在同一个包中,您可以这样做。我们之前对.MainActivity也是这样做的。

接下来,我们编写 Java 类。右键单击app/java文件夹(app/src/main/java/com.cardbookvr.launcherlobby/),然后导航到新建 | Java 类。命名为OverlayView

定义类,使其扩展LinearLayout,并添加一个构造方法,如下:

public class OverlayView extends LinearLayout{

    public OverlayView(Context context, AttributeSet attrs) {
        super(context, attrs);

        TextView textView = new TextView(context, attrs);
        addView(textView);

        textView.setTextColor(Color.rgb(150, 255, 180));
        textView.setText("Hello Virtual World!");
        setVisibility(View.VISIBLE);
    }
}

OverlayView()构造方法创建了一个新的TextView实例,颜色是宜人的绿色,文本是Hello Virtual World!

运行应用程序,您会注意到我们的文本显示在屏幕左上角,如下面的屏幕截图所示:

一个简单的文本覆盖

使用子视图将文本居中

接下来,我们创建一个单独的视图组,并使用它来控制文本对象。具体来说,是将其居中在视图中。

OverlayView构造函数中,用一个名为EyeView的不同ViewGroup助手类的实例替换TextView。目前,它是单眼的,但很快我们将使用这个类来创建两个视图:每个眼睛一个:

    public OverlayView(Context context, AttributeSet attrs) {
        super(context, attrs);

        LayoutParams params = new LayoutParams(
            LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, 1.0f);
        params.setMargins(0, 0, 0, 0);

        OverlayEye eye = new OverlayEye(context, attrs);
        eye.setLayoutParams(params);
        addView(eye);

        eye.setColor(Color.rgb(150, 255, 180));
        eye.addContent("Hello Virtual World!");
        setVisibility(View.VISIBLE);
  }

我们创建了一个名为 eye 的OverlayEye的新实例,设置了它的颜色,并添加了文本字符串。

在使用ViewGroup类时,您需要指定LayoutParams来告诉父级如何布局视图,我们希望它是全屏大小且没有边距(参考developer.android.com/reference/android/view/ViewGroup.LayoutParams.html)。

在同一个OverlayView.java文件中,我们将添加名为OverlayEye的私有类,如下:

    private class OverlayEye extends ViewGroup {
        private Context context;
        private AttributeSet attrs;
        private TextView textView;
        private int textColor;

        public OverlayEye(Context context, AttributeSet attrs) {
            super(context, attrs);
            this.context = context;
            this.attrs = attrs;
        }

        public void setColor(int color) {
            this.textColor = color;
        }

        public void addContent(String text) {
            textView = new TextView(context, attrs);
            textView.setGravity(Gravity.CENTER);
            textView.setTextColor(textColor);
            textView.setText(text);
            addView(textView);
        }
    }

我们已将TextView的创建与OverlayEye的构造函数分开。这样做的原因很快就会变得清楚。

OverlayEye构造函数注册了添加新内容视图所需的上下文和属性。

然后,addContent创建TextView实例并将其添加到布局中。

现在我们为OverlayEye定义onLayout,它设置了 textview 的边距,特别是顶部边距,作为强制文本垂直居中的机制:

        @Override
        protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
            final int width = right - left;
            final int height = bottom - top;

            final float verticalTextPos = 0.52f;

            float topMargin = height * verticalTextPos;
            textView.layout(0, (int) topMargin, width, bottom);
        }

为了使文本垂直居中,我们使用顶部边距将其从屏幕顶部向下推。文本将垂直定位在屏幕中心的下方,由verticalTextPos指定,这是一个百分比值,其中 1.0 是屏幕的全高。我们选择了一个值 0.52,将文本的顶部向下推到额外的 2%,刚好在屏幕中间的下方。

运行应用程序,您会注意到我们的文本现在居中显示在屏幕上:

使用子视图居中文本

为每只眼睛创建立体视图

现在,我们变得真实。虚拟地,也就是说。对于 VR,我们需要立体左右眼视图。幸运的是,我们有这个方便的OverlayEye类,我们可以为每只眼睛重复使用。

您的眼睛之间有一个可测量的距离,称为瞳距IPD)。当您在 Cardboard 头盔中观看立体图像时,每只眼睛有单独的视图,它们之间有一个对应的偏移距离(水平)。

让我们假设我们的文本位于垂直于视线方向的平面上。也就是说,我们直视文本平面。给定一个与文本距离您的眼睛的距离对应的数值,我们可以通过固定数量的像素水平移动左右眼的视图来创建视差效果。我们将称之为depthOffset值。较大的深度偏移将使文本看起来更近;较小的深度偏移将使文本看起来更远。深度偏移为零将表示没有视差,就好像文本离得很远(大于 20 英尺)一样。

对于我们的应用程序,我们将选择一个深度偏移因子为 0.01,或者以屏幕坐标(屏幕尺寸的一部分)测量的 1%。图标将看起来大约离我们 2 米远(6 英尺),这是 VR 中的一个舒适距离,尽管这个值是一个临时近似值。使用屏幕尺寸的百分比而不是实际像素数量,我们可以确保我们的应用程序将适应任何屏幕/设备尺寸。

现在让我们来实现这个。

首先,在OverlayView类的顶部声明leftEyerightEye值的变量:

public class OverlayView extends LinearLayout{
    private final OverlayEye leftEye;
    private final OverlayEye rightEye;

OverlayView的构造方法中初始化它们:

    public CardboardOverlayView(Context context, AttributeSet attrs) {
        super(context, attrs);

        LayoutParams params = new LayoutParams(
                LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, 1.0f);
        params.setMargins(0, 0, 0, 0);

        leftEye = new OverlayEye(context, attrs);
        leftEye.setLayoutParams(params);
        addView(leftEye);

        rightEye = new OverlayEye(context, attrs);
        rightEye.setLayoutParams(params);
        addView(rightEye);

        setDepthFactor(0.01f);
        setColor(Color.rgb(150, 255, 180));
        addContent("Hello Virtual World!");
        setVisibility(View.VISIBLE);
   }

注意中间的六行,我们在那里定义了leftViewrightView,并为它们调用了addViewsetDepthFactor调用将在视图中设置该值。

添加深度、颜色和文本内容的 setter 方法:

    public void setDepthFactor(float factor) {
        leftEye.setDepthFactor(factor);
        rightEye.setDepthFactor(-factor);
    }

    public void setColor(int color) {
        leftEye.setColor(color);
        rightEye.setColor(color);
    }

    public void addContent(String text) {
        leftEye.addContent(text);
        rightEye.addContent(text);
    }

注意

重要提示:请注意,对于rightEye值,我们使用偏移值的负值。为了创建视差效果,它需要向左眼视图的相反方向移动。我们仍然可以通过只移动一个眼睛来实现视差,但是那样所有的内容看起来都会稍微偏离中心。

OverlayEye类需要深度因子 setter,我们将其转换为像素作为depthOffset。此外,声明物理视图宽度的变量(以像素为单位):

        private int depthOffset;
        private int viewWidth;

onLayout中,设置视图宽度(以像素为单位)在计算后:

            viewWidth = width;

定义将深度因子转换为像素偏移的 setter 方法:

        public void setDepthFactor(float factor) {
            this.depthOffset = (int)(factor * viewWidth);
        }

现在,当我们在addContent中创建textView时,我们可以按depthOffset值以像素为单位移动它:

            textView.setX(depthOffset);
            addView(textView);

当您运行应用程序时,您的屏幕将如下所示:

为每只眼睛创建立体视图

文本现在是立体视图,尽管它“贴在您的脸上”,因为当您的头部移动时它不会移动。它附在面罩或 HUD 上。

从 MainActivity 控制覆盖视图

下一步是删除一些硬编码的属性,并从MainActivity类中控制它们。

MainActivity.java中,在类的顶部添加一个overlayView变量:

public class MainActivity extends CardboardActivity implements CardboardView.StereoRenderer {
    private OverlayView overlayView;

onCreate中初始化其值。我们将使用addContent()方法显示文本:

        ...
        setCardboardView(cardboardView);
        overlayView = (OverlayView) findViewById(R.id.overlay);
        overlayView.addContent("Hello Virtual World");

不要忘记从OverlayView方法中删除对addContent的调用:

        setDepthOffset(0.01f);
        setColor(Color.rgb(150, 255, 180));
        addContent("Hello Virtual World!");
        setVisibility(View.VISIBLE); 
   }

再次运行应用程序。它应该看起来和之前一样。

您可以使用这样的代码来创建一个 3D 提示,比如文本通知消息。或者,它可以用来构建一个 HUD 面板,以分享游戏状态或报告当前设备属性。例如,要显示当前的屏幕参数,您可以将它们放入MainActivity中:

        ScreenParams sp = cardboardView.getHeadMountedDisplay().getScreenParams();
        overlayView.setText(sp.toString());

这将显示手机的物理宽度和高度(以像素为单位)。

使用虚拟屏幕

在虚拟现实中,您所看到的空间比屏幕上的空间要大。屏幕就像是虚拟空间的视口。在这个项目中,我们不计算 3D 视图和裁剪平面,我们将头部运动限制在左/右偏航旋转。

您可以将可见空间看作是圆柱体内表面,您的头位于中心。当您旋转头部时,屏幕上会显示一部分展开的圆柱体。

使用虚拟屏幕

虚拟屏幕的像素高度与物理设备相同。

我们需要计算虚拟宽度。例如,一种方法是计算一度头部旋转的像素数。然后,完整旋转的宽度将是每度像素 360*。

我们可以轻松找到显示屏的物理宽度(以像素为单位)。实际上,在onLayout中已经找到了它,作为viewWidth变量。或者,可以从 Cardboard SDK 调用中检索:

    ScreenParams sp = cardboardView.getHeadMountedDisplay().getScreenParams();
    Log.d(TAG, "screen width: " + sp.getWidth());

从 SDK 中,我们还可以获取 Cardboard 头盔的视野FOV)角度(以度为单位)。这个值会因设备而异,并且是 Cardboard 设备配置参数的一部分:

    FieldOfView fov = cardboardView.getHeadMountedDisplay().getCardboardDeviceParams().getLeftEyeMaxFov();
    Log.d(TAG, "FOV: " + fov.getLeft());

有了这个,我们可以计算每度的像素数以及虚拟屏幕的总宽度。例如,在我的 Nexus 4 上,设备宽度(横向模式)为 1,280,使用 Homido 观看器,FOV 为 40.0 度。因此,分屏视图为 640 像素,给我们提供了 16.0 像素每度和虚拟屏幕宽度为 5,760 像素。

在此期间,我们还可以计算并记住pixelsPerRadian值,这将有助于根据当前用户的HeadTransform(以弧度给出)确定头部偏移。

让我们添加它。在OverlayView类的顶部,添加这些变量:

    private int virtualWidth; 
    private float pixelsPerRadian;

然后,添加以下方法:

    public void calcVirtualWidth(CardboardView cardboard) {
        int screenWidth = cardboard.getHeadMountedDisplay().getScreenParams().getWidth() / 2;
        float fov = cardboard.getCardboardDeviceParams().getLeftEyeMaxFov().getLeft();
        float pixelsPerDegree = screenWidth / fov;
		pixelsPerRadian = (float) (pixelsPerDegree * 180.0 / Math.PI);
        virtualWidth = (int) (pixelsPerDegree * 360.0);
    }

MainActivityonCreate方法中,添加以下调用:

        overlayView.calcVirtualWidth(cardboardView);

请注意,从设备参数中报告的 FOV 值是由头盔制造商定义的粗略近似值,并且在某些设备上可能被高估和填充。实际的 FOV 可以从传递给onDrawEye()的眼睛对象中检索,因为那代表应该被渲染的实际视锥体。一旦项目运行起来,您可能会考虑对自己的代码进行这种更改。

现在,我们可以使用这些值来响应用户的头部旋转。

响应头部观察

让文本随着我们的头部移动,这样它就不会看起来贴在您的脸上!当您向左或向右看时,我们将将文本向相反方向移动,这样它看起来在空间中是静止的。

为此,我们将从MainActivity开始。在onNewFrame方法中,我们将确定水平头部旋转角度并将其传递给overlayView对象。

MainActivity中,定义onNewFrame

    public void onNewFrame(HeadTransform headTransform) {
        final float[] angles = new float[3];
        headTransform.getEulerAngles(angles, 0);
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                overlayView.setHeadYaw(angles[1]);
            }
        });
    }

onNewFrame方法接收当前的HeadTransform实例作为参数,这是一个提供当前头部姿势的对象。

有各种数学表示头部姿势的方式,例如前向 XYZ 方向矢量,或者角度的组合。getEulerAngles方法将姿势获取为称为欧拉角(发音为欧拉)的三个角度,分别是俯仰、偏航和滚转:

  • Pitch将您的头部转动,就像点头“是”

  • Yaw将您的头向左/向右转动(就像摇头“不”一样)

  • Roll将您的头部从耳朵转到肩膀(“做一个滚动!”)

这些轴对应于XYZ坐标轴。我们将限制这种体验到偏航,当您向左或向右选择菜单项时。因此,我们只发送第二个欧拉角angles[1]overlayView类。

注意使用runOnUiThread,这确保了overlayView更新在 UI 线程上运行。否则,我们会引发各种异常并破坏 UI(你可以参考developer.android.com/reference/android/app/Activity.html#runOnUiThread(java.lang.Runnable))。

因此,在OverlayView中,添加一个headOffset变量和一个设置它的方法setHeadYaw

    private int headOffset;

    public void setHeadYaw(float angle) {
        headOffset = (int)( angle * pixelsPerRadian );
        leftEye.setHeadOffset(headOffset);
        rightEye.setHeadOffset(headOffset);
    }

这里的想法是将头部旋转转换为屏幕上文本对象的位置偏移。当你的头部向左转时,将对象移动到右边。当你的头部向右转时,将对象移动到左边。因此,当你转动头部时,对象在屏幕上滚动。

我们从 Cardboard SDK 获取的偏航角(围绕垂直Y轴的旋转)是以弧度表示的。我们计算要偏移视图的像素数,与头部相反的方向。因此,我们将角度乘以pixelsPerRadian。为什么我们不取反角度?事实证明,顺时针旋转在Y轴上被注册为负旋转。想想看。

最后,在OverlayEye中,定义setHeadOffset方法来改变视图对象的 X 位置。确保也包括depthOffset变量:

        public void setHeadOffset(int headOffset) {
            textView.setX( headOffset + depthOffset );
        }

运行应用程序。当你移动头部时,文本应该向相反方向滚动。

向视图添加图标

接下来,我们将向视图添加一个图标图像。

现在,让我们只使用一个通用图标,比如android_robot.png。这个文件的副本可以在互联网上找到,本章的文件中也包含了一个副本。将android_robot.png文件粘贴到项目的app/res/drawable/文件夹中。别担心,我们以后会使用实际的应用程序图标。

我们想要同时显示文本和图标,所以我们可以添加代码来将图像视图添加到addContent方法中。

MainActivityonCreate方法中,修改addContent调用,将图标作为第二个参数传递:

        Drawable icon = getResources()
            .getDrawable(R.drawable.android_robot, null);
        overlayView.addContent("Hello Virtual World!", icon);

OverlayViewaddContent中,添加图标参数并将其传递给OverlayEye视图:

    public void addContent(String text, Drawable icon) {
        leftEye.addContent(text, icon);
        rightEye.addContent(text, icon);
    }

现在是OverlayEye类的时间。在OverlayEye的顶部,添加一个变量到ImageView实例:

    private class OverlayEye extends ViewGroup {
        private TextView textView;
        private ImageView imageView;

修改OverlayEyeaddContent,以便还接受一个Drawable图标并为其创建ImageView实例。修改后的方法现在如下所示:

        public void addContent(String text, Drawable icon) {
            textView = new TextView(context, attrs);
            textView.setGravity(Gravity.CENTER);
            textView.setTextColor(textColor);
            textView.setText(text);
            addView(textView);

            imageView = new ImageView(context, attrs);
 imageView.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
 imageView.setAdjustViewBounds(true);
 // preserve aspect ratio
 imageView.setImageDrawable(icon);
 addView(imageView);
        }

使用imageView.setScaleType.CENTER_INSIDE告诉视图从中心缩放图像。将setAdjustViewBounds设置为true告诉视图保持图像的纵横比。

OverlayEyeonLayout方法中设置ImageView的布局参数。在onLayout方法的底部添加以下代码:

            final float imageSize = 0.1f;
            final float verticalImageOffset = -0.07f;
            float imageMargin = (1.0f - imageSize) / 2.0f;
            topMargin = (height * (imageMargin + verticalImageOffset));
            float botMargin = topMargin + (height * imageSize);
            imageView.layout(0, (int) topMargin, width, (int) botMargin);

当图像被绘制时,它将自动缩放以适应顶部和底部边距。换句话说,给定一个期望的图像大小(例如屏幕高度的 10%,或 0.1f),图像边距因子为(1-size)/2,乘以屏幕的像素高度以获得像素边距。我们还添加了一个小的垂直偏移(负值,向上移动),以便在图标和下面的文本之间留出间距。

最后,将imageView偏移添加到setHeadOffset方法中:

        public void setHeadOffset(int headOffset) {
            textView.setX( headOffset + depthOffset );
            imageView.setX( headOffset + depthOffset );
        }

运行应用程序。你的屏幕会是这样的。当你移动头部时,图标和文本都会滚动。

向视图添加图标

列出已安装的 Cardboard 应用程序

如果你没有忘记,这个 LauncherLobby 应用程序的目的是在设备上显示 Cardboard 应用程序的列表,并让用户选择一个来启动它。

如果你喜欢我们迄今为止构建的内容,你可能想要保存一份备份以供将来参考。我们接下来要做的更改将显著修改代码,以支持应用程序快捷方式的视图列表。

我们将用addShortcut方法替换addContent方法,并用快捷方式的列表替换imageViewtextView变量。每个快捷方式包括一个ImageView和一个TextView来显示快捷方式,以及一个ActivityInfo对象,用于启动应用程序。快捷方式的图像和文本将像之前显示的那样重叠显示,并且将水平排列在一条线上,相隔固定距离。

查询 Cardboard 应用程序

首先,让我们获取设备上安装的 Cardboard 应用程序的列表。在MainActivityonCreate方法的末尾,添加一个调用一个新方法getAppList

        getAppList();

然后,在MainActivity中定义此方法,如下所示:

    private void getAppList() {
        final Intent mainIntent = new Intent(Intent.ACTION_MAIN, null);
        mainIntent.addCategory("com.google.intent.category.CARDBOARD");
        mainIntent.addFlags(PackageManager.GET_INTENT_FILTERS);

        final List<ResolveInfo> pkgAppsList = getPackageManager().queryIntentActivities( mainIntent, PackageManager.GET_INTENT_FILTERS);

        for (ResolveInfo info : pkgAppsList) {
            Log.d("getAppList", info.loadLabel(getPackageManager()).toString());
        }
    }

运行它,并在 Android Studio 的logcat窗口中查看。代码获取当前设备上的 Cardboard 应用程序列表(pkgAppsList)并将它们的标签(name)打印到调试控制台。

Cardboard 应用程序通过具有CARDBOARD意图类别来识别,因此我们通过该类别进行过滤。调用addFlags并在queryIntentActivities中指定标志是重要的,否则我们将无法获取意图过滤器的列表,也无法匹配CARDBOARD类别的应用程序。还要注意,我们正在使用Activity类的getPackageManager()函数。如果您需要将此方法放在另一个类中,它将需要对活动的引用。我们将在本书的后面再次使用意图。有关包管理器和意图的更多信息,请参阅developer.android.com/reference/android/content/pm/PackageManager.htmldeveloper.android.com/reference/android/content/Intent.html

为应用程序创建快捷方式类

接下来,我们将定义一个Shortcut类,其中包含我们需要的每个 Cardboard 应用程序的详细信息。

创建一个名为Shortcut的新 Java 类。定义如下:

public class Shortcut {
    private static final String TAG = "Shortcut";
    public String name;
    public Drawable icon;
    ActivityInfo info;

    public Shortcut(ResolveInfo info, PackageManager packageManager){
        name = info.loadLabel(packageManager).toString();
        icon = info.loadIcon(packageManager);
        this.info = info.activityInfo;
    }
}

MainActivity中,修改getAppList()以从pkgAppsList构建快捷方式并将其添加到overlayView

        ...
        int count = 0;
        for (ResolveInfo info : pkgAppsList) {
            overlayView.addShortcut( new Shortcut(info, getPackageManager()));
            if (++count == 24)
                break;
        }

我们需要限制适合在我们的视图圆柱体内的快捷方式的数量。在这种情况下,我选择了 24 作为一个合理的数字。

向 OverlayView 添加快捷方式

现在,我们修改OverlayView以支持将要呈现的快捷方式列表。首先,声明一个列表变量shortcuts来保存它们:

public class OverlayView extends LinearLayout {
    private List<Shortcut> shortcuts = new ArrayList<Shortcut>();
    private final int maxShortcuts = 24;
    private int shortcutWidth;

addShortcut方法如下:

    public void addShortcut(Shortcut shortcut){
        shortcuts.add(shortcut);
        leftEye.addShortcut(shortcut);
        rightEye.addShortcut(shortcut);
    }

如您所见,这调用了OverlayEye类中的addShortcut方法。这将为布局构建一个TextViewImageView实例列表。

注意maxShortcutsshortcutWidth变量。maxShortcuts定义了我们想要在虚拟屏幕上放置的快捷方式的最大数量,shortcutWidth将是屏幕上每个快捷方式槽的宽度。在calcVirtualWidth()中初始化shortcutWidth,在calcVirtualWidth的末尾添加以下代码行:

        shortcutWidth = virtualWidth / maxShortcuts;

在 OverlayEye 中使用视图列表

OverlayEye的顶部,用列表替换textViewimageView变量:

    private class OverlayEye extends LinearLayout {
        private final List<TextView> textViews = new ArrayList<TextView>();
        private final List<ImageView> imageViews = new ArrayList<ImageView>();

现在我们准备在OverlayEye中编写addShortcut方法。它看起来非常像我们要替换的addContent方法。它创建textViewimageView(如前所述),然后将它们放入列表中:

        public void addShortcut(Shortcut shortcut) {
            TextView textView = new TextView(context, attrs);
            textView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 12.0f);
            textView.setGravity(Gravity.CENTER);
            textView.setTextColor(textColor);
            textView.setText(shortcut.name);
            addView(textView);
            textViews.add(textView);

            ImageView imageView = new ImageView(context, attrs);
            imageView.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
            imageView.setAdjustViewBounds(true); 
            imageView.setImageDrawable(shortcut.icon);
            addView(imageView);
            imageViews.add(imageView);
        }

setAdjustViewBounds设置为true可以保留图像的纵横比。

删除OverlayViewOverlayEye类中的过时addContent方法定义。

现在,在onLayout中,我们要迭代textViews列表,如下所示:

            for(TextView textView : textViews) {
                textView.layout(0, (int) topMargin, width, bottom);
            }

我们还要迭代imageViews列表,如下所示:

            for(ImageView imageView : imageViews) {
                imageView.layout(0, (int) topMargin, width, (int) botMargin);
            }

最后,我们还需要在setHeadOffset中迭代列表:

        public void setHeadOffset(int headOffset) {
            int slot = 0;
            for(TextView textView : textViews) {
                textView.setX(headOffset + depthOffset + (shortcutWidth * slot));
                slot++;
            }
            slot = 0;
            for(ImageView imageView : imageViews) {
                imageView.setX(headOffset + depthOffset + (shortcutWidth * slot));
                slot++;
            }
        }

运行应用程序。现在,您将看到您的 Cardboard 快捷方式整齐地排列在一个水平菜单中,您可以通过转动头部来滚动。

在 OverlayEye 中使用视图列表

注意

请注意,一些 Java 程序员可能会指出,OverlayEye类中的快捷方式列表和每个视图的列表是多余的。的确如此,但事实证明,将每个眼睛的绘制功能重构到Shortcut类中是相当复杂的。我们发现这种方式是最简单和最容易理解的。

突出显示当前的快捷方式

当用户凝视快捷方式时,它应该能够指示它是可选择的。在下一节中,我们将连接它以突出显示所选项目并实际启动相应的应用程序。

这里的诀窍是确定哪个插槽在用户面前。为了突出显示它,我们将提亮文本颜色。

让我们编写一个辅助方法来确定基于headOffset变量(从头部偏航角计算得出)当前在凝视中的插槽。将getSlot方法添加到OverlayView类中:

    public int getSlot() {
        int slotOffset = shortcutWidth/2 - headOffset;
        slotOffset /= shortcutWidth;
        if(slotOffset < 0)
            slotOffset = 0;
        if(slotOffset >= shortcuts.size())
            slotOffset = shortcuts.size() - 1;
        return slotOffset;
    }

shortcutWidth值的一半被添加到headOffset值,因此我们检测凝视在快捷方式的中心。然后,我们添加headOffset的负值,因为它最初被计算为位置偏移,与视图方向相反。headOffset的负值实际上对应于大于零的插槽编号。

getSlot应该返回 0 到虚拟布局中插槽数量之间的数字;在这种情况下,是 24。由于可能向右看并设置正的headOffset变量,getSlot可能会返回负数,因此我们要检查边界条件。

现在,我们可以突出显示当前选定的插槽。我们将通过更改文本标签颜色来实现。修改setHeadOffset如下:

        public void setHeadOffset(int headOffset) {
            int currentSlot = getSlot();
            int slot = 0;
            for(TextView textView : textViews) {
                textView.setX(headOffset + depthOffset + (shortcutWidth * slot));
                if (slot==currentSlot) {
                    textView.setTextColor(Color.WHITE);
                } else {
                    textView.setTextColor(textColor);
                }
                slot++;
            }
            slot = 0;
            for(ImageView imageView : imageViews) {
                imageView.setX(headOffset + depthOffset + (shortcutWidth * slot));
                slot++;
            }
        }

运行应用程序,您凝视的项目将被突出显示。当然,可能还有其他有趣的突出显示所选应用程序的方法,但现在这已经足够了。

使用触发器选择并启动应用程序

最后一步是检测用户正在凝视的快捷方式,并响应触发(点击)启动应用程序。

当我们从这个应用程序启动一个新应用程序时,我们需要引用MainActivity对象。一种方法是将其作为单例对象。现在让我们这样做。请注意,将活动定义为单例可能会导致问题。Android 可以启动单个Activity类的多个实例,但即使跨应用程序,静态变量也是共享的。

MainActivity类的顶部添加一个instance变量:

    public static MainActivity instance;

onCreate中初始化它:

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        instance = this;

现在在MainActivity中,添加一个处理 Cardboard 触发器的处理程序:

    @Override
    public void onCardboardTrigger(){
        overlayView.onTrigger();
    }

然后,在OverlayView中添加以下方法:

    public void onTrigger() {
        shortcuts.get( getSlot() ).launch();
    }

我们使用getSlot来索引我们的快捷方式列表。因为我们在getSlot本身中检查了边界条件,所以我们不需要担心ArrayIndexOutOfBounds异常。

最后,在Shortcut中添加一个launch()方法:

    public void launch() {
        ComponentName name = new ComponentName(info.applicationInfo.packageName,
                info.name);
        Intent i = new Intent(Intent.ACTION_MAIN);

        i.addCategory(Intent.CATEGORY_LAUNCHER);
        i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK |
                Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
        i.setComponent(name);

        if(MainActivity.instance != null) {
            MainActivity.instance.startActivity(i);
        } else {
            Log.e(TAG, "Cannot find activity singleton");
        }
    }

我们使用在Shortcut类中存储的ActivityInfo对象来创建一个新的Intent实例,然后调用MainActivity.instance.startActivity并将其作为参数传递来启动应用程序。

请注意,一旦您从此应用程序启动新应用程序,就没有系统范围的方法可以从 VR 内部返回到 LauncherLobby。用户将不得不从 Cardboard Viewer 中取出手机,然后点击返回按钮。但是,SDK 确实支持CardboardView.setOnCardboardBackButtonListener,如果您想要呈现返回或退出按钮,可以将其添加到您的 Cardboard 应用程序中。

准备就绪!LauncherLobby 已经可以开始运行了。

进一步增强

改进和增强此项目的一些想法包括以下内容:

  • 支持超过 24 个快捷方式,可能添加多行或无限滚动机制

  • 重复使用图像和文本视图对象;您一次只能看到很少的几个

  • 目前,非常长的应用标签会重叠,调整您的视图代码以使文本换行,或者在标签过长时引入省略号(...)

  • 添加一个圆柱形背景图像(天空盒)

  • 其他突出当前快捷方式的替代方法,也许可以通过发光来突出显示,或者通过调整其视差偏移将其移近

  • 添加声音和/或振动以增强体验并加强选择反馈。

总结

在本章中,我们构建了 LauncherLobby 应用程序,该应用程序可用于在您的设备上启动其他 Cardboard 应用程序。我们没有使用 3D 图形和 OpenGL,而是使用 Android GUI 和虚拟圆柱屏幕来实现这一点。

实施的第一部分主要是教学性的:如何添加TextView叠加层,将其居中在视图组中,然后用左/右眼视差视图进行立体显示。然后,我们根据当前物理设备尺寸和当前 Cardboard 设备视场参数确定了虚拟屏幕的大小,即展开的圆柱体。当用户左右移动头部(偏航旋转)时,对象在虚拟屏幕上滚动。最后,我们查询 Android 设备上安装的 Cardboard 应用程序,显示它们的图标和标题在水平菜单中,并允许您通过盯着它并点击触发器来选择要启动的应用程序。

在下一章中,我们将回到 3D 图形和 OpenGL。这一次,我们正在构建一个软件抽象层,帮助封装许多较低级别的细节和日常工作。这个引擎也将在本书的其他项目中被重复使用。

第五章:RenderBox 引擎

虽然 Cardboard Java SDK 和 OpenGL ES 是移动 VR 应用程序强大而稳健的库,但它们的层次相对较低。软件开发最佳实践期望我们将常见的编程模式抽象成新的类和数据结构。在第三章中,Cardboard Box,我们对细节有了一些实际经验。这一次,我们将重温这些细节,同时将它们抽象成一个可重用的库,我们将称之为RenderBox。这将涉及向量数学、材料、光照等,全部打包成一个整洁的包。

在本章中,您将学习:

  • 创建一个新的 Cardboard 项目

  • 编写一个带有着色器的Material

  • 探索我们的Math

  • 编写一个Transform

  • 编写一个带有RenderObject CubeCameraLight组件的Component

  • 添加一个用于渲染带有顶点颜色和光照的Material

  • 编写一个Time动画类

  • 将所有这些导出到一个可重用的RenderBox库中

该项目的源代码可以在 Packt Publishing 网站上找到,并且在 GitHub 上也可以找到github.com/cardbookvr/renderboxdemo(每个主题作为单独的提交)。最终的RenderBoxLib项目将继续在本书的其他项目中进行维护和重用,也可以在 Packt Publishing 网站和 GitHub 上找到github.com/cardbookvr/renderboxlib

介绍 RenderBox - 一个图形引擎

在虚拟现实应用程序中,您正在创建一个三维空间,其中包含一堆对象。用户的视点,或者说摄像头,也位于这个空间中。借助 Cardboard SDK 的帮助,场景被渲染两次,一次为左眼和右眼,以创建并排的立体视图。第二个同样重要的功能是将传感器数据转换为头部朝向,跟踪现实生活中用户的头部。像素是使用 OpenGL ES 库在屏幕上绘制或渲染的,该库与设备上的硬件图形处理器GPU)通信。

我们将把图形渲染代码组织成单独的 Java 类,然后将其提取到一个可重用的图形引擎库中。我们将称这个库为RenderBox

正如您将看到的,RenderBox类实现了CardboardView.StereoRender接口。但它不仅仅是这样。虚拟现实需要 3D 图形渲染,而在低级别的 OpenGL ES 调用(和其他支持的 API)中进行所有这些工作可能会很繁琐,至少可以这么说,特别是当您的应用程序不断增长时。此外,这些 API 要求您像半导体芯片一样思考!缓冲区、着色器和矩阵数学,哦!我是说,谁想一直这样思考?我宁愿像一个 3D 艺术家和 VR 开发人员一样思考。

有许多不同的部分需要跟踪和管理,它们可能会变得复杂。作为软件开发人员,我们的角色是识别常见模式并实现抽象层,以减少这种复杂性,避免重复的代码,并将程序表达为更接近问题域的对象(软件类)。在我们的情况下,这个领域制作可以在 Cardboard VR 设备上渲染的 3D 场景。

RenderBox开始将细节抽象成一个干净的代码层。它旨在处理 OpenGL 调用和复杂的算术,同时仍然让我们以我们想要的方式设置我们的特定于应用程序的代码。如果我们的项目需要任何特殊情况,它还会创建一个称为实体组件模式en.wikipedia.org/wiki/Entity_component_system)的常见模式,用于新材料和组件类型。这是我们库中主要类的一个示例:

介绍 RenderBox - 一个图形引擎

RenderBox类实现了CardboardView.StereoRenderer,从而将这个责任从应用的MainActivity类中解放出来。正如我们将看到的,MainActivity通过IRenderBox接口(具有设置、preDraw 和 postDraw 钩子)与RenderBox进行通信,以便MainActivity实现IRenderBox

让我们考虑可以参与 3D VR 场景的Component类型:

  • RenderObject:这些是场景中的可绘制模型,例如立方体和球体。

  • Camera:这是用户的视点,用于渲染场景

  • Light:这些是用于阴影和阴影的照明源

我们场景中的每个对象都在空间中具有 X、Y 和 Z 位置、旋转和三个比例尺。这些属性由Transform类定义。变换可以按层次结构排列,让您构建更复杂的对象,这些对象由更简单的对象组装而成。

每个Transform类可以与一个或多个Component类关联。不同类型的组件(例如CameraLightRenderObject)扩展了Component类。组件不应该存在于没有附加到Transform类的情况下,但反之(没有组件的变换)是完全可以的。

在内部,RenderBox维护着一个RenderObjects列表。这些是场景中的几何模型。RenderObjects的类型包括CubeSphere等。这些对象与Material相关联,定义了它们的颜色、纹理和/或阴影属性。材料又引用、编译和执行低级着色器程序。每帧维护一个扁平的组件列表比每帧遍历变换层次结构更有效。这是我们使用实体组件模式的一个完美例子。

RenderBox包中的其他内容包括用于实现动画的Time类,以及用于向量和矩阵操作的Math函数库。

现在,让我们开始把这些放在一起。

游戏计划是什么?最终目标是创建我们的RenderBox图形引擎库。将其保留在自己的项目中(如果您使用源代码控制,如 Git,则还可以保留在自己的存储库中)将非常方便,因此可以独立地进行改进和维护。但是,为了开始这个过程,我们需要一个简单的应用程序来构建它,向您展示如何使用它,并验证(如果不是测试)它是否正常工作。这将被称为RenderBoxDemo。在本章结束时,我们将RenderBox代码提取到一个 Android 库模块中,然后导出它。

创建一个新项目

如果您想了解有关这些步骤的更多详细信息和解释,请参考第二章中的创建新的 Cardboard 项目部分,骨架 Cardboard 项目,并在那里跟着做:

  1. 打开 Android Studio,创建一个新项目。让我们将其命名为RenderBoxDemo,并以空活动为目标Android 4.4 KitKat(API 19)

  2. 将 Cardboard SDK 的common.aarcore.aar库文件作为新模块添加到项目中,使用文件 | 新建 | 新建模块...

  3. 使用文件 | 项目结构将库模块作为项目应用程序的依赖项。

  4. 编辑AndroidManifest.xml文件,如第二章中所述,骨架 Cardboard 项目,务必保留此项目的package名称。

  5. 编辑build.gradle文件,如第二章中所述,骨架 Cardboard 项目,以便针对 SDK 22 进行编译。

  6. 编辑activity_main.xml布局文件,如第二章中所述,骨架 Cardboard 项目

现在,打开MainActivity.java文件,并编辑MainActivity Java 类以扩展CardboardActivity

    public class MainActivity extends CardboardActivity {
        private static final String TAG = "RenderBoxDemo";

注意

请注意,与之前的章节不同,我们不实现CardboardView.StereoRender。相反,我们将在RenderBox类中实现该接口(在下一个主题中)。

创建 RenderBox 包文件夹

由于我们的计划是将RenderBox代码导出为一个库,让我们把它放到自己的包中。

在 Android 层次结构面板中,使用Gear图标,取消选中紧凑的空中间包,以便我们可以在com.cardbookvr下插入新的包。

在项目视图中右键单击app/java/com/carbookvr/文件夹,导航到新建 | ,并命名为renderbox。您现在可能希望再次启用紧凑的空中间包

renderbox文件夹中,创建三个包子文件夹,分别命名为componentsmaterialsmath。项目现在应该有与以下截图中显示的相同的文件夹:

创建 RenderBox 包文件夹

创建一个空的 RenderBox 类

让我们首先创建RenderBox类 Java 代码的框架。右键单击renderbox/文件夹,导航到新建 | Java 类,并命名为RenderBox

现在,打开RenderBox.java文件并编辑它以实现CardboardView.StereoRenderer接口。添加以下代码:

public class RenderBox implements CardboardView.StereoRenderer {
    private static final String TAG = "RenderBox";

    public static RenderBox instance;
    public Activity mainActivity;
    IRenderBox callbacks;

    public RenderBox(Activity mainActivity, IRenderBox callbacks){
        instance = this;
        this.mainActivity = mainActivity;
        this.callbacks = callbacks;
    }
}

这主要是在这一点上进行的一些日常工作。RenderBox类被定义为implements CardboardView.StereoRenderer。它的构造函数将接收对MainActivity实例和IRenderBox实现者(在这种情况下也是MainActivity)类的引用。MainActivity现在必须实现IRenderBox方法(下面将定义)。通过这种方式,我们实例化了框架并实现了关键方法。

请注意,我们还通过在类构造函数中注册this实例,使RenderBox成为单例。

我们还必须为StereoRenderer类添加方法覆盖。从智能感知菜单中选择实现方法...(或Ctrl + I),以添加接口的存根方法覆盖,如下所示:

    @Override
    public void onNewFrame(HeadTransform headTransform) {
    }

    @Override
    public void onDrawEye(Eye eye) {
    }

    @Override
    public void onFinishFrame(Viewport viewport) {
    }

    @Override
    public void onSurfaceChanged(int i, int i1) {
    }

    @Override
    public void onSurfaceCreated(EGLConfig eglConfig) {
    }

    @Override
    public void onRendererShutdown() {
    }

现在是添加一个错误报告方法checkGLErrorRenderBox中来记录 OpenGL 渲染错误的好时机,如下面的代码所示:

    /**
     * Checks if we've had an error inside of OpenGL ES, and if so what that error is.
     * @param label Label to report in case of error.
     */
    public static void checkGLError(String label) {
        int error;
        while ((error = GLES20.glGetError()) != GLES20.GL_NO_ERROR) {
            String errorText = String.format("%s: glError %d, %s", label, error, GLU.gluErrorString(error));
            Log.e(TAG, errorText);
            throw new RuntimeException(errorText);
        }
    }

在之前的章节项目中,我们定义了MainActivity,使其实现了CardboardView.StereoRenderer。现在这个任务被委托给了我们的新的RenderBox对象。让我们告诉MainActivity去使用它。

MainActivity.java中,修改onCreate方法,创建RenderBox的新实例并将其设置为视图渲染器,如下所示:

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        CardboardView cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
        cardboardView.setRenderer(new RenderBox(this,this));
        setCardboardView(cardboardView);
    }

注意,cardboardView.setRender传递了新的RenderBox,它将MainActivity实例作为ActivityIRenderBox参数。哇,我们完全控制了 Cardboard SDK 的集成,现在一切都是关于实现IRenderbox。通过这种方式,我们将 Cardboard SDK、OpenGL 和各种其他外部依赖封装在我们自己的库中。现在,如果这些规范发生变化,我们只需要保持RenderBox更新,我们的应用程序就可以像以前一样告诉RenderBox该做什么。

添加 IRenderBox 接口

一旦我们把所有这些放在一起,MainActivity类将实现IRenderBox接口。该接口为活动提供了setuppreDrawpostDraw函数的回调。

onSurfaceCreated中进行一些通用工作后,将调用设置方法。在onDrawEye期间将调用preDrawpostDraw方法。我们将在本章后面看到这一点。

我们现在可以设置它了。在层次结构面板中右键单击renderbox,导航到新建 | Java 类,选择类型:"接口",并命名为IRenderBox。这只是几行代码,应该包括以下代码:

public interface IRenderBox {
    public void setup();
    public void preDraw();
    public void postDraw();
}

然后,修改MainActivity以使其实现IRenderBox

public class MainActivity extends CardboardActivity implements IRenderBox {

选择智能感知实现方法(或Ctrl + I),以添加接口方法的覆盖。Android Studio 将自动填充以下内容:

    @Override
    public void setup() {

    }

    @Override
    public void preDraw() {

    }

    @Override
    public void postDraw() {

    }

如果您现在运行空白应用程序,将不会出现任何构建错误,并且它将显示空的 Cardboard 分屏视图:

添加 IRenderBox 接口

现在我们已经创建了一个骨架应用程序,准备实现RenderBox包和实用程序,这将帮助我们构建新的 Cardboard VR 应用程序。

在接下来的几个主题中,我们将构建RenderBox中需要的一些类。不幸的是,在我们编写完这些代码之前,我们无法在您的 Cardboard 设备上显示任何有趣的东西。这也限制了我们测试和验证编码是否正确的能力。这可能是引入单元测试的合适时机,比如 JUnit。有关详细信息,请参阅Unit testing support文档(tools.android.com/tech-docs/unit-testing-support)。不幸的是,空间不允许我们介绍这个主题并在本书的项目中使用它。但我们鼓励您自己去追求这个。 (我会提醒您,这个项目的 GitHub 存储库为每个主题都有单独的提交,随着我们的进行逐步添加代码)。

材料、纹理和着色器

在第三章中,我们介绍了 OpenGL ES 2.0 图形管线和简单的着色器。现在我们将把这些代码提取到一个单独的Material类中。

在计算机图形学中,材料指的是几何模型的视觉表面特性。在场景中渲染对象时,材料与照明和着色器代码以及 OpenGL 图形管线所需的其他场景信息一起使用。

实色材料是最简单的;物体的整个表面是单一颜色。最终渲染中的任何颜色变化都是由于照明、阴影和其他着色器变体中的特性。完全可以使用照明和阴影来产生实色材料,但最简单的例子只是用相同的颜色填充光栅段,就像我们的第一个着色器一样。

纹理材料可能在图像文件(如 JPG)中定义表面细节。纹理就像贴在物体表面上的墙纸。它们可以被广泛使用,并负责用户在物体上感知到的大部分细节。一个实色的球体可能看起来像一个乒乓球。一个纹理球可能看起来像地球。可以添加更多的纹理通道来定义阴影的变化,甚至在表面处于阴影时发光。在第六章结束时,您将看到这种效果,当我们在地球的黑暗一侧添加人造光源时。

更真实的基于物理的着色超出了纹理贴图,还包括模拟的高度图、金属光泽和其他瑕疵,比如锈迹或污垢。我们不会在本书中涉及到这一点,但在图形引擎如 Unity 3D 和虚幻引擎中很常见。我们的RenderBox库可以扩展以支持它。

目前,我们将为基本的实色材料和相关着色器构建基础设施。在本章的后面,我们将扩展它以支持照明。

抽象材料

renderbox/materials/文件夹中,创建一个名为Material的新 Java 类,并开始编写如下:

public abstract class Material {
    private static final String TAG = "RenderBox.Material";

    protected static final float[] modelView = new float[16];
    protected static final float[] modelViewProjection = new float[16];

    public static int createProgram(int vertexShaderResource, int fragmentShaderResource){
        int vertexShader = loadGLShader(GLES20.GL_VERTEX_SHADER, vertexShaderResource);
        int passthroughShader = loadGLShader(GLES20.GL_FRAGMENT_SHADER, fragmentShaderResource);

        int program = GLES20.glCreateProgram();
        GLES20.glAttachShader(program, vertexShader);
        GLES20.glAttachShader(program, passthroughShader);
        GLES20.glLinkProgram(program);
        GLES20.glUseProgram(program);

        RenderBox.checkGLError("Material.createProgram");
        return program;
    }

    public abstract void draw(float[] view, float[] perspective);
}

这定义了一个抽象类,将用于扩展我们定义的各种类型的材料。createProgram方法加载指定的着色器脚本,并构建一个附有着色器的 OpenGL ES 程序。

我们还定义了一个抽象的draw()方法,将在每个着色器中单独实现。除其他事项外,它要求在类的顶部声明modelViewmodelViewProjection变换矩阵。在这一点上,我们实际上只会使用modelViewProjection,但当我们添加照明时,将需要一个单独的引用modelView矩阵。

接下来,将以下实用方法添加到Material类中以加载着色器:

    /**
     * Converts a raw text file, saved as a resource, into an OpenGL ES shader.
     *
     * @param type The type of shader we will be creating.
     * @param resId The resource ID of the raw text file about to be turned into a shader.
     * @return The shader object handler.
     */
    public static int loadGLShader(int type, int resId) {
        String code = readRawTextFile(resId);
        int shader = GLES20.glCreateShader(type);
        GLES20.glShaderSource(shader, code);
        GLES20.glCompileShader(shader);

        // Get the compilation status.
        final int[] compileStatus = new int[1];
        GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0);

        // If the compilation failed, delete the shader.
        if (compileStatus[0] == 0) {
            Log.e(TAG, "Error compiling shader: " + GLES20.glGetShaderInfoLog(shader));
            GLES20.glDeleteShader(shader);
            shader = 0;
        }

        if (shader == 0) {
            throw new RuntimeException("Error creating shader.");
        }

        return shader;
    }

    /**
     * Converts a raw text file into a string.
     *
     * @param resId The resource ID of the raw text file about to be turned into a shader.
     * @return The context of the text file, or null in case of error.
     */
    private static String readRawTextFile(int resId) {
        InputStream inputStream = RenderBox.instance.mainActivity.getResources().openRawResource(resId);
        try {
            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line).append("\n");
            }
            reader.close();
            return sb.toString();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

如第三章 纸箱中所讨论的,这些方法将加载一个着色器脚本并对其进行编译。

稍后,我们将从这个类派生特定的材料,并定义每个材料将使用的特定着色器。

Math 包

在第三章 纸箱中,我们介绍了 3D 几何和矩阵数学计算。我们将把这些整合成更有用的函数。

我们组合的许多数学代码来自现有的开源项目(在源代码的注释中给出了归属)。毕竟,我们不妨利用那些喜欢这些东西并且开源了优秀的真实和经过测试的代码的数学天才。代码列表包含在本书的文件下载中。

注意

以下列表记录了我们的数学 API。实际代码包含在本书的文件下载和 GitHub 存储库中。

一般来说,这些数学都属于线性代数的范畴,但其中大部分是特定于图形编程的,并且在现代 CPU 上进行快速浮点数运算的限制内运行。

我们鼓励您浏览本书附带的源代码,显然您需要访问才能完成项目。可以说,所有包含的内容对于 3D 游戏引擎来说都是非常标准的,并且实际上在很大程度上是从一个名为LibGDX的开源引擎中获取的(或者经过检查)。LibGDX 的数学库非常庞大,针对移动 CPU 进行了优化,并且可以成为我们更简单的数学包的很好的替代品。我们还将广泛使用 Android 的Matrix类,在大多数情况下,它在本机代码中运行,并避免了Java 虚拟机JVM或在 Android 情况下的 Dalvik VM)的开销。

以下是我们数学 API 的摘要。

MathUtils

MathUtils变量和方法大多是不言自明的:PIsincos等,定义为使用floats作为 Java 的Math类的替代,该类包含双精度。在计算机图形学中,我们使用浮点数。数学计算需要更少的功率和更少的晶体管,精度损失是可以接受的。您的MathUtils类应该如下所示:

// File: renderbox/math/MathUtils.java
public class MathUtils {
    static public final float PI = 3.1415927f;
    static public final float PI2 = PI * 2;
    static public final float degreesToRadians = PI / 180;
    static public final float radiansToDegrees = 180f / PI;
}

Matrix4

Matrix4类管理 4x4 变换矩阵,用于在三维空间中平移(位置)、旋转和缩放点。我们很快将充分利用这些。以下是Matrix4类的缩减版本,其中删除了函数体:

// File: renderbox/math/Matrix4.java
public class Matrix4{
    public final float[] val = new float[16];
    public Matrix4()
    public Matrix4 toIdentity()

    public static Matrix4 TRS(Vector3 position, Vector3 rotation, Vector3 scale)
    public Matrix4 translate(Vector3 position)
    public Matrix4 rotate(Vector3 rotation)
    public Matrix4 scale(Vector3 scale)
    public Vector3 multiplyPoint3x4(Vector3 v)
    public Matrix4 multiply(Matrix4 matrix)
    public Matrix4 multiply(float[] matrix)

特别注意 TRS 函数。它被Transform类用于将位置、旋转和缩放信息组合成一个有用的矩阵,代表所有三个。创建此矩阵的顺序很重要。首先,我们生成一个平移矩阵,然后我们旋转和缩放它。生成的矩阵可以与任何 3D 点(我们的顶点)相乘,以按层次应用这三个操作。

四元数

四元数以这样的方式表示三维空间中的旋转方向,当两个四元数组合时,不会丢失任何信息。

从人类的角度来看,更容易将旋转方向视为三个欧拉(发音为“欧拉”)角,因为我们考虑旋转的三个维度:俯仰、偏航和滚动。我们使用四元数而不是更直接的旋转向量表示的原因是,根据您将三个欧拉旋转应用于对象的顺序,结果的 3D 方向将不同。

注意

有关四元数和欧拉角的更多信息,请参考以下链接:

尽管四元数是一个四维结构,但我们将每个四元数视为一个单一值,它代表一个 3D 方向。因此,当我们连续应用多个旋转操作时,不会出现一个轴的旋转影响另一个轴的效果的问题。如果这一切都毫无意义,不要担心。这是 3D 图形中最棘手的概念之一。以下是简化的Quaternion类:

// File: renderbox/math/Quaternion.java
public class Quaternion {
    public float x,y,z,w;
    public Quaternion()
    public Quaternion(Quaternion quat)

    public Quaternion setEulerAngles (float pitch, float yaw, float roll) public Quaternion setEulerAnglesRad (float pitch, float yaw, float roll) 
    public Quaternion conjugate ()
    public Quaternion multiply(final Quaternion other)
    public float[] toMatrix4()
    public String toString()

Vector2

Vector2是由(X,Y)坐标定义的二维点或方向向量。使用Vector2类,您可以转换和操作向量。以下是简化的Vector2类:

// File: renderbox/math/Vector2.java
public class Vector2 {
    public float x;
    public float y;

    public static final Vector2 zero = new Vector2(0, 0);
    public static final Vector2 up = new Vector2(0, 1);
    public static final Vector2 down = new Vector2(0, -1);
    public static final Vector2 left = new Vector2(-1, 0);
    public static final Vector2 right = new Vector2(1, 0);

    public Vector2()

    public Vector2(float xValue, float yValue)
    public Vector2(Vector2 other)
    public Vector2(float[] vec)

    public final Vector2 add(Vector2 other)
    public final Vector2 add(float otherX, float otherY, float otherZ)
    public final Vector2 subtract(Vector2 other)
    public final Vector2 multiply(float magnitude)
    public final Vector2 multiply(Vector2 other)
    public final Vector2 divide(float magnitude)
    public final Vector2 set(Vector2 other)
    public final Vector2 set(float xValue, float yValue)
    public final Vector2 scale(float xValue, float yValue)
    public final Vector2 scale(Vector2 scale)
    public final float dot(Vector2 other)
    public final float length2()
    public final float distance2(Vector2 other)
    public Vector2 normalize()
    public final Vector2 zero()
    public float[] toFloat3()
    public float[] toFloat4()
    public float[] toFloat4(float w)
    public String toString()

Vector3

Vector3是由 X、Y 和 Z 坐标定义的三维点或方向向量。使用Vector3类,您可以转换和操作向量。以下是简化的Vector3类:

// File: renderbox/math/Vector3.java
public final class Vector3 {
    public float x;
    public float y;
    public float z;

    public static final Vector3 zero = new Vector3(0, 0, 0);
    public static final Vector3 up = new Vector3(0, 1, 0);
    public static final Vector3 down = new Vector3(0, -1, 0);
    public static final Vector3 left = new Vector3(-1, 0, 0);
    public static final Vector3 right = new Vector3(1, 0, 0);
    public static final Vector3 forward = new Vector3(0, 0, 1);
    public static final Vector3 backward = new Vector3(0, 0, -1);

    public Vector3()
    public Vector3(float xValue, float yValue, float zValue)
    public Vector3(Vector3 other)
    public Vector3(float[] vec)

    public final Vector3 add(Vector3 other)
    public final Vector3 add(float otherX, float otherY, float otherZ)
    public final Vector3 subtract(Vector3 other)
    public final Vector3 multiply(float magnitude)
    public final Vector3 multiply(Vector3 other)
    public final Vector3 divide(float magnitude)
    public final Vector3 set(Vector3 other)
    public final Vector3 set(float xValue, float yValue, float zValue)
    public final Vector3 scale(float xValue, float yValue, float zValue)
    public final Vector3 scale(Vector3 scale)
    public final float dot(Vector3 other)
    public final float length()
    public final float length2()
    public final float distance2(Vector3 other)
    public Vector3 normalize()
    public final Vector3 zero()
    public float[] toFloat3()
    public float[] toFloat4()
    public float[] toFloat4(float w)
    public String toString()

Vector2Vector3共享许多相同的功能,但要特别注意 3D 中存在而 2D 中不存在的函数。接下来,我们将看到在实现Transform类时如何使用数学库。

Transform 类

3D 虚拟现实场景将由各种对象构建,每个对象在 3D 空间中由Transform定义了位置、旋转和缩放。

允许对变换进行分层分组也是自然而然的有用的。这种分组还创建了本地空间和世界空间之间的区别,其中子对象只跟踪它们的平移、旋转和缩放TRS)与其父对象的差异(本地空间)。我们存储的实际数据是本地位置(我们将使用位置和平移这两个词),旋转和缩放。全局位置、旋转和缩放是通过将本地 TRS 组合到所有父级链中计算出来的。

首先,让我们定义Transform类。在 Android Studio 的层次结构面板中,右键单击renderbox/,转到新建 | Java 类,并将其命名为Transform

每个Transform可能有一个或多个关联的组件。通常只有一个,但可以添加尽可能多的组件(正如我们将在本书的其他项目中看到的)。我们将在变换中维护组件列表,如下所示:

public class Transform {
    private static final String TAG = "RenderBox.Transform";

    List<Component> components = new ArrayList<Component>();

     public Transform() {}

    public Transform addComponent(Component component){
        component.transform = this;
        return this;
    }
    public List<Component> getComponents(){
        return components;
    }
}

我们将在下一个主题中定义Component类。如果在定义之前现在引用它真的让你困扰,你可以从renderbox/components文件夹中的空 Component Java 类开始。

现在回到Transform类。Transform对象在空间中有一个位置、方向和缩放,由其localPositionlocalRotationlocalScale变量定义。让我们定义这些私有变量,然后添加方法来操作它们。

此外,由于变换可以按层次结构排列,我们将包括对可能的parent变换的引用,如下所示:

    private Vector3 localPosition = new Vector3(0,0,0);
    private Quaternion localRotation = new Quaternion();
    private Vector3 localScale = new Vector3(1,1,1);

    private Transform parent = null;

位置、旋转和缩放值被初始化为身份值,也就是说,直到它们在其他地方被明确设置之前,没有位置偏移、旋转或调整大小。请注意,身份缩放是(1,1,1)。

parent变换变量允许每个变换在层次结构中有一个父级。你可以在变换中保留子对象的列表,但你可能会惊讶地知道,在不必深入层次结构的情况下,你可以走得很远。如果你可以避免,就像我们一样,你可以在设置/取消父引用时节省大量的分支。维护子对象列表意味着每次取消父对象的操作都需要O(n)的时间,设置父对象时还需要额外的O(1)插入成本。在子对象中寻找特定对象也不是很有效率。

父级方法

使用setParentunParent方法可以将变换添加到层次结构中的位置或从中移除。现在让我们来定义它们:

    public Transform setParent(Transform Parent){
        setParent(parent, true);
        return this;
    }

    public Transform setParent(Transform parent, boolean updatePosition){
        if(this.parent == parent)
        //Early-out if setting same parent--don't do anything
            return this;
        if(parent == null){
            unParent(updatePosition);
            return this;
        }

        if(updatePosition){
            Vector3 tmp_position = getPosition();
            this.parent = parent;
            setPosition(tmp_position);
        } else {
            this.parent = parent;
        }
        return this;
    }

    public Transform upParent(){
        unParent(true);
        return this;
    }

    public Transform unParent(boolean updatePosition){
        if(parent == null)
        //Early out--we already have no parent
            return this;
        if(updatePosition){
            localPosition = getPosition();
        }
        parent = null;
        return this;
    }

简单地说,setParent方法将this.parent设置为给定的父级变换。可选地,您可以指定相对于父级更新位置。我们添加了一个优化,如果父级已经设置,则跳过此过程。将父级设置为null等同于调用unParent

unParent方法将变换从层次结构中移除。可选地,您可以指定相对于(先前的)父级更新位置,以便变换现在与层次结构断开连接,但仍保持在世界空间中的相同位置。

请注意,当进行父子关系的设置和解除时,旋转和缩放也可以并且应该更新。在本书的项目中我们不需要这些,所以它们留作读者的练习。另外,注意我们的setParent方法包括一个参数来决定是否更新位置。如果设置为false,操作会更快一些,但是如果父变换没有设置为单位矩阵(无平移、旋转或缩放),对象的全局状态会发生变化。为了方便起见,您可以将updatePosition设置为true,这将将当前的全局变换应用到局部变量中,保持对象在空间中固定,保持当前的旋转和缩放。

位置方法

setPosition方法设置相对于父级的变换位置,或者如果没有父级,则将绝对世界位置应用于局部变量。如果要使用向量或单独的分量值,提供了两个重载。getPosition将基于父级变换计算世界空间位置。请注意,这将与变换层次的深度相关的 CPU 成本。作为优化,您可能希望在Transform类中包含一个系统来缓存世界空间位置,在修改父级变换时使缓存失效。一个更简单的替代方法是确保在调用getPosition后立即将位置存储在局部变量中。相同的优化也适用于旋转和缩放。

定义位置的获取器和设置器如下:

    public Transform setPosition(float x, float y, float z){
        if(parent != null){
            localPosition = new Vector3(x,y,z).subtract(parent.getPosition());
        } else {
            localPosition = new Vector3(x, y, z);
        }
        return this;
    }

    public Transform setPosition(Vector3 position){
        if(parent != null){
            localPosition = new Vector3(position).subtract(parent.getPosition());
        } else {
            localPosition = position;
        }
        return this;
    }

    public Vector3 getPosition(){
        if(parent != null){
            return Matrix4.TRS(parent.getPosition(), parent.getRotation(), parent.getScale()).multiplyPoint3x4(localPosition);
        }
        return localPosition;
    }

    public Transform setLocalPosition(float x, float y, float z){
        localPosition = new Vector3(x, y, z);
        return this;
    }

    public Transform setLocalPosition(Vector3 position){
        localPosition = position;
        return this;
    }

    public Vector3 getLocalPosition(){
        return localPosition;
    }

旋转方法

setRotation方法设置相对于父级的变换旋转,如果没有父级,则将绝对世界旋转应用于局部变量。同样,多个重载提供了不同输入数据的选项。定义旋转的获取器和设置器如下:

    public Transform setRotation(float pitch, float yaw, float roll){
        if(parent != null){
            localRotation = new Quaternion(parent.getRotation()).multiply(new Quaternion().setEulerAngles(pitch, yaw, roll).conjugate()).conjugate();
        } else {
            localRotation = new Quaternion().setEulerAngles(pitch, yaw, roll);
        }
        return this;
    }

    /**
     * Set the rotation of the object in global space
     * Note: if this object has a parent, setRoation modifies the input rotation!
     * @param rotation
     */
    public Transform setRotation(Quaternion rotation){
        if(parent != null){
            localRotation = new Quaternion(parent.getRotation()).multiply(rotation.conjugate()).conjugate();
        } else {
            localRotation = rotation;
        }
        return this;
    }

    public Quaternion getRotation(){
        if(parent != null){
            return new Quaternion(parent.getRotation()).multiply(localRotation);
        }
        return localRotation;
    }

    public Transform setLocalRotation(float pitch, float yaw, float roll){
        localRotation = new Quaternion().setEulerAngles(pitch, yaw, roll);
        return this;
    }

    public Transform setLocalRotation(Quaternion rotation){
        localRotation = rotation;
        return this;
    }

    public Quaternion getLocalRotation(){
        return localRotation;
    }

    public Transform rotate(float pitch, float yaw, float roll){
        localRotation.multiply(new Quaternion().setEulerAngles(pitch, yaw, roll));
        return this;
    }

缩放方法

setScale方法设置相对于父级的变换比例,或者如果没有父级,则将绝对比例应用于局部变量。定义比例的获取器和设置器如下:

    public Vector3 getScale(){
        if(parent != null){
            Matrix4 result = new Matrix4();
            result.setRotate(localRotation);
            return new Vector3(parent.getScale())
                .scale(localScale);
        }
        return localScale;
    }

    public Transform setLocalScale(float x, float y, float z){
        localScale = new Vector3(x,y,z);
        return this;
    }

    public Transform setLocalScale(Vector3 scale){
        localScale = scale;
        return this;
    }

    public Vector3 getLocalScale(){
        return localScale;
    }

    public Transform scale(float x, float y, float z){
        localScale.scale(x, y, z);
        return this;
    }

变换为矩阵并绘制

Transform类的最后一件事是将单位矩阵转换为一个告诉 OpenGL 如何正确绘制对象的矩阵。为此,我们按顺序对矩阵进行平移、旋转和缩放。从技术上讲,我们还可以使用矩阵做一些很酷的事情,比如扭曲和倾斜模型,但数学已经足够复杂了。如果您想了解更多,请在搜索引擎中输入变换矩阵四元数到矩阵以及我们一直在讨论的其他术语。所有这些背后的数学都是迷人的,而且太详细了,无法在一个段落中解释清楚。

我们还提供了drawMatrix()函数,用于设置绘制调用的光照和模型矩阵。由于光照模型是一个中间步骤,将这个调用合并起来是有意义的;

    public float[] toFloatMatrix(){
        return Matrix4.TRS(getPosition(), getRotation(), getScale()).val;
    }

    public float[] toLightMatrix(){
        return Matrix4.TR(getPosition(), getRotation()).val;
    }

    /**
     * Set up the lighting model and model matrices for a draw call
     * Since the lighting model is an intermediate step, it makes sense to combine this call
     */
    public void drawMatrices() {
        Matrix4 modelMatrix = Matrix4.TR(getPosition(), getRotation());
        RenderObject.lightingModel = modelMatrix.val;
        modelMatrix = new Matrix4(modelMatrix);
        RenderObject.model = modelMatrix.scale(getScale()).val;
    }

drawMatrices方法使用了RenderObject类的变量,这些变量稍后将被定义。我们只是将我们的矩阵设置为RenderObject类中的静态变量,这似乎非常反 Java。正如您将看到的,实际上并不需要多个lightingModel对象和模型的实例存在。它们总是在每个对象绘制时及时计算出来。如果我们要引入避免一直重新计算这个矩阵的优化,保留这些信息就是有意义的。为了简单起见,我们只是在每次绘制每个对象时重新计算矩阵,因为它可能与上一帧不同。

接下来,我们将看到Transform类在我们实现Component类时是如何被使用的,这个类将被一些定义 3D 场景中对象的类所扩展。

组件类

我们的 3D 虚拟现实场景由各种组件组成。组件可能包括几何对象、灯光和摄像机。根据其关联的变换,组件可以在 3D 空间中定位、旋转和缩放。让我们创建一个Component类,它将作为场景中其他对象类的基础。

如果您还没有创建Component.java,现在在renderbox/components文件夹中创建一个。定义如下:

public class Component {
    public Transform transform;

    public boolean enabled = true;
}

我们包含了一个enabled标志,当我们绘制场景时,这将非常方便地隐藏/显示对象。

就是这样。接下来,我们将定义我们的第一个组件RenderObject,以表示场景中的几何对象。

RenderObject 组件

RenderObject将作为可以在场景中渲染的几何对象的父类。RenderObject扩展了Component,因此它有一个Transform

renderbox/components文件夹中,创建一个名为RenderObject的新 Java 类。将其定义为扩展Component的抽象类:

public abstract class RenderObject extends Component {
    private static final String TAG = "RenderObject";

    public RenderObject(){
        super();
        RenderBox.instance.renderObjects.add(this);
    }
}

我们首先要做的是让每个实例将自己添加到由RenderBox实例维护的renderObjects列表中。现在让我们转到RenderBox类,并为这个列表添加支持。

打开RenderBox.java文件并添加一个renderObjects列表:

public class RenderBox implements CardboardView.StereoRenderer {

    public List<RenderObject> renderObjects = new ArrayList<RenderObject>();

现在,回到RenderObject类;我们将实现三个方法:allocateFloatBufferallocateShortBufferdraw

OpenGL ES 要求我们为各种数据分配许多不同的内存缓冲区,包括模型顶点、法向量和索引列表。allocateFloatBufferallocateShortBuffer方法是对象可以用于浮点数和整数的实用方法。索引是整数(具体来说是 short);其他所有内容都将是浮点数。这些将可供派生对象类使用:

public abstract class RenderObject extends Component {
       ...
    protected static FloatBuffer allocateFloatBuffer(float[] data){
        ByteBuffer bbVertices = ByteBuffer.allocateDirect(data.length * 4);
        bbVertices.order(ByteOrder.nativeOrder());
        FloatBuffer buffer = bbVertices.asFloatBuffer();
        buffer.put(data);
        buffer.position(0);
        return buffer;
    }

    protected static ShortBuffer allocateShortBuffer(short[] data){
        ByteBuffer bbVertices = ByteBuffer.allocateDirect(data.length * 2);
        bbVertices.order(ByteOrder.nativeOrder());
        ShortBuffer buffer = bbVertices.asShortBuffer();
        buffer.put(data);
        buffer.position(0);
        return buffer;
    }
}

聪明的读者可能已经注意到我们首先使用ByteBuffer,然后将其转换为FloatBufferShortBuffer。虽然从字节到浮点的转换可能是有道理的——原始内存通常不表示为浮点数——但有些人可能会想知道为什么我们不从一开始就分配ShortBuffer作为ShortBuffer。实际上,原因在这两种情况下是一样的。我们希望利用allocateDirect方法,这是更有效率的,只存在于ByteBuffer类中。

最终,RenderObject组件的目的是在屏幕上绘制几何图形。这是通过对 3D 视图进行变换并通过Material类进行渲染来实现的。让我们为材质定义变量,一些 setter 和 getter 方法以及draw方法:

    protected Material material;
    public static float[] model;
    public static float[] lightingModel;

    public Material getMaterial(){
        return material;
    }
    public RenderObject setMaterial(Material material){
        this.material = material;
        return this;
    }

    public void draw(float[] view, float[] perspective){
        if(!enabled)
            return;
        //Compute position every frame in case it changed
        transform.drawMatrices();
        material.draw(view, perspective);
    }

draw方法为这个对象准备了模型变换,大部分绘制动作发生在材质中。draw方法将从当前的Camera组件中调用,因为它响应 Cardboard SDK 的onDrawEye挂钩的姿势。如果组件未启用,则会被跳过。

RenderObject类是抽象的;我们不会直接使用RenderObjects。相反,我们将派生对象类,比如CubeSphere。让我们从RenderObject组件中创建Cube类。

Cube RenderObject 组件

为了演示目的,我们将从一个简单的立方体开始。稍后,我们将通过光照对其进行改进。在第三章中,我们定义了一个Cube模型。我们将从这里使用相同的类和数据结构。您甚至可以复制代码,但它在下面的文本中显示。在renderbox/components/文件夹中创建一个CubeJava 类:

// File: renderbox/components/Cube.java
public class Cube {
    public static final float[] CUBE_COORDS = new float[] {
            // Front face
            -1.0f, 1.0f, 1.0f,
            -1.0f, -1.0f, 1.0f,
            1.0f, 1.0f, 1.0f,
            -1.0f, -1.0f, 1.0f,
            1.0f, -1.0f, 1.0f,
            1.0f, 1.0f, 1.0f,

            // Right face
            1.0f, 1.0f, 1.0f,
            1.0f, -1.0f, 1.0f,
            1.0f, 1.0f, -1.0f,
            1.0f, -1.0f, 1.0f,
            1.0f, -1.0f, -1.0f,
            1.0f, 1.0f, -1.0f,

            // Back face
            1.0f, 1.0f, -1.0f,
            1.0f, -1.0f, -1.0f,
            -1.0f, 1.0f, -1.0f,
            1.0f, -1.0f, -1.0f,
            -1.0f, -1.0f, -1.0f,
            -1.0f, 1.0f, -1.0f,

            // Left face
            -1.0f, 1.0f, -1.0f,
            -1.0f, -1.0f, -1.0f,
            -1.0f, 1.0f, 1.0f,
            -1.0f, -1.0f, -1.0f,
            -1.0f, -1.0f, 1.0f,
            -1.0f, 1.0f, 1.0f,

            // Top face
            -1.0f, 1.0f, -1.0f,
            -1.0f, 1.0f, 1.0f,
            1.0f, 1.0f, -1.0f,
            -1.0f, 1.0f, 1.0f,
            1.0f, 1.0f, 1.0f,
            1.0f, 1.0f, -1.0f,

            // Bottom face
            1.0f, -1.0f, -1.0f,
            1.0f, -1.0f, 1.0f,
            -1.0f, -1.0f, -1.0f,
            1.0f, -1.0f, 1.0f,
            -1.0f, -1.0f, 1.0f,
            -1.0f, -1.0f, -1.0f,
    };

    public static final float[] CUBE_COLORS_FACES = new float[] {
            // Front, green
            0f, 0.53f, 0.27f, 1.0f,
            // Right, blue
            0.0f, 0.34f, 0.90f, 1.0f,
            // Back, also green
            0f, 0.53f, 0.27f, 1.0f,
            // Left, also blue
            0.0f, 0.34f, 0.90f, 1.0f,
            // Top, red
            0.84f,  0.18f,  0.13f, 1.0f,
            // Bottom, also red
            0.84f,  0.18f,  0.13f, 1.0f
    };

    /**
     * Utility method for generating float arrays for cube faces
     *
     * @param model - float[] array of values per face.
     * @param coords_per_vertex - int number of coordinates per vertex.
     * @return - Returns float array of coordinates for triangulated cube faces.
     *               6 faces X 6 points X coords_per_vertex
     */
    public static float[] cubeFacesToArray(float[] model, int coords_per_vertex) {
        float coords[] = new float[6 * 6 * coords_per_vertex];
        int index = 0;
        for (int iFace=0; iFace < 6; iFace++) {
            for (int iVertex=0; iVertex < 6; iVertex++) {
                for (int iCoord=0; iCoord < coords_per_vertex; iCoord++) {
                    coords[index] = model[iFace*coords_per_vertex + iCoord];
                    index++;
                }
            }
        }
        return coords;
    }
}

我们列出了立方体每个面的坐标。每个面由两个三角形组成,共 12 个三角形,或者共 36 组坐标来定义立方体。

我们还列出了立方体每个面的不同颜色。而不是将颜色重复 36 次,有cubeFacesToArray方法来生成它们。

现在,我们需要升级Cube以适用于RenderBox

首先,添加extends RenderObject。这将在构造函数中提供super()方法,并允许您调用draw()方法:

public class Cube extends RenderObject {

为其顶点和颜色分配缓冲区,并创建将用于渲染的Material类:

    public static FloatBuffer vertexBuffer;
    public static FloatBuffer colorBuffer;
    public static final int numIndices = 36;

    public Cube(){
        super();
        allocateBuffers();
        createMaterial();
    }

    public static void allocateBuffers(){
        //Already setup?
        if (vertexBuffer != null) return;
        vertexBuffer = allocateFloatBuffer(CUBE_COORDS);
        colorBuffer = allocateFloatBuffer(cubeFacesToArray(CUBE_COLORS_FACES, 4));
    }

    public void createMaterial(){
        VertexColorMaterial mat = new VertexColorMaterial();
        mat.setBuffers(vertexBuffer, colorBuffer, numIndices);
        material = mat;
    }

我们通过检查vertexBuffer是否为null来确保allocateBuffers只运行一次。

我们计划使用VertexColorMaterial类来渲染大多数立方体。接下来将定义它。

Camera组件将调用Cube类的draw方法(从RenderObject继承),然后调用Material类的draw方法。draw方法将从主Camera组件中调用,因为它响应 Cardboard SDK 的onDrawEye挂钩。

顶点颜色材质和着色器

Cube组件需要一个Material来在显示器上呈现。我们的Cube为每个面定义了单独的颜色,这些颜色被定义为单独的顶点颜色。我们将定义一个VertexColorMaterial实例和相应的着色器。

顶点颜色着色器

至少,OpenGL 管道要求我们定义一个顶点着色器,它将顶点从 3D 空间转换到 2D 空间,以及一个片段着色器,它计算光栅段的像素颜色值。与我们在第三章中创建的简单着色器Cardboard Box类似,我们将创建两个文件,vertex_color_vertex.shadervertex_color_fragment.shader。除非你已经这样做了,否则创建一个新的 Android 资源目录,类型为raw,并将其命名为raw。然后,对于每个文件,右键单击目录,然后转到新建 | 文件。对于这两个文件,使用以下代码。顶点着色器的代码如下:

// File:res/raw/vertex_color_vertex.shader
uniform mat4 u_Model;
uniform mat4 u_MVP;

attribute vec4 a_Position;
attribute vec4 a_Color;

varying vec4 v_Color;

void main() {
   v_Color = a_Color;
   gl_Position = u_MVP * a_Position;
}

片段着色器的代码如下:

//File: res/raw/vertex_color_fragment.shader
precision mediump float;
varying vec4 v_Color;

void main() {
    gl_FragColor = v_Color;
}

vertex着色器通过u_MVP矩阵转换每个顶点,这个矩阵将由Material类的绘制函数提供。片段着色器只是通过顶点着色器指定的颜色。

VertexColorMaterial

现在,我们准备实现我们的第一个材质,VertexColorMaterial类。在renderbox/materials/目录中创建一个名为VertexColorMaterial的新 Java 类。将类定义为extends Material

public class VertexColorMaterial extends Material {

我们将要实现的方法如下:

  • VertexColorMaterial:这些是构造函数

  • setupProgram:这将创建着色器程序并获取其 OpenGL 变量位置

  • setBuffers:这将设置用于渲染的分配的缓冲区

  • draw:这将从视图角度绘制模型

以下是完整的代码:

public class VertexColorMaterial extends Material {
    static int program = -1;

    static int positionParam;
    static int colorParam;
    static int modelParam;
    static int MVPParam;

    FloatBuffer vertexBuffer;
    FloatBuffer colorBuffer;
    int numIndices;

    public VertexColorMaterial(){
        super();
        setupProgram();
    }

    public static void setupProgram(){
        //Already setup?
		if (program != -1) return;
        //Create shader program
        program = createProgram(R.raw.vertex_color_vertex, R.raw.vertex_color_fragment);

        //Get vertex attribute parameters
        positionParam = GLES20.glGetAttribLocation(program, "a_Position");
        colorParam = GLES20.glGetAttribLocation(program, "a_Color");

        //Enable vertex attribute parameters
        GLES20.glEnableVertexAttribArray(positionParam);
        GLES20.glEnableVertexAttribArray(colorParam);

        //Shader-specific parameters
        modelParam = GLES20.glGetUniformLocation(program, "u_Model");
        MVPParam = GLES20.glGetUniformLocation(program, "u_MVP");

        RenderBox.checkGLError("Solid Color Lighting params");
    }

    public void setBuffers(FloatBuffer vertexBuffer,  FloatBuffer colorBuffer, int numIndices){
        this.vertexBuffer = vertexBuffer;
        this.colorBuffer = colorBuffer;
        this.numIndices = numIndices;
    }

    @Override
    public void draw(float[] view, float[] perspective) {
        Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.model, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, modelView, 0);

        GLES20.glUseProgram(program);

        // Set the Model in the shader, used to calculate lighting
        GLES20.glUniformMatrix4fv(modelParam, 1, false, RenderObject.model, 0);

        // Set the position of the cube
        GLES20.glVertexAttribPointer(positionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);

        // Set the ModelViewProjection matrix in the shader.
        GLES20.glUniformMatrix4fv(MVPParam, 1, false, modelViewProjection, 0);

        // Set the normal positions of the cube, again for shading
        GLES20.glVertexAttribPointer(colorParam, 4, GLES20.GL_FLOAT, false, 0, colorBuffer);

        // Set the ModelViewProjection matrix in the shader.
        GLES20.glUniformMatrix4fv(MVPParam, 1, false, modelViewProjection, 0);

        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, numIndices);
    }

    public static void destroy(){
        program = -1;
    }
}

setupProgram方法为我们在res/raw/目录中创建的两个着色器vertex_color_vertexvertex_color_fragment创建了一个 OpenGL ES 程序。然后,它使用GetAttribLocationGetUniformLocation调用获取positionParamcolorParamMVPParm着色器变量的引用,这些引用在稍后用于绘制。

setBuffers方法设置了用于定义将使用此材质绘制的对象的内存缓冲区。该方法假定对象模型由一组 3D 顶点(X、Y 和 Z 坐标)组成。

draw()方法使用给定的模型视图透视MVP)变换矩阵在缓冲区中渲染指定的对象。(有关详细解释,请参阅第三章中的3D 相机、透视和头部旋转部分,Cardboard Box。)

你可能已经注意到我们没有使用之前提到的ShortBuffer函数。稍后,材料将使用glDrawElements调用以及索引缓冲区。glDrawArrays本质上是glDrawElements的退化形式,它假定一个顺序的索引缓冲区(即 0、1、2、3 等)。对于复杂的模型,重用三角形之间的顶点是更有效的,这就需要一个索引缓冲区。

为了完整起见,我们还将为每个Material类提供一个destroy()方法。稍后我们将确切地知道为什么必须销毁材料。

正如你所看到的,Material封装了许多底层的 OpenGL ES 2.0 调用,用于编译着色器脚本,创建渲染程序,在着色器中设置模型视图透视矩阵,并绘制 3D 图形元素。

现在我们可以实现Camera组件了。

Camera组件

Camera类是另一种Component类型,像其他组件对象一样在空间中定位。摄像机很特殊,因为通过摄像机的眼睛,我们渲染场景。对于 VR,我们为每只眼睛渲染一次。

让我们创建Camera类,然后看看它是如何工作的。在renderbox/components文件夹中创建它,并定义如下:

public class Camera extends Component {
    private static final String TAG = "renderbox.Camera";

    private static final float Z_NEAR = .1f;
    public static final float Z_FAR = 1000f;

    private final float[] camera = new float[16];
    private final float[] view = new float[16];
    public Transform getTransform(){return transform;}

    public Camera(){
        //The camera breaks pattern and creates its own Transform
        transform = new Transform();
    }

    public void onNewFrame(){
        // Build the camera matrix and apply it to the ModelView.
        Vector3 position = transform.getPosition();
        Matrix.setLookAtM(camera, 0, position.x, position.y, position.z + Z_NEAR, position.x, position.y, position.z, 0.0f, 1.0f, 0.0f);

        RenderBox.checkGLError("onNewFrame");
    }

    public void onDrawEye(Eye eye) {
        GLES20.glEnable(GLES20.GL_DEPTH_TEST);
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);

        RenderBox.checkGLError("glClear");

        // Apply the eye transformation to the camera.
        Matrix.multiplyMM(view, 0, eye.getEyeView(), 0, camera, 0);

        // Build the ModelView and ModelViewProjection matrices
        float[] perspective = eye.getPerspective(Z_NEAR, Z_FAR);

        for(RenderObject obj : RenderBox.instance.renderObjects) {
            obj.draw(view, perspective);
        }
        RenderBox.checkGLError("Drawing complete");
    }
}

Camera类实现了两个方法:onNewFrameonDrawEye,这些方法将从RenderBox类委托而来(而RenderBox类又是从MainActivity委托而来)。

正如其名称所示,onNewFrame在每个新帧更新时被调用。它接收当前 Cardboard SDK 的HeadTransform,描述了用户的头部方向。我们的摄像机实际上不需要headTransform值,因为Eye.getEyeView()中已经包含了旋转信息,它与摄像机矩阵结合在一起。相反,我们只需要使用Matrix.setLookAtM来定义其位置和初始方向(参考developer.android.com/reference/android/opengl/Matrix.html)。

onDrawEye方法由 Cardboard SDK 为每只眼睛视图调用一次。给定一个 Cardboard SDK 眼睛视图,该方法开始渲染场景。它清除表面,包括深度缓冲区(用于确定可见像素),将眼睛变换应用于摄像机(包括透视),然后绘制场景中的每个RenderObject对象。

RenderBox方法

好了!我们离目标更近了。现在,我们准备使用之前创建的代码在RenderBox中构建一个小场景。首先,场景将简单地由一个有颜色的立方体和一个摄像机组成。

在这个项目开始时,我们创建了骨架RenderBox类,它实现了CardboardView.StereoRenderer

现在,我们添加一个Camera实例。在RenderBox类的顶部,声明mainCamera,它将在onSurfaceCreated中初始化:

    public static Camera mainCamera;

请注意,Android Studio 可能会找到其他Camera类;确保它使用我们在此包中创建的类。

在您的应用程序启动后不久,MainActivity类被实例化,onSurfaceCreated回调被调用。这是我们可以清除屏幕、分配缓冲区和构建着色器程序的地方。现在让我们添加它:

    public void onSurfaceCreated(EGLConfig eglConfig) {
        RenderBox.reset();
        GLES20.glClearColor(0.1f, 0.1f, 0.1f, 0.5f);

        mainCamera = new Camera();

        checkGLError("onSurfaceCreated");
        callbacks.setup();
    }

为了安全起见,它首先调用 reset,这将销毁可能已经通过重置其程序句柄编译的任何材料,然后可能编译其他材料。这个需要稍后的项目中会变得清楚,我们将实现意图功能来启动/重新启动应用程序:

    /**
     * Used to "clean up" compiled shaders, which have to be recompiled for a "fresh" activity
     */
    public static void reset(){
        VertexColorMaterial.destroy();
    }

onSurfaceCreated的最后一件事是调用setup回调。这将在接口实现者中实现,我们的情况下是MainActivity

在每个新帧中,我们将调用相机的onNewFrame方法来构建相机矩阵并将其应用于其模型视图。

如果我们想在以后的项目中引用当前的头部姿势(分别作为变换矩阵和角度的headViewheadAngles),我们也可以在RenderBox中添加以下代码:

    public static final float[] headView = new float[16];
    public static final float[] headAngles = new float[3];

    public void onNewFrame(HeadTransform headTransform) {
        headTransform.getHeadView(headView, 0);
        headTransform.getEulerAngles(headAngles, 0);
        mainCamera.onNewFrame();
        callbacks.preDraw();
    }

然后,当 Cardboard SDK 开始绘制每只眼睛(用于左右分屏立体视图),我们将调用相机的onDrawEye方法:

    public void onDrawEye(Eye eye) {
        mainCamera.onDrawEye(eye);
    }

在此过程中,我们还可以启用preDrawpostDraw回调(在先前的代码中,在onNewFrame中以及在onFinishFrame中)。

    public void onFinishFrame(Viewport viewport) {
        callbacks.postDraw();
    }

如果这些接口回调在MainActivity中实现,它们将从这里调用。

现在,我们可以构建一个使用CameraCubeVertexColorMaterial类的场景。

一个简单的盒子场景

让我们摇滚这个旋律!只需一个立方体和当然,一个相机(已经由RenderBox自动设置)。使用IRenderBox接口的setup回调设置MainActivity类。

MainActivitysetup中,我们为立方体创建一个Transform并将其定位,使其在空间中被设置回并略微偏移:

    Transform cube;

        @Override
    public void setup() {
        cube = new Transform();
        cube.addComponent(new Cube());
        cube.setLocalPosition(2.0f, -2.f, -5.0f);
    }

在 Android Studio 中,点击运行。程序应该编译、构建并安装到您连接的 Android 手机上。如果收到任何编译错误,请立即修复!如前所述,使用Matrix类时,请确保导入正确的Camera类型。SDK 中还有一个Camera类,表示手机的物理摄像头。

您将在设备显示上看到类似于这样的东西。(记住在设备面对您时启动应用程序,否则您可能需要转身才能找到立方体!)

一个简单的盒子场景

我不知道你,但我很兴奋!现在,让我们添加一些光和阴影。

带有面法线的立方体

现在,让我们向场景中添加光,并用它来渲染立方体。为此,我们还需要为立方体的每个面定义法线向量,这些向量在着色器计算中使用。

如果你从第三章中的Cube派生,纸箱,你可能已经有这段代码:

    public static final float[] CUBE_NORMALS_FACES = new float[] {
            // Front face
            0.0f, 0.0f, 1.0f,
            // Right face    
            1.0f, 0.0f, 0.0f,
            // Back face
            0.0f, 0.0f, -1.0f,
            // Left face
            -1.0f, 0.0f, 0.0f,
            // Top face
            0.0f, 1.0f, 0.0f,
            // Bottom face
            0.0f, -1.0f, 0.0f,
    };

现在,为法线添加一个缓冲区,就像我们为颜色和顶点分配的一样,并为它们分配空间:


public static FloatBuffer normalBuffer;
    ...

    public static void allocateBuffers(){
        ...

normalBuffer = allocateFloatBuffer( cubeFacesToArray(CUBE_NORMALS_FACES, 3) );
    }

我们将在createMaterial中添加一个光照选项参数,并使用VertexColorLightingMaterial来实现它,如果设置为true

    public Cube createMaterial(boolean lighting){
 if(lighting){
 VertexColorLightingMaterial mat = new VertexColorLightingMaterial();
 mat.setBuffers(vertexBuffer, colorBuffer, normalBuffer, 36);
 material = mat;
 } else {
            VertexColorMaterial mat = new VertexColorMaterial();
            mat.setBuffers(vertexBuffer, colorBuffer, numIndices);
            material = mat;
 }
        return this;
    }

当然,VertexColorLightingMaterial类还没有编写。这很快就会出现。然而,首先我们应该创建一个Light组件,也可以添加到照亮场景。

我们将用两种变体重构Cube()构造方法。当没有给出参数时,Cube不会创建任何Material。当给出一个布尔光照参数时,它会传递给createMaterial以选择材料:

    public Cube(){
        super();
        allocateBuffers();
    }

    public Cube(boolean lighting){
        super();
        allocateBuffers();
        createMaterial(lighting);
    }

我们稍后会提醒你,但不要忘记在MainActivity中修改对Cube(true)的调用以传递光照选项。

请注意,出于方便起见,我们在构造函数中创建了材料。没有什么能阻止我们只是向RenderObject添加一个setMaterial()方法或使材料变量公开。事实上,随着对象和材料类型的增加,这成为唯一合理的进行的方式。这是我们简化的Material系统的一个缺点,它期望每种材料类型有一个不同的类。

光组件

在我们的场景中,光源是一种带有颜色和浮点数组的“组件”,用于表示在眼睛空间中计算出的位置。现在让我们创建“光源”类。

renderbox/components文件夹中创建一个新的Light Java 类。定义如下:

public class Light extends Component {
    private static final String TAG = "RenderBox.Light";

    public final float[] lightPosInEyeSpace = new float[4];
    public float[] color = new float[]{1,1,1,1};

    public void onDraw(float[] view){
        Matrix.multiplyMV(lightPosInEyeSpace, 0, view, 0, transform.getPosition().toFloat4(), 0);
    }
}

我们的默认光源是白色(颜色为 1,1,1)。

onDraw方法根据Transform的位置乘以当前视图矩阵来计算眼睛空间中的实际光源位置。

可以扩展RenderBox以支持多个光源和其他复杂的渲染,比如阴影等等。但是,我们将限制场景中只有一个光源。因此,我们将保持它作为RenderBox中的实例变量。

现在,我们可以在RenderBox中向场景添加一个默认光源,就像我们之前添加Camera组件一样。在RenderBox.java中,添加以下代码:

    public Light mainLight;

修改onSurfaceCreated以初始化光源并将其添加到场景中:

    public void onSurfaceCreated(EGLConfig eglConfig) {
        ...
 mainLight = new Light();
 new Transform().addComponent(mainLight);
        mainCamera = new Camera();
        ...
    }

然后,在Camera类的onDrawEye中计算其位置(它可能会在每一帧中改变)。编辑Camera.java中的Camera类:

    public void onDrawEye(Eye eye) {
        ...
        // Apply the eye transformation to the camera.
        Matrix.multiplyMM(view, 0, eye.getEyeView(), 0, camera, 0);

// Compute lighting position

RenderBox.instance.mainLight.onDraw(view);

然后,我们还可以在Material类的draw方法中引用mainLight对象。我们本可以将颜色和位置声明为静态变量,因为我们只使用一个光源,但是为了未来支持多个光源,这样做更有意义。

顶点颜色照明材质和着色器

下一个主题有点复杂。我们将编写新的顶点和片段着色器来处理光照,并编写一个扩展Material的相应类来使用它们。不过,不用担心,我们之前已经做过一次了。这次我们只是要真正解释一下。

让我们直接开始。找到res/raw/文件夹。然后,对于每个文件,右键单击它,然后转到新建 | 文件以创建新文件。

文件:res/raw/vertex_color_lighting_vertex.shader

uniform mat4 u_Model;
uniform mat4 u_MVP;
uniform mat4 u_MVMatrix;
uniform vec3 u_LightPos;

attribute vec4 a_Position;
attribute vec4 a_Color;
attribute vec3 a_Normal;

varying vec4 v_Color;

const float ONE = 1.0;
const float COEFF = 0.00001;

void main() {
    vec3 modelViewVertex = vec3(u_MVMatrix * a_Position);
    vec3 modelViewNormal = vec3(u_MVMatrix * vec4(a_Normal, 0.0));

    float distance = length(u_LightPos - modelViewVertex);
    vec3 lightVector = normalize(u_LightPos - modelViewVertex);
    float diffuse = max(dot(modelViewNormal, lightVector), 0.5);

    diffuse = diffuse * (ONE / (ONE + (COEFF * distance * distance)));
    v_Color = a_Color * diffuse;
    gl_Position = u_MVP * a_Position;
}

顶点着色器使用模型视图变换矩阵将 3D 顶点映射到 2D 屏幕空间。然后,它找到光源距离和方向,计算该点的光颜色和强度。这些值通过图形管线传递。片段着色器然后确定光栅段中的像素颜色。

// File: res/raw/vertex_color_lighting_fragment.shader
precision mediump float;
varying vec4 v_Color;

void main() {
    gl_FragColor = v_Color;
}

现在,我们将创建Material。在renderbox/materials/文件夹中,创建一个VertexColorLightingMaterial类。定义它以扩展Material,然后声明其缓冲区和setupProgramdraw方法。以下是完整的代码:

public class VertexColorLightingMaterial extends Material {
    private static final String TAG = "vertexcollight";
    static int program = -1;
    //Initialize to a totally invalid value for setup state

    static int positionParam;
    static int colorParam;
    static int normalParam;
    static int MVParam;
    static int MVPParam;
    static int lightPosParam;

    FloatBuffer vertexBuffer;
    FloatBuffer normalBuffer;
    FloatBuffer colorBuffer;
    int numIndices;

    public VertexColorLightingMaterial(){
        super();
        setupProgram();
    }

    public static void setupProgram(){
        //Already setup?
		if (program != -1) return;
        //Create shader program
        program = createProgram(R.raw.vertex_color_lighting_vertex, R.raw.vertex_color_lighting_fragment);

        //Get vertex attribute parameters
        positionParam = GLES20.glGetAttribLocation(program, "a_Position");
        normalParam = GLES20.glGetAttribLocation(program, "a_Normal");
        colorParam = GLES20.glGetAttribLocation(program, "a_Color");

        //Enable vertex attribute parameters
        GLES20.glEnableVertexAttribArray(positionParam);
        GLES20.glEnableVertexAttribArray(normalParam);
        GLES20.glEnableVertexAttribArray(colorParam);

        //Shader-specific parameteters
        MVParam = GLES20.glGetUniformLocation(program, "u_MVMatrix");
        MVPParam = GLES20.glGetUniformLocation(program, "u_MVP");
        lightPosParam = GLES20.glGetUniformLocation(program, "u_LightPos");

        RenderBox.checkGLError("Solid Color Lighting params");
    }
    public void setBuffers(FloatBuffer vertexBuffer, FloatBuffer colorBuffer, FloatBuffer normalBuffer, int numIndices){
        this.vertexBuffer = vertexBuffer;
        this.normalBuffer = normalBuffer;
        this.colorBuffer = colorBuffer;
        this.numIndices = numIndices;
    }

    @Override
    public void draw(float[] view, float[] perspective) {
        GLES20.glUseProgram(program);

        GLES20.glUniform3fv(lightPosParam, 1, RenderBox.instance.mainLight.lightPosInEyeSpace, 0);

        Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.lightingModel, 0);

        // Set the ModelView in the shader, used to calculate // lighting
        GLES20.glUniformMatrix4fv(MVParam, 1, false, modelView, 0);

        Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.model, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, modelView, 0);
        // Set the ModelViewProjection matrix in the shader.
        GLES20.glUniformMatrix4fv(MVPParam, 1, false, modelViewProjection, 0);

        // Set the normal positions of the cube, again for shading
        GLES20.glVertexAttribPointer(normalParam, 3, GLES20.GL_FLOAT, false, 0, normalBuffer);
        GLES20.glVertexAttribPointer(colorParam, 4, GLES20.GL_FLOAT, false, 0, colorBuffer);

        // Set the position of the cube
        GLES20.glVertexAttribPointer(positionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);

        GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, numIndices);
    }

    public static void destroy(){
        program = -1;
    }
}

这里有很多事情要做,但是如果你仔细阅读,就可以跟上。大多数情况下,材质代码设置了我们在着色器程序中编写的参数。

draw()方法中特别重要的是,我们获取当前位置变换矩阵,mainLightRenderBox.instance.mainLight.lightPosInEyeSpace和光颜色,并将它们传递给着色器程序。

现在是一个很好的时机来提及GLES20.glEnableVertexAttribArray的调用,这对于你使用的每个顶点属性都是必需的。顶点属性是为每个顶点指定的任何数据,因此在这种情况下,我们有位置、法线和颜色。与以前不同,我们现在使用法线和颜色。

介绍了新的Material后,让我们按照惯例将其添加到RenderBox.reset()中:

    public static void reset(){
        VertexColorMaterial.destroy();
 VertexColorLightingMaterial.destroy();
    }

最后,在MainActivitysetup()方法中,确保将光照参数传递给Cube构造函数:

    public void setup() {
        cube = new Transform();
        cube.addComponent(new Cube(true));
        cube.setLocalPosition(2.0f, -2.f, -5.0f);
    }

运行你的应用程序。TAADAA!现在我们有了。与非光照材质视图的差异可能很微妙,但它更真实,更虚拟。

顶点颜色照明材质和着色器

如果您想调整阴影,您可能需要调整用于计算漫反射光照的衰减值(例如,将COEFF = 0.00001更改为0.001)在vertex_color_lighting_vertex.shader中,具体取决于您场景的规模。对于那些仍然在黑暗中的人(意思是不了解),衰减是一个用于描述光强随距离减弱的术语,实际上也适用于任何物理信号(例如光、无线电、声音等)。如果您有一个非常大的场景,您可能需要一个较小的值(以便光线到达遥远的区域)或者相反(以便不是所有东西都在光线下)。您可能还希望将衰减设置为统一的浮点参数,可以根据材质或光源进行调整和设置,以实现恰到好处的光照条件。

到目前为止,我们一直在使用单个点光源照亮我们的场景。点光源是一个在 3D 空间中具有位置的光源,它在所有方向上均匀地投射光线。就像在房间的特定位置放置标准灯泡一样,重要的是它与物体之间的距离,以及光线击中表面的角度。对于点光源,旋转并不重要,除非使用 cookie 来将纹理应用到光线上。我们在书中没有实现光 cookie,但它们非常酷。

其他光源可以是定向光,它们将模拟地球上的阳光,所有光线基本上都朝着同一个方向。定向光具有影响光线方向的旋转,但它们没有位置,因为我们假设理论上的光源在沿着该方向矢量的无限远处。从图形学的角度来看,第三种光源是聚光灯,其中光线呈锥形,并在其击中的表面上投射出一个圆形或椭圆形。聚光灯最终将以与我们对 MVP 矩阵进行的透视变换类似的方式工作。在本书的示例中,我们将只使用单个点光源。其他光源类型的实现留给读者作为练习。

动画时间

是时候增加一些兴奋了。让我们动画化立方体,使其旋转。这将有助于演示阴影效果。

为此,我们需要一个Time类。这是一个单例实用程序类,用于计算帧并使该信息可用于应用程序,例如通过getDeltaTime。请注意,这是一个final类,这意味着它不能被扩展。在 Java 中没有静态类,但如果我们将构造函数设置为私有,我们可以确保没有东西会实例化它。

renderbox/文件夹中创建一个新的Time类。它不会被扩展,所以我们可以将其声明为final。以下是代码:

public final class Time {
    private Time(){}
    static long startTime;
    static long lastFrame;
    static long deltaTime;
    static int frameCount;

    protected static void start(){
        frameCount = 0;
        startTime = System.currentTimeMillis();
        lastFrame = startTime;
    }
    protected static void update(){
        long current =System.currentTimeMillis();
        frameCount++;
        deltaTime = current - lastFrame;
        lastFrame = current;
    }

    public static int getFrameCount(){return frameCount;}

    public static float getTime(){
        return (float)(System.currentTimeMillis() - startTime) / 1000;
    }

    public static float getDeltaTime(){
        return deltaTime * 0.001f;
    }
}

RenderBox设置中启动计时器:

    public RenderBox(Activity mainActivity, IRenderBox callbacks){
        ...
 Time.start();
    }

然后,在RenderBoxonNewFrame方法中调用Time.update()

    public void onNewFrame(HeadTransform headTransform) {
 Time.update();
        ...
}

现在,我们可以使用它来修改每帧的立方体变换,通过preDraw()接口挂钩。在MainActivity中,使立方体每秒围绕X轴旋转 5 度,Y轴旋转 10 度,Z轴旋转 7.5 度:

    public void preDraw() {
        float dt = Time.getDeltaTime();
        cube.rotate(dt * 5, dt * 10, dt * 7.5f);
    }

getDeltaTime()方法返回自上一帧以来的秒数。因此,如果我们希望它每秒围绕X轴旋转 5 度,我们将deltaTime乘以 5,以获得这一帧要旋转的度数的比例。

运行应用程序。摇滚起来!!!

检测物体的朝向

等等,还有更多!只需再添加一件事。构建交互式应用程序需要我们能够确定用户是否凝视特定对象。我们可以将其放入RenderObject中,以便场景中的任何对象都可以被凝视检测到。

我们将实现的技术很简单。考虑到我们渲染的每个对象都投影到相机平面上,我们实际上只需要确定用户是否在观察对象的平面。基本上,我们检查相机和平面位置之间的向量是否与相机的视角方向相同。但我们会加入一些容差,这样你就不必完全看着平面的中心(那样是不切实际的)。我们将检查一个狭窄的范围。一个好的方法是计算这些向量之间的角度。我们计算这些向量之间的俯仰和偏航角度(分别是上/下X轴角度和左/右Y轴角度)。然后,我们检查这些角度是否在一个狭窄的阈值范围内,表明用户正在观察平面(多多少少)。

这种方法就像第三章纸板箱中使用的方法一样,尽管当时我们把它放在了MainActivity中。现在,我们将其移动到RenderObject组件中。

请注意,这可能会变得低效。这种技术对我们的项目来说是可以的,因为对象的数量有限,所以计算并不昂贵。但是,如果我们有一个包含许多对象的大型复杂场景,这种设置就会不够用了。在这种情况下,一个解决方案是添加一个isSelectable标志,以便只有在给定帧中应该是交互式的对象才会是交互式的。

如果我们使用一个功能齐全的游戏引擎,我们将拥有一个物理引擎,能够进行raycast来精确确定你的凝视中心是否与对象交叉,具有很高的准确度。虽然在游戏中这可能很棒,但对于我们的目的来说有些过度了。

RenderObject的顶部,添加一个布尔变量来存储isLooking值。还要添加两个变量来保存偏航和俯仰范围限制,以检测相机的观察角度,以及我们将用于计算的modelView矩阵:

    public boolean isLooking;
    private static final float YAW_LIMIT = 0.15f;
    private static final float PITCH_LIMIT = 0.15f;
    final float[] modelView = new float[16];

isLookingAtObject方法的实现如下。我们将对象空间转换为相机空间,使用onNewFrame中的headView值,计算俯仰和偏航角度,然后检查它们是否在容差范围内:

    private boolean isLookingAtObject() {
        float[] initVec = { 0, 0, 0, 1.0f };
        float[] objPositionVec = new float[4];

        // Convert object space to camera space. Use the headView // from onNewFrame.
        Matrix.multiplyMM(modelView, 0, RenderBox.headView, 0, model, 0);
        Matrix.multiplyMV(objPositionVec, 0, modelView, 0, initVec, 0);

        float pitch = (float) Math.atan2(objPositionVec[1], -objPositionVec[2]);
        float yaw = (float) Math.atan2(objPositionVec[0], -objPositionVec[2]);

        return Math.abs(pitch) < PITCH_LIMIT && Math.abs(yaw) < YAW_LIMIT;
    }

为了方便起见,我们将在对象绘制时同时设置isLooking标志。在draw方法的末尾添加调用:

    public void draw(float[] view, float[] perspective){
        . . . 
 isLooking = isLookingAtObject();
    }

就是这样。

对于一个简单的测试,当用户凝视立方体时,我们将在控制台上记录一些文本。在MainActivity中,为Cube对象创建一个单独的变量:

    Cube cubeObject;

    public void setup() {
        cube = new Transform();
        cubeObject = new Cube(true);
        cube.addComponent(cubeObject);
        cube.setLocalPosition(2.0f, -2.f, -5.0f);
    }

然后,在postDraw中进行测试,如下所示:

    public void postDraw() {
        if (cubeObject.isLooking) {
            Log.d(TAG, "isLooking at Cube");
        }
    }

导出 RenderBox 包

既然我们已经完成了创建这个美丽的RenderBox库,那么我们如何在其他项目中重用它呢?这就是模块.aar文件发挥作用的地方。在 Android 项目之间共享代码有许多方法。最明显的方法是根据需要将代码片段直接复制到下一个项目中。虽然在某些情况下这是完全可以接受的,实际上应该是你正常流程的一部分,但这可能会变得相当乏味。如果我们有一堆相互引用并依赖于某个文件层次结构的文件,比如RenderBox,那该怎么办呢?如果你熟悉 Java 开发,你可能会说,“显然只需将编译后的类导出为.jar文件。”你说得对,除了这是 Android。我们还有一些生成的类以及包含,这种情况下,我们的着色器代码的/res文件夹。实际上我们想要的是一个.aar文件。Android 程序员可能熟悉.aidl文件,它们用于类似的目的,但专门用于在应用程序之间建立接口,而不是封装功能代码。

要生成一个.aar文件,我们首先需要将我们的代码放入一个具有与应用程序不同输出的 Android Studio 模块中。从这一点开始,您有几个选项。我们建议您创建一个专用的 Android Studio 项目,其中包含RenderBox模块以及一个测试应用程序,它将与库一起构建,并作为一种手段来确保您对库所做的任何更改不会破坏任何内容。您也可以只是将renderbox包和/res/raw文件夹复制到一个新项目中,然后从那里开始,但最终您会发现模块更加方便。

您可能会认为"我们将把这个新项目称为RenderBox",但您可能会遇到问题。基本上,构建系统无法处理项目和模块具有相同名称的情况(它们应该具有相同的包名称,这是不允许的)。如果您将项目命名为RenderBox(从技术上讲,如果您遵循了说明,您不应该这样做),并包含一个活动,然后创建一个名为RenderBox的模块,您将看到一个构建错误,抱怨项目和模块共享名称。如果您创建一个没有活动的空项目,称为RenderBox,并添加一个名为RenderBox的模块,您可能会得逞,但一旦您尝试从该项目构建应用程序,您会发现无法构建。因此,我们建议您从这里的下一步是创建一个名为RenderBoxLib的新项目。

构建 RenderBoxLib 模块

让我们试试看。转到文件 | 新建 | 新项目。将项目命名为RenderBoxLib

我们不需要MainActivity类,但我们仍然需要一个,如前所述,作为测试用例来确保我们的库正常工作。在库项目中添加一个测试应用程序不仅使我们能够在一个步骤中测试对库的更改,而且还确保我们不能构建库的新版本而不确保使用它的应用程序也可以编译它。即使您的库没有语法错误,当您将其包含在新项目中时,它可能仍会破坏编译。

因此,继续添加一个空活动,然后在默认选项中点击完成

到目前为止都是熟悉的领域。然而,现在我们要创建一个新模块:

  1. 转到文件 | 新建 | 新模块,然后选择Android 库构建 RenderBoxLib 模块

  2. 将其命名为RenderBox构建 RenderBoxLib 模块

  3. 现在,我们在项目视图中有一个新文件夹:构建 RenderBoxLib 模块

不要在 Android Studio 中执行下一步,让我们使用文件管理器(Windows 资源管理器或 Finder,或者如果您是专业人士,则使用终端)将RenderBox文件从现有项目复制到新项目中。如果您使用版本控制,您可能考虑将存储库转移到新项目,或在复制之前创建一个初始提交;这取决于您以及您对保留历史记录的重视程度。

我们希望将所有RenderBox代码从RenderBoxDemo项目的/app/src/main/java/com/cardbookvr/renderbox文件夹复制到RenderBoxLib/renderbox/src/main/java/com/cardbookvr/renderbox文件夹中。

资源也是一样的;从RenderBoxDemo项目的/app/src/main/res/raw文件夹复制到/renderbox/src/main/res/raw文件夹。

这意味着我们在原始项目中创建的几乎每个.java.shader文件都会放入新项目的模块中,放在相应的位置。

我们不会将MainActivity.java或任何 XML 文件,比如layouts/activity_main.xmlAndroidManifest.xml转移到模块中。这些都是特定于应用程序的文件,不包括在库中。

复制文件后,返回到 Android Studio,点击同步按钮。这将确保 Android Studio 已经注意到了新文件。

构建 RenderBoxLib 模块

然后,在层次结构面板中选择renderbox,通过导航到构建 | 构建模块'RenderBox'(或Ctrl + Shift + F9)来启动构建。您会看到一堆错误。让我们处理一下。

RenderBox引用了 Cardboard SDK,因此,我们必须以类似的方式将其作为依赖项包含在RenderBox模块中,就像在项目开始时那样:

  1. 将 Cardboard SDK 的common.aarcore.aar库文件作为新模块添加到项目中,使用文件 | 新建 | 新建模块...导入.JAR/.AAR 包

  2. 将库模块设置为RenderBox模型的依赖项,使用文件 | 项目结构。在左侧面板中,选择RenderBox,然后选择依赖项选项卡 | + | 模块依赖项,并添加 common 和 core 模块。

一旦您同步项目并触发构建,您希望看到与CardboardView相关的错误和其他错误消失。

又一次构建。还有其他错误吗?

这是因为之前提到的命名问题。如果您的模块包名称与原始项目的包名称不匹配(即com.cardbookvr.renderbox),则必须在复制的 Java 文件中将其重命名。即使这些匹配,我们将原始项目命名为RenderBoxDemo,这意味着生成的 R 类将成为com.cardbookvr.renderboxdemo包的一部分。对这个包的任何导入引用都需要更改。

首先删除引用com.cardbookvr.renderboxdemo(如Material Java 文件)的行。然后,任何对 R 类的引用都会显示为错误:

构建 RenderBoxLib 模块

删除这行,Android Studio 将生成一个新的有效导入行。尝试再次构建。如果没有错误,我们就可以继续了。

现在您会看到关于 R 的引用显示为错误,并提出建议:

构建 RenderBoxLib 模块

如果您继续按Alt + Enter,Android Studio 将为您的代码添加适当的导入行。如果您看不到Alt + Enter工具提示,请尝试将光标放在 R 旁边。通过这种方式使用该功能,您必须从按Alt + Enter后看到的菜单中选择导入类。如果仍然看到错误,请确保您已将着色器代码复制到/renderbox/res/raw文件夹中,并且没有其他错误干扰此过程。基本上,我们正在从代码中删除任何外部引用,并使RenderBox能够独立构建。我们也可以通过简单地将import com.cardbook.renderbox.R;粘贴到import com.cardbook.renderboxdemo.R;上来完成此代码修复。这可能比第一种方法更容易,但那样您就不会了解Alt + Enter了。

完成后,我们应该能够无错误地构建。这可能看起来有点凌乱,但偶尔凌乱一下也无妨。您甚至可能会对构建流程有所了解。

如果一切顺利,您会在renderbox/build/outputs/aar/中看到一个名为renderbox-debug.aar的文件。如果是这样,您就完成了。哇!

最后一点想法:您应该在最终应用程序中包含renderbox-release.aar,但与此同时将失去有用的调试功能。我们不会在本书中讨论如何在调试和发布之间切换,但了解构建配置对发布流程至关重要。

RenderBox 测试应用程序

这个新项目包含了renderbox模块,但也有一个我们最初创建的app文件夹。app是我们可以实现测试应用程序的地方,以确保至少库已构建并基本运行。

我们将对RenderBoxLib中的应用程序模块执行与我们的新项目相同的操作(就像renderbox一样,app是一个模块。原来我们一直在使用模块!):

  1. 右键单击app文件夹,转到打开模块设置,并将现有的renderbox模块作为模块依赖项添加到编译范围中。请注意,依赖项不能是循环的。现在renderbox是应用程序的依赖项,反之则不成立。

  2. 更新/res/layout/activity_main.xmlAndroidManifest.xml,就像我们在本章开头看到的那样。(如果您只是复制代码,请确保将package=值更改为当前名称,例如com.cardbookvr.renderboxlib)。

  3. 设置class MainActivity extends CardboardActivity implements IRenderBox

  4. 我们现在还希望我们的MainActivity类实例化RenderBox并定义一个setup()方法,就像RenderBoxDemo中的MainActivity一样。实际上,只需复制整个RenderBoxDemo中的MainActivity类,并确保您不要复制/覆盖新文件的顶部的包定义。

幸运的话,您应该能够单击绿色运行按钮,选择目标设备,并看到一个运行中的应用程序,其中包含我们的伙伴,顶点颜色立方体。从最终结果来看,我们已经正式倒退了,但我们的应用程序特定代码非常干净和简单!

在未来项目中使用 RenderBox

现在我们已经经历了所有这些麻烦,让我们进行一次试运行,看看如何使用我们漂亮的小包装。再来一次。您可以执行以下步骤来启动本书中的每个后续项目:

  1. 创建一个新项目,可以随意命名,例如MyCardboardApp,用于 API 19 KitKat。包括空活动

  2. 现在,转到文件 | 新建 | 新建模块...。这有点违反直觉,尽管我们正在导入一个现有模块,但我们正在将一个新模块添加到这个项目中。选择导入.JAR/.AAR 包在未来项目中使用 RenderBox

  3. 您需要导航到RenderBoxLib/renderbox/build/outputs文件夹,选择.aar文件。我们建议将模块重命名为renderbox,而不是renderbox-debug。单击完成。对于生产应用程序,您希望在项目中有两个不同的模块:一个用于调试,一个用于发布,但是在本书的项目中,我们只使用调试。

  4. 既然我们有了这个新模块,我们需要将其添加为默认应用程序的依赖项。返回熟悉的模块设置屏幕,转到app依赖项选项卡。单击右侧的加号标签,并选择模块依赖项在未来项目中使用 RenderBox

  5. 然后,您可以添加renderbox在未来项目中使用 RenderBox

现在,我们在新项目的/renderbox模块文件夹中有了.aar文件的副本。当您对RenderBox库进行更改时,您只需要构建一个新的.aar文件(构建菜单,MakeProject),覆盖新项目中的副本,并触发项目同步,或者如果您想确保,可以进行清理和重建。新项目不会保持与库输出项目的构建文件夹的链接。

设置新项目所需的其余步骤如下:

  1. 使用文件 | 新建模块导入 Cardboard SDK 的.aarcommoncore,并将它们作为应用程序的依赖项添加进去。

  2. 更新/res/layout/activity_main.xmlAndroidManifest.xml,就像我们刚刚为RenderBoxDemo所做的那样。

  3. 设置MainActivity类,使其扩展CardboardActivity并实现IRenderBox,使用与以前相同的代码。

  4. 我们现在也希望我们的MainActivity类实例化RenderBox并定义一个setup()方法,就像我们在RenderBoxDemo中的MainActivity类一样。实际上,只需复制整个MainActivity类,并小心不要复制/覆盖文件顶部的包定义。

再次构建和运行。搞定了!我们现在可以继续进行一些很酷的东西。

注意

从现在开始,这将是我们的新项目流程,因为本书中的其余项目都使用RenderBox库模块。

关于模块流程的最后一句话:剥橙子的方法不止一种。您可以在RenderBox演示项目中创建一个新模块,获取其输出,然后开始运行。您还可以只是复制源文件并尝试使用 Git 子模块或子树来同步源代码。IntelliJ 文档中的这一页讨论了一些更细节的内容(www.jetbrains.com/idea/help/sharing-android-source-code-and-resources-using-library-projects.html)。在关于保持主要活动和布局文件完全特定于应用程序的决定以及在RenderBox模块中包含大部分或全部着色器和材料的决定方面,我们也做出了某些决定。在这些决策点中,都有利弊,我们建议您在未来的项目中仔细考虑如何构建自己的代码。

总结

在本章中,我们创建了一个简短而轻巧的图形引擎,用于构建新的 Cardboard VR 应用程序。我们将低级别的 OpenGL ES API 调用抽象成一套Material类和一个Camera类。我们为几何实体定义了RenderObject,以及从Component类继承的CameraLight组件。我们定义了一个Transform类,用于在 3D 空间中层次化地组织和定位实体(包含组件)。所有这些都集成在RenderBox类下,该类在MainActivity类中实例化和控制,而MainActivity类又实现了IRenderBox接口。我们通过指定MainActivity类作为IRenderBox的实现者,并实现setuppreDrawpostDraw来完成循环。

为了开发这个库,我们遵循了第三章纸板盒中涵盖的大部分内容,少了一些关于如何使用 OpenGL ES 和矩阵库的解释,更多地关注实现我们的RenderBox软件架构。

生成的RenderBox引擎库现在在自己的项目中。在接下来的章节中,我们将重用这个库,并且我们将扩展它,包括新的组件和材料。我们鼓励您将RenderBoxLib代码保存在源代码仓库中,比如 Git。当然,最终的代码是与书籍资产一起提供的,并且在我们的 GitHub 仓库中也有。

下一章是一个科学项目!我们将建立一个我们太阳系的模型,包括太阳、行星、卫星和星空。使用RenderBox,我们将添加一个Sphere组件,并将纹理着色器添加到我们的材料套件中。

第六章:太阳系

当我 8 岁的时候,在学校的一个科学项目中,我用电线、聚苯乙烯泡沫球和油漆制作了一个太阳系。今天,全世界的 8 岁孩子们都可以在 VR 中制作虚拟太阳系,特别是如果他们读了这一章!这个项目创建了一个模拟我们太阳系的 Cardboard VR 应用程序。也许不是完全科学准确,但对于一个孩子的项目来说已经足够好了,比聚苯乙烯泡沫球要好。

在本章中,您将使用RenderBox库创建一个新的太阳系项目,执行以下步骤:

  • 设置新项目

  • 创建一个Sphere组件和一个纯色材料

  • 添加带照明的“地球”纹理材料

  • 安排太阳系的几何形状

  • 天体的动画

  • 交互式更改相机位置

  • 使用我们的新代码更新RenderBox

当我们将它们放在一起时,我们将从一个球体创建行星和卫星。然而,大部分代码将用于渲染这些天体的各种材料和着色器。

注意

此项目的源代码可以在 Packt Publishing 网站上找到,并且在 GitHub 上也可以找到github.com/cardbookvr/solarsystem(每个主题作为单独的提交)。

设置新项目

为了构建此项目,我们将使用我们在第五章中创建的RenderBox库,RenderBox 引擎。您可以使用您自己的库,或者从本书提供的可下载文件或我们的 GitHub 存储库中获取副本(使用标记为after-ch5的提交——github.com/cardbookvr/renderboxlib/releases/tag/after-ch5)。有关如何导入RenderBox库的更详细说明,请参阅第五章的最终在未来项目中使用 RenderBox部分,RenderBox 引擎。执行以下步骤创建新项目:

  1. 在打开的 Android Studio 中创建一个新项目。让我们将其命名为SolarSystem,并针对Android 4.4 KitKat (API 19)使用空活动

  2. renderboxcommoncore包创建新模块,使用文件 | 新建模块 | 导入.JAR/.AAR 包

  3. 将模块设置为应用程序的依赖项,使用文件 | 项目结构

  4. 按照第二章中的说明编辑build.gradle文件,骨架 Cardboard 项目,以编译 SDK 22。

  5. 更新/res/layout/activity_main.xmlAndroidManifest.xml,如前几章所述。

  6. MainActivity编辑为class MainActivity extends CardboardActivity implements IRenderBox,并实现接口方法存根(Ctrl + I)。

我们可以继续在MainActivity中定义onCreate方法。该类现在具有以下代码:

public class MainActivity extends CardboardActivity implements IRenderBox {
    private static final String TAG = "SolarSystem";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        CardboardView cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
        cardboardView.setRenderer(new RenderBox(this, this));
        setCardboardView(cardboardView);
    }
    @Override
    public void setup() {

    }
    @Override
    public void preDraw() {
    }
    @Override
    public void postDraw() {
    }
}

在构建此项目时,我们将创建一些新的类,这些类可能是RenderBox库的良好扩展。我们将首先将它们作为常规类放入此项目。然后,在本章末尾,我们将帮助您将它们移入RenderBox库项目并重新构建该库:

  1. 右键单击solarsystem文件夹(com.cardbookvr.solarsystem),选择新建 | ,并命名为RenderBoxExt

  2. RenderBoxExt中,创建名为componentsmaterials的包子文件夹。

没有真正的技术需要将其制作成一个单独的包,但这有助于组织我们的文件,因为RenderBoxExt中的文件将在本章末尾移入我们可重用的库中。

您可以将一个立方体临时添加到场景中,以确保一切设置正确。将其添加到setup方法中,如下所示:

    public void setup() {
        new Transform()
            .setLocalPosition(0,0,-7)
            .setLocalRotation(45,60,0)
            .addComponent(new Cube(true));
    }

如果您还记得,立方体是添加到变换中的一个组件。立方体定义了它的几何形状(例如,顶点)。变换定义了它在 3D 空间中的位置、旋转和缩放。

您应该能够在 Android 设备上点击Run 'app',并且看到立方体和 Cardboard 分屏视图,没有编译错误。

创建一个球体组件

我们的太阳系将由球体构成,代表行星、卫星和太阳。让我们首先创建一个Sphere组件。我们将定义一个球体,它是由形成球体表面的顶点三角网格组成的(有关三角网格的更多信息,请参阅en.wikipedia.org/wiki/Triangle_mesh)。

右键单击RenderBoxExt/components文件夹,选择New | Java Class,并将其命名为Sphere。将其定义为public class Sphere extends RenderObject

public class Sphere extends RenderObject{
    private static final String TAG = "RenderBox.Sphere";
    public Sphere() {
        super();
        allocateBuffers();
    }
}

构造函数调用一个辅助方法allocateBuffers,该方法为顶点、法线、纹理和索引分配缓冲区。让我们在类的顶部声明这些变量:

    public static FloatBuffer vertexBuffer;
    public static FloatBuffer normalBuffer;
    public static FloatBuffer texCoordBuffer;
    public static ShortBuffer indexBuffer;
    public static int numIndices;

请注意,我们决定将缓冲区声明为public,以便在为对象创建任意纹理材料时具有未来的灵活性。

我们将定义一个半径为 1 的球体。它的顶点由 24 个经度部分(如同一天中的小时)和 16 个纬度部分排列,为我们的目的提供了足够的分辨率。顶部和底部盖子分开处理。这是一个很长的方法,所以我们将为您分解它。这是代码的第一部分,我们在其中声明和初始化变量,包括顶点数组。与我们的Material设置方法类似,我们只需要一次性分配Sphere缓冲区,在这种情况下,我们使用顶点缓冲区变量来跟踪这种状态。如果它不为空,则已经分配了缓冲区。否则,我们应该继续执行该函数,该函数将设置这个值:

    public static void allocateBuffers(){
        //Already allocated?
        if (vertexBuffer != null) return;
        //Generate a sphere model
        float radius = 1f;
        // Longitude |||
        int nbLong = 24;
        // Latitude ---
        int nbLat = 16;

        Vector3[] vertices = new Vector3[(nbLong+1) * nbLat + nbLong * 2];
        float _pi = MathUtils.PI;
        float _2pi = MathUtils.PI2;

计算顶点位置;首先是顶部和底部,然后沿纬度/经度球形网格:

        //Top and bottom vertices are duplicated
        for(int i = 0; i < nbLong; i++){
            vertices[i] = new Vector3(Vector3.up).multiply(radius);
            vertices[vertices.length - i - 1] = new Vector3(Vector3.up).multiply(-radius);
        }
        for( int lat = 0; lat < nbLat; lat++ )
        {
            float a1 = _pi * (float)(lat+1) / (nbLat+1);
            float sin1 = (float)Math.sin(a1);
            float cos1 = (float)Math.cos(a1);

            for( int lon = 0; lon <= nbLong; lon++ )
            {
                float a2 = _2pi * (float)(lon == nbLong ? 0 : lon) / nbLong;
                float sin2 = (float)Math.sin(a2);
                float cos2 = (float)Math.cos(a2);

                vertices[lon + lat * (nbLong + 1) + nbLong] = 
                    new Vector3( sin1 * cos2, cos1, sin1 * sin2 ).multiply(radius);
            }
        }

接下来,我们计算顶点法线,然后进行纹理映射的 UV:

        Vector3[] normals = new Vector3[vertices.length];
        for( int n = 0; n < vertices.length; n++ )
            normals[n] = new Vector3(vertices[n]).normalize();

        Vector2[] uvs = new Vector2[vertices.length];
        float uvStart = 1.0f / (nbLong * 2);
        float uvStride = 1.0f / nbLong;
        for(int i = 0; i < nbLong; i++) {
            uvs[i] = new Vector2(uvStart + i * uvStride, 1f);
            uvs[uvs.length - i - 1] = new Vector2(1 - (uvStart + i * uvStride), 0f);
        }
        for( int lat = 0; lat < nbLat; lat++ )
            for( int lon = 0; lon <= nbLong; lon++ )
                uvs[lon + lat * (nbLong + 1) + nbLong] = new Vector2( (float)lon / nbLong, 1f - (float)(lat+1) / (nbLat+1) );

同一allocateBuffers方法的下一部分生成了连接顶点的三角形索引:

        int nbFaces = (nbLong+1) * nbLat + 2;
        int nbTriangles = nbFaces * 2;
        int nbIndexes = nbTriangles * 3;
        numIndices = nbIndexes;
        short[] triangles = new short[ nbIndexes ];

        //Top Cap
        int i = 0;
        for( short lon = 0; lon < nbLong; lon++ )
        {
            triangles[i++] = lon;
            triangles[i++] = (short)(nbLong + lon+1);
            triangles[i++] = (short)(nbLong + lon);
        }

        //Middle
        for( short lat = 0; lat < nbLat - 1; lat++ )
        {
            for( short lon = 0; lon < nbLong; lon++ )
            {
                short current = (short)(lon + lat * (nbLong + 1) + nbLong);
                short next = (short)(current + nbLong + 1);

                triangles[i++] = current;
                triangles[i++] = (short)(current + 1);
                triangles[i++] = (short)(next + 1);

                triangles[i++] = current;
                triangles[i++] = (short)(next + 1);
                triangles[i++] = next;
            }
        }

        //Bottom Cap
        for( short lon = 0; lon < nbLong; lon++ )
        {
            triangles[i++] = (short)(vertices.length - lon - 1);
            triangles[i++] = (short)(vertices.length - nbLong - (lon+1) - 1);
            triangles[i++] = (short)(vertices.length - nbLong - (lon) - 1);
        }

最后,将这些计算值应用到相应的vertexBuffernormalBuffertexCoordBufferindexBuffer数组中,如下所示:

        //convert Vector3[] to float[]
        float[] vertexArray = new float[vertices.length * 3];
        for(i = 0; i < vertices.length; i++){
            int step = i * 3;
            vertexArray[step] = vertices[i].x;
            vertexArray[step + 1] = vertices[i].y;
            vertexArray[step + 2] = vertices[i].z;
        }
        float[] normalArray = new float[normals.length * 3];
        for(i = 0; i < normals.length; i++){
            int step = i * 3;
            normalArray[step] = normals[i].x;
            normalArray[step + 1] = normals[i].y;
            normalArray[step + 2] = normals[i].z;
        }
        float[] texCoordArray = new float[uvs.length * 2];
        for(i = 0; i < uvs.length; i++){
            int step = i * 2;
            texCoordArray[step] = uvs[i].x;
            texCoordArray[step + 1] = uvs[i].y;
        }

        vertexBuffer = allocateFloatBuffer(vertexArray);
        normalBuffer = allocateFloatBuffer(normalArray);
        texCoordBuffer = allocateFloatBuffer(texCoordArray);
        indexBuffer = allocateShortBuffer(triangles);
    }

这是很多代码,可能在书页上很难阅读;如果您愿意,您可以在项目 GitHub 存储库中找到一份副本。

方便的是,由于球体位于原点(0,0,0)处,每个顶点的法线向量对应于顶点位置本身(从原点辐射到顶点)。严格来说,由于我们使用了半径为 1,我们可以避免“normalize()”步骤来生成法线数组,以进行优化。以下图显示了 24 x 16 顶点球体及其法线向量:

创建球体组件

请注意,我们的算法包括一个有趣的修复,避免了极点处的单个顶点(所有 UV 汇聚在一个点上,导致一些旋转的纹理伪影)。

我们在 UV X 上创建nLon-1个共同位置的顶点,偏移量为1/(nLon2)*,在顶部和底部绘制齿。以下图显示了球体的展平 UV 图,说明了极点的齿状:

创建球体组件

实色光照的球体

我们将从以实色渲染我们的球体开始,但是使用光照着色。和往常一样,我们首先编写着色器函数,其中包括定义它所使用的Material中的程序变量。然后,我们将定义SolidColorLightingMaterial类,并将其添加到Sphere组件中。

实色光照着色器

在之前的章节中,我们在顶点着色器中进行了光照计算。这更简单(也更快),但是将计算转移到片段着色器会产生更好的结果。原因是,在顶点着色器中,你只有一个法线值可以与光线方向进行比较。在片段着色器中,所有顶点属性都被插值,这意味着在两个顶点之间的给定点的法线值将是它们两个法线之间的某个点。当这种情况发生时,你会看到三角形面上的平滑渐变,而不是在每个顶点周围的局部阴影伪影。我们将创建一个新的Material类来在片段着色器中实现光照。

如果需要,为着色器创建一个 Android 资源目录(资源类型:raw),res/raw/。然后,创建solid_color_lighting_vertex.shaderres/raw/solid_color_lighting_fragment.shader文件,并定义如下。

文件:res/raw/solid_color_lighting_vertex.shader

uniform mat4 u_MVP;
uniform mat4 u_MV;

attribute vec4 a_Position;
attribute vec3 a_Normal;

varying vec3 v_Position;
varying vec3 v_Normal;

void main() {
    // vertex in eye space
    v_Position = vec3(u_MV * a_Position);

    // normal's orientation in eye space
    v_Normal = vec3(u_MV * vec4(a_Normal, 0.0));

    // point in normalized screen coordinates
    gl_Position = u_MVP * a_Position;
}

请注意,我们为u_MVu_MVP分别有单独的统一变量。另外,如果你还记得,在上一章中,我们将光照模型与实际模型分开,因为我们不希望缩放影响光照计算。同样,投影矩阵只对将相机 FOV 应用于顶点位置有用,并且会干扰光照计算。

文件:res/raw/solid_color_lighting_fragment.shader

precision mediump float; // default medium precision in the fragment shader
uniform vec3 u_LightPos; // light position in eye space
uniform vec4 u_LightCol;
uniform vec4 u_Color;

varying vec3 v_Position;        
varying vec3 v_Normal;
varying vec2 v_TexCoordinate;   

void main() {
    // distance for attenuation.
    float distance = length(u_LightPos - v_Position);

    // lighting direction vector from the light to the vertex
    vec3 lightVector = normalize(u_LightPos - v_Position);

    // dot product of the light vector and vertex normal. // If the normal and light vector are
    // pointing in the same direction then it will get max // illumination.
    float diffuse = max(dot(v_Normal, lightVector), 0.01);

    // Add a tiny bit of ambient lighting (this is outerspace)
    diffuse = diffuse + 0.025;  

    // Multiply color by the diffuse illumination level and // texture value to get final output color
    gl_FragColor = u_Color * u_LightCol * diffuse;
}

纯色光照材质

接下来,我们为着色器定义Material类。在 materials 文件夹中,创建一个名为SolidColorLightingMaterial的新 Java 类,并定义如下:

public class SolidColorLightingMaterial extends Material {
    private static final String TAG = "solidcolorlighting";

}

添加颜色、程序引用和缓冲区的变量,如下面的代码所示:

    float[] color = new float[4];
    static int program = -1;
    static int positionParam;
    static int colorParam;
    static int normalParam;
    static int modelParam;
    static int MVParam;
    static int MVPParam;
    static int lightPosParam;
    static int lightColParam;

    FloatBuffer vertexBuffer;
    FloatBuffer normalBuffer;
    ShortBuffer indexBuffer;
    int numIndices;

现在,我们可以添加一个构造函数,它接收一个颜色(RGBA)值并设置着色器程序,如下所示:

    public SolidColorLightingMaterial(float[] c){
        super();
        setColor(c);
        setupProgram();
    }

    public void setColor(float[] c){
        color = c;
    }

正如我们之前所见,setupProgram方法创建了着色器程序并获取了对其参数的引用:

    public static void setupProgram(){
        //Already setup?
        if (program != -1) return;

        //Create shader program
        program = createProgram(R.raw.solid_color_lighting_vertex, R.raw.solid_color_lighting_fragment);

        //Get vertex attribute parameters
        positionParam = GLES20.glGetAttribLocation(program, "a_Position");
        normalParam = GLES20.glGetAttribLocation(program, "a_Normal");

        //Enable them (turns out this is kind of a big deal ;)
        GLES20.glEnableVertexAttribArray(positionParam);
        GLES20.glEnableVertexAttribArray(normalParam);

        //Shader-specific parameters
        colorParam = GLES20.glGetUniformLocation(program, "u_Color");
        MVParam = GLES20.glGetUniformLocation(program, "u_MV");
        MVPParam = GLES20.glGetUniformLocation(program, "u_MVP");
        lightPosParam = GLES20.glGetUniformLocation(program, "u_LightPos");
        lightColParam = GLES20.glGetUniformLocation(program, "u_LightCol");

        RenderBox.checkGLError("Solid Color Lighting params");
    }

同样,我们添加一个setBuffers方法,该方法由RenderObject组件(Sphere)调用:

    public void setBuffers(FloatBuffer vertexBuffer, FloatBuffer normalBuffer, ShortBuffer indexBuffer, int numIndices){
        this.vertexBuffer = vertexBuffer;
        this.normalBuffer = normalBuffer;
        this.indexBuffer = indexBuffer;
        this.numIndices = numIndices;
    }

最后,添加draw代码,该代码将从Camera组件中调用,以渲染在缓冲区中准备的几何图形(通过setBuffers)。draw方法如下所示:

    @Override
    public void draw(float[] view, float[] perspective) {
        GLES20.glUseProgram(program);

        GLES20.glUniform3fv(lightPosParam, 1, RenderBox.instance.mainLight.lightPosInEyeSpace, 0);
        GLES20.glUniform4fv(lightColParam, 1, RenderBox.instance.mainLight.color, 0);

        Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.lightingModel, 0);
        // Set the ModelView in the shader, // used to calculate lighting
        GLES20.glUniformMatrix4fv(MVParam, 1, false, modelView, 0);
        Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.model, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, modelView, 0);
        // Set the ModelViewProjection matrix for eye position.
        GLES20.glUniformMatrix4fv(MVPParam, 1, false, modelViewProjection, 0);

        GLES20.glUniform4fv(colorParam, 1, color, 0);

        //Set vertex attributes
        GLES20.glVertexAttribPointer(positionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);
        GLES20.glVertexAttribPointer(normalParam, 3, GLES20.GL_FLOAT, false, 0, normalBuffer);

        GLES20.glDrawElements(GLES20.GL_TRIANGLES, numIndices, GLES20.GL_UNSIGNED_SHORT, indexBuffer);
    }

现在我们有了一个纯色光照材质和着色器,我们可以将它们添加到Sphere类中,以在我们的项目中使用。

向球体添加材质

为了将这个MaterialSphere一起使用,我们将定义一个新的构造函数(Sphere),它调用一个辅助方法(createSolidColorLightingMaterial)来创建材质并设置缓冲区。代码如下:

    public Sphere(float[] color) {
        super();
        allocateBuffers();
        createSolidColorLightingMaterial(color);
    }

    public Sphere createSolidColorLightingMaterial(float[] color){
        SolidColorLightingMaterial mat = new SolidColorLightingMaterial(color);
        mat.setBuffers(vertexBuffer, normalBuffer, indexBuffer, numIndices);
        material = mat;
        return this;
    }

好的,现在我们可以将球体添加到我们的场景中。

查看球体

让我们看看这是什么样子!我们将创建一个带有球体、灯光和相机的场景。请记住,幸运的是,RenderBox类为我们创建了默认的CameraLight实例。我们只需要添加Sphere组件。

编辑你的MainActivity.java文件,在setup中添加球体。我们将它着色为黄色,并将其定位在xyz位置(2,-2,5):

    private Transform sphere;

    @Override
    public void setup() {
        sphere = new Transform();
        float[] color = new float[]{1, 1, 0.5f, 1};
        sphere.addComponent(new Sphere(color));
        sphere.setLocalPosition(2.0f, -2.f, -5.0f);
    }

这就是它应该看起来的样子,一对金色的立体对球:

查看球体

如果你看到了我看到的,你就应该得到一个奖励!

添加地球纹理材质

接下来,我们将通过在球体表面上渲染纹理来将我们的球体改造成地球的球体。

着色器可能会变得非常复杂,实现各种高光、反射、阴影等。一个更简单的算法,仍然利用颜色纹理和光照的是漫反射材质。这就是我们将在这里使用的。漫反射一词指的是光线在表面上的扩散,而不是反射或闪亮(镜面光照)。

纹理只是一个可以映射(投影)到几何表面上的图像文件(例如.jpg)。由于球体不容易被扁平化或剥离成二维地图(正如几个世纪的制图师所证明的那样),纹理图像将会看起来扭曲。以下是我们将用于地球的纹理。(本书的下载文件中提供了此文件的副本,类似的文件也可以在互联网上找到www.solarsystemscope.com/nexus/textures/):

  • 在我们的应用程序中,我们计划将图像资产打包到res/drawable文件夹中。如果需要,现在创建此文件夹。

  • earth_tex.png文件添加到其中。

earth_tex纹理如下图所示:

添加地球纹理材质

加载纹理文件

现在我们需要一个函数将纹理加载到我们的应用程序中。我们可以将其添加到MainActivity中。或者,您可以直接将其添加到RenderBox库的RenderObject类中。(现在将其添加到MainActivity中,我们将在本章末将其与其他扩展一起移动到库中。)添加以下代码:

    public static int loadTexture(final int resourceId){
        final int[] textureHandle = new int[1];

        GLES20.glGenTextures(1, textureHandle, 0);

        if (textureHandle[0] != 0)
        {
            final BitmapFactory.Options options = new BitmapFactory.Options();
            options.inScaled = false;   // No pre-scaling

            // Read in the resource
            final Bitmap bitmap = BitmapFactory.decodeResource(RenderBox.instance.mainActivity.getResources(), resourceId, options);
            // Bind to the texture in OpenGL
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle[0]);

            // Set filtering
            GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
            GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST);

            // Load the bitmap into the bound texture.
            GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);

            // Recycle the bitmap, since its data has been loaded // into OpenGL.
            bitmap.recycle();
        }

        if (textureHandle[0] == 0)
        {
            throw new RuntimeException("Error loading texture.");
        }

        return textureHandle[0];
    }

loadTexture方法返回一个整数句柄,该句柄可用于引用加载的纹理数据。

漫反射光照着色器

现在您可能已经熟悉了,我们将创建一个新的Material,它使用新的着色器。我们现在将编写着色器。在res/raw文件夹中创建两个文件,分别命名为diffuse_lighting_vertex.shaderdiffuse_lighting_fragment.shader,并定义如下。

文件:res/raw/diffuse_lighting_vertex.shader

uniform mat4 u_MVP;
uniform mat4 u_MV;

attribute vec4 a_Position;
attribute vec3 a_Normal;
attribute vec2 a_TexCoordinate;

varying vec3 v_Position;
varying vec3 v_Normal;
varying vec2 v_TexCoordinate;

void main() {
    // vertex in eye space
    v_Position = vec3(u_MV * a_Position);

    // pass through the texture coordinate.
    v_TexCoordinate = a_TexCoordinate;

    // normal's orientation in eye space
    v_Normal = vec3(u_MV * vec4(a_Normal, 0.0));

    // final point in normalized screen coordinates
    gl_Position = u_MVP * a_Position;
}

文件:res/raw/diffuse_lighting_fragment.shader

precision highp float; // default high precision for floating point ranges of the planets

uniform vec3 u_LightPos;        // light position in eye space
uniform vec4 u_LightCol;
uniform sampler2D u_Texture;    // the input texture

varying vec3 v_Position;
varying vec3 v_Normal;
varying vec2 v_TexCoordinate;

void main() {
    // distance for attenuation.
    float distance = length(u_LightPos - v_Position);

    // lighting direction vector from the light to the vertex
    vec3 lightVector = normalize(u_LightPos - v_Position);

    // dot product of the light vector and vertex normal. // If the normal and light vector are
    // pointing in the same direction then it will get max // illumination.
    float diffuse = max(dot(v_Normal, lightVector), 0.01);

    // Add a tiny bit of ambient lighting (this is outerspace)
    diffuse = diffuse + 0.025;

    // Multiply the color by the diffuse illumination level and // texture value to get final output color
    gl_FragColor = texture2D(u_Texture, v_TexCoordinate) * u_LightCol * diffuse;
}

这些着色器为光源添加属性,并利用顶点上的几何法向量来计算着色。您可能已经注意到,这与纯色着色器之间的区别在于使用了texture2D,这是一个采样器函数。另外,请注意我们将u_Texture声明为sampler2D。这种变量类型和函数利用了内置到 GPU 硬件中的纹理单元,并可以与 UV 坐标一起用于从纹理图像返回颜色值。根据图形硬件的不同,纹理单元的数量是固定的。您可以使用 OpenGL 查询纹理单元的数量。对于移动 GPU 来说,一个很好的经验法则是期望有八个纹理单元。这意味着任何着色器可以同时使用多达八个纹理。

漫反射光照材质

现在我们可以编写一个使用纹理和着色器的Material。在materials/文件夹中,创建一个名为DiffuseLightingMaterial的新 Java 类,如下所示:

public class DiffuseLightingMaterial extends Material {
    private static final String TAG = "diffuselightingmaterial";

添加纹理 ID、程序引用和缓冲区的变量,如下所示:

    int textureId;
    static int program = -1; //Initialize to a totally invalid value for setup state
    static int positionParam;
    static int texCoordParam;
    static int textureParam;
    static int normalParam;
    static int MVParam;    
    static int MVPParam;
    static int lightPosParam;
    static int lightColParam;

    FloatBuffer vertexBuffer;
    FloatBuffer texCoordBuffer;
    FloatBuffer normalBuffer;
    ShortBuffer indexBuffer;
    int numIndices;

现在我们可以添加一个构造函数,该构造函数设置着色器程序并为给定的资源 ID 加载纹理,如下所示:

    public DiffuseLightingMaterial(int resourceId){
        super();
        setupProgram();
        this.textureId = MainActivity.loadTexture(resourceId);
    }

正如我们之前所见,setupProgram方法创建着色器程序并获取其参数的引用:

    public static void setupProgram(){
        //Already setup?
        if (program != -1) return;

        //Create shader program
        program = createProgram(R.raw.diffuse_lighting_vertex, R.raw.diffuse_lighting_fragment);
        RenderBox.checkGLError("Diffuse Texture Color Lighting shader compile");

        //Get vertex attribute parameters
        positionParam = GLES20.glGetAttribLocation(program, "a_Position");
        normalParam = GLES20.glGetAttribLocation(program, "a_Normal");
        texCoordParam = GLES20.glGetAttribLocation(program, "a_TexCoordinate");

        //Enable them (turns out this is kind of a big deal ;)
        GLES20.glEnableVertexAttribArray(positionParam);
        GLES20.glEnableVertexAttribArray(normalParam);
        GLES20.glEnableVertexAttribArray(texCoordParam);

        //Shader-specific parameters
        textureParam = GLES20.glGetUniformLocation(program, "u_Texture");
        MVParam = GLES20.glGetUniformLocation(program, "u_MV");
        MVPParam = GLES20.glGetUniformLocation(program, "u_MVP");
        lightPosParam = GLES20.glGetUniformLocation(program, "u_LightPos");
        lightColParam = GLES20.glGetUniformLocation(program, "u_LightCol");

        RenderBox.checkGLError("Diffuse Texture Color Lighting params");
    }

同样,我们添加一个setBuffers方法,该方法由RenderObject组件(Sphere)调用:

    public void setBuffers(FloatBuffer vertexBuffer, FloatBuffer normalBuffer, FloatBuffer texCoordBuffer, ShortBuffer indexBuffer, int numIndices){
        //Associate VBO data with this instance of the material
        this.vertexBuffer = vertexBuffer;
        this.normalBuffer = normalBuffer;
        this.texCoordBuffer = texCoordBuffer;
        this.indexBuffer = indexBuffer;
        this.numIndices = numIndices;
    }

最后,添加draw代码,该代码将从Camera组件调用,以渲染通过setBuffers准备的几何图形。draw方法如下所示:

    @Override
    public void draw(float[] view, float[] perspective) {
        GLES20.glUseProgram(program);

        // Set the active texture unit to texture unit 0.
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);

        // Bind the texture to this unit.
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);

        // Tell the texture uniform sampler to use this texture in // the shader by binding to texture unit 0.
        GLES20.glUniform1i(textureParam, 0);

        //Technically, we don't need to do this with every draw //call, but the light could move.
        //We could also add a step for shader-global parameters //which don't vary per-object
        GLES20.glUniform3fv(lightPosParam, 1, RenderBox.instance.mainLight.lightPosInEyeSpace, 0);
        GLES20.glUniform4fv(lightColParam, 1, RenderBox.instance.mainLight.color, 0);

        Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.lightingModel, 0);
        // Set the ModelView in the shader, used to calculate // lighting
        GLES20.glUniformMatrix4fv(MVParam, 1, false, modelView, 0);
        Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.model, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, modelView, 0);
        // Set the ModelViewProjection matrix for eye position.
        GLES20.glUniformMatrix4fv(MVPParam, 1, false, modelViewProjection, 0);

        //Set vertex attributes
        GLES20.glVertexAttribPointer(positionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);
        GLES20.glVertexAttribPointer(normalParam, 3, GLES20.GL_FLOAT, false, 0, normalBuffer);
        GLES20.glVertexAttribPointer(texCoordParam, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer);

        GLES20.glDrawElements(GLES20.GL_TRIANGLES, numIndices, GLES20.GL_UNSIGNED_SHORT, indexBuffer);

        RenderBox.checkGLError("Diffuse Texture Color Lighting draw");
    }
}

与我们之前定义的SolidColorLightingMaterial类进行比较,您会注意到它非常相似。我们用纹理 ID 替换了单一颜色,并添加了由Sphere组件给出的纹理坐标缓冲区(texCoordBuffer)的要求。另外,请注意我们将活动纹理单元设置为GL_TEXTURE0并绑定纹理。

向 Sphere 组件添加漫反射光照纹理

要将新的材质添加到Sphere组件中,我们将创建一个接收纹理句柄的替代构造函数。然后,它创建DiffuseLightingMaterial类的一个实例,并从球体设置缓冲区。

通过定义一个接受纹理 ID 并调用一个名为createDiffuseMaterial的新辅助方法的新构造函数(Sphere),将材料添加到Sphere组件中,如下所示:

    public Sphere(int textureId){
        super();
        allocateBuffers();
        createDiffuseMaterial(textureId);
    }

    public Sphere createDiffuseMaterial(int textureId){
        DiffuseLightingMaterial mat = new DiffuseLightingMaterial(textureId);
        mat.setBuffers(vertexBuffer, normalBuffer, texCoordBuffer, indexBuffer, numIndices);
        material = mat;
        return this;
    }

现在,我们可以使用纹理材料。

查看地球

要将地球纹理添加到我们的球体中,修改MainActivitysetup方法,以指定纹理资源 ID 而不是颜色,如下所示:

    @Override
    public void setup() {
        sphere = new Transform();
        sphere.addComponent(new Sphere(R.drawable.earth_tex));
        sphere.setLocalPosition(2.0f, -2.f, -2.0f);
    }

你看,家,甜蜜的家!

查看地球

看起来真的很酷。哎呀,它是颠倒的!虽然在外太空中没有特定的上下,但我们的地球看起来颠倒了。让我们在setup方法中翻转它,以便它以正确的方向开始,而且在此过程中,让我们利用Transform方法返回它们自己的事实,这样我们就可以链接调用,如下所示:

    public void setup() {
        sphere = new Transform()
            .setLocalPosition(2.0f, -2.f, -2.0f)
            .rotate(0, 0, 180f)
            .addComponent(new Sphere(R.drawable.earth_tex));
    }

当然,地球应该旋转。让我们对其进行动画处理,使其旋转,就像我们期望地球会做的那样。将此添加到preDraw方法中,该方法在每个新帧之前调用。它使用Time类的getDeltaTime方法,该方法返回自上一帧以来的当前秒数变化的分数。如果我们希望它每秒旋转-10 度,我们使用-10 * deltaTime

    public void preDraw() {
        float dt = Time.getDeltaTime();
        sphere.rotate( 0, -10f * dt, 0);
    }

我觉得这样很好!你觉得呢?

改变相机位置

还有一件事。我们似乎是在与光源一致地观看地球。让我们移动相机视图,这样我们就可以从侧面看到地球。这样,我们可以更好地看到受光照的阴影。

假设我们将光源位置保留在原点(0,0,0),就好像它是太阳在太阳系的中心。地球距离太阳 1.471 亿公里。让我们将球体放在原点右侧那么多单位,并将相机放在相同的相对位置。现在,setup方法看起来像以下代码:

    public void setup() {
        sphere = new Transform()
            .setLocalPosition(147.1f, 0, 0)
            .rotate(0, 0, 180f)
            .addComponent(new Sphere(R.drawable.earth_tex));
        RenderBox.mainCamera.getTransform().setLocalPosition(147.1f, 2f, 2f);
    }

运行它,你会看到这个:

改变相机位置

看起来几乎真实,是吗?NASA 会感到自豪!

白天和夜晚材料

不过,地球的背面看起来异常黑暗。我的意思是,现在不是 18 世纪。现在很多东西都是 24 x 7,尤其是我们的城市。让我们用一个单独的地球夜晚纹理来表示这一点,其中有城市灯光。

我们有一个文件供您使用,名为earth_night_tex.jpg。将文件的副本拖入您的res/drawable/文件夹中。

在这本书的页面上可能有点难以辨认,但这就是纹理图像的样子:

白天和夜晚材料

白天/夜晚着色器

为了支持这一点,我们将创建一个新的DayNightMaterial类,该类接受地球纹理的两个版本。该材料还将包含相应的片段着色器,该着色器考虑了表面相对于光源方向的法线向量(使用点积,如果您熟悉矢量数学)来决定是使用白天纹理图像还是夜晚纹理图像进行渲染。

在你的res/raw/文件夹中,创建day_night_vertex.shaderday_night_fragment.shader文件,然后定义它们,如下所示。

文件:day_night_vertex.shader

uniform mat4 u_MVP;
uniform mat4 u_MV;

attribute vec4 a_Position;
attribute vec3 a_Normal;
attribute vec2 a_TexCoordinate;

varying vec3 v_Position;
varying vec3 v_Normal;
varying vec2 v_TexCoordinate;

void main() {
   // vertex to eye space
   v_Position = vec3(u_MV * a_Position);

   // pass through the texture coordinate
   v_TexCoordinate = a_TexCoordinate;

   // normal's orientation in eye space
   v_Normal = vec3(u_MV * vec4(a_Normal, 0.0));

   // final point in normalized screen coordinates
   gl_Position = u_MVP * a_Position;
}

除了添加v_Texcoordinate之外,这与我们的SolidColorLighting着色器完全相同。

文件:day_night_fragment.shader

precision highp float; //  default high precision for floating point ranges of the //  planets
uniform vec3 u_LightPos;      // light position in eye space
uniform vec4 u_LightCol;
uniform sampler2D u_Texture;  // the day texture.
uniform sampler2D u_NightTexture;    // the night texture.

varying vec3 v_Position;
varying vec3 v_Normal;
varying vec2 v_TexCoordinate;

void main() {
    // lighting direction vector from the light to the vertex
    vec3 lightVector = normalize(u_LightPos - v_Position);

    // dot product of the light vector and vertex normal. If the // normal and light vector are
    // pointing in the same direction then it will get max // illumination.
    float ambient = 0.3;
    float dotProd = dot(v_Normal, lightVector);
    float blend = min(1.0, dotProd * 2.0);
    if(dotProd < 0.0){
        //flat ambient level of 0.3
        gl_FragColor = texture2D(u_NightTexture, v_TexCoordinate) * ambient;
    } else {
        gl_FragColor = (
            texture2D(u_Texture, v_TexCoordinate) * blend
            + texture2D(u_NightTexture, v_TexCoordinate) * (1.0 - blend)
        ) * u_LightCol * min(max(dotProd * 2.0, ambient), 1.0);
    }
}

与往常一样,对于照明,我们计算顶点法线向量和光线方向向量的点积(dotProd)。当该值为负时,顶点面向远离光源(太阳),因此我们将使用夜晚纹理进行渲染。否则,我们将使用常规白天地球纹理进行渲染。

光照计算还包括混合值。这基本上是一种在计算gl_FragColor变量时将过渡区域压缩到终结器周围的方法。我们将点积乘以 2.0,使其遵循更陡的斜率,但仍将混合值夹在 0 和 1 之间。这有点复杂,但一旦你考虑数学,它应该有些意义。

我们使用两种纹理来绘制相同的表面。虽然这可能看起来是独特的白天/黑夜情况,但实际上这是一种非常常见的方法,称为多重纹理。你可能不相信,但在引入一次使用多个纹理的能力之前,3D 图形实际上已经取得了相当大的进展。如今,你几乎可以在任何地方看到多重纹理,实现诸如法线贴图、贴花纹理和位移/视差着色器等技术,这些技术可以在简单的网格上创建更多的细节。

DayNightMaterial 类

现在我们可以编写DayNightMaterial类。它基本上类似于我们之前创建的DiffuseLightingMaterial类,但支持两种纹理。因此,构造函数接受两个纹理 ID。setBuffers方法与之前的方法相同,draw方法几乎相同,但增加了夜间纹理的绑定。

以下是完整的代码,突出显示与DiffuseLightingMaterial不同的行:

public class DayNightMaterial extends Material {
    private static final String TAG = "daynightmaterial";

与我们其他材料一样,声明我们需要的变量,包括白天和夜晚的纹理 ID:

    int textureId;
    int nightTextureId;

    static int program = -1; //Initialize to a totally invalid value for setup state
    static int positionParam;
    static int texCoordParam;
    static int textureParam;
    static int nightTextureParam;
    static int normalParam;
    static int MVParam;
    static int MVPParam;
    static int lightPosParam;
    static int lightColParam;

    FloatBuffer vertexBuffer;
    FloatBuffer texCoordBuffer;
    FloatBuffer normalBuffer;
    ShortBuffer indexBuffer;
    int numIndices;

定义接受资源 ID 和setupProgram辅助方法的构造函数:

    public DayNightMaterial(int resourceId, int nightResourceId){
        super();
        setupProgram();
        this.textureId = MainActivity.loadTexture(resourceId);

        this.nightTextureId = MainActivity.loadTexture(nightResourceId);
    }

    public static void setupProgram(){
        if(program != -1) return;
        //Create shader program
        program = createProgram(R.raw.day_night_vertex, R.raw.day_night_fragment);

        //Get vertex attribute parameters
        positionParam = GLES20.glGetAttribLocation(program, "a_Position");
        normalParam = GLES20.glGetAttribLocation(program, "a_Normal");
        texCoordParam = GLES20.glGetAttribLocation(program, "a_TexCoordinate");

        //Enable them (turns out this is kind of a big deal ;)
        GLES20.glEnableVertexAttribArray(positionParam);
        GLES20.glEnableVertexAttribArray(normalParam);
        GLES20.glEnableVertexAttribArray(texCoordParam);

        //Shader-specific parameters
        textureParam = GLES20.glGetUniformLocation(program, "u_Texture");
        nightTextureParam = GLES20.glGetUniformLocation(program, "u_NightTexture");
        MVParam = GLES20.glGetUniformLocation(program, "u_MV");
        MVPParam = GLES20.glGetUniformLocation(program, "u_MVP");
        lightPosParam = GLES20.glGetUniformLocation(program, "u_LightPos");
        lightColParam = GLES20.glGetUniformLocation(program, "u_LightCol");

        RenderBox.checkGLError("Day/Night params");
    }

    public void setBuffers(FloatBuffer vertexBuffer, FloatBuffer normalBuffer, FloatBuffer texCoordBuffer, ShortBuffer indexBuffer, int numIndices){
        //Associate VBO data with this instance of the material
        this.vertexBuffer = vertexBuffer;
        this.normalBuffer = normalBuffer;
        this.texCoordBuffer = texCoordBuffer;
        this.indexBuffer = indexBuffer;
        this.numIndices = numIndices;
    }

最后,draw方法将所有内容输出到屏幕上:

    @Override
    public void draw(float[] view, float[] perspective) {
        GLES20.glUseProgram(program);

        // Set the active texture unit to texture unit 0.
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);

        // Bind the texture to this unit.
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);

        GLES20.glActiveTexture(GLES20.GL_TEXTURE1);
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, nightTextureId);

        // Tell the texture uniform sampler to use this texture in // the shader by binding to texture unit 0.
        GLES20.glUniform1i(textureParam, 0);
        GLES20.glUniform1i(nightTextureParam, 1);

        //Technically, we don't need to do this with every draw //call, but the light could move.
        //We could also add a step for shader-global parameters //which don't vary per-object
        GLES20.glUniform3fv(lightPosParam, 1, RenderBox.instance.mainLight.lightPosInEyeSpace, 0);
        GLES20.glUniform4fv(lightColParam, 1, RenderBox.instance.mainLight.color, 0);

        Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.lightingModel, 0);
        // Set the ModelView in the shader, used to calculate // lighting
        GLES20.glUniformMatrix4fv(MVParam, 1, false, modelView, 0);
        Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.model, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, modelView, 0);
        // Set the ModelViewProjection matrix for eye position.
        GLES20.glUniformMatrix4fv(MVPParam, 1, false, modelViewProjection, 0);

        //Set vertex attributes
        GLES20.glVertexAttribPointer(positionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);
        GLES20.glVertexAttribPointer(normalParam, 3, GLES20.GL_FLOAT, false, 0, normalBuffer);
        GLES20.glVertexAttribPointer(texCoordParam, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer);

        GLES20.glDrawElements(GLES20.GL_TRIANGLES, numIndices, GLES20.GL_UNSIGNED_SHORT, indexBuffer);

        RenderBox.checkGLError("DayNight Texture Color Lighting draw");
    }
}

渲染白天/黑夜

现在我们准备将新材料整合到我们的Sphere组件中,看看效果如何。

Sphere.java中,添加一个新的构造函数和createDayNightMaterial辅助方法,如下:

    public Sphere(int textureId, int nightTextureId){
        super();
        allocateBuffers();
        createDayNightMaterial(textureId, nightTextureId);
    }

    public Sphere createDayNightMaterial(int textureId, int nightTextureId){
        DayNightMaterial mat = new DayNightMaterial(textureId, nightTextureId);
        mat.setBuffers(vertexBuffer, normalBuffer, texCoordBuffer, indexBuffer, numIndices);
        material = mat;
        return this;
    }

让我们从MainActivitysetup方法中调用它,并用新的Sphere实例替换调用,传递两个纹理的资源 ID:

    .addComponent(new Sphere(R.drawable.earth_tex, R.drawable.earth_night_tex));

现在运行。看起来真酷!高雅!不幸的是,在这里粘贴屏幕截图并没有太多意义,因为城市夜间灯光不会显示得很好。你只能在自己的 Cardboard 观看器中看到它。相信我,这是值得的!

接下来,太阳来了,我说,没问题...

创建太阳

太阳将被渲染为一个带纹理的球体。然而,它不像我们的地球那样有正面和背面的阴影。我们需要以未照亮或者说未着色的方式渲染它。这意味着我们需要创建UnlitTextureMaterial

我们也有太阳的纹理文件(以及所有行星)。尽管它们包含在本书的可下载文件中,但我们不会在本章中展示它们。

sun_tex.png文件拖到res/drawable/文件夹中。

未照亮的纹理着色器

正如我们在本书中早些时候看到的那样,未照亮的着色器比带照明的着色器简单得多。在res/raw/文件夹中,创建unlit_tex_vertex.shaderunlit_tex_fragment.shader文件,并定义它们如下。

文件:unlit_tex_vertex.shader

uniform mat4 u_MVP;

attribute vec4 a_Position;
attribute vec2 a_TexCoordinate;

varying vec3 v_Position;
varying vec2 v_TexCoordinate;

void main() {
   // pass through the texture coordinate
   v_TexCoordinate = a_TexCoordinate;

   // final point in normalized screen coordinates
   gl_Position = u_MVP * a_Position;
}

文件:unlit_tex_fragment.shader

precision mediump float;        // default medium precision
uniform sampler2D u_Texture;    // the input texture

varying vec3 v_Position;
varying vec2 v_TexCoordinate;

void main() {
    // Send the color from the texture straight out
    gl_FragColor = texture2D(u_Texture, v_TexCoordinate);
}

是的,这比我们之前的着色器简单。

未照亮的纹理材料

现在,我们可以编写UnlitTexMaterial类。以下是初始代码:

public class UnlitTexMaterial extends Material {
    private static final String TAG = "unlittex";

    int textureId;

    static int program = -1; //Initialize to a totally invalid value for setup state
    static int positionParam;
    static int texCoordParam;
    static int textureParam;
    static int MVPParam;

    FloatBuffer vertexBuffer;
    FloatBuffer texCoordBuffer;
    ShortBuffer indexBuffer;
    int numIndices;

以下是构造函数,setupProgramsetBuffers方法:

    public UnlitTexMaterial(int resourceId){
        super();
        setupProgram();
        this.textureId = MainActivity.loadTexture(resourceId);
    }

    public static void setupProgram(){
        if(program != -1) return;
        //Create shader program
        program = createProgram(R.raw.unlit_tex_vertex, R.raw.unlit_tex_fragment);

        //Get vertex attribute parameters
        positionParam = GLES20.glGetAttribLocation(program, "a_Position");
        texCoordParam = GLES20.glGetAttribLocation(program, "a_TexCoordinate");

        //Enable them (turns out this is kind of a big deal ;)
        GLES20.glEnableVertexAttribArray(positionParam);
        GLES20.glEnableVertexAttribArray(texCoordParam);

        //Shader-specific parameters
        textureParam = GLES20.glGetUniformLocation(program, "u_Texture");
        MVPParam = GLES20.glGetUniformLocation(program, "u_MVP");

        RenderBox.checkGLError("Unlit Texture params");
    }

    public void setBuffers(FloatBuffer vertexBuffer, FloatBuffer texCoordBuffer, ShortBuffer indexBuffer, int numIndices){
        //Associate VBO data with this instance of the material
        this.vertexBuffer = vertexBuffer;
        this.texCoordBuffer = texCoordBuffer;
        this.indexBuffer = indexBuffer;
        this.numIndices = numIndices;
    }

在后续项目中,为纹理 ID 添加获取器和设置器方法会很方便(这里不使用):

    public void setTexture(int textureHandle){
        textureId = textureHandle;
    }

      public int getTexture(){
          return textureId;
      }

最后,这是draw方法:

    @Override
    public void draw(float[] view, float[] perspective) {
        GLES20.glUseProgram(program);

        // Set the active texture unit to texture unit 0.
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);

        // Bind the texture to this unit.
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);

        // Tell the texture uniform sampler to use this texture in // the shader by binding to texture unit 0.
        GLES20.glUniform1i(textureParam, 0);

        Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.model, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, modelView, 0);
        // Set the ModelViewProjection matrix in the shader.
        GLES20.glUniformMatrix4fv(MVPParam, 1, false, modelViewProjection, 0);

        // Set the vertex attributes
        GLES20.glVertexAttribPointer(positionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);
        GLES20.glVertexAttribPointer(texCoordParam, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer);

        GLES20.glDrawElements(GLES20.GL_TRIANGLES, numIndices, GLES20.GL_UNSIGNED_SHORT, indexBuffer);

        RenderBox.checkGLError("Unlit Texture draw");
    }
}

使用未照亮的纹理进行渲染

我们准备将新材料整合到我们的Sphere类中,看看效果如何。

Sphere.java中,添加一个新的构造函数,该构造函数接受一个boolean参数,指示纹理是否应该被照亮,并添加createUnlitTexMaterial辅助方法:

    public Sphere(int textureId, boolean lighting){
        super();
        allocateBuffers();
        if (lighting) {
            createDiffuseMaterial(textureId);
        } else {
            createUnlitTexMaterial(textureId);
        }
    }

    public Sphere createUnlitTexMaterial(int textureId){
        UnlitTexMaterial mat = new UnlitTexMaterial(textureId);
        mat.setBuffers(vertexBuffer, texCoordBuffer, indexBuffer, numIndices);
        material = mat;
        return this;
    }

请注意,我们定义构造函数的方式,您可以调用new Sphere(texId)new Sphere(texId, true)来获得有光的渲染。但对于未照明的渲染,您必须使用第二个Sphere(texId, false)。还要注意,构造函数中设置整个组件并不是唯一的方法。我们之所以这样做,是因为这样可以使我们的MainActivity代码简洁。实际上,随着我们开始扩展对RenderBox及其着色器库的使用,将有必要将大部分代码放入我们的MainActivity类中。为每种材料创建构造函数是不可能的。最终,需要一个材料系统,以允许您创建和设置材料,而无需为每个材料创建一个新类。

添加太阳

现在,我们只需要将太阳球添加到MainActivitysetup方法中。让它变大,比如说,按照 6.963 的比例(记住这是以百万公里为单位)。现在这个值可能看起来是任意的,但当我们对太阳系的几何进行计算并缩放行星时,您将看到它的来源。

将以下代码添加到MainActivitysetup方法中:

    public void setup() {
        Transform origin = new Transform();

        //Sun
        Transform sun = new Transform()
            .setParent(origin, false)
            .setLocalScale(6.963f, 6.963f, 6.963f)
            .addComponent(new Sphere(R.drawable.sun_tex, false));

        //"Sun" light
        RenderBox.instance.mainLight.transform.setPosition( origin.getPosition());
        RenderBox.instance.mainLight.color = new float[]{1, 1, 0.8f, 1};

            //Earth…

我们首先定义一个原点变换,它将是太阳系的中心。然后,我们创建太阳,将其作为原点的父级,并给定比例。然后,添加一个具有太阳纹理的新球组件。我们还给了我们的光一个略带黄色的颜色,这将与地球的纹理颜色混合。

这是渲染后的太阳的样子,它似乎照亮了地球:

添加太阳

现在,让我们继续进行太阳系的其余部分。

创建一个 Planet 类

在构建我们的太阳系时,将有助于将Planet类抽象出来用于每个行星。

行星有许多不同的属性,除了它们的纹理资源 ID 外,还定义了它们独特的特征。行星与太阳的距离、大小(半径)和轨道速度。行星都围绕太阳作为它们的原点进行轨道运行。

  • 距离将是其距离太阳的距离,以百万公里为单位进行测量。

  • 半径将是行星的大小,以公里为单位(实际上是以百万公里为单位,以保持一致)。

  • 旋转是行星绕其自身轴旋转的速率(其一天之一)。

  • 轨道是行星绕太阳运行的速率(其一年之一)。我们将假设轨道是完全圆形的。

  • TexId是行星纹理图像的资源 ID。

  • origin是其轨道的中心。对于行星,这将是太阳的变换。对于卫星,这将是卫星的行星。

太阳系是一个非常庞大的东西。距离和半径以百万公里为单位进行测量。行星之间的距离非常遥远,相对于它们的轨道大小来说,它们的大小相对较小。旋转和轨道值是相对速率。您会注意到,我们将它们标准化为每地球日 10 秒。

根据这些属性,行星保持两个变换:一个变换用于行星本身,另一个变换描述其在轨道上的位置。通过这种方式,我们可以旋转每个行星的单独父级变换,当行星处于局部位置时,其大小等于轨道半径,会导致行星以圆形运动。然后我们可以使用其变换旋转行星本身。

对于月球,我们还将使用Planet类(是的,我知道,也许我们应该将其命名为HeavenlyBody?),但将其原点设置为地球。月球不会旋转。

在您的应用程序中(例如app/java/com/cardbookvr/solarsystem/),创建一个 Java 类并命名为Planet。添加其属性(distanceradiusrotationorbitorbitTransformtransform)的变量,如下所示:

public class Planet {
    protected float rotation, orbit;
    protected Transform orbitTransform, transform;

    public float distance, radius;

定义一个构造函数,该构造函数接受行星的属性值,初始化变量并计算初始变换:

    public Planet(float distance, float radius, float rotation, float orbit, int texId, Transform origin){
        setupPlanet(distance, radius, rotation, orbit, origin);
        transform.addComponent(new Sphere(texId));
    }

    public void setupPlanet(float distance, float radius, float rotation, float orbit, Transform origin){
        this.distance = distance;
        this.radius = radius;
        this.rotation = rotation;
        this.orbit = orbit;
        this.orbitTransform = new Transform();
        this.orbitTransform.setParent(origin, false);

        transform = new Transform()
            .setParent(orbitTransform, false)
            .setLocalPosition(distance, 0, 0)
            .setLocalRotation(180, 0, 0)
            .setLocalScale(radius, radius, radius);
    }

构造函数为行星生成初始变换,并添加一个带有给定纹理的Sphere组件。

在每一帧新的画面中,我们将更新orbitTransform绕太阳的旋转(年)和行星绕自身轴的旋转(日):

    public void preDraw(float dt){
        orbitTransform.rotate(0, dt * orbit, 0);
        transform.rotate(0, dt * -rotation, 0);
    }

我们还可以为Planet类的变换提供一些访问器方法:

    public Transform getTransform() { return transform; }
    public Transform getOrbitransform() { return orbitTransform; }

现在,让我们来看看我们太阳系的几何形状。

太阳系的形成

这是我们将真正的科学投入到我们的项目中的机会。以下表格显示了每个行星的实际距离、大小、旋转和轨道值。(大部分数据来自www.enchantedlearning.com/subjects/astronomy/planets/。)

行星 距离太阳(百万公里) 半径大小(公里) 白天长度(地球小时) 年长度(地球年)
水星 57.9 2440 1408.8 0.24
金星 108.2 6052 5832 0.615
地球 147.1 6371 24 1.0
地球的月亮 0.363(来自地球) 1737 0
火星 227.9 3390 24.6 2.379
木星 778.3 69911 9.84 11.862
土星 1427.0 58232 10.2 29.456
天王星 2871.0 25362 17.9 84.07
海王星 4497 24622 19.1 164.81
冥王星(仍然计数) 5913 1186 6.39 247.7

我们还为每个行星准备了纹理图像。这些文件包含在本书的下载中。它们应该被添加到res/drawable文件夹中,命名为mercury_tex.pngvenus_tex.png等等。以下表格标识了我们使用的来源以及您可以找到它们的位置:

行星 纹理
水星 laps.noaa.gov/albers/sos/mercury/mercury/mercury_rgb_cyl_www.jpg
金星 csdrive.srru.ac.th/55122420119/texture/venus.jpg
地球 www.solarsystemscope.com/nexus/content/tc-earth_texture/tc-earth_daymap.jpg夜晚:www.solarsystemscope.com/nexus/content/tc-earth_texture/tc-earth_nightmap.jpg
地球的月亮 farm1.staticflickr.com/120/263411684_ea405ffa8f_o_d.jpg
火星 lh5.ggpht.com/-2aLH6cYiaKs/TdOsBtnpRqI/AAAAAAAAAP4/bnMOdD9OMjk/s9000/mars%2Btexture.jpg
木星 laps.noaa.gov/albers/sos/jupiter/jupiter/jupiter_rgb_cyl_www.jpg
土星 www.solarsystemscope.com/nexus/content/planet_textures/texture_saturn.jpg
天王星 www.astrosurf.com/nunes/render/maps/full/uranus.jpg
海王星 www.solarsystemscope.com/nexus/content/planet_textures/texture_neptune.jpg
冥王星 www.shatters.net/celestia/files/pluto.jpg
太阳 www.solarsystemscope.com/nexus/textures/texture_pack/assets/preview_sun.jpg
银河系 www.geckzilla.com/apod/tycho_cyl_glow.png(由 Judy Schmidt,geckzilla.com/

在 MainActivity 中设置行星

我们将在MainActivity中设置所有行星,使用一个setupPlanets方法,该方法将从setup中调用。让我们开始吧。

在类的顶部,声明一个 planets 数组:

    Planet[] planets;

然后,我们声明了一些常量,我们将在下面解释:

    // tighten up the distances (millions km)
    float DISTANCE_FACTOR = 0.5f; // this is 100x relative to interplanetary distances
    float SCALE_FACTOR = 0.0001f; // animation rate for one earth rotation (seconds per rotation)
    float EDAY_RATE = 10f; // rotation scale factor e.g. to animate earth: dt * 24 * // DEG_PER_EHOUR
    float DEG_PER_EHOUR = (360f / 24f / EDAY_RATE); // animation rate for one earth rotation (seconds per orbit)//  (real is EDAY_RATE * 365.26)
    float EYEAR_RATE = 1500f; // orbit scale factorfloat DEG_PER_EYEAR = (360f / EYEAR_RATE); 

setupPlanets 方法使用我们的天体数据并相应地构建新的行星。首先,让我们定义物理数据,如下所示:

    public void setupPlanets(Transform origin) {

        float[] distances = new float[] { 57.9f, 108.2f, 149.6f, 227.9f, 778.3f, 1427f, 2871f, 4497f, 5913f };
        float[] fudged_distances = new float[] { 57.9f, 108.2f, 149.6f, 227.9f, 400f, 500f, 600f, 700f, 800f };

        float[] radii = new float[] { 2440f, 6052f, 6371f, 3390f, 69911f, 58232f, 25362f, 24622f, 1186f };

        float[] rotations = new float[] { 1408.8f * 0.05f, 5832f * 0.01f, 24f, 24.6f, 9.84f, 10.2f, 17.9f, 19.1f, 6.39f };

        float[] orbits = new float[] { 0.24f, 0.615f, 1.0f, 2.379f, 11.862f, 29.456f, 84.07f, 164.81f, 247.7f };

distances 数组包含每个行星距离太阳的距离,单位为百万公里。这实际上非常巨大,尤其是对于那些非常遥远且相对于其他行星不太可见的外行星。为了使事情更有趣,我们将调整这些行星(从木星到冥王星)的距离,因此我们将使用的值在 fudged_distances 数组中。

radii 数组包含每个行星的实际大小(以公里为单位)。

rotations 数组包含地球小时的长度。由于水星和金星的自转速度相对于地球非常快,我们将通过任意比例因子人为地减慢它们的速度。

orbits 数组包含每个行星的年份长度和绕太阳一周所需的时间。

现在,让我们为每个行星的材质设置纹理 ID:

        int[] texIds = new int[]{
                R.drawable.mercury_tex,
                R.drawable.venus_tex,
                R.drawable.earth_tex,
                R.drawable.mars_tex,
                R.drawable.jupiter_tex,
                R.drawable.saturn_tex,
                R.drawable.uranus_tex,
                R.drawable.neptune_tex,
                R.drawable.pluto_tex
        };

现在初始化 planets 数组,为每个创建一个新的 Planet 对象:

        planets = new Planet[distances.length + 1];
        for(int i = 0; i < distances.length; i++){
            planets[i] = new Planet(
                    fudged_distances[i] * DISTANCE_FACTOR,
                    radii[i] * SCALE_FACTOR,
                    rotations[i] * DEG_PER_EHOUR,
                    orbits[i] * DEG_PER_EYEAR * fudged_distances[i]/distances[i],
                    texIds[i],
                    origin);
        }

虽然我们对一些行星的实际距离进行了调整,使它们更接近内太阳系,但我们还将所有距离乘以 DISTANCE_FACTOR 标量,主要是为了不使我们的浮点精度计算失真。我们通过不同的 SCALE_FACTOR 变量来缩放所有行星的大小,使它们相对于实际尺寸更大(0.0001 实际上是 100 的因子,因为半径是以公里计算的,而距离是以百万公里计算的)。

旋转动画速率是行星的实际日长度,乘以我们希望在 VR 中动画化一天的速度。我们默认为每个地球日 10 秒。

最后,行星轨道动画有自己的比例因子。我们加快了大约 2 倍。您还可以调整距离调整因子的轨道速率(例如,冥王星绕太阳运行一次需要 247 个地球年,但我们已经将其移动得更接近,因此它需要减速)。

然后,我们添加了地球的月球。我们在这里也使用了一些艺术许可,调整了距离和半径,并加快了其轨道速率,使其在 VR 中观看更有吸引力。

        // Create the moon
        planets[distances.length] = new Planet(7.5f, 0.5f, 0, - 0.516f, R.drawable.moon_tex, planets[2].getTransform());}

让我们再看看另一个方法:goToPlanet。将摄像机方便地定位在特定行星附近。由于行星位于数据驱动的位置并且将在轨道上移动,最好将摄像机设置为行星变换的子级。这是我们将轨道变换与行星变换分开的原因之一。我们不希望摄像机随着行星旋转而旋转——您可能会晕!这是实现方式:

    void goToPlanet(int index){
        RenderBox.mainCamera.getTransform().setParent( planets[index].getOrbitransform(), false);
        RenderBox.mainCamera.getTransform().setLocalPosition( planets[index].distance, planets[index].radius * 1.5f, planets[index].radius * 2f);
    }

注意

请注意,我们最终在代码中使用的比例和距离值是从而不是实际的天体测量中得出的。要体验真正有教育价值的太阳系 VR 体验,请查看《太空巨人》(www.titansofspacevr.com/)。

摄像机的行星视图

gotoPlanet 函数被调用时带有一个行星索引(例如,地球是 2),这样我们就可以将摄像机定位在指定行星附近。Camera 组件被作为行星的 orbitTransform 变量的父级,以获取行星当前的轨道旋转。然后,它被定位为距离太阳的行星距离,然后相对于行星的大小进行偏移。

MainActivity 类的设置方法中,我们已经设置了太阳和地球。我们将用一个 setupPlanets 辅助方法替换地球球体:

    public void setup() { 
        //Sun ...

        // Planets
 setupPlanets(origin);

 // Start looking at Earth 
 goToPlanet(2);
    }

如果现在构建并运行项目,您将看到地球、太阳,可能还有一些行星。但直到它们在它们的轨道上运动时,它们才会活跃起来。

动画天体

现在我们已经实例化了所有行星,我们可以对它们的轨道和轴旋转进行动画处理。只需在 MainAcitvity 类的 preDraw 方法中更新它们的变换即可:

    @Override
    public void preDraw() {
        float dt = Time.getDeltaTime();
        for(int i = 0; i < planets.length; i++){
            planets[i].preDraw(dt);
        }
    }

跑吧!哦,哇!我感觉自己像个神。嗯,不完全是,因为外面很黑。我们需要星星!

星空天穹

如果宇宙只是一个巨大的球,我们就在其中,那将是我们要想象的实现一个星空球形背景。

在计算机图形学中,您可以创建背景,使场景看起来比实际更大。您可以使用球形纹理或天穹,就像我们将在这里使用的一样。(许多游戏引擎中的常见替代品是由立方体的六个内部面构成的立方体天空盒。)

在我们提供的纹理集中,有milky_way_tex.png。如果res/drawable/目录中没有该文件,请将其拖入其中。

现在,我们可以将星空天穹添加到我们的场景中。将以下代码添加到MainActivity.setup()中:

        //Stars in the sky
        Transform stars = new Transform()
                .setParent(RenderBox.mainCamera.transform, false)
                .setLocalScale(Camera.Z_FAR * 0.99f, Camera.Z_FAR * 0.99f, Camera.Z_FAR * 0.99f)
                .addComponent(new Sphere(R.drawable.milky_way_tex, false));

这看起来更加神圣。

星空天穹

您可能想知道 0.99 因子是什么意思。不同的 GPU 对浮点数的处理方式不同。虽然有些可能以某种方式在绘制距离上渲染顶点,但其他可能会因为浮点精度而在几何图形“边缘”出现渲染故障。在这种情况下,我们只是通过一个任意小的因子将天空盒拉向相机。在 VR 中,天空盒尽可能远是非常重要的,这样它就不会因视差而被绘制。天空盒在左眼和右眼的确切位置相同,这就是让你的大脑误以为它是无限远的技巧。您可能会发现您需要调整这个因子以避免天空盒中的空洞。

微调地球

如果你是一个太空迷,你可能会想到我们可以对我们的地球模型做一些事情。首先,我们应该添加夜景纹理。(火星和其他行星不需要,因为它们的城市在夜晚关闭了所有的灯光。)此外,地球在其轴上略微倾斜。我们可以修复这个问题。

夜晚纹理

首先,让我们添加夜景纹理。为此,让我们将Earth Java 类作为Planet的子类。右键单击您的 Javasolarsystem文件夹,选择新建 | Java 类,并将其命名为Earth。然后,开始定义它如下:

public class Earth extends Planet {

    public Earth(float distance, float radius, float rotation, float orbit, int texId, int nightTexId, Transform origin) {
        super(distance, radius, rotation, orbit, origin);
        transform.addComponent(new Sphere(texId, nightTexId));
    }
}

这要求我们向Planet类添加一个新的构造函数,省略texId,因为地球构造函数创建了新的Sphere组件,这次有两个纹理,textIdnightTexId

Planet.java中,添加以下代码:

    public Planet(float distance, float radius, float rotation, float orbit, Transform origin){
        setupPlanet(distance, radius, rotation, orbit, origin);
    }

现在,在MainActivity中,让我们单独创建一个地球,而不是其他行星。在setupPlanets中,修改循环以处理这种情况:

        for(int i = 0; i < distances.length; i++){
 if (i == 2) {
 planets[i] = new Earth(
 fudged_distances[i] * DISTANCE_FACTOR,
 radii[i] * SCALE_FACTOR,
 rotations[i] * DEG_PER_EHOUR,
 orbits[i] * DEG_PER_EYEAR * fudged_distances[i] / distances[i],
 texIds[i],
 R.drawable.earth_night_tex,
 origin);
 } else {
                planets[i] = new Planet(

轴倾斜和摆动

在所有的伟大之中,就像所有的自然和人类一样,地球并不完美。在这种情况下,我们谈论的是倾斜和摆动。地球的旋转轴并不完全垂直于轨道平面。它也在旋转时受到轻微的摆动。我们可以在我们的虚拟模型中展示这一点。

修改Earth类的构造函数如下:

    Transform wobble;

    public Earth(float distance, float radius, float rotation, float orbit, int texId, int nightTexId, Transform origin) {
        super(distance, radius, rotation, orbit, origin);

        wobble = new Transform()
                .setLocalPosition(distance, 0, 0)
                .setParent(orbitTransform, false);

        Transform tilt = new Transform()
                .setLocalRotation(-23.4f,0,0)
                .setParent(wobble, false);

        transform
                .setParent(tilt, false)
                .setLocalPosition(0,0,0)
                .addComponent(new Sphere(texId, nightTexId));
    }

现在,地球在每一帧的旋转都是针对这个摆动变换的,所以给地球自己的preDraw方法,如下所示:

    public void preDraw(float dt){
        orbitTransform.rotate(0, dt * orbit, 0);
        wobble.rotate(0, dt * 5, 0);
        transform.rotate(0, dt * -rotation, 0);
    }

改变相机位置

我们太阳系的最后一个特性是使它更具互动性。我的意思是所有这些行星看起来都很酷,但你实际上无法从那么远的地方看到它们。点击 Cardboard 触发器从行星跳到行星,近距离观察一下,怎么样?

幸运的是,我们已经有一个goToPlanet方法,我们用它来设置我们从地球的初始视图。因为MainActivity扩展了CardboardActivity,我们可以使用 Cardboard SDK 的onCardboardTrigger方法(参考developers.google.com/cardboard/android/latest/reference/com/google/vrtoolkit/cardboard/CardboardActivity.html#onCardboardTrigger())。

将以下代码添加到MainActivity中:

    int currPlanet = 2;

    public void onCardboardTrigger(){
        if (++currPlanet >= planets.length)
            currPlanet = 0;
        goToPlanet(currPlanet);
    }

应用程序将从地球(索引 2)附近的摄像机开始。当用户按下硬纸板触发器(或触摸屏幕)时,它将前往火星(3)。然后是木星,依此类推,然后循环回水星(0)。

可能的增强

您能想到对这个项目的其他增强吗?以下是一些您可以考虑并尝试实现的增强:

  • 为土星添加环。(一种廉价的实现方法可能是使用具有透明度的平面。)

  • 改进goToPlanet,使摄像机位置在各个位置之间动画变化。

  • 添加控件,允许您改变透视或在太空中自由飞行。

  • 添加一个俯视选项,以获得太阳系的“传统”图片。(注意在规模上的浮点精度问题。)

  • 为每个其他行星添加卫星。(这可以像我们为地球的月球所做的那样实现,以其母行星为原点。)

  • 表示火星和木星之间的小行星带。

  • 为其他行星添加倾斜和摆动。你知道天王星是侧着自转的吗?

  • 为每个行星添加文本标签,使用行星的变换,但始终面向摄像机。在没有 3D 文本对象的情况下,标签可以是预先准备的图像。

  • 添加背景音乐。

  • 以准确表示给定日期上每个行星的相对位置的方式改进位置精度。

更新 RenderBox 库

随着太阳系项目的实施和我们的代码稳定,您可能会意识到我们构建了一些不一定特定于此应用程序的代码,可以在其他项目中重用,并且应该回到RenderBox库。这就是我们现在要做的。

我们建议您直接在 Android Studio 中执行此操作,从此项目的层次结构视图中选择并复制到其他项目中。执行以下步骤:

  1. 将太阳系的res/raw/目录中的所有.shader文件移动到RenderBox库的RenderBox模块的res/raw/目录中。如果您一直在跟进,将会有八个文件,分别是day_nightdiffuse_lightingsolid_color_lightingunilt_tex的顶点和片段.shader文件。

  2. 将太阳系的RenderBoxExt模块文件夹中的所有ComponentMaterial.java文件移动到RenderBox库的RenderBox模块中的相应文件夹中。删除源代码中对MainActivity的所有无效引用。

  3. 在太阳系项目中,我们在MainActivity中实现了一个名为loadTexture的方法。它应该属于RenderBox库。找到太阳系的MainActivity.java文件中loadTexture的声明,并剪切代码。然后,打开RenderBox库中的RenderObject.java文件,并将定义粘贴到RenderObject类中。

  4. RenderBox库中,用RenderObject.loadTexture替换(重构)所有MainActivity.loadTexture的实例。这些将在几个Material Java 文件中找到,我们在其中加载材质纹理。

  5. RenderBox.java中,reset()方法销毁任何材料的句柄。添加我们刚刚引入的新材料的调用:

  • DayNightMaterial.destroy()

  • DiffuseLightingMaterial.destroy()

  • SolidColorLightingMaterial.destroy()

  • UnlitTexMaterial.destroy()

  1. 解决任何包名称不匹配的问题,并修复任何其他编译时错误,包括删除源代码中对solarsystem的任何引用。

现在,您应该能够成功重建库(构建 | 生成模块'renderbox'),以生成更新的renderbox[-debug].aar库文件。

最后,太阳系项目现在可以使用新的.aar库。将RenderBoxLib项目的renderbox/build/output文件夹中的renderbox[-debug].aar文件复制到 SolarSystem 的renderbox/文件夹中,用新构建的文件替换同名文件。使用此版本的库构建和运行太阳系项目。

总结

恭喜!你在太阳系科学项目中获得了“A”!

在本章中,我们构建了一个太阳系模拟,可以在虚拟现实中使用 Cardboard VR 观看器和安卓手机查看。本项目使用并扩展了RenderBox库,如第五章 RenderBox Engine中所讨论的那样。

首先,我们向我们的技能库中添加了一个Sphere组件。最初,它是使用纯色光照材质渲染的。然后,我们定义了一个漫反射光照材质,并使用地球图像纹理渲染了这个球体,结果呈现出了一个地球仪。接下来,我们增强了材质以接受两个纹理,为球体的背面/“夜晚”一侧添加了额外的纹理。最后,我们创建了一个未照明的纹理材质,用于太阳。凭借太阳系行星的实际大小和与太阳的距离,我们配置了一个太阳系场景,包括九大行星、地球的月亮和太阳。我们添加了一个星空作为天空圆顶,并为它们适当的自转(日)和公转(年)进行了动画处理。我们还实现了一些交互,通过对 Cardboard 触发事件的响应,将摄像机视图从行星移动到行星。

在下一章中,我们将再次使用我们的球体,这次是为了查看您的 360 度照片库。

第七章:360 度画廊

360 度全景照片和视频是虚拟现实的一种不同方法。与使用 OpenGL 实时渲染 3D 几何图形不同,您让用户四处查看一个预先渲染或拍摄的场景。360 度查看器是向消费者介绍 VR 的绝佳方式,因为它们提供了非常自然的体验,并且易于制作。拍摄照片比实时渲染物体的逼真场景要容易得多。使用新一代 360 度摄像机或 Google 相机应用程序中的全景图像功能很容易记录图像。查看预先录制的图像需要比渲染完整的 3D 场景需要更少的计算机功率,并且在移动 Cardboard 查看器上运行良好。电池电量也不应该是一个问题。

非 VR 360 度媒体已经相当普遍。例如,多年来,房地产列表网站提供了带有基于网络的播放器的全景漫游,让您可以交互地查看空间。同样,YouTube 支持上传和播放 360 度视频,并提供一个带有交互式控件的播放器,可以在播放期间四处查看。Google 地图允许您上传 360 度静态全景图像,就像他们的街景工具一样,您可以使用 Android 或 iOS 应用程序(有关更多信息,请访问www.google.com/maps/about/contribute/photosphere/)或消费者 360 摄像机创建。互联网上充斥着 360 度媒体!

在 VR 中查看 360 度媒体令人惊讶地沉浸,即使是静态照片(甚至没有成对的立体图像)。你站在一个球体的中心,图像投影在内表面上,但你感觉自己真的在被捕捉的场景中。只需转动头部来四处看。

在这个项目中,我们将构建一个照片库,让您可以在手机上浏览照片。常规的平面图片和全景照片将显示在您左侧的大屏幕上。但是 360 度全景照片将完全沉浸您在球形投影中。我们将通过以下步骤完成这个项目:

  • 设置新项目

  • 查看 360 度全景照片

  • 在大型虚拟投影屏幕上查看常规照片

  • 为照片添加边框

  • 从设备的相机文件夹加载和显示照片图像

  • 调整照片的方向和长宽比

  • 创建一个用户界面,其中包含一个缩略图图像网格,用于选择要滚动查看的照片

  • 确保良好的、响应迅速的 VR 体验,具有线程安全的操作

  • 启动 Android 图像查看意图应用程序

此项目的源代码可以在 Packt Publishing 网站上找到,并且在 GitHub 上也可以找到github.com/cardbookvr/gallery360(每个主题都是单独的提交)。

设置新项目

要构建这个项目,我们将使用我们在第五章中创建的RenderBox库,RenderBox Engine。您可以使用您自己的库,或者从本书提供的下载文件或我们的 GitHub 存储库中获取一份副本(使用标记为after-ch6的提交——github.com/cardbookvr/renderboxlib/releases/tag/after-ch6)。有关如何导入RenderBox库的更详细描述,请参阅第五章中的最后一节,在未来项目中使用 RenderBox。要做到这一点,请执行以下步骤:

  1. 打开 Android Studio,创建一个新项目。让我们将其命名为Gallery360,并针对Android 4.4 KitKat(API 19)使用空活动

  2. renderboxcommoncore包创建新模块,使用文件 | 新模块 | 导入.JAR/.AAR 包

  3. 将模块设置为应用程序的依赖项,使用文件 | 项目结构

  4. 根据第二章中的说明编辑build.gradle文件,骨架 Cardboard 项目,以便编译 SDK 22。

  5. 更新/res/layout/activity_main.xmlAndroidManifest.xml,如前几章所述。

  6. MainActivity编辑为class MainActivity extends CardboardActivity implements IRenderBox,并实现接口方法存根(Ctrl + I)。

我们可以继续在MainActivity中定义onCreate方法。该类现在有以下代码:

public class MainActivity extends CardboardActivity implements IRenderBox {
    private static final String TAG = "Gallery360";
    CardboardView cardboardView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
        cardboardView.setRenderer(new RenderBox(this, this));
        setCardboardView(cardboardView);
    }
    @Override
    public void setup() {

    }
    @Override
    public void preDraw() {
        // code run beginning each frame
    }
    @Override
    public void postDraw() {
        // code run end of each frame
    }
}

在我们实施这个项目的过程中,我们将创建一些可能成为RenderBoxLib的好扩展的新类。起初,我们将把它们作为这个项目中的常规类。然后,在本章末,我们将帮助您将它们移动到RenderBoxLib项目中,并重新构建库。执行以下步骤:

  1. 右键单击gallery360文件夹(com.cardbookvr.gallery360),然后转到新建 | ,并命名包为RenderBoxExt

  2. RenderBoxExt中,创建名为componentsmaterials的包子文件夹。

没有真正的技术需要将其作为一个单独的包,但这有助于组织我们的文件,因为RenderBoxExt中的文件将在本章末被移动到我们的可重用库中。

您可以将一个立方体临时添加到场景中,以确保一切设置正确。将其添加到setup方法中,如下所示:

    public void setup() {
        new Transform()
            .setLocalPosition(0,0,-7)
            .setLocalRotation(45,60,0)
            .addComponent(new Cube(true));
    }

如果你记得的话,立方体是添加到变换中的一个组件。立方体定义了它的几何形状(例如,顶点)。变换定义了它在 3D 空间中的位置、旋转和缩放。

您应该能够在 Android 设备上点击运行'app',没有编译错误,并看到立方体和 Cardboard 分屏视图。

查看 360 度照片

自从发现地球是圆的以来,制图师和水手们一直在努力将球形地球投影到二维图表上。结果是对地球某些区域的不可避免的失真。

注意

要了解更多关于地图投影和球体失真的信息,请访问en.wikipedia.org/wiki/Map_projection

对于 360 度媒体,我们通常使用等经纬(或子午线)投影,其中球体被展开成柱状投影,随着你向北极和南极推进,纹理会被拉伸,而经线则保持等距垂直直线。为了说明这一点,考虑提索特指示圈(访问en.wikipedia.org/wiki/Tissot%27s_indicatrix了解更多信息),它显示了一个球体,上面有战略性排列的相同圆圈(Stefan Kühn 的插图):

查看 360 度照片

以下图片显示了使用等经纬投影(en.wikipedia.org/wiki/Equirectangular_projection)展开的地球:

查看 360 度照片

我们将为我们的全景照片使用等经纬网格和适当投影(扭曲)的图像作为纹理贴图。为了查看,我们将相机视点放在球体的中心,并将图像渲染到内表面。

您可能已经注意到,我们的地球和其他行星纹理上有相同类型的失真。这是将球形图像映射到平面图像的一种常见方式,事实上,自从我们在第六章中为我们的球体创建 UV 以来,我们一直在“计算”这个问题!您将不得不在 UV 偏移上下些功夫,以防止它们出现拉伸,但您也应该能够以同样的方式在球体上显示全景照片。

查看一个样本全景照片

您可以为这个主题选择任何 360 度等经纬图像。我们在本书中包含了以下海滩照片,名为sample360.jpg

查看示例全景照片

将其添加到您的项目中。

将要查看的图像复制到项目的res/drawable/文件夹中。现在将以下代码添加到MainActivity.java文件中:

    final int DEFAULT_BACKGROUND = R.drawable.sample360;

    Sphere photosphere;

    @Override
    public void setup() {
        setupBackground();
    }

    void setupBackground() {
        photosphere = new Sphere(DEFAULT_BACKGROUND, false);
        new Transform()
            .setLocalScale(Camera.Z_FAR * 0.99f, -Camera.Z_FAR * 0.99f, Camera.Z_FAR * 0.99f)
            .addComponent(photosphere);
    }

注意,将比例乘以 0.99 可以避免由于某些手机上的浮点精度错误而导致背景图像的不必要剪裁。使用负比例y轴可以补偿纹理着色器的反向渲染(或者您可以修改着色器代码)。

您可以用您的文件名替换可绘制的文件名R.drawable.sample360,如DEFAULT_BACKGROUND变量中所定义的那样。这个变量必须是 final 的,根据 Android 资源系统的要求。

setup方法中,我们像一直以来一样创建一个Sphere组件。从一个新的变换开始,缩放它,然后向变换添加一个新的Sphere组件,带有我们的资源 ID。我们将对象命名为background,因为以后,这个对象将成为应用程序的默认背景。

运行应用程序,并将手机插入 Cardboard 查看器中。哇!你来到了 Margaritaville!如果这看起来很容易,那么你是对的;它确实很容易!实际上,艰苦的工作是由全景应用程序或其他将图像转换为等距投影的应用程序完成的。其余的工作就是我们一直在做的标准 UV 投影数学!

查看示例全景照片

使用背景图像

我们将制作一个画廊,让用户从多张图片中选择。当用户第一次启动应用程序时,如果用户看到一些更中性的东西会很好。这本书的可下载文件中包含了一个更合适的背景图像。它的名称是bg.png,包含一个常规的网格。将其复制到您的res/drawable/文件夹中。然后,将DEFAULT_BACKGROUND更改为R.drawable.bg

使用背景图像

重新运行应用程序,应该看起来像这样:

使用背景图像

查看常规照片

现在我们已经完成了这个,让我们准备我们的应用程序也能够查看常规的平面照片。我们将通过将它们渲染到一个平面上来实现这一点。所以首先我们需要定义一个Plane组件。

定义 Plane 组件并分配缓冲区

Plane组件理所当然地属于RenderBox库,但目前,我们将直接将其添加到应用程序中。

RenderBoxExt/components/文件夹中创建一个新的 Java 类文件,并将其命名为Plane。将其定义为extends RenderObject,如下所示:

public class Plane extends RenderObject {
}

RenderBox库中的其他几何图形一样,我们将用三角形定义平面。只需要两个相邻的三角形,总共六个索引。以下数据数组定义了我们默认平面的 3D 坐标、UV 纹理坐标、顶点颜色(中灰色)、法线向量和相应的索引。在类的顶部添加以下代码:

    public static final float[] COORDS = new float[] {
            -1.0f, 1.0f, 0.0f,
            1.0f, 1.0f, 0.0f,
            -1.0f, -1.0f, 0.0f,
            1.0f, -1.0f, 0.0f
    };
    public static final float[] TEX_COORDS = new float[] {
            0.0f, 1.0f,
            1.0f, 1.0f,
            0f, 0f,
            1.0f, 0f,
    };
    public static final float[] COLORS = new float[] {
            0.5f, 0.5f, 0.5f, 1.0f,
            0.5f, 0.5f, 0.5f, 1.0f,
            0.5f, 0.5f, 0.5f, 1.0f,
            0.5f, 0.5f, 0.5f, 1.0f
    };
    public static final float[] NORMALS = new float[] {
            0.0f, 0.0f, -1.0f,
            0.0f, 0.0f, -1.0f,
            0.0f, 0.0f, -1.0f,
            0.0f, 0.0f, -1.0f
    };
    public static final short[] INDICES = new short[] {
            0, 1, 2,
            1, 3, 2
    };

现在,我们可以定义Plane构造函数,调用allocateBuffers辅助方法为顶点、法线、纹理和索引分配缓冲区。让我们在类的顶部声明这些变量,并编写方法:

    public static FloatBuffer vertexBuffer;
    public static FloatBuffer colorBuffer;
    public static FloatBuffer normalBuffer;
    public static FloatBuffer texCoordBuffer;
    public static ShortBuffer indexBuffer;
    public static final int numIndices = 6;

    public Plane(){
        super();
        allocateBuffers();
    }

    public static void allocateBuffers(){
        //Already allocated?
        if (vertexBuffer != null) return;
        vertexBuffer   = allocateFloatBuffer(COORDS);
        texCoordBuffer = allocateFloatBuffer(TEX_COORDS);
        colorBuffer    = allocateFloatBuffer(COLORS);
        normalBuffer   = allocateFloatBuffer(NORMALS);
        indexBuffer    = allocateShortBuffer(INDICES);
    }

同样,我们通过检查vertexBuffer是否为空来确保allocateBuffers只运行一次。(请注意,我们决定将缓冲区声明为public,以便将来可以为对象创建任意纹理材料。)

向 Plane 组件添加材质

接下来,我们可以向Plane添加一个适当的材质,使用纹理图像。使用与内置Sphere组件在第六章太阳系中一致的构造函数 API 模式,我们将添加调用一个新的Plane的能力,带有图像纹理 ID 和可选的光照布尔标志。然后,我们将添加帮助方法来分配相应的Material对象并设置它们的缓冲区:

    public Plane(int textureId, boolean lighting) {
        super();
        allocateBuffers();
        if (lighting) {
            createDiffuseMaterial(textureId);
        } else {
            createUnlitTexMaterial(textureId);
        }
    }

    public Plane createDiffuseMaterial(int textureId) {
        DiffuseLightingMaterial mat = new DiffuseLightingMaterial(textureId);
        mat.setBuffers(vertexBuffer, normalBuffer, texCoordBuffer, indexBuffer, numIndices);
        material = mat;
        return this;
    }

    public Plane createUnlitTexMaterial(int textureId) {
        UnlitTexMaterial mat = new UnlitTexMaterial(textureId);
        mat.setBuffers(vertexBuffer, texCoordBuffer, indexBuffer, numIndices);
        material = mat;
        return this;
    }

向场景添加图像屏幕

现在我们可以在MainActivity中向场景添加图像了。很快我们将在手机的照片文件夹中查看照片,但在这一点上,你可以使用我们之前使用过的相同(全景)照片(或者将另一张放在你的res/drawable文件夹中)。请注意,如果图像对于手机的 GPU 来说太大,可能会出现显示问题。我们稍后会解决这个问题,所以尽量保持在任何维度上小于 4096 像素。

将对象命名为screen,因为稍后我们将用它来投影用户从画廊中选择的照片。

MainActivity.java中,更新setup函数以添加图像到场景中,如下所示:

    Plane screen;

    public void setup() {
        setupBackground();
        setupScreen();
    }

    void setupScreen() {
        screen = new Plane(R.drawable.sample360, false);
        new Transform()
                .setLocalScale(4, 4, 1)
                .setLocalPosition(0, 0, -5)
                .setLocalRotation(0, 0, 180)
                .addComponent(screen);
    }

屏幕按 4 个单位(在 X 和 Y 方向)缩放,并放置在相机前方 5 个单位。这就像坐在离 8 米宽的电影屏幕 5 米(15 英尺)远的地方!

另外,请注意我们在z轴上将平面旋转了 180 度;否则,图像将会颠倒。我们的世界坐标系的上方向沿着正y轴。然而,UV 空间(用于渲染纹理)通常将原点放在左上角,并且正向是向下的。(如果你记得,在上一章中,这就是为什么我们还必须翻转地球的原因)。在本章后面,当我们实现一个Image类时,我们将从图像文件中读取实际的方向并相应地设置旋转。这是我们的带有图像的屏幕平面(从一个角度看):

向场景添加图像屏幕

将屏幕平面(及其图像纹理)与屏幕的放置和大小分开将是方便的。我们稍后会看到这为什么很重要,但这与根据图像参数进行缩放和旋转有关。让我们重构代码,使屏幕由screenRoot变换作为父级,如下所示:

    void setupScreen() {
        Transform screenRoot = new Transform()
                .setLocalScale(4, 4, 1)
                .setLocalRotation(0, 0, 180)
                .setLocalPosition(0, 0, -5);

        screen = new Plane(R.drawable.sample360, false);

        new Transform()
                .setParent(screenRoot, false)
                .addComponent(screen);
    }

在图像上放置边框

图像在框架中看起来最好。让我们现在添加一个。有许多方法可以实现这一点,但我们将使用着色器。这个框架也将用于缩略图图像,并且将使我们能够更改颜色以突出显示用户选择图像时的区域。此外,它有助于定义对比区域,确保您可以在任何背景上看到任何图像的边缘。

边框着色器

我们可以先编写着色器程序,其中包括从使用它的Material对象中定义它们需要的变量。

如果需要,为着色器创建一个资源目录res/raw/。然后,创建border_vertex.shaderborder_fragment.shader文件。定义如下。

border_vertex着色器与我们之前使用的unlit_tex_vertex着色器是相同的。

文件:res/raw/border_vertex.shader

uniform mat4 u_MVP;

attribute vec4 a_Position;
attribute vec2 a_TexCoordinate;

varying vec3 v_Position;
varying vec2 v_TexCoordinate;

void main() {
   // pass through the texture coordinate
   v_TexCoordinate = a_TexCoordinate;

   // final point in normalized screen coordinates
   gl_Position = u_MVP * a_Position;
}

对于border_fragement着色器,我们添加了边框颜色(u_Color)和宽度(u_Width)的变量。然后,添加一些逻辑来决定当前渲染的坐标是在边框上还是在纹理图像中:

文件:res/raw/border_fragment.shader

precision mediump float;
uniform sampler2D u_Texture; 

varying vec3 v_Position; 
varying vec2 v_TexCoordinate;
uniform vec4 u_Color;
uniform float u_Width;

void main() {
    // send the color from the texture straight out unless in // border area
    if(
        v_TexCoordinate.x > u_Width
        && v_TexCoordinate.x < 1.0 - u_Width
        && v_TexCoordinate.y > u_Width
        && v_TexCoordinate.y < 1.0 - u_Width
    ){
        gl_FragColor = texture2D(u_Texture, v_TexCoordinate);
    } else {
        gl_FragColor = u_Color;
    }
}

请注意,这种技术会切掉图像的边缘。我们发现这是可以接受的,但如果你真的想看到整个图像,你可以在texture2D采样调用中偏移 UV 坐标。它会看起来像这样:

float scale = 1.0 / (1 - u_Width * 2);
Vec2 offset = vec(
    v_TexCoordinate.x * scale – u_Width,
    v_TexCoordinate.x * scale – u_Width);
gl_FragColor = texture2D(u_Texture, offset);

最后,细心的读者可能会注意到,当平面被非均匀缩放(使其成为矩形)时,边框将被缩放,使得垂直边框可能比水平边框更厚或更薄。有许多方法可以解决这个问题,但这留给(过度努力的)读者作为练习。

边框材质

接下来,我们为边框着色器定义材质。在RenderBoxExt/materials/中创建一个名为BorderMaterial的新 Java 类,并定义如下:

public class BorderMaterial extends Material {
    private static final String TAG = "bordermaterial";

}

为纹理 ID、边框宽度和颜色添加材质变量。然后,添加着色器程序引用和缓冲区的变量,如下面的代码所示:

    int textureId;
    public float borderWidth = 0.1f;
    public float[] borderColor = new float[]{0, 0, 0, 1}; // black
    static int program = -1; //Initialize to a totally invalid value for setup state
    static int positionParam;
    static int texCoordParam;
    static int textureParam;
    static int MVPParam;
    static int colorParam;
    static int widthParam;

    FloatBuffer vertexBuffer;
    FloatBuffer texCoordBuffer;
    ShortBuffer indexBuffer;
    int numIndices;

现在我们可以添加一个构造函数。正如我们之前所见,它调用一个setupProgram辅助方法,该方法创建着色器程序并获取对其参数的引用:

    public BorderMaterial() {
        super();
        setupProgram();
    }

    public static void setupProgram() {
        //Already setup?
        if (program > -1) return;
        //Create shader program
        program = createProgram(R.raw.border_vertex, R.raw.border_fragment);

        //Get vertex attribute parameters
        positionParam = GLES20.glGetAttribLocation(program, "a_Position");
        texCoordParam = GLES20.glGetAttribLocation(program, "a_TexCoordinate");

        //Enable them (turns out this is kind of a big deal ;)
        GLES20.glEnableVertexAttribArray(positionParam);
        GLES20.glEnableVertexAttribArray(texCoordParam);

        //Shader-specific parameters
        textureParam = GLES20.glGetUniformLocation(program, "u_Texture");
        MVPParam = GLES20.glGetUniformLocation(program, "u_MVP");
        colorParam = GLES20.glGetUniformLocation(program, "u_Color");
        widthParam = GLES20.glGetUniformLocation(program, "u_Width");
        RenderBox.checkGLError("Border params");
    }

同样,我们添加一个setBuffers方法,该方法将由RenderObject组件(Plane)调用:

public void setBuffers(FloatBuffer vertexBuffer, FloatBuffer texCoordBuffer, ShortBuffer indexBuffer, int numIndices){
        //Associate VBO data with this instance of the material
        this.vertexBuffer = vertexBuffer;
        this.texCoordBuffer = texCoordBuffer;
        this.indexBuffer = indexBuffer;
        this.numIndices = numIndices;
    }

为纹理 ID 提供一个 setter 方法:

    public void setTexture(int textureHandle) {
        textureId = textureHandle;
    }

添加绘制代码,该代码将从Camera组件调用,以渲染通过setBuffer准备的几何图形。绘制方法如下:

    @Override
    public void draw(float[] view, float[] perspective) {
        GLES20.glUseProgram(program);

        // Set the active texture unit to texture unit 0.
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);

        // Bind the texture to this unit.
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);

        // Tell the texture uniform sampler to use this texture in // the shader by binding to texture unit 0.
        GLES20.glUniform1i(textureParam, 0);

        Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.model, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, modelView, 0);
        // Set the ModelViewProjection matrix for eye position.
        GLES20.glUniformMatrix4fv(MVPParam, 1, false, modelViewProjection, 0);

        GLES20.glUniform4fv(colorParam, 1, borderColor, 0);
        GLES20.glUniform1f(widthParam, borderWidth);

        //Set vertex attributes
        GLES20.glVertexAttribPointer(positionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);
        GLES20.glVertexAttribPointer(texCoordParam, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer);

        GLES20.glDrawElements(GLES20.GL_TRIANGLES, numIndices, GLES20.GL_UNSIGNED_SHORT, indexBuffer);

        RenderBox.checkGLError("Border material draw");
    }

还有一件事;让我们提供一个销毁现有材质的方法:

    public static void destroy(){
        program = -1;
    }

使用边框材质

为了使用BorderMaterial类而不是先前在Plane类中编写的默认UnlitTexMaterial类,我们可以将其添加到PlaneJava 类中,如下所示。我们计划在Plane类之外(在MainActivity中)创建材质,因此我们只需要设置它。在Plane.java中,添加以下代码:

    public void setupBorderMaterial(BorderMaterial material){
        this.material = material;
        material.setBuffers(vertexBuffer, texCoordBuffer,         indexBuffer, numIndices);
    }

MainActivity中,修改setupScreen方法以使用此材质而不是默认材质,如下所示。我们首先创建材质并将纹理设置为我们的示例图像。我们不需要设置颜色,默认为黑色。然后我们创建屏幕平面并设置其材质。然后创建变换并添加屏幕组件:

    void setupScreen() {
        //...
        Screen = new Plane();
        BorderMaterial screenMaterial = new BorderMaterial();
        screenMaterial.setTexture(RenderBox.loadTexture( R.drawable.sample360));
        screen.setupBorderMaterial(screenMaterial);
        //...

}

现在运行它,它应该看起来像这样:

使用边框材质

加载和显示照片图像

到目前为止,我们已经在项目的drawable资源文件夹中使用图像。下一步是从手机中读取照片图像并在虚拟屏幕上显示其中一个。

定义图像类

让我们制作一个占位符Image类。稍后,我们将构建属性和方法。定义如下:

public class Image {
    final static String TAG = "image";
    String path;
    public Image(String path) {
        this.path = path;
    }
    public static boolean isValidImage(String path){
        String extension = getExtension(path);
        if(extension == null)
            return false;
        switch (extension){
            case "jpg":
                return true;
            case "jpeg":
                return true;
            case "png":
                return true;
        }
        return false;
    }
    static String getExtension(String path){
        String[] split = path.split("\\.");
        if(split== null || split.length < 2)
            return null;
        return split[split.length - 1].toLowerCase();
    }
}

我们定义一个构造函数,该构造函数接受图像的完整路径。我们还提供一个验证方法,该方法检查路径是否实际上是图像,基于文件名扩展名。我们不想在构造时加载和绑定图像数据,因为我们不想一次加载所有图像;正如您将看到的,我们将使用工作线程来智能地管理这些。

将图像读入应用程序

现在在MainActivity中,访问手机上的照片文件夹,并在我们的应用程序中构建图像列表。以下getImageList辅助方法查找给定文件夹路径,并为找到的每个文件实例化一个新的Image对象:

    final List<Image> images = new ArrayList<>();

    int loadImageList(String path) {
        File f = new File(path);
        File[] file = f.listFiles();
        if (file==null)
            return 0;
        for (int i = 0; i < file.length; i++) {
            if (Image.isValidImage(file[i].getName())) {
                Image img = new Image(path + "/" + file[i].getName());
                images.add(img);
            }
        }
        return file.length;
    }

setup方法中使用此方法,传入相机图像文件夹路径的名称,如下所示(您的路径可能有所不同):

    final String imagesPath = "/storage/emulated/0/DCIM/Camera";

    public void setup() {
        …

        loadImageList(imagesPath);
    }

还要确保在您的AndroidManifest.xml文件中包含以下行,以赋予应用程序读取设备外部存储的权限。从技术上讲,使用 Cardboard SDK 时,您应该已经拥有此权限:

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

您可以向getImageList循环添加日志消息并运行它以验证它是否正在找到文件。如果没有,您可能需要发现您的照片文件夹的实际路径。

这是我们需要非常小心权限的第一个项目。直到这一点,Cardboard SDK 本身是唯一需要访问文件系统的东西,但现在我们需要它来使应用程序本身正常运行。如果您使用的是 Andriod 6.0 的设备,并且您没有确保将应用程序编译为 SDK 22,您将无法加载图像文件,应用程序要么无所作为,要么崩溃。

如果您正在针对 SDK 22 进行编译,并且在清单中正确设置了权限,但仍然获得空文件列表,请尝试使用文件浏览器在设备上查找正确的路径。很可能是我们提供的路径不存在或为空。当然,确保您确实使用该设备拍摄了照片!

图像加载纹理

如果您还记得,在第六章太阳系中,我们编写了一个loadTexture方法,该方法从项目的res/drawable文件夹中读取静态图像到内存位图,并将其绑定到 OpenGL 中的纹理。在这里,我们将做类似的事情,但是从手机的相机路径中获取图像,并提供额外处理的方法,例如调整大小和旋转方向。

Image类的顶部,添加一个变量来保存当前的纹理句柄:

    int textureHandle;

图像的loadTexture方法,给定图像文件的路径,将图像文件加载到位图中,然后将其转换为纹理。(此方法将从MainActivity中的应用程序的CardboardView类中调用。)编写如下:

    public void loadTexture(CardboardView cardboardView) {
        if (textureHandle != 0)
            return;
        final Bitmap bitmap = BitmapFactory.decodeFile(path);
        if (bitmap == null){
            throw new RuntimeException("Error loading bitmap.");
        }
        textureHandle = bitmapToTexture(bitmap);
    }

我们添加了一个小的(但重要的)优化,检查纹理是否已经加载;如果不需要,就不要再次加载。

我们的bitmapToTexture的实现如下所示。给定一个位图,它将位图绑定到 OpenGL ES 纹理(带有一些错误检查)。将以下代码添加到Image中:

    public static int bitmapToTexture(Bitmap bitmap){
        final int[] textureHandle = new int[1];

        GLES20.glGenTextures(1, textureHandle, 0);
        RenderBox.checkGLError("Bitmap GenTexture");

        if (textureHandle[0] != 0) {
            // Bind to the texture in OpenGL
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle[0]);

            // Set filtering
            GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
            GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST);

            // Load the bitmap into the bound texture.
            GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
        }
        if (textureHandle[0] == 0){
            throw new RuntimeException("Error loading texture.");
        }

        return textureHandle[0];
    }

在屏幕上显示图像

让我们在应用程序中显示我们的相机图像之一,比如第一个。

在虚拟屏幕上显示图像,我们可以编写一个show方法,该方法接受当前的CardboardView对象和Plane屏幕。它将加载和绑定图像纹理,并将其句柄传递给材质。在Image类中,实现show方法如下:

    public void show(CardboardView cardboardView, Plane screen) {
        loadTexture(cardboardView);
        BorderMaterial material = (BorderMaterial) screen.getMaterial();
        material.setTexture(textureHandle);
    }

现在让我们使用这些东西!转到MainActivity并编写一个单独的showImage方法来加载图像纹理。然后,暂时从setup中调用它,使用我们找到的第一个图像(您的相机文件夹中至少需要一个图像):

    public void setup() {
        setupBackground();
        setupScreen();
        loadImageList(imagesPath);
        showImage(images.get(0));
    }

    void showImage(Image image) {
        image.show(cardboardView, screen);
    }

现在修改setupScreen也是有意义的,因此它创建屏幕但不在其上加载图像纹理。删除其中的screenMaterial.setTexture调用。

现在运行应用程序,您将在屏幕上看到自己的图像。这是我的:

在屏幕上显示图像

旋转到正确的方向

一些图像文件类型会跟踪它们的图像方向,特别是 JPG 文件(.jpg.jpeg)。我们可以从文件中包含的 EXIF 元数据中获取方向值,该元数据由相机应用程序编写。(例如,请参阅sylvana.net/jpegcrop/exif_orientation.html。请注意,某些设备可能不符合规定或包含不同的结果。)

如果图像不是 JPG,我们将跳过此步骤。

Image类的顶部,声明一个变量来保存当前图像的旋转:

    Quaternion rotation;

rotation值存储为Quaternion实例,如我们的 RenderBox 数学库中定义的那样。如果您还记得第五章RenderBox 引擎,四元数表示三维空间中的旋转方向,比欧拉角更精确和更不含糊。但是欧拉角更符合人类的习惯,指定每个xyz轴的角度。因此,我们将根据图像的方向使用欧拉角设置四元数。最终,我们在这里使用Quaternion,因为它是Transform.rotation的基础类型:

    void calcRotation(Plane screen){
        rotation = new Quaternion();

        // use Exif tags to determine orientation, only available // in jpg (and jpeg)
        String ext = getExtension(path);
        if (ext.equals("jpg") || ext.equals("jpeg")) {

            try {
                ExifInterface exif = new ExifInterface(path);
                switch (exif.getAttribute(ExifInterface.TAG_ORIENTATION)) {
                    // Correct orientation, but flipped on the // horizontal axis
                    case "2":
                        rotation = new Quaternion().setEulerAngles(180, 0, 0);
                        break;
                    // Upside-down
                    case "3":
                        rotation = new Quaternion().setEulerAngles(0, 0, 180);
                        break;
                    // Upside-Down & Flipped along horizontal axis
                    case "4":
                        rotation = new Quaternion().setEulerAngles(180, 0, 180);
                        break;
                    // Turned 90 deg to the left and flipped
                    case "5":
                        rotation = new Quaternion().setEulerAngles(0, 180, 90);
                        break;
                    // Turned 90 deg to the left
                    case "6":
                        rotation = new Quaternion().setEulerAngles(0, 0, -90);
                        break;
                    // Turned 90 deg to the right and flipped
                    case "7":
                        rotation = new Quaternion().setEulerAngles(0, 180, 90);
                        break;
                    // Turned 90 deg to the right
                    case "8":
                        rotation = new Quaternion().setEulerAngles(0, 0, 90);
                        break;
                    //Correct orientation--do nothing
                    default:
                        break;
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        screen.transform.setLocalRotation(rotation);
    }

现在在Image类的show方法中设置屏幕的旋转,如下所示:

    public void show(CardboardView cardboardView, Plane screen) {
        loadTexture(cardboardView);
        BorderMaterial material = (BorderMaterial) screen.getMaterial();
        material.setTexture(textureHandle);
        calcRotation(screen);
    }

再次运行您的项目。图像应该被正确定向。请注意,您的原始图像可能一直都很好。一旦我们开始使用缩略图网格,检查旋转代码是否有效将变得更容易。

旋转到正确的方向

纠正宽度和高度的尺寸

方形图像很容易。但通常,照片是矩形的。我们可以获取图像的实际宽度和高度,并相应地缩放屏幕,以便显示不会出现扭曲。

Image类的顶部,声明变量来保存当前图像的宽度和高度:

    int height, width;

然后,在decodeFile方法中使用位图选项设置它们,如下所示:

    public void loadTexture(CardboardView cardboardView) {
        if (textureHandle != 0)
            return;
        BitmapFactory.Options options = new BitmapFactory.Options();
        final Bitmap bitmap = BitmapFactory.decodeFile(path, options);
        if (bitmap == null){
            throw new RuntimeException("Error loading bitmap.");
        }
        width = options.outWidth;
        height = options.outHeight;
        textureHandle = bitmapToTexture(bitmap);
    }

decodeFile调用将返回选项中的图像宽度和高度(以及其他信息)(参考developer.android.com/reference/android/graphics/BitmapFactory.Options.html)。

现在我们可以在Image类的show方法中设置屏幕大小。我们将规范化比例,使得较长的一侧为 1.0,较短的一侧根据图像纵横比计算:

    public void show(CardboardView cardboardView, Plane screen) {
        loadTexture(cardboardView);
        BorderMaterial material = (BorderMaterial) screen.getMaterial();
        material.setTexture(textureHandle);
        calcRotation(screen);
        calcScale(screen);
    }

    void calcScale(Plane screen) {
        if (width > 0 && width > height) {
            screen.transform.setLocalScale(1, (float) height / width, 1);
        } else if(height > 0) {
            screen.transform.setLocalScale((float) width / height, 1, 1);
        }
    }

如果您现在运行它,屏幕将具有图像的正确纵横比:

纠正宽度和高度的尺寸

将图像缩小到指定大小

您手机上的相机可能非常棒!它可能真的非常棒!当打印或进行大量裁剪时,百万像素图像非常重要。但是在我们的应用程序中查看时,我们不需要全分辨率图像。实际上,如果图像大小生成了对设备硬件来说太大的纹理,您可能已经在运行此项目时遇到了问题。

当加载纹理时,我们可以通过限制最大尺寸并将位图缩放到这些约束范围内来解决此问题。我们将要求 OpenGL ES 告诉我们当前的最大纹理尺寸。我们将在MainActivity中执行此操作,因此它通常是可用的(和/或将其移动到RenderBox库项目中的RenderBox类中)。将以下内容添加到MainActivity

    static int MAX_TEXTURE_SIZE = 2048;

    void setupMaxTextureSize() {
        //get max texture size
        int[] maxTextureSize = new int[1];
        GLES20.glGetIntegerv(GLES20.GL_MAX_TEXTURE_SIZE, maxTextureSize, 0);
        MAX_TEXTURE_SIZE = maxTextureSize[0];
        Log.i(TAG, "Max texture size = " + MAX_TEXTURE_SIZE);
    }

我们将其称为MainActivity类的setup方法的第一行。

至于缩放图像,不幸的是,Android 的BitmapFactory不允许您直接请求采样图像的新大小。相反,对于任意图像,您可以指定采样率,例如每隔一个像素(2),每隔四个像素(4)等。它必须是 2 的幂。

回到Image类。首先,我们将在loadTexture中添加一个sampleSize参数,该参数可以用作decodeFile的参数,如下所示:

    public void loadTexture(CardboardView cardboardView, int sampleSize) {
        if (textureHandle != 0)
            return;
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inSampleSize = sampleSize;
        final Bitmap bitmap = BitmapFactory.decodeFile(path, options);
        if(bitmap == null){
            throw new RuntimeException("Error loading bitmap.");
        }
        width = options.outWidth;
        height = options.outHeight;
        textureHandle = bitmapToTexture(bitmap);
    }

要确定图像的适当采样大小,我们首先需要找出其完整尺寸,然后找出哪个采样大小最接近但小于我们将使用的最大纹理尺寸。数学并不太困难,但我们将使用程序方法来搜索最佳大小值,而不是进行这样的搜索。

幸运的是,decodeFile的一个输入选项是仅检索图像边界,而不是实际加载图像。编写一个名为loadFullTexture的新加载纹理方法,如下所示:

    public void loadFullTexture(CardboardView cardboardView) {
        // search for best size
        int sampleSize = 1;
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        do {
            options.inSampleSize = sampleSize;
            BitmapFactory.decodeFile(path, options);
            sampleSize *= 2;
        } while (options.outWidth > MainActivity.MAX_TEXTURE_SIZE || options.outHeight > MainActivity.MAX_TEXTURE_SIZE);
        sampleSize /= 2;
        loadTexture(cardboardView, sampleSize);
    }

我们不断增加采样大小,直到找到一个在MAX_TEXTURE_SIZE范围内生成位图的大小,并然后调用loadTexture

show方法中使用loadFullTexture而不是其他的loadTexture

    public void show(CardboardView cardboardView, Plane screen) {
        loadFullTexture(cardboardView);
        BorderMaterial material = (BorderMaterial) screen.getMaterial();
        ...

运行项目。它应该看起来与之前的一样。但是如果您的相机太好,也许它不会像以前那样崩溃。

这种采样还将有助于在用户界面中显示图像的缩略图版本。对于缩略图视图,加载全尺寸的位图没有意义。

加载和显示全景图像

到目前为止,我们一直以相同的方式处理所有图像。但其中一些可能是 360 度图像。这些应该显示在全景照片上,而不是虚拟屏幕上。

如果您的设备相机文件夹中还没有 360 度照片,可以使用 Google 相机应用程序创建它们。

注意

如果您手机上的默认相机应用程序不包括全景模式,则可能需要从 Play 商店下载 Google 相机应用程序。第三方相机可能使用不同的名称。例如,三星将其全景功能称为环绕拍摄

一些图像包括 XMP 元数据,其中将包括有关图像是否为等距投影而畸变的信息。这对于区分球形图像和平面图像可能很有用。但是,Android API 不包括 XMP 接口,因此集成 XMP 头解析超出了本书的范围。

现在,我们只需检查文件名是否以PANO_为前缀。将以下变量添加到Image类中,并在构造方法中设置它:

    public boolean isPhotosphere;

    public Image(String path) {
        this.path = path;
        isPhotosphere = path.toLowerCase().contains("pano");
    }

现在我们可以构建MainActivity显示方法来处理普通照片(显示在虚拟屏幕上)与照片球(显示在背景球上)。此外,它应该处理在虚拟屏幕上显示的平面图像与渲染照片球之间的切换,反之亦然。

我们希望记住背景照片球纹理的纹理句柄 ID。在MainActivity类的顶部添加一个bgTextureHandle句柄:

    int bgTextureHandle;

然后,在setupBackground中调用getTexture设置它:

    void setupBackground() {
        photosphere = new Sphere(DEFAULT_BACKGROUND, false);
        new Transform()
                .setLocalScale(Camera.Z_FAR * 0.99f,-Camera.Z_FAR * 0.99f, Camera.Z_FAR * 0.99f)
                .addComponent(photosphere);
        UnlitTexMaterial mat = (UnlitTexMaterial) photosphere.getMaterial();
        bgTextureHandle = mat.getTexture();
    }

现在我们可以更新showImage方法,如下所示:

    void showImage(Image image) {
        UnlitTexMaterial bgMaterial = (UnlitTexMaterial) photosphere.getMaterial();
        image.loadFullTexture(cardboardView);
        if (image.isPhotosphere) {
            bgMaterial.setTexture(image.textureHandle);
            screen.enabled = false;
        } else {
            bgMaterial.setTexture(bgTextureHandle);
            screen.enabled = true;
            image.show(cardboardView, screen);
        }
    }

当图像是照片球时,我们将将背景照片球纹理设置为图像,并隐藏屏幕平面。当图像是普通照片时,我们将将背景纹理恢复为默认值,并在虚拟屏幕上显示图像。

在我们实现用户界面(下一步)进行测试之前,您需要知道图像列表中哪个图像是照片球。如果现在制作一个新的照片球,它将成为列表中的最后一个,并且您可以更改setup方法以调用showImage。例如,运行以下代码:

         showImage(images.get(images.size()-1));

再次运行项目并感到高兴!

图像库用户界面

在我们继续为该项目实现用户界面之前,让我们谈谈我们希望它如何工作。

该项目的目的是允许用户从其手机存储中选择照片并在 VR 中查看它。手机的照片集将以可滚动的缩略图图像网格的形式呈现。如果照片是普通的 2D 照片,它将显示在我们刚刚制作的虚拟屏幕平面上。如果是照片球,我们将将其视为完全沉浸式的 360 度球形投影。

我们提议的场景布局草图如下图所示。用户摄像头位于原点,照片球由灰色圆圈表示,围绕用户周围。在用户面前(由启动时的校准确定),将有一个来自手机相册的 5 x 3 网格的缩略图图像。这将是一个可滚动的列表。在用户的左侧,有图像投影屏幕。

图像库用户界面

具体来说,UI 将实现以下功能:

  • 在 5 x 3 网格中显示最多 15 个缩略图图像。

  • 允许用户通过观看缩略图图像并单击 Cardboard 触发器来选择其中之一。当处于视线中时,缩略图将被突出显示。

  • 选择普通照片将在场景中的虚拟投影屏幕上显示它(并将照片球清除为背景图像)。

  • 选择照片球将隐藏虚拟投影屏幕并将图像加载到照片球投影中。

  • 允许用户通过选择上/下箭头滚动缩略图图像。

我们的一些 UI 考虑是虚拟现实独特的。最重要的是,所有用户界面元素和控件都在世界坐标空间中,也就是说,它们被集成到场景中作为具有位置、旋转和比例的几何对象,就像任何其他组件一样。这与大多数移动游戏中 UI 作为屏幕空间叠加的方式实现的情况形成对比。

为什么?因为在 VR 中,为了创建立体效果,每只眼睛都有一个单独的视点,通过瞳孔间距进行偏移。这可以通过在屏幕空间中水平偏移屏幕空间对象的位置来模拟,使它们看起来具有视差(这是我们在第四章中使用的技术,启动器大厅)。但是当与 3D 几何、摄像机、照明和渲染混合时,该技术证明是不够的。需要一个世界空间 UI 来实现有效的用户体验和沉浸感。

另一个在 VR 中独有的功能是凝视选择。在这种情况下,您的凝视会突出显示一个图像缩略图,然后您点击 Cardboard 触发器打开图像。

最后,正如前面提到的,由于我们是在世界空间中工作,并且根据我们的凝视进行选择,我们的 3D 空间的布局是一个重要考虑因素。请记住,我们处于 VR 环境中,不受手机屏幕矩形边缘的限制。场景中的对象可以放置在您周围的任何位置。另一方面,您不希望用户一直扭来扭去(除非这是体验的一个意图部分)。我们将注意舒适区域来放置我们的 UI 控件和图像屏幕。

此外,谷歌和其他地方的研究人员已经开始制定用户界面设计的最佳实践,包括菜单和 UI 控件与摄像头的最佳距离,大约为 5 到 15 英尺(1.5 到 5 米)。这个距离足够近,可以享受 3D 视差效果,但不会让你看起来眯着眼睛专注于物体。

好的,让我们开始 UI 实现。

将照片屏幕定位在左侧

首先,让我们将屏幕从前方移动到侧面,即将其向左旋转 90 度。我们的变换数学在旋转后进行位置,所以现在我们沿x轴偏移它。修改MainActivity类的setupScreen方法,如下所示:

    void setupScreen() {
        Transform screenRoot = new Transform()
                .setLocalScale(4, 4, 1)
                .setLocalRotation(0, -90, 0)
                .setLocalPosition(-5, 0, 0);
                ...

在网格中显示缩略图

缩略图是完整图像的迷你版本。因此,我们不需要加载全尺寸的纹理位图。为了简单起见,让我们总是将其缩小 4 倍(原始尺寸的 1/16)。

缩略图图像

Image类中,show方法加载完整的纹理。让我们编写一个类似的showThumbnail方法,使用较小的采样。在Image类中,添加以下代码:

    public void showThumbnail(CardboardView cardboardView, Plane thumb) {
        loadTexture(cardboardView, 4);
        BorderMaterial material = (BorderMaterial) thumb.getMaterial();
        material.setTexture(textureHandle);
        calcRotation(thumb);
        calcScale(thumb);
    }

缩略图类

为项目创建一个新的Thumbnail类,其中包含一个小的Plane对象和一个Image对象来显示。它还获取当前的cardboardView实例,Image将需要它:

public class Thumbnail {
    final static String TAG = "Thumbnail";

    public Plane plane;
    public Image image;
    CardboardView cardboardView;

    public Thumbnail(CardboardView cardboardView) {
        this.cardboardView = cardboardView;
    }
}

定义一个setImage方法,加载图像纹理并显示为缩略图:

    public void setImage(Image image) {
        this.image = image;
        // Turn the image into a GPU texture
        image.loadTexture(cardboardView, 4);
        // TODO: wait until texture binding is done
        // show it
        image.showThumbnail(cardboardView, plane);
    }

最后,为缩略图可见性添加一个快速切换:

    public void setVisible(boolean visible) {
        plane.enabled = visible;
    }

缩略图网格

计划在MainActivity类的顶部显示 5x3 缩略图图像网格。声明一个thumbnails变量来保存缩略图列表:

    final int GRID_X = 5;
    final int GRID_Y = 3;

    final List<Thumbnail> thumbnails = new ArrayList<>();

在一个名为setupThumbnailGrid的新方法中构建列表。第一个缩略图位于页面的左上角(-4,3,-5),每个缩略图在x轴上间隔 2.1 个单位,在y轴上间隔 3 个单位,如下所示:

    void setupThumbnailGrid() {
        int count = 0;
        for (int i = 0; i < GRID_Y; i++) {
            for (int j = 0; j < GRID_X; j++) {
                if (count < images.size()) {
                    Thumbnail thumb = new 
                        Thumbnail(cardboardView);
                    thumbnails.add(thumb);

                    Transform image = new Transform();
                    image.setLocalPosition(-4 + j * 2.1f, 3 - i * 3, -5);
                    Plane imgPlane = new Plane();
                    thumb.plane = imgPlane;
                    imgPlane.enabled = false;
                    BorderMaterial material = new BorderMaterial();
                    imgPlane.setupBorderMaterial(material);
                    image.addComponent(imgPlane);
                }
                count++;
            }
        }
    }

现在我们需要将图像纹理添加到平面上。我们将编写另一个方法updateThumbnails,如下所示。它将在网格中显示前 15 张图像(如果没有那么多,就显示更少):

    void updateThumbnails() {
        int count = 0;
        for (Thumbnail thumb : thumbnails) {
            if (count < images.size()) {
                thumb.setImage(images.get(count));
                thumb.setVisible(true);
            } else {
                thumb.setVisible(false);
            }
            count++;
        }
    }

将这些新方法添加到setup中:

    public void setup() {
        setupMaxTextureSize();
        setupBackground();
        setupScreen();
        loadImageList(imagesPath);
        setupThumbnailGrid();
        updateThumbnails();
    }

当您运行项目时,它应该看起来像这样:

缩略图网格

请注意,缩略图的大小已调整以匹配图像的宽高比,并且已正确定向,因为我们之前在Image类中实现了这些功能。

提示

如果您的手机上已经没有超过 15 张照片,请在loadImageList中添加一个循环来加载重复的照片。例如,运行以下代码:

for(int j = 0; j < 3; j++) { //Repeat image list
    for (int i = 0; i < file.length; i++) {
        if (Image.isValidImage(file[i].getName())) {
            ...

凝视加载

我们希望检测用户何时看着缩略图,并通过更改其边框颜色来突出显示图像。如果用户将目光从缩略图移开,它将取消突出显示。当用户点击 Cardboard 触发器时,该图像将被加载。

凝视突出显示

幸运的是,我们在第五章的末尾在RenderBox库中实现了isLooking检测,RenderBox Engine。如果您还记得,该技术通过检查相机和平面位置之间的矢量是否与相机的视图方向相同来确定用户是否正在查看平面,容忍度为阈值。

我们可以在MainActivity中使用这个。我们将编写一个selectObject辅助方法,检查场景中是否有任何对象被选中并突出显示它们。首先,让我们在MainActivity类的顶部声明一些变量。selectedThumbnail对象保存当前选定的缩略图索引。我们为正常和选定状态定义边框颜色:

    final float[] selectedColor = new float[]{0, 0.5f, 0.5f, 1};
    final float[] invalidColor = new float[]{0.5f, 0, 0, 1};
    final float[] normalColor = new float[]{0, 0, 0, 1};
    Thumbnail selectedThumbnail = null;

现在,selectObject方法会遍历每个缩略图,检查它是否isLooking,并相应地突出显示(或取消突出显示)它:

    void selectObject() {
        selectedThumbnail = null;
        for (Thumbnail thumb : thumbnails) {
            if (thumb.image == null)
                return;
            Plane plane = thumb.plane;
            BorderMaterial material = (BorderMaterial) plane.getMaterial();
            if (plane.isLooking) {
                selectedThumbnail = thumb;
                material.borderColor = selectedColor;
            } else {
                material.borderColor = normalColor;
            }
        }
    }

RenderBox提供了一些挂钩,包括postDraw,我们将在其中检查选定的对象。我们希望使用postDraw,因为我们需要等到在所有RenderObjects上调用draw之后才知道用户正在查看哪一个。在MainActivity中,添加对selectObject方法的调用,如下所示:

    @Override
    public void postDraw() {
        selectObject();
    }

运行项目。当您盯着缩略图图像时,它应该会被突出显示!

选择和显示照片

现在,我们可以从缩略图网格中选择图像,我们需要一种点击它并显示该图像的方法。这将在MainActivity中使用 Cardboard SDK 挂钩onCardboardTrigger来实现。

到目前为止,我们所做的工作不需要太多就可以实现这一点:

    @Override
    public void onCardboardTrigger() {
        if (selectedThumbnail != null) {
            showImage(selectedThumbnail.image);
        }
    }

尝试运行它。现在突出显示一个图像并扳动扳机。如果你幸运的话,它会工作……我的崩溃了。

排队事件

发生了什么?我们遇到了线程安全问题。到目前为止,我们一直在渲染线程中执行所有代码,该线程由GLSurfaceView/CardboardView类通过 Cardboard SDK 启动。该线程拥有对 GPU 和我们正在渲染的特定表面的访问权限。对onCardboardTrigger的调用来自不是渲染线程的线程。这意味着我们不能从这里进行任何 OpenGL 调用。幸运的是,GLSurfaceView提供了一种通过名为queueEvent的方法在渲染线程上执行任意代码的巧妙方法。queueEvent方法接受一个Runnable参数,这是一个用于创建这类一次性过程的 Java 类(参见[developer.android.com/reference/android/opengl/GLSurfaceView.html#queueEvent(java.lang.Runnable](http://developer.android.com/reference/android/opengl/GLSurfaceView.html#queueEvent(java.lang.Runnable)))。

修改showImage,将其包装在Runnable参数中,如下所示:

    void showImage(final Image image) {
        cardboardView.queueEvent(new Runnable() {
            @Override
            public void run() {

                UnlitTexMaterial bgMaterial = (UnlitTexMaterial) photosphere.getMaterial();
                image.loadFullTexture(cardboardView);
                if (image.isPhotosphere) {
                    Log.d(TAG, "!!! is photosphere");
                    bgMaterial.setTexture(image.textureHandle);
                    screen.enabled = false;
                } else {
                    bgMaterial.setTexture(bgTextureHandle);
                    screen.enabled = true;
                    image.show(cardboardView, screen);
                }

            }
        });
    }

请注意,传递给匿名类的任何数据,例如我们的图像,必须声明为final,才能从新过程中访问。

尝试再次运行项目。它应该可以工作。您可以盯着缩略图,点击触发器,该图像将被显示,无论是在虚拟屏幕上还是在背景光球中。

使用振动器

别担心,我们会保持干净。我们希望在用户选择图像时为用户提供一些触觉反馈,使用手机的振动器。而在 Android 中,这是直截了当的。

首先,确保您的AndroidManifest.xml文件包含以下代码行:

    <uses-permission android:name="android.permission.VIBRATE" />

MainActivity类的顶部,声明一个vibrator变量:

    private Vibrator vibrator;

然后,在onCreate中,添加以下代码进行初始化:

    vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);

然后,在onCardboardTrigger中使用它,如下所示:

       vibrator.vibrate(25);

再次运行。点击它,你会感觉到它。啊!但不要得意忘形,它不是那种振动器。

启用滚动

我们的缩略图网格有 15 张图像。如果您的手机有超过 15 张照片,您需要滚动列表。对于这个项目,我们将实现一个简单的机制来上下滚动列表,使用三角形滚动按钮。

创建三角形组件

与我们的RenderBox中的其他RenderObjects一样,Triangle组件定义了描述三角形的坐标、法线、索引和其他数据。我们创建一个分配缓冲区的构造方法。与Plane组件一样,我们希望使用BorderMaterial类,以便在选择时可以突出显示。与Plane组件一样,它将确定用户何时在查看它。话不多说,这是代码。

RenderBoxExt/components文件夹中创建一个新的 Java 类文件Triangle.java。我们首先声明它extends RenderObject,并声明以下变量:

public class Triangle extends RenderObject {

    /*
    Special triangle for border shader

    *   0/3 (0,1,0)/(0,1,0) (0,1)/(1,1)
              /|\
             / | \
            *--*--*
            1  2  4
     */

    private static final float YAW_LIMIT = 0.15f;
    private static final float PITCH_LIMIT = 0.15f;
    public static final float[] COORDS = new float[] {
            0f, 1.0f, 0.0f,
            -1.0f, -1.0f, 0.0f,
            0.0f, -1.0f, 0.0f,
            0f, 1.0f, 0.0f,
            1.0f, -1.0f, 0.0f,
    };
    public static final float[] TEX_COORDS = new float[] {
            0f, 1f,
            0f, 0f,
            0.5f, 0f,
            1f, 1f,
            1f, 0f
    };
    public static final float[] COLORS = new float[] {
            0.5f, 0.5f, 0.5f, 1.0f,
            0.5f, 0.5f, 0.5f, 1.0f,
            0.5f, 0.5f, 0.5f, 1.0f,
            0.5f, 0.5f, 0.5f, 1.0f,
            0.5f, 0.5f, 0.5f, 1.0f
    };
    public static final float[] NORMALS = new float[] {
            0.0f, 0.0f, -1.0f,
            0.0f, 0.0f, -1.0f,
            0.0f, 0.0f, -1.0f,
            0.0f, 0.0f, -1.0f,
            0.0f, 0.0f, -1.0f
    };
    public static final short[] INDICES = new short[] {
            1, 0, 2,
            2, 3, 4
    };

    private static FloatBuffer vertexBuffer;
    private static FloatBuffer colorBuffer;
    private static FloatBuffer normalBuffer;
    private static FloatBuffer texCoordBuffer;
    private static ShortBuffer indexBuffer;
    static final int numIndices = 6;

    static boolean setup;
}

如果不清楚为什么我们需要这个由 2 个三角形组成的三角形,那是因为 UV 的工作方式。您无法仅使用一个三角形获得完整的边框,至少不是我们编写边框着色器的方式。

添加一个构造函数,以及一个allocateBuffers辅助程序:

    public Triangle(){
        super();
        allocateBuffers();
    }

    public static void allocateBuffers(){
        //Already allocated?
        if (vertexBuffer != null) return;
        vertexBuffer = allocateFloatBuffer(COORDS);
        texCoordBuffer = allocateFloatBuffer(TEX_COORDS);
        colorBuffer = allocateFloatBuffer(COLORS);
        normalBuffer = allocateFloatBuffer(NORMALS);
        indexBuffer = allocateShortBuffer(INDICES);
    }

我们可以创建各种材料,但我们实际上只计划使用BorderMaterial,因此让我们像我们在Plane中那样支持它:

    public void setupBorderMaterial(BorderMaterial material){
        this.material = material;
        material.setBuffers(vertexBuffer, texCoordBuffer, indexBuffer, numIndices);
    }

向 UI 添加三角形

MainActivity中,我们可以添加updown三角形按钮来滚动缩略图。在MainActivity类的顶部,声明三角形及其材料的变量:

    Triangle up, down;
    BorderMaterial upMaterial, downMaterial;
    boolean upSelected, downSelected;

定义一个setupScrollButtons辅助程序如下:

    void setupScrollButtons() {
        up = new Triangle();
        upMaterial = new BorderMaterial();
        up.setupBorderMaterial(upMaterial);
        new Transform()
            .setLocalPosition(0,6,-5)
            .addComponent(up);

        down = new Triangle();
        downMaterial = new BorderMaterial();
        down.setupBorderMaterial(downMaterial);
        new Transform()
            .setLocalPosition(0,-6,-5)
            .setLocalRotation(0,0,180)
            .addComponent(down);
    }

然后,从setup方法中调用它:

    public void setup() {
        setupMaxTextureSize();
        setupBackground();
        setupScreen();
        loadImageList(imagesPath);
        setupThumbnailGrid();
        setupScrollButtons();
        updateThumbnails();
    }

运行项目时,您将看到箭头:

向 UI 添加三角形

与滚动按钮交互

现在,我们将检测用户何时在查看三角形,使用selectObject中的isLooking(从postDraw挂钩调用):

    void selectObject() {
        ...

        if (up.isLooking) {
            upSelected = true;
            upMaterial.borderColor = selectedColor;
        } else {
            upSelected = false;
            upMaterial.borderColor = normalColor;
        }

        if (down.isLooking) {
            downSelected = true;
            downMaterial.borderColor = selectedColor;
        } else {
            downSelected = false;
            downMaterial.borderColor = normalColor;
        }
    }

实现滚动方法

为了实现滚动缩略图图像,我们将保持网格平面不变,只滚动纹理。使用偏移变量来保存网格中第一个图像的索引:

    static int thumbOffset = 0;

现在,修改updateThumbnails方法,使用缩略图偏移作为图像纹理的起始索引来填充平面纹理:

    void updateThumbnails() {
        int count = thumbOffset;
        for (Thumbnail thumb : thumbnails) {
        . . .

当按下上下箭头时,我们可以在onCardboardTrigger中执行滚动,通过将thumbOffset变量一次移动一行(GRID_X):

    public void onCardboardTrigger() {
        if (selectedThumbnail != null) {
            vibrator.vibrate(25);
            showImage(selectedThumbnail.image);
        }
        if (upSelected) {
            // scroll up
            thumbOffset -= GRID_X;
            if (thumbOffset < 0) {
                thumbOffset = images.size() - GRID_X;
            }
            vibrator.vibrate(25);
            updateThumbnails();
        }
        if (downSelected) {
            // scroll down
            if (thumbOffset < images.size()) {
                thumbOffset += GRID_X;
            } else {
                thumbOffset = 0;
            }
            vibrator.vibrate(25);
            updateThumbnails();
        }
    }

showImage一样,updateThumbnails方法需要在渲染线程上运行:

    void updateThumbnails() {
        cardboardView.queueEvent(new Runnable() {
            @Override
            public void run() {
                ...

运行项目。现在,您可以单击上下箭头来滚动浏览照片。

保持响应并使用线程

我们的加载和滚动代码存在一些问题,都与加载图像和转换位图是计算密集型的事实有关。尝试一次为 15 张图像执行此操作会导致应用程序似乎冻结。您可能还注意到,自从我们添加了缩略图网格以来,应用程序启动时间显着延长。

在传统的应用程序中,应用程序在等待数据加载时锁定可能会很烦人,但在 VR 中,应用程序需要保持活动状态。应用程序需要继续响应头部运动,并为每个帧更新显示,以显示与当前视图方向相对应的视图。如果应用程序在加载文件时被锁定,它会感觉卡住,即卡在您的脸上!在完全沉浸式体验中,并且在桌面 HMD 上,视觉锁定是引起恶心或模拟疾病的最严重原因。

解决方案是一个工作线程。成功支持多线程的关键是提供过程之间使用信号量(布尔标志)相互通信的能力。我们将使用以下内容:

  • Image.loadLock:当等待 GPU 生成纹理时为真

  • MainActivity.cancelUpdate:当线程由于用户事件而停止时为真

  • MainActivity gridUpdateLock:当网格正在更新时为真;忽略其他用户事件

让我们声明这些。在Image类的顶部,添加以下代码:

        public static boolean loadLock = false;

MainActivity类的顶部,添加以下内容:

    public static boolean cancelUpdate = false;
    static boolean gridUpdateLock = false;

首先,让我们确定我们代码中计算密集的部分。随意进行自己的调查,但让我们假设BitmapFactory.decodeFile是罪魁祸首。理想情况下,任何与渲染无关的代码都应该在工作线程上完成,但要注意预优化。我们正在做这项工作是因为我们注意到了一个问题,所以我们应该能够确定导致问题的新代码。一个合理的猜测指向了将任意图像加载到纹理中的这个业务。

我们在哪里进行这个操作?嗯,对BitmapFactory.decodeFile的实际调用来自Image.loadTexture,但更一般地说,所有这些都是从MainActivity.updateGridTexturesMainActivity.showImage中启动的。现在让我们更新这最后两个函数。

幸运的是,showImage已经被包装在Runnable中,以便将其执行重定向到渲染线程。现在我们想确保它总是发生在渲染线程之外。我们将在不同的地方使用queueEvent来避免我们之前遇到的错误。我们用Thread替换了以前的Runnable代码。例如,showImage现在看起来是这样的:

    void showImage(final Image image) {
        new Thread() {
            @Override
            public void run() {
              UnlitTexMaterial bgMaterial = (UnlitTexMaterial) photosphere.getMaterial();
                ...
            }
        }.start();
    }

updateThumbnails做同样的操作。当我们在这里时,添加gridUpdateLock标志,它在运行时保持设置,并处理cancelUpdate标志,以便可以中断循环:

    void updateThumbnails() {
 gridUpdateLock = true;
 new Thread() {
 @Override
 public void run() {
                int count = thumbOffset;
                for (Thumbnail thumb : thumbnails) {
 if (cancelUpdate)
 return;
                    if (count < images.size()) {
                        thumb.setImage(images.get(count));
                        thumb.setVisible(true);
                    } else {
                        thumb.setVisible(false);
                    }
                    count++;
                }
 cancelUpdate = false;
 gridUpdateLock = false;
 }
 }.start();
    }

专注于Image类的loadTexture方法,我们需要使用queueEvent将 GPU 调用重定向回渲染线程。如果你现在尝试运行应用程序,它将立即崩溃。这是因为showImage现在总是在自己的线程中运行,当我们最终进行 OpenGL 调用生成纹理时,我们将得到无效操作错误,就像我们之前添加触发输入时那样。为了解决这个问题,修改loadTexture如下:

    public void loadTexture(CardboardView cardboardView, int sampleSize) {
        if (textureHandle != 0)
            return;
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inSampleSize = sampleSize;
        final Bitmap bitmap = BitmapFactory.decodeFile(path, options);
        if(bitmap == null){
            throw new RuntimeException("Error loading bitmap.");
        }
        width = options.outWidth;
        height = options.outHeight;

 loadLock = true;
 cardboardView.queueEvent(new Runnable() {
 @Override
 public void run() {
 if (MainActivity.cancelUpdate)
 return;
                             textureHandle = bitmapToTexture(bitmap);
                             bitmap.recycle();
 loadLock = false;
 }
 }
        });
 while (loadLock){
 try {
 Thread.sleep(100);
 } catch (InterruptedException e) {
 e.printStackTrace();
 }
 }
    }

我们改变了bitmapToTexture,现在它在 GPU 线程上调用。我们使用loadLock标志来指示加载正在忙碌。当它完成时,标志被重置。与此同时,loadTexture在返回之前等待它完成,因为我们需要这个textureHandle值以供以后使用。但由于我们总是从工作线程调用这个,应用程序不会因等待而挂起。这个改变也将改善启动时间。

同样,我们在Thumbnail类中也做同样的事情;它的setImage方法也加载图像纹理。修改它,使它看起来像这样:

    public void setImage(Image image) {
        this.image = image;
        // Turn the image into a GPU texture
        image.loadTexture(cardboardView, 4);
 // wait until texture binding is done
 try {
 while (Image.loadLock) {
 if (MainActivity.cancelUpdate)
 return;
 Thread.sleep(10);
 }
 } catch (InterruptedException e) {
 e.printStackTrace();
 }
        // show it
        . . .
    }

你可能已经注意到了所有这些中更微妙的问题。如果我们试图在这些工作线程操作的中间关闭应用程序,它将崩溃。潜在的问题是线程持续存在,但图形上下文已被销毁,即使你只是切换应用程序。尝试使用无效的图形上下文生成纹理会导致崩溃,并且用户几乎得不到通知。坏消息。我们想要做的是在应用程序关闭时停止工作线程。这就是cancelUpdate发挥作用的地方。在MainActivity中,我们将在onCreate方法中设置它的值,并在onStartonResumeonPause挂钩方法中添加方法,如下所示:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        cancelUpdate = false;
        //...
    }

    @Override
    protected void onStart(){
        super.onStart();
        cancelUpdate = true;
    }
    @Override
    protected void onResume(){
        super.onResume();
        cancelUpdate = false;
    }
    @Override
    protected void onPause(){
        super.onPause();
        cancelUpdate = true;
    }

如果你在网格更新时尝试点击某物,它不应该让你这样做。在onCardboardTrigger的顶部添加以下代码:

        if (gridUpdateLock) {
            vibrator.vibrate(new long[]{0,50,30,50}, -1);
            return;
        }

这个新的long[]{0,50,30,50}业务是将一个序列编程到振动器中的一种方式。在这种情况下,连续使用两个短(50 毫秒)的脉冲来指示 nuh-uh 反应。

我们甚至可以再进一步,就像这样在gridUpdateLock期间用禁用的颜色突出显示可选择的对象:

            if (plane.isLooking) {
                selectedThumbnail = thumb;
                if(gridUpdateLock)
                    material.borderColor = invalidColor;
                else
                    material.borderColor = selectedColor;

           ...

你的项目应该像以前一样运行。但现在它更具响应性,行为更好,并且不会因等待图像加载而卡住。

线程和虚拟现实的解释

OpenGL 不是线程安全的。这听起来像是一个设计缺陷。实际上,更像是一个设计要求。您希望您的图形 API 尽可能快地频繁绘制帧。正如您可能知道的,或者很快就会了解到,等待是线程经常做的事情。如果您引入多线程访问图形硬件,您就会引入硬件可能在 CPU 上等待的时期,仅仅是为了弄清楚它的线程调度和谁需要在那个时候访问。说“只有一个线程可以访问 GPU”更简单更快。从技术上讲,随着图形 API 变得更加先进(DirectX 12 和 Vulkan),这并不严格正确,但我们不会在本书中涉及多线程渲染。

让我们先退一步,问一个问题,“为什么我们需要使用线程?”对于一些经验丰富的应用程序开发人员来说,答案应该是显而易见的。但并非所有程序员都需要使用线程,更糟糕的是,许多程序员在不适当的时候或者根本不需要的时候使用线程。对于那些仍然不清楚的人来说,线程是一个“同时运行两个程序的方法”的花哨术语。在实际层面上,操作系统控制着调度线程一个接一个地运行,或者在不同的 CPU 核心上运行,但作为程序员,我们假设所有线程都在“同时”运行。

顺便说一句,虽然我们只允许一个 CPU 线程控制 GPU,但 GPU 的整个意义在于它是大规模多线程的。移动 GPU 仍在发展中,但高端的 Tegra 芯片拥有数百个核心(目前,X1 拥有 256 个核心),落后于拥有数千个核心的台式机等效芯片(Titan Black @ 2880 个核心)。GPU 被设置为在单独的线程上处理每个像素(或其他类似的小数据),并且有一些硬件魔术自动调度所有这些线程而零开销。把您的渲染线程想象成一个缓慢的工头,指挥着一支小型的 CPU 军队来执行您的命令并报告结果,或者在大多数情况下,直接将它们绘制到屏幕上。这意味着 CPU 已经在代表 GPU 做了相当多的等待,从而使您的其他工作线程可以在需要更多 CPU 渲染工作时进行任务并等待。

当您想要运行一个需要一段时间的进程,并且希望避免阻塞程序的执行或主线程时,线程通常是有用的。这种情况最常见的地方是启动一个后台进程并允许 UI 继续更新。如果您正在创建一个媒体编码器程序,您不希望在解码视频时程序在 30 分钟内无响应。相反,您希望程序正常运行,允许用户点击按钮并从后台工作中看到进度更新。在这种情况下,您必须让 UI 和后台线程偶尔休息一下,以便发送和检查两者之间传递的消息。调整休息时间或睡眠时间以及线程优先级值可以避免一个线程占用太多 CPU 时间。

回到 OpenGL 和图形编程。在游戏引擎中,将工作分成几个不同的线程(渲染、物理、音频、输入等)是很常见的。然而,渲染线程总是一种“指挥者”,因为渲染仍然倾向于是最时间敏感的工作,必须至少每秒发生 30 次。在虚拟现实中,这种约束更加重要。也许我们不担心物理和音频,但我们仍然需要确保我们的渲染器能够尽快地绘制东西,否则就会失去存在感。此外,只要人在看屏幕,我们就永远不能停止渲染。我们需要线程来避免渲染帧之间出现“抽搐”或不可接受的长时间间隔。

头部跟踪对于 VR 体验至关重要。一个人移动头部,只看着一个固定的图像,会开始感到恶心,或者模拟病。即使是黑色背景上的一些文本,如果没有通过某种固定的地平线进行补偿,最终也会引起不适。有时,我们确实需要阻塞渲染线程相当长的时间,最好的选择是首先将图像淡化为纯色或空白。这样可以在短时间内感到舒适。在 VR 中最糟糕的事情是由于渲染线程上的大量工作而导致周期性的抽搐或帧速率下降。如果您不能保持恒定、平稳的帧速率,您的 VR 体验就毫无价值。

在我们的情况下,我们需要解码一系列相当大的位图并将它们加载到 GPU 纹理中。不幸的是,解码步骤需要几百毫秒,并导致我们刚刚谈到的这些抽搐。然而,由于这不是 GPU 工作,它不必发生在渲染线程上!如果我们想要避免在我们的setup()preDraw()postDraw()函数中进行任何繁重的工作,我们应该在任何想要解码位图的时候创建一个线程。在更新我们的预览网格的情况下,我们可能只需要创建一个单独的线程,它可以运行整个更新过程,在每个位图之间等待。在 CPU 领域,操作系统需要使用一些资源来调度线程并分配它们的资源。与为每个位图启动和关闭线程相比,只创建一个线程来完成整个作业要高效得多。

当然,我们需要利用我们的老朋友queueEvent来进行任何图形工作,这样才能生成和加载纹理。事实证明,更新图像的显示并不是图形工作,因为它只涉及更改我们材质上的一个值。然而,我们确实需要等待图形工作,以获取这个新值。由于这些优化和约束,我们需要一个锁定系统,以允许一个线程等待其他线程完成其工作,并防止用户在完成之前中断或重新启动此过程。这就是我们在上一个主题中刚刚实现的。

使用意图启动

如果您可以在手机上查看图像时随时启动此应用程序,这不是很酷吗,尤其是 360 度全景照片?

Android 操作系统的一个更强大的功能是使用意图在应用程序之间进行通信。意图是任何应用程序可以发送到 Android 系统的消息,它声明了使用另一个应用程序进行某种目的的意图。意图对象包含许多成员,用于描述需要执行的操作类型,以及(如果有的话)需要执行操作的数据。作为用户,您可能熟悉默认操作选择器,它显示了许多应用程序图标以及仅此一次始终的选择。您看到的是您刚刚使用的应用程序向系统广播新意图的结果。当您选择一个应用程序时,Android 会从该应用程序启动一个新的活动,该活动已注册以响应该类型的意图。

在您的AndroidManifest.xml文件中,向活动块添加一个意图过滤器。让 Android 知道该应用程序可以用作图像查看器。添加以下 XML 代码:

<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <data android:mimeType="image/*" />
</intent-filter>

我们只需要处理这样一种情况,即当应用程序启动时,意图图像是默认加载的图像。在MainActivity中,我们将编写一个显示图像的新函数,如下所示。该方法获取 URI 路径并将其转换为文件路径名,调用该路径上的新Image对象,然后调用showImage方法。(有关参考,请访问developer.android.com/guide/topics/providers/content-provider-basics.html):

    void showUriImage(final Uri uri) {
        Log.d(TAG, "intent data " + uri.getPath());
        File file = new File(uri.getPath());
        if(file.exists()){
            Image img = new Image(uri.getPath());
            showImage(img);
        } else {
            String[] filePathColumn = {MediaStore.Images.Media.DATA};
            Cursor cursor = getContentResolver().query(uri, filePathColumn, null, null, null);
            if (cursor == null)
                return;
            if (cursor.moveToFirst()) {
                int columnIndex = cursor.getColumnIndex(filePathColumn[0]);
                String yourRealPath = cursor.getString(columnIndex);
                Image img = new Image(yourRealPath);
                showImage(img);
           }
           // else report image not found error?
           cursor.close();

    }

然后,在setup中添加对showUriImage的调用,如下所示:

    public void setup() {
        BorderMaterial.destroy();
        setupMaxTextureSize();
        setupBackground();
        setupScreen();
        loadImageList(imagesPath);
        setupThumbnailGrid();
        setupScrollButtons();
        Uri intentUri = getIntent().getData();
        if (intentUri != null) {
            showUriImage(intentUri);
        }
        updateThumbnails();
    }

我们还添加了对BorderMaterial.destroy()的调用,因为意图会启动活动的第二个实例。如果我们不销毁材料,新的活动实例,它有自己的图形上下文,将在尝试使用在第一个活动的图形上下文上编译的着色器时抛出错误。

现在,项目已经构建并安装到手机上,当您选择一个图像文件时,例如,从文件夹浏览器应用程序(例如我的文件(三星))中,您将得到一个选择使用意图查看图像的应用程序的选项。您的 Gallery360 应用程序(或者您实际上命名的任何应用程序)将是其中的一个选择,如下面的屏幕截图所示。选择它,它将以该图像文件视图作为默认视图启动。

使用意图启动

使用倾斜向上手势显示/隐藏网格

在 Cardboard 的早期,您只有一个按钮。就是这样。按钮和头部跟踪是用户与应用程序交互的唯一方式。而且因为按钮是一个巧妙的磁铁东西,你甚至不能按住这个按钮。有了 Cardboard 2.0,屏幕变成了按钮,我们还意识到我们可以短暂地把盒子从脸上拿下来,把手机向上倾斜,然后把它放回去,并将其解释为手势。因此,第二个输入诞生了!在撰写本文时,示例 Cardboard 应用程序将其用作返回手势。

我们将使用倾斜向上来显示和隐藏网格和箭头,以便您可以完全沉浸在所选的全景照片中。由于这样做的工作量较小,我们还允许用户随时执行此操作,而不仅仅是在查看全景照片时。与振动反馈一样,这实际上是一个相当轻松的功能。大部分的工作都是由OrientationEventListener类完成的。

MainActivity类的顶部,添加一个变量来表示网格的状态,方向事件监听器,以及倾斜检测计时器,如下所示:

      static boolean setupComplete = false;

      boolean interfaceVisible = true;
      OrientationEventListener orientationEventListener;
      int orientThreshold = 10;
      boolean orientFlip = false;
      long tiltTime;
      int tiltDamper = 250;

首先,我们可以编写一个方法来切换缩略图网格菜单的开/关。检查是否有比平面更少的图像,因为空的图像已经在updateThumbnails中被禁用:

    void toggleGridMenu() {
        interfaceVisible = !interfaceVisible;
        if (up != null)
            up.enabled = !up.enabled;
        if (down != null)
            down.enabled = !down.enabled;
        int texCount = thumbOffset;
        for (Thumbnail thumb : thumbnails) {
            if (texCount < images.size() && thumb != null) {
                thumb.setVisible(interfaceVisible);
            }
            texCount++;
        }
    }

接下来,编写一个setupOrientationListener辅助方法,当设备方向发生变化时提供回调函数。如果方向接近垂直,我们可以调用我们的切换函数,一旦设备返回横向并再次垂直,我们再次切换:

    void setupOrientationListener() {
        orientationEventListener = new OrientationEventListener(this, SensorManager.SENSOR_DELAY_NORMAL) {
            @Override
            public void onOrientationChanged(int orientation) {
                if(gridUpdateLock || !setupComplete)
                    return;
           if(System.currentTimeMillis() - tiltTime > tiltDamper) {
                    if(Math.abs(orientation) < orientThreshold || Math.abs(orientation - 180) < orientThreshold){   //"close enough" to portrait mode
                        if(!orientFlip) {
                            Log.d(TAG, "tilt up! " + orientation);
                            vibrator.vibrate(25);
                            toggleGridMenu();
                        }
                        orientFlip = true;
                    }
                    if(Math.abs(orientation - 90) < orientThreshold || Math.abs(orientation - 270) < orientThreshold) {    //"close enough" to landscape mode
                        orientFlip = false;
                    }
                          tiltTime = System.currentTimeMillis();
                }
            }
        };
        if(orientationEventListener.canDetectOrientation())
            orientationEventListener.enable();
    }

然后,将其添加到onCreate中:

    protected void onCreate(Bundle savedInstanceState) {
        ...
        setupOrientationListener();
    }

setupComplete标志防止在网格仍在创建时切换网格。让我们在updateThumbnails之后重置完成标志:

    void updateThumbnails() {
        . . .
                cancelUpdate = false;
                gridUpdateLock = false;
 setupComplete = true;

onDestroy中销毁它是明智的:

    @Override
    protected void onDestroy(){
        super.onDestroy();
        orientationEventListener.disable();
    }

onOrientationChanged回调将在手机改变方向时触发。我们只对从横向到纵向的变化感兴趣,并且我们也希望确保它不会发生太频繁,因此有了倾斜阻尼器功能。您可能希望调整值(目前为 250 毫秒)以满足您的喜好。时间太短,可能会错误地注册两次变化。时间太长,用户可能会在截止时间内尝试两次倾斜。

球形缩略图

球形 360 度图像应该比普通的缩略图图像更好,不是吗?我建议我们将它们显示为小球。也许我们应该称它们为拇指尖或拇指弹珠。无论如何,让我们做一些小小的修改来实现这一点。

Thumbnail类中添加一个球体

Thumbnail类中,添加一个sphere变量:

    public Sphere sphere;

修改setImage以识别全景图像:

    public void setImage(Image image) {
        // ...
        // show it
        if (image.isPhotosphere) {
            UnlitTexMaterial material = (UnlitTexMaterial) sphere.getMaterial();
            material.setTexture(image.textureHandle);
        } else {
            image.showThumbnail(cardboardView, plane);
        }
    }

我们还必须更改setVisible以处理planesphere变量,如下所示:

    public void setVisible(boolean visible) {
        if(visible) {
            if(image.isPhotosphere){
                plane.enabled = false;
                sphere.enabled = true;
            } else{
                plane.enabled = true;
                sphere.enabled = false;
            }
        } else {
            plane.enabled = false;
            sphere.enabled = false;
        }
    }

接下来,在MainActivity类的setupThumbnailGrid中,除了Plane对象之外,还初始化一个Sphere对象(在GRID_YGRID_X循环内):

                    . . . 
                    image.addComponent(imgPlane);

                    Transform sphere = new Transform();
                    sphere.setLocalPosition(-4 + j * 2.1f, 3 - i * 3, -5);
                    sphere.setLocalRotation(180, 0, 0);
                    sphere.setLocalScale(normalScale, normalScale, normalScale);
                    Sphere imgSphere = new Sphere(R.drawable.bg, false);
                    thumb.sphere = imgSphere;
                    imgSphere.enabled = false;
                    sphere.addComponent(imgSphere);

现在,缩略图既有一个平面又有一个球体,我们可以根据图像类型进行填充。

最后,我们只需要修改selectObject方法,看看我们如何突出显示一个球体缩略图。我们通过改变边框颜色来突出显示矩形的缩略图。我们的球体没有边框;因此我们将改变它们的大小。

MainActivity的顶部,添加变量到正常和选定的比例:

    final float selectedScale = 1.25f;
    final float normalScale = 0.85f;

现在,当图像是全景照片时,将selectObject更改为以不同的方式行事:

    void selectObject() {
        float deltaTime = Time.getDeltaTime();
        selectedThumbnail = null;
        for (Thumbnail thumb : thumbnails) {
            if (thumb.image == null)
                return;
            if(thumb.image.isPhotosphere) {
                Sphere sphere = thumb.sphere;
                if (sphere.isLooking) {
                    selectedThumbnail = thumb;
                    if (!gridUpdateLock)
                        sphere.transform.setLocalScale(selectedScale, selectedScale, selectedScale);
                } else {
                    sphere.transform.setLocalScale(normalScale, normalScale, normalScale);
                }
                sphere.transform.rotate(0, 10 * deltaTime, 0);
            } else {
                Plane plane = thumb.plane;
                //...
            }
        }
        //. . .

哇哦!我们甚至让球体旋转,这样你就可以看到它 360 度的全部荣耀!这太有趣了,简直应该是非法的。

在缩略图类中添加一个球体

就是这样!一个美丽的照片查看器应用程序,支持常规相机图像和 360 度全景照片。

更新 RenderBox 库

Gallery360 项目已经实施并且我们的代码已经稳定,你可能会意识到我们构建了一些不一定特定于这个应用程序的代码,可以在其他项目中重复使用,并且应该回到RenderBox库中。

我们在上一个项目的最后在第六章中完成了这个。你可以参考那个主题了解详情。按照以下步骤更新RenderBoxLib项目:

  1. RenderBoxExt/components移动PlaneTriangle组件。

  2. RenderBoxExt/materials移动BorderMaterial组件。

  3. res/raw移动边框着色器文件。

  4. 修复任何无效引用以正确的包名称。

  5. 通过点击Build | Make Project来重建库。

进一步的可能增强

哇,这是很多工作!这个东西肯定完成了,不是吗?永远不是!这里有一些改进,迫切需要实施:

  • 更好地检测手机图片:

  • 并不是每个人都把他们的所有图像保存在特定的路径中。事实上,一些相机软件使用完全不同的路径!引入一个合适的文件浏览器。

  • 更好地检测全景图像:

  • 在 XMP 头文件中有一个Projection Type属性,这是一些 JPG 文件中的另一个元数据。不幸的是,Android API 没有一个特定的类来读取这些数据,集成第三方库超出了这个项目的范围。可以尝试以下链接:

github.com/dragon66/pixymeta-android

github.com/drewnoakes/metadata-extractor

不要使用全景技术,因为它会捕捉到常规全景照片。允许用户标记或修复显示不正确的全景照片或旋转元数据的图像。

  • 动画 UI 操作-选择时的缩放/平移,平滑网格滚动。

  • 一个很棒的技术,可以防止网格瓦片出现在上/下箭头的后面,称为深度遮罩。你也可以在世界空间中引入最大和最小的 Y 值,超出这些值的瓦片将无法绘制。但深度遮罩更酷。

  • 响应GALLERY意图,用来覆盖网格并从另一个应用程序中选择图像。

  • VIEW意图中接受来自网络的图像 URL。

  • 你需要首先下载图像,然后从下载路径加载它。

总结

我希望你和我一样对我们在这里取得的成就感到兴奋!我们构建了一个真正实用的 Cardboard VR 应用程序,用于查看常规照片和 360 度全景照片的画廊。该项目使用了RenderBox库,如第五章中所讨论的,RenderBox Engine

首先,我们演示了全景照片的工作原理,并使用RenderBox库在 Cardboard 上查看了一个,而没有进行任何自定义更改。然后,为了查看常规照片,我们创建了一个Plane组件,用作虚拟投影屏幕。我们编写了新的材料和着色器来渲染带有边框的图像。

接下来,我们定义了一个新的Image类,并从手机的相机文件夹中加载图像到列表中,并编写了一个方法来在屏幕上显示图像Plane,纠正其方向和纵横比。然后,我们构建了一个用户界面,显示了缩略图图像的网格,并允许您通过凝视并点击 Cardboard 触发器来选择其中一个图像进行显示。该网格是可滚动的,这要求我们添加线程,以便在加载文件时应用程序不会出现锁定。最后,我们添加了一些功能来启动带有图像查看意图的应用程序,通过将手机垂直倾斜来切换菜单网格,并为全景照片添加了球形缩略图。

在下一章中,我们将构建另一种类型的查看器;这次是为了查看 OBJ 文件中的完整 3D 模型。

第八章:3D 模型查看器

三维模型随处可见,从机械工程的机械零件到医学成像;从视频游戏设计到 3D 打印。 3D 模型与照片、视频、音乐和其他媒体一样丰富多样。然而,虽然浏览器和应用程序对其他媒体类型有原生支持,但对 3D 模型的支持并不多。有一天,3D 查看标准将集成到浏览器中(例如 WebGL 和 WebVR)。在那之前,我们将不得不依赖插件和姊妹应用程序来查看我们的模型。例如,可以在网上找到免费的 OBJ 格式的 3D 文件模型,包括 TF3DM(tf3dm.com/)、TurboSquid(www.turbosquid.com/)和其他许多网站(www.hongkiat.com/blog/60-excellent-free-3d-model-websites/)。

在这个项目中,我们将构建一个 Android 3D 模型查看器应用程序,让您可以使用 Cardboard VR 头盔打开和查看 3D 模型。我们将使用的文件格式是 OBJ,这是 Wavefront Technologies 最初为电影 3D 动画开发的开放格式。OBJ 可以由许多 3D 设计应用程序创建和导出,包括开源应用程序,如 Blender 和 MeshLab,以及商业应用程序,如 3D Studio Max 和 Maya。OBJ 是一个非压缩的纯文本文件,用于存储由三角形(或更高阶多边形)组成的 3D 对象的表面网格的描述。

为了实现查看器,我们将读取和解析 OBJ 文件模型,并在 3D 中显示它们以供 Cardboard 查看。我们将通过以下步骤实现这一目标:

  • 设置新项目

  • 编写 OBJ 文件解析器以导入几何图形

  • 显示 3D 模型

  • 使用用户的头部运动旋转对象的视图

此项目的源代码可以在 Packt Publishing 网站上找到,并且在 GitHub 上也可以找到(github.com/cardbookvr/modelviewer)(每个主题作为单独的提交)。

建立一个新项目

为了构建这个项目,我们将使用在第五章中创建的RenderBox库,RenderBox 引擎。您可以使用您自己的库,或者从本书提供的可下载文件或我们的 GitHub 存储库中获取副本(使用标记为after-ch7的提交—github.com/cardbookvr/renderboxlib/releases/tag/after-ch7)。有关如何导入RenderBox库的更详细描述,请参阅第五章的最后一节,在未来项目中使用 RenderBox。执行以下步骤创建一个新项目:

  1. 打开 Android Studio,创建一个新项目。让我们将其命名为Gallery360,并以空活动为目标定位到Android 4.4 KitKat (API 19)

  2. 创建renderboxcommoncore包的新模块,使用文件 | 新建模块 | 导入.JAR/.AAR 包

  3. 使用文件 | 项目结构将模块设置为应用程序的依赖项。

  4. 按照第二章中的说明编辑build.gradle文件,Cardboard 项目的骨架,以便对 SDK 22 进行编译。

  5. 更新/res/layout/activity_main.xmlAndroidManifest.xml,如前几章所述。

  6. MainActivity编辑为class MainActivity extends CardboardActivity implements IRenderBox,并实现接口方法存根(Ctrl + I)。

我们可以继续在MainActivity中定义onCreate方法。该类现在具有以下代码:

public class MainActivity extends CardboardActivity implements IRenderBox {
    private static final String TAG = "ModelViewer";
    CardboardView cardboardView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
        cardboardView.setRenderer(new RenderBox(this, this));
        setCardboardView(cardboardView);
    }
    @Override
    public void setup() {
    }
    @Override
    public void preDraw() {
        // code run beginning each frame
    }
    @Override
    public void postDraw() {
        // code run end of each frame
    }
}

您可以将一个立方体临时添加到场景中,以确保一切设置正确。将其添加到setup方法中,如下所示:

    public void setup() {
        new Transform()
            .setLocalPosition(0,0,-7)
            .setLocalRotation(45,60,0)
            .addComponent(new Cube(true));
    }

如果您记得,Cube是添加到TransformComponentCube定义了它的几何形状(例如,顶点)。Transform定义了它在 3D 空间中的位置、旋转和缩放。

您应该能够在 Android 设备上点击Run 'app'而没有编译错误,并看到立方体和 Cardboard 分屏视图。

了解 OBJ 文件格式

该项目的目标是查看 Wavefront OBJ 格式的 3D 模型。在我们开始编码之前,让我们来看看文件格式。可以在www.fileformat.info/format/wavefrontobj/egff.htm找到参考资料。

正如我们所知,3D 模型可以表示为 X、Y 和 Z 顶点的网格。顶点集连接在一起定义网格表面的一个面。完整的网格表面是这些面的集合。

每个顶点也可以分配一个法线向量和/或纹理坐标。法线向量定义了该顶点的外向面向方向,用于光照计算。UV 纹理坐标可用于将纹理图像映射到网格表面上。格式的其他特性包括自由曲线和材质,我们在这个项目中不会支持。

作为纯文本文件,OBJ 被组织为单独的文本行。每个非空行以关键字开头,后面是由空格分隔的该关键字的数据。注释以#开头,并且被解析器忽略。

OBJ 数据关键字包括:

  • v: 几何顶点(例如,v 0.0 1.0 0.0

  • vt: 纹理顶点(例如,vt 0.0 1.0 0.0)[在我们的项目中不受支持]

  • vn: 顶点法线(例如,vn 0.0 1.0 0.0

  • f: 多边形面索引(例如,f 1 2 3

面值是指向顶点列表中的顶点的索引(从第一个顶点开始为 1)。

至于指定面索引的f命令,它们是整数值,用于索引顶点列表。当有三个索引时,它描述一个三角形;四个描述一个四边形,依此类推。

当纹理顶点存在时,它们被引用为斜杠后的第二个数字,例如,f 1/1 2/2 3/3。我们现在不支持它们,但可能需要在f命令中解析它们。当顶点法线存在时,它们被引用为斜杠后的第三个数字,例如,f 1//1 2//2 3//3f 1/1/1 2/2/2 3/3/3

索引可以是负数,这种情况下它们将以-1 表示最后(最近遇到的)项目,-2 表示前一个项目,依此类推。

其他行,包括我们这里不支持的数据,将被忽略。

例如,以下数据表示一个简单的三角形:

# Simple Wavefront file
v 0.0 0.0 0.0
v 0.0 1.0 0.0
v 1.0 0.0 0.0
f 1 2 3

我们的 OBJ 实现是有限的。它可以安全地处理本书附带的示例模型,也许还可以处理您在互联网上找到或自己制作的其他模型。然而,这只是一个示例代码和演示项目。在我们的RenderBox引擎中编写一个强大的数据导入器并支持 OBJ 的许多特性超出了本书的范围。

创建ModelObject

首先,我们将定义一个ModelObject类,它扩展了RenderObject。它将从 OBJ 文件加载模型数据,并设置其材质所需的缓冲区(以及 OpenGL ES 着色器,以在 VR 场景中呈现)。

右键单击app/java/com.cardboardvr.modelviewer/文件夹,转到New | Java Class,并命名为ModelObject。定义它以扩展RenderObject,如下所示:

public class ModelObject extends RenderObject {
}

就像我们在之前的章节中所做的那样,当引入新类型的RenderObjects时,我们将有一个或多个构造函数,可以实例化一个Material并设置缓冲区。对于ModelObject,我们将传入一个文件资源句柄,解析文件(参考下一个主题),并创建一个纯色材质(最初,没有光照),如下所示:

    public ModelObject(int objFile) {
        super();
        InputStream inputStream = RenderBox.instance.mainActivity.getResources().openRawResource(objFile);
        if (inputStream == null)
            return; // error
        parseObj(inputStream);
        createMaterial();
    }

现在添加材质如下。首先,声明缓冲区的变量(就像我们在之前的项目中为其他RenderObjects所做的那样)。这些可以是私有的,但我们的约定是如果我们想在外部定义新材质,就将它们保持为公共的:

    public static FloatBuffer vertexBuffer;
    public static FloatBuffer colorBuffer;
    public static FloatBuffer normalBuffer;
    public static ShortBuffer indexBuffer;
    public int numIndices;

这是createMaterial方法(从构造函数中调用):

    public ModelObject createMaterial(){
        SolidColorLightingMaterial scm = new SolidColorLightingMaterial(new float[]{0.5f, 0.5f, 0.5f, 1});
        scm.setBuffers(vertexBuffer, normalBuffer, indexBuffer, numIndices);
        material = scm;
        return this;
    }

接下来,我们实现parseObj方法。

解析 OBJ 模型

parseObj方法将打开资源文件作为InputStream。它一次读取一行,解析命令和数据,构建模型的顶点、法线和索引列表。然后,我们从数据构建缓冲区。

首先,在ModelObject类的顶部,声明数据列表的变量:

    Vector<Short> faces=new Vector<Short>();
    Vector<Short> vtPointer=new Vector<Short>();
    Vector<Short> vnPointer=new Vector<Short>();
    Vector<Float> v=new Vector<Float>();
    Vector<Float> vn=new Vector<Float>();
    Vector<Material> materials=null;

让我们编写parseObj,并为辅助方法添加占位符。我们打开文件,处理每一行,构建缓冲区,并处理潜在的 IO 错误:

    void parseObj(InputStream inputStream) {
        BufferedReader reader = null;
        String line = null;

        reader = new BufferedReader(new InputStreamReader(inputStream));
        if (reader == null)
            return; // error

        try { // try to read lines of the file
            while ((line = reader.readLine()) != null) {
                parseLine(line);
            }
            buildBuffers();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

parseLine代码非常简单。行的第一个标记是一个或两个字符的命令(如vvnf),后面是数据值(浮点坐标或整数索引)。以下是parseLinevvn顶点的解析器的代码:

    private void parseLine(String line) {
        Log.v("obj", line);
        if(line.startsWith("f")){//a polygonal face
            processFLine(line);
        }
        else
        if(line.startsWith("vn")){
            processVNLine(line);
        }
        else
        if(line.startsWith("v")){ //line having geometric position of single vertex
            processVLine(line);
        }
    }

    private void processVLine(String line){
        String [] tokens=line.split("[ ]+"); 
        //split the line at the spaces
        int c=tokens.length;
        for(int i=1; i<c; i++){ //add the vertex to the vertex array
            v.add(Float.valueOf(tokens[i]));
        }
    }

    private void processVNLine(String line){
        String [] tokens=line.split("[ ]+"); 
        //split the line at the spaces
        int c=tokens.length;
        for(int i=1; i<c; i++){ //add the vertex to the vertex array
            vn.add(Float.valueOf(tokens[i]));
        }
    }

f行需要处理各种值情况。

至于指定面索引的f命令,它们是索引到顶点列表的整数值。当有三个索引时,它描述一个三角形,四个描述一个四边形,依此类推。超过三边的任何形状都需要被细分为三角形,以便在 OpenGL ES 中进行渲染。

还可以有任意组合的索引值,包括格式如vv/vtv/vt/vn,甚至v//vn/vt/vn//vn。(请记住,由于我们没有映射纹理,我们只会使用第一个和第三个。)

让我们先处理最简单的情况,即三角形面:

    private void processFLine(String line){
        String [] tokens=line.split("[ ]+");
        int c=tokens.length;

        if(tokens[1].matches("[0-9]+")){//f: v
            if(c==4){//3 faces
                for(int i=1; i<c; i++){
                    Short s=Short.valueOf(tokens[i]);
                    s--;
                    faces.add(s);
                }
            }
        }
    }

现在考虑面上有超过三个索引。我们需要一个方法来将多边形三角化。让我们现在编写这个方法:

    public static Vector<Short> triangulate(Vector<Short> polygon){
        Vector<Short> triangles=new Vector<Short>();
        for(int i=1; i<polygon.size()-1; i++){
            triangles.add(polygon.get(0));
            triangles.add(polygon.get(i));
            triangles.add(polygon.get(i+1));
        }
        return triangles;
    }

我们可以在processFLine中使用它:

    private void processFLine(String line) {
        String[] tokens = line.split("[ ]+");
        int c = tokens.length;

        if (tokens[1].matches("[0-9]+") || //f: v
            tokens[1].matches("[0-9]+/[0-9]+")) {//f: v/vt

            if (c == 4) {//3 faces
                for (int i = 1; i < c; i++) {
                    Short s = Short.valueOf(tokens[i]);
                    s--;
                    faces.add(s);
                }
            }
            else{//more faces
                Vector<Short> polygon=new Vector<Short>();
                for(int i=1; i<tokens.length; i++){
                    Short s=Short.valueOf(tokens[i]);
                    s--;
                    polygon.add(s);
                }
                faces.addAll(triangulate(polygon));
                //triangulate the polygon and //add the resulting faces
            }
        }
        //if(tokens[1].matches("[0-9]+//[0-9]+")){//f: v//vn
        //if(tokens[1].matches("[0-9]+/[0-9]+/[0-9]+")){
		//f: v/vt/vn
    }

这段代码适用于面值vv/vt,因为我们跳过纹理。我还注释掉了面索引值的其他两种排列。其余部分大部分只是暴力字符串解析。v//vn情况如下:

    if(tokens[1].matches("[0-9]+//[0-9]+")){//f: v//vn
        if(c==4){//3 faces
            for(int i=1; i<c; i++){
                Short s=Short.valueOf(tokens[i].split("//")[0]);
                s--;
                faces.add(s);
                s=Short.valueOf(tokens[i].split("//")[1]);
                s--;
                vnPointer.add(s);
            }
        }
        else{//triangulate
            Vector<Short> tmpFaces=new Vector<Short>();
            Vector<Short> tmpVn=new Vector<Short>();
            for(int i=1; i<tokens.length; i++){
                Short s=Short.valueOf(tokens[i].split("//")[0]);
                s--;
                tmpFaces.add(s);
                s=Short.valueOf(tokens[i].split("//")[1]);
                s--;
                tmpVn.add(s);
            }
            faces.addAll(triangulate(tmpFaces));
            vnPointer.addAll(triangulate(tmpVn));
        }
    }

最后,v/vt/vn情况如下:

    if(tokens[1].matches("[0-9]+/[0-9]+/[0-9]+")){//f: v/vt/vn
        if(c==4){//3 faces
            for(int i=1; i<c; i++){
                Short s=Short.valueOf(tokens[i].split("/")[0]);
                s--;
                faces.add(s);
                // (skip vt)
                s=Short.valueOf(tokens[i].split("/")[2]);
                s--;
                vnPointer.add(s);
            }
        }
        else{//triangulate
            Vector<Short> tmpFaces=new Vector<Short>();
            Vector<Short> tmpVn=new Vector<Short>();
            for(int i=1; i<tokens.length; i++){
                Short s=Short.valueOf(tokens[i].split("/")[0]);
                s--;
                tmpFaces.add(s);
                // (skip vt)
                s=Short.valueOf(tokens[i].split("/")[2]);
                s--;
                tmpVn.add(s);
            }
            faces.addAll(triangulate(tmpFaces));
            vnPointer.addAll(triangulate(tmpVn));
        }
    }

如前所述,在 OBJ 文件格式描述中,索引可以是负数;在这种情况下,它们需要从顶点列表的末尾向后引用。这可以通过将索引值添加到索引列表的大小来实现。为了支持这一点,在前面的代码中,用以下内容替换所有s--行:

                   if (s < 0)
                       s = (short)(s + v.size());
                   else
                       s--;

buildBuffers

parseObj方法的最后一步是从模型数据构建我们的着色器缓冲区,即vertexBuffernormalBufferindexBuffer变量。我们现在可以将其添加到buildBuffers方法中,如下所示:

    private void buildBuffers() {
        numIndices = faces.size();
        float[] tmp = new float[v.size()];
        int i = 0;
        for(Float f : v)
            tmp[i++] = (f != null ? f : Float.NaN);
        vertexBuffer = allocateFloatBuffer(tmp);

        i = 0;
        tmp = new float[vn.size()];
        for(Float f : vn)
            tmp[i++] = (f != null ? -f : Float.NaN); 
            //invert normals
        normalBuffer = allocateFloatBuffer(tmp);

        i = 0;
        short[] indicies = new short[faces.size()];
        for(Short s : faces)
            indicies[i++] = (s != null ? s : 0);
        indexBuffer = allocateShortBuffer(indicies);
    }

有一个注意事项。我们注意到对于RenderBox坐标系和着色器,有必要从 OBJ 数据中反转法线(使用-f而不是f)。实际上,这取决于 OBJ 导出器(3Ds Max、Blender 和 Maya)。其中一些会翻转法线,而另一些则不会。不幸的是,除了查看模型之外,没有办法确定法线是否被翻转。因此,一些 OBJ 导入器/查看器提供了(可选的)功能,可以根据面几何计算法线,而不是依赖于导入数据本身。

模型范围、缩放和中心

3D 模型有各种形状和大小。为了在我们的应用程序中查看它们,我们需要知道模型的最小和最大边界以及其几何中心,以便适当地进行缩放和定位。让我们现在将这些添加到ModelObject中。

ModelObject类的顶部,添加以下变量:

    public Vector3 extentsMin, extentsMax;

在解析器中初始化范围,然后解析模型数据。最小范围初始化为最大可能值;最大范围初始化为最小可能值:

    public ModelObject(int objFile) {
        super();
        extentsMin = new Vector3(Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE);
        extentsMax = new Vector3(Float.MIN_VALUE, Float.MIN_VALUE, Float.MIN_VALUE);
        . . .

我们将在模型加载后而不是在导入过程中计算范围。当我们向顶点列表添加一个新顶点时,我们将计算当前范围。在processVLine循环中添加一个调用setExtents

    private void processVLine(String line) {
        String[] tokens = line.split("[ ]+"); 
        //split the line at the spaces
        int c = tokens.length;
        for (int i = 1; i < c; i++) { //add the vertex to the vertex array
            Float value = Float.valueOf(tokens[i]);
            v.add(value);
            setExtents(i, value);
        }
    }

然后,setExtents方法可以实现如下:

    private void setExtents(int coord, Float value) {
        switch (coord) {
            case 1:
                if (value < extentsMin.x)
                    extentsMin.x = value;
                if (value > extentsMax.x)
                    extentsMax.x = value;
                break;
            case 2:
                if (value < extentsMin.y)
                    extentsMin.y = value;
                if (value > extentsMax.y)
                    extentsMax.y = value;
                break;
            case 3:
                if (value < extentsMin.z)
                    extentsMin.z = value;
                if (value > extentsMax.z)
                    extentsMax.z = value;
                break;
        }
    }

让我们添加一个标量方法,当我们将模型添加到场景中时会很有用(正如你将在下一个主题中看到的),将其缩放到一个标准大小,范围为-11

    public float normalScalar() {
        float sizeX = (extentsMax.x - extentsMin.x);
        float sizeY = (extentsMax.y - extentsMin.y);
        float sizeZ = (extentsMax.z - extentsMin.z);
        return (2.0f / Math.max(sizeX, Math.max(sizeY, sizeZ)));
    }

现在,让我们试试吧!

我是一个小茶壶

几十年来,3D 计算机图形研究人员和开发人员一直在使用这个可爱的茶壶模型。它是一个经典!背后的故事是,著名的计算机图形先驱和研究人员马丁·纽维尔需要一个模型来进行他的工作,他的妻子建议他在家里对他们的茶壶进行建模。原作现在展览在波士顿计算机博物馆。我们已经在本书的可下载文件中包含了这个经典模型的 OBJ 版本。

当然,你可以选择自己的 OBJ 文件,但如果你想使用茶壶,找到teapot.obj文件,并将其复制到res/raw文件夹中(如果需要,创建该文件夹)。

现在加载模型并尝试。在MainActivity中,添加一个变量到MainActivity类的顶部来保存当前模型:

    Transform model;

将以下代码添加到setup方法中。注意,我们将其缩放到原始大小的一小部分,并将其放置在摄像头前方 3 个单位处:

    public void setup() {
        ModelObject modelObj = new ModelObject(R.raw.teapot);
        float scalar = modelObj.normalScalar();
        model = new Transform()
                .setLocalPosition(0, 0, -3)
                .setLocalScale(scalar, scalar, scalar)
                .addComponent(modelObj);
    }

运行项目,应该看起来像这样:

我是一个小茶壶

你可以看到模型已经成功加载和渲染。不幸的是,阴影很难辨认。为了更好地观看阴影茶壶,让我们把它下移一点。修改setup中的setLocalPosition方法,如下所示:

                .setLocalPosition(0, -2, -3) 

以下截图被裁剪和放大,这样你就可以看到这里的阴影茶壶,就像你在 Cardboard 观看器中看到的一样:

我是一个小茶壶

我是一个旋转的小茶壶

通过旋转模型来增强观看体验,当用户旋转头部时模型也会旋转。这种效果与“正常”的虚拟现实体验不同。通常情况下,在 VR 中移动头部会旋转场景中相机的主观视图,以便与头部运动一起四处张望。在这个项目中,头部运动就像一个输入控制旋转模型。模型始终固定在你面前的位置。

实现这个功能非常简单。RenderBox preDraw接口方法在每一帧开始时被调用。我们将获取当前的头部角度并相应地旋转模型,将头部后欧拉角转换为四元数(组合多个欧拉角可能导致意外的最终旋转方向)。我们还会共轭(即反转)旋转,这样当你抬头时,你会看到物体的底部,依此类推。这样感觉更自然。

MainActivity中,添加以下代码到preDraw

    public void preDraw() {
        float[] hAngles = RenderBox.instance.headAngles;
        Quaternion rot = new Quaternion();
        rot.setEulerAnglesRad(hAngles[0], hAngles[1], hAngles[2]);
        model.setLocalRotation(rot.conjugate());
    }

setup中,确保setLocalPosition方法将茶壶直立在摄像头前方:

                .setLocalPosition(0, 0, -3)

尝试运行它。我们快要成功了!模型随着头部旋转,但我们仍然在 VR 空间中四处张望。

为了锁定头部位置,我们只需要在RenderBox中禁用头部跟踪。如果你的RenderBox版本(在第五章中构建的RenderBox Engine)还没有这个功能,那么将其添加到你的单独的RenderBoxLib lib 项目中,如下所示:

Camera.java文件中,首先添加一个新的公共变量headTracking

    public boolean headTracking = true;

修改onDrawEye方法以有条件地更新视图变换,如下所示:

        if (headTracking) {
            // Apply the eye transformation to the camera.
            Matrix.multiplyMM(view, 0, eye.getEyeView(), 0, camera, 0);
        } else {
             // copy camera into view
            for (int i=0; i < camera.length; i++) { view[i] = camera[i]; }
        }

确保在重新构建后将更新的.aar文件复制到ModelViewer项目的RenderBox模块文件夹中。

现在,在MainActivity类的setup()中,添加以下设置:

        RenderBox.instance.mainCamera.headTracking = false;

现在运行它,当你移动头部时,模型保持相对静止,但随着你转动头部而旋转。太棒了!好多了。

线程安全

在第七章中,360 度画廊,我们解释了需要工作线程将处理从渲染线程中卸载的需求。在这个项目中,我们将在ModelObject构造函数中添加线程,用于读取和解析模型文件:

    public ModelObject(final int objFile) {
        super();
        extentsMin = new Vector3(Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE);
        extentsMax = new Vector3(Float.MIN_VALUE, Float.MIN_VALUE, Float.MIN_VALUE);

        SolidColorLightingMaterial.setupProgram();
        enabled = false;
        new Thread(new Runnable() {
            @Override
            public void run() {
                InputStream inputStream = RenderBox.instance.mainActivity.getResources().openRawResource(objFile);
                if (inputStream == null)
                    return; // error
                createMaterial();
                enabled = true;
                float scalar = normalScalar();
                transform.setLocalScale(scalar, scalar, scalar);
            }
        }).start();
    }

我们必须将文件句柄objFile声明为final,以便能够从内部类中访问它。您可能还注意到,我们在启动线程之前添加了对材质的setup程序的调用,以确保它在时间上得到正确设置并避免崩溃应用。这避免了在queueEvent过程中调用createMaterial的需要,因为着色器编译器利用了图形上下文。同样,我们在加载完成之前禁用对象。最后,由于加载是异步的,必须在此过程的末尾设置比例。我们以前的方法在setup()中设置了比例,现在在模型加载完成之前完成了。

使用意图启动

在第七章中,360 度画廊,我们介绍了使用 Android 意图将应用程序与特定文件类型相关联,以便将我们的应用程序作为这些文件的查看器启动。我们将在这里为 OBJ 文件做同样的事情。

意图是任何应用程序都可以发送到 Android 系统的消息,宣告其意图使用另一个应用程序来完成某个特定目的。意图对象包含许多成员,用于描述需要执行的操作类型,以及(如果有的话)需要执行操作的数据。对于图像库,我们将意图过滤器与图像 MIME 类型相关联。对于这个项目,我们将意图过滤器与文件名扩展名相关联。

在您的AndroidManifest.xml文件中,向活动块添加一个意图过滤器。这让 Android 知道该应用程序可以用作 OBJ 文件查看器。我们需要将其指定为文件方案和文件名模式。通配符 MIME 类型和主机也是 Android 所必需的。添加以下 XML 代码:

            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="file" />
                <data android:mimeType="*/*" />
                <data android:pathPattern=".*\\.obj" />
                <data android:host="*" />
            </intent-filter>

为了处理这种情况,我们将在ModelObject中添加一个新的构造函数,该构造函数接受一个 URI 字符串而不是资源 ID,就像我们之前做的那样。与其他构造函数一样,我们需要打开一个输入流并将其传递给parseObj。以下是构造函数,包括工作线程:

    public ModelObject(final String uri) {
        super();
        extentsMin = new Vector3(Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE);
        extentsMax = new Vector3(Float.MIN_VALUE, Float.MIN_VALUE, Float.MIN_VALUE);
        SolidColorLightingMaterial.setupProgram();
        enabled = false;
        new Thread(new Runnable() {
            @Override
            public void run() {
                File file = new File(uri.toString());
                FileInputStream fileInputStream;
                try {
                    fileInputStream = new FileInputStream(file);
                } catch (IOException e) {
                    e.printStackTrace();
                    return; // error
                }
                parseObj(fileInputStream);
                createMaterial();
                enabled = true;
                float scalar = normalScalar();
                transform.setLocalScale(scalar, scalar, scalar);
            }
        }).start();
    }

现在在MainActivity类的setup中,我们将检查应用程序是否是从意图启动的,并使用意图 URI。否则,我们将查看默认模型,就像我们之前做的那样:

    public void setup() {
        ModelObject modelObj;
        Uri intentUri = getIntent().getData();
        if (intentUri != null) {
            Log.d(TAG, "!!!! intent " + intentUri.getPath());
            modelObj = new ModelObject(intentUri.getPath());
        } else {
            // default object
            modelObj = new ModelObject(R.raw.teapot);
        }
        //...        

现在项目已经构建并安装到手机上,让我们尝试一些网络集成。打开网络浏览器并访问一个 3D 模型下载网站。

找到有趣模型的下载链接,将其下载到手机上,然后在提示时,使用ModelViewer应用程序查看它!

实用和生产就绪

请注意,正如前面提到的,我们已经创建了 OBJ 模型格式的有限实现,因此您找到的每个模型在这一点上可能无法正确查看(如果有的话)。不过,这可能是足够的,取决于您自己项目的要求,例如,如果您在资源文件夹中包含特定模型,可以在应用的发布版本中查看。当您完全控制输入数据时,您可以偷个懒。

虽然 OBJ 文件格式的基本结构并不是非常复杂,正如我们在这里所展示的,就像软件(以及生活中的许多事物)一样,“魔鬼在细节中”。以这个项目作为起点,然后构建您自己的实用和生产就绪的 OBJ 文件解析器和渲染器将需要相当多的额外工作。您还可以研究现有的软件包、其他模型格式,或者甚至从开源游戏引擎(如 LibGDX)中提取一些代码。我们省略的 OBJ 的特性,但值得考虑的包括以下内容:

  • 纹理顶点

  • 材质定义

  • 曲线元素

  • 几何图形的分组

  • 颜色和其他顶点属性

总结

在这个项目中,我们编写了一个简单的查看器,用于以开放的 OBJ 文件格式查看 3D 模型。我们实现了一个ModelObject类,它解析模型文件并构建了RenderBox需要的向量和法线缓冲区,以在场景中渲染对象。然后我们启用了阴影。然后我们使查看器交互,这样模型就会随着你移动头部而旋转。

在下一章中,我们将探索另一种类型的媒体,即音乐。音乐可视化器会响应当前的音乐播放器,在 VR 世界中显示跳舞的几何图形。

第九章:音乐可视化

“'看音乐,听舞蹈',”著名的俄罗斯裔编舞家、美国芭蕾舞之父乔治·巴兰钦说道。

我们不打算提升艺术形式的水平,但或许将手机上的播放列表可视化会很有趣。在这个项目中,我们将创建 3D 动画抽象图形,以音乐的节奏起舞。您可能熟悉 2D 音乐可视化,但在 VR 中会是什么样子呢?要获得灵感,可以尝试使用短语geometry wars在 Google 上搜索图像,例如 XBox 的经典游戏!

可视化应用程序从 Android 音频系统接收输入并显示可视化效果。在这个项目中,我们将利用 Android 的Visualizer类,让应用程序捕获当前播放音频的一部分,而不是完整的高保真音乐细节,而是足够进行可视化的低质量音频内容。

在这个项目中,我们将:

  • 设置新项目

  • 构建名为 VisualizerBox 的 Java 类架构

  • 从手机音频播放器中捕获波形数据

  • 构建几何可视化

  • 构建基于纹理的可视化

  • 捕获 FFT 数据并构建 FFT 可视化

  • 添加迷幻轨迹模式

  • 支持多个并发可视化

此项目的源代码可以在 Packt Publishing 网站和 GitHub 上找到,网址为github.com/cardbookvr/visualizevr(每个主题作为单独的提交)。

设置新项目

要构建此项目,我们将使用我们在第五章中创建的 RenderBox 库,RenderBox 引擎。您可以使用您自己的库,或者从本书提供的可下载文件或我们的 GitHub 存储库中获取副本(使用标记为after-ch8的提交 - github.com/cardbookvr/renderboxlib/releases/tag/after-ch8)。有关如何导入RenderBox库的更详细描述,请参阅第五章的最后一节,在未来项目中使用 RenderBox。要创建新项目,请执行以下步骤:

  1. 打开 Android Studio,创建一个新项目。让我们将其命名为VisualizeVR,并针对Android 4.4 KitKat (API 19)使用空活动

  2. renderboxcommoncore包创建新模块,使用文件|新建模块|导入.JAR/.AAR 包

  3. 将模块设置为应用程序的依赖项,使用文件|项目结构

  4. 根据第二章中的说明编辑build.gradle文件,骨架硬纸板项目,以便编译针对 SDK 22。

  5. 更新/res/layout/activity_main.xmlAndroidManifest.xml,如前几章所述。

  6. MainActivity编辑为class MainActivity extends CardboardActivity implements IRenderBox,并实现接口方法存根(Ctrl + I)。

我们可以继续在MainActivity中定义onCreate方法。该类现在具有以下代码:

public class MainActivity extends CardboardActivity implements IRenderBox {
    private static final String TAG = "MainActivity";CardboardView cardboardView;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
        cardboardView.setRenderer(new RenderBox(this, this));
        setCardboardView(cardboardView);
    }
    @Override
    public void setup() {
    }
    @Override
    public void preDraw() {
        // code run beginning each frame
    }
    @Override
    public void postDraw() {
        // code run end of each frame
    }
}

您可以将一个立方体临时添加到场景中,以确保一切设置正确。将其添加到setup方法中,如下所示:

    public void setup() {
        new Transform()
            .setLocalPosition(0,0,-7)
            .setLocalRotation(45,60,0)
            .addComponent(new Cube(true));
    }

如果您记得,Cube是添加到TransformComponentCube定义了其几何形状(例如,顶点)。Transform定义了其在 3D 空间中的位置、旋转和缩放。

您应该能够在 Android 设备上单击运行'app'而没有编译错误,并看到立方体和硬纸板分屏视图。

捕获音频数据

使用 Android 的Visualizer类(developer.android.com/reference/android/media/audiofx/Visualizer.html),我们可以以指定的采样率检索当前播放的音频数据的一部分。您可以选择捕获波形和/或频率数据:

  • 波形:这是表示音频振幅采样系列的单声道音频波形字节数组,或脉冲编码调制PCM)数据

  • 频率:这是表示音频频率采样的快速傅立叶变换FFT)字节数组

数据限制为 8 位,因此对于播放而言并不有用,但对于可视化来说足够了。您可以指定采样率,尽管它必须是 2 的幂。

掌握了这些知识,我们现在将继续实施一个架构,捕获音频数据并使其可用于您可以构建的可视化渲染器。

VisualizerBox 架构

音乐可视化器通常看起来非常酷,尤其是一开始。但是一段时间后,它们可能会显得太重复,甚至无聊。因此,在我们的设计中,我们将构建一个能够排队一些不同可视化的能力,然后在一段时间后从一个切换到另一个。

为了开始我们的实施,我们将定义一个可扩展的架构结构,让我们在开发新的可视化时能够扩展。

然而,即使在那之前,我们必须确保应用程序有权限使用我们需要的 Android 音频功能。将以下指令添加到AndroidManifest.xml中:

    <!-- Visualizer permissions -->
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

请记住,RenderBox库最初是在第五章中开发的,RenderBox 引擎,允许MainActivity将大部分图形和 Cardboard VR 工作委托给RenderBox类和相关类(ComponentMaterial等)。我们将在此基础上构建一个类似的设计模式,建立在RenderBox之上。MainActivity可以实例化特定的可视化,然后将工作委托给VisualizerBox类。

VisualizerBox类将提供回调函数给 Android 的Visualizer类。让我们首先定义这个的骨架实现。创建一个VisualizerBoxJava 类,如下所示:

public class VisualizerBox {
    static final String TAG = "VisualizerBox";
    public VisualizerBox(final CardboardView cardboardView){
    }
    public void setup() {
    }
    public void preDraw() {
    }
    public void postDraw() {
    }
}

VisualizerBox集成到MainActivity中,在类的顶部添加一个visualizerBox变量。在MainActivity中,添加以下行:

    VisualizerBox visualizerBox;

onCreate中进行初始化:

        visualizerBox = new VisualizerBox(cardboardView);

此外,在MainActivity中,调用每个IRenderBox接口方法的相应版本:

    @Override
    public void setup() {
        visualizerBox.setup();
    }
    @Override
    public void preDraw() {
        visualizerBox.preDraw();
    }
    @Override
    public void postDraw() {
        visualizerBox.postDraw();
    }

很好。现在我们将设置VisualizerBox,让您构建和使用一个或多个可视化。因此,首先让我们在Visualization.java文件中定义抽象的Visualization类,如下所示:

public abstract class Visualization {
    VisualizerBox visualizerBox;            //owner

    public Visualization(VisualizerBox visualizerBox){
        this.visualizerBox = visualizerBox;
    }
    public abstract void setup();
    public abstract void preDraw();
    public abstract void postDraw();
}

现在我们有了一个机制来为应用程序创建各种可视化实现。在我们继续编写其中一个之前,让我们还提供与VisualizerBox的集成。在VisualizerBox类的顶部,添加一个变量到当前的activeViz对象:

    public Visualization activeViz;

然后,从接口方法中调用它:

    public void setup() {
        if(activeViz != null)
            activeViz.setup();
    }
    public void preDraw() {
        if(activeViz != null)
            activeViz.preDraw();
    }
    public void postDraw() {
        if(activeViz != null)
            activeViz.postDraw();
    }

当然,我们甚至还没有使用 Android 的Visualizer类,也没有在屏幕上渲染任何东西。接下来会有。

现在,让我们为可视化创建一个占位符。在项目中创建一个名为visualizations的新文件夹。右键单击您的 Java 代码文件夹(例如java/com/cardbookvr/visualizevr/),转到新建 | ,并将其命名为visualizations。然后,右键单击新的visualizations文件夹,转到新建 | Java 类,并将其命名为BlankVisualization。然后,将其定义为extends Visualization,如下所示:

public class BlankVisualization extends Visualization {
    static final String TAG = "BlankVisualization";
    public BlankVisualization(VisualizerBox visualizerBox) {
        super(visualizerBox);
    }
    @Override
    public void setup() {
    }
    @Override
    public void preDraw() {
    }
    @Override
    public void postDraw() {
    }
}

我们将能够将其用作特定可视化器的模板。每种方法的目的都相当不言自明:

  • setup:这个方法初始化可视化的变量、转换和材料

  • preDraw:此代码在每帧开始时执行;例如,使用当前捕获的音频数据

  • postDraw:此代码在每帧结束时执行

现在让我们给这个骨架添加一些内容。

波形数据捕获

如前所述,Android 的Visualizer类让我们定义回调来捕获音频数据。这些数据有两种格式:波形和 FFT。我们现在将仅添加波形数据到VisualizerBox类中。

首先,定义我们将用于捕获音频数据的变量,如下所示:

    Visualizer visualizer;
    public static int captureSize;
    public static byte[] audioBytes;

使用 API,我们可以确定可用的最小捕获大小,然后将其用作我们的捕获样本大小。

然后,在构造函数中初始化它们如下。首先,实例化一个 AndroidVisualizer。然后设置要使用的捕获大小,并分配我们的缓冲区:

    public VisualizerBox(final CardboardView cardboardView){
        visualizer = new Visualizer(0);
        captureSize = Visualizer.getCaptureSizeRange()[0];
        visualizer.setCaptureSize(captureSize);
        // capture audio data
        // Visualizer.OnDataCaptureListener captureListener = ...
        visualizer.setDataCaptureListener(captureListener, Visualizer.getMaxCaptureRate(), true, true);
        visualizer.setEnabled(true);
    }

我们希望出于各种原因使用最小尺寸。首先,它会更快,而在虚拟现实中,速度至关重要。其次,它将我们的 FFT 样本(稍后讨论)组织成更少的桶。这很有帮助,因为每个桶可以在更广泛的频率范围内捕捉更多的活动。

注意

请注意,我们在定义捕获监听器的地方留下了一个注释,然后在可视化器中设置它。确保你启用了可视化器作为始终监听。

首先编写仅用于波形数据的captureListener对象。我们定义并实例化一个实现Visualizer.OnDataCaptureListener的新匿名类,并为其提供一个名为onWaveFormDataCapture的函数,该函数接收波形字节并将其存储到我们的Visualization代码中(即将推出):

        // capture audio data
        Visualizer.OnDataCaptureListener captureListener = new Visualizer.OnDataCaptureListener() {
            @Override
            public void onWaveFormDataCapture(Visualizer visualizer, byte[] bytes, int samplingRate) {
                audioBytes = bytes;
            }
            @Override
            public void onFftDataCapture(Visualizer visualizer, byte[] bytes, int samplingRate) {
            }
        };

界面仍然要求我们提供一个onFftDataCapture方法,但我们暂时将其留空。

现在我们准备为这个项目添加一些图形。

一个基本的几何可视化

对于我们的第一个可视化,我们将创建一个基本的均衡器波形图形。它将是一个由一系列根据音频波形数据进行缩放的立方体组成的矩形块。我们将使用内置的Cube组件,已经在RenderBox库中的基本顶点颜色照明材质。

visualizations/文件夹中,创建一个名为GeometricVisualization的新的 Java 类,并开始如下:

public class GeometricVisualization extends Visualization {
    static final String TAG = "GeometricVisualization";
    public GeometricVisualization(VisualizerBox visualizerBox) {
        super(visualizerBox);
    }
}

在类的顶部,声明一个立方体变换的Transform数组和相应的RenderObjects数组:

    Transform[] cubes;
    Cube[] cubeRenderers;

然后,在setup方法中初始化它们。我们将分配立方体数组,对齐并缩放为相邻的一组块,创建一个波浪状块的 3D 表示。setup方法可以实现如下:

    public void setup() {
        cubes = new Transform[VisualizerBox.captureSize / 2];
        cubeRenderers = new Cube[VisualizerBox.captureSize / 2];

        float offset = -3f;
        float scaleFactor = (offset * -2) / cubes.length;
        for(int i = 0; i < cubes.length; i++) {
            cubeRenderers[i] = new Cube(true);
            cubes[i] = new Transform()
                    .setLocalPosition(offset, -2, -5)
                    .addComponent(cubeRenderers[i]);
            offset += scaleFactor;
        }
    }

现在在每一帧上,我们只需要根据音频源中当前的波形数据(在VisualizerBox中获取)修改每个立方体的高度。实现preDraw方法如下:

    public void preDraw() {
        if (VisualizerBox.audioBytes != null) {
            float scaleFactor = 3f / cubes.length;
            for(int i = 0; i < cubes.length; i++) {
                cubes[i].setLocalScale(scaleFactor, VisualizerBox.audioBytes[i] * 0.01f, 1);
            }
        }
    }

    public void postDraw() {
    }

我们还需要为postDraw实现添加一个存根。然后,实例化可视化并使其成为活动状态。在MainActivity中,在onCreate的末尾,添加以下代码行:

        visualizerBox.activeViz = new GeometricVisualization(visualizerBox);

现在我们只需要这些。

在手机上播放一些音乐。然后运行应用程序。你会看到类似这样的东西:

基本几何可视化

正如你所看到的,我们在场景中保留了单位立方体,因为它有助于澄清发生了什么。每个音频数据都是一个薄的“切片”(或者是一个扁平的立方体),其高度随音频值的变化而变化。如果你正在查看前一个屏幕图像的彩色版本,你会注意到可视化立方体的彩色面就像孤立的立方体,因为它们使用相同的对象和材质进行渲染。

这个可视化是使用音频波形数据动态修改 3D 几何的一个非常基本的例子。让你的想象力奔放,创造属于你自己的。音频字节可以控制任何变换参数,包括比例、位置和旋转。记住我们在一个 3D 虚拟现实空间中,你可以使用所有这些——把你的东西四处移动,上下移动,甚至在你的身后。我们有一些基本的原始几何形状(立方体、球体、平面、三角形等)。但你也可以使用音频数据来参数化生成新的形状和模型。此外,你甚至可以集成前一章的ModelObject类来加载有趣的 3D 模型!

在下一个主题中,我们将看看如何在基于纹理的材质着色器中使用音频波形数据。

基于 2D 纹理的可视化

第二个可视化也将是基本的示波器类型显示波形数据。然而,以前我们使用音频数据来缩放 3D 切片立方体;这一次,我们将使用一个着色器,在 2D 平面上渲染它们全部,使用音频数据作为输入。

我们的RenderBox库允许我们定义新的材质和着色器。在以前的项目中,我们构建了使用位图图像进行纹理映射的材质,以便在渲染时将其渲染到几何图形上。在这个项目中,我们将使用音频字节数组来绘制四边形,使用字节值来控制设置更亮颜色的位置。(请注意,Plane类是在第七章中添加到RenderBox库中的,360 度画廊。)

纹理生成器和加载器

首先,让我们生成一个纹理结构来保存我们的纹理数据。在VisualizerBox类中,添加以下方法来设置 GLES 中的纹理。我们不能使用我们正常的纹理流程,因为它是设计为直接从图像数据中分配纹理。我们的数据是一维的,所以使用Texture2D资源可能看起来有点奇怪,但我们将高度设置为一个像素:

    public static int genTexture(){
        final int[] textureHandle = new int[1];
        GLES20.glGenTextures(1, textureHandle, 0);
        RenderBox.checkGLError("VisualizerBox GenTexture");
        if (textureHandle[0] != 0) {
            // Bind to the texture in OpenGL
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle[0]);
            // Set filtering
            GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
            GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST);
        }
        if (textureHandle[0] == 0){
            throw new RuntimeException("Error loading texture.");
        }
        return textureHandle[0];
    }

然后添加setup的调用,包括一个静态变量来保存生成的纹理句柄:

    public static int audioTexture = -1;

    public void setup() {
        audioTexture = genTexture();
        if(activeViz != null)
            activeViz.setup();
    }

现在我们可以从音频字节数据中填充纹理。在 Android 的Visualizer监听器中,在onWaveFormDataCapture方法中添加一个loadTexture的调用:

            public void onWaveFormDataCapture(Visualizer visualizer, byte[] bytes, int samplingRate){
                audioBytes = bytes;
                loadTexture(cardboardView, audioTexture, bytes);
            }

让我们按照以下方式定义loadTexture。它将音频字节复制到一个新的数组缓冲区,并将其传递给 OpenGL ES,使用glBindTextureglTexImage2D调用。

(参考stackoverflow.com/questions/14290096/how-to-create-a-opengl-texture-from-byte-array-in-android。):

    public static void loadTexture(CardboardView cardboardView, final int textureId, byte[] bytes){
        if(textureId < 0)
            return;
        final ByteBuffer buffer = ByteBuffer.allocateDirect(bytes.length * 4);
        final int length = bytes.length;
        buffer.order(ByteOrder.nativeOrder());
        buffer.put(bytes);
        buffer.position(0);
        cardboardView.queueEvent(new Runnable() {
            @Override
            public void run() {
                GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
                GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_LUMINANCE, length, 1, 0,
                        GLES20.GL_LUMINANCE, GLES20.GL_UNSIGNED_BYTE, buffer);
            }
        });
    }

波形着色器

现在是时候编写着色器程序了,这些程序将决定在Material类中需要设置的参数和属性,以及其他内容。

如果需要,为着色器创建一个资源目录,res/raw/。然后,创建waveform_vertex.shaderwaveform_fragment.shader文件。定义如下。

waveform_vertex.shader文件与我们之前使用的unlit_tex_vertex着色器相同。严格来说,我们可以重用这个文件,并在createProgram函数中指定它的资源,但是除非你明确遵循某种模式,否则最好定义单独的着色器文件。

文件:res/raw/waveform_vertex.shader

uniform mat4 u_MVP;
attribute vec4 a_Position;
attribute vec2 a_TexCoordinate;
varying vec2 v_TexCoordinate;
void main() {
   // pass through the texture coordinate
   v_TexCoordinate = a_TexCoordinate;
   // final point in normalized screen coordinates
   gl_Position = u_MVP * a_Position;
}

对于waveform_fragment着色器,我们添加了一个固定颜色(u_Color)和阈值宽度(u_Width)的变量。然后,添加一些逻辑来决定当前正在渲染的像素的y坐标是否在样本的u_Width范围内。

文件:res/raw/waveform_fragment.shader

precision mediump float;        // default medium precision
uniform sampler2D u_Texture;    // the input texture
varying vec2 v_TexCoordinate;   // interpolated texture coordinate per fragment
uniform vec4 u_Color;
uniform float u_Width;
// The entry point for our fragment shader.
void main() {
    vec4 color;
    float dist = abs(v_TexCoordinate.y - texture2D(u_Texture, v_TexCoordinate).r);
    if(dist < u_Width){
        color = u_Color;
    }
    gl_FragColor = color;
}

基本波形材质

现在我们为着色器定义Material类。创建一个名为WaveformMaterial的新的 Java 类,并将其定义如下:

public class WaveformMaterial extends Material {
    private static final String TAG = "WaveformMaterial";
}

为纹理 ID、边框、宽度和颜色添加材质变量。然后,添加着色器程序引用和缓冲区的变量,如下所示:

    static int program = -1; //Initialize to a totally invalid value for setup state
    static int positionParam;
    static int texCoordParam;
    static int textureParam;
    static int MVPParam;
    static int colorParam;
    static int widthParam;

    public float borderWidth = 0.01f;
    public float[] borderColor = new float[]{0.6549f, 0.8392f, 1f, 1f};

    FloatBuffer vertexBuffer;
    FloatBuffer texCoordBuffer;
    ShortBuffer indexBuffer;
    int numIndices;

现在我们可以添加一个构造函数。正如我们之前看到的,它调用了一个setupProgram辅助方法,该方法创建着色器程序并获取对其参数的引用:

    public WaveformMaterial() {
        super();
        setupProgram();
    }

    public static void setupProgram() {
        if(program > -1) return;
        //Create shader program
        program = createProgram( R.raw.waveform_vertex, R.raw.waveform_fragment );
        RenderBox.checkGLError("Bitmap GenTexture");

        //Get vertex attribute parameters
        positionParam = GLES20.glGetAttribLocation(program, "a_Position");
        RenderBox.checkGLError("Bitmap GenTexture");
        texCoordParam = GLES20.glGetAttribLocation(program, "a_TexCoordinate");
        RenderBox.checkGLError("Bitmap GenTexture");

        //Enable them (turns out this is kind of a big deal ;)
        GLES20.glEnableVertexAttribArray(positionParam);
        RenderBox.checkGLError("Bitmap GenTexture");
        GLES20.glEnableVertexAttribArray(texCoordParam);
        RenderBox.checkGLError("Bitmap GenTexture");

        //Shader-specific parameters
        textureParam = GLES20.glGetUniformLocation(program, "u_Texture");
        MVPParam = GLES20.glGetUniformLocation(program, "u_MVP");
        colorParam = GLES20.glGetUniformLocation(program, "u_Color");
        widthParam = GLES20.glGetUniformLocation(program, "u_Width");
        RenderBox.checkGLError("Waveform params");
    }

同样,我们添加一个setBuffers方法,供RenderObject组件(Plane)调用:

    public WaveformMaterial setBuffers(FloatBuffer vertexBuffer, FloatBuffer texCoordBuffer, ShortBuffer indexBuffer, int numIndices) {
        //Associate VBO data with this instance of the material
        this.vertexBuffer = vertexBuffer;
        this.texCoordBuffer = texCoordBuffer;
        this.indexBuffer = indexBuffer;
        this.numIndices = numIndices;
        return this;
    }

添加draw代码,它将从Camera组件中调用,以渲染在缓冲区中准备的几何图形(通过setBuffers)。draw方法如下所示:

    @Override
    public void draw(float[] view, float[] perspective) {
        GLES20.glUseProgram(program);

        // Set the active texture unit to texture unit 0.
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0);

        // Bind the texture to this unit.
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, VisualizerBox.audioTexture);

        // Tell the texture uniform sampler to use this texture in //the shader by binding to texture unit 0.
        GLES20.glUniform1i(textureParam, 0);

        Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.model, 0);
        Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, modelView, 0);
        // Set the ModelViewProjection matrix for eye position.
        GLES20.glUniformMatrix4fv(MVPParam, 1, false, modelViewProjection, 0);

        GLES20.glUniform4fv(colorParam, 1, borderColor, 0);
        GLES20.glUniform1f(widthParam, borderWidth);

        //Set vertex attributes
        GLES20.glVertexAttribPointer(positionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);
        GLES20.glVertexAttribPointer(texCoordParam, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer);

        GLES20.glDrawElements(GLES20.GL_TRIANGLES, numIndices, GLES20.GL_UNSIGNED_SHORT, indexBuffer);

        RenderBox.checkGLError("WaveformMaterial draw");
    }

还有一件事;让我们提供一个销毁现有材质的方法:

    public static void destroy(){
        program = -1;
    }

波形可视化

现在我们可以创建一个新的可视化对象。在visualizations/文件夹下,创建一个名为WaveformVisualization的新的 Java 类,并将其定义为extends Visualization

public class WaveformVisualization extends Visualization {
    static final String TAG = "WaveformVisualization";
    public WaveformVisualization(VisualizerBox visualizerBox) {
        super(visualizerBox);
    }
    @Override
    public void setup() {
    }
    @Override
    public void preDraw() {
    }
    @Override
    public void postDraw() {
    }
}

声明我们将要创建的Plane组件的变量:

    RenderObject plane;

setup方法中创建如下。将材质设置为新的WaveformMaterial,并将其位置设置为靠左:

    public void setup() {
        plane = new Plane().setMaterial(new WaveformMaterial()
                .setBuffers(Plane.vertexBuffer, Plane.texCoordBuffer, Plane.indexBuffer, Plane.numIndices));

        new Transform()
                .setLocalPosition(-5, 0, 0)
                .setLocalRotation(0, 90, 0)
                .addComponent(plane);
    }

现在在MainActivityonCreate中,用这个替换以前的可视化:

        visualizerBox.activeViz = new WaveformVisualization(visualizerBox);

当您运行项目时,您会得到这样的可视化:

波形可视化

FFT 可视化

对于下一个可视化,我们将引入使用 FFT 数据(而不是波形数据)。与前一个示例一样,我们将从数据动态生成纹理,并编写材质和着色器来渲染它。

捕获 FFT 音频数据

首先,我们需要将数据捕获添加到我们的VisualizerBox类中。我们将首先添加我们需要的变量:

    public static byte[] fftBytes, fftNorm;
    public static float[] fftPrep;
    public static int fftTexture = -1;

我们需要分配 FFT 数据数组,并且为此我们需要知道它们的大小。我们可以询问 Android Visualizer API 它能够给我们多少数据。现在,我们将选择最小的大小,然后分配数组如下:

    public VisualizerBox(final CardboardView cardboardView){
        . . .
        fftPrep = new float[captureSize / 2];
        fftNorm = new byte[captureSize / 2];
        ...

捕获 FFT 数据类似于捕获波形数据。但是在保存之前,我们将对其进行一些预处理。根据 Android Visualizer API 文档,(http://developer.android.com/reference/android/media/audiofx/Visualizer.html#getFft(byte[] getFfT函数提供以下指定的数据:

  • 捕获是 8 位幅度 FFT;覆盖的频率范围是 0(DC)到getSamplingRate()返回的采样率的一半

  • 捕获返回与捕获大小的一半加一相等的频率点的实部和虚部

注意

请注意,只有实部返回给第一个点(DC)和最后一个点(采样频率/2)。

返回的字节数组中的布局如下:

  • ngetCaptureSize()返回的捕获大小

  • RfkIfk分别是第k频率分量的实部和虚部

  • 如果FsgetSamplingRate()返回的采样频率,则第k频率为:(kFs)/(n/2)*

同样,我们将把传入的捕获数据准备成一个在 0 到 255 之间的归一化值数组。我们的实现如下。在OnDataCaptureListener实例中的onWaveFormDataCapture方法之后立即添加onFftDataCapture声明:

            @Override
            public void onFftDataCapture(Visualizer visualizer, byte[] bytes, int samplingRate) {
                fftBytes = bytes;
                float max = 0;
                for(int i = 0; i < fftPrep.length; i++) {
                    if(fftBytes.length > i * 2) {
                        fftPrep[i] = (float)Math.sqrt(fftBytes[i * 2] * fftBytes[i * 2] + fftBytes[i * 2 + 1] * fftBytes[i * 2 + 1]);
                        if(fftPrep[i] > max){
                            max = fftPrep[i];
                        }
                    }
                }
                float coeff = 1 / max;
                for(int i = 0; i < fftPrep.length; i++) {
                    if(fftPrep[i] < MIN_THRESHOLD){
                        fftPrep[i] = 0;
                    }
                    fftNorm[i] = (byte)(fftPrep[i] * coeff * 255);
                }
                loadTexture(cardboardView, fftTexture, fftNorm);
            }

请注意,我们的算法使用MIN_THRESHOLD值为 1.5 来过滤掉不重要的值:

    final float MIN_THRESHOLD = 1.5f;

现在在setup()中,用生成的纹理初始化fftTexture,就像我们对audioTexture变量做的那样:

    public void setup() {
        audioTexture = genTexture();
 fftTexture = genTexture();
        if(activeViz != null)
            activeViz.setup();
    }

FFT 着色器

现在我们需要编写着色器程序。

如果需要,为着色器创建一个资源目录res/raw/fft_vertex.shader与之前创建的waveform_vertext.shader相同,因此可以直接复制它。

对于fft_fragment着色器,我们添加了一些逻辑来决定当前坐标是否正在渲染。在这种情况下,我们没有指定宽度,只是渲染所有低于该值的像素。从某种角度来看,我们的波形着色器是一条线图(实际上是一个散点图),而我们的 FFT 着色器是一个条形图。

文件:res/raw/fft_fragment.shader

precision mediump float;        // default medium precision
uniform sampler2D u_Texture;    // the input texture

varying vec2 v_TexCoordinate;   // interpolated texture coordinate per fragment
uniform vec4 u_Color;

void main() {
    vec4 color;
    if(v_TexCoordinate.y < texture2D(u_Texture, v_TexCoordinate).r){
        color = u_Color;
    }
    gl_FragColor = color;
}

基本 FFT 材质

FFTMaterial类的代码与WaveformMaterial类的代码非常相似。因此,为了简洁起见,只需将该文件复制到一个名为FFTMaterial.java的新文件中。然后,修改如下。

确保类名和构造方法名称现在读作FFTMaterial

public class FFTMaterial extends Material {
    private static final String TAG = "FFTMaterial";
    ...

    public FFTMaterial(){
    ...

我们决定将borderColor数组更改为不同的色调:

    public float[] borderColor = new float[]{0.84f, 0.65f, 1f, 1f};

setupProgram中,确保您引用了R.raw.fft_vertexR.raw.fft_fragment着色器:

        program = createProgram( R.raw.fft_vertex, R.raw.fft_fragment);

然后,确保正在设置适当的特定于着色器的参数。这些着色器使用u_Color(但没有u_Width变量):

    //Shader-specific parameters
    textureParam = GLES20.glGetUniformLocation(program, "u_Texture");
    MVPParam = GLES20.glGetUniformLocation(program, "u_MVP");
    colorParam = GLES20.glGetUniformLocation(program, "u_Color");
    RenderBox.checkGLError("FFT params");

现在,在draw方法中,我们将使用VisualizerBox.fftTexture值进行绘制(而不是VisualizerBox.audioTexture),因此将调用GLES20.glBindTexture更改如下:

GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, VisualizerBox.fftTexture);

确保colorParam参数已设置(但与WaveformMaterial类不同,这里没有宽度参数):

GLES20.glUniform4fv(colorParam, 1, borderColor, 0);

FFT 可视化

现在我们可以为 FFT 数据添加可视化。在visualizations/文件夹中,将WaveformVisualization.java文件复制到一个名为FFTVisualization.java的新文件中。确保它定义如下:

    public class FFTVisualization extends Visualization {

在它的setup方法中,我们将创建一个Plane组件,并使用FFTMaterial类对其进行纹理处理,如下所示(还要注意修改位置和旋转值):

    public void setup() {
        plane = new Plane().setMaterial(new FFTMaterial()
                .setBuffers(Plane.vertexBuffer, Plane.texCoordBuffer, Plane.indexBuffer, Plane.numIndices));

        new Transform()
                .setLocalPosition(5, 0, 0)
                .setLocalRotation(0, -90, 0)
                .addComponent(plane);
    }

现在在MainActivityonCreate中,用这个替换以前的可视化:

visualizerBox.activeViz = new FFTVisualization(visualizerBox);

当你运行这个项目时,我们得到了一个像这样的可视化,旋转并定位到右边:

FFT 可视化

这个简单的例子说明了 FFT 数据将音频的空间频率分离成离散的数据值。即使不理解底层的数学(这是非常复杂的),通常只需要知道数据随着音乐的变化和流动。我们在这里使用它来驱动纹理映射。FFT 也可以像我们在第一个例子中使用波形数据一样,用来驱动场景中 3D 对象的属性,包括位置、比例和旋转,以及参数化定义的几何形状。事实上,它通常是更好的数据通道。每个条形图对应一个单独的频率范围,因此您可以指定某些对象对高频率和低频率做出响应。

迷幻轨迹模式

如果你渴望致幻的模拟,我们将在我们的可视化中引入一个“迷幻轨迹模式”!这个实现被添加到了RenderBox库本身。如果你正在使用已完成的RenderBox库,那么只需在你的应用程序中切换到这个模式。例如,在MainActivitysetup()中,在最后添加以下代码行:

        RenderBox.mainCamera.trailsMode = true;

要在你的RenderBox库的副本中实现它,打开 Android Studio 中的那个项目。在Camera类(components/Camera.java文件)中添加public boolean trailsMode

    public boolean trailsMode;

然后,在onDrawEye中,我们不再擦除新帧的屏幕,而是在整个帧上绘制一个全屏四边形,带有 alpha 透明度,从而留下上一帧的幽灵般的淡化图像。每个后续帧都会被更多的半透明黑色覆盖,导致它们随着时间的推移而淡出。定义颜色值如下:

public static float[] customClearColor = new float[]{0,0,0,0.05f};

然后,修改onDrawEye,使其如下所示:

    public void onDrawEye(Eye eye) {
 if(trailsMode) {
 GLES20.glEnable(GLES20.GL_BLEND);
 GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA);
 customClear(customClearColor);
 GLES20.glEnable(GLES20.GL_DEPTH_TEST);
 GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT);
 } else {
            GLES20.glEnable(GLES20.GL_DEPTH_TEST);
            GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);
        }

        ...

customClear方法跳过清除调用,保留上一帧的颜色。相反,它只是绘制一个半透明的全屏黑色四边形,略微变暗每一帧的“旧”图像。在我们这样做之前,相机需要一个着色器程序来绘制全屏的纯色。

fullscreen_solid_color_vertex.shader 如下所示:

attribute vec4 v_Position;

void main() {
   gl_Position = v_Position;
}

fullscreen_solid_color_fragment.shader 如下所示:

precision mediump float;
uniform vec4 u_Color;

void main() {
    gl_FragColor = u_Color;
}

现在回到Camera组件。我们设置程序并定义一个全屏四边形网格,缓冲区和其他变量。首先,我们定义我们需要的变量:

    static int program = -1;
    static int positionParam, colorParam;
    static boolean setup;
    public static FloatBuffer vertexBuffer;
    public static ShortBuffer indexBuffer;
    public static final int numIndices = 6;
    public boolean trailsMode;

    public static final float[] COORDS = new float[] {
            -1.0f, 1.0f, 0.0f,
            1.0f, 1.0f, 0.0f,
            -1.0f, -1.0f, 0.0f,
            1.0f, -1.0f, 0.0f
    };
    public static final short[] INDICES = new short[] {
            0, 1, 2,
            1, 3, 2
    };
    public static float[] customClearColor = new float[]{0,0,0,0.05f};

然后,定义一个设置程序的方法:

    public static void setupProgram(){
        if(program > -1)    //This means program has been set up //(valid program or error)
            return;
        //Create shader program
        program = Material.createProgram(R.raw.fullscreen_solid_color_vertex, R.raw.fullscreen_solid_color_fragment);

        //Get vertex attribute parameters
        positionParam = GLES20.glGetAttribLocation(program, "v_Position");

        //Enable vertex attribute parameters
        GLES20.glEnableVertexAttribArray(positionParam);

        //Shader-specific parameters
        colorParam = GLES20.glGetUniformLocation(program, "u_Color");

        RenderBox.checkGLError("Fullscreen Solid Color params");
    }

定义一个分配缓冲区的方法:

    public static void allocateBuffers(){
        setup = true;
        vertexBuffer = RenderObject.allocateFloatBuffer(COORDS);
        indexBuffer = RenderObject.allocateShortBuffer(INDICES);
    }

然后,从Camera初始化器中调用这些方法:

    public Camera(){
        transform = new Transform();
 setupProgram();
 allocateBuffers();
    }

最后,我们可以实现customClear方法:

    public static void customClear(float[] clearColor){
        GLES20.glUseProgram(program);
        // Set the position buffer
        GLES20.glVertexAttribPointer(positionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);
        GLES20.glUniform4fv(colorParam, 1, clearColor, 0);
        GLES20.glDrawElements(GLES20.GL_TRIANGLES, numIndices, GLES20.GL_UNSIGNED_SHORT, indexBuffer);
    }

重新构建RenderBox模块,并将库文件复制回VisualizeVR项目。不要忘记将trailsMode设置为true

现在当你运行这个应用程序时,它看起来很迷幻和酷!

迷幻轨迹模式

多个同时的可视化

现在我们有了一系列的可视化,我们可以增强应用程序,使其能够同时运行多个可视化,并在它们之间进行切换。

为了支持多个并发的可视化,用visualizations列表替换VisualizerBox中的activeViz变量。

public List<Visualization> visualizations = new ArrayList<Visualization|();

然后,在使用它的每个VisualizerBox方法中循环列表。我们总是希望设置所有这些,但只绘制(preDrawpostDraw)活动的可视化:

    public void setup() {
        audioTexture = genTexture();
        fftTexture = genTexture();
        for (Visualization viz : visualizations) {
            viz.setup();
        }
    }
    public void preDraw() {
        for (Visualization viz : visualizations) {
            viz.preDraw();
        }
    }
    public void postDraw() {
        for (Visualization viz : visualizations) {
            viz.postDraw();
        }
    }

我们可以在MainActivity中控制场景。修改MainActivity类的onCreate方法来填充visualizations列表,如下所示:

visualizerBox = new VisualizerBox(cardboardView);
visualizerBox.visualizations.add( new GeometricVisualization(visualizerBox));
visualizerBox.visualizations.add( new WaveformVisualization(visualizerBox));
visualizerBox.visualizations.add( new FFTVisualization(visualizerBox));

运行项目,我们有一个充满可视化的 3D 场景!

多个同时可视化

随机可视化

我们可以通过随着时间的推移添加和删除可视化来在可视化之间切换。在以下示例中,我们从一个活动可视化开始,然后每隔几秒切换一个随机可视化。

首先,在抽象的Visualization类中添加一个activate方法,它接受一个布尔类型的enabled参数。布尔类型的active变量是只读的:

    public boolean active = true;
    public abstract void activate(boolean enabled);

其实现将取决于具体的可视化。RenderBox库提供了一个enabled标志,用于渲染对象。最容易的是那些实例化单个Plane组件的可视化,比如WaveformVisualizationFFTVisualization。对于这些,添加以下代码:

    @Override
    public void activate(boolean enabled) {
        active = enabled;
        plane.enabled = enabled;
    }

对于GeometricVisualization类,我们可以启用(和禁用)每个组件立方体:

    @Override
    public void activate(boolean enabled) {
        active = enabled;
        for(int i = 0; i < cubes.length; i++) {
            cubeRenderers[i].enabled = enabled;
        }
    }

现在我们可以在MainActivity类中控制这个。

从不活动的visualizations开始。将此初始化添加到MainActivitysetup()中:

        for (Visualization viz : visualizerBox.visualizations) {
            viz.activate(false);
        }

MainActivitypreDraw中,我们将检查当前时间(使用RenderBox库的Time类),并在每 3 秒后切换一个随机可视化。首先,在类的顶部添加一些变量:

    float timeToChange = 0f;
    final float CHANGE_DELAY = 3f;
    final Random rand = new Random();

现在我们可以修改preDraw来检查时间并修改visualizations列表:

    public void preDraw() {
        if (Time.getTime() > timeToChange) {
            int idx = rand.nextInt( visualizerBox.visualizations.size() );
            Visualization viz = visualizerBox.visualizations.get(idx);
            viz.activate(!viz.active);
            timeToChange += CHANGE_DELAY;
        }
        visualizerBox.preDraw();
    }

类似的时间控制结构(或增量时间)可以用于实现许多种动画,比如改变可视化对象的位置、旋转和/或比例,或者随着时间的推移演变几何本身。

进一步的增强

我们希望我们给了你一些工具,让你开始制作自己的音乐可视化。正如我们在本章中所建议的,选项是无限的。不幸的是,空间限制我们在这里编写更多有趣的代码。

  • 动画:我们对每个可视化应用了最简单的变换:简单的位置、比例,也许是 90 度的旋转。当然,位置、旋转和比例可以进行动画处理,即与音乐协调更新每一帧,或者独立于音乐使用Time.deltaTime。东西可以在你周围虚拟飞来飞去!

  • 高级纹理和着色器:我们的着色器和数据驱动纹理是最基本的:基本上渲染与音频字节值对应的单色像素。音频数据可以输入到更复杂和有趣的算法中,以生成新的图案和颜色,或者用于变形预加载的纹理。

  • 纹理映射:项目中的纹理材料只是映射到一个平面上。嘿,伙计,这是虚拟现实!将纹理映射到一个全景照片或其他几何图形上,完全沉浸你的用户。

  • 渲染到纹理:我们的轨迹模式对于这些可视化看起来还不错,但对于任何足够复杂的东西可能会变得一团糟。相反,您可以将其专门用于纹理平面的表面。设置 RTs 是复杂的,超出了本书的范围。基本上,您向场景引入另一个摄像机,指示 OpenGL 将后续绘制调用渲染到您创建的新表面,并将该表面用作要渲染到其上的对象的纹理缓冲区。RT 是一个强大的概念,可以实现反射和游戏内安全摄像头等技术。此外,您可以对表面应用变换,使轨迹看起来飞向远处,这是传统可视化器(如 MilkDrop)中的一种受欢迎的效果。

  • 参数几何:音频数据可以用来驱动定义和渲染各种复杂度的 3D 几何模型。想想分形、晶体和 3D 多面体。参考 Goldberg 多面体(参考schoengeometry.com/)和神圣几何(参考www.geometrycode.com/sacred-geometry/)以获取灵感。

社区邀请

我们邀请您与本书的其他读者和 Cardboard 社区分享您自己的可视化效果。一种方法是通过我们的 GitHub 存储库。如果您创建了一个新的可视化效果,请将其作为拉取请求提交到项目github.com/cardbookvr/visualizevr,或者创建您自己的整个项目的分支!

总结

在本章中,我们构建了一个作为 Cardboard VR 应用程序运行的音乐可视化器。我们设计了一个通用架构,让您可以定义多个可视化效果,将它们插入到应用程序中,并在它们之间进行过渡。该应用程序使用 Android 的Visualization API 从手机当前的音频播放器中捕获波形和 FFT 数据。

首先,我们定义了VisualizerBox类,负责与 Android 的Visualizer API 的活动和回调函数。然后,我们定义了一个抽象的Visualization类来实现各种可视化效果。然后,我们将波形音频数据捕获到VisualizerBox中,并使用它来参数化地动画化一系列立方体,制作成一个 3D 波浪箱。接下来,我们编写了第二个可视化器;这次使用波形数据动态生成纹理,并使用材质着色器程序进行渲染。最后,我们捕获了 FFT 音频数据,并用它进行了第三个可视化。然后,我们增加了更多有趣的内容,比如迷幻轨迹模式和多个并发的可视化效果,随机进行过渡。

我们承认,视觉示例非常简单,但希望它们能激发您的想象力。我们挑战您构建自己的 3D 虚拟现实音乐可视化,也许利用了这个项目中的技术以及本书中的其他内容。

前往未来

我们希望您喜欢这个介绍和通过 Cardboard 虚拟现实开发的旅程。在整本书中,我们探索了 Google Cardboard Java SDK,OpenGL ES 2.0 图形和 Android 开发。我们涉及了许多 VR 最佳实践,并看到了在移动平台上进行低级图形开发的局限性。但是,如果您跟随我们的步伐,您已经成功地实现了一个合理的通用 3D 图形和 VR 开发库。您创建了各种各样的 VR 应用程序,包括应用程序启动器、太阳系模拟、360 度媒体画廊、3D 模型查看器和音乐可视化器。

自然地,我们期待 Cardboard Java SDK 从此时开始发生变化、演变和成熟。没有人真正知道未来会发生什么,甚至包括谷歌在内。然而,我们现在站在一个崭新未来的悬崖边。预测未来的最好方法就是帮助创造它。现在轮到你了!

posted @ 2024-05-22 15:12  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报