安卓-Processing-教程-全-

龙哥盟 / 2024-10-06 / 原文

安卓 Processing 教程(全)

原文:Processing for Android

协议:CC BY-NC-SA 4.0

一、Android 模式入门

在本章中,我们将介绍处理软件和 Android 模式,它们背后的社区项目,以及我们如何开始使用该模式为 Android 设备创建应用。

加工项目是什么?

Processing project 是一个社区项目,致力于分享知识、促进教育和促进基于代码的艺术和设计的多样性。处理软件是这一举措的核心部分,现在由处理基金会( https://processingfoundation.org/ )指导。该处理软件由麻省理工学院媒体实验室的 Casey Reas 和 Ben Fry 于 2001 年创建,作为计算艺术和设计的教学和制作工具,并从那时起一直在不断发展。可在 https://processing.org/ 下载,其源代码在自由软件许可(GPL 和 LGPL)下发布。从现在开始,我在谈加工软件的时候,就简单的指加工。

处理包括两个互补的部分:语言和开发环境。它们共同组成了一个“软件草图”,旨在允许用代码快速表达视觉想法,同时还提供足够的空间让这些想法发展成为成熟的项目。处理已经被用来在生成艺术、数据可视化和交互式装置中创建许多美丽和鼓舞人心的作品,其中一些被包括在处理网站( https://processing.org/exhibition/ )上的策划列表中。

Processing 语言

Processing 语言包括一组用于处理屏幕绘图、数据输入/输出和用户交互的功能。处理项目( https://processing.org/people/ )背后的一个小型志愿者团队精心构建了这组功能,技术上称为应用接口或 API,通过简单一致的命名约定、明确的行为和明确定义的范围来简化图形和交互式应用的开发。虽然最初是在 Java 中实现的,但处理 API 目前可以在许多编程语言中使用,包括 Python、JavaScript 和 r。然而,正是这个 API 的 Java 实现,以及对 Java 语言的一些简化,定义了 Processing 语言。尽管有这种区别,但在整本书中,我将互换使用术语 Processing 语言和 API,因为在 Android 的上下文中,我们本质上将使用处理 API 的 Java 实现。

在 2001 年以来的积极发展中,Processing 语言现在不仅包含函数之间的大约 300 项,还包含类和常量( https://processing.org/reference/ )。这种语言的一个定义性特征是,它提供了用很少的代码创建一个能够显示交互式图形的程序的可能性。正如我所提到的,它还包含了许多关于 Java 语言的简化,目的是让不熟悉计算机代码的人更容易学习。下面的程序举例说明了 Processing 语言的这些特性:

color bg = 150;

void setup() {
  size(200, 200);
}

void draw() {
  background(bg);
  ellipse(mouseX, mouseY, 100, 100);
}

这个程序的输出是一个 200×200 像素的窗口,其中包含一个跟随鼠标移动的白色圆圈;该窗口的背景是灰色的。函数setup()draw()几乎出现在任何加工程序中,并驱动其“绘图循环”程序的所有初始化都应该在setup()中进行,在程序启动时只执行一次。然后,包含所有绘图指令的draw()函数每秒被连续调用几次(默认情况下,60 次),这样程序的图形输出就可以随时间而变化。

但是,如果您熟悉 Java,您可能已经注意到这段代码不是有效的 Java 程序。例如,没有封装所有代码的主类的显式定义,也没有 Java 中初始化处理显示和用户输入的“窗口工具包”所需的附加指令。这个程序需要在处理开发环境中运行,处理开发环境对处理代码应用“预处理”步骤,以便将其转换成有效的 Java 程序。然而,这种转换发生在幕后,处理用户根本不需要担心。

处理开发环境

处理开发环境(PDE)是一个应用,它为我们提供了一个简化的代码编辑器来编写、调试和运行处理程序,称为草图(图 1-1 )。PDE 还整合了一个整洁的用户界面,以处理用它创建的所有草图,并添加库和其他扩展 PDE 核心功能的外部组件,如 p5.js、Python 或 Android 模式。

A432415_1_En_1_Fig1_HTML.jpg

图 1-1。

The Processing development environment showing a running sketch in Java mode

PDE 和 Processing 语言的简单易用是这本“代码速写本”的关键要素对于许多想开始从事代码工作的人来说,一个绊脚石是现代开发环境的复杂性,比如 Eclipse 或 IntelliJ,就冗长的安装和压倒性的用户界面而言。相比之下,PDE 通过提供简单的安装过程和最小化的界面来解决这些问题,而加工草图的简单结构使用户能够快速获得视觉反馈。Processing 的目标是支持类似于用笔和纸画草图的迭代开发过程,在这个过程中,人们可以从一个简单的想法开始,并通过连续的草图来完善它。

Note

处理 API 可以在 PDE 之外使用;例如,在更高级的集成开发环境(IDE)中,如 Eclipse、NetBeans 或 IntelliJ。当使用这些 ide 编写程序时,Processing 的所有绘图、数据和交互 API 都是可用的;然而,Processing 语言对于 Java 的简化将会丢失。

我们可以从主网站( https://processing.org/download )下载最新版本的处理。正如上一段所指出的,安装相当简单,只需要打开。zip(在 Windows 和 Mac 上)或。包含 PDE 和所有其他核心文件的 tgz(在 Linux 上)包。然后,我们应该能够从HomeApplications文件夹中的任何位置运行 PDE,而不需要任何额外的步骤。

PDE 在 sketchbook 文件夹中组织用户草图。每个草图都存储在 sketchbook 内的子文件夹中,而 sketchbook 又包含一个或多个带有。pde 扩展。默认情况下,处理会在用户帐户中的Documents文件夹内创建 sketchbook 文件夹(例如,Mac 上的/Users/andres/Documents/Processing),但是可以通过在首选项窗口中选择所需的 sketchbook 文件夹来更改该位置,该窗口位于 Mac 上的处理菜单下以及 Windows 和 Linux 上的文件菜单下(图 1-2 )。请注意顶部的素描本位置。

A432415_1_En_1_Fig2_HTML.jpg

图 1-2。

The Preferences window on Mac

延伸加工

正如我在开始提到的,处理项目不仅是 PDE 或语言,而且,非常重要的是,围绕软件的使用和共享、教学和包容性的目标建立的社区。由于 Processing 的开放性和模块化架构,许多人对“核心”软件做出了改进和扩展。这些贡献属于以下四类之一:

  • 库:模块(包含一个或多个构建在 jar 包中的 Java 代码文件,以及附加的文档和示例文件),使得在草图中访问新的功能成为可能。例如,用于计算机视觉的 OpenCV 库(仅适用于 PC/Mac),或用于 Android 传感器的 Ketai(在第 7 和 8 章节中介绍)。
  • 编程模式:可选的代码编辑器和相关的 PDE 定制,允许在 PDE 中使用完全不同的语言。例如安卓模式。我们将在本章的下一节看到如何安装 Android 模式。
  • 工具:只能从处理中运行的应用,提供特定的功能来帮助编写代码、调试和测试草图。例如拾色器(在第二章中讨论)。
  • 示例:可以用作学习材质或参考的贡献代码草图包。如丹尼尔·希夫曼( http://learningprocessing.com/ )的《学习处理》一书中的草图。

通过贡献的库、模式、工具和示例进行处理的扩展,使其能够扩展到不属于原始软件的应用领域,如移动应用、计算机视觉和物理计算,同时保持核心功能的简单性和对新程序员的可访问性。

投稿经理

默认情况下,处理包括一种默认模式,Java,在这种模式下,我们可以使用 Processing 语言的 Java 实现在 Windows、Mac 和 Linux 计算机上编写和运行草图。Processing 还捆绑了几个“核心”库,其中一些是 OpenGL(用于绘制硬件加速的 2D 和 3D 场景)、pdf(将图形导出为 pdf 文件)和 data(允许处理 CSV 和 JSON 等格式的数据文件)。

为了安装额外的贡献,我们可以使用贡献管理器(CM ),它使得这个过程无缝。CM 的截图如图 1-3 所示。CM 有五个选项卡,前四个用于每种贡献类型——库、模式、工具和示例——第五个用于更新。作者在中央存储库中注册的所有贡献都可以通过 CM 访问,并且在有新版本可用时也可以通过 CM 更新。

A432415_1_En_1_Fig3_HTML.jpg

图 1-3。

The Contribution Manager in Processing, showing the Modes tab Note

作者没有注册的贡献,因此不能通过 CM 使用,仍然可以手动安装。我们需要下载包含库、模式、工具或示例的包,通常是 zip 格式,并将其解压缩到 sketchbook 文件夹中。库、模式、工具和示例都有单独的子文件夹。更多信息见 https://processing.org/reference/libraries/

Android 处理

与处理软件本身一样,Android 的处理也有几个方面。首先,它是一个始于 2009 年的社区工作,旨在支持使用 Processing 开发 Android 应用,并将该项目的一些概念转化为移动应用的上下文:迭代草图、简单性和可访问性。

从软件的角度来看,Processing for Android 由 processing-android 库和自定义 PDE 编程模式本身组成。该库是包含处理 API 的所有功能的包,但针对 Android 平台进行了重新实现。Android mode 提供了 PDE 的定制版本,允许我们编写处理代码,并在 Android 设备或仿真器上运行。Android mode 包括 processing-android 库,我们需要它来运行我们的处理代码而不出错。然而,这些区别在这一点上并不重要,因为处理将让我们安装和使用 Android 模式,而不必担心处理-android 库。对于那些打算在更高级的应用中使用 Android 处理的人来说,这个库将变得更加重要。

Note

处理-android 库可以从像 android Studio 这样的 IDE 中导入,允许使用常规 Android 应用中的所有处理功能。附录 a 中介绍了这种高级用法。

安装 Android 模式

一旦我们在计算机上安装了 Processing,我们应该能够通过运行 Processing 应用打开 PDE,然后我们可以通过 CM 安装最新版本的 Android mode。该模式还需要 Android 软件开发工具包(SDK)才能工作。Android SDK 是 Google 为开发和调试 Android 应用而提供的一组库、工具、文档和其他支持文件。因此,要安装 Android 模式和 SDK(如果需要的话),请遵循以下步骤:

  • If a valid SDK is detected on the computer, Processing will ask if we want to use it or download a new one (Figure 1-5). Because the SDK is very large (up to several GBs), it can be a good idea to use the one that is already installed to save disk space. However, if that SDK is also used by another development tool, such as Android Studio, it may get updated outside Processing, which may lead to incompatibilities with the mode.

    A432415_1_En_1_Fig5_HTML.jpg

    图 1-5。

    Choosing between using an existing SDK or downloading a new one automatically (top), and between locating an SDK manually or downloading one automatically (bottom)

  • 如果没有检测到有效的 Android SDK,处理将要求手动定位 SDK 或自动下载 SDK(图 1-5 )。

  1. Open the CM by clicking the “Add Mode…” option that appears in the drop-down menu in the upper-right corner of the PDE (Figure 1-4).

    A432415_1_En_1_Fig4_HTML.jpg

    图 1-4。

    Opening the Contribution Manager to add a new mode

  2. 在模式选项卡中选择 Android 模式条目,然后单击安装按钮。

  3. 安装完成后,关闭 CM,使用图 1-4 中相同的下拉菜单切换到 Android 模式。

Note

安卓模式 4.0 版本需要安卓 8.0 版本(奥利奥),对应 API 级( https://source.android.com/source/build-numbers )。该模式的自动 SDK 下载将从谷歌服务器检索这个版本。

Android 模式的预发布版本以及旧的、不受支持的版本不再可以通过 CM 获得,而是存放在 GitHub 发布页面( https://github.com/processing/processing-android/releases )上,可以通过下载相应的文件并将其解压缩到 Processing 的 sketchbook 中的Modes文件夹中来手动安装。

Android 模式的界面

Android 模式下的编辑器和 Java 模式下的编辑器非常相似。工具栏包含播放和停止按钮,用于启动草图和停止草图的执行(在设备上或模拟器中)。编辑器中的代码自动完成功能也是可用的。但是,Android mode 的 4.0 版本没有提供集成的调试器。主菜单还包含许多 Android 特有的选项(图 1-6 )。“文件”菜单中有选项可以将当前草图导出为一个可上传到谷歌 Play 商店的包,或者导出为一个可以用 Android Studio 打开的项目。“草图”菜单包含在设备上或模拟器中运行草图的独立选项,以及包含多个选项的独立 Android 菜单,其中包括草图的目标输出类型(常规应用、壁纸、watch face 或 VR 应用)以及当前连接到计算机的 Android 设备列表。所有这些选项将在后续章节中介绍。

A432415_1_En_1_Fig6_HTML.jpg

图 1-6。

Android-specific options in the interface of Android mode

在设备上运行草图

一旦我们用 PDE 编写了一些草图代码,我们就可以在 Android 手机、平板电脑或手表上运行它。我们需要首先确保为我们的设备打开“USB 调试”。这样做的过程因设备和设备上安装的 Android 操作系统版本而异。在大多数情况下,此设置位于“系统设置”下的“开发人员选项”中。在 Android 4.2 和更高版本中,默认情况下开发者选项是隐藏的,但是我们可以按照以下说明来启用它们:

  1. 打开设置应用。
  2. 滚动到底部,选择“关于手机”
  3. 滚动到底部并轻按“构件号”七次。
  4. 返回上一个屏幕,在底部找到开发者选项。

打开 USB 调试后(我们只需要做一次),我们必须通过 USB 端口将设备连接到计算机。然后,处理将尝试识别它,并将其添加到 Android 菜单中的可用设备列表中。

Note

安卓模式 4.0 版本只支持运行安卓 4.2(果冻豆,API 级)或更新版本的设备。

让我们使用清单 1-1 中的代码作为我们对 Android sketch 的第一次处理!理解其中的每一行代码并不重要,因为我们将在接下来的章节中详细介绍处理 API。这段代码只是在接受触摸按压的那一半屏幕上画了一个黑色方块。

void setup() {
  fill(0);  
}

void draw() {
  background(204);
  if (mousePressed) {
    if (mouseX < width/2) rect(0, 0, width/2, height);
    else rect(width/2, 0, width/2, height);
  }
}

Listing 1-1.Our First Processing for Android Sketch

可以将多个设备同时连接到计算机,但在“设备”菜单中只能选择一个作为“活动”设备,我们的草图将在这里安装和运行。图 1-7 显示了我们已经加载到 PDE 中的第一个草图,以及运行它所选择的设备。

A432415_1_En_1_Fig7_HTML.jpg

图 1-7。

Selecting the device to run the sketch on

在我们选择了活动设备后,我们可以点击运行按钮或选择草图菜单下的“在设备上运行”。我们应该看到一些消息向下滚动到 PDE 的控制台,同时处理编译草图,将其打包为调试应用,并将其安装到设备上。一个重要的细节是,第一次运行草图时,计算机需要连接到互联网。Processing 使用一个名为 Gradle 的工具从草图的源代码中构建应用。Android mode 自带“Gradle wrapper”,所以我们不需要手动安装 Gradle,但是 wrapper 会在第一次调用时自动下载 Gradle 工具的其余部分。我们可以在第一次运行草图后离线。如果一切顺利,草图应该启动并显示在设备的屏幕上,如图 1-8 所示。

A432415_1_En_1_Fig8_HTML.jpg

图 1-8。

Running a sketch on a connected phone Note

如果我们运行的是 Windows,就需要安装一个专门的 USB 驱动来连接设备( https://developer.android.com/studio/run/oem-usb.html )。如果我们在处理过程中自动下载了 Android SDK,那么 Nexus 设备的最新谷歌 USB 驱动程序将会在sketchbook文件夹内的android\sdk子文件夹下;如C:\Users\andres\Documents\Processing\android\sdk\extras\google\usb_driver

如果我们运行的是 Linux,我们可能需要安装一些额外的包( https://developer.android.com/studio/run/device.html )。此外,确保 USB 连接没有配置为“仅充电”

在模拟器中运行草图

如果我们没有运行草图的设备,我们可以使用模拟器。仿真器是一种创建物理设备的软件副本的程序。这个复制品被称为 Android 虚拟设备(AVD),尽管它通常比真实设备慢,但它可以用于在我们目前没有的硬件上测试草图。

我们第一次在仿真器中运行草图时,处理将下载包含在我们的计算机上创建 AVD 所需的所有信息的系统映像(图 1-9 )。但是,它最初会询问我们是要使用“ARM”还是“x86”映像。之所以会这样,是因为安卓设备用的是 ARM CPUs,而台式电脑用的是 x86 处理器。使用带有 ARM 映像的 AVD 时,仿真器会将 ARM 指令逐个转换为 x86 指令,速度较慢。但是如果我们使用 x86 映像,我们计算机中的 CPU 将能够更直接、更快速地模拟 AVD 的 CPU。使用 x86 映像的一个缺点是,我们必须在 Mac 或 Windows 上安装名为 HAXM 的附加软件。由于下载的 HAXM 和 SDK 一起处理,它会为我们安装它,以防我们决定使用 x86 映像。

A432415_1_En_1_Fig9_HTML.jpg

图 1-9。

System image download dialog in Android mode

我们还必须记住,HAXM 只与 Intel 处理器兼容,所以如果我们的计算机有 AMD CPU,模拟器就不能处理 x86 映像。Linux 有自己的 AVD 加速系统,不需要 HAXM,所以我们可以在配有 AMD CPU 的 Linux 电脑上使用 x86 镜像。不过我们需要执行一些额外的配置步骤,这里描述了: https://developer.android.com/studio/run/emulator-acceleration.html#vm-linux

下载完成后,可能需要几分钟,这取决于互联网连接(仿真器的系统映像大小约为 900 MB),处理将启动仿真器,然后在其中启动草图。一旦我们的清单 1-1 在模拟器中运行,它应该如图 1-10 所示。

A432415_1_En_1_Fig10_HTML.jpg

图 1-10。

Running our sketch in the emulator

摘要

在第一章中,我们已经了解了什么是处理项目和软件,以及我们如何通过 Android 编程模式使用处理来创建应用。正如我们所看到的,处理软件的一些主要特征是它的最小界面和代码项目的简单结构,它被称为草图。这些特性让我们可以非常快速地在设备或仿真器上开始编写和测试自己的草图。

二、Processing 语言

如果您不熟悉 Processing 语言,请阅读本章,了解 2D 形状的创建、几何变换和颜色的使用,以及如何处理触摸屏输入。本章以一个绘制草图的分步示例结束,我们将在第三章中使用它来学习如何将通过处理创建的应用导出和上传到谷歌 Play 商店。

艺术家和设计师的编程速写本

正如我们在前一章中所学的,Processing 语言,结合 PDE,使编程新手更容易开始创建交互式图形。这种语言被设计成最小化、简单易学,但又有足够的表现力来创建不同领域的基于代码的项目:生成艺术、数据可视化、声音艺术、电影、表演等等。它包括大约 200 个不同类别的函数——绘图、交互、排版等等——以及几个帮助处理表单、颜色和数据的类。

我们也可以将处理视为“编码素描本”,类似于我们用来快速起草和提炼想法的纸质素描本。这个类比的一个重要部分是,与纸质速写本一样,处理使我们能够尽快从代码中获得视觉反馈。下一节将描述处理过程中的基本结构,它使我们能够轻松地在屏幕上生成动画输出。

加工草图的设置/绘制结构

在大多数情况下,我们需要我们的处理草图连续运行,以便在屏幕上显示动画图形并跟踪用户输入。我们可以使用一个基本的代码结构来实现这样的交互式草图,其中我们首先在一个setup()函数中执行所有的初始化操作,然后在每次处理需要渲染一个新帧时运行一个draw()函数。

Note

本章中的所有代码示例都可以在 Java 或 Android 模式下运行,因为它们不依赖于特定于任何一种模式的 Processing 语言的任何特性。此外,因为这本书的一个要求是对编程语言有一定程度的了解,所以我们不会讨论编程的基本概念(例如,条件、循环、注释)。

有了这个结构,我们可以创建一个每秒更新固定次数的动画,默认情况下是 60 次。在对draw()函数的每次调用中,我们不仅需要绘制构成合成的可视元素,还需要执行更新合成所需的所有计算。例如,在清单 2-1 中,我们使用函数line(x, 0, x, height)绘制了一条从左向右水平穿过屏幕的垂直线。线的水平位置包含在变量x中,我们用x = (x + 1) % width在每一帧中更新它。在这一行代码中,我们将x加 1,然后计算屏幕宽度模的结果。由于“ab取模”定义为a除以b的整数的余数(例如9 % 41),所以结果只能是 0 到 b - 1 之间的数。因此,我们草图中的x不能小于 0,也不能大于 width - 1,这正是我们所需要的:x一次递增 1 个单位,到达右边缘后折回 0。该草图的输出如图 2-1 所示。

A432415_1_En_2_Fig1_HTML.jpg

图 2-1。

Output of the animated line sketch

int x = 0;

void setup() {
  size(600, 200);
  strokeWeight(2);
  stroke(255);  
}

void draw() {
  background(50);
  line(x, 0, x, height);
  x = (x + 1) % width;
}

Listing 2-1.A Sketch That Draws a Vertical Line Moving Horizontally Across the Screen

处理以每秒 60 帧的默认帧率调用draw()函数;然而,我们可以使用函数frameRate(int fps)来改变这个默认值。例如,如果我们在setup(),中添加frameRate(1),草图将每秒绘制 1 帧。

有时,我们可能需要在几帧之后停止处理动画。我们可以分别使用noLoop()loop()函数来停止和恢复动画。处理过程中有一个名为looping的布尔(逻辑)变量,根据草图是否运行动画循环,该变量为真或为假。我们可以在前面的代码中添加简单的击键检测功能来停止/恢复草图,这在清单 2-2 中实现。

int x = 0;

void setup() {
  size(600, 200);
  strokeWeight(2);
  stroke(255);  
}

void draw() {
  background(50);
  line(x, 0, x, height);
  x = (x + 1) % width;
}

void keyPressed() {
  if (looping) {
    noLoop();
  } else {
    loop();
  }
}

Listing 2-2.Pausing/Resuming the Animation Loop

除了这些交互式草图,我们还可以创建没有设置/绘制的静态草图,如果我们只想生成不需要更新的固定组合,这通常很有用。处理只运行这些草图中的代码一次。清单 2-3 包含一个简单的静态草图,画出了图 2-2 中看到的白色圆圈。

A432415_1_En_2_Fig2_HTML.jpg

图 2-2。

Output of the static sketch

size(400, 400);
ellipse(200, 200, 150, 150);
Listing 2-3.Static Sketch Without setup() and draw() Functions

用代码绘图

上一节中的例子指出了基于代码的绘制中的一些重要概念。首先,我们需要指定想要在屏幕上绘制的元素的坐标;二是有函数,比如line(),可以让我们通过设置定义形状的适当数值,来绘制各种图元或形状;第三,我们可以设置这些形状的视觉“样式”(例如,笔画颜色和粗细)。

在处理过程中,我们可以绘制不同种类的形状(点、线、多边形)和特定的属性(笔画粗细和颜色、填充颜色等)。这些属性可以被认为是“样式参数”,一旦设置,就会影响之后绘制的所有内容。例如,清单 2-4 中的每个圆圈有不同的填充颜色,但是如果我们注释掉第二个fill()调用,第一个和第二个圆圈将是红色的,因为在开始时设置的填充颜色会影响前两个ellipse调用。图 2-3 显示了该草图在这些情况下的输出。

A432415_1_En_2_Fig3_HTML.jpg

图 2-3。

Effect of the calling order of style functions

size(460, 200);
strokeWeight(5);
fill(255, 0, 0);
stroke(0, 255, 0);
ellipse(100, 100, 200, 200);
fill(255, 0, 200); // Comment this line out to make second circle red
stroke(0);
ellipse(250, 100, 100, 100);
fill(0, 200, 200);
ellipse(380, 100, 160, 160);
Listing 2-4.Setting Style Attributes

屏幕坐标

处理将其图形输出绘制到一个矩形像素网格中,沿水平方向(x 轴)从 0 到 width–1,沿垂直方向(y 轴)从 0 到 height–1,如图 2-4 所示。当在 Java 模式下运行代码时,该网格将包含在一个单独的输出窗口中,或者当使用 Android 模式时,该网格将位于设备屏幕的中心。

A432415_1_En_2_Fig4_HTML.jpg

图 2-4。

Diagram of the screen’s pixels

在用 Processing 绘图时,我们需要记住 x 坐标是从左到右的,而 y 坐标是从上到下的。因此,像素(0,0)表示屏幕的左上角,像素(width-1,height-1)表示右下角。大多数 2D 绘图函数在处理过程中的参数指的是屏幕的像素坐标。例如,下面的示例代码将产生如图 2-5 所示的输出(为清晰起见,其中每个方块代表一个像素)。

A432415_1_En_2_Fig5_HTML.jpg

图 2-5。

Pixels covered by a stroked rectangle in Processing

stroke(200, 0, 0);
fill(100, 200, 100);
rect(2, 1, width – 1, height - 2);

我们应该根据屏幕大小的限制来调整我们用 Processing 绘制的形状的大小。一般情况下,参考屏幕尺寸时,建议使用widthheight内部变量,而不是实际值;这样,我们可以重新调整尺寸,而不必修改绘图代码,就像清单 2-5 中所做的那样。

Size(800, 800);
stroke(0);
fill(180);
background(97);
line(width/2, 0, width/2, height);
line(0, height/2, width, height/2);  
rect(0, 0, 200, 200);
rect(width – 200, 0, 199, 200);
rect(width – 200, height – 200, 199, 199);
rect(0, height – 200, 199, 199);
rect(200, 200, width – 400, height – 400);
Listing 2-5.Using screen coordinates.

在这段代码中,一些矩形的宽度/高度不寻常,为 199。这就是为什么屏幕外边框上的线条是可见的,因为正如我们刚刚看到的,最后一行/列像素的 x 坐标是 height-1/width-1。草图的输出,所有外部笔画都落在边缘像素上,如图 2-6 所示,就像它出现在 Nexus 5X 手机上一样。您还会注意到,这个输出只占据了屏幕中心 800 × 800 的正方形,因为这是我们在代码中指定的大小。我们将在本章后面看到如何使用整个屏幕,在第四章我们将看到如何根据设备的分辨率缩放图形。

A432415_1_En_2_Fig6_HTML.jpg

图 2-6。

Output of code Listing 2-5, on a Nexus 5X phone

形式

我们可以通过处理生成的所有视觉形式都被绘制成二维或三维形状。通常,我们通过在beginShape()endShape()函数中显式指定定义其边界的所有顶点来构造这些形状,如清单 2-6 所示(其输出如图 2-7 所示)。

A432415_1_En_2_Fig7_HTML.jpg

图 2-7。

Composition created with several shapes

size(600, 300);  

beginShape(QUADS);
vertex(5, 250);
vertex(590, 250);
vertex(590, 290);
vertex(5, 290);
endShape();

beginShape();
vertex(30, 25);
vertex(90, 90);
vertex(210, 10);
vertex(160, 120);  
vertex(210, 270);  
vertex(110, 180);
vertex(10, 270);
vertex(60, 150);
endShape(CLOSE);  

beginShape(TRIANGLES);
vertex(50, 30);
vertex(90, 75);
vertex(110, 30);  
endShape();

ellipse(470, 80, 70, 70);

Listing 2-6.Using beginShape() and endShape()

尽管我们在第一个例子中没有使用beginShape / endShape,在第一个例子中,我们用内置函数ellipse()rect()创建了原始形状,但这些只是对它们对应的beginShape / endShape调用的速记调用。事实上,我们可以使用beginShape(int kind)创建其他类型的图元形状,其中kind参数表示所需的图元。例如,在清单 2-7 中,我们用一组三角形构造了一个正多边形,这些三角形从一个中心顶点呈扇形散开。

size(300, 300);
int numTriangles = 10;
beginShape(TRIANGLE_FAN);
vertex(width/2, height/2);
for (int i = 0; i <= numTriangles; i++) {
  float a = map(i, 0, numTriangles, 0, TWO_PI);
  float x = width/2 + 100 * cos(a);
  float y = height/2 + 100 * sin(a);
  vertex(x, y);
}
endShape();
Listing 2-7.Creating a Triangle Fan

在这个例子中,我们使用一个for循环来迭代三角扇形的划分数。Processing 作为 Java 语言的扩展,继承了 Java 的所有控制结构,这是我们算法绘图所需要的。另外,注意函数map()的使用,它是处理 API 的一部分。这个函数非常有用,它允许我们将一个范围内的数值转换为另一个范围内的相应值。在这种情况下,索引i在 0 和numTriangles之间变化,我们要将其转换为 0 和 2π之间的角度。

A432415_1_En_2_Fig8_HTML.jpg

图 2-8。

Outputs of the triangle fan example for different numbers of vertices

其他种类的原始形状有TRIANGLE_STRIPQUAD_STRIPLINESPOINTS,这些都在 Processing 的参考资料中有完整的记录。例如,当你需要创建一个矩形网格或一个挖空的圆时,QUAD_STRIP就变得很方便,就像我们在清单 2-8 中所做的那样。

size(300, 300);  
beginShape(QUAD_STRIP);
int numQuads = 10;
for (int i = 0; i <= numQuads; i++) {
  float a = map(i, 0, numQuads, 0, TWO_PI);
  float x0 = width/2 + 100 * cos(a);
  float y0 = height/2 + 100 * sin(a);
  float x1 = width/2 + 130 * cos(a);
  float y1 = height/2 + 130 * sin(a);    
  vertex(x0, y0);
  vertex(x1, y1);
}
endShape();
Listing 2-8.Creating a Quad Strip

通过调整numQuads变量的值,我们可以获得更多细节的几何图形,如图 2-9 所示。

A432415_1_En_2_Fig9_HTML.jpg

图 2-9。

Quad strip example with different values for numQuads

然而,我们经常需要创建更复杂的形状,如曲线。尽管我们可以手动计算曲线上的顶点,但 Processing 提供了许多函数来精确地完成这项工作,特别是对于 Catmull-Rom 样条以及二次和三次贝塞尔曲线。例如,bezierVertex()函数允许我们在三次贝塞尔曲线上定义一个点。它需要曲线必须通过的锚点以及定义开始和结束方向的控制点。当开始一条贝塞尔曲线时,第一个锚点用一个常规的vertex()调用设置,如图 2-10 所示。

A432415_1_En_2_Fig10_HTML.jpg

图 2-10。

Parameters of the bezierVertex() function

我们可以将几条贝塞尔曲线组合成一个形状,以便生成更复杂的图形,如清单 2-9 所示。

size(300, 300);
int numLobes = 4;
float radAnchor = 50;
float radControl = 150;
float centerX = width/2;
float centerY = height/2;
beginShape();
for (int i = 0; i < numLobes; i++) {
  float a = map(i, 0, numLobes, 0, TWO_PI);
  float a1 = map(i + 1, 0, numLobes, 0, TWO_PI);
  float cx0 = centerX + radControl * cos(a);
  float cy0 = centerY + radControl * sin(a);
  float cx1 = centerX + radControl * cos(a1);
  float cy1 = centerY + radControl * sin(a1);
  float x0 = centerX + radAnchor * cos(a);
  float y0 = centerY + radAnchor * sin(a);
  float x1 = centerX + radAnchor * cos(a1);
  float y1 = centerY + radAnchor * sin(a1);    
  vertex(x0, y0);
  bezierVertex(cx0, cy0, cx1, cy1, x1, y1);
}
endShape();
Listing 2-9.Creating Multi-lobed Shape with Bezier Curves

通过调整草图中的参数(叶瓣数、锚点半径、控制点半径),我们可以获得一整个系列的形状,其中一些我们可以在图 2-11 中看到。

A432415_1_En_2_Fig11_HTML.jpg

图 2-11。

Family of multi-lobed shapes created with Bezier curves

颜色

颜色是视觉设计的另一个重要组成部分,除了整个输出屏幕的背景颜色之外,Processing 还提供了许多功能来设置形状内部的颜色(填充颜色)和边缘的颜色(描边颜色)。

默认情况下,我们可以使用 0 到 255 之间的 RGB(红、绿、蓝)值来设置颜色,如清单 2-10 中的代码及其在图 2-12 中的输出所示。

A432415_1_En_2_Fig12_HTML.jpg

图 2-12。

Output of setting stroke and fill RGB colors

size(600, 300);
strokeWeight(5);
fill(214, 87, 58);
stroke(53, 124, 115);
rect(10, 10, 180, 280);
stroke(115, 48, 128);
fill(252, 215, 51);
rect(210, 10, 180, 280);
stroke(224, 155, 73);
fill(17, 76, 131);
rect(410, 10, 180, 280);
Listing 2-10.Setting Fill and Stroke Colors Using RGB Values

即使我们可以使用 RGB 值创建几乎任何可以想象的颜色,也很难找到我们需要的颜色的正确数字组合。处理包括一个方便的颜色选择器工具来帮助我们交互地选择颜色,然后我们可以将它作为 RGB 值复制到我们的草图中。在 PDE 的 tools 菜单下,颜色选择器以及任何其他安装的工具都是可用的(图 2-13 )。

A432415_1_En_2_Fig13_HTML.jpg

图 2-13。

Color Selector tool

我们还可以在 HSB(色调、饱和度和亮度)空间中指定颜色。HSB 模式可以用colorMode()功能设置,这也允许我们设置每个组件的范围。在代码清单 2-11 中,我们通过将圆周围的位置映射到色调来绘制色轮。

size(300, 300);
colorMode(HSB, TWO_PI, 1, 1);
float centerX = width/2;
float centerY = height/2;
float maxRad = width/2;
strokeWeight(2);
stroke(0, 0, 1);
for (int i = 0; i < 6; i++) {
  float r0 = map(i, 0, 6, 0, 1);
  float r1 = map(i + 1, 0, 6, 0, 1);
  beginShape(QUADS);
  for (int j = 0; j <= 10; j++) {
    float a0 = map(j, 0, 10, 0, TWO_PI);
    float a1 = map(j + 1, 0, 10, 0, TWO_PI);  
    float x0 = centerX + maxRad * r0 * cos(a0);
    float y0 = centerY + maxRad * r0 * sin(a0);
    float x1 = centerX + maxRad * r1 * cos(a0);
    float y1 = centerY + maxRad * r1 * sin(a0);  
    float x2 = centerX + maxRad * r1 * cos(a1);
    float y2 = centerY + maxRad * r1 * sin(a1);
    float x3 = centerX + maxRad * r0 * cos(a1);
    float y3 = centerY + maxRad * r0 * sin(a1);
    fill(a0, r0, 1);
    vertex(x0, y0);
    vertex(x1, y1);
    vertex(x2, y2);
    vertex(x3, y3);
  }
  endShape();
}
Listing 2-11.Drawing a Color Wheel Using HSB Values

让我们在这个例子中注意一些重要的事情。首先,我们将色调的范围设置为 2π,以便使指数和颜色之间的转换更加直接。第二,我们用QUADS代替QUAD_STRIP。我们不能为一个条带中的每个四边形设置单独的颜色,因为它们都与前一个和下一个四边形共享一个公共边。相反,在QUADS形状中,每个四边形都是独立于其他四边形定义的,因此可以有不同的样式属性。我们最终的色轮如图 2-14 所示。

A432415_1_En_2_Fig14_HTML.jpg

图 2-14。

Output of HSB color wheel example Note

我们还可以用十六进制(hex)格式指定颜色,这在 web 开发中非常常见;即fill(#FF0000)stroke(#FFFFFF)

几何变换

到目前为止,我们已经看到了如何构建形状和选择颜色。此外,我们需要能够通过应用平移、旋转和缩放变换来移动它们并改变它们的大小(图 2-15 )。

A432415_1_En_2_Fig15_HTML.jpg

图 2-15。

The three types of geometric transformations

虽然平移、旋转和缩放的概念很直观,但是很难预测几次连续变换的效果。在考虑变换时,想象一下变换只在应用后影响坐标会有所帮助。例如,如果我们沿 x 轴平移 20 个单位,沿 y 轴平移 30 个单位,那么随后将围绕点(20,30)进行旋转。相反,如果首先应用旋转,那么轴将被旋转,并且将沿着旋转的轴发生平移。因此,如果我们在这个变换链的末端绘制一个形状,它的最终位置可能会有所不同,这取决于它们的完成顺序(图 2-16 )。

A432415_1_En_2_Fig16_HTML.jpg

图 2-16。

Geometric transformations cannot be exchanged

我们可以用pushMatrix()函数保存当前的转换“状态”,用相应的popMatrix()函数恢复。我们必须总是成对地使用这两个函数。它们允许我们通过只对形状的特定子集设置变换来创建复杂的相对运动。例如,清单 2-12 生成一个动画,显示一个椭圆和一个正方形围绕位于屏幕中心的一个较大的正方形旋转,而较小的正方形也围绕自己的中心旋转。图 2-17 显示了该动画的快照。

A432415_1_En_2_Fig17_HTML.jpg

图 2-17。

Using pushMatrix() and popMatrix() to keep transformations separate

float angle;

void setup() {
  size(400, 400);
  rectMode(CENTER);
  noStroke();
}

void draw() {
  background(170);
  translate(width/2, height/2);
  rotate(angle);
  rect(0, 0, 100, 100);
  pushMatrix();
  translate(150, 0);  
  rotate(2 * angle);
  scale(0.5);
  rect(0, 0, 100, 100);
  popMatrix();
  translate(0, 180);
  ellipse(0, 0, 30, 30);
  angle += 0.01;
}

Listing 2-12.Using pushMatrix() and popMatrix()

响应用户输入

键盘输入和触摸屏输入允许我们向草图中输入信息以控制其行为。由于用户可以在任何时候触摸屏幕或按键,而不一定是在处理绘制帧时,因此我们需要一种方法来检索这些信息,无论我们在草图中处于哪个绘制阶段。

Processing 提供了几个内置变量和函数来处理用户输入。变量mouseXmouseY给出了在 Java 模式下鼠标的当前位置。这些变量在 Android 模式下仍然可用,尽管移动设备通常没有鼠标。在这种情况下,它们只是代表屏幕上第一个触摸点的位置(处理也支持多点触摸交互,这将在第五章中介绍)。mouseXmouseY都由mousePressed补充,表示鼠标/触摸屏是否被按下。使用这些变量,我们可以用很少的代码创建一个简单的绘图草图,如清单 2-13 中所示。它在手机上的输出将如图 2-18 所示。由于通过size()功能设置的宽度和高度小于屏幕分辨率,我们看到输出区域被我们无法绘制的浅色背景包围。然而,如果我们不使用size(width, height)初始化草图,而是使用fullScreen()功能,我们可以使用整个屏幕。这也有隐藏屏幕顶部的状态栏和底部的导航栏的优点。

A432415_1_En_2_Fig18_HTML.jpg

图 2-18。

Drawing with ellipses

void setup() {
  size(1000, 500);
  noStroke();
  fill(255, 100);
}

void draw() {
  if (mousePressed) {
    ellipse(mouseX, mouseY, 50, 50);
  }
}

Listing 2-13.A Free-hand Drawing Sketch Using Circles

mouseX/Y存储鼠标/触摸的当前位置时,处理还提供变量pmouseXpmouseY,它们存储先前的位置。通过连接pmouseX/Y坐标和mouseX/Y坐标,我们可以画出跟随指针移动的连续线条。清单 2-14 展示了这种技术,它也使用了fullScreen()以便我们可以在整个屏幕表面上绘制,如图 2-19 所示。

A432415_1_En_2_Fig19_HTML.jpg

图 2-19。

Output of our simple drawing sketch, in full-screen mode

void setup() {
  fullScreen();
}

void draw() {
  if (mousePressed) {
    line(pmouseX, pmouseY, mouseX, mouseY);
  }
}

Listing 2-14.Another Free-hand Drawing

Sketch

创建一个画藤蔓的应用

我们最后一部分的目标是编写一个绘图应用,将算法形状融入手绘线条中。一种可能性是用类似于生长的植物、藤蔓、叶子和花的形状来增加由线提供的支架。我们之前学过的贝塞尔曲线可以用来生成看起来很自然的形状。用笔和纸画一些草图(图 2-20 )也可以帮助我们尝试一些视觉创意。

A432415_1_En_2_Fig20_HTML.jpg

图 2-20。

Sketches for the vine-drawing app

我们可以在之前的草图基础上继续。我们之前忽略的一件事是在形式和颜色上某种程度的“随机性”。处理中的random(float a, float b)函数允许我们在ab之间选择随机数,然后我们可以在通过bezierVertex()函数构建的叶子/花朵形状中使用这些随机数。在清单 2-15 中,我们应用random函数来引入我们形状的颜色和波瓣数量的变化,图 2-21 显示了三次单独运行的草图输出。

A432415_1_En_2_Fig21_HTML.jpg

图 2-21。

Output of the flower/leaf sketch

void setup() {
  size(600, 200);  
  frameRate(1);
}

void draw() {
  background(180);
  drawFlower(100, 100);
  drawFlower(300, 100);
  drawFlower(500, 100);  
}

void drawFlower(float posx, float posy) {
  pushMatrix();
  translate(posx, posy);
  fill(random(255), random(255), random(255), 200);
  beginShape();
  int n = int(random(4, 10));
  for (int i = 0; i < n; i++) {
    float a = map(i, 0, n, 0, TWO_PI);
    float a1 = map(i + 1, 0, n, 0, TWO_PI);    
    float r = random(10, 100);
    float x = r * cos(a);
    float y = r * sin(a);
    float x1 = r * cos(a1);
    float y1 = r * sin(a1);    
    vertex(0, 0);
    bezierVertex(x, y, x1, y1, 0, 0);    
  }
  endShape();
  popMatrix();
}

Listing 2-15.Generating Randomized Flowers/Leaves with Bezier Curves

除了花/叶,我们可以添加一些额外的元素;例如,一个生长中的螺旋分支结束于一个果实。螺旋有一个x(t) = r(t) cos(a(t))y(t) = r(t) sin(a(t))形式的参数公式(、https://www.khanacademy.org/tag/parametric-equations、),其中参数t从 0 到 1,控制曲线的增长。在调整了径向函数之后,我用r(t) = 1/t达到了令人满意的增长行为,所以我们可以从清单 2-16 中的代码开始绘制一个单螺旋(图 2-22 )。

A432415_1_En_2_Fig22_HTML.jpg

图 2-22。

Output of our spiral parametric equation sketch

size(300, 300);
noFill();
translate(width/2, height/2);
beginShape();
float maxt = 10;
float maxr = 150;
for (float t = 1; t < maxt; t += 0.1) {
  float r = maxr/t;
  float x = r  * cos(t);
  float y = r  * sin(t);
  vertex(x, y);
}
endShape();
Listing 2-16.Drawing a Spiral Using Parametric Equations

圈数由参数t的最大值控制,而最大半径决定螺旋向外延伸多少。这两个参数将在random()函数的帮助下给我们一些视觉上的变化,就像我们之前做的那样。一个问题是,我们需要螺旋的主干与画线的方向一致。我们可以通过旋转角度加 180 度(π)来沿所需角度定向螺旋。这就是我们在清单 2-17 中所做的,它生成的三个不同的螺旋如图 2-23 所示。

A432415_1_En_2_Fig23_HTML.jpg

图 2-23。

Output of the randomized spirals sketch .

void setup() {
  size(600, 200);  
  frameRate(1);
}

void draw() {
  background(180);
  drawSpiral(100, 100, 0);
  drawSpiral(300, 100, QUARTER_PI);
  drawSpiral(500, 100, PI);
}

void drawSpiral(float posx, float posy, float angle) {
  pushMatrix();
  translate(posx, posy);  
  rotate(angle + PI);
  noFill();
  beginShape();
  float maxt = random(5, 20);
  float maxr = random(50, 80);
  float x0 = maxr * cos(1);
  float y0 = maxr * sin(1);
  for (float t = 1; t < maxt; t += 0.1) {
    float r = maxr/t;
    float x = r  * cos(t) - x0;
    float y = r  * sin(t) - y0;
    vertex(x, y);
  }
  endShape();
  popMatrix();
}

Listing 2-17.Adding Random Variability to Our Spiral-Generation Algorithm

我们现在可以将所有这些元素放在一个简单的绘图应用中,将树叶、藤蔓和水果添加到手绘线条中(清单 2-18 )。当按下鼠标/触摸屏时,会随机添加树叶和藤蔓。我们可以通过设置在每一帧中绘制新叶子或藤蔓的概率来控制细节的数量。通过使用值 0.05,平均来说,我们将每 20 帧添加一个有按压事件的新元素。通过从当前和先前鼠标/触摸位置之间的差异构建一个PVector对象来计算螺旋藤蔓与最后一条线段的连接角度。PVector是一个处理 2D 和 3D 向量的内置类。这个类包含几个实用函数,其中一个给我们矢量的航向角;即向量沿 x 轴的角度。图 2-24 显示了用该 app 绘制的图纸。

A432415_1_En_2_Fig24_HTML.jpg

图 2-24。

Output of the vine-drawing sketch

void setup() {
  fullScreen();  
  noFill();
  colorMode(HSB, 360, 99, 99);
  strokeWeight(2);
  stroke(210);  
  background(0, 0, 99);
}

void draw() {
  if (mousePressed) {
    line(pmouseX, pmouseY, mouseX, mouseY);
    if (random(1) < 0.05) {    
      PVector dir = new PVector(mouseX - pmouseX, mouseY - pmouseY);
      float a = dir.heading();
      drawSpiral(mouseX, mouseY, a);
    }
    if (random(1) < 0.05) {
      drawFlower(mouseX, mouseY);
    }
  }
}

void keyPressed() {
  background(0, 0, 99);
}

void drawFlower(float xc, float yc) {
  pushMatrix();
  pushStyle();
  noStroke();
  translate(xc, yc);  
  fill(random(60, 79), random(50, 60), 85, 190);
  beginShape();
  int numLobes = int(random(4, 10));
  for (int i = 0; i <= numLobes; i++) {
     float a = map(i, 0, numLobes, 0, TWO_PI);
     float a1 = map(i + 1, 0, numLobes, 0, TWO_PI);
     float r = random(10, 50);
     float x = r * cos(a);
     float y = r * sin(a);
     float x1 = r * cos(a1);
     float y1 = r * sin(a1);    
     vertex(0, 0);     
     vertex(0, 0);
     bezierVertex(x, y, x1, y1, 0, 0);     
  }
  endShape();
  popStyle();
  popMatrix();
}

void drawSpiral(float xc, float yc, float a) {
  pushMatrix();
  pushStyle();
  translate(xc, yc);  
  rotate(PI + a);
  noFill();
  beginShape();
  float maxt = random(5, 10);
  float maxr = random(20, 70);
  float sign = (random(1) < 0.5) ? -1 : +1;  
  float x0 = maxr * cos(sign);
  float y0 = maxr * sin(sign);
  for (float t = 1; t < maxt; t += 0.5) {
    float r = maxr/t;
    float x = r  * cos(sign * t) - x0;
    float y = r  * sin(sign * t) - y0;
    vertex(x, y);
  }
  endShape();
  noStroke();
  fill(random(310, 360), 80, 80);
  float x1 = (maxr/maxt) * cos(sign * maxt) - x0;
  float y1 = (maxr/maxt) * sin(sign * maxt) - y0;
  float r = random(5, 10);
  ellipse(x1, y1, r, r);
  popStyle();
  popMatrix();
}

Listing 2-18.Full Vine-Drawing Sketch

摘要

我们现在已经对 Processing 语言有了一个总体的了解,并且能够使用它的一些函数和变量来绘制形状、设置颜色、应用变换以及通过鼠标或触摸屏处理用户交互。尽管我们只涉及了处理中所有可用功能的一小部分,但我们在这里看到的应该给我们足够的材质来探索算法绘图,制作我们自己的交互式草图,并作为 Android 应用运行它们。

三、从草图到游戏商店

在这一章中,我们将回顾创建完整的 Android 项目处理过程中涉及的步骤,从草图绘制、调试到将项目导出为已签名的应用,以便上传到谷歌 Play 商店。我们将使用上一章的草图作为例子。

草图绘制和调试

在前面的章节中,我们强调了“代码草图”的重要性,其中即时的视觉输出和快速迭代是处理项目开发的核心要素。另一个至关重要的组成部分是识别和解决代码中的错误或“bug ”,这个过程称为调试。

调试需要的时间和编写代码本身一样多。使调试具有挑战性的是,一些错误是错误的逻辑或不正确的计算的结果,并且因为代码中没有打字错误或任何其他语法错误,所以处理能够运行草图。不幸的是,没有简单的技术可以消除程序中的所有错误,但是 Processing 提供了一些实用工具来帮助我们。

从控制台获取信息

调试程序最简单的方法是在程序执行流程的不同点打印变量值和消息。Processing 的 API 包括文本打印函数print()println(),它们输出到 PDE 中的控制台区域。这两个函数的唯一区别是println()在末尾增加了一个新的换行符,而print()没有。清单 3-1 展示了一个草图,它使用println()来指示一个事件的发生(在本例中是鼠标按压)和一个内置变量的值。

void setup() {
  fullScreen();
}

void draw() {
  println("frame #", frameCount);
}

void mousePressed() {
  println("Press event");
}

Listing 3-1.Using println() in a Sketch to Show Information on the Console

加工控制台显示这些功能打印的任何内容,以及指示草图执行过程中出现问题的任何警告或错误信息(图 3-1 )。

A432415_1_En_3_Fig1_HTML.jpg

图 3-1。

PDE’s console outlined with red

将消息打印到控制台进行调试的主要问题是,它需要为我们想要跟踪的每个变量添加这些额外的函数调用。一旦我们完成了调试,我们需要删除或注释掉所有这些调用,这对于大型草图来说会变得很麻烦。

Note

处理过程中的注释工作方式与 Java 中的完全相同:我们可以使用两个连续的正斜杠、//注释掉一行代码,以及一整块文本,其中/*在块的开头,*/在块的结尾。我们还可以使用 PDE 中编辑菜单下的“注释/取消注释”选项。

使用 logcat 获取更多信息

我们可以从处理控制台获得许多有用的信息,但有时这不足以找出我们的草图有什么问题。Android SDK 包括几个命令行工具,可以帮助我们进行调试。最重要的 SDK 工具是 adb (Android Debug Bridge),它使我们用于开发的计算机和设备或仿真器之间的通信成为可能。事实上,在从 PDE 运行草图时,处理使用 adb 来查询哪些设备可用,并将草图推送到设备或仿真器。

我们也可以手动使用 adb 例如,获得更详细的调试消息。为此,我们需要打开一个终端控制台,进入后,我们必须切换到 Android SDK 的安装目录。如果 SDK 是通过处理自动安装的,那么它应该位于android子文件夹中的 sketchbook 文件夹中。在该文件夹中,SDK 工具位于sdk/platform-tools中。在那里,我们可以使用logcat选项运行 adb 工具,打印出包含所有消息的日志。例如,图 3-2 显示了我们在 Mac 上运行logcat所需的命令序列。

A432415_1_En_3_Fig2_HTML.jpg

图 3-2。

Terminal session on Mac displaying the commands to run logcat

默认情况下,logcat打印 Android 设备或模拟器生成的所有消息——不仅仅是来自我们正在调试的草图的消息,还有来自所有其他当前正在运行的进程的消息——所以我们可能会得到太多的消息。如果将logcat–I选项一起使用,可显示加工的打印信息。Logcat 有额外的选项,只显示错误消息(-E)或警告(-W)。完整的选项列表可以在谷歌的开发者网站上找到( https://developer.android.com/studio/command-line/logcat.html )。

使用集成调试器

Java mode in Processing 3.0 引入了一个集成的调试器,使我们更容易跟踪正在运行的草图的内部状态。即使调试器在 Android 模式下不可用,我们仍然可以使用它来调试 Android 草图。如果一个处理草图不依赖于 Android 特定的功能,它应该兼容 Android 和 Java 模式,因为这两种模式的代码 API(几乎)是相同的。在这种情况下,我们可以暂时切换到 Java 模式,利用它的调试器,然后回到 Android 模式,继续在设备或仿真器上工作。

我们打开调试器的方法是,按下模式选择器旁边菜单栏左侧带有蝴蝶图标的按钮,或者在 Debug 菜单中选择“Enable Debugger”。启用后,我们可以访问 PDE 中的几个附加选项,以便在草图运行时使用。例如,我们可以在草图的代码中的任何一行添加检查点。检查点指示草图的执行应该在哪里停止,以允许我们检查草图中所有变量的值,包括用户定义的和内置的变量。

我们可以通过双击代码编辑器左边的行号来创建一个新的检查点。菱形标志将表示该线已用检查点标记。当我们运行一个包含一个或多个检查点的草图时,处理将在到达每个检查点时停止执行,此时我们可以使用变量检查器窗口检查变量的值(图 3-3 )。我们通过按工具栏上的继续按钮来恢复执行。我们也可以通过按下 step 按钮来一行一行地查看每个变量在每行之后如何改变它的值。

A432415_1_En_3_Fig3_HTML.jpg

图 3-3。

Debugging session with the integrated debugger in Java mode

集成调试器中的所有这些功能可以帮助我们在不添加打印指令的情况下识别代码中的错误,尽管修复棘手的错误总是具有挑战性,即使使用调试器也可能需要很长时间。最后,根据我们从调试器或打印指令中获得的信息,它归结为理解草图中代码的逻辑及其可能的结果和边缘情况。这样,我们可以缩小包含 bug 的代码部分。

报告处理错误

有时,处理草图中的意外或错误行为可能不是草图本身的错误,而是处理核心中的错误。如果你非常怀疑你发现了一个处理 bug,你可以在项目的 GitHub 页面上报告。如果是影响 Android 模式的 bug,请在 https://github.com/processing/processing-android/issues 的处理-android 存储库中打开一个新问题,并尽可能多地包含重现该 bug 的信息,帮助开发人员检查该问题并最终修复它。

准备发布草图

在 PDE 中调试了一个草图之后,我们可能想要打包它,通过谷歌 Play 商店公开发布。当从 PDE 工作时,处理创建一个调试应用包,它只能安装在我们自己的设备上用于测试目的。创建一个适合普遍发行的应用需要一些额外的步骤和考虑,以确保它可以上传到 Play Store。

调整设备的 DPI

为了准备公开发布我们的草图,我们必须首先确保它可以在(大多数)正在使用的 Android 设备上运行。在编写和调试草图时,我们经常使用一个或几个不同的设备,因此很难预测我们无法访问的硬件上的问题。一个常见的情况是,在不同的设备上运行处理草图时,图形看起来要么太大,要么太小。手机、平板电脑和手表的分辨率(像素数量)和物理屏幕尺寸可能会有很大差异,因此,在特定尺寸的屏幕上观看时,以一种分辨率设计的图形元素在另一种设备上可能会看起来不正确。由于 Android 设计为支持各种屏幕尺寸和分辨率的组合,我们需要一种处理方法来适应我们草图的视觉设计,以便它在不同设备上看起来像预期的那样。

分辨率与屏幕尺寸的比率就是所谓的 DPI(每英寸点数,在计算机屏幕的上下文中相当于每英寸像素,或 PPI)。DPI 是比较不同设备的基本数值。请务必记住,较高的 DPI 并不一定意味着较高的分辨率,因为具有相同分辨率的两台不同设备可能具有不同的屏幕尺寸。例如,Galaxy Nexus(对角线 4.65 英寸)的分辨率为 720 × 1280 像素,而 Nexus 7(对角线 7 英寸)的分辨率为 800 × 1280 像素。这些设备的 dpi 分别为 316 和 216,即使 Galaxy Nexus 的分辨率实际上略低于 Nexus 7。

Android 根据以下六个广义密度将设备分类到“密度桶”中(具体设备将根据哪个最接近其实际 DPI 而归入其中一个类别):

  • ldpi(低)120 dpi
  • mdpi(中等)∼160 dpi
  • hdpi(高)240 dpi
  • xhdpi(超高)320 dpi
  • xxhdpi(超高)480 dpi
  • xxxhdpi(超高)640 dpi

正如我们将在本章后面看到的,在生成应用图标时,广义密度水平在处理过程中很重要,但在编写代码时就不那么重要了。为了确保我们草图中的视觉元素能够跨不同设备适当缩放,还有一个来自 Android 的参数,Processing 通过它的 API 提供了这个参数。这是显示密度,一个代表与参考 160 dpi 屏幕(例如,320 × 480,3.5 英寸屏幕)相比,我们设备中的像素大(或小)多少的数字。因此,在 160 dpi 的屏幕上,这个密度值将是 1;在 120 dpi 的屏幕上,它将是 0.75,等等。

Note

Google 关于多屏支持的 API 指南给出了 Android 上密度独立的详细信息: https://developer.android.com/guide/practices/screens_support.html

显示密度作为名为displayDensity的常量在处理中可用,我们可以在代码中的任何地方使用它。调整设备 DPI 输出的最简单方法是将草图中所有图形元素的大小乘以displayDensity,,这是清单 3-2 中所示的方法。正如我们在图 3-4 中看到的,草图绘制的圆圈大小在具有不同 dpi 的设备中是相同的。同样,这个例子使用fullScreen()来初始化我们的草图输出到整个屏幕的大小,不管它的分辨率如何。

A432415_1_En_3_Fig4_HTML.jpg

图 3-4。

From left to right: output of our sketch on a Samsung Galaxy Tab 4 (7", 1280 × 800 px, 216 dpi), Nexus 5X (5.2", 1920 × 800 px, 424 dpi), and a Moto E (4.3", 960 × 540 px, 256 dpi)

void setup() {
  fullScreen();
  noStroke();
}

void draw() {
  background(0);
  float r = 50 * displayDensity;
  int maxi = int(width/r);
  int maxj = int(height/r);
  for (int i = 0; i <= maxi; i++) {
    float x = map(i, 0, maxi, 0, width);
    for (int j = 0; j <= maxj; j++) {
      float y = map(j, 0, maxj, 0, height);
      ellipse(x, y, r, r);
    }
  }
}

Listing 3-2.Using displayDensity to Adjust Our Sketch to Different Screen Sizes and Resolutions

我们现在可以回到上一章的藤蔓绘制草图,在需要缩放图形的代码部分添加displayDensity。更具体地说,任何代表形状大小或屏幕上顶点位置的变量或值都应该乘以displayDensity。清单 3-3 显示了应用于原始草图的这些变化。

void drawFlower(float xc, float yc) {
  pushMatrix();
  pushStyle();
  noStroke();
  translate(xc, yc);  
  fill(random(60, 79), random(50, 60), 85, 190);
  beginShape();
  int numLobes = int(random(4, 10));
  for (int i = 0; i <= numLobes; i++) {
     float a = map(i, 0, numLobes, 0, TWO_PI);
     float a1 = map(i + 1, 0, numLobes, 0, TWO_PI);
     float r = random(10, 50) * displayDensity;
     ...
}

void drawSpiral(float xc, float yc, float a) {
  pushMatrix();
  pushStyle();
  translate(xc, yc);  
  rotate(PI + a);
  noFill();
  beginShape();
  float maxr = random(20, 70) * displayDensity;
  ...
  fill(random(310, 360), 80, 80);
  float x1 = (maxr/maxt) * cos(sign * maxt) - x0;
  float y1 = (maxr/maxt) * sin(sign * maxt) - y0;
  float r = random(5, 10) * displayDensity;
  ellipse(x1, y1, r, r);
  popStyle();
  popMatrix();
}  

Listing 3-3.Adding displayDensity to the Vine-drawing Sketch from Chapter 2

使用模拟器

我们在第一章简要讨论了模拟器。即使我们有自己的设备,模拟器也是有用的,因为它允许我们测试我们无法访问的硬件配置。处理过程会创建一个默认的 Android 虚拟设备(AVD)来在模拟器中运行,但它的分辨率只有 480 × 800 像素,以确保在不同的计算机上有合理的性能。我们可以使用命令行工具avdmanager创建其他具有不同属性的 avd,它包含在 Android SDK 中。我们必须记住,模拟器的运行速度可能会比实际设备慢,尤其是如果您使用的是高分辨率 avd 或具有其他高端功能的 avd。

由于avdmanager是一个命令行工具,我们首先需要打开一个终端控制台并切换到工具目录,其中avdmanager和模拟器启动器位于 SDK 文件夹中。图 3-5 显示了使用 Nexus 5X 手机的设备定义创建新的 AVD,然后使用仿真器启动它的步骤顺序。

A432415_1_En_3_Fig5_HTML.jpg

图 3-5。

Creating and launching a new AVD from the command line using the avdmanager and emulator tools

在运行avdmanager命令的行中,我们提供了四个参数:

  • AVD 的名称,可以是我们希望使用的任何名称
  • -k "system-images;android-26;google_apis;x86":用于 AVD 的 SDK 包;为了找出 SDK 中可用的 SDK 包,我们需要查看 SDK 文件夹中的 system-images 子文件夹。
  • -d: "Nexus 5X":包含我们想要仿真的设备的硬件参数的设备定义。我们可以通过运行命令'./avdmanager list devices'列出所有可用的设备定义。
  • -p ∼/Documents/Processing/android/avd/n5x:我们将存储该 AVD 的文件夹;在这种情况下,我们使用 sketchbook 文件夹中的android/avd/n5x,因为这是 Android 模式用于默认 AVDs 的默认位置。

图 3-5 中的下一行实际上启动了模拟器,但是在此之前,我们需要设置 AVD 的“皮肤”,告诉模拟器它应该呈现手机屏幕的实际尺寸。目前,avdmanager没有设置设备皮肤的选项,但我们可以手动将其添加到 AVD 的配置文件中,在本例中,该文件位于∼/Documents/Processing/android/avd/n5x中,名为config.ini。我们可以用任何文本编辑器打开这个文件,然后在末尾添加一行skin.name=widthxheight,使用设备的宽度和高度,尽管我们也可以使用我们偏好的其他值,如图 3-6 所示。

A432415_1_En_3_Fig6_HTML.jpg

图 3-6。

Adding a skin resolution to the AVD’s config.ini file

一旦我们将皮肤分辨率添加到 AVD 的config.ini文件中,我们就可以运行前面显示的仿真器行,它包括以下参数:

  • -avd n5x:我们要启动的 AVD 的名称
  • -gpu auto:使仿真器能够使用计算机上的硬件加速来更快地渲染 AVD 的屏幕(如果可用的话)。否则,它将使用较慢的软件渲染器。
  • -port 5566:设置连接控制台和 adb 与仿真器的 TCP 端口号。

要使用我们的新 AVD 来代替 Processing 的默认 AVD,我们应该手动启动它,就像我们在本例中所做的那样,然后 Processing 会在其中安装我们的草图,而不是在默认 AVD 中。但是,我们需要确保使用正确的端口参数,因为处理将只能与运行在端口 5566 上的电话模拟器和端口 5576 上的手表模拟器通信。

Note

谷歌的 Android 开发者网站包括 avdmanager ( https://developer.android.com/studio/command-line/avdmanager.html )和从命令行运行仿真器( https://developer.android.com/studio/run/emulator-commandline.html )的页面。在那里,我们可以找到关于这些工具的更多信息。

设置图标和包名

Android 应用要求在应用启动器菜单中以不同的像素密度显示各种大小的图标。从 PDE 运行草图时,处理使用一组默认的通用图标,但这些图标不应用于公开发布。

为了将我们自己的图标添加到项目中,我们需要在中创建以下文件:图标-36、图标-48、图标-72、图标-96、图标-144 和图标-192。ldpi (36 × 36)、mdpi (48 × 48)、hdpi (72 × 27)、xhdpi (96 × 96)、xxhdpi (144 × 144)和 xxxhdpi (192 × 192)分辨率的 PNG 格式。一旦我们有了这些文件,在导出签名包之前,我们把它们放在草图的文件夹中。

对于上一章的藤蔓绘制应用,我们将使用如图 3-7 所示的一组图标。

A432415_1_En_3_Fig7_HTML.jpg

图 3-7。

Set of icons for the vine-drawing app

谷歌发布了一套遵循公司材质 UI 风格的图标创建指南和资源,可在 https://www.google.com/design/spec/style/icons.html 获得

设置包名和版本

Google App Store 中的应用由一个包名唯一标识,包名是一串类似于com.example.helloworld的文本。这个包名称遵循 Java 包命名惯例,其中应用名称(helloworld)在最后,以相反的顺序(com.example)在开发应用的公司或个人的网站之前。

处理通过在草图名称前添加processing.test来自动构建这个包名。在我们第一次从 PDE(在设备上或者在仿真器中)运行之后,我们可以通过编辑处理在 sketch 文件夹中生成的manifest.xml文件来改变默认的包名。我们还可以设置版本代码和版本名称。例如,在下面处理生成的清单文件中,包名为com.example.vines_draw,版本号为 10,版本名为 0.5.4:

<?xml version="1.0" encoding="UTF-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          android:versionCode="10" android:versionName="0.5.4"
          package="com.example.vines_draw">
    <uses-sdk android:minSdkVersion="17" android:targetSdkVersion="25"/>
    <application android:icon="@drawable/icon"
                 android:label="Vines Draw">
        <activity android:name=".MainActivity"
                  android:theme=
                  "@style/Theme.AppCompat.Light.NoActionBar.FullScreen">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>
</manifest>

请注意,我们的应用的包名必须是唯一的,因为谷歌 Play 商店上不能有两个应用具有相同的包名。此外,我们应该使用应用标签中的android:label属性来设置应用名称。Android 将使用这个标签作为应用在启动器和 UI 其他部分的可见标题。

作为签名包导出

Android 模式通过签名和对齐应用简化了我们草图的发布,因此我们可以非常轻松地将其上传到 Google Play 开发人员控制台。签名过程包括创建一个包含公钥/私钥对的公钥的公钥证书,这样当应用包被签名时,它会嵌入一个唯一的指纹,该指纹将包与其作者相关联。这确保了该应用的任何未来更新都是真实的,并且来自原作者()https://developer。安卓。com/ studio/ publish/ appsigning。html 。需要对齐来优化封装内的数据存储,从而减少运行应用时消耗的 RAM 量。虽然 Processing 将为我们进行签名和对齐,但我们仍然需要创建一个 Google Play 开发者帐户来使用 Play 控制台,这需要在撰写本文时一次性支付 25 美元( support)。谷歌。com/Google play/Android developer/answer/6112435。从处理开始,我们需要做的就是选择文件菜单下的“导出签名包”选项(图 3-8 )。

A432415_1_En_3_Fig8_HTML.jpg

图 3-8。

“Export Signed Package” option in the PDE’s File menu

选择此选项后,处理将要求创建一个新的密钥库来保存发布密钥,以便对应用包进行签名。密钥库需要密码和关于密钥库发行者的附加信息(名称、组织、城市、州、国家),尽管这些是可选的。允许我们输入所有这些信息的密钥库管理器窗口显示在图 3-9 中。

A432415_1_En_3_Fig9_HTML.jpg

图 3-9。

Entering the information needed to create a keystore in Processing

请记住此密码,因为每次导出新的签名包时都必须使用它。尽管您可以重置它并创建一个新的密钥,但您应该记住,一旦应用上传到 Play Store,您就不能更改密钥——应用的任何后续更新都需要使用与原始密钥相同的密钥进行签名,否则它将被拒绝,您必须使用新密钥创建一个新包。

已签名(并对齐)的包将保存在草图文件夹内的 build 子文件夹中,名为[Sketch name in lowercase]_release_signed_aligned.apk。一旦我们有了这个包,我们就可以按照谷歌的指示来完成应用发布过程: https://support.google.com/googleplay/android-developer/answer/113469

如果我们按照葡萄藤草图的所有这些步骤,我们应该能够生成一个签名包,准备上传到 Play Store。我们也可以使用 adb 工具在我们的设备上手动安装它(参见图 3-10 )。

A432415_1_En_3_Fig10_HTML.jpg

图 3-10。

Installing a signed package from the command line using adb

如果我们手动或通过 Play Store 安装最终的应用包,我们应该会在应用启动器中看到我们为其创建的图标(图 3-11 )。

A432415_1_En_3_Fig11_HTML.jpg

图 3-11。

The vine-drawing app installed on our device

摘要

本章涵盖了许多技术主题,包括使用 Processing 的控制台、集成调试器或 adb 中的 logcat 选项调试我们的代码;根据设备的 DPI 缩放草图的输出;并将我们的草图导出为签名包,准备上传到 Play Store。有了这些工具,我们已经准备好与全世界的 Android 用户分享我们的创作了!

四、绘制图形和文本

在这一章中,我们将深入探讨用于绘制形状、图像和文本的处理 API,使用几个代码示例来说明 API 中的不同功能。我们还将学习如何使用 P2D 渲染器和PShape类来获得更好的 2D 性能。

正在处理的渲染器

在前面的章节中,我们学习了加工草图的基本结构,它由一个setup()和一个draw()函数组成,前者包含草图初始化,后者包含在每一帧中更新屏幕的代码。作为初始化的一部分,我们需要用size()函数指示输出区域的大小,如清单 4-1 所示。我们还看到,fullScreen()功能允许我们使用设备屏幕的整个区域,而不管其分辨率如何。

size()fullScreen()函数都接受一个“渲染器”选项。渲染器是处理过程中的模块,它将草图中的绘制命令转换为设备屏幕上的最终图像。处理渲染器通过 Android 系统提供的 API(https://source.android.com/devices/graphics/)与图形硬件通信来实现这一点。

默认渲染器(JAVA2D)在没有给size()fullScreen()额外选项时启用,使用 Android 的 Canvas API 并提供高质量的 2D 渲染。但是,性能可能会受到限制,尤其是在绘制许多形状和其他图形元素时。另外两个渲染器,P2DP3D,通过 OpenGL API 使用图形处理单元(GPU),这带来了更高的性能,但代价是增加了电池消耗。我们可以通过调用带有适当参数的size()fullScreen()来选择渲染器;例如,size(w, h)size(w, h, JAVA2D)将使用默认渲染器生成草图,而size(w, h, P2D)fullScreen(P2D)将启用 P2D 渲染器,如我们在清单 4-1 中所做的。无论我们使用 JAVA2D 还是 P2D,这个草图的输出都是一样的,但是在本章的后面我们会看到使用 P2D 的一些具体的优点。我们将在第十三章中介绍使用 P3D 渲染器绘制 3D 图形。

A432415_1_En_4_Fig1_HTML.jpg

图 4-1。

Output of the full-screen P2D sketch

void setup() {
  fullScreen(P2D);
  background(255);  
  noFill();
  rectMode(CENTER);
}

void draw() {  
  float w = 2*(width/2-mouseX);
  rect(width/2, height/2, w, w/width * height);

}

Listing 4-1.Using Full-screen Output

with the P2D Renderer

绘制形状

第二章给我们概述了绘图 API 在处理中的一些重要元素。我们看到了如何使用像ellipse()rect()这样的函数绘制原始形状,而任意形状可以使用beginShape()vertex()endShape()函数逐个顶点地创建。在这一节中,我们将更深入地了解形状绘制 API,并学习如何使用PShape类和通过将矢量图形加载到PShape对象中来将形状存储到对象中以便更快地渲染。

更多形状类型

让我们首先回顾一下我们所掌握的所有可能的形状类型。本质上,根据我们在beginShape()中指定的类型,顶点将以不同的方式连接以构建所需的几何图形,如图 4-2 所示。

A432415_1_En_4_Fig2_HTML.jpg

图 4-2。

All the shape types available in Processing

beginShape / endShape构造形状时,顶点的数量和顺序非常重要。图 4-2 描绘了每个顶点是如何根据类型合并到形状中的。如果我们以不同的顺序提供顶点——例如,如果我们在QUADS类型的形状中交换顶点 2 和 3——那么产生的形状看起来会扭曲。此外,每种类型(点和多边形除外)都需要特定数量的顶点来构建单独的形状;例如,3 × N 画 N 个三角形,4 × N 画 N 个四边形,等等。如果我们想创造复杂的形状,我们必须熟悉这些规则。例如,考虑在每种形状类型下,相同的顶点布局如何导致非常不同的视觉结果,如清单 4-2 所示。

A432415_1_En_4_Fig3_HTML.jpg

图 4-3。

Outputs for different shape types

int[] types = {POINTS, LINES, TRIANGLES,
               TRIANGLE_STRIP, TRIANGLE_FAN,
               QUADS, QUAD_STRIP, POLYGON};
int selected = 0;

void setup() {
  size(300, 300);
  strokeWeight(2);
}

void draw() {
  background(150);
  beginShape(types[selected]);
  for (int i = 0; i <= 10; i++) {
    float a = map(i, 0, 10, 0, TWO_PI);
    float x0 = width/2  + 100 * cos(a);
    float y0 = height/2 + 100 * sin(a);
    float x1 = width/2  + 130 * cos(a);
    float y1 = height/2 + 130 * sin(a);    
    vertex(x0, y0);
    vertex(x1, y1);
  }
  endShape();
}

void mousePressed() {
  selected = (selected + 1) % types.length;
  println("Drawing shape", selected);
}

Listing 4-2.Drawing Different Shapes of Different Types Using beginShape() and endShape()

Note

POLYGON类型是beginShape()的默认参数,所以如果我们不提供任何显式类型,我们将创建一个多边形。此外,多边形可以是开放的或封闭的,这可以用endShape(mode)控制,模式可以是OPENCLOSE

曲线形状

到目前为止,我们一直在使用的vertex()函数允许我们向形状添加顶点,然后根据我们在beginShape()中选择的类型参数连接这些顶点。这种方法足够通用,可以生成我们能想到的几乎任何形状,甚至是弯曲的形状。在这种情况下,我们可以沿着数学曲线计算顶点的位置,然后将这些顶点添加到多边形形状中。例如,在清单 4-3 中,我们使用极坐标来生成一个随机的、看起来有机的形状。

A432415_1_En_4_Fig4_HTML.jpg

图 4-4。

Three shapes created by our “organic shape” example

size(480, 480);
translate(width/2, height/2);
int numPoints = 100;
int degree = 5;
beginShape();  
float[] coeffs = new float[degree];
for (int d = 0; d < degree; d++) {
  coeffs[d] = random(0, 1);
}
float phase = random(0, TWO_PI);
for (int i = 0; i <= numPoints; i++) {
  float theta = map(i, 0, numPoints, 0, TWO_PI);
  float rho = 5;
  for (int d = 1; d <= degree; d++) {
    rho += coeffs[d - 1] * sin(d*theta+phase);
  }
  float x = 30 * rho * cos(theta);
  float y = 30 * rho * sin(theta);
  vertex(x, y);
}
endShape();
Listing 4-3.Creating a Curved “Organic” Shape with Polar Coordinates

在这个例子中,我们使用变量numPoints来设置要添加到形状中的点数。这个数字越高,曲线看起来越平滑。我们可以使用 Catmull-Rom 样条和 Bezier 曲线来代替,两者都给我们更直观的曲线控制,正如我们接下来将看到的。

我们通过重复调用curveVertex()函数,为样条需要经过的每个顶点调用一次,并通过设置其控制点,在形状内添加 Catmull-Rom 样条。这些控制点决定了样条曲线在其端点处的方向。Catmull-Rom 样条的一个方便的方面是它们通过所有的控制点,但是这些点和端点处的方向之间的关系不容易可视化。

让我们看看使用样条的细节。为了使代码更具可读性,我们将在处理中使用PVector类,它允许我们存储 2D 和 3D 位置,并执行基本的矢量代数。一个PVector对象有三个浮点字段——x、y、z——和一些计算方法,比如向量加法、减法、长度和航向角。《加工参考》有一节详细介绍了如何使用该功能( https://processing.org/reference/PVector.html ),还有一节教程( https://processing.org/tutorials/pvector/ )。在清单 4-4 中,我们使用了一个PVector对象数组来存储样条曲线经过的所有点。

size(480, 480);

PVector[] points = new PVector[11];
for (int i = 0; i <= 10; i++) {
  if (i < 10) {
    float a = map(i, 0, 10, 0, TWO_PI);
    float r = random(100, 200);
    points[i] = new PVector(r * cos(a), r * sin(a));
  } else {
    points[10] = points[0].copy();
  }
}  

translate(width/2, height/2);
fill(255);
beginShape();
for (int i = 0; i <= 10; i++) {
  if (i == 0 || i == 10) curveVertex(points[i].x, points[i].y);    
  curveVertex(points[i].x, points[i].y);
}
endShape();

fill(0);
for (int i = 0; i <= 10; i++) {
  ellipse(points[i].x, points[i].y, 10, 10);
}

Listing 4-4.Creating Catmull-Rom Splines with curveVertex()

我们首先创建一个 PVector 对象数组,用来存储曲线上的位置。因为我们正在创建一个封闭的形状,所以最后一个PVector是第一个的副本。然后,我们将存储在 PVector 数组中的位置作为曲线顶点添加到多边形形状中。线条if (i == 0 || i == 10) curveVertex(points[i].x, points[i].y);添加了对应于控制点的附加顶点,这些顶点被设置为与曲线中的第一个和最后一个点相同。尽管样条给了我们一条通过所有点的平滑曲线,我们可能会在第一点得到一个尖角,如图 4-5 所示。

A432415_1_En_4_Fig5_HTML.jpg

图 4-5。

Output of the Catmull-Rom spline example

另一方面,由于控制点可用于调整曲线上每对顶点之间的曲率,贝塞尔曲线允许更直观地操纵形状。我们在第二章中应用了贝塞尔曲线来创建类似花和叶子的形状,我们可以在许多其他情况下使用它们。类似于我们之前的随机斑点,我们可以使用贝塞尔曲线创建一个随机形状。我们需要平滑地连接贝塞尔曲线;每一个都需要两个控制点和两个顶点。图 4-6 显示了如何共享相邻贝塞尔曲线的顶点和控制点,以确保整个曲线不包含尖角。

A432415_1_En_4_Fig6_HTML.jpg

图 4-6。

Smoothly joining Bezier curves

与样条曲线一样,我们可以生成贝塞尔曲线将通过的点,方法是以等间距围绕形状的整个周长移动,然后构建切线方向,我们将沿着该方向放置连续曲线之间共享的控制点。这就是我们在清单 4-5 中所做的。

size(480, 480);

PVector[] points = new PVector[11];
PVector[] directions = new PVector[11];
for (int i = 0; i <= 10; i++) {
  if (i < 10) {
    float a = map(i, 0, 10, 0, TWO_PI);
    float r = random(100, 200);
    points[i] = new PVector(r * cos(a), r * sin(a));
    directions[i] = PVector.fromAngle(points[i].heading() +
                    random(0, QUARTER_PI));
    directions[i].mult(60);
  } else {
    points[10] = points[0].copy();
    directions[10] = directions[0].copy();
  }
}  

translate(width/2, height/2);
strokeWeight(2);
fill(255);
beginShape();
for (int i = 0; i < 10; i++) {
  vertex(points[i].x, points[i].y);
  PVector CP1 = PVector.add(points[i], directions[i]);
  PVector CP2 = PVector.sub(points[i+1], directions[i+1]);  
  bezierVertex(CP1.x, CP1.y, CP2.x, CP2.y, points[i+1].x, points[i+1].y);  
}
endShape();

Listing 4-5.Creating a Bezier Curve with Consecutive Vertices

注意使用PVectorfromAngle()方法生成一个方向向量,方法是将位置向量旋转 0 到 90 度之间的随机量(QUARTER_PI,然后用一个常数因子(60)对其进行缩放。同样,最后一个矢量的位置和方向需要从第一个矢量复制过来,这样形状才能正确闭合。一旦所有这些向量被计算并存储在数组中,我们就用vertex()bezierVertex()函数创建形状。

我们还可以在示例中添加一些额外的代码来绘制顶点和控制点。我们所要做的就是遍历点和方向数组,然后使用椭圆和直线来显示它们与形状的关系。清单 4-6 包含了这些额外的代码,我们将把它们粘贴到清单 4-5 的草图的末尾,得到如图 4-7 所示的输出。

A432415_1_En_4_Fig7_HTML.jpg

图 4-7。

Shape obtained by joining Bezier consecutive curves, with their control points

strokeWeight(1);
for (int i = 0; i <= 10; i++) {
  PVector prevCP = PVector.sub(points[i], directions[i]);
  PVector nextCP = PVector.add(points[i], directions[i]);
  stroke(0);
  line(prevCP.x, prevCP.y, nextCP.x, nextCP.y);
  noStroke();
  fill(190, 30, 45);
  ellipse(points[i].x, points[i].y, 10, 10);  
  fill(28, 117, 188);
  ellipse(prevCP.x, prevCP.y, 7, 7);
  ellipse(nextCP.x, nextCP.y, 7, 7);

}

Listing 4-6.Drawing the Control Points and Tangent Directions to a Bezier Curve

可以将曲线/贝塞尔曲线顶点与常规顶点组合在同一形状中。让我们用刚刚学的东西画一个简单的海景,用贝塞尔曲线造波。我在图 4-8 中勾勒出了这个想法。

A432415_1_En_4_Fig8_HTML.jpg

图 4-8。

Pen and paper sketch of a seascape using Bezier curves

因为我们将使用相同的代码但不同的参数绘制几个形状,所以将每个形状存储在一个单独的对象中会很方便。为此,我们将定义一个包含一条波浪线的类,然后我们可以用它在setup()函数中创建几个波浪,如清单 4-7 所示。

void setup() {
  fullScreen();
  orientation(LANDSCAPE);
  colorMode(HSB, 360, 100,100);
  waves = new WavyLine[10];
  for (int i = 0; i < 10; i++) {
    float y = map(i, 0, 9, height * 0.85, height * 0.025);
    color c = color(225, map(i, 0, 10, 30, 100), 90);
    waves[i] = new WavyLine(y, c);
  }
}

void draw() {
  background(219, 240, 255);  
  for (int i = waves.length - 1; i >= 0; i--) {
    waves[i].display();
  }
}

class WavyLine {
  int numDiv;
  color fillColor;
  PVector[] positions;
  PVector[] directions;

  WavyLine(float y, color c) {
    numDiv = int(8 * displayDensity);
    positions = new PVector[numDiv];
    directions = new PVector[numDiv];    

    fillColor = c;
    for (int i = 0; i < numDiv; i++) {
      float x = 0;      
      if (0 < i) {
        if (i == numDiv - 1) x = width;
        else x = random(i/float(numDiv) * width * 1.2,
                        (i+1)/float(numDiv) * width * 0.8);
      }
      positions[i] = new PVector(x, y + random(-20, 20));
      directions[i] = PVector.fromAngle(random(-0.5 * HALF_PI,
                      +0.5 * HALF_PI));
      directions[i].mult(20 * displayDensity);
    }    
  }

  void display() {
    noStroke();
    fill(fillColor);
    beginShape();
    for (int i = 0; i < numDiv - 1; i++) {
      vertex(positions[i].x, positions[i].y);
      PVector cp1 = PVector.add(positions[i], directions[i]);
      PVector cp2 = PVector.sub(positions[i+1], directions[i+1]);  
      bezierVertex(cp1.x, cp1.y, cp2.x, cp2.y,
                   positions[i+1].x, positions[i+1].y);
    }
    vertex(width, height);
    vertex(0, height);    
    endShape();
  }
}

Listing 4-7.Seascape Sketch, Using Objects to Draw Several Shapes

在这个例子中,我们使用内置的displayDensity变量,我们已经在第三章中讨论过,以确保贝塞尔曲线细分的数量和由控制点定义的切线向量的长度与屏幕的 DPI 成比例,这样我们就可以在不同的设备上获得一致的输出(图 4-9 )。

A432415_1_En_4_Fig9_HTML.jpg

图 4-9。

Final result of the seascape sketch

在这个例子中,我们应该注意的一些额外的事情是在setup()中的orientation(LANDSCAPE)调用来强制草图以横向方向运行,以及使用 HSB 颜色空间来更容易地实现从较亮到较暗的蓝色色调的渐变。

形状属性

处理允许我们设置几个决定形状最终外观的属性。我们已经使用了填充和描边颜色属性,但是还有更多。例如,我们不仅可以设置线条的颜色,还可以设置线条的粗细(粗细)、端点和连接连续线段的连接点,如清单 4-8 所示。

size(800, 480);
float x = width/2;
float y = height/2;
stroke(0, 150);
strokeWeight(10);
strokeJoin(ROUND);
strokeCap(ROUND);
beginShape(LINES);
for (int i = 0; i < 100; i++) {
  float px = x;
  float py = y;
  float nx = x + (random(0, 1) > 0.5? -1: +1) * 50;
  float ny = y + (random(0, 1) > 0.5? -1: +1) * 50;
  if (0 <= nx && nx < width && 0 <= ny && ny < height) {
    vertex(px, py);
    vertex(nx, ny);
    x = nx;
    y = ny;
  }
}
endShape();  
Listing 4-8.Setting Stroke Attributes

尝试使用不同的笔划连接值(MITERBEVELROUND)、端点值(SQUAREPROJECTROUND)和权重值绘制草图,并与图 4-10 中所示的输出进行比较。

A432415_1_En_4_Fig10_HTML.jpg

图 4-10。

Output of sketch demonstrating stroke attributes

尽管这些属性通常是为整个形状定义的(即,它们应用于形状中的所有顶点),但 P2D 和 P3D 渲染器允许您定义逐顶点属性。例如,可以为每个顶点单独设置填充颜色,然后处理将在中间位置插入颜色。清单 4-9 中草图的输出类似于第二章中的 HSB 色轮,但是这里我们不需要设置中间色,因为 P2D 渲染器会自动设置(图 4-11 )。

A432415_1_En_4_Fig11_HTML.jpg

图 4-11。

Color wheel obtained by interpolation of the fill color

size(300, 300, P2D);
colorMode(HSB, 360, 100, 100);
background(0, 0, 100);
translate(width/2, height/2);
noStroke();
beginShape(TRIANGLE_FAN);
    fill(TWO_PI, 0, 100);
    vertex(0, 0);
    for (int i = 0; i <= 10; i++) {
    float a = map(i, 0, 10, 0, 360);
    float x = 150 * cos(radians(a));
    float y = 150 * sin(radians(a));
    fill(a, 100, 100);
    vertex(x, y);
  }
endShape();
Listing 4-9.Using Color Interpolation in the P2D Renderer

形状样式

我们可以通过处理设置的所有形状属性决定了当前的“样式”正如我们之前看到的,填充颜色、笔画颜色、粗细、帽和连接都是样式属性。随着我们的草图变得越来越复杂,尤其是对象在绘制时会设置自己的属性,很容易会忘记当前的样式。

处理包括两个函数,pushStyle()popStyle(),它们可以方便地管理我们形状的样式,特别是当我们在执行草图的不同点改变许多样式属性时。pushStyle()保存所有样式属性的当前值,而popStyle()将所有样式属性恢复到我们上次调用pushStyle()时保存的值。如果我们在连续的pushStyle()popStyle()调用之间设置一种全新的风格,我们可以确定两种不同的风格不会混淆。清单 4-10 提供了这种技术的一个简单例子。

Circle[] circles = new Circle[100];

void setup() {
  size(800, 800);
  for (int i = 0; i < circles.length; i++) {
    circles[i] = new Circle();
  }
}

void draw() {
  translate(width/2, height/2);
  rotate(frameCount * 0.01);
  for (int i = 0; i < circles.length; i++) {
    circles[i].display();
  }  
}

class Circle {
  float x, y, r, w;
  color fc, sc;
  Circle() {
    x = random(-width/2, width/2);
    y = random(-height/2, height/2);
    r = random(10, 100);
    w = random(2, 10);
    fc = color(random(255), random(255), random(255));
    sc = color(random(255), random(255), random(255));
  }
  void display() {
    pushStyle();
    stroke(sc);
    strokeWeight(w);
    fill(fc);
    ellipse(x, y, r, r);
    popStyle();
  }
}

Listing 4-10.Saving and Restoring Styles with pushStyle() and popStyle()

形状轮廓

Processing API 中另一个有用的特性是使用等高线进行减法绘制。包含在beginContour()endContour()函数之间的所有顶点定义了一个从较大形状中移除的负形状,如清单 4-11 所示,它生成了如图 4-12 所示的输出。

A432415_1_En_4_Fig12_HTML.jpg

图 4-12。

Shape with holes created with beginContour/endContour

void setup() {
  size(300, 300);  
}

void draw() {
  background(190);
  translate(width/2, height/2);
  float r = width/2;
  beginShape();
  circleVertices(0, 0, r, 0, TWO_PI);  
  makeContour(0, 0, r/4);
  makeContour(-r/2, -r/2, r/4);
  makeContour(+r/2, -r/2, r/4);
  makeContour(-r/2, +r/2, r/4);
  makeContour(+r/2, +r/2, r/4);  
  endShape();
}

void makeContour(float xc, float yc, float r) {
  beginContour();
  circleVertices(xc, yc, r, TWO_PI, 0);
  endContour();
}

void circleVertices(float xc, float yc, float r, float a0, float a1) {
  for (int i = 0; i <= 30; i++) {
    float a = map(i, 0, 30, a0, a1);
    vertex(xc + r * cos(a), yc + r * sin(a));
  }
}

Listing 4-11.Making Holes Inside a Shape with beginContour() and endContour()

Note

轮廓只能用于POLYGON类型的形状。此外,轮廓中顶点的“缠绕”或方向(顺时针或逆时针)必须与包含形状的方向相反。

PShape 类

正如我们到目前为止所看到的,绘制形状包括在每一帧中重复调用beginShape()vertex()endShape()。我们可以预先计算坐标并将它们放入自定义类中,就像我们在海景示例中所做的那样;然而,Processing 已经提供了一个内置的类来保存形状数据,恰当地称为PShape。这个类不仅帮助我们保持代码更有组织性和可读性,还允许我们从矢量图形文件(SVG)中读取形状,并且在使用 P2D 渲染器时,提高我们草图的性能。

创建 PShapes

通过调用createShape()函数可以创建一个PShape对象,我们可以通过三种不同的方式向它传递适当的参数:

  1. 如果没有提供参数,createShape()返回一个空的PShape,我们可以使用beginShape()vertex(),endShape()来构建一个定制的形状。
  2. 提供原始类型(ELLIPSERECT等)。)和初始化原始形状所需的附加参数。
  3. 指定单个GROUP参数,这会产生一个可用于包含其他形状(自定义或原始)的PShape

一旦我们创建并正确初始化了PShape对象,我们就可以使用shape()函数任意多次绘制它,如清单 4-12 和图 4-13 所示。

A432415_1_En_4_Fig13_HTML.jpg

图 4-13。

Primitive, custom, and group PShape objects

size(650, 200, P2D);
PShape circle = createShape(ELLIPSE, 100, 100, 100, 100);
PShape poly = createShape();
poly.beginShape(QUADS);
poly.vertex(200, 50);
poly.vertex(300, 50);
poly.vertex(300, 150);
poly.vertex(200, 150);
poly.endShape();
PShape group = createShape(GROUP);
group.addChild(circle);
group.addChild(poly);
shape(circle);
shape(poly);
translate(300, 0);
shape(group);
Listing 4-12.Creating and Drawing PShape Objects

我们可以使用在本章第一节中学到的所有形状绘制功能来创建自定义形状;唯一的区别是我们需要调用相应的PShape对象上的函数。

当我们需要处理一个在草图运行时不会改变的非常大的几何体时,组合形状非常有用。如果没有PShape,每一帧的顶点都会被复制到 GPU 内存中,这样会降低帧率。然而,如果我们将所有这些顶点打包在一个PShape中,并使用 P2D 渲染器,这个拷贝只发生一次,这会带来更好的性能。这对于受电池电量使用限制的移动设备尤其重要。作为一个例子,让我们考虑一下模式示例中演示|性能下的CubicGridImmediate(编号PShape)和CubicGridRetained ( PShape)内置示例的帧速率。两张草图创造了完全相同的几何图形,一个半透明立方体的 3D 网格;然而,第一个草图勉强超过每秒 10 帧(fps),而第二个草图在大多数设备上将以每秒 60 帧的速度运行。

尽管PShape几何体原则上必须是静态的,才能实现这些性能提升,但在一定程度上修改顶点颜色和位置仍然是可能的,而不会降低速度。如果我们的修改只应用于一个较大的组中的子形状的子集,性能应该保持很高。让我们看看清单 4-13 中的场景,它生成了图 4-14 中描述的输出。

A432415_1_En_4_Fig14_HTML.jpg

图 4-14。

Modifying the fill color of a child shape inside a group

PShape grid, sel;

void setup() {
  fullScreen(P2D);
  orientation(LANDSCAPE);
  grid = createShape(GROUP);
  for (int j = 0; j < 4; j++) {
    float y0 = map(j, 0, 4, 0, height);
    float y1 = map(j+1, 0, 4, 0, height);        
    for (int i = 0; i < 8; i++) {
      float x0 = map(i, 0, 8, 0, width);
      float x1 = map(i+1, 0, 8, 0, width);
      PShape sh = createShape(RECT, x0, y0, x1 - x0, y1 - y0, 30);
      grid.addChild(sh);
    }
  }
}

void draw() {
  background(180);
  shape(grid);
}

void mousePressed() {
  int i = int(float(mouseX) / width * 8);
  int j = int(float(mouseY) / height * 4);
  int idx = j * 8 + i;
  sel = grid.getChild(idx);
  sel.setFill(color(#FA2D45));
}

void mouseReleased() {
  sel.setFill(color(#C252FF));
}

Listing 4-13.Modifying Attributes of Child Shapes After Creation

这里,一次只修改一个子形状,获得新的填充颜色。只有更新的信息才会传输到 GPU,从而保持性能稳定。然而,随着更多的形状被同时修改,这种增益将会减少,直到性能变得等同于没有任何PShape对象的绘制。

Note

一个PShape对象的大多数属性在创建后都可以修改。在 https://processing.org/reference/PShape.html 可以找到所有可用的设置器功能。

从 SVG 加载形状

我们还可以通过使用loadShape()函数,使用PShape对象加载存储在文件中的几何图形。该函数接受 SVG 和 OBJ 格式,后者在 P3D 渲染器中受支持。SVG 代表可缩放矢量图形,SVG 格式的文件包含一个形状或一组形状的规范,其方式与处理几何图形的方式非常相似:作为顶点、样条或贝塞尔曲线的列表。

为了在我们的草图中加载一个 SVG 文件,我们首先需要将它放在草图的数据目录中。当我们在设备或仿真器上运行草图时,数据文件夹的所有内容都将被正确打包,以便可以从应用中访问它们。

Note

可以在草图的文件夹中手动创建数据目录。如果将媒体文件拖到 PDE 中,也会自动创建它。

一旦 SVG 被加载到一个PShape对象中,我们可以对它应用变换,比如平移或旋转,甚至改变它的样式属性,就像我们在清单 4-14 中所做的那样,在那里我们将三个 SVG 文件加载到不同的形状中(图 4-15 )。

A432415_1_En_4_Fig15_HTML.jpg

图 4-15。

Loading, modifying, and displaying SVGs

size(450, 200, P2D);
PShape cc = loadShape("cc.svg");
PShape moz = loadShape("mozilla.svg");
PShape ruby = loadShape("ruby.svg");
translate(30, 50);
cc.setFill(color(170, 116, 0));
cc.setStroke(color(255, 155, 0));
shape(cc);
translate(cc.width + 30, 0);
shape(moz);
translate(moz.width + 30, 0);
shape(ruby);
Listing 4-14.Load SVG Files into PShape Objects

SVG 文件对于绘制难以单独通过代码生成的复杂几何图形非常有用。复杂 SVG 的另一个优点是,它们可以按层次方式组织,子形状在组内,允许单独操纵子形状。让我们看一个绘制世界地图 SVG 的例子,它也包含国家的名称(清单 4-15 )。运行它应该产生图 4-16 。

A432415_1_En_4_Fig16_HTML.jpg

图 4-16。

Loading a map from an SVG file and selecting a country by its name attribute

PShape world;

void setup() {
  size(950, 620, P2D);
  world = loadShape("World-map.svg");
  for (PShape child: world.getChildren()) {
    if (child.getName().equals("algeria")) child.setFill(color(255, 0, 0));    
  }
}

void draw() {
  background(255);
  shape(world);
}

Listing 4-15.Selecting a Child Shape by Name and Setting Its Attributes

请注意我们是如何遍历所有子形状的,可以使用getChildren()函数从包含组中检索这些子形状作为一个PShape对象的数组。

绘制图像

在我们的应用中加载和显示图像文件是一个基本功能,处理起来非常简单。处理支持 GIF、JPG、TGA 和 PNG 图像格式,并包括一个内置的类PImage,用于处理草图中的图像。PImage封装图像的所有信息,包括宽度、高度和单个像素。加载和显示图像可以通过两个函数来完成,loadImage()image(),如清单 4-16 所示。

fullScreen();
PImage img = loadImage("paine.jpg");  
image(img, 0, 0, width, height);
Listing 4-16.Loading and Displaying an Image

image()函数最多接受四个参数:屏幕上图像的 x 和 y 坐标,以及显示图像的宽度和高度。这些宽度和高度参数不需要与图像的原始分辨率相同,它可以从PImage对象中的PImage.widthPImage.height变量获得。不带宽度和高度参数调用image()会导致图像以其源分辨率绘制,这相当于调用image(img, 0, 0, img.width, img.height)

我们可以使用tint()函数将色调应用于整个图像,然后使用noTint()将其移除(否则,随后显示的所有图像将具有相同的色调,因为色调是另一种样式属性)。清单 4-17 举例说明了tint()noTint()的使用,其输出如图 4-17 所示。

A432415_1_En_4_Fig17_HTML.jpg

图 4-17。

Output of displaying an image with three different tints and no tinting

PImage img;
void setup() {
  size(800, 533);
  img = loadImage("paine.jpg");  
}

void draw() {  
  image(img, 0, 0, width/2, height/2);
  tint(255, 0, 0);
  image(img, width/2, 0, width/2, height/2);
  tint(0, 255, 0);
  image(img, 0, height/2, width/2, height/2);
  tint(0, 0, 255);
  image(img, width/2, height/2, width/2, height/2);  
  noTint();
}

Listing 4-17.Tinting an Image

Note

当加载图像或任何其他媒体文件(如 SVG)时,在setup()函数中这样做很重要,该函数仅在应用启动时调用。否则,图像将在每一帧中重复加载,使应用变慢,直至无法使用。

纹理形状

我们也可以使用图像文件纹理形状(只有在 P2D/P3D 渲染器)。纹理化本质上是指将图像环绕在形状周围,这样形状就不再是用单一颜色绘制的。这个过程要求我们指定图像的哪些部分对应于形状的每个顶点。纹理可能非常复杂,尤其是在处理 3D 中的不规则形状时。清单 4-18 展示了最简单的情况——纹理化一个矩形。

PImage img;
size(800, 533, P2D);
img = loadImage("paine.jpg");
beginShape();
texture(img);
vertex(100, 0, 0, 0);
vertex(width – 100, 0, img.width, 0);
vertex(width, height, img.width, img.height);
vertex(0, height, 0, img.height);
endShape();
Listing 4-18.Texturing a Rectangle with an Image Loaded from a File

正如我们在这个例子中看到的,我们需要为vertex()函数提供两个额外的参数。vertex(x, y, u, v)调用中的这些参数对应于纹理映射的 UV 坐标,并指示图像中的像素(u,v)将到达形状中的顶点(x,y)。渲染器将基于该信息确定所有其他像素-顶点对应关系。在我们的简单纹理代码中,结果如图 4-18 所示。

A432415_1_En_4_Fig18_HTML.jpg

图 4-18。

Textured 2D shape

绘图文本

文本是图形编程的另一个基本元素。Processing 提供了几个功能来在草图中绘制文本,并通过使用不同的字体和调整属性(如大小和对齐)来控制文本的外观。在接下来的几节中,我们将研究其中的一些函数。

加载和创建字体

在加工草图中绘制文本的第一步是加载位图字体。字体将被存储在一个PFont变量中,如果我们想在不同的时间用不同的字体绘图,我们可以在同一个草图中的不同PFont变量之间切换。内置的字体创建工具(位于“工具|创建字体…”下)允许我们从运行处理的 PC 或 Mac 计算机上可用的字体创建新的位图字体。该工具的界面如图 4-19 所示。一旦我们选择了字体、所需的大小和文件名,我们点击 OK,工具将在草图的数据目录中生成一个扩展名为. vlw 的字体文件,准备使用。

A432415_1_En_4_Fig19_HTML.jpg

图 4-19。

Font-creator tool in the PDE

为了加载我们的新字体并将其设置为当前字体,我们分别使用了loadFont()textFont()函数。一旦我们设置了想要的字体,我们就可以使用text()功能在屏幕上的任何地方绘制文本。清单 4-19 展示了所有这些功能。

size(450, 100);
PFont font = loadFont("SansSerif-32.vlw");
textFont(font);
fill(120);
text("On Exactitude in Science", 40, 60);
Listing 4-19.Loading a bitmap font generated with the font creator tool

text(str, x, y)调用中的xy参数让我们设置文本的屏幕位置。使用默认的文本对齐选项,它们表示第一个字符左下角的位置。图 4-20 显示了我们的文本绘制草图的输出。

A432415_1_En_4_Fig20_HTML.jpg

图 4-20。

Text output in Processing

创建一个. vwl 字体文件,然后将其加载到草图中的一个缺点是,因为字体是预先创建的,所以它必须包含所有可能的字符。这浪费了内存,尤其是如果我们最终只使用了其中的几个。或者,我们可以用createFont(name, size)函数动态创建字体,该函数接受系统范围的字体名称或 TrueType 的文件名。ttf)或 OpenType(。otf)字体,以及字体大小。只有草图中实际使用的角色才会被创建并存储在内存中。这显示在清单 4-20 中。

size(450, 100);
PFont font = createFont("SansSerif", 32);
textFont(font);
fill(120);
text("On Exactitude in Science", 40, 60);
Listing 4-20.Creating a Font on the Fly

Note

Android 提供了三种适用于任何应用的全系统字体:serif、sans-serif 和 monospaced。每种字体都有四种变体:普通、粗体、斜体和粗斜体,因此,例如,无衬线字体的字体名称是 SansSerif、SansSerif-Bold、SansSerif-Italic 和 SansSerif-BoldItalic。

如果我们不向text()提供任何其他参数,文本字符串将继续向右延伸,直到脱离屏幕,或者,如果字符串包含一个换行符(\n),则转到下一行。我们可以用四个参数设置一个矩形区域,xywh,如果需要的话,处理会通过将文本分成几行来自动容纳在矩形内,如清单 4-21 和图 4-21 所示。

A432415_1_En_4_Fig21_HTML.jpg

图 4-21。

Text output in Processing fitted inside a rectangular area

size(900, 300);
PFont font = createFont("Monospaced", 32);
textFont(font);
fill(120);
text("...In that Empire, the Art of Cartography attained such Perfection " +
     "that the map of a single Province occupied the entirety of a City, " +
     "and the map of the Empire, the entirety of a Province.", 20, 20,
     width - 40, height - 40);
Listing 4-21.Placing Text Inside a Rectangular Area

文本属性

除了字体的名称和大小,我们还可以控制文本对齐(LEFTRIGHTCENTERBOTTOMTOP)和文本行之间的行距(清单 4-22 )。图 4-22 显示了设置这些属性的结果。尽管在本例中我们只设置了水平方向的对齐,但我们也可以通过向textAlign()CENTERBOTTOMTOP提供第二个参数来设置垂直方向的对齐。

A432415_1_En_4_Fig22_HTML.jpg

图 4-22。

Drawing text with different fonts and attributes

size(900, 300);
PFont titleFont = createFont("Serif", 32);
PFont textFont = createFont("Serif", 28);
textFont(titleFont);
textAlign(CENTER);
fill(120);
text("On Exactitude in Science", width/2, 60);
textFont(textFont);
textAlign(RIGHT);
textLeading(60);
text("...In that Empire, the Art of Cartography attained such Perfection " +
     "that the map of a single Province occupied the entirety of a City, " +
     "and the map of the Empire, the entirety of a Province.",
     20, 100, width - 40, height - 20);
Listing 4-22.Setting Text Alignment and Leading

缩放文本

在第三章中,我们看到了如何使用displayDensity变量根据设备的 DPI 缩放草图中的图形。这种技术允许我们在不同分辨率和屏幕尺寸的设备上保持一致的视觉输出,我们也可以在绘制文本时使用它。我们需要做的就是将字体大小乘以displayDensity,如清单 4-23 所示。我们在图 4-23 中看到,屏幕上的文本大小在三个具有不同 dpi 的设备上保持不变。我们在这个例子中引入的一个额外的函数是loadStrings(),它读取数据文件夹中的一个文本文件,并返回一个包含文件中所有文本行的字符串数组。

A432415_1_En_4_Fig23_HTML.jpg

图 4-23。

From left to right, text output on a Samsung Galaxy Tab 4 (7”, 1280 × 800 px, 216 dpi), Nexus 5X (5.2”, 1920 × 800 px, 424 dpi), and a Moto E (4.3”, 960 × 540 px, 256 dpi)

fullScreen();
orientation(PORTRAIT);
PFont titleFont = createFont("Serif-Bold", 25 * displayDensity);
PFont bodyFont = createFont("Serif", 18 * displayDensity);
PFont footFont = createFont("Serif-Italic", 15 * displayDensity);
String[] lines = loadStrings("borges.txt");
String title = lines[0];
String body = lines[1];
String footer = lines[2];
textFont(titleFont);
textAlign(CENTER, CENTER);
fill(120);
text(title, 10, 10, width - 20, height * 0.1 - 20);
textFont(bodyFont);
text(body, 10, height * 0.1, width - 20, height * 0.8);
textAlign(RIGHT, BOTTOM);     
textFont(footFont);
text(footer, 10, height * 0.9 + 10, width - 20, height * 0.1 - 20);
Listing 4-23.Scaling Font Size by the Display Density

摘要

这是一个很长的章节,但是我们涵盖了很多重要的概念和技术!基于我们在第二章中看到的 Processing 语言的介绍,我们现在已经学习了使用各种几何图形、样条和贝塞尔曲线绘制形状的细节;用不同的属性调整它们的外观;并用PShape类优化代码。除此之外,我们还学会了如何在草图中绘制图像和文本。通过将这些资源付诸实践,我们应该能够创建几乎任何我们能想到的视觉合成,并将其转化为 Android 应用。

五、触摸屏交互

本章将详细介绍 Android 处理中的触摸屏支持。我们将学习如何在草图中捕捉单点和多点触摸事件,如何处理这些事件以实现基于触摸的交互,如选择、滚动、滑动和挤压,以及如何使用虚拟键盘。

Android 中的触摸事件

在这一章中,我们谈到了一个专门针对移动设备的话题。自 2007 年推出 iPhone 以来,触摸屏已经成为与智能手机、平板电脑和可穿戴设备交互的主要机制。旧手机通常包括一个物理键盘,但今天这些已经很少了,键盘输入是通过虚拟或软件键盘实现的。

触摸屏交互非常直接和直观,在将手势作为体验核心部分的应用中非常有用(例如,笔记和绘图应用)。触摸的物理特性使其成为移动设备上创意应用的理想交互方式。

Android 系统提供了对触摸屏交互的全面支持,从单点触摸事件、用手指触发的多点触摸手势到手写笔输入。由于其通用性,Android 中的触摸 API 可能很难使用,因此 Android 的处理用一个更简单的 API 包装了这种复杂性,尽管它可能不会覆盖所有的触摸屏功能,但它使创建广泛的基于触摸的交互成为可能。

基本触摸事件

从最早的版本开始,Java 处理模式就包含了变量和函数来处理与鼠标的交互。所有这些变量和函数在 Android 模式下也是可用的,它们的工作方式与最初的 Java 模式非常相似,至少对于单触事件是如此。当然,不同之处在于事件是由我们的手指按下触摸屏而不是鼠标的移动触发的。我们在前面的章节中使用了一些鼠标 API 特别是,mouseXmouseY变量——来跟踪触摸点的位置。清单 5-1 展示了这个 API 的一个基本例子,在这里我们控制一些形状的位置(图 5-1 )。

A432415_1_En_5_Fig1_HTML.jpg

图 5-1。

Simple use of mouseX and mouseY variables to track touch position

void setup() {
  fullScreen();
  strokeWeight(20);  
  fill(#3B91FF);
}

void draw() {
  background(#FFD53B);
  stroke(#3B91FF);
  line(0, 0, mouseX, mouseY);
  line(width, 0, mouseX, mouseY);
  line(width, height, mouseX, mouseY);
  line(0, height, mouseX, mouseY);
  noStroke();
  ellipse(mouseX, mouseY, 200, 200);
}

Listing 5-1.Simple Touch Event Using the Mouse Variables

与实际鼠标的一个重要区别是存在“按下”状态:我们可以在不按下任何按钮的情况下移动鼠标,当我们按下鼠标时,会触发一个“拖动”事件,直到我们松开按钮。对于触摸屏,“鼠标”在移动时总是处于“按下”状态。这种差异使得典型的基于鼠标的交互——悬停——无效,当我们在屏幕的预定义区域内移动鼠标但不按任何按钮时,就会发生悬停。

当触摸开始/结束时,或者当触摸点改变位置时,我们可以精确地执行特定的任务。每当这些事件发生时,处理都会自动调用函数mousePressed()mousedDragged()mouseReleased(),因此我们可以在其中实现我们的事件处理功能。例如,在清单 5-2 中,只要我们在屏幕上拖动手指,我们就会在鼠标位置绘制一个不断增长的椭圆。此外,请注意使用displayDensity来缩放椭圆的初始半径及其在拖动时的常规增量,这样无论设备的 DPI 如何,它都显示相同的大小。

boolean drawing = false;
float radius;

void setup() {
  fullScreen();
  noStroke();
  fill(100, 100);
}

void draw() {
  background(255);
  if (drawing) {
    ellipse(mouseX, mouseY, radius, radius);
  }
}

void mousePressed() {
  drawing = true;
  radius = 70 * displayDensity;
}

void mouseReleased() {  
  drawing = false;
}

void mouseDragged() {
  radius += 0.5 * displayDensity;
}

Listing 5-2.Detecting Press, Drag, and Release “Mouse” Events

我们可以在这个简单的例子的基础上建立一个到目前为止创建的所有椭圆的列表,并给它们分配随机的 RGB 颜色。为此,我们创建一个类来存储椭圆的位置、大小和颜色,如清单 5-3 所示。

ArrayList<Circle> circles;
Circle newCircle;

void setup() {
  fullScreen();
  circles = new ArrayList<Circle>();
  noStroke();
}

void draw() {
  background(255);
  for (Circle c: circles) {
    c.draw();
  }
  if (newCircle != null) newCircle.draw();
}

void mousePressed() {
  newCircle = new Circle(mouseX, mouseY);
}

void mouseReleased() {  
  circles.add(newCircle);
  newCircle = null;
}

void mouseDragged() {
  newCircle.setPosition(mouseX, mouseY);
  newCircle.grow();
}

class Circle {
  color c;
  float x, y, r;
  Circle(float x, float y) {
    this.x = x;
    this.y = y;
    r = 70 * displayDensity;
    c = color(random(255), random(255), random(255), 100);
  }
  void grow() {
    r += 0.5 * displayDensity;
  }
  void setPosition(float x, float y) {
    this.x = x;
    this.y = y;
  }
  void draw() {
    fill(c);
    ellipse(x, y, r, r);
  }
}

Listing 5-3.Drawing Multiple Growing Ellipses with Mouse Events

除了mouseX/Y变量之外,处理还将触摸指针的先前位置存储在pmouseX/Y变量中。使用这些变量,我们可以编写一个简单的绘图草图,其中我们用一条线段连接以前和当前的鼠标位置,只要用户一直按着屏幕,就可以绘制一条连续的路径。我们可以用内置的布尔变量mousePressed来判断用户是否在按屏幕。该草图如清单 5-4 所示,用其绘制的图纸如图 5-2 所示。

A432415_1_En_5_Fig2_HTML.jpg

图 5-2。

Generating a line drawing with our sketch

void setup() {
  fullScreen();
  strokeWeight(10);
  stroke(100, 100);
}

void draw() {  
  if (mousePressed) line(pmouseX, pmouseY, mouseX, mouseY);
}

Listing 5-4.Simple Drawing Sketch Using Current and Previous Mouse Positions

先前和当前触摸位置之间的差异告诉我们手指在屏幕上滑动的速度。我们滑动得越快,这种差异就越大,所以我们可以用它来驱动物体的运动,就像我们在清单 5-3 中看到的圆圈一样。例如,圆圈的速度可以与滑动速度成比例。让我们通过添加一对速度变量(vx代表 x 方向,vy代表 y 方向)和一个setVelocity()方法到Circle类来实现这个想法,如清单 5-5 所示。

ArrayList<Circle> circles;
Circle newCircle;

void setup() {
  fullScreen();
  circles = new ArrayList<Circle>();
  noStroke();
}

void draw() {
  background(255);
  for (Circle c: circles) {
    c.draw();
  }
  if (newCircle != null) newCircle.draw();
}

void mousePressed() {
  newCircle = new Circle(mouseX, mouseY);
}

void mouseReleased() {  
  newCircle.setVelocity(mouseX - pmouseX, mouseY - pmouseY);
  circles.add(newCircle);
  newCircle = null;
}

void mouseDragged() {
  newCircle.setPosition(mouseX, mouseY);
  newCircle.grow();
}

class Circle {
  color c;
  float x, y, r, vx, vy;
  Circle(float x, float y) {
    this.x = x;
    this.y = y;
    r = 70 * displayDensity;
    c = color(random(255), random(255), random(255), 100);
  }
  void grow() {
    r += 0.5 * displayDensity;
  }
  void setPosition(float x, float y) {
    this.x = x;
    this.y = y;
  }
  void setVelocity(float vx, float vy) {
    this.vx = vx;
    this.vy = vy;
  }
  void draw() {
    x += vx;
    y += vy;
    if (x < 0 || x > width) vx = -vx;
    if (y < 0 || y > height) vy = -vy;    
    fill(c);
    ellipse(x, y, r, r);
  }
}

Listing 5-5.Using the Difference Between Current and Previous Mouse Positions to Calculate the Velocity of Graphical Elements in Our Sketch

尽管在这个草图的原始版本中,圆圈在我们释放触摸后立即停止移动,但它们现在继续以与滑动速度成比例的速度沿着滑动方向移动,因为我们一直将vxvy添加到它们的当前位置。此外,通过if (x < 0 || x > width) vx = -vx;if (y < 0 || y > height) vy = -vy;线,我们实现了一个非常简单的碰撞检测算法,其中如果一个圆移动经过屏幕的边缘,它的速度就会反转,这样它的移动就会反向朝向屏幕的内部。换句话说,圆圈在屏幕边缘反弹。

作为对这个例子的最后补充,我们将实现一个 Clear 按钮。由于我们每次触摸屏幕时都不断添加圆圈,最终屏幕会变得杂乱无章。按钮只是屏幕上的一个矩形区域,当按下时会触发一些动作,在这种情况下,会删除我们从开始添加的所有圆圈。事实上,我们不需要太多额外的代码来实现这个按钮。清单 5-6 显示了我们需要在draw()mouseReleased()中加入什么来绘制和触发按钮(图 5-3 )。

A432415_1_En_5_Fig3_HTML.jpg

图 5-3。

Outcome of the circle-drawing sketch, complete with a Clear button

ArrayList<Circle> circles;
Circle newCircle;
float buttonHeight = 200 * displayDensity;
...
void draw() {
  background(255);
  for (Circle c: circles) {
    c.draw();
  }
  if (newCircle != null) newCircle.draw();
  fill(100, 180);
  rect(0, height - buttonHeight, width, buttonHeight);
  fill(80);
  text("Touch this area to clear", 0, height - buttonHeight, width, buttonHeight);
}
...
void mouseReleased() {  
  newCircle.setVelocity(mouseX - pmouseX, mouseY - pmouseY);
  circles.add(newCircle);
  newCircle = null;
  if (height - buttonHeight < mouseY) circles.clear();
}
...
Listing 5-6.Implementation of a Simple Clear Button

这个例子向我们展示了单触式事件可以走多远,以及如何在我们的应用中使用它们来控制移动和交互。我们可以将这些技术扩展到具有更多界面动作和对象行为的更复杂的情况。

多点触摸事件

我们已经学习了如何使用继承自处理 Java 的鼠标 API 来处理单触事件。然而,Android 设备上的触摸屏可以同时跟踪几个触摸点,最大值由屏幕的功能决定。一些设备可以同时跟踪多达十个触摸点。

处理包括touches数组来提供关于触摸点的信息。该数组中的每个元素都包含一个唯一的数字标识符,允许我们跨连续帧跟踪指针,并检索其当前的 x 和 y 坐标,以及指针的压力和面积。手机和平板电脑上的电容式触摸屏不仅能够测量触摸点的位置,还能测量我们施加在屏幕上的压力。面积是指针大小的近似度量,它与压力有关,因为我们越用力将手指按在屏幕上,接触面积就应该越大。

每次检测到新的触摸点时,处理将触发startTouch()功能。反之,当一个触摸点被释放时,endTouch()将被调用。与针对单次触摸事件的mouseDragged()功能类似,每次当前触摸点改变位置时,都会调用touchMoved()功能。同样,类似于mousePressed,有一个touchPressed逻辑变量,根据是否检测到至少一个触摸点来存储真或假。清单 5-7 展示了所有这些功能,其输出在图 5-4 中显示了多个触摸点。

A432415_1_En_5_Fig4_HTML.jpg

图 5-4。

Output of simple multi-touch example Note

压力和面积以 0 到 1 之间的标准值给出,需要根据屏幕分辨率(压力)和触摸屏校准(面积)进行缩放。

void setup() {
  fullScreen();
  noStroke();
  colorMode(HSB, 350, 100, 100);
  textFont(createFont("SansSerif", displayDensity * 24));
}

void draw() {
  background(30, 0, 100);
  fill(30, 0, 20);
  text("Number of touch points: " + touches.length, 20, displayDensity * 50);
  for (int i = 0; i < touches.length; i++) {
    float s = displayDensity * map(touches[i].area, 0, 1, 30, 300);    
    fill(30, map(touches[i].pressure, 0.6, 1.6, 0, 100), 70, 200);
    ellipse(touches[i].x, touches[i].y, s, s);
  }
}

void touchStarted() {
  println("Touch started");
}

void touchEnded() {
  println("Touch ended");
}

void touchMoved() {
  println("Touch moved");
}

Listing 5-7.Accessing Properties of Multiple Touch Points

用于转换标准化面积和压力值的映射是特定于设备的。在这种情况下,大小范围从 0 到 1,这是在 Nexus 5X 中观察到的范围;但是,其他设备可能有不同的范围。压力的情况类似,在同一 Nexus 设备上从 0.6 到 1.6 不等。

touches阵列中的每个触摸点都有一个唯一的 ID,我们可以用它来跟踪它的运动。触摸阵列中触摸点的索引不得用作其标识符,因为它可能从一帧到下一帧不相同(例如,一个触摸点可能是一帧中的元素 0,而下一帧中的元素 3)。另一方面,触摸 ID 对于每个触摸点都是唯一的,因为它被按下直到最终释放。

在下一个例子中,列表 5-8 ,我们将使用 touch ID 创建一个多点触摸绘画草图。每个手指将控制一个画笔,该画笔使用由触摸点的索引确定的 HSB 颜色绘制一个圆。这个想法是将这些画笔对象存储在一个哈希映射中,哈希映射是一种数据结构,也称为字典( https://developer.android.com/reference/java/util/HashMap.html ),我们可以使用它将值(在本例中为画笔)与唯一的键(触摸 id)相关联。

在这段代码中,当在touchStarted()函数中检测到触摸时,我们向哈希表添加一个新的画笔,当调用touchEnded()时,在touches数组中找不到现有画笔的键(ID)时,我们删除现有画笔。每当一个动作触发了touchMoved()功能,我们就更新所有的笔刷。该草图的典型输出如图 5-5 所示。

A432415_1_En_5_Fig5_HTML.jpg

图 5-5。

Multi-touch painting

import java.util.*;

HashMap<Integer, Brush> brushes;

void setup() {
  fullScreen();
  brushes = new HashMap<Integer, Brush>();
  noStroke();
  colorMode(HSB, 360, 100, 100);
  background(0, 0, 100);
}

void draw() {  
  for (Brush b: brushes.values()) b.draw();
}

void touchStarted() {
  for (int i = 0; i < touches.length; i++) {
    if (!brushes.containsKey(touches[i].id)) {
      brushes.put(touches[i].id, new Brush(i));
    }
  }
}

void touchEnded() {
  Set<Integer> ids = new HashSet<Integer>(brushes.keySet());
  for (int id: ids) {
    boolean found = false;
    for (int i = 0; i < touches.length; i++) {
      if (touches[i].id == id) found = true;
    }
    if (!found) brushes.remove(id);
  }
}

void touchMoved() {
  for (int i = 0; i < touches.length; i++) {
    Brush b = brushes.get(touches[i].id);
    b.update(touches[i].x, touches[i].y, touches[i].area);
  }
}

class Brush {
  color c;
  float x, y, s;
  Brush(int index) {
    c = color(map(index, 0, 10, 0, 360), 60, 75, 100);
  }
  void update(float x, float y, float s) {
    this.x = x;
    this.y = y;
    this.s = map(s, 0, 1, 50, 500);
  }
  void draw() {
    fill(c);
    ellipse(x, y, s, s);
  }
}

Listing 5-8.Painting with Multiple Brushes

有几件重要的事情需要注意。首先,我们可以确定touchStarted()touchEnded()只有在新的触摸点分别向下或向上时才会被调用。所以,我们在这些函数中需要做的就是识别哪个是传入指针,哪个是传出指针。在触摸释放的情况下,我们迭代哈希表中的所有当前键,直到我们在touches数组中找到一个不对应于有效 id 的键。因为我们在遍历哈希表的键时修改了哈希表,所以我们需要用Set<Integer> ids = new HashSet<Integer>(brushes.keySet());创建一个原始键集的副本,然后执行搜索和删除操作。

基于触摸的交互

为移动应用创建一个直观且吸引人的界面并不容易;它需要理解用户界面(UI)原理、实践和大量的迭代。除了低级的单点和多点触摸处理功能,Android 的处理不提供任何内置的 UI 功能,因此我们有很大的自由来定义我们的应用将如何管理与用户的交互。在这一节中,我们将回顾一些基本技术,这些技术可以应用于许多不同的情况。

形状选择

回到第四章,我们回顾了使用PShape对象存储复杂的 SVG 形状,并通过 P2D 或 P3D 渲染器增加帧速率。由于 SVG 形状由子形状组成,我们可能希望通过触摸单独选择这些子形状,因此了解如何执行测试以确定触摸点是否落在PShape对象内是很有用的。如果我们正在处理一个基本的形状,比如一个矩形或者一个圆形,我们可以编写一个简单的针对这个形状的测试;然而,对于不规则的形状,比如地图上的国家,我们需要一个更通用的方法。PShape类有一个名为getTessellation()的函数,它返回一个与源形状完全相同的新形状,但仅由三角形组成(这个三角形集合决定了更复杂形状的“镶嵌”)。由于很容易确定一个点是否落在一个三角形内( http://blackpawn.com/texts/pointinpoly/default.html ),我们可以检查鼠标或触摸位置是否落在镶嵌的任何三角形内,如果是,我们可以断定已经选择了较大的形状。这就是我们在清单 5-9 中所做的,其结果如图 5-6 所示。

A432415_1_En_5_Fig6_HTML.jpg

图 5-6。

Selecting a country inside an SVG shape with touch events

PShape world, country;

void setup() {
  fullScreen(P2D);
  orientation(LANDSCAPE);
  world = loadShape("World-map.svg");
  world.scale(width / world.width);
}

void draw() {
  background(255);
  if (mousePressed) {
    if (country != null) country.setFill(color(0));
    for (PShape child: world.getChildren()) {
      if (child.getVertexCount() == 0) continue;
      PShape tess = child.getTessellation();
      boolean inside = false;
      for (int i = 0; i < tess.getVertexCount(); i += 3) {
        PVector v0 = tess.getVertex(i);
        PVector v1 = tess.getVertex(i + 1);
        PVector v2 = tess.getVertex(i + 2);
        if (insideTriangle(new PVector(mouseX, mouseY), v0, v1, v2)) {
          inside = true;
          country = child;
          break;
        }      
      }
      if (inside) {
        country.setFill(color(255, 0, 0));
        break;
      }      
    }
  }  
  shape(world);
}

boolean insideTriangle(PVector pt, PVector v1, PVector v2, PVector v3) {
  boolean b1, b2, b3;
  b1 = sign(pt, v1, v2) < 0.0f;
  b2 = sign(pt, v2, v3) < 0.0f;
  b3 = sign(pt, v3, v1) < 0.0f;
  return ((b1 == b2) && (b2 == b3));
}

float sign (PVector p1, PVector p2, PVector p3) {
  return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
}

Listing 5-9.Selecting a Child Shape Inside a Group Shape with Touch Events

卷动

滚动是与移动设备交互的另一种基本模式。由于相对于笔记本电脑和其他计算机来说,它们的屏幕尺寸较小,所以信息通常不能一次显示在一个页面上。由沿屏幕边缘的触摸位移控制的(水平或垂直)滚动条是最常见的滚动功能。

清单 5-10 中的代码示例包括一个非常简单的垂直scrollbar类,它跟踪沿 y 轴的位移,以便平移图形元素,从而显示应该可见的元素。这个滚动条实现的关键部分是计算所有元素的总高度,并使用它来确定我们可以向下滚动滚动条多远,直到到达最后一个元素。该类中的update()方法获取鼠标/触摸拖动的量,并更新变量translateY,该变量包含垂直平移。

ScrollBar scrollbar;
int numItems = 20;

void setup() {
  fullScreen(P2D);
  orientation(PORTRAIT);
  scrollbar = new ScrollBar(0.2 * height * numItems, 0.1 * width);
  noStroke();
}

void draw() {
  background(255);
  pushMatrix();
  translate(0, scrollbar.translateY);
  for (int i = 0; i < numItems; i++) {
    fill(map(i, 0, numItems - 1, 220, 0));
    rect(20, i * 0.2 * height + 20, width - 40, 0.2 * height - 20);
  }
  popMatrix();
  scrollbar.draw();
}

public void mousePressed() {
  scrollbar.open();
}

public void mouseDragged() {
  scrollbar.update(mouseY - pmouseY);
}

void mouseReleased() {
  scrollbar.close();
}

class ScrollBar {
  float totalHeight;
  float translateY;
  float opacity;
  float barWidth;

  ScrollBar(float h, float w) {
    totalHeight = h;
    barWidth = w;
    translateY = 0;
    opacity = 0;    
  }

  void open() {
    opacity = 150;
  }

  void close() {
    opacity = 0;
  }

  void update(float dy) {      
    if (totalHeight + translateY + dy > height) {
      translateY += dy;
      if (translateY > 0) translateY = 0;      
    }
  }

  void draw() {
    if (0 < opacity) {
      float frac = (height / totalHeight);
      float x = width - 1.5 * barWidth;
      float y = PApplet.map(translateY / totalHeight, -1, 0, height, 0);
      float w = barWidth;
      float h = frac * height;
      pushStyle();
      fill(150, opacity);
      rect(x, y, w, h, 0.2 * w);
      popStyle();
    }
  }
}

Listing 5-10.Implementing a Scrolling Bar

条件totalHeight + translateY + dy > height确保我们不会滚动到列表中的最后一个元素,而translateY > 0帮助我们避免向上滚动到屏幕顶部。我们可以在任何草图中使用这个类,只要我们能够提供想要显示的元素的总高度。图 5-7 显示了我们的滚动条的运行。

A432415_1_En_5_Fig7_HTML.jpg

图 5-7。

Scrolling through a pre-defined list of elements

滑动和挤压

滑动和挤压手势是智能手机和平板电脑上最具特色的两种触摸屏交互方式。我们通常使用滑动或拖动在连续的元素之间翻转,如页面或图像,收缩或缩放是放大和缩小图像或部分屏幕的默认手势。

虽然 Android 的处理不会在滑动或挤压发生时触发类似于mousePressed()touchMoved()的调用,但是我们可以在我们的处理草图中使用 Android API 来添加对这些事件的支持。谷歌的官方 Android 开发者网站有一个非常详细的部分,介绍了如何通过几个手势检测类来使用触摸手势( https://developer.android.com/training/gestures/index.html )。

Android 提供了一个GestureDetector,需要与一个包含特殊“事件处理”方法的 listener 类结合使用,当检测到滑动或缩放事件时会调用该方法。为了使用这个功能,我们需要从 Android SDK 添加一些导入,然后为手势监听器编写实现。将事件处理与 Android 的处理相集成的另一个重要元素是将事件对象从处理传递给surfaceTouchEvent()函数中的手势处理程序。每次有新的触摸事件时都会调用这个函数,但是它也需要调用父实现,以便处理可以执行默认的事件处理(更新鼠标和触摸变量等等)。所有这些都显示在清单 5-11 中,我们在其中进行刷卡检测,其输出如图 5-8 所示。

A432415_1_En_5_Fig8_HTML.jpg

图 5-8。

Detecting swipe direction

import android.os.Looper;
import android.view.MotionEvent;
import android.view.GestureDetector;
import android.view.GestureDetector.OnGestureListener;

GestureDetector detector;
PVector swipe = new PVector();

void setup() {  
  fullScreen();
  Looper.prepare();
  detector = new GestureDetector(surface.getActivity(),
                                 new SwipeListener());
  strokeWeight(20);
}

boolean surfaceTouchEvent(MotionEvent event) {
  detector.onTouchEvent(event);
  return super.surfaceTouchEvent(event);
}

void draw() {
  background(210);
  translate(width/2, height/2);
  drawArrow();
}

void drawArrow() {
  float x = swipe.x;
  float y = swipe.y;
  line(0, 0, x, y);  
  swipe.rotate(QUARTER_PI/2);
  swipe.mult(0.85);
  line(x, y, swipe.x, swipe.y);
  swipe.rotate(-QUARTER_PI);
  line(x, y, swipe.x, swipe.y);
  swipe.rotate(QUARTER_PI/2);
  swipe.mult(1/0.85);
}

class SwipeListener extends GestureDetector.SimpleOnGestureListener {    
  boolean onFling(MotionEvent event1, MotionEvent event2,
                  float velocityX, float velocityY) {
    swipe.set(velocityX, velocityY);
    swipe.normalize();
    swipe.mult(min(width/2, height/2));
    return true;
  }
}

Listing 5-11.Swipe Detection Using the Android API in Processing

请注意 setup()中对 Looper.prepare()的调用。Android 的 Looper 是一个类,允许一个 app 中的主线程接收其他线程的消息( https://developer.android.com/reference/android/os/Looper.html )。在这个特殊的例子中,我们需要 Looper 从草图中读取手势事件。

我们可以用类似的方式实现缩放检测器,在清单 5-12 中,我们用它来放大和缩小图像。

import android.os.Looper;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.ScaleGestureDetector.SimpleOnScaleGestureListener;

ScaleGestureDetector detector;
PImage img;
float scaleFactor = 1;

void setup() {  
  fullScreen();
  img = loadImage("jelly.jpg");
  Looper.prepare();
  detector = new ScaleGestureDetector(surface.getActivity(),
                                      new ScaleListener());
  imageMode(CENTER);
}

boolean surfaceTouchEvent(MotionEvent event) {
  detector.onTouchEvent(event);
  return super.surfaceTouchEvent(event);
}

void draw() {
  background(180);
  translate(width/2, height/2);
  scale(scaleFactor);
  image(img, 0, 0);
}

class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
  public boolean onScale(ScaleGestureDetector detector) {
    scaleFactor *= detector.getScaleFactor();
    scaleFactor = constrain(scaleFactor, 0.1, 5);
    return true;   
  }
}

Listing 5-12.Zooming In and Out with a Scale Detector

使用键盘

在本章的最后,我们将描述一些在按键输入处理中可用的函数。尽管在笔记本电脑和台式电脑上,键盘和鼠标通常是不同的输入设备,但触摸屏通过“软”或虚拟键盘在很大程度上吸收了键盘的功能。

处理 Java 有几个函数来处理键盘事件,变量来检查最后按下的键,如在线语言参考( https://processing.org/reference/ )和教程( https://processing.org/tutorials/interactivity/ )中所述。所有这些功能(除了keyTyped)在安卓模式下也是可用的。

如果设备有物理键盘,为了使用键盘 API,没有什么特别的事情要做,但是在虚拟键盘的情况下,我们需要首先打开它,并在用户完成输入后关闭它。Android 模式增加了两个功能来做到这一点,openKeyboard()closeKeyboard()。本章的最后一个例子,列出了 5-13 ,举例说明了它们的用法,以及键盘 API 中的一些其他函数。

String text = "touch the screen to type something";
boolean keyboard = false;

void setup() {
  fullScreen();
  textFont(createFont("Monospaced", 25 * displayDensity));
  textAlign(CENTER);
  fill(100);
}

void draw() {
  background(200);
  text(text, 0, 20, width, height - 40);
}

void keyReleased() {
  if (key == DELETE || key == BACKSPACE) {
    text = text.substring(text.length() - 1);
  } else {
    text += key;
  }
}

void mouseReleased() {
  if (!keyboard) {;
    text = "";
    openKeyboard();
    keyboard = true;
  } else {
    closeKeyboard();
    keyboard = false;
  }
}

Listing 5-13.Typing Text with the Virtual Keyboard

摘要

我们已经学习了如何在处理过程中处理单点和多点触摸事件,然后我们继续研究移动设备上常用的不同交互技术(选择、滚动、滑动、挤压和缩放)。有了这些工具,我们就可以实现最适合用户需求的交互类型的用户界面。

六、实时壁纸

在经历了 Android 的绘图和交互处理的细节之后,我们将在这一章结束本书的第二部分。我们将学习如何从相机加载图像来创建画廊壁纸,然后如何使用粒子系统来实现我们的手机或平板电脑的动画背景。

动态壁纸

动态壁纸是一种特殊类型的 Android 应用,作为主屏幕和锁屏的背景运行。它们是在 Android 2.1 版本中引入的,所以今天的大多数设备都支持它们。我们可以将目前为止看到的任何绘图和交互技术应用到动态壁纸上。这使我们能够为设备创建动态背景,同时访问传感器和网络数据并对用户输入做出反应。

我们可以从前面的章节中提取任何草图,并将其作为动态壁纸运行,但要设计和实现一个成功的壁纸,我们必须考虑壁纸应用的具体特征和局限性,这将在接下来的章节中讨论。

编写和安装动态壁纸

Android 处理让我们可以非常容易地将草图作为动态壁纸运行:我们所需要做的就是在 PDE 的 Android 菜单下选择“壁纸”选项。一旦草图作为壁纸安装,它不会立即开始运行。需要通过安卓的壁纸选择器来选择。为此,长按主屏幕中的任何空闲区域,然后在接下来出现的弹出菜单中选择“设置壁纸”选项,这可能与下面的截图不同,具体取决于所使用的 Android 或 UI 皮肤。壁纸将首先在预览模式下运行,在预览模式下,我们可以确认选择或继续浏览可用的壁纸。一旦选中,壁纸将在主屏幕的背景中重启,在启动图标的后面。图 6-1 显示了这些阶段。

A432415_1_En_6_Fig1_HTML.jpg

图 6-1。

Selecting the wallpaper option in the PDE (left), opening the wallpaper selector on the device (center), live wallpaper running in the home screen (right)

让我们考虑一下动态壁纸的几个重要方面。首先,它们覆盖了整个屏幕区域,所以我们应该用fullScreen()函数初始化它们。第二,动态壁纸在后台持续运行,因此会很快耗尽电池。因此,在打算作为壁纸运行的草图中不要使用非常繁重的计算是一个好主意。一般来说,这个建议对所有移动应用都有效,但对壁纸更是如此。减少动态壁纸电池使用的一个简单“技巧”是通过frameRate()功能设置一个较低的帧速率。使用 30 或 25 而不是默认的 60 将保持动画相当流畅,而不会以非常高的速度重绘屏幕和消耗更多的电池电量。

清单 6-1 中的代码生成了一个大小可变的椭圆网格,我们使用了fullScreen()——以确保壁纸覆盖整个屏幕——和frameRate(25),,因为屏幕不需要更新得更快。

void setup() {
  fullScreen();
  frameRate(25);
  noStroke();  
  fill(#FF03F3);
}

void draw() {
  background(#6268FF);
  float maxRad = 50 * displayDensity;  
  for (int i = 0; i < width/maxRad; i++) {
    float x = map(i, 0, int(width/maxRad) - 1, 0, width);
    for (int j = 0; j < height/maxRad; j++) {
      float y = map(j, 0, int(height/maxRad) - 1, 0, height);
      float t = millis() / 1000.0;
      float r = maxRad * cos(t + 0.1 * PI * i * j);
      ellipse(x, y, r, r);
    }
  }
}

Listing 6-1.Simple Live Wallpaper

要运行动态壁纸,我们首先需要在选择器中打开它。事实上,选择器向我们展示了一个壁纸的预览实例,我们可以将它设置为背景或者取消它来预览另一个。就在我们设置壁纸之后,预览实例被系统关闭,一个非预览实例被立即启动。我们可以通过调用previewWallpaper()函数来验证壁纸是否在预览模式下运行,该函数会相应地返回 true 或 false。这种检查使我们有机会在预览模式期间执行特殊的定制,例如加载更少的资源,因为壁纸不会运行很长时间,或者显示壁纸的代表性输出。

使用多个主屏幕

在介绍更高级的壁纸示例之前,我们将通过一个显示背景图像的简单应用来了解动态壁纸的一些重要特性。我们已经看到了如何在处理中加载和显示图像。同样的方法也适用于壁纸:只需将一个图像文件复制到草图的数据文件夹,用loadImage()加载,用image()显示,这样它就覆盖了设备的整个屏幕,如清单 6-2 所示。

PImage pic;
fullScreen();
pic = loadImage("paine.jpg");
image(pic, 0, 0, width, height);
Listing 6-2.Loading and Displaying an Image in Full-screen Mode

此代码的一个问题是,如果图像与屏幕的比例不同,图像会看起来失真。此外,壁纸总是以纵向模式显示,因此横向拍摄的照片会沿垂直方向拉伸。我们可以在image()函数中设置高度,使显示图像的宽高比与其原始比例相同,如清单 6-3 所示。

PImage pic;
fullScreen();
pic = loadImage("paine.jpg");
imageMode(CENTER);
float r = float(pic.width) / float(pic.height);
float h = width/r;
image(pic, width/2, height/2, width, h);
Listing 6-3.Keeping the Image Ratio

这里我们将图像模式设置为CENTER,因此image()函数的xy参数被取为图像的中心,这使得它在屏幕上居中变得容易。因为我们把它画成屏幕的宽度,所以我们需要使用高度width/r,其中r是图像的原始宽高比。

Note

一个PImage对象中的宽度和高度变量是整数,所以我们需要把它们转换成浮点数,用float(x)得到一个带小数点的正确比值,比如 1.6(float(1280)/float(800)的结果)或者 0.561(也就是 float(2576) / float(4592))。

然而,使用这种方法,我们可能会浪费大量的屏幕空间,尤其是当图像非常宽的时候。Android 提供了一个功能可以在这种情况下有所帮助:多个主屏幕。用户可以通过左右滑动来移动这些屏幕,壁纸将相应地向两侧移动适当的量,这是根据主屏幕的数量和这些屏幕的宽度来确定的。处理通过两个函数公开这些信息:wallpaperHomeCount()wallpaperOffset()。这首先返回主屏幕的当前数量(随着用户添加或删除主屏幕,它可能会在壁纸的生命周期内发生变化),而第二个返回 0 和 1 之间的浮点数,对应于主屏幕的水平位移:当我们在第一个屏幕时为 0,最后一个屏幕时为 1。清单 6-4 展示了我们如何使用这些函数来创建图像滚动交互。

PImage pic;
float ratio;

void setup() {
  fullScreen();
  pic = loadImage("paine.jpg");
  ratio = float(pic.width)/float(pic.height);
}

void draw() {
  background(0);
  float w = wallpaperHomeCount() * width;
  float h = w/ratio;
  float x = map(wallpaperOffset(), 0, 1, 0, -(wallpaperHomeCount()-1) * width);
  image(pic, x, 0, w, h);
}

Listing 6-4.Image Scrolling Across Home Screens

我们使用wallpaperHomeCount() * width作为图像的显示宽度,跨越所有屏幕,当用户向右滑动时,我们将图像向左平移位移x。这样,图像的正确部分显示在当前屏幕上,平滑过渡,因为wallpaperOffset()在 0 和 1 之间连续变化(图 6-2 )。

A432415_1_En_6_Fig2_HTML.jpg

图 6-2。

Interpretation of the offset function in live wallpapers Note

当在屏幕上滑动时,我们可能会注意到断断续续的动画,特别是在高分辨率屏幕的设备上。这可能是默认渲染器无法以平滑帧速率在全屏模式下绘制的结果。一个解决方案是切换到 P2D 渲染器,它使用 GPU 进行硬件加速渲染。

处理权限

我们已经学会了在多个主屏幕上加载图像并显示为壁纸。我们可以基于这种技术创建一个照片库壁纸,浏览手机或平板电脑上的相机拍摄的照片。但是为了加载这些照片,我们的草图需要访问设备的外部存储,并且读取外部存储的权限必须由用户明确授予。尽管我们现在将在一个墙纸示例中查看权限请求,但是这些函数可以在任何类型的草图中使用。

事实上,权限是 Android 开发的一个非常重要的方面,因为移动设备处理几种不同类型的个人数据(联系人、位置、消息),未经授权访问这些数据可能会导致隐私泄露。Android 操作系统确保每个应用都被授权访问设备中的特定数据和功能。常规权限(例如,Wi-Fi 和蓝牙访问)在用户首次安装应用时授予,而关键或“危险”权限(例如,访问摄像头、位置、麦克风、存储和身体传感器)需要在用户打开应用( https://developer.android.com/guide/topics/permissions/index.html )时授予(在使用 Android 6.0 或更新版本的设备上)。

我们的草图所需的任何权限,无论是常规权限还是危险权限,都必须通过使用 Android 权限选择器从 PDE 添加到草图中,该选择器可从 Android 菜单下的“草图权限”选项获得(参见图 6-3 )。

A432415_1_En_6_Fig3_HTML.jpg

图 6-3。

“Sketch Permissions” option (left) and Android Permission Selector dialog (right)

对于普通权限,我们需要做的就是用权限选择器选择它们。然而,对于危险的权限,我们也必须在草图代码中用requestPermission()函数显式地请求它们。这个函数有两个参数——请求权限的名称(例如,android.permission. READ_EXTERNAL_STORAGE)和一个回调函数的名称,在我们的草图中,一旦用户授予(或拒绝)了权限,这个回调函数就会被调用。回调函数必须有一个布尔参数,这是它接收权限请求结果的地方。清单 6-5 展示了这种机制,当许可被授予时,背景色变成绿色。回调函数不一定从setup()调用,因为当草图已经在draw()函数中时,Android 系统会显示权限对话框。所以,我们应该准备好处理 draw()中缺少预期权限的情况。为此,我们可以使用 hasPermission()函数来检查作为参数传递的权限,即:has permission(" Android . permission . read _ EXTERNAL _ STORAGE "),是否已经被授予,并在每种情况下运行适当的代码。

color bckColor = #EA6411;

void setup() {
  fullScreen();
  requestPermission("android.permission.READ_EXTERNAL_STORAGE",
                    "handlePermission");
}

void draw() {
  background(bckColor);
}

void handlePermission(boolean granted) {
  if (granted) bckColor = #58EA11;
}

Listing 6-5.Requesting a Dangerous Permission

Note

https://developer.android.com/reference/android/Manifest.permission.html 可以获得每个版本 Android 的所有权限列表。

回到我们的 image-gallery 壁纸草图,除了请求读取外部存储器的许可之外,它还需要列出存储在外部存储器中的所有照片。这个功能不是处理 API 的一部分,但是我们可以从草图中访问 Android API,并导入允许我们执行这些更高级任务的 Android 包。在从保存相机拍摄的图片和视频的DCIM文件夹中列出文件的情况下,我们可以使用android.os.Environment包中的getExternalStoragePublicDirectory()方法( https://developer.android.com/reference/android/os/Environment.html )。我们需要做的就是在草图的开始导入这个包。

我们现在有了图库壁纸所需的所有部分,如清单 6-6 所示。我们现在将讨论本例中引入的新代码。

import android.os.Environment;

PImage defImage, currImage;
ArrayList<String> imageNames = new ArrayList<String>();
int lastChange;
int swapInterval = 10;

void setup() {
  fullScreen();
  defImage = loadImage("default.jpg");
  if (!wallpaperPreview()) {
    requestPermission("android.permission.READ_EXTERNAL_STORAGE",
                      "scanForImages");
  }
  loadRandomImage();  
}

void draw() {
  background(0);
  float ratio = float(currImage.width)/float(currImage.height);
  float w = wallpaperHomeCount() * width;  
  float h = w/ratio;
  if (h < height) {
    h = height;
    w = ratio * h;
  }  
  float x = map(wallpaperOffset(), 0, 1, 0, -(wallpaperHomeCount()-1) * width);
  float y = (height - h)/2;
  image(currImage, x, y, w, h);
  int t = millis();
  if (swapInterval * 1000 < t - lastChange) {
    loadRandomImage();
    lastChange = t;
  }
}

void loadRandomImage() {
  if (imageNames.size() == 0) {
    currImage = defImage;
  } else {
    int i = int(random(1) * imageNames.size());
    String fn = imageNames.get(i);
    currImage = loadImage(fn);
  }    
}

void scanForImages(boolean grantedPermission) {
  if (grantedPermission) {
    File dcimDir = Environment.getExternalStoragePublicDirectory(
                   Environment.DIRECTORY_DCIM);
    String[] subDirs = dcimDir.list();
    if (subDirs == null) return;
    for (String d: subDirs) {
      if (d.charAt(0) == '.') continue;
      File fullPath = new File (dcimDir, d);  
      File[] listFiles = fullPath.listFiles();
      for (File f: listFiles) {
        String filename = f.getAbsolutePath().toLowerCase();
        if (filename.endsWith(".jpg")) imageNames.add(filename);
      }
    }
  }
}

Listing 6-6.Image-Gallery Wallpaper

这段代码的逻辑很简单:我们有当前和默认的PImage变量(currImagedefImage)以及一个图像文件名列表(imageNames)。当授予READ_EXTERNAL_STORAGE权限时,该列表在scanForImages()函数中初始化。在这个函数中,我们获得了用getExternalStoragePublicDirectory()表示 DCIM 文件夹的File对象,然后我们用它来遍历所有子文件夹,最后我们列出了每个子文件夹的内容。文件与。jpg 扩展名添加到列表中。每十秒钟调用一次loadRandomImage(),从列表中选择一个随机文件名,将新的PImage加载到currImage中。如果列表是空的,如果许可没有被授予,草图就使用默认的图像,我们应该在运行草图之前将它添加到数据文件夹中。

正如我们在本章开始时讨论的,我们不希望在仅仅预览壁纸时向用户呈现权限对话框,这就是为什么我们只在不处于预览模式时调用requestPermission()。预览将显示默认图像,这样用户在预览壁纸时仍然能够看到图像。

粒子系统

应用开发人员经常使用动态壁纸来创建动画图形,为静态背景图像提供替代方案。然而,这些图形不能过于强烈,因为它们可能会分散用户对设备屏幕上显示的相关信息的注意力(电话、消息、来自其他应用的提醒)。

怎样才能打造出视觉上有趣又不分散注意力的壁纸?一个可能的概念是流体模拟,其中一群粒子在背景中“有机地”移动,并留下某种痕迹。有机运动将有助于保持壁纸柔和,但仍然有吸引力,增加了随机变化的成分。我们也可以结合触摸交互来驱动某种形式的粒子运动,因为我们在前一章看到的触摸 API 可用于壁纸。考虑到这些想法,这可能是在现有作品中寻找视觉灵感的好时机,从绘画到基于代码的项目(图 6-4 )。

A432415_1_En_6_Fig4_HTML.jpg

图 6-4。

From top to bottom: “Starry Night,” by Vincent van Gogh (1889), Plate from “Processing Images and Video for An Impressionist Effect” by Peter Litwinowicz (1997), Drawing Machine #10 by Ale Gonzalez ( https://www.openprocessing.org/sketch/34320 ).

我们现在可以开始用笔和纸勾画出一些想法(图 6-5 )。一个想法是:单个粒子沿着弯曲的路径在接触点之间移动。问题是,我们如何模拟这些平滑的路径?在下一节中,我们将回顾一些技术,这些技术将允许我们实现这样的系统。

A432415_1_En_6_Fig5_HTML.jpg

图 6-5。

Some pen and paper sketches for the particle-system wallpaper

自主代理

创建自然感觉的粒子系统的问题已经被研究了很多年。丹尼尔·希夫曼的书《代码的本质》(可在网上的 http://natureofcode.com/ 获得)很好地涵盖了这个主题(特别是第 4 和 6 章)。粒子系统允许我们模拟大量个体实体的涌现行为,每个个体都遵循简单(或更复杂)的运动规则。

粒子群具有某种程度的自主行为,由作用于它们的力决定,这在我们的项目中可能是有用的,因为我们不需要指定每个粒子的确切运动,只需要指定整体的力。克雷格·雷诺兹( http://www.red3d.com/cwr/steer/ )提出了生成转向行为的算法。在一种称为流场跟随的算法中,目标速度场(“流场”)可以将粒子的运动导向特定的位置,而不会看起来是强制的或人为的。在这个算法中,每个粒子都有一个由其加速度、速度和位置决定的动态,一个力基于当前速度和屏幕空间每个点定义的目标速度之差作用在粒子上,如图 6-6 所示。

A432415_1_En_6_Fig6_HTML.jpg

图 6-6。

Diagram of the steering force (red) that is applied on a particle moving on a flow field of velocities (blue)

为了让这种方法正常工作,我们需要提供一个驱动粒子运动的速度流场。我们认为触摸互动是这种运动的潜在来源。例如,每当指针改变位置时,我们可以计算测量指针位置变化的向量,即(mouseX - pmouseX, mouseY – pmouseY),作为位置(mouseX, mouseY)处的“速度”。让我们在清单 6-7 中实现这种方法。在这个例子中,我们将使用两个类来组织代码——一个存储每个粒子,另一个保存整个场。我们可以在 Java 模式下运行它,也可以在我们的设备或模拟器上作为常规应用运行。输出应该看起来或多或少如图 6-7 所示。

A432415_1_En_6_Fig7_HTML.jpg

图 6-7。

Particle movement steered by a flow field derived from changes in touch position

ArrayList<Particle> particles;
Field field;

void setup() {
  size(1200, 600);
  field = new Field();
  particles = new ArrayList<Particle>();
}

void draw() {
  background(255);
  field.display();
  for (int i = particles.size() - 1; i >= 0; i--) {
    Particle p = particles.get(i);
    p.update(field);
    p.display();
    if (p.dead()) particles.remove(i);
  }
}

void mouseDragged() {
  field.update(mouseX, mouseY, mouseX - pmouseX, mouseY - pmouseY);  
  particles.add(new Particle(mouseX, mouseY));
}

class Particle {
  PVector position;
  PVector velocity;
  PVector acceleration;
  float size;
  int life;
  float maxAccel;
  float maxSpeed;
  int maxLife;

  Particle(float x, float y) {
    position = new PVector(x, y);
    size = random(15, 25);
    velocity = new PVector(0, 0);    
    acceleration = new PVector(0, 0);    
    maxSpeed = random(2, 5);
    maxAccel = random(0.1, 0.5);
    maxLife = int(random(100, 200));
  }

  boolean dead() {
    return maxLife < life;
  }

  public void setPosition(float x, float y) {
    position.set(x, y);
  }

  void update(Field flow) {
    PVector desired = flow.lookup(int(position.x), int(position.y));
    acceleration.x = maxSpeed * desired.x - velocity.x;
    acceleration.y = maxSpeed * desired.y - velocity.y;
    acceleration.limit(maxAccel);
    velocity.add(acceleration);
    velocity.limit(maxSpeed);
    position.add(velocity);
    life++;
  }

  void display() {
    noStroke();
    fill(180, 150);    
    ellipse(position.x, position.y, size, size);    
  }
}

class Field {
  PVector[][] field;

  Field() {
    field = new PVector[width][height];
    for (int i = 0; i < width; i++) {
      for (int j = 0; j < height; j++) {
        field[i][j] = new PVector(0, 0);
      }
    }
  }

  void update(int x, int y, float vx, float vy) {
    for (int i = max(0, x - 20); i < min(x + 20, width); i++) {
      for (int j = max(0, y - 20); j < min(y + 20, height); j++) {
        PVector v = field[i][j];
        v.set(vx, vy);
        v.normalize();                
      }
    }
  }

  PVector lookup(int x, int y) {
    return field[x][y];
  }

  void display() {
    int resolution = 20;
    int cols = width / resolution;
    int rows = height / resolution;
    for (int i = 1; i < cols; i++) {
      for (int j = 1; j < rows; j++) {
        int x = i * resolution;
        int y = j * resolution;
        PVector v = lookup(x, y);
        pushMatrix();        
        translate(x, y);
        stroke(28, 117, 188);
        strokeWeight(2);
        rotate(v.heading());
        float len = v.mag() * (resolution - 2);
        if (0 < len) {
          float arrowsize = 8;
          line(0, 0, len, 0);
          line(len, 0, len-arrowsize, +arrowsize/2);
          line(len, 0, len-arrowsize, -arrowsize/2);
        }
        popMatrix();     
      }      
    }
  }
}

Listing 6-7.Particle System with Flow Field Calculated from Touch or Mouse Events

这段代码中发生了几件事,所以让我们一步一步地回顾一下。在draw()函数中,我们首先显示流场,然后迭代粒子列表,根据当前场更新它们的位置。我们展示它们,并最终移除那些被标记为“死亡”的请注意,我们以相反的顺序迭代列表,因为我们在迭代的同时删除了列表中的元素。如果我们执行一个普通的正向循环,我们可能会越过列表的末尾(因为开始循环时它的大小小于末尾的大小),从而导致运行时错误。在mouseDragged()中,我们使用当前和上一个位置之间的差异来更新当前指针位置处的场,并且还添加了一个新粒子。

Particle类跟踪粒子的动态状态,包括它的位置、速度和加速度,以及它的“寿命”每次我们调用update()方法时,我们从粒子当前位置的流场获得所需的速度,然后计算转向力产生的加速度,根据雷诺公式,该加速度是所需速度和当前速度之差。maxSpeed因子允许我们改变粒子的运动,因为它是在Particle构造器中随机确定的,导致一些粒子运动得更快,另一些运动得更慢。

Field类有一个PVector对象的数组,带有width × height元素,包含屏幕每个像素的流场值。该字段最初到处都是零,但是我们使用update()方法将其设置为从触摸或鼠标事件中获得的值。请注意,在这种方法中,我们不仅仅在(x,y)位置设置场向量,而是在以(x,y)为中心的矩形中设置场向量,因为否则向量场的变化只会影响非常小的区域——不足以控制粒子穿过屏幕。最后,display()方法也需要注意,因为我们不能在屏幕上画出所有像素的场矢量。我们使用一个更大的网格,这样我们就可以为每个尺寸为resolution的矩形绘制一个矢量。PVector类中的方法,比如heading(),在这里可以方便地将向量绘制成小箭头,这有助于可视化流向。

虽然这个例子是一个简单的操纵行为的应用,我们可以用许多不同的方式来改进,但一个问题是它需要持续的触摸输入来保持系统的发展。虽然这对于常规应用来说没问题,但如此依赖壁纸中的触摸交互可能会有问题,因为主屏幕上的触摸主要用于驱动与 UI 的交互。在粒子中创建运动的拖动可能会被误认为是改变主屏幕的滑动,反之亦然。我们将在下一节中看到如何生成不需要触摸交互的流场。

图像流场

有几种方法我们可以生成一个平滑的流场,给我们的粒子系统足够的视觉可变性。一种可能是使用由柏林噪声(mrl.nyu.edu/∼perlin/doc/oscar.html)生成的场,柏林噪声是一种合成生成的随机噪声,它平滑地变化并产生看起来更有机的图案。另一种可能是使用图像。事实证明,我们可以转换图像中每个像素位置的颜色信息来计算流场的速度矢量。

更具体地说,如果我们用brightness()函数计算一种颜色的亮度,我们得到一个介于 0(黑色)和 1(白色)之间的数,我们可以用它作为速度矢量相对于 x 轴的角度。这会产生一个平滑的速度流场,遵循图像的特征(边缘、颜色漩涡等)。).我们可以使用 Processing 的PVector类中的fromAngle(theta)函数来轻松计算向量。从图像中生成速度场时,另一个非常重要的功能是访问图像中各个像素的能力。一旦我们调用了loadPixels()函数,我们就可以用任何PImage对象都可用的pixels数组来做到这一点。我们在清单 6-8 中组合了所有这些,它加载一个图像并生成相关的流场,如图 6-8 所示。

A432415_1_En_6_Fig8_HTML.jpg

图 6-8。

Flow field generated from an image

PImage img;

void setup() {
  fullScreen();
  img = loadImage("jupiter.jpg");
  img.loadPixels();
}

void draw() {
  image(img, 0, 0, width, height);
  int resolution = 30;
  int cols = width / resolution;
  int rows = height / resolution;
  for (int i = 1; i < cols; i++) {
    for (int j = 1; j < rows; j++) {
      int x = i * resolution;
      int y = j * resolution;
      int ix = int(map(x, 0, width, 0, img.width - 1));
      int iy = int(map(y, 0, height, 0, img.height - 1));
      int idx = ix + iy * img.width;
      int c = img.pixels[idx];
      float theta = map(brightness(c), 0, 255, 0, TWO_PI);
      PVector v = PVector.fromAngle(theta);
      drawArrow(x, y, v, resolution-2);
    }
  }
}

void drawArrow(float x, float y, PVector v, float l) {
  pushMatrix();
  float arrowsize = 8;
  translate(x, y);
  strokeWeight(2);
  stroke(28, 117, 188);
  rotate(v.heading());
  float len = v.mag() * l;
  line(0, 0, len, 0);
  line(len, 0, len-arrowsize, +arrowsize/2);
  line(len, 0, len-arrowsize, -arrowsize/2);
  popMatrix();  
}

Listing 6-8.Code That Generates a Flow Field from an Image

在这个例子中,我们需要访问图像中的单个像素。我们通过首先调用setup()中的方法loadPixels()来实现,该方法初始化我们稍后在draw()中使用的pixels数组。这是一个一维数组,图像中的每一行像素都是一个接一个存储的,所以像素(ix,iy)对应于数组中的元素 idx,如图 6-9 所示。然而,在代码中还有另一个转换,因为我们拉伸图像以覆盖整个屏幕。因此,我们首先需要使用map()函数将屏幕坐标(x,y)映射到像素索引(ix,iy)。然后,我们可以应用图像中像素的索引和阵列中的索引之间的对应关系。

A432415_1_En_6_Fig9_HTML.jpg

图 6-9。

Correspondence between pixels in an image and elements in the pixels array

图像流动壁纸

我们现在知道如何从任何图像生成一个平滑的矢量场。我们可以将固定的图像列表添加到我们的草图中,并在它们之间循环,以产生可变的运动模式。但更好的是,我们可以应用我们在照片图库示例中学到的技术来加载用设备的摄像头拍摄的照片,并将它们用作我们的流场的源。这将为我们的壁纸增添独特和无尽的变化。然而,为了实现这个壁纸,仍然有一些重要的细节需要解决,我们将在下面的部分中考虑。

加载、调整大小和裁剪图像

我们可能面临的一个问题是,设备的摄像头,尤其是最近的 Android 手机,可以以非常高的分辨率拍照。当我们将这些图片加载到一个PImage对象中,然后用loadPixels()加载pixels数组时,我们可能会耗尽内存(壁纸分配给它们的资源较少,以免影响系统中的其他应用)。然而,我们不需要全高分辨率的图像来计算流场。事实上,我们可以将一张图片加载到一个临时位图对象中,然后在将它加载到草图中的PImage对象之前,调整它的大小并将其裁剪到所需的大小。在清单 6-9 中,我们将croppedBitmap()函数添加到清单 6-6 中的图库示例中。

import android.os.Environment;
import java.io.FileOutputStream;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
...
void loadRandomImage() {
  if (imageNames.size() == 0) {
    currImage = defImage;
  } else {
    int i = int(random(1) * imageNames.size());
    String sourceFn = imageNames.get(i);
    try {
      File destFile = sketchFile("cropped.jpg");
      Bitmap bitmap = croppedBitmap(sourceFn);      
      OutputStream fout = new FileOutputStream(destFile);
      bitmap.compress(Bitmap.CompressFormat.JPEG, 85, fout);
      currImage = loadImage("cropped.jpg");
    } catch (Exception ex) {
      println("An error while cropping has occurred");
      currImage = defImage;
    }
  }    
}
...
Bitmap croppedBitmap(String sourceFile) {
  BitmapFactory.Options options = new BitmapFactory.Options();
  options.inJustDecodeBounds = false;
  options.inSampleSize = 4;  
  Bitmap src = BitmapFactory.decodeFile(sourceFile, options);  
  int srcW = options.outWidth;
  int srcH = options.outHeight;
  float ratio = float(width)/float(height);
  float srcRatio = float(srcW)/float(srcH);
  int cropX, cropY, cropW, cropH;
  if (ratio < srcRatio) {
    cropH = srcH;
    cropW = int(ratio * cropH);    
  } else {
    cropW = srcW;
    cropH = int(cropW / ratio);
  }
  cropX = (srcW - cropW)/2;
  cropY = (srcH - cropH)/2;
  return Bitmap.createBitmap(src, cropX, cropY, cropW, cropH);
}
Listing 6-9.Cropping and Loading Images

这里,在选择图像文件之后,我们应用调整大小/裁剪,其中我们利用Bitmap类来解码图像,同时对其进行下采样(在本例中,将其原始宽度和高度减少四分之一),然后我们确定裁剪区域,以便在全屏模式下显示图像的正确比例区域。我们将裁剪后的图像保存到草图文件夹中的一个新文件中,名为cropped.jpg,然后像以前一样用loadImage()加载它。我们应该能够看到背景中显示的图像的像素化。

把所有东西放在一起

在经历了前面几节从图像创建流场(清单 6-8 )和从外部存储器裁剪并加载图像(清单 6-9 )之后,我们准备好将我们的图像流壁纸放在一起。我们可以按照基于触摸的粒子系统的结构,用单独的ParticleField类,来得到清单 6-10 。

import android.os.Environment;
import java.io.FileOutputStream;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;

int maxParticles = 200;
int swapInterval = 10;
Field field;
ArrayList<Particle> particles;
ArrayList<String> imageNames = new ArrayList<String>();
int lastChange;

void setup() {
  fullScreen(P2D);
  frameRate(25);  
  field = new Field();
  if (!wallpaperPreview()) {
    requestPermission("android.permission.READ_EXTERNAL_STORAGE",
                      "scanForImages");
  }  
  particles = new ArrayList<Particle>();
  for (int i = 0; i < maxParticles; i++) {
    particles.add(new Particle(random(width), random(height)));
  }
  loadRandomImage();
  background(255);
}

void draw() {
  for (Particle b: particles) {
    b.update(field);
    b.display();
  }
  int t = millis();
  if (swapInterval * 1000 < t - lastChange) {
    loadRandomImage();
    lastChange = t;
  }
}

void loadRandomImage() {
  if (imageNames.size() == 0) {
    field.update("default.jpg");
  } else {
    int i = int(random(1) * imageNames.size());
    String sourceFn = imageNames.get(i);
    try {
      File destFile = sketchFile("cropped.jpg");
      Bitmap bitmap = croppedBitmap(sourceFn);      
      OutputStream fout = new FileOutputStream(destFile);
      bitmap.compress(Bitmap.CompressFormat.JPEG, 85, fout);
      field.update("cropped.jpg");
    } catch (Exception ex) {
      println("An error while cropping has occurred");
      field.update("default.jpg");
    }
  }
}

void scanForImages(boolean grantedPermission) {
  if (grantedPermission) {
    File dcimDir = Environment.getExternalStoragePublicDirectory(
                   Environment.DIRECTORY_DCIM);
    String[] subDirs = dcimDir.list();
    if (subDirs == null) return;
    for (String d: subDirs) {
      if (d.charAt(0) == '.') continue;
      File fullPath = new File (dcimDir, d);  
      File[] listFiles = fullPath.listFiles();
      for (File f: listFiles) {
        String filename = f.getAbsolutePath().toLowerCase();
        if (filename.endsWith(".jpg")) imageNames.add(filename);
      }
    }
  }
}

Bitmap croppedBitmap(String sourceFile) {
  BitmapFactory.Options options = new BitmapFactory.Options();
  options.inJustDecodeBounds = false;
  options.inSampleSize = 4;  
  Bitmap src = BitmapFactory.decodeFile(sourceFile, options);  
  int srcW = options.outWidth;
  int srcH = options.outHeight;
  float ratio = float(width)/float(height);
  float srcRatio = float(srcW)/float(srcH);
  int cropX, cropY, cropW, cropH;
  if (ratio < srcRatio) {
    cropH = srcH;
    cropW = int(ratio * cropH);    
  } else {
    cropW = srcW;
    cropH = int(cropW / ratio);
  }
  cropX = (srcW - cropW)/2;
  cropY = (srcH - cropH)/2;
  return Bitmap.createBitmap(src, cropX, cropY, cropW, cropH);
}

class Field {
  PImage flowImage;

  void update(String fn) {
    flowImage = loadImage(fn);
    flowImage.loadPixels();
  }

  PVector lookupVector(PVector v) {
    if (flowImage == null) return PVector.random2D();
    color c = flowImage.pixels[getPixelIndex(v.x, v.y)];
    float theta = map(brightness(c), 0, 255, 0, TWO_PI);
    return PVector.fromAngle(theta);
  }  

  color lookupColor(PVector v) {
    if (flowImage == null) return color(0, 0, 100, 0);
    return flowImage.pixels[getPixelIndex(v.x, v.y)];
  }

  int getPixelIndex(float x, float y) {
    int ix = int(map(x, 0, width, 0, flowImage.width - 1));
    int iy = int(map(y, 0, height, 0, flowImage.height - 1));
    return constrain(ix + iy * flowImage.width, 0, flowImage.pixels.length - 1);    
  }  
}

class Particle {
  PVector position;
  PVector velocity;
  PVector acceleration;
  float size;
  color color;
  int life;  
  float maxAccel;
  float maxSpeed;    
  int maxLife;

  Particle(float x, float y) {
    position = new PVector(x, y);
    size = random(2, 4) * displayDensity;
    velocity = new PVector(0, 0);    
    acceleration = new PVector(0, 0);
    maxSpeed = random(2, 5);
    maxAccel = random(0.1, 0.5);    
    maxLife = int(random(5, 20));
  }

  void update(Field flow) {
    if (life == 0) {
      color = flow.lookupColor(position);
    } else if (life > frameRate * maxLife) {
      position.x = random(width);
      position.y = random(height);
      life = 0;
      color = flow.lookupColor(position);
    }

    PVector desired = flow.lookupVector(position);
    acceleration.x = maxSpeed * desired.x - velocity.x;
    acceleration.y = maxSpeed * desired.y - velocity.y;
    acceleration.limit(maxAccel);
    velocity.add(acceleration);
    velocity.limit(maxSpeed);
    position.add(velocity);

    if (position.x < -size) position.x = width + size;
    if (position.y < -size) position.y = height + size;
    if (position.x > width + size) position.x = -size;
    if (position.y > height + size) position.y = -size;

    life++;
  }

  void display() {
    float theta = velocity.heading();
    noStroke();
    pushMatrix();
    translate(position.x,position.y);
    rotate(theta);
    fill(color);
    beginShape(QUADS);    
    vertex(-2*size, -size);
    vertex(+2*size, -size);
    vertex(+2*size, +size);
    vertex(-2*size, +size);
    endShape();
    popMatrix();
  }
}

Listing 6-10.Image-flow Wallpaper

Field类包含保存要从中导出流场的图像的PImage对象,通过传入文件名来设置新图像的update()方法,以及两个查找方法lookupVector()lookupColor(),它们分别返回给定位置的场向量和图像颜色。注意getPixelIndex()方法的使用是很重要的,该类在内部使用该方法将屏幕位置(x,y)转换为有效的像素索引,而不考虑当前流图像的分辨率。

Particle类与清单 6-7 中看到的非常相似,增加了一些关键的东西:在每次更新的开始,我们计算粒子“寿命”开始时的颜色,并在life变量达到最大值时重新开始它的位置;在更新结束时,我们检查粒子是否在屏幕之外,如果是,我们将它包裹起来,使它从屏幕的另一侧出现,以确保我们的粒子不会跑偏。

使用线程

正如在线参考中所解释的,加工草图遵循特定的步骤顺序:首先是setup(),然后是draw(),一遍又一遍地循环。一个线程也是一系列具有开始、中间和结束的步骤。一个加工草图是一个单独的线程,通常称为“动画线程”( https://processing.org/reference/thread_.html )。虽然在大多数情况下单线程就足够了,但有时我们需要一个单独的线程来执行额外的计算。例如,我们的图像流壁纸的一个问题是,当一个新的图像被调整大小和加载时,它会暂停一会儿。原因是调整大小/加载过程当前发生在动画线程中,阻止了后续帧的渲染,因此造成了延迟。解决方案是在一个单独的线程中运行loadRandomImage()函数,这样就可以在不妨碍渲染的情况下并行调整图像大小和加载图像。这可以通过用thread("loadRandomImage")替换draw()中对loadRandomImage()的调用来实现。

因为线程彼此独立运行,我们可能会面临多个线程同时调用 Field 类中的update()方法的问题,或者粒子在另一个线程更新字段数据时从动画线程中查找字段数据。如果处理不当,这种并发访问会导致意外错误。一个解决方案是将访问flowImageField类中的所有方法标记为“同步”当一个线程正在调用同步方法时,不能从另一个线程调用它们。清单 6-11 显示了使用线程和处理并发所需的壁纸代码的变化。

void draw() {...
  if (swapInterval * 1000 < t - lastChange) {
    thread("loadRandomImage");
    lastChange = t;
  }
}
...
class Field {
  PImage flowImage;

  synchronized void update(String fn) {
    flowImage = loadImage(fn);
    flowImage.loadPixels();
  }

  synchronized PVector lookupVector(PVector v) {
    if (flowImage == null) return PVector.random2D();
    color c = flowImage.pixels[getPixelIndex(v.x, v.y)];
    float theta = map(brightness(c), 0, 255, 0, TWO_PI);
    return PVector.fromAngle(theta);
  }  

  synchronized color lookupColor(PVector v) {
    if (flowImage == null) return color(0, 0, 100, 0);
    return flowImage.pixels[getPixelIndex(v.x, v.y)];
  }
...
}

Listing 6-11.Running the Image-loading Function in a Separate Thread

控制色调

正如我们之前所讨论的,动态壁纸应该在视觉上吸引人,但又不至于压倒用户界面。在我们的例子中,粒子的颜色可能太亮或太饱和,使得很难看到前景中的图标。这个问题的一个解决方案是在我们的草图中使用 HSB 颜色空间,然后在从pixels数组中获得颜色时调整颜色的亮度。清单 6-12 包括一个修改过的lookupColor()方法,该方法应用处理的hue()saturation()函数来提取这些属性,然后用color()来重建最终的颜色,因此粒子保持其原始色调和饱和度,但降低其亮度和不透明度。

void setup() {
  ...
  colorMode(HSB, 360, 100, 100, 100);
  background(0, 0, 100);
}
...
class Field {
  ...
  synchronized color lookupColor(PVector v) {
    if (flowImage == null) return color(0, 0, 100, 0);
    color c = flowImage.pixels[getPixelIndex(v.x, v.y)];
    float h = hue(c);
    float s = saturation(c);
    float b = 50;
    return color(h, s, b, 70);    
  }
  ...
}
Listing 6-12.Using the HSB Color Space

为了使用 HSB 色彩空间,我们需要使用colorMode()函数在setup()中设置色彩模式,我们已经在第四章中看到了。修改后我们的图像流壁纸应该显示更多柔和的颜色,如图 6-10 所示。

A432415_1_En_6_Fig10_HTML.jpg

图 6-10。

Final version of the image-flow wallpaper running in the background

结束项目

作为这个项目的最后一个阶段,我们应该为所有需要的分辨率(36 × 36、48 × 48、72 × 72、96 × 96、144 × 144 和 192 × 192 像素)创建图标,并在 manifest 文件中为我们的壁纸设置一个唯一的包名和版本号。见图 6-11 。

A432415_1_En_6_Fig11_HTML.jpg

图 6-11。

Icon set for the image flow wallpaper.

如果草图在设备或仿真器上至少运行过一次,则清单文件中已经填充了大部分必需的值。我们应该在应用和服务标签中设置一个唯一的包名和android:label属性,以便在壁纸选择器和应用列表中用一个更易读的标题来标识壁纸。包含所有这些值的完整清单文件如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          android:versionCode="1"android:versionName="1.0"
          package="com.example.image_flow">
    <uses-sdk android:minSdkVersion="17" android:targetSdkVersion="25"/>
    <uses-feature android:name="android.software.live_wallpaper"/>
    <application android:icon="@drawable/icon"
                 android:label="Image Flow">
        <service android:label="Image Flow"
                 android:name=".MainService"
                 android:permission="android.permission.BIND_WALLPAPER">
            <intent-filter>
                <action
                 android:name="android.service.wallpaper.WallpaperService"/>
            </intent-filter>
            <meta-data android:name="android.service.wallpaper"
                       android:resource="@xml/wallpaper"/>
        </service>
        <activity android:name="processing.android.PermissionRequestor"/>    
    </application>
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
</manifest>

编辑完清单后,我们准备将草图导出为一个签名包,以便上传到谷歌 Play 商店,正如我们在第三章中所讨论的。

摘要

动态壁纸为我们提供了一种独特的媒介来创建动画图形,用户可以在他们的设备上体验动态背景。我们可以在处理中应用整个绘图 API 来创建原始壁纸,正如我们在本章中了解到的,也可以导入 Android API 来执行更高级的任务,例如从外部存储中读取文件或调整图像大小。本章还介绍了正常和危险权限的概念,以及如何从我们的草图中请求它们,我们将在下一章中多次重温。

七、读取传感器数据

Android 设备使用众多传感器从我们周围的物理世界读取数据,如加速度计、陀螺仪和磁力计。在本章中,我们将学习如何使用 Android SDK 和 Ketai 库从我们的处理草图中访问这些数据。

Android 设备中的传感器

如今,从智能手机到活动追踪器,几乎所有移动设备都配备了各种硬件传感器来捕捉设备的移动、环境和位置数据(以及我们自己的数据)。Android 设备通常包含加速度计、陀螺仪、磁力计和位置传感器。这些传感器让我们能够以多种方式访问大量信息,用于处理草图,从控制屏幕上图形元素的行为和创建不限于触摸手势的用户界面,到推断和可视化我们自己的运动模式。

让我们从安卓手机、平板电脑或手表上最典型的传感器开始(查看谷歌官方开发者指南了解更多细节: https://developer.android.com/guide/topics/sensors/sensors_overview.html )。

加速计

加速度传感器能够测量沿三个坐标轴的加速度,如图 7-1 所示。加速度是以米/秒 2 来衡量的,而 Android 将重力产生的加速度也包括在内。加速度数据有许多用途;例如,它可以是确定空间方向的基础,也可以帮助检测突然的运动,如冲击或振动。

A432415_1_En_7_Fig1_HTML.jpg

图 7-1。

Physical axes that Android uses to describe acceleration and other sensor data

陀螺仪

陀螺仪测量设备的角速度;即围绕图 7-1 中加速度计传感器定义的相同三个轴的旋转速率(弧度/秒)。虽然它与加速度计相似,都可以用来确定器件的方位,但陀螺仪可以检测旋转,而加速度计则不能。因此,当我们需要测量旋转运动时,陀螺仪非常有用,例如旋转、转动等。

磁力计

磁力计是一种测量地磁场强度和方向的传感器——也使用图 7-1 中描述的坐标系——通过以μT(微特斯拉)为单位提供沿每个轴的磁场的原始分量。这种传感器的一个典型应用(我们将在下一章详细讨论)是使用指南针来显示设备相对于磁北的角度。

位置

这不是一个实际的传感器,而是从不同来源(全球定位系统,或 GPS,cell-ID 和 Wi-Fi)收集的数据的组合,使我们能够以不同的分辨率水平(粗略或精细)确定设备的地理位置(纬度/经度)。GPS 数据是从环绕地球运行的卫星网络中获得的,在开阔的天空下( http://www.gps.gov/systems/gps/performance/accuracy/ )精度约为 4.9 米(16 英尺)。从蜂窝塔或 Wi-Fi 接入点 id 获得的位置信息准确度低得多(在 5,300 英尺和 1 英里之间),但由于它是被动工作的,与 GPS 进行的主动定位相比,它消耗的能量非常少。

从处理中访问传感器

Processing 语言没有专门的函数来读取传感器数据,但是有两种简单的方法可以从我们的代码中访问这些数据。第一种是依赖 Android API,我们可以通过导入所有相关的 Android 包从处理中调用它,就像我们在第四章中从外部存储器读取文件一样。第二种方法是使用贡献的库,它扩展了 Android 模式的功能。我们将学习访问传感器数据的两种方式,从使用草图中的 Android SDK 开始。

创建传感器管理器

使用 Android sensor API 的第一步是获取包含草图的应用的“上下文”。我们可以把这个上下文看作是一个接口,允许我们访问关于我们的应用的有用信息。例如,一旦我们获得了上下文,我们就可以从中创建一个传感器管理器。该经理将在草图中创建我们需要的任何传感器。

让我们看一个读取加速度计数据的具体例子。我们将传感器管理器的初始化放在setup()函数中,如清单 7-1 所示,其中我们还为加速度计创建了传感器对象。

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;

Context context;
SensorManager manager;
Sensor sensor;

void setup() {
  fullScreen();  
  context = getContext();
  manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
  sensor = manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
}

void draw() {
}

Listing 7-1.Accessing the Accelerometer

在这段代码中,我们必须从 Android SDK 导入几个包来访问ContextSensorManagerSensor类。

添加传感器监听器

在下一步中(清单 7-2 ,我们添加一个监听器对象,通知草图传感器有新数据可用。我们通过实现两个方法onSensorChanged()onAccuracyChanged(),从 Android API 的基类SensorEventListener中派生出特定于我们的草图的监听器类。前者在新数据可用时调用,后者在传感器的精度改变时调用。一旦我们获得了 listener 类的实例,我们必须向管理器注册它,这样它就可以生成数据了。

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;

Context context;
SensorManager manager;
Sensor sensor;
AccelerometerListener listener;

void setup() {
  fullScreen();  
  context = getActivity();
  manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
  sensor = manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
  listener = new AccelerometerListener();
  manager.registerListener(listener, sensor,
                           SensorManager.SENSOR_DELAY_NORMAL);
}

void draw() {
}

class AccelerometerListener implements SensorEventListener {
  public void onSensorChanged(SensorEvent event) {
  }
  public void onAccuracyChanged(Sensor sensor, int accuracy) {
  }
}

Listing 7-2.Creating a Listener

您可能已经注意到监听器注册中的SensorManager.SENSOR_DELAY_NORMAL参数。此参数设置传感器用新数据更新的速率。更快的速率意味着更高的响应速度,但也意味着更多的电池消耗。默认的SENSOR_DELAY_NORMAL为屏幕方向变化设置足够快的速率,而SENSOR_DELAY_GAMESENSOR_DELAY_UI分别适用于游戏和用户界面。最后,SENSOR_DELAY_FASTEST让我们尽可能快地获得传感器数据。

从传感器读取数据

正如我们刚才提到的,事件监听器有两个方法,onSensorChanged()onAccuracyChanged()。我们只需要用onSensorChanged()从传感器上获取数据。对于加速度计,数据由三个浮点数组成,代表器件沿 x、y 和 z 轴的加速度。

在清单 7-3 中,我们简单地将这些值打印到屏幕上。我们可以验证,如果我们将手机平放在桌子上,屏幕朝上,我们应该会看到 9.81 m/s 的 Z 加速度 2 (实际加速度会在该值附近波动,因为加速度计数据有噪声),这对应于重力加速度。

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;

Context context;
SensorManager manager;
Sensor sensor;
AccelerometerListener listener;
float ax, ay, az;

void setup() {
  fullScreen();  
  context = getContext();
  manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
  sensor = manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
  listener = new AccelerometerListener();
  manager.registerListener(listener, sensor,
                           SensorManager.SENSOR_DELAY_NORMAL);

  textFont(createFont("SansSerif", displayDensity * 24));
  textAlign(CENTER, CENTER);
}

void draw() {
  background(157);
  text("X: " + ax + "\n" + "Y: " + ay + "\n" + "Z: " + az, width/2, height/2);
}

public void resume() {
  if (manager != null) {
    manager.registerListener(listener, sensor,
                             SensorManager.SENSOR_DELAY_NORMAL);
  }
}

public void pause() {
  if (manager != null) {
    manager.unregisterListener(listener);
  }
}
class AccelerometerListener implements SensorEventListener {
  public void onSensorChanged(SensorEvent event) {
    ax = event.values[0];
    ay = event.values[1];
    az = event.values[2];
  }
  public void onAccuracyChanged(Sensor sensor, int accuracy) {
  }
}

Listing 7-3.Reading the Accelerometer

此外,我们在草图暂停时注销监听器,在草图恢复时重新注册。图 7-2 显示了我们第一个传感器草图的输出,这只是 X、Y 和 Z 加速度值的打印输出。

A432415_1_En_7_Fig2_HTML.jpg

图 7-2。

Output of the accelerometer sensor example Note

作为使用传感器的最佳实践,我们应该在草图的活动暂停时取消注册关联的侦听器,以减少电池使用,然后在活动恢复时再次注册它。

来自其他传感器的读数

我们在前面的例子中放在一起的结构可以重复使用,对于其他类型的传感器几乎没有变化。例如,如果我们想使用陀螺仪读取每个轴的旋转角度,我们需要做的就是请求一个TYPE_GYROSCOPE传感器,如清单 7-4 所示。

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;

Context context;
SensorManager manager;
Sensor sensor;
GyroscopeListener listener;
float rx, ry, rz;

void setup() {
  fullScreen();  
  context = getContext();
  manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
  sensor = manager.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
  listener = new GyroscopeListener();
  manager.registerListener(listener, sensor,
                           SensorManager.SENSOR_DELAY_NORMAL);

  textFont(createFont("SansSerif", displayDensity * 24));
  textAlign(CENTER, CENTER);
}

void draw() {
  background(157);
  text("X: " + rx + "\n" + "Y: " + ry + "\n" + "Z: " + rz, width/2, height/2);
}

public void resume() {
  if (manager != null) {
    manager.registerListener(listener, sensor,
                             SensorManager.SENSOR_DELAY_NORMAL);
  }
}

public void pause() {
  if (manager != null) {
    manager.unregisterListener(listener);
  }
}

class GyroscopeListener implements SensorEventListener {
  public void onSensorChanged(SensorEvent event) {
    rx = event.values[0];
    ry = event.values[1];
    rz = event.values[2];    
  }
  public void onAccuracyChanged(Sensor sensor, int accuracy) {
  }
}

Listing 7-4.Reading the Gyroscope

科泰图书馆

我们已经学会了从草图中直接调用 Android API 来访问传感器数据。这种方法的优点是我们可以随时访问 Android API,但缺点是我们需要额外的代码来创建传感器管理器和监听器。这些额外的代码没有遵循处理 API 的约定,因此新用户可能很难理解。

我们不得不使用 Android 传感器 API,因为 Android 的处理不包括一个。然而,我们可以通过使用贡献的库为处理添加新的功能。正如我们在第一章中看到的,贡献库是封装了不属于处理核心的额外函数和类的模块。我们可以将这些库导入到草图中,以使用它们的特性和功能。事实证明,有一个旨在以简化、类似处理的方式使用 Android 传感器的库。它被称为科泰( http://ketai.org/ ),由丹尼尔·索特和耶稣·杜兰创造。

安装科泰

贡献库,比如 Ketai,可以使用贡献管理器(Contributions Manager,或 CM)轻松地安装在进程中。要打开 CM,我们进入草图菜单,然后选择“导入库|添加库…”我们已经使用 CM 来安装 Android 模式,但它也是安装库、工具和示例的主界面。

打开 CM 后,我们选择 Libraries 选项卡;在那里,我们可以通过向下滚动列表或按名称搜索来搜索感兴趣的库。一旦我们找到了 Ketai 的条目,我们需要做的就是点击 Install 按钮(图 7-3 )。

A432415_1_En_7_Fig3_HTML.jpg

图 7-3。

Installing the Ketai library through the Contributions Manager

使用 Ketai

Ketai 库为我们的 Android 设备中的传感器提供了一个简单的接口,遵循核心处理 API 的风格。Ketai 需要一些初始化代码来导入库并创建 KetaiSensor 对象,但是读取传感器值非常容易。我们只需要在我们的草图中添加一个“事件处理程序”函数,每当有来自传感器的新值时就会调用这个函数(类似于内置的mousePressed()touchMoved()函数的工作方式)。

清单 7-5 中展示了一个简单的草图,它读取加速度计并将数值显示为文本,就像我们在清单 7-3 中所做的那样。其输出如图 7-4 所示。

A432415_1_En_7_Fig4_HTML.jpg

图 7-4。

Output of Ketai accelerometer example

import ketai.sensors.*;

KetaiSensor sensor;
float accelerometerX, accelerometerY, accelerometerZ;

void setup() {
  fullScreen();  
  sensor = new KetaiSensor(this);
  sensor.start();
  textAlign(CENTER, CENTER);
  textSize(displayDensity * 36);
}

void draw() {
  background(78, 93, 75);
  text("Accelerometer: \n" +
    "x: " + nfp(accelerometerX, 1, 3) + "\n" +
    "y: " + nfp(accelerometerY, 1, 3) + "\n" +
    "z: " + nfp(accelerometerZ, 1, 3), 0, 0, width, height);
}

void onAccelerometerEvent(float x, float y, float z) {
  accelerometerX = x;
  accelerometerY = y;
  accelerometerZ = z;
}

Listing 7-5.Reading the Accelerometer

with Ketai

来自其他传感器的数据以同样的方式被访问;我们只需要添加一个不同的事件处理程序。例如,为了读取陀螺仪的值,我们使用onGyroscopeEvent(列表 7-6 )。

import ketai.sensors.*;

KetaiSensor sensor;
float rotationX, rotationY, rotationZ;

void setup() {
 fullScreen();
  sensor = new KetaiSensor(this);
  sensor.start();
  textAlign(CENTER, CENTER);
  textSize(displayDensity * 24);
}

void draw() {
  background(78, 93, 75);
  text("Gyroscope: \n" +
    "x: " + nfp(rotationX, 1, 3) + "\n" +
    "y: " + nfp(rotationY, 1, 3) + "\n" +
    "z: " + nfp(rotationZ, 1, 3), 0, 0, width, height);
}

void onGyroscopeEvent(float x, float y, float z) {
  rotationX = x;
  rotationY = y;
  rotationZ = z;
}

Listing 7-6.Reading the Gyroscope

with Ketai

Ketai 中的事件处理程序

对于 Ketai,我们通过添加相应的事件处理函数来指示要读取哪些传感器数据。这里显示了一些 Ketai 支持的处理程序,完整的列表可以在库的参考( http://ketai.org/reference/sensors )中找到。Daniel Sauter 的《Android 快速开发》,可以在 https://www.mobileprocessing.org 在线获得,也是了解科泰所有细节的好资源。

  • void onSensorEvent(SensorEvent e):这个处理程序返回一个“原始的”Android sensor 事件对象,它包含描述事件的所有相关信息,包括类型、值等等。这个SensorEvent类在官方 Android 参考中有完整的文档,在这里可以找到: https://developer.android.com/reference/android/hardware/SensorEvent.html
  • void onAccelerometerEvent(float x, float y, float z, long a, int b):我们接收加速度计数据,x, y, z为沿三个轴的加速度,单位为米/秒 2a为事件的时间戳(单位为纳秒),b为当前的精度水平。
  • void onAccelerometerEvent(float x, float y, float z):与之前相同,但没有给出时间戳或精确度
  • void onGyroscopeEvent(float x, float y, float z, long a, int b):提供x, y, z弧度/秒的角速度,事件时间戳a,精度等级b
  • void onGyroscopeEvent(float x, float y, float z):仅角速度
  • void onPressureEvent(float p):当前环境压力p,单位为百帕(hPa)
  • void onTemperatureEvent(float t):当前温度t,单位为摄氏度

我们可以通过添加所有需要的事件处理程序来同时使用几个传感器。然而,设备可能不包括特定的传感器。为了正确处理这种情况,我们可以使用KetaiSensor中的isXXXAvailable()函数来检查任何支持的传感器的可用性。让我们将前面的例子合并到一个草图中,该草图读取并显示加速度计和陀螺仪数据,但前提是器件具有这些传感器。一般来说,几乎所有的 Android 手机都配有加速度计,但更便宜的入门级设备往往没有陀螺仪。

清单 7-7 包括setup()函数中的传感器可用性代码,并使用长度代表传感器值大小的条图形显示加速度计和陀螺仪值(图 7-5 )。它的灵感来自 Tiago Martins 创造的一个更全面的例子,他还写了几个其他草图来说明传感器在处理中的使用( https://github.com/tms-martins/processing-androidExamples )。

A432415_1_En_7_Fig5_HTML.jpg

图 7-5。

Showing values from the accelerometer and the gyroscope

import ketai.sensors.*;

KetaiSensor sensor;
boolean hasAccel = false;
boolean hasGyro = false;
PVector dataAccel = new PVector();
PVector dataGyro = new PVector();

void setup() {
  fullScreen();  
  sensor = new KetaiSensor(this);
  sensor.start();

  if (sensor.isAccelerometerAvailable()) {
    hasAccel = true;
    println("Device has accelerometer");
  }
  if (sensor.isGyroscopeAvailable()) {
    hasGyro = true;
    println("Device has gyroscope");
  }  

  noStroke();
}

void draw() {
  background(255);
  float h = height/6;
  float y = 0;
  translate(width/2, 0);
  if (hasAccel) {
    fill(#C63030);
    rect(0, y, map(dataAccel.x, -10, +10, -width/2, +width/2), h);
    y += h;
    rect(0, y, map(dataAccel.y, -10, +10, -width/2, +width/2), h);
    y += h;
    rect(0, y, map(dataAccel.z, -10, +10, -width/2, +width/2), h);
    y += h;
  }
  if (hasGyro) {
    fill(#30C652);
    rect(0, y, map(dataGyro.x, -10, +10, -width/2, +width/2), h);
    y += h;
    rect(0, y, map(dataGyro.y, -10, +10, -width/2, +width/2), h);
    y += h;
    rect(0, y, map(dataGyro.z, -10, +10, -width/2, +width/2), h);
  }  
}

void onAccelerometerEvent(float x, float y, float z) {
  dataAccel.set(x, y, z);
}

void onGyroscopeEvent(float x, float y, float z) {
  dataGyro.set(x, y, z);
}

Listing 7-7.Checking Sensor Availability with Ketai

摘要

本章为我们在加工草图中使用传感器数据奠定了基础,无论是通过 Android API 还是通过 Ketai 库。在接下来的章节中,我们将基于这些技术来创建由设备的移动或位置驱动的图形和交互。

八、利用传感器数据驱动图形和声音

上一章已经介绍了读取传感器数据的基础知识,现在我们将了解如何在处理草图中使用来自加速度计、磁力计和陀螺仪的数据来生成交互式图形和声音。

使用科泰读取传感器数据

Android 设备中可用的传感器为我们提供了设备周围的大量数据。我们看到了如何通过使用 Android API 或 Ketai 库来检索这些数据,后者使传感器处理更容易。一旦数据以数值的形式出现在我们的草图中,我们就可以用任何我们想要的方式来驱动代码中的动画和交互。

在这一章中,我们将重点讨论三种特定的传感器,它们可以对我们设备的移动和位置状态提供即时反馈:加速度计(以及衍生的计步器)、磁场传感器和陀螺仪。有了这些传感器的数据,我们的机器人草图将能够对设备检测到的各种运动做出反应:突然的摇动、行走、空间旋转以及相对于地球磁场的方向。

我们将使用 Ketai 来读取传感器数据,因为它消除了定义事件监听器和传感器管理器的需要,从而简化了代码。然而,本章中的所有例子都可以很容易地使用 Android API。

测量加速度

加速度是速度相对于时间的变化率,但 Android 返回的加速度值还包括由于重力产生的加速度,该加速度指向地面,大小为 9.8 m/s 2 。如果我们的手机完全静止在桌子上,屏幕朝上,它的加速度应该是 a = (0,0,-9.8),因为它包括了 z 轴负方向的重力(记得图 7-1 )。但是,如果我们旋转手机,重力加速度将沿着三个轴投影,这取决于手机相对于垂直方向的方向。

A432415_1_En_8_Fig1_HTML.jpg

图 8-1。

Acceleration pattern during the walking stages (left), and acceleration data corresponding to a series of steps (right). Reproduced with permission from Neil Zhao

震动检测

当我们摇动手机时,我们使它的速度在很短的时间内从零快速变化到一个高值。因此,在此期间加速度会很高。我们可以通过计算加速度向量的大小来检测这种情况,如果它足够大,就触发“震动事件”。但是,我们还需要考虑到重力已经在加速度中了,所以它的大小需要至少大于重力的大小,9.8 m/s 2 。我们可以通过比较从 Ketai 获得的加速度向量的大小和重力常数来做到这一点,如果前者比预定义的阈值大,则确定发生了震动。这就是我们在清单 8-1 中要做的事情。

import ketai.sensors.*;
import android.hardware.SensorManager;

KetaiSensor sensor;
PVector accel = new PVector();
int shakeTime;

color bColor = color(78, 93, 75);

void setup() {
  fullScreen();  
  sensor = new KetaiSensor(this);
  sensor.start();
  textAlign(CENTER, CENTER);
  textSize(displayDensity * 36);
}

void draw() {
  background(bColor);
  text("Accelerometer: \n" +
    "x: " + nfp(accel.x, 1, 3) + "\n" +
    "y: " + nfp(accel.y, 1, 3) + "\n" +
    "z: " + nfp(accel.z, 1, 3), 0, 0, width, height);
}

void onAccelerometerEvent(float x, float y, float z) {
  accel.set(x, y, z);
  int now = millis();
  if (now - shakeTime > 250) {
    if (1.2 * SensorManager.GRAVITY_EARTH < accel.mag()) {
      bColor = color(216, 100, 46);
      shakeTime = now;
    } else {
      bColor = color(78, 93, 75);
    }    
  }  
}

Listing 8-1.Simple Shake-Detection Code

检查抖动的条件是1.2 * SensorManager.GRAVITY_EARTH < accel.mag())。这里,我们使用 1.2 作为抖动检测的阈值,我们可以使用更小或更大的值来检测更弱或更强的抖动。Android 中传感器 API 的SensorManager类变得很方便,因为它包含一个常数GRAVITY_EARTH,代表地球的重力加速度(太阳系中所有行星都有类似的常数,加上月球、太阳和虚构的死星)。时间条件已经到位,所以我们的应用不能每 250 毫秒触发一次以上的震动。

步进计数器

在检测震动的情况下,我们只需识别由加速度大小表征的单个事件。在行走或跑步时检测步数的情况下,问题更加困难:当我们迈出一步时,仅仅识别加速度的单一变化是不够的,而是我们需要记录一段时间内的规律,如图 8-1 所示。

然而,这种模式并不遵循完美的曲线,因为它受到信号噪声和行走速度不规则性的影响。再者就是因人而异,看自己的步态。虽然找出一种能够从原始加速度计数据中检测步数的算法并不太难,但 Android 通过在 4.4 版本中提供一种新型传感器(KitKat)来解决这个问题:步数计数器。该传感器为我们分析加速度计输入。它每走一步都会触发一个新的事件,所以我们可以在任何我们希望的时间间隔内计算步数。清单 8-2 中描述了加工中一个非常简单的步骤检测草图。

import ketai.sensors.*;

KetaiSensor sensor;
color bColor = color(78, 93, 75);
int stepTime = 0;
int stepCount = 0;

void setup() {
  fullScreen();
  orientation(PORTRAIT);
  sensor = new KetaiSensor(this);
  sensor.start();
  textAlign(CENTER, CENTER);
  textSize(displayDensity * 24);
}

void draw() {
  if (millis() - stepTime > 500) {
    bColor = color(78, 93, 75);
  }
  background(bColor);
  text("Number of steps = " + stepCount, 0, 0, width, height);
}

void onStepDetectorEvent() {
  bColor = color(216, 100, 46);
  stepTime = millis();  
  stepCount++;
}

Listing 8-2.Using Android’s Step Counter

Ketai 有另一个函数onStepCounterEvent(float s),我们在变量s中接收设备重启后的总步数。如果我们需要在应用未运行时跟踪一天的总步数,而又不错过活动,这可能会很有用。

步骤数据的视听映射

正如我们刚刚看到的,使用 Ketai 中的步数检测器事件来计算单个步数非常容易。如何在我们的加工草图中使用这些步数数据是一个我们只有在考虑我们的最终目标是什么之后才能回答的问题;例如,显示身体活动的“实用”可视化,创建该活动的更抽象的表示,驱动一些我们可以用作动态壁纸的背景图形(和/或音频),等等。

由我们来决定如何将传感器数据映射到视觉或声音元素中。为了说明如何进行这种映射,我们将绘制一个草图,其中每一个新的步骤都会触发一个简单的动画,一个彩色的圆圈出现在屏幕上,然后淡出到背景中,因此最终结果将是一个响应我们行走的几何图案。

在进入 Android 模式之前,我们可以开始用 Java 模式写一些草图来完善视觉概念。一种可能的方法是使用一个矩形网格,我们在网格上随机放置彩色点。定义一个类来保存点的动画逻辑,以及在草图运行时使用数组列表来跟踪可变数量的点,这可能是有用的。所有这些想法都在清单 8-3 中实现了。

float minSize = 50;
float maxSize = 100;
ArrayList<ColorDot> dots;

void setup() {
  size(800, 480);
  colorMode(HSB, 360, 100, 100, 100);
  noStroke();
  dots = new ArrayList<ColorDot>();
}

void draw() {
  background(0, 0, 0);  

  if (random(1) < 0.1) {
    dots.add(new ColorDot());
  }  

  for (int i = dots.size() - 1; i >= 0 ; i--) {
    ColorDot d = dots.get(i);
    d.update();
    d.display();
    if (d.colorAlpha < 1) {
      dots.remove(i);
    }    
  }  
}

class ColorDot {
  float posX, posY;
  float rad, maxRad;
  float colorHue, colorAlpha;

  ColorDot() {
    posX = int(random(1, width/maxSize)) * maxSize;
    posY = int(random(1, height/maxSize)) * maxSize;
    rad = 0.1;
    maxRad = random(minSize, maxSize);
    colorHue = random(0, 360);
    colorAlpha = 70;
  }

  void update() {
    if (rad < maxRad) {
      rad *= 1.5;
    } else {
      colorAlpha -= 0.3;
    }
  }

  void display() {
    fill(colorHue, 100, 100, colorAlpha);
    ellipse(posX, posY, rad, rad);
  }
}

Listing 8-3.
Random Colored Dots

这里,我们使用 HSB 空间从整个光谱中随机选取一种颜色,同时保持饱和度和亮度不变。这些点通过快速增大尺寸(随着半径的rad *= 1.5更新)来制作动画,然后通过降低 alpha 随着colorAlpha -= 0.3逐渐消失,直到它们变得完全透明,这时它们被移除。在每一帧中以 0.1 的概率添加新点。调整这些值后,我们应该得到类似于图 8-2 的输出。

A432415_1_En_8_Fig2_HTML.jpg

图 8-2。

Output of the initial sketch that generates random dots, running from Java mode

下一步是连接点动画与步骤检测。实现这一点的一个简单方法是在每次触发步进检测器事件时创建一个新的点。因此,我们需要将 Ketai 库添加到前面的代码中,然后在onStepDetectorEvent()事件中创建点,如清单 8-4 所示。

import ketai.sensors.*;

KetaiSensor sensor;

float minSize = 150 * displayDensity;
float maxSize = 300 * displayDensity;
ArrayList<ColorDot> dots;

void setup() {
  fullScreen();
  orientation(LANDSCAPE);
  colorMode(HSB, 360, 100, 100, 100);
  noStroke();  
  dots = new ArrayList<ColorDot>();  
  sensor = new KetaiSensor(this);
  sensor.start();  
}

void draw() {
  background(0, 0, 0);    
  for (int i = dots.size() - 1; i >= 0 ; i--) {
    ColorDot d = dots.get(i);
    d.update();
    d.display();
    if (d.colorAlpha < 1) {
      dots.remove(i);
    }
  }  
}

class ColorDot {
  float posX, posY;
  float rad, maxRad;
  float colorHue, colorAlpha;

  ColorDot() {
    posX = int(random(1, width/maxSize)) * maxSize;
    posY = int(random(1, height/maxSize)) * maxSize;
    rad = 0.1;
    maxRad = random(minSize, maxSize);    
    colorHue = random(0, 360);    
    colorAlpha = 70;
  }

  void update() {
    if (rad < maxRad) {
      rad *= 1.5;
    } else {
      colorAlpha -= 0.1;
    }
  }

  void display() {
    fill(colorHue, 100, 100, colorAlpha);
    ellipse(posX, posY, rad, rad);
  }
}

void onStepDetectorEvent() {
  dots.add(new ColorDot());
}

Listing 8-4.Using Steps to Animate the Dots

注意点的最小和最大尺寸现在是如何被displayDensity缩放的,所以我们草图的输出保持了它的比例,而不管屏幕设备的 DPI。我们可以运行这个草图作为一个普通的应用或动态壁纸,以防我们想让它一直运行,并在我们的主屏幕上驱动背景图像。

可以用不同的方法来完善这个草图。例如,我们可以通过将这些参数与时间和行走速度联系起来,来减少点的大小和颜色的随机性。为了计算后者,我们可以在某个固定的时间间隔(比如说每五秒钟)将计数值重置为零,并除以自上次重置以来经过的时间(因为速度=数值差/时间差)。清单 8-5 包括我们需要存储当前行走速度、上次更新时间和步数的额外变量,以及计算点半径和色调的差异(其余与清单 8-4 相同)。

import ketai.sensors.*;

KetaiSensor sensor;

float minSize = 150 * displayDensity;
float maxSize = 300 * displayDensity;
ArrayList<ColorDot> dots;

int stepCount = 0;
int updateTime = 0;
float walkSpeed = 0;
...
class ColorDot {
  float posX, posY;
  float rad, maxRad;
  float colorHue, colorAlpha;

  ColorDot() {
    posX = int(random(1, width/maxSize)) * maxSize;
    posY = int(random(1, height/maxSize)) * maxSize;
    rad = 0.1;
    float speedf = constrain(walkSpeed, 0, 2)/2.0;
    maxRad = map(speedf, 1, 0, minSize, maxSize);
    colorHue = map(second(), 0, 60, 0, 360);    
    colorAlpha = 70;
  }
  ...
}

void onStepDetectorEvent() {
  int now = millis();
  stepCount++;
  if (5000 < now - updateTime) {
    walkSpeed = stepCount/5.0;
    stepCount = 0;
    updateTime = now;
  }
  dots.add(new ColorDot());
}

Listing 8-5.Using Time and Walking Speed to Control Animation

如果我们检查ColorDot类的构造函数中半径的计算,我们可以看到walkSpeed中的值并没有被直接使用,而是首先用函数constrain()限制在 0-2 步/秒的范围内,然后进行归一化,这样我们就有了一个介于 0 和 1 之间的值,我们可以一致地映射到半径范围maxSize - minSize。这意味着我们走得越快,圆点应该越小。点的色调也是映射的结果,在这种情况下,使用second()功能获得的当前秒到 0-360°色调范围。

播放音频

到目前为止,本书中的所有例子都是纯视觉的,没有音频成分。然而,点草图可以利用声音来补充步行驱动的动画。一种选择是每次检测到一个音步时播放随机的音频片段,但是也许我们可以通过播放音阶的音符来做一些更有趣的事情。

为了简单起见,让我们考虑一个五声音阶( http://www.musictheoryis.com/pentatonic-scale/ ),它有音符 A、G、E、D 和 c。如果我们总是按照它们的自然顺序演奏这些音符,我们会一遍又一遍地听到原始音阶,结果会相当重复。在另一个极端,随机选择一个音符会太混乱。所以,我们可以尝试一个中间的解决方案,它有足够的可变性,同时保持尺度的和谐;例如,播放当前音符的上一个或下一个音符,给每个选项一个预定义的概率。我们如何着手实施这个想法呢?

首先,与传感器一样,处理不包括任何用于音频播放的内置功能。然而,我们可以使用 Android API 创建一个最小的AudioPlayer类来扩展 Android 的MediaPlayer。然后,我们需要获得五个音符的音频剪辑,并将它们复制到草图的数据文件夹中。

Note

Android 支持多种音频格式,包括 MP3、WAVE、MIDI 和 Vorbis。有关媒体格式和编解码器的完整列表,请参考开发网站上的媒体格式页面: https://developer.android.com/guide/topics/media/media-formats.html

清单 8-6 结合了我们之前的彩色点草图和一个AudioPlayer类以及我们之前讨论过的简单逻辑来挑选要演奏的音符(只显示了与清单 8-5 不同的代码部分)。

import ketai.sensors.*;
import android.media.MediaPlayer;
import android.content.res.AssetFileDescriptor;
import android.media.AudioManager;

KetaiSensor sensor;
...
int numNotes = 5;
AudioPlayer [] notes = new AudioPlayer[numNotes];
int lastNote = int(random(1) * 4);

void setup() {
  fullScreen();
  orientation(LANDSCAPE);
  colorMode(HSB, 360, 100, 100, 100);
  noStroke();  
  for (int i = 0; i < numNotes; i++) notes[i] = new AudioPlayer();
  notes[0].loadFile(this, "5A.wav");
  notes[1].loadFile(this, "4G.wav");
  notes[2].loadFile(this, "4E.wav");
  notes[3].loadFile(this, "4D.wav");
  notes[4].loadFile(this, "4C.wav");  
  dots = new ArrayList<ColorDot>();  
  sensor = new KetaiSensor(this);
  sensor.start();  
}
...
class ColorDot {
  float posX, posY;
  float rad, maxRad;
float colorHue, colorAlpha;
int note;

  ColorDot() {
    posX = int(random(1, width/maxSize)) * maxSize;
    posY = int(random(1, height/maxSize)) * maxSize;
    rad = 0.1;
    float speedf = constrain(walkSpeed, 0, 2)/2.0;
    maxRad = map(speedf, 1, 0, minSize, maxSize);    
    selectNote();
    colorHue = map(note, 0, 4, 0, 360);    
    colorAlpha = 70;
  }

  void selectNote() {
    float r = random(1);    
    note = lastNote;
    if (r < 0.4) note--;
    else if (r > 0.6) note++;
    if (note < 0) note = 1;
    if (4 < note) note = 3;
    notes[note].play();
    lastNote = note;    
  }
  ...
}
...
class AudioPlayer extends MediaPlayer {
  boolean loadFile(PApplet app, String fileName) {
    AssetFileDescriptor desc;
    try {
      desc = app.getActivity().getAssets().openFd(fileName);
    } catch (IOException e) {
      println("Error loading " + fileName);
      println(e.getMessage());
      return false;
    }

    if (desc == null) {
      println("Cannot find " + fileName);
      return false;
    }

    try {
      setDataSource(desc.getFileDescriptor(), desc.getStartOffset(),
                    desc.getLength());
      setAudioStreamType(AudioManager.STREAM_MUSIC);
      prepare();
      return true;
    } catch (IOException e) {
      println(e.getMessage());
      return false;
    }
  }

  void play() {
    if (isPlaying()) seekTo(0);
    start();
  }
}

Listing 8-6.Playing a Pentatonic Scale by Walking

我们将每个音符加载到一个单独的AudioPlayer类实例中,并将五个AudioPlayer对象存储在notes数组中。我们在setup()中初始化这个数组,然后在ColorDot类的新selectNote()方法中实现选择逻辑,使用 0.4 作为选择音阶中前一个音符的概率,使用 0.6 作为选择下一个音符的概率。图 8-3 显示了该草图的输出,但是,当然,我们需要在实际设备上运行它,以便在走动时欣赏它的音频部分。

A432415_1_En_8_Fig3_HTML.jpg

图 8-3。

Dots sketch running on the device

使用磁传感器

磁性传感器(或磁力计)是我们在 Android 设备中发现的另一种非常常见的传感器,它对几个应用都很有用。例如,清单 8-7 显示了我们如何使用它来检测金属物体的接近程度,方法是将磁场的测量值与我们当前位置的地球磁场的预期值进行比较。如果我们在手机上运行这个草图,我们将有效地把它变成一个金属探测器!

import ketai.sensors.*;
import android.hardware.GeomagneticField;

KetaiSensor sensor;
float expMag, obsMag;

void setup() {
  fullScreen();
  sensor = new KetaiSensor(this);
  sensor.start();  
  GeomagneticField geoField = new GeomagneticField(14.0093, 120.996147, 300,
                              System.currentTimeMillis());
  expMag = geoField.getFieldStrength()/1000;
}

void draw() {
  println(obsMag, expMag);
  if (obsMag < 0.7 * expMag || 1.3 * expMag < obsMag) {
    background(255);
  } else {
    background(0);
  }  
}

void onMagneticFieldEvent(float x, float y, float z) {
  obsMag = sqrt(sq(x) + sq(y) + sq(z));
}

Listing 8-7.Detecting the Strength of the Magnetic Field

请注意,我们必须向GeomagneticField()构造函数提供我们当前位置的地理坐标(以度表示的纬度和经度,以米表示的高度),以及所谓的纪元时间(自 1970 年 1 月 1 日以来以毫秒表示的当前时间),以便获得仅由地球磁场引起的场。然后,我们可以与设备测量的实际磁场进行比较。

创建指南针应用

除了用于实现方便的金属探测器之外,将磁场数据与加速度相结合可以用于确定设备相对于地球磁北极的方向。换句话说,一个指南针。

重力和地磁矢量编码了确定设备相对于地球表面的方向所需的所有信息。使用 Ketai,我们可以获得加速度和磁场向量的分量,并利用这些分量获得旋转矩阵,该矩阵将坐标从设备系统(图 7-1 )转换到世界坐标系,我们可以想象该坐标系与我们在地球表面的位置相关,如图 8-4 所示。

A432415_1_En_8_Fig4_HTML.jpg

图 8-4。

World coordinate system, with x pointing east, y pointing north, and z away from Earth’s center

最后一步是从旋转矩阵中导出相对于这些 xyz 轴的方向角:方位角(围绕-z 的角度)、俯仰角(围绕 x 的角度)和滚动角(围绕 y 的角度)。为了实现指南针,我们只需要方位角,因为它给出了我们所在位置相对于指向北方的 y 轴的偏差。Android API 的SensorManager类包含了几个方便的方法来执行所有这些计算,我们在清单 8-8 中执行了这些计算。

import ketai.sensors.*;
import android.hardware.SensorManager;

KetaiSensor sensor;

float[] gravity = new float[3];
float[] geomagnetic = new float[3];
float[] I = new float[16];
float[] R = new float[16];
float orientation[] = new float[3];

float easing = 0.05;
float azimuth;

void setup() {
  fullScreen(P2D);
  orientation(PORTRAIT);  
  sensor = new KetaiSensor(this);
  sensor.start();    
}

void draw() {
  background(255);

  float cx = width * 0.5;
  float cy = height * 0.4;
  float radius = 0.8 * cx;

  translate(cx, cy);

  noFill();
  stroke(0);
  strokeWeight(2);
  ellipse(0, 0, radius*2, radius*2);
  line(0, -cy, 0, -radius);

  fill(192, 0, 0);
  noStroke();    
  rotate(-azimuth);
  beginShape();
  vertex(-30, 40);
  vertex(0, 0);
  vertex(30, 40);
  vertex(0, -radius);
  endShape();
}

void onAccelerometerEvent(float x, float y, float z) {
  gravity[0] = x; gravity[1] = y; gravity[2] = z;
  calculateOrientation();
}

void onMagneticFieldEvent(float x, float y, float z) {
  geomagnetic[0] = x; geomagnetic[1] = y; geomagnetic[2] = z;
  calculateOrientation();
}

void calculateOrientation() {
  if (SensorManager.getRotationMatrix(R, I, gravity, geomagnetic)) {
    SensorManager.getOrientation(R, orientation);
    azimuth += easing * (orientation[0] - azimuth);
  }  
}

Listing 8-8.A Compass Sketch

通过向SensorManager中的getRotationMatrix()getOrientation()方法提供加速度和磁场矢量,我们将获得一个包含方位角、俯仰角和滚动角的方向矢量。在这个例子中,我们只使用方位角来绘制指南针,我们可以将它作为动态壁纸安装,这样它在后台总是可用的(如图 8-5 所示)。

A432415_1_En_8_Fig5_HTML.jpg

图 8-5。

Compass sketch running as a live wallpaper

加速度计和磁力计的值都有噪声,所以我们用线azimuth += easing * (orientation[0] - azimuth)对值进行了一些“缓和”。使用这个公式,我们用新值的一部分来更新当前的方位角值,因此变化更柔和,噪声被平滑掉。缓动常数越接近 0,平滑度越强,指南针指针的移动越受抑制。另一方面,缓动值 1 将导致完全没有平滑,因为它等同于指定新的传感器值azimuth = orientation[0]

或者,我们可以直接从科泰获得方向向量,而不必依赖来自 Android 的SensorManager类。为此,我们首先必须在setup()中显式启用加速度计和磁场传感器(因为我们不会使用 Ketai 的事件函数),然后我们可以从draw()中的KetaiSensor对象调用getOrientation(),如清单 8-9 所示。草图的这个修改版本的输出应该和以前的一样。

import ketai.sensors.*;

float orientation[] = new float[3];
float easing = 0.05;
float azimuth;

KetaiSensor sensor;

void setup() {
  fullScreen(P2D);
  orientation(PORTRAIT);  
  sensor = new KetaiSensor(this);
  sensor.enableAccelerometer();
  sensor.enableMagenticField();
  sensor.start();
}

void draw() {
  ...  
  ellipse(0, 0, radius*2, radius*2);
  line(0, -cy, 0, -radius);

  sensor.getOrientation(orientation);
  azimuth += easing * (orientation[0] - azimuth);

  fill(192, 0, 0);
  noStroke();    
  ...
}

Listing 8-9.Using Ketai’s getOrientation() Function

陀螺仪

陀螺仪可以补充加速度计和磁力计,但也可以应用于这些传感器无法处理的情况。加速度计和磁力计为我们提供设备在空间中的运动和方向的数据;然而,它们有局限性。一方面,加速度计不能检测恒速运动,因为在这种情况下加速度为零,而另一方面,磁传感器仅给出与位置(即,相对于地球磁场的方向)相关的非常粗略的变量。此外,两个传感器都返回带有大量噪声的值。

相比之下,陀螺仪可以精确读取设备在空间旋转的角速度。有了这个速度,就有可能推断出设备相对于任意初始状态的方位。也就是说,它不能给我们一个相对于一个系统的方向的绝对描述,比如我们之前讨论过的世界坐标。然而,该信息可以在加速度计和磁力计的帮助下推断出来。

让我们通过几个简单的例子来了解陀螺仪的工作原理。因为它提供了我们在加工草图中用来控制 3D 运动的值,所以写一个非常简单的 3D 草图是有意义的。使用 Ketai,很容易从陀螺仪获得旋转角度,就像我们之前对其他传感器所做的那样。我们将使用清单 8-10 中的 P3D 渲染器来绘制一个简单的 3D 场景,其中一个立方体根据陀螺仪测量的角速度绕其中心旋转。

import ketai.sensors.*;

KetaiSensor sensor;
float rotationX, rotationY, rotationZ;

void setup() {
  fullScreen(P3D);
  orientation(LANDSCAPE);
  sensor = new KetaiSensor(this);
  sensor.start();
  rectMode(CENTER);
  fill(180);
}

void draw() {
  background(255);
  translate(width/2, height/2);
  rotateZ(rotationZ);
  rotateY(rotationX);
  rotateX(rotationY);
  box(height * 0.3);
}

void onGyroscopeEvent(float x, float y, float z) {
  rotationX += 0.1 * x;
  rotationY += 0.1 * y;
  rotationZ += 0.1 * z;
}

Listing 8-10.Rotating a Box with the Gyroscope

由于 x、y 和 z 值是(角速度),我们不能直接使用它们作为场景中对象的旋转角度,而是应该将它们添加到旋转变量中(由一个常数缩放,在本例中为 0.1,但可以调整以使移动更慢或更快)。此外,我们用rotateY()函数应用rotationX角度(意味着我们绕 y 轴旋转立方体),用rotateX()应用rotationY。此开关的原因是设备的方向被锁定在LANDSCAPE,这意味着加工屏幕中的 x 轴对应于设备的水平方向,该方向沿着设备坐标系的 y 轴,我们在图 7-1 中看到。

A432415_1_En_8_Fig6_HTML.jpg

图 8-6。

Sketch using the gyroscope to control rotation of a cube

使用陀螺仪的另一个重要方面是,涉及设备的任何旋转都将被传感器测量;例如,它将检测手持电话的人在行走时何时转身(即使电话相对于用户的相对方向没有改变)。在这种情况下,我们可以通过减去一个偏移值来保持初始方向,任何时候我们都可以在手机上“重新定位”场景。例如,我们可以存储当前的旋转角度作为我们触摸屏幕时的偏移,如清单 8-11 中所做的那样(仅显示与清单 8-10 不同的部分)。

...
void draw() {
  background(255);
  translate(width/2, height/2);
  rotateZ(rotationZ - offsetZ);
  rotateY(rotationX - offsetX);
  rotateX(rotationY - offsetY);
  box(height * 0.3);
}
...
void mousePressed() {
  offsetX = rotationX;
  offsetY = rotationY;
  offsetZ = rotationZ;
}
Listing 8-11.
Recentering

the Gyroscope Data

使用陀螺仪时,我们不仅限于处理 3D 几何图形。如果我们在 2D 绘图,我们需要做的就是跟踪 z 旋转,如清单 8-12 所示。

import ketai.sensors.*;

KetaiSensor sensor;
float rotationZ, offsetZ;

void setup() {
  fullScreen(P2D);
  orientation(LANDSCAPE);
  sensor = new KetaiSensor(this);
  sensor.start();
  rectMode(CENTER);
  fill(180);
}

void draw() {
  background(255);
  translate(width/2, height/2);
  rotate(rotationZ - offsetZ);
  rect(0, 0, height * 0.3, height * 0.3);
}

void onGyroscopeEvent(float x, float y, float z) {
  rotationZ += 0.1 * z;
}

void mousePressed() {
  offsetZ = rotationZ;
}

Listing 8-12.Gyroscope Rotation in 2D

陀螺仪可用于在游戏应用中实现输入。我们将在本章的最后一节看到如何做到这一点。

用陀螺仪控制导航

在前面的例子中,我们使用旋转角度来控制 2D 和 3D 形状,它们保持固定在屏幕的中心。如果我们只需要控制形状的旋转,这就足够了,但是如果我们还想确定它们的平移,我们需要想出其他方法。

事实上,一种方法是不平移我们想要用陀螺仪控制的形状,而是以相反的方向平移场景的其余部分。图 8-7 中的图表有助于形象化这个想法。在这里,我们将写一个草图来导航一艘“宇宙飞船”(只是一个三角形),通过一个无尽的小行星(椭圆)领域。关键是正确编码所有椭圆的平移,以传达飞船相对于小行星的相对运动。

A432415_1_En_8_Fig7_HTML.jpg

图 8-7。

Diagram on relative translations of moving objects

实现这种效果的数学并不困难:如果我们的形状最初朝着屏幕的顶部边缘移动,描述这种移动的前向向量将是v = new PVector(0, -1),因为处理中的 y 轴指向下。我们可以计算一个矩阵来表示应该应用于这个向量的旋转。产生的向量可用于平移场景中的所有其他形状,以创建相对运动。

处理 API 包括一个封装这些计算的PMatrix2D类。例如,如果我们的旋转角度是QUARTER_PI,我们可以通过做mat.rotate(QUARTER_PI)来生成一个对应于这个旋转的旋转矩阵,其中mat是一个PMatrix3D类型的对象。一旦我们完成了这些,我们就可以将矩阵应用到代表翻译的PVector对象上;例如mat.mult(v, rv),其中v是原始的PVectorrv是旋转后的结果PVector。让我们看看清单 8-13 中的这个 API。

import ketai.sensors.*;

KetaiSensor sensor;
float rotationZ, offsetZ;
PMatrix2D rotMatrix = new PMatrix2D();
PVector forward = new PVector(0, -1);
PVector forwardRot = new PVector();
ArrayList<Asteroid> field;
float speed = 2;

void setup() {
  fullScreen(P2D);
  orientation(LANDSCAPE);
  sensor = new KetaiSensor(this);
  sensor.start();
  ellipseMode(CENTER);
  noStroke();
  field = new ArrayList<Asteroid>();
  for (int i = 0; i < 100; i++) {
    field.add(new Asteroid());
  }
}

void draw() {
  background(0);

  boolean hit = false;  
  float angle = rotationZ - offsetZ;
  rotMatrix.reset();
  rotMatrix.rotate(angle);
  rotMatrix.mult(forward, forwardRot);
  forwardRot.mult(speed);
  for (Asteroid a: field) {
    a.update(forwardRot);
    a.display();
    if (a.hit(width/2, height/2)) hit = true;
  }

  pushMatrix();
  translate(width/2, height/2);
  rotate(angle);
  if (hit) {
    fill(252, 103, 43);
  } else {
    fill(67, 125, 222);
  }
  float h = height * 0.2;
  triangle(0, -h/2, h/3, +h/2, -h/3, +h/2);
  popMatrix();
}

void onGyroscopeEvent(float x, float y, float z) {
  rotationZ += 0.1 * z;
}

void mousePressed() {
  offsetZ = rotationZ;
}

class Asteroid {
  float x, y, r;
  color c;
  Asteroid() {
    c = color(random(255), random(255), random(255));
    r = height * random(0.05, 0.1);
    x = random(-2 * width, +2 * width);
    y = random(-2 * height, +2 * height);
  }
  void update(PVector v) {
    x -= v.x;
    y -= v.y;
    if (x < -2 * width || 2 * width < x ||
        y < -2 * height || 2 * height < y) {
      x = random(-2 * width, +2 * width);
      y = random(-2 * height, +2 * height);      
    }
  }
  void display() {
    fill(c);
    ellipse(x, y, r, r);
  }
  boolean hit(float sx, float sy) {
    return dist(x, y, sx, sy) < r;
  }
}

Listing 8-13.Controlling a Spaceship with the Gyroscope

正如我们在这段代码中看到的,宇宙飞船总是被绘制在屏幕的中心,并且它是由旋转的正向向量平移的小行星,正如我们前面讨论的那样。Asteroid类包含所有处理将每个小行星放置在随机位置的逻辑,使用旋转的正向向量更新其位置,在当前位置显示它,并通过检查它是否足够靠近屏幕的中心来确定它是否正在撞击飞船。

每颗小行星都被放置在一个尺寸为[-2 * width, +2 * width] × [-2 * height, +2 * height]的矩形区域中,一旦它移出这个区域(由update()函数中的边界检查决定),它就会被再次放回内部。此外,请注意 x 和 y、–v.x–v.y平移中的负号,这确保了正确的相对运动。我们可以把正向矢量想象成我们飞船的速度,事实上通过速度因子(在这个草图中设置为 2)缩放它,我们可以让飞船移动得更快或更慢。

最后,我们实现了一个简单的碰撞检测元素,这样当一颗小行星接近它在屏幕中心的位置时,飞船就会改变颜色。我们可以设想多种方法,通过添加交互来控制速度、更好的图像图形和 SVG 形状等等,将这个早期原型变成一个更有吸引力的游戏。在其初始形式中,输出应该类似于图 8-8 。

A432415_1_En_8_Fig8_HTML.jpg

图 8-8。

Controlling the navigation through a field of obstacles with the gyroscope

摘要

基于在处理过程中读取传感器数据的基本技术,我们现在已经了解了三种常见硬件传感器的一些高级应用:加速度计、磁力计和陀螺仪。这些传感器特别重要,因为它们可以提供关于设备移动和位置的即时反馈,从而使我们能够基于物理手势和动作创建交互式应用。我们会发现这些交互在很多项目中非常有用,从可视化身体活动到用图形和声音编码我们自己的游戏体验。

九、地理定位

我们的 Android 设备中的地理位置传感器让我们能够高精度地知道我们在哪里,我们可以在位置感知应用中使用这些信息。在这一章中,我们将看到如何在处理中创建这种类型的应用,我们将开发一个结合位置和谷歌街景图像的最终项目。

Android 中的位置数据

我们每天都在智能手机中使用位置感知应用来寻找我们周围的名胜,提前计划旅行方向,或玩基于位置的游戏,如 Turf 和 Pokémon GO。所有这些用途都是通过相同的基础地理定位技术实现的,主要是全球定位系统(GPS),但也包括蜂窝塔三角测量、蓝牙邻近检测和 Wi-Fi 接入点。GPS 是大多数人立即与地理定位联系在一起的技术:它基于由美国拥有并由美国空军运营的卫星网络,这些卫星将地理定位信息发送到地球表面的 GPS 接收器,包括移动电话中的接收器。

Note

其他国家也开发了类似的系统,如 GLONASS(俄国)、北斗(中国)、NAVIC(印度)和伽利略(欧洲)。默认情况下,Android 系统只使用 GPS 卫星,但一些制造商引入了一些变化,以便从这些其他系统中获取地理位置数据,这可以提供更好的覆盖范围和准确性。Play Store 中提供的 GPS 测试应用可以显示手机正在使用的系统。

使用 GPS 或类似的导航卫星系统来获取位置数据的一个缺点是,它需要大量电池来为 GPS 天线供电。还有,手机需要对天空有一个通畅的视线。为了解决这些问题,我们可以利用其他定位源,如 Cell-ID、蓝牙和 Wi-Fi,它们精度较低,但能耗较低。作为参考,GPS 定位的精度在 16 英尺(4.9 米)左右,而 Wi-Fi 的精度在 130 英尺(40 米)以内。Cell-ID 的可变性更高,具体取决于小区大小,范围从几英尺到几英里。

然而,我们不需要担心何时以及如何选择特定的位置系统,因为 Android 会在手机设置中给定一个通用配置的最佳位置供应器之间自动切换,如图 9-1 所示。在 Android 7 及更高版本中,我们所要做的就是设置我们是想要结合所有可能来源的高精度定位,还是不需要 GPS 的电池节省,还是只需要 GPS。

A432415_1_En_9_Fig1_HTML.jpg

图 9-1。

Android settings to choose the location mode

在处理中使用位置 API

Android 提供了全面的 API 来访问系统中可用的位置服务( https://developer.android.com/guide/topics/location/index.html )。我们还可以使用 Ketai 库来获取位置值,而不必担心这个 API,就像我们对运动传感器所做的那样。然而,在本章中,我们将直接从我们的处理草图中使用位置 API,因为在使用位置服务时有许多重要的方面需要考虑,特别是权限处理和并发性,即使我们稍后使用 Ketai,熟悉它们也是一个好主意。

Note

Google Play 服务位置 API(https://developer.android.com/training/location/index.html)是我们将在本章学习的标准 Android 位置 API 的更新和功能更丰富的替代品。然而,当从 PDE 编码时,处理仅支持后者。我们可以将我们的草图导出为 Android 项目,然后从 Android Studio 导入以使用 Google Play 服务(详见附录 A)。

位置权限

在我们的 Android 应用中使用特定功能,例如访问互联网,需要使用 Android 权限选择器向我们的草图添加适当的权限。然而,我们在第六章中看到,这对于危险权限来说是不够的,危险权限需要在运行 Android 6 或更新版本的设备上运行时进行额外的显式请求。访问位置数据的权限属于这一类,它们是ACCESS_COARSE_LOCATIONACCESS_FINE_LOCATION。第一个允许访问从手机信号塔和 Wi-Fi 获得的大致位置,而第二个允许从 GPS 获得位置。为了在我们的草图中使用这些权限,我们需要在权限选择器中检查它们(图 9-2 ),然后在我们的草图代码中使用requestPermission()函数。

A432415_1_En_9_Fig2_HTML.jpg

图 9-2。

Selecting coarse and fine location permissions

清单 9-1 展示了启用位置的草图的基本设置,其中我们定义了一个位置管理器和相关的监听器,其方式与我们之前对其他传感器所做的类似,包括设置草图所需的权限。

import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;

LocationManager manager;
SimpleListener listener;
String provider;

double currentLatitude;
double currentLongitude;
double currentAltitude;

void setup () {
  fullScreen();
  textFont(createFont("SansSerif", displayDensity * 24));
  textAlign(CENTER, CENTER);
  requestPermission("android.permission.ACCESS_FINE_LOCATION", "initLocation");
}

void draw() {
  background(0);
  if (hasPermission("android.permission.ACCESS_FINE_LOCATION")) {
    text("Latitude: " + currentLatitude + "\n" +
         "Longitude: " + currentLongitude + "\n" +
         "Altitude: " + currentAltitude, width, height);
  } else {
    text("No permissions to access location", 0, 0, width, height);
  }
}

void initLocation(boolean granted) {
  if (granted) {
    Context context = getContext();
    listener = new SimpleListener();
    manager = (LocationManager)
              context.getSystemService(Context.LOCATION_SERVICE);
    provider = LocationManager.NETWORK_PROVIDER;
    if (manager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
      provider = LocationManager.GPS_PROVIDER;
    }
    manager.requestLocationUpdates(provider, 1000, 1, listener);
  }
}

public void resume() {
  if (manager != null) {
    manager.requestLocationUpdates(provider, 1000, 1, listener);
  }
}

public void pause() {
  if (manager != null) {
    manager.removeUpdates(listener);
  }
}

class SimpleListener implements LocationListener {
  public void onLocationChanged(Location loc) {
    currentLatitude = loc.getLatitude();
    currentLongitude = loc.getLongitude();
    currentAltitude = loc.getAltitude();
  }
  public void onProviderDisabled(String provider) { }
  public void onProviderEnabled(String provider) { }
  public void onStatusChanged(String provider, int status, Bundle extras) { }
}

Listing 9-1.Getting Location Data

onLocationChanged()中接收到的位置对象包含几条信息,其中最重要的是经纬度值,表示手机在地球表面的位置,沿着与赤道平行的线和连接地理极点的子午线。

Note

Android 以双精度数字的形式提供纬度和经度,有效数字反映了位置读数的精度:米精度需要五个有效数字,而亚米细节需要六个或更多。

还有一些重要的事情需要注意。首先,我们请求了精确定位权限,这将为我们提供可用的最高分辨率以及对不太精确的源的访问,因此不需要为粗略定位请求单独的权限。其次,我们通过指明我们的首选供应器(网络或 GPS)用requestLocationUpdates()配置了位置管理器。Android 将通过考虑所请求的权限、设备的位置模式以及每个时刻位置数据的可用来源的组合来确定实际的供应器。在代码中,我们将网络供应器设置为默认供应器,它使用来自手机信号发射塔和 Wi-Fi 接入点的数据来确定位置,如果启用了相应的供应器,则切换到更精确的 GPS。我们还设置了位置更新之间的最小时间间隔(单位为毫秒)(这里 1000 表示更新不能超过每秒一次)和触发位置更新的最小距离(单位为米)。

最后,我们像对其他传感器一样实现了恢复和暂停事件,因此应用暂停时不会生成位置更新,应用在恢复后会再次请求它们。当应用在后台运行时,这对于节省电池非常重要。

事件线程和并发

在我们前面看到的所有传感器示例中,我们在相应的侦听器中读取传感器数据没有太大困难。只要我们只是将最后收到的数据存储在 float 变量中,就应该没问题。然而,一旦我们将传感器信息保存在数据结构(如数组或数组列表)中以跟踪以前的值,问题就开始了。问题源于这样一个事实:处理中的draw()函数是从动画线程中调用的(看看第六章中的“使用线程”一节),而事件处理方法,像位置情况下的onLocationChanged(),是从另一个线程,即应用的主线程中调用的。由于这些线程是并行运行的,当它们试图同时访问相同的数据时,可能会发生冲突;也就是同时。这可能会导致我们的应用出现意外行为,甚至崩溃。

正如我们在第六章中讨论的,解决并发问题需要一些额外的工作。一种解决方案是将每次调用onLocationChanged()获得的位置数据存储在一个“队列”中,然后在绘制过程中从队列中检索事件。队列是“同步的”,因此当在一个线程中添加新数据或删除现有数据时,任何其他线程都必须等待,直到操作结束。这种特殊的技术不能解决所有的并发问题,但是对于我们的情况来说已经足够了。清单 9-2 展示了我们如何实现纬度/经度位置的队列。

import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;

LocationManager manager;
SimpleListener listener;
String provider;
LocationQueue queue = new LocationQueue();
ArrayList<LocationValue> path = new ArrayList<LocationValue>();

void setup () {
  fullScreen();
  textFont(createFont("SansSerif", displayDensity * 24));
  textAlign(CENTER, CENTER);
  requestPermission("android.permission.ACCESS_FINE_LOCATION", "initLocation");
}

void draw() {
  background(0);
  while (queue.available()) {
    LocationValue loc = queue.remove();
    path.add(0, loc);
  }
  String info = "";
  for (LocationValue loc: path) {
    info += loc.latitude + ", " + loc.longitude + "\n";
  }
  text(info, 0, 0, width, height);
}

void initLocation(boolean granted) {
  if (granted) {
    Context context = getContext();
    listener = new SimpleListener();
    manager = (LocationManager)
              context.getSystemService(Context.LOCATION_SERVICE);
    provider = LocationManager.NETWORK_PROVIDER;
    if (manager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
      provider = LocationManager.GPS_PROVIDER;
    }
    manager.requestLocationUpdates(provider, 1000, 1, listener);
  }
}

class SimpleListener implements LocationListener {
  public void onLocationChanged(Location loc) {
    queue.add(new LocationValue(loc.getLatitude(), loc.getLongitude()));
  }
  public void onProviderDisabled(String provider) { }
  public void onProviderEnabled(String provider) { }
  public void onStatusChanged(String provider, int status, Bundle extras) { }
}

public void resume() {
  if (manager != null) {
    manager.requestLocationUpdates(provider, 1000, 1, listener);
  }
}

public void pause() {
  if (manager != null) {
    manager.removeUpdates(listener);
  }
}

class LocationValue {
  double latitude;
  double longitude;
  LocationValue(double lat, double lon) {
    latitude = lat;
    longitude = lon;
  }
}

class LocationQueue {
  LocationValue[] values = new LocationValue[10];
  int offset, count;

  synchronized void add(LocationValue val) {
    if (count == values.length) {
      values = (LocationValue[]) expand(values);
    }
    values[count++] = val;
  }

  synchronized LocationValue remove() {
    if (offset == count) {
      return null;
    }
    LocationValue outgoing = values[offset++];
    if (offset == count) {
      offset = 0;
      count = 0;
    }
    return outgoing;
  }

  synchronized boolean available() {
    return 0 < count;
  }
}

Listing 9-2.Storing Locations in a Queue

权限和监听器设置代码与清单 9-1 中的相同,但是现在我们有了两个新的类,LocationValueLocationQueue. LocationValue非常简单,它只是以双精度存储了一对纬度/经度值。让我们更仔细地看看LocationQueue。它有三个方法:add()remove()available(),所有这些方法都是同步的,因此不能从不同的线程中同时调用。当在onLocationChanged()中接收到一个新的位置时,我们创建一个新的LocationValue并将其添加到队列中。随着新的位置不断从事件线程进入,它们被存储在队列内部的values数组中,如果需要的话,这个数组的大小可以增加一倍。在动画线程中,我们从队列中移除位置,并将它们添加到path数组列表中,这样我们就可以在每一帧中打印到目前为止收到的所有纬度/经度值,从最后到第一的顺序相反。请注意,我们从队列中删除位置并不使用剩余多少,因为如果在我们仍在绘制新的帧时新的位置到达,这个数字可能会改变,而只是通过检查队列是否有可用的元素。

Note

在应用开始接收位置值之前,我们可能会经历一段延迟。这种延迟是由设备搜索来自 GPS 卫星、本地信号发射塔或 Wi-Fi 接入点的信号造成的。

现在,我们可以将路径绘制为连接连续位置的线条。清单 9-3 展示了新的draw()函数,草图的其余部分是相同的。因为我们将纬度和经度值映射到屏幕上的位置,所以我们必须确定最小值和最大值来定义映射。此外,我们将双精度值转换成单精度浮点数,可以在min()max()map()函数中用作参数。图 9-3 显示了一个人走动时该草图的典型输出。

A432415_1_En_9_Fig3_HTML.jpg

图 9-3。

Path-tracking sketch running

float minLat = 90;
float maxLat = -90;
float minLon = 180;
float maxLon = -180;
...
void draw() {
  background(255);
  while (queue.available()) {
    LocationValue loc = queue.remove();
    minLat = min(minLat, (float)loc.latitude);
    maxLat = max(maxLat, (float)loc.latitude);
    minLon = min(minLon, (float)loc.longitude);
    maxLon = max(maxLon, (float)loc.longitude);
    path.add(0, loc);
  }
  stroke(70, 200);
  strokeWeight(displayDensity * 4);
  beginShape(LINE_STRIP);
  for (LocationValue loc: path) {
    float x = map((float)loc.longitude, minLon, maxLon,
                                        0.1 * width, 0.9 * width);
    float y = map((float)loc.latitude, minLat, maxLat,
                                       0.1 * height, 0.9 * height);
    vertex(x, y);
  }
  endShape();
}
Listing 9-3.Drawing the Locations Along a Line Strip

与科泰的位置

使用 Ketai 的优点是,我们在上一节中讨论的所有细节(权限、并发性)都被自动处理,因此我们可以专注于使用位置值。使用 Ketai 重写的前一个例子要短得多,正如我们在清单 9-4 中看到的。

import ketai.sensors.*;

KetaiLocation location;
ArrayList<LocationValue> path = new ArrayList<LocationValue>();

float minLat = 90;
float maxLat = -90;
float minLon = 180;
float maxLon = -180;

void setup () {
  fullScreen();
  location = new KetaiLocation(this);
}

void draw() {
  background(255);
  stroke(70, 200);
  strokeWeight(displayDensity * 4);
  beginShape(LINE_STRIP);
  for (LocationValue loc: path) {
    float x = map((float)loc.longitude, minLon, maxLon,
                                        0.1 * width, 0.9 * width);
    float y = map((float)loc.latitude, minLat, maxLat,
                                       0.1 * height, 0.9 * height);
    vertex(x, y);
  }
  endShape();
}

void onLocationEvent(double lat, double lon) {
  path.add(new LocationValue(lat, lon));
  minLat = Math.min(minLat, (float)lat);
  maxLat = Math.max(maxLat, (float)lat);
  minLon = Math.min(minLon, (float)lon);
  maxLon = Math.max(maxLon, (float)lon);
}

class LocationValue {
  double latitude;
  double longitude;
  LocationValue(double lat, double lon) {
    latitude = lat;
    longitude = lon;
  }
}

Listing 9-4.Getting Location Data with Ketai

onLocationEvent()由 Ketai 在与draw()函数相同的线程中触发,因此不存在遇到并发问题的风险。

使用附加位置数据

当我们的位置监听器在onLocationChanged()处理程序方法中接收到一个新位置时,我们不仅可以获得该位置的纬度和经度,还可以获得其他相关信息,比如海拔、精确度和方位( https://developer.android.com/reference/android/location/Location.html )。精度值很重要,因为它反映了当前供应器的位置精度。特别是,如果连续位置值之间的差异小于当前精度,则存储连续位置值没有意义。我们可以修改我们之前的位置队列示例(清单 9-3 )来合并这个检查。清单 9-5 中显示了这些变化。

void draw() {
  background(255);
  while (queue.available()) {
    LocationValue loc = queue.remove();
    minLat = min(minLat, (float)loc.latitude);
    maxLat = max(maxLat, (float)loc.latitude);
    minLon = min(minLon, (float)loc.longitude);
    maxLon = max(maxLon, (float)loc.longitude);
    if (0 < path.size()) {
      LocationValue last = path.get(path.size() - 1);
      if (last.distanceTo(loc) < loc.accuracy + last.accuracy) continue;
    }
    path.add(0, loc);
  }
  stroke(70, 200);
  strokeWeight(displayDensity * 4);
  beginShape(LINE_STRIP);
  for (LocationValue loc: path) {
    float x = map((float)loc.longitude, minLon, maxLon,
                                        0.1 * width, 0.9 * width);
    float y = map((float)loc.latitude, minLat, maxLat,
                                       0.1 * height, 0.9 * height);
    vertex(x, y);
  }
  endShape();
}
...
class SimpleListener implements LocationListener {
  public void onLocationChanged(Location loc) {
    queue.add(new LocationValue(loc.getLatitude(), loc.getLongitude(),
                                loc.getAccuracy()));
  }
  ...
}
...
class LocationValue {
  double latitude;
  double longitude;
  double accuracy;

  LocationValue(double lat, double lon, double acc) {
    latitude = lat;
    longitude = lon;
    accuracy = acc;
  }

  double distanceTo(LocationValue dest) {
    double a1 = radians((float)latitude);
    double a2 = radians((float)longitude);
    double b1 = radians((float)dest.latitude);
    double b2 = radians((float)dest.longitude);

    double t1 = Math.cos(a1) * Math.cos(a2) * Math.cos(b1) * Math.cos(b2);
    double t2 = Math.cos(a1) * Math.sin(a2) * Math.cos(b1) * Math.sin(b2);
    double t3 = Math.sin(a1) * Math.sin(b1);
    double tt = Math.acos(t1 + t2 + t3);

    return 6366000 * tt;
  }
}

Listing 9-5.Using Location Accuracy

我们从onLocationChanged()事件中的Location参数获得位置精度,并将其存储在LocationValue对象中,与它的纬度和经度放在一起。然后,我们使用最新位置和先前位置的精确度来确定最新位置是否足够不同以添加到路径中。这包括计算两个位置之间的距离,也就是地球表面连接它们的弧的长度。有几个公式可以近似这个距离(https://en.wikipedia.org/wiki/Great-circle_distance#Computational_formulas);在代码中,我们使用了所谓的余弦定律,该定律假设地球是一个完美的球形(并不完全准确,因为我们的星球沿其南北轴略微变平,但对于这个简单的应用来说已经足够了)。

街景学院

在这一点上,我们有几种技术可以用来创建一个更复杂的地理定位项目。正如开头提到的,我们每天都在使用位置感知应用,一天可能会有几十次甚至上百次。谷歌街景等工具如此受欢迎,以至于我们第一次看到一个新地方时,往往不是亲自去,而是在谷歌地图上查看。我们在城市中的体验受到这些应用的影响,因此值得尝试使用街景图像结合我们白天参观的地点来创建某种非功能性的视觉拼贴。事实上,这种想法并不新鲜,许多艺术家以前就一直在使用它,将日常的城市景观转化为既熟悉又陌生又令人迷失方向的构图。图 9-4 再现了艺术家 Masumi Hayashi ( http://masumimuseum.com/ )的全景照片拼贴画,他使用定制的系统捕捉了 360 条风景,后来合并成拼贴画。最近,安娜丽莎·卡西尼( http://www.annalisacasini.com/project-withtools-google-maps/ )一直在用街景图像创作超现实的城市景观拼贴画。

A432415_1_En_9_Fig4_HTML.jpg

图 9-4。

Top: OSERF Building Broad Street View, Columbus, Ohio, by Masumi Hayashi (2001). Reproduced with permission from the Estate of Masumi Hayashi.

由于智能手机的永远在线特性和实时位置更新的可能性,我们可以在走动时使用从互联网上下载的街景图像来构建一个动态拼贴。从视觉输出的角度来看,要解决的最重要的问题是如何将城市意象组合成引人入胜的作品。当然也有很多(无限!)我们可以这样做的方法,像 Hayashi 和 Casini 这样的艺术家的作品给了我们一些参考来启发我们自己。

我们将首先通过我们的处理草图解决检索街景图像的问题,因为这一步是实现我们概念的任何进一步工作的先决条件。一旦我们能够解决这个技术问题,我们将考虑自动创建拼贴的方法。

使用谷歌街景图像 API

谷歌街景是谷歌地图和谷歌地球的一个受欢迎的功能,它提供了世界上许多地方的全景,主要是城市和城镇的街道,但现在也包括建筑内部,珊瑚礁,甚至太空等网站!街景可以从使用不同 API 的 Android 应用中访问,其中一个 API 允许您在专门的视图组件中创建交互式全景。然而,这个 API 不适合在我们的处理草图中使用,因为全景视图不能与处理的绘图表面集成。

幸运的是,谷歌还提供了一个图像 API,通过这个 API,人们可以使用 HTTP 请求( https://developers.google.com/maps/documentation/streetview/intro )下载与经纬度坐标相对应的街景静态图像。要使用这个 API,我们首先要在 Google Maps APIs 下启用 Google Street View Image API,并在开发者控制台( https://console.developers.google.com/apis/dashboard )中创建一个 Google API 项目。然后,我们必须获得一个 API 键( https://developers.google.com/maps/documentation/android-api/signup )来添加到项目中。这些步骤非常重要,否则我们的应用将无法请求街景图像。我们可以通过在 web 浏览器中创建一个请求来测试一切是否如预期的那样工作。Google 街景图像 API 请求具有以下格式:

http://maps.googleapis.com/maps/api/streetview?size=WIDTHxHEIGHT&location=LAT,LONG&sensor=SENSOR_STATUS&heading=HEADING&fov=FOV&pitch=PITCH&key=GOOGLE_API_CONSOLE_KEY

请求中的大多数参数都是可选的,除了位置、大小,当然还有 API 键。上一段链接的谷歌街景图片 API 页面详细描述了所有这些 URL 参数。该请求应该返回一个图像,然后我们可以保存在我们的计算机上。

我们可以在处理草图中使用完全相同的请求语法来请求一个PImage。这是通过清单 9-6 中的简单代码实现的,在这里你必须提供你自己的 API 密匙,还需要给草图添加互联网权限。

PImage street;
String apiKey = "<your API key>";

void setup() {
  size(512, 512);
  street = requestImage("http://maps.googleapis.com/maps/api/streetview?" +
                        "location=42.383401,-71.116110&size=512x512&" +
                        "fov=90&pitch=-10&key=" + apiKey);
}

void draw() {
  if (0 < street.width && 0 < street.height) {
    image(street, 0, 0, width, height);
  }
}

Listing 9-6.Requesting a Street View Image

requestImage()函数返回一个PImage对象,该对象将在一个单独的线程中下载,以避免在传输图像数据时挂起我们的草图。当图像的宽度和高度大于零时,我们就知道图像准备好了。如果由于某种原因请求失败,宽度和高度都将被设置为-1。这也是为什么 draw()中有 if(0<street . width&&0<street . height)条件的原因。

通过将从 Google Street View Image API 请求图像的能力与我们之前从清单 9-5 中获得的路径跟踪草图相结合,我们将能够显示从定位服务接收到的最新位置的街道图像。让我们看看如何在清单 9-7 中做到这一点。

...
ArrayList<LocationValue> path = new ArrayList<LocationValue>();
ArrayList<PImage> street = new ArrayList<PImage>();
String apiKey = "<your API key>";
...
void draw() {
  background(255);
  while (queue.available()) {
    LocationValue loc = queue.remove();
    minLat = min(minLat, (float)loc.latitude);
    maxLat = max(maxLat, (float)loc.latitude);
    minLon = min(minLon, (float)loc.longitude);
    maxLon = max(maxLon, (float)loc.longitude);
    if (0 < path.size()) {
      LocationValue last = path.get(path.size() - 1);
      if (last.distanceTo(loc) < loc.accuracy + last.accuracy) continue;
    }
    path.add(0, loc);
    String url = "http://maps.googleapis.com/maps/api/streetview?location=" +
                 loc.latitude + "," + loc.longitude +
                 "&size=512x512&fov=90&pitch=-10&sensor=false&key=" + apiKey;
    street.add(requestImage(url));
  }
  if (0 < street.size()) {
    PImage img = street.get(street.size() - 1);
    if (0 < img.width && 0 < img.height) {
      image(img, 0, 0, width, height);
    }
  }
}
...
Listing 9-7.Showing Street View of Our Last Location

我们将每个新位置请求的图像添加到street数组列表中,然后在下载完成后选择列表中的最后一个,就像我们在清单 9-6 中所做的那样。

Note

如果您使用版本控制服务(如 GitHub)来存储代码项目,请注意不要将 API 密钥上传到任何人都可以访问的公共存储库中。如果您将敏感数据提交到一个公共 Git 存储库中,本文将解释如何完全删除它: https://help.github.com/articles/removing-sensitive-data-from-a-repository/

Voronoi 镶嵌

我们现在面临的挑战是通过代码创建一个视觉上有趣的拼贴画。思考这个问题的一种方法是考虑如何将屏幕分成不重叠的区域,每个区域对应一个位置及其相关图像。这种分割在技术上被称为镶嵌。使用矩形的镶嵌很容易实现,但是可能看起来太简单了。然而,有一个众所周知的分割称为 Voronoi 镶嵌。为了创建 Voronoi 镶嵌,我们从一组任意的 2D 点开始。接下来,如果我们在 2D 平面中的位置(x,y ),我们找到集合中最接近(x,y)的点 p。我们说(x,y)属于由 p 确定的区域。然后我们用相同的颜色画出与 p 相关的所有这样的位置(x,y)。按照这个简单的算法,我们将把飞机分割成看起来或多或少像图 9-5 左面的区域。

A432415_1_En_9_Fig5_HTML.jpg

图 9-5。

Left: Voronoi tessellation with 20 regions. Right: Voronoi portraits by Mark Kleback and Sheiva Rezvani

Voronoi 镶嵌在生物学、地理学、数学、气象学和机器人学等需要进行空间数据分析的领域都有应用。Voronoi 镶嵌能够将空间划分为多个区域,从而更容易操作数据。艺术家们还将其作为基本技术来创作一些作品,其中一些底层数据集(如图 9-5 右侧面板中的肖像)经过处理,以生成数据的“自然”分区,同时还具有视觉吸引力。

给定一个点集,有许多算法可以生成 Voronoi 镶嵌图,有些算法非常有效,但实现起来也很复杂。最简单的算法,但也是效率最低的算法,是将我们对 Voronoi 镶嵌的文字描述翻译成代码的算法。这是非常低效的,因为我们需要将屏幕中的每个像素(x,y)与集合中的所有点进行比较,因此它的执行时间随着屏幕中像素数量的平方而增长,而像素数量的平方又是宽度和高度的乘积。这意味着随着屏幕分辨率的增加,算法的速度会非常快。

但是,如果屏幕分辨率不太高,这个简单的算法仍然足够快,可以交互运行。在清单 9-8 中,每当我们按下鼠标(或触摸屏幕)时,我们都会向 Voronoi 集中添加一个新点。我们将“Voronoi 点”的最大数量设置为 10,分辨率设置为 512 × 512。这个草图可以在 Java 或 Android 模式下运行,无需任何修改。使该算法在处理中容易实现的关键元素是pixels数组,我们在第六章中讨论过。该数组包含屏幕中每个像素的颜色,以连续值的形式排列,因此,如果屏幕的分辨率为 W × H,则pixels数组中的前 W 个元素对应于屏幕中的第一行,接下来的 W 个元素对应于第二行,依此类推。我们也可以使用pixels数组来设置屏幕中任何像素的颜色。我们在清单 9-8 中这样做。

int lastPoint = 0;
int maxPoints = 10;
VoronoiPoint[] points = new VoronoiPoint[maxPoints];
boolean updated = false;

void setup () {
  size(512, 512);
}

void draw() {
  if (updated) {
    updateColors();
    drawPoints();
    updated = false;
  }
}

void mousePressed() {
  points[lastPoint] = new VoronoiPoint(mouseX, mouseY, color(random(255), random(255), random(255)));
  lastPoint = (lastPoint + 1) % maxPoints;
  updated = true;
}

void updateColors() {
  int idx = 0;
  loadPixels();
  for (int y = 0; y < height; y++) {
    for (int x = 0; x < width; x++) {
      int closest = findClosestPoint(x, y);
      if (-1 < closest) pixels[idx] = points[closest].getColor();
      idx++;
    }
  }
  updatePixels();
}

void drawPoints() {
  strokeWeight(10);
  stroke(0, 50);
  for (int i = 0; i < points.length; i++) {
    VoronoiPoint p = points[i];
    if (p == null) break;
    point(p.x, p.y);
  }
}

int findClosestPoint(float x, float y) {
  int minIdx = -1;
  float minDist = 1000;
  for (int i = 0; i < points.length; i++) {
    VoronoiPoint p = points[i];
    if (p == null) break;
    float d = dist(x, y, p.x, p.y);
    if (d < minDist) {
      minIdx = i;
      minDist = d;
    }
  }
  return minIdx;
}

class VoronoiPoint {
  float x, y;
  color c;
  VoronoiPoint(float x, float y, color c){
    this.x = x;
    this.y = y;
    this.c = c;
  }
  color getColor() {
    return c;
  }
}

Listing 9-8.Generating a Voronoi Tessellation

我们通过定位 Voronoi 列表中最接近的点,在updateColors()函数中设置每个屏幕像素的颜色。为此,我们需要首先调用loadPixels()来确保像素数组被初始化,然后更新 pixels(),以便像素被绘制到屏幕上。findClosestPoint()功能找到离屏幕位置最近的点(x,y);它的工作方式是设置一个大的初始距离(1000),然后遍历所有点,将到(x,y)的距离小于前一个的点的索引保存在变量minIdx中。

如果我们在电脑或 Android 设备上运行这个草图,我们将创建新的 Voronoi 区域,直到最多十个,每个区域被分配一个随机的颜色。一旦我们超过 10 个,草图就在点阵列上循环,新的鼠标/触摸位置将取代原来的位置。图 9-6 显示了典型的输出。

A432415_1_En_9_Fig6_HTML.jpg

图 9-6。

A typical Voronoi tessellation

在这个交互式镶嵌中,我们为每个点指定了一种纯色。但是,我们可以使用图像来代替,这样相应区域中的像素就可以用图像像素的颜色来绘制。这样,每个区域将显示不同图像的一部分。如果所有图像的大小都与屏幕分辨率相同,那么修改前面的清单以使用图像就很容易了。这就是我们在清单 9-9 中所做的。这里,我们使用街景以 512 × 512 的分辨率生成的一组十张图像,streetview0.jpgstreetview9.jpg,并存储在草图的data文件夹中。

...
void mousePressed() {
  points[lastPoint] = new VoronoiPoint(mouseX, mouseY, lastPoint);
  lastPoint = (lastPoint + 1) % maxPoints;
  updated = true;
}

void updateColors() {
  int idx = 0;
  loadPixels();
  for (int y = 0; y < height; y++) {
    for (int x = 0; x < width; x++) {
      int closest = findClosestPoint(x, y);
      if (-1 < closest) pixels[idx] = points[closest].getColor(idx);
      idx++;
    }
  }
  updatePixels();
}
...
class VoronoiPoint {
  float x, y;
  PImage img;
  VoronoiPoint(float x, float y, int i) {
    this.x = x;
    this.y = y;
    img = loadImage("streetview" + i + ".jpg");
    img.loadPixels();
  }
  color getColor(int idx) {
    return img.pixels[idx];
  }
}

Listing 9-9.Painting a Voronoi Tessellation with Images

这个清单只显示了需要修改的几部分代码。现在,我们使用点索引来加载存储在每个VoronoiPoint类中的图像对象。PImage类也有一个pixels数组,我们可以从中检索图像每个像素的颜色。使用来自谷歌街景的十张图片,我们的新例子应该生成一个类似于图 9-7 所示的拼贴画。

A432415_1_En_9_Fig7_HTML.jpg

图 9-7。

Painting the regions in a Voronoi tessellation using Street View images

使用屏幕外绘图图面

我们的 Voronoi 镶嵌代码的一个限制是,它只能在低分辨率下使用,这使得它不适合全屏应用。我们可以应用屏幕外绘图来解决这个问题。想法是将镶嵌画到一个较小的绘图表面上,然后在屏幕上以全分辨率呈现该表面的内容。处理允许我们用createGraphics(width, height)函数创建一个屏幕外绘图表面,该函数返回一个PGraphics对象( https://processing.org/reference/PGraphics.html )封装一个具有所请求分辨率的表面。我们可以用目前为止学过的所有 API 绘制成一个PGraphics对象。要记住的一件重要的事情是在beginDraw()endDraw()之间封闭所有的PGraphics绘图调用。清单 9-10 显示了使用屏幕外渲染将图像镶嵌草图制作成全屏应用所需的更改。

...
PGraphics canvas;

void setup () {
  fullScreen();
  canvas = createGraphics(512, 512);
}

void draw() {
  ...
  image(canvas, 0, 0, width, height);
}

void mousePressed() {
  float x = map(mouseX, 0, width, 0, canvas.width);
  float y = map(mouseY, 0, height, 0, canvas.height);
  points[lastPoint] = new VoronoiPoint(x, y, lastPoint);
  lastPoint = (lastPoint + 1) % maxPoints;
  updated = true;
}

void updateColors() {
  int idx = 0;
  canvas.beginDraw();
  canvas.loadPixels();
  for (int y = 0; y < canvas.height; y++) {
    for (int x = 0; x < canvas.width; x++) {
      int closest = findClosestPoint(x, y);
      if (-1 < closest) canvas.pixels[idx] = points[closest].getColor(idx);
      idx++;
    }
  }
  canvas.updatePixels();
  canvas.endDraw();
}

void drawPoints() {
  canvas.beginDraw();
  canvas.strokeWeight(10);
  canvas.stroke(0, 50);
  for (int i = 0; i < points.length; i++) {
    VoronoiPoint p = points[i];
    if (p == null) break;
    canvas.point(p.x, p.y);
  }
  canvas.endDraw();
}

Listing 9-10.Drawing into an Offscreen Pgraphics Object

此外,请记住,虽然主屏幕表面中的(x,y)坐标范围是从(0,0)到(宽度,高度),但PGraphics分辨率是(PGraphics.width, PGraphics.height),这意味着我们可能需要将坐标从一个表面映射到另一个表面。例如,(mouseX, mouseY)坐标总是指向屏幕,所以我们应该将它们映射到PGraphics对象的宽度和高度,这样交互就能在屏幕外正常工作。

把所有东西放在一起

到目前为止,我们已经成功地用一组预定义的街景图像生成了拼贴画。因此,是时候将这些代码与我们早期的草图结合起来了,这些草图从可用的定位服务中检索设备的位置。我们将使用纬度和经度值来组合 HTTPS 请求,以获得新的街景图像,就像我们在清单 9-7 中所做的那样,并将它分配给一个随机的 Voronoi 点。

然而,我们应该考虑几个重要的技术方面。首先,我们希望以全屏分辨率绘制拼贴画,为此我们可以使用一个分辨率较低的屏幕外PGraphics对象,例如 512 × 512,然后放大它以覆盖整个屏幕,就像我们在清单 9-10 中所做的那样,尽管保留了图像的原始平方比。

第二个方面涉及位置更新的频率。非常频繁地请求更新,尤其是在高精度模式(即 GPS)下,会更快地耗尽电池,但我们实际上不需要非常频繁的更新,因为应用应该等待足够长的时间,以确保周围环境有明显的差异。位置更新的粒度是在对requestLocationUpdates()的调用中设置的,这里我们使用 1 秒和 1 米作为更新的最小时间和距离。这些值对于我们的拼贴目的来说太小了,我们可以将它们增加很多—例如,分别增加到 30 秒和 20 米。考虑到这些因素,让我们看看清单 9-11 中的完整代码。

import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;

LocationManager manager;
SimpleListener listener;
String provider;
LocationQueue queue = new LocationQueue();
ArrayList<LocationValue> path = new ArrayList<LocationValue>();
int lastPoint = 0;
int maxPoints = 10;
VoronoiPoint[] points = new VoronoiPoint[maxPoints];
PGraphics canvas;
String apiKey = "<your API key>";

void setup () {
  fullScreen();
  orientation(PORTRAIT);
  canvas = createGraphics(512, 512);
  imageMode(CENTER);
  requestPermission("android.permission.ACCESS_FINE_LOCATION", "initLocation");
}

void draw() {
  background(0);
  updatePositions();
  float h = max(width, height);
  image(canvas, width/2, height/2, h, h);
}

void updatePositions() {
  while (queue.available()) {
    LocationValue loc = queue.remove();
    if (0 < path.size()) {
      LocationValue last = path.get(path.size() - 1);
      if (last.distanceTo(loc) < 20) continue;
    }
    String url = "http://maps.googleapis.com/maps/api/streetview?location=" +
                 loc.latitude + "," + loc.longitude +
                 "&size=512x512&fov=90&pitch=-10&sensor=false&key=" + apiKey;
    loc.setStreetView(requestImage(url));
    path.add(loc);
  }

  boolean newImage = false;
  for (int i = path.size() - 1; i >= 0; i--) {
    LocationValue loc = path.get(i);
    PImage img = loc.getStreetView();
    if (img.width == -1 || img.height == -1) {
      path.remove(i);
    } else if (img.width == 512 && img.height == 512) {
      float x = random(0, canvas.width);
      float y = random(0, canvas.height);
      points[lastPoint] = new VoronoiPoint(x, y, img);
      lastPoint = (lastPoint + 1) % maxPoints;
      newImage = true;
      path.remove(i);
    }
  }
  if (newImage) updateColors();
}

void updateColors() {
  int idx = 0;
  canvas.beginDraw();
  canvas.loadPixels();
  for (int y = 0; y < canvas.height; y++) {
    for (int x = 0; x < canvas.width; x++) {
      int closest = findClosestPoint(x, y);
      if (-1 < closest) canvas.pixels[idx] = points[closest].getColor(idx);
      idx++;
    }
  }
  canvas.updatePixels();
  canvas.endDraw();
}

void initLocation(boolean granted) {
  if (granted) {
    Context context = getContext();
    listener = new SimpleListener();
    manager = (LocationManager)
              context.getSystemService(Context.LOCATION_SERVICE);
    provider = LocationManager.NETWORK_PROVIDER;
    if (manager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
      provider = LocationManager.GPS_PROVIDER;
    }
    manager.requestLocationUpdates(provider, 30000, 20, listener);
  }
}

class SimpleListener implements LocationListener {
  public void onLocationChanged(Location loc) {
    queue.add(new LocationValue(loc.getLatitude(), loc.getLongitude()));
  }
  public void onProviderDisabled(String provider) { }
  public void onProviderEnabled(String provider) { }
  public void onStatusChanged(String provider, int status, Bundle extras) { }
}

public void resume() {
  if (manager != null) {
    manager.requestLocationUpdates(provider, 30000, 20, listener);
  }
}

public void pause() {
  if (manager != null) {
    manager.removeUpdates(listener);
  }
}

class LocationValue {
  double latitude;
  double longitude;
  PImage streetView;

  LocationValue(double lat, double lon) {
    latitude = lat;
    longitude = lon;
  }

  void setStreetView(PImage img) {
    streetView = img;
  }

  PImage getStreetView() {
    return streetView;
  }

  double distanceTo(LocationValue dest) {
    double a1 = radians((float)latitude);
    double a2 = radians((float)longitude);
    double b1 = radians((float)dest.latitude);
    double b2 = radians((float)dest.longitude);

    double t1 = Math.cos(a1) * Math.cos(a2) * Math.cos(b1) * Math.cos(b2);
    double t2 = Math.cos(a1) * Math.sin(a2) * Math.cos(b1) * Math.sin(b2);
    double t3 = Math.sin(a1) * Math.sin(b1);
    double tt = Math.acos(t1 + t2 + t3);

    return 6366000 * tt;
  }
}

class LocationQueue {
  LocationValue[] values = new LocationValue[10];
  int offset, count;

  synchronized void add(LocationValue val) {
    if (count == values.length) {
      values = (LocationValue[]) expand(values);
    }
    values[count++] = val;
  }

  synchronized LocationValue remove() {
    if (offset == count) {
      return null;
    }
    LocationValue outgoing = values[offset++];
    if (offset == count) {
      offset = 0;
      count = 0;
    }
    return outgoing;
  }

  synchronized boolean available() {
    return 0 < count;
  }
}

class VoronoiPoint {
  float x, y;
  PImage img;

  VoronoiPoint(float x, float y, PImage img){
    this.x = x;
    this.y = y;
    this.img = img;
    img.loadPixels();
  }

  color getColor(int idx) {
    return img.pixels[idx];
  }
}

int findClosestPoint(float x, float y) {
  int minIdx = -1;
  float minDist = 1000;
  for (int i = 0; i < points.length; i++) {
    VoronoiPoint p = points[i];
    if (p == null) break;
    float d = dist(x, y, p.x, p.y);
    if (d < minDist) {
      minIdx = i;
      minDist = d;
    }
  }
  return minIdx;
}

Listing 9-11.
Street View Collage

让我们来讨论这个草图中的新代码,特别是updatePositions()函数。第一部分与我们之前所做的非常相似:从队列中删除新位置,如果它们太靠近前一个位置(这里的阈值是 20 米),则跳过它们,否则将它们添加到路径中。我们还生成相应的街景请求,并将PImage对象存储在新的LocationValue对象中。然后,它向后遍历到目前为止接收到的所有位置,并删除那些没有接收到街景图像(通过检查所请求图像的宽度或高度是否为-1)或完成了图像下载的位置。后面的位置被删除,因为它们的图像被分配给新的 Voronoi 点,所以不再需要它们。通过这种方式,我们确保应用的内存使用量不会持续增加,直到它耗尽内存并被迫退出;我们最多只存储十幅同步图像(分辨率为 512 × 512),因为这是我们的 Voronoi 点阵列的长度。

一个较小的细节,但仍然是重要的,以确保拼贴看起来很好,是确保它覆盖整个屏幕,同时保持其原始的纵横比。如果我们看看draw()函数中的代码,我们会看到如何做:我们用线float h = max(width, height)取最大的屏幕尺寸,然后用image()函数以这个尺寸h画出屏幕外的PGraphics画布,把它放在屏幕的中心。由于我们在setup()中将图像模式设置为CENTER,画布将正确地显示在屏幕中央。

我们可以将该草图作为常规应用或动态壁纸运行(在第二种情况下,我们可能希望使用wallpaperPreview()功能,以便仅在选择壁纸后请求位置许可)。输出的一些例子如图 9-8 所示。如果我们计划通过 Play Store 发布它,我们还必须创建一整套图标,在它的清单文件中写下完整的包和应用的名称以及版本,并导出一个发布包,就像我们在第 3 和 6 章中为最终项目所做的那样。

A432415_1_En_9_Fig8_HTML.jpg

图 9-8。

Different Street View collages generated with the final sketch, running as a live wallpaper

摘要

在这一章中,我们详细探讨了 Android location API 提供的可能性,以及我们如何在 Android 处理中使用它来结合其他技术(如 Google Street View)创建非常规的地理定位应用。正如我们刚刚看到的,处理给了我们很大的自由度来访问不同的数据源(GPS、图像等)。),而且还提供了一个坚实的框架来成功整合这些资源,以便我们可以将我们的想法从概念草图转化为最终的应用。

十、可穿戴设备

在本章中,我们将使用处理来为 Android 智能手表创建手表面部。我们将讨论在编写智能手表应用时应该考虑的可穿戴设备的具体功能和限制。

从活动追踪器到智能手表

尽管我们在考虑移动开发时可能会首先想到手机和平板电脑,但自 2009 年推出健身追踪器(如 Fitbit)以来,可穿戴设备已经出现在许多人的生活中,最近这种设备的列表已经扩展到包括苹果和几家安卓制造商的数字智能手表。随着传感器技术的快速发展和电子元件尺寸的减小,这些设备能够执行广泛的功能,而不仅仅是计数步数和心跳。事实上,2017 款智能手表拥有许多与智能手机相同的功能(2D 和 3D 图形、触摸屏、位置和移动传感器、Wi-Fi 连接)。

Android 平台通过 Android 操作系统的 Wear 版本为所有这些设备提供支持。运行 Android Wear 1.x 的设备需要与运行 Android 4.3 或更高版本的 Android 手机配对,才能启用可穿戴设备中的所有功能(例如,显示来自手机的电子邮件和消息通知),而运行 Wear 2.x 的手表可以运行独立的应用,不需要将手表与手机配对。Android 平台上的穿戴应用( https://developer.android.com/training/wearables/apps/index.html )可以访问手表的传感器和图形。手表脸是一种特殊的佩戴应用,作为手表的背景运行,与手机和平板电脑上的动态壁纸没有什么不同。它们旨在显示时间和其他相关信息,如身体活动。

Android 的处理目前允许我们在 Android 智能手表上运行草图作为手表表面,但不能作为一般的穿戴应用。前几章讨论的所有绘图、交互和传感 API 都适用于手表表面,只需增加一些功能来处理智能手表的独特功能。

Note

Android mode 4.0 版本可用于为运行 Android Wear 2.0 或更高版本的智能手表创建手表面部。它不支持 Wear 1.x 设备。

智能手表

一些制造商提供 Android 智能手表,因此有各种不同规格和风格的型号。图 10-1 展示了 Android 手表的一小部分选择。

A432415_1_En_10_Fig1_HTML.jpg

图 10-1。

A selection of Android smartwatches, from left to right: Sony Smartwatch 3, Moto 360, LG Watch Urbane, Polar M600

尽管手表种类繁多,但所有的手表都必须符合技术规格的最低标准。为 Android Wear 1 . x 版本发布的手表具有(圆形或方形)显示器,密度在 200 到 300 dpi 之间(因此,属于 hdpi 范围),Wi-Fi 和蓝牙连接,加速度计,陀螺仪,通常还有心率传感器,4 GB 的内部存储,以及长达两天的混合使用电池寿命(完全活动模式与节省电池的“环境”模式)。正如本章介绍中提到的,鉴于 Wear 2.x 设备的自主性增加,Wear 2.0 将鼓励手表配备更多传感器、更高的显示密度和更长的电池寿命。

Note

Wear 1.x 和 2.x 之间的一个重要区别是,对于前者,手表总是需要与智能手机配对才能发挥全部功能(即显示信息,提供位置),但对于后者,它们可以完全自主工作,并运行与为手机设计的应用一样强大的应用。

运行手表表面草图

正如我们可以在实际设备或模拟器上运行常规应用的处理草图一样,我们也可以在手表或模拟器上运行我们的手表表面草图。在物理设备上调试的过程通常更方便,因为仿真器通常更慢,并且无法模拟我们可能需要的所有传感器数据,以便调试我们的手表表面,但仿真器允许我们测试各种显示配置,并且如果我们还没有 Android 手表,可以运行我们的手表表面。

使用手表

要在 Android 手表上运行处理草图,我们首先需要在手表上启用“开发者选项”,如下所示:

  1. 打开手表上的设置菜单。
  2. 滚动到菜单底部,选择“系统|关于”
  3. 轻按内部版本号七次。
  4. 从“设置”菜单中,选择“开发人员选项”
  5. 确认“ADB 调试”已启用。

一旦我们启用了开发者选项,我们必须在两个选项之间进行选择,以便在 Wear 2.x 手表上运行和调试我们的手表表面草图:Wi-Fi 和蓝牙。谷歌关于调试 Wear 应用的开发者指南详细介绍了所有细节( https://developer.android.com/training/wearables/apps/debugging.html ),我们现在来回顾一下最重要的步骤。

使用蓝牙时,手表必须与手机配对。首先,我们需要在两台设备上启用蓝牙调试。在手表上,我们通过打开“设置|开发者选项”并启用“蓝牙调试”来实现这一点在手机上,我们打开 Android Wear 伴侣应用,点击它的设置图标,然后启用“通过蓝牙调试”一旦我们完成了所有这些,处理器就应该能够通过与手机的蓝牙配对连接到手表。

Note

如果手表通过蓝牙与手机配对,并且该手机是唯一通过 USB 连接到电脑的设备,则处理将能够自动连接到手表。但如果有多部手机,就需要用 adb 命令```java `./adb -s ID forward tcp:4444 localabstract:/adb-hub手动连接手表,提供手表配对的手机 ID,然后使用命令 adb connect 127.0.0.1:4444``。

就 Wi-Fi 而言,我们运行处理的电脑和手表必须连接到同一个网络。然后,我们需要在手表上启用 Wi-Fi 调试,方法是进入“设置|开发者选项”并启用“通过 Wi-Fi 调试”稍后,手表将显示其 IP 地址(如 192.168.1.100)。一旦我们获得了手表的 IP 地址,我们将打开一个终端。从那里,我们将切换到 Android SDK 内的platform-tools文件夹,并运行命令``` `adb connect 192.168.1.100``,如图 10-2 所示。

A432415_1_En_10_Fig2_HTML.jpg

图 10-2。

Connecting to a watch over Wi-Fi from the command line

一旦我们通过 Wi-Fi 或蓝牙连接了手表,我们应该会在 Android 菜单下的设备列表中看到它。此外,我们必须确保选择“手表表面”选项,因为加工不允许我们在手表上运行其他草图类型(图 10-3 )。

A432415_1_En_10_Fig3_HTML.jpg

图 10-3。

Enabling running sketches as watch faces and listing a connected watch

通过蓝牙或 Wi-Fi 连接到我们的手表后,我们可以按照清单 10-1 运行一个动画手表表面。

void setup() {
  fullScreen();
  strokeCap(ROUND);
  stroke(255);
  noFill();
}

void draw() {
  background(0);
  if (wearAmbient()) strokeWeight(1);
  else strokeWeight(10);
  float angle = map(millis() % 60000, 0, 60000, 0, TWO_PI);
  arc(width/2, height/2, width/2, width/2, 0, angle);
}

Listing 10-1.Simple Animated Watch Face

在 Processing 将草图作为观察面安装到设备上之后,我们必须选择它作为活动观察面。为此,向左滑动屏幕以访问最喜爱的手表面孔列表。如果我们的没有出现在这个列表中,点击列表最右端的“添加更多的手表面孔”,你应该会在那里找到草图,可能在其他可用的手表面孔中。首先在那里选择它,一旦它被添加到收藏列表,你可以点击它设置为当前背景。输出将如图 10-4 所示。

A432415_1_En_10_Fig4_HTML.jpg

图 10-4。

Output of the animated watch face example

请注意,表盘不会一直看起来像这个图中的样子。几秒钟后,手表将进入环境模式,显示每分钟更新一次。此模式的目的是在我们不看手表时节省电池电量。一旦手表检测到(使用其加速度计)转动手腕看时间的典型手势,它将返回到交互模式。谷歌的开发者指南建议在环境模式下将大部分屏幕设置为黑色背景,并用细白线绘制剩余的图形元素。正如我们在代码中看到的,处理过程给了我们wearAmbient()函数来检测手表是否处于环境模式,并相应地更新图形。

使用模拟器

我们在第一章中看到,为了在模拟器中运行手机 Android 虚拟设备(AVD ),我们应该安装一个系统映像。我们还看到,我们必须决定是否要使用 ARM 或 x86 映像。要使用带有 watch faces 的模拟器,我们需要安装一个单独的 watch AVD 供模拟器使用。我们第一次在仿真器中运行手表表面草图时,会看到一个对话框要求我们下载手表系统映像(图 10-5 ),然后是 ARM/x86 选择。一旦镜像(以及 x86 镜像的 HAXM 软件,正如我们在第一章中所讨论的)被下载并安装,处理会将草图复制到仿真器中,并在草图作为表盘成功安装后通知我们。

A432415_1_En_10_Fig5_HTML.jpg

图 10-5。

Downloading the watch system image

就像实际设备一样,我们需要选择我们的手表表面,以便将其设置为当前背景,这可以通过我们之前看到的相同系列步骤来完成,如图 10-6 所示:将手表表面添加到收藏夹列表,然后从该列表中选择它。

A432415_1_En_10_Fig6_HTML.jpg

图 10-6。

Selecting a watch face in the emulator

默认处理创建一个分辨率为 280 × 280 的方形手表 AVD。然而,在第三章中,我们了解到我们可以用 avdmanager 命令行工具创建其他 avd。只要我们在正确的端口上使用仿真器工具启动这些 avd,处理就会在这些 avd 上运行我们的草图。例如,让我们用"wear_round_360_300dpi"设备定义创建一个圆形手表 AVD,并在端口 5576 上启动它,这样我们就可以在处理中使用它。这样做的命令如图 10-7 所示(创建 AVD 后,记得将skin参数添加到其config.ini文件中,正如我们在第三章中看到的)。图 10-8 显示了在 round watch AVD 中运行我们的草图所得到的仿真器。

A432415_1_En_10_Fig8_HTML.jpg

图 10-8。

Running our watch face sketch in the custom AVD

A432415_1_En_10_Fig7_HTML.jpg

图 10-7。

Creating and launching a custom watch AVD

显示时间

显示时间是手表的基本功能之一,通过处理,我们能够创建任何我们可以想象的时间的可视化表示。Processing 提供了许多函数来获取当前的时间和日期— year()month()day()hour()minute()second()—这将允许我们生成自己的时间可视化。作为一个基本的例子,在清单 10-2 中,我们用文本显示时间。

void setup() {
  fullScreen();
  frameRate(1);
  textFont(createFont("Serif-Bold", 48));
  textAlign(CENTER, CENTER);
  fill(255);
}

void draw() {
  background(0);
  if (wearInteractive()) {
    String str = hour() + ":" + nfs(minute(), 2) + ":" + nfs(second(), 2);
    text(str, width/2, height/2);
  }
}

Listing 10-2.Displaying the Time as Text

注意我们使用了frameRate(1)。因为我们显示的时间精确到秒,所以不需要以更高的帧速率运行草图,这也有助于节省电池。nfs()函数方便地在数字的右边加零,所以得到的字符串总是有两位数。最后,wearInteractive()简单地返回与wearAmbient()函数相反的函数,我们在第一个 watch face 中使用了这个函数。

计算步数

我们可以通过 Android API 或 Ketai 库,使用与前几章相同的技术访问手表中的传感器。我们将在第十二章研究身体感应的可能性,但在这里的清单 10-3 中,我们展示了一个使用 Android sensor API 的简单计步器示例。

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;

Context context;
SensorManager manager;
Sensor sensor;
SensorListener listener;

int offset = -1;
int steps;

void setup() {
  fullScreen();
  frameRate(1);
  Context context = (Context) surface.getComponent();
  manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
  sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
  listener = new SensorListener();
  manager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL);
  textFont(createFont("SansSerif", 40 * displayDensity));
  textAlign(CENTER, CENTER);
  fill(255);
}

void draw() {
  background(0);
  if (wearInteractive()) {
    String str = steps + " steps";
    float w = textWidth(str);
    text(str, width/2, height/2);
  }
}

void resume() {
  if (manager != null)
    manager.registerListener(listener, sensor,
                             SensorManager.SENSOR_DELAY_NORMAL);
}

void pause() {
  if (manager != null) manager.unregisterListener(listener);
}

class SensorListener implements SensorEventListener {
  public void onSensorChanged(SensorEvent event) {
    if (offset == -1) offset = (int)event.values[0];
    steps = (int)event.values[0] - offset;
  }
  public void onAccuracyChanged(Sensor sensor, int accuracy) { }
}

Listing 10-3.Simple Step Counter

由于计步传感器(它不是实际的硬件传感器,而是一个“衍生”传感器,使用来自加速度计的信息来计算步数)返回的值是从手表启动时开始累积的,因此我们存储第一个值,从特定表盘打开的那一刻开始计数。

智能手表设计

使用 Processing 的绘图 API 来创建手表表面,结合上下文和传感器数据,为时间的表示开辟了无数方向。智能手表显示屏的有限尺寸在视觉设计和信息密度方面带来了挑战。我们将在接下来的两章中更深入地探讨这些挑战。

谷歌官方开发者网站包括一个专门关于表盘设计的部分( https://developer.android.com/design/wear/watchfaces.html ),它提供了一些关于设计概念和语言的有用指导,以及如何处理智能手表特有的方面。

屏幕形状和插图

要考虑的第一个重要方面是使表盘的图形适应圆形和方形显示屏,以便我们的视觉设计在两种情况下都能有效工作。

Note

即使显示器是圆形的,宽度和高度值也是指显示器沿水平和垂直方向的最大范围。

您可以通过调用wearRound()wearSquare()函数来确定屏幕的形状,对于圆形/方形屏幕,这些函数将返回 true,否则返回 false,如清单 10-4 所示。

void setup() {
  fullScreen();
  if (wearSquare()) rectMode(CENTER);
}

void draw() {
  background(0);
  if (wearAmbient()) {
    stroke(255);
    noFill();
  } else {
    noStroke();
    fill(255);
  }
  float scale = map(second(), 0, 59, 0, 1);
  if (wearRound()) {
    ellipse(width/2, height/2, scale * width, scale * width);
  } else {
    rect(width/2, height/2, scale * width, scale * height);
  }
}

Listing 10-4.Adjusting Graphics to the Screen Shape

然而,如果我们在屏幕底部有插图(或“下巴”)的设备(如 Moto 360)上运行清单 10-4 中的草图,我们会注意到圆圈相对于手表的边框是偏心的,即使它相对于显示屏本身是居中的(图 10-9 )。

A432415_1_En_10_Fig9_HTML.jpg

图 10-9。

“Chin” in a Moto 360 smartwatch. Graphics centered at the screen center (left), and translated by half of the bottom inset to properly center them with respect to the bezels

在这些情况下,我们可以使用 wearInsets()函数正确地将草图居中。该函数返回一个对象,该对象包含显示器周围的嵌入边框:topbottomleftright。对于下巴较低的设备,我们只需要添加调用translate(0, wearInsets().bottom/2)来使图形相对于边框中心居中,尽管代价是修剪图形的下部(清单 10-5 )。

void setup() {
  fullScreen();
  if (wearSquare()) rectMode(CENTER);
}

void draw() {
  background(0);
  if (wearAmbient()) {
    stroke(255);
    noFill();
  } else {
    noStroke();
    fill(255);
  }
  translate(0, wearInsets().bottom/2);
  float scale = map(second(), 0, 59, 0, 1);
  if (wearRound()) {
    ellipse(width/2, height/2, scale * width, scale * width);
  } else {
    rect(width/2, height/2, scale * width, scale * height);
  }
}

Listing 10-5.Watch Face Insets

观看面孔预览图标

除了我们在第三章讨论的常规应用图标,Android 还需要一组预览图标来显示选择列表中的手表表面。常规图标将用于 UI 的其他部分,如应用信息和卸载菜单。

由于手表可以是圆形或方形,我们只需要提供两个预览图标:一个用于圆形表壳(分辨率为 320 × 320),另一个用于方形表壳(分辨率为 280 × 280)。这两个图标都需要复制到草图的文件夹中,并且它们必须有名称preview_circular.pngpreview_rectangular.png

图 10-10 显示了预览图标,注意圆形预览中的红色部分将不可见。将草图导出为已签名的包时,在所有八个图标(六个常规的 ldpi、mdpi、hdpi、xhdpi、xxhdpi 和 xxxhdpi 分辨率图标和两个预览图标)都包含在草图文件夹中之前,处理不会让我们完成导出。

A432415_1_En_10_Fig10_HTML.jpg

图 10-10。

Preview images for round (left) and square (right) devices. The red portion in the round preview will be ignored.

摘要

在关于 Android 智能手表的第一章中,我们学习了可穿戴设备和应用的基础知识,以及如何为这些新颖的设备创建手表外观。我们还详细研究了将处理连接到实际手表或仿真器所需的设置,以测试我们在不同显示器配置下的表盘草图。

十一、可视化时间

显示时间是手表的主要功能之一,在这一章中,我们将学习如何应用 Processing 的绘图 API 来实验不同的时间可视化表示。

从日晷到智能手表

时间的视觉表现可以追溯到文明的开端,那时日晷被用来记录白天的时间。自 16 世纪以来,机械表和后来更小的手表一直在使用。在过去几十年的数字时代,具有多种功能的电子表开始流行。用于测量和显示时间的机器的悠久历史(图 11-1 )为我们提供了丰富的技术和文化背景,让我们可以从智能手表提供的几乎无限的可能性中汲取灵感或重新诠释。

A432415_1_En_11_Fig1_HTML.jpg

图 11-1。

From left to right: Sundial at the Imperial Palace in Beijing (eighth century BC); design for a ring watch, from Livre d'Aneaux d'Orfevrerie (1561); a late Victorian silver open-faced pocket watch (c.1890), Casio DBC 600 digital calculator watch (1985)

虽然我们已经可以在谷歌 Play 商店上找到数千种不同的表盘,但其中许多都将模拟概念转化为非常逼真的数字表现。这是一种有效的方法,但是数字画布也允许我们以完全原创的方式来表现时间。图 11-2 展示了一些 Android 手表表盘的例子,这些都可以在谷歌 Play 商店上找到,展示了一些有趣的想法,从凝视用户的眼睛、抽象图案和代表时间流逝的古怪动画,到通过使用数字变焦对模拟手表表盘的重新诠释。

A432415_1_En_11_Fig2_HTML.jpg

图 11-2。

From left to right: Gaze Effect (by Fathom Information Design); Waves (by ustwo); Space and Time (by Geng Gao); and Spotlight (by Maize) Note

为了截图一个表脸,我们可以使用 Android SDK 中的 adb 工具。首先,将屏幕截图保存到手表上的一个图像文件:adb -s 127.0.0.1:4444 shell screencap -p /sdcard/screenshot.png。然后,将生成的图像下载到电脑:adb -s 127.0.0.1:4444 pull -p /sdcard/screenshot.png。我们也可以使用第三方图形工具来使这个过程变得更加容易,就像 Mac 的 Android 工具: https://github.com/mortenjust/androidtool-mac

用时间来控制运动

处理包括一个时间 API,它允许我们获取当前的时间和日期。让我们从使用小时、分钟和秒来控制一个简单的动画开始。如果我们要处理模拟概念的数字实现,例如旋转指针,时间和角度值之间的转换相当简单:我们可以在它们各自的范围之间映射值;例如,分和秒在 0 到 60 之间,角度在 0 到TWO_PI (2π)之间。清单 11-1 展示了这种映射。

void setup() {
  noStroke();
  strokeWeight(2);
}

void draw() {
  background(0);

  float hAngle = map(hour() % 12, 0, 12, 0, TWO_PI);
  float mAngle = map(minute(), 0, 60, 0, TWO_PI);
  float sAngle = map(second(), 0, 60, 0, TWO_PI);

  translate(width/2, height/2 + wearInsets().bottom/2);
  fill(ambientMode ? 0 : #F0DB3F);
  ellipse(0, 0, width, width);
  drawLine(hAngle, width/2);

  fill(ambientMode ? 0 : #FFB25F);
  ellipse(0, 0, 0.75 * width, 0.75 * width);
  drawLine(mAngle, 0.75 * width/2);

  fill(ambientMode ? 0 : #ED774D);
  ellipse(0, 0, 0.5 * width, 0.5 * width);
  drawLine(sAngle, 0.5 * width/2);

  fill(0);
  ellipse(0, 0, 0.25 * width, 0.25 * width);    
}

void drawLine(float a, float r) {
  pushStyle();
  stroke(wearAmbient() ? 255 : 0);
  pushMatrix();
  rotate(a);
  line(0, 0, 0, -r);
  popMatrix();
  popStyle();
}

Listing 11-1.Concentric Circles for Seconds, Minutes, and Hours

该草图的输出(图 11-3 )是三个同心圆,其中的线对应时针、分针和秒针。由于线条是从屏幕中心向上画出的,这是模拟手表中的典型参考位置,因此可以在 0 和TWO_PI之间直接旋转。

A432415_1_En_11_Fig3_HTML.jpg

图 11-3。

Concentric circles watch face

一旦我们在设备或模拟器上运行这个手表表面,我们可以注意到指针的动画不流畅。最里面的圆从一秒跳到下一秒,因为我们没有在两个连续的秒之间插入中间角度。这个问题的一个解决方案是使用millis()函数,该函数返回草图开始运行以来经过的毫秒数。我们可以计算连续的millis()调用之间的差异,以计算两个时间点之间的毫秒差异,然后使用该值创建更平滑的动画。

更具体地说,如果我们在之前的草图中添加两个新变量,比如说s0m0,我们就可以跟踪秒的值发生变化的时刻(要么递增 1,要么重置为零),存储该特定时刻的毫秒数,然后用它来计算每个连续时刻我们所处的秒的分数。事实上,做起来比说起来容易,清单 11-2 显示了我们之前的草图中使这个工作的增加。

void draw() {
  background(0);

  int h = hour() % 12;
  int m = minute();
  int s = second();

  if (s0 != s) {
    m0 = millis();
    s0 = s;
  }
  float f = (millis() - m0)/1000.0;

  float sf = s + f;
  float mf = m + sf/60.0;
  float hf = h + mf/60.0;

  float hAngle = map(hf, 0, 12, 0, TWO_PI);
  float mAngle = map(mf, 0, 60, 0, TWO_PI);
  float sAngle = map(sf, 0, 60, 0, TWO_PI);
  ...
}

Listing 11-2.
Concentric Circles

with Second Animation

sfmfhf变量是十进制的秒、分和小时值,我们可以像以前一样将它们映射到角度范围,这将导致连续旋转。

方形和圆形表盘

Android 智能手表可以有方形或圆形框架,我们需要确保我们的表盘设计可以同时适用于这两种框架,或者如果我们优先考虑其中一个,我们仍然应该提供另一个的可用体验。圆形手表比方形手表更受欢迎,或者相反,是手表 UI 设计中的一个有争议的话题:“以我的经验来看,圆形面手表比方形面手表卖得好。我不知道这到底是为什么。。。。很可能纯粹是心理作用。人们对钟表应该是什么样子的语义观念”( https://www.wareable.com/smartwatches/round-v-square-smartwatches-which-is-best )。在相反的一端:“我认为这一轮的日子屈指可数。我们低估了人们适应新事物、新模式的能力。我认为使用更方形手表的体验会让人们在使用两者时更有意义,他们会改变主意”( https://birchtree.me/blog/data-is-square/ )。

无论哪种格式最终更受欢迎,Android 都支持这两种格式,这取决于我们提出考虑方形和圆形手表的设计。作为这个问题的一个例子,让我们继续一个简单的方形表盘设计:一个有 24 个正方形的矩形网格,分成 6 行 4 列。每个方块对应一个小时,已经过去的小时完全变灰,当前小时变灰到当前分钟给出的百分比。清单 11-3 展示了这种设计的实现。

void setup() {
  textFont(createFont("Monospaced", 15 * displayDensity));
  textAlign(CENTER, CENTER);
  noStroke();
}

void draw() {
  background(0);
  int h = hour();
  int m = minute();
  float cellW = 0.9 * width/4.0;
  float cellH = 0.9 * height/6.0;
  translate(0.05 * cellW, 0.05 * cellH + wearInsets().bottom/2);
  for (int n = 0; n < 24; n++) {
    int i = n % 4;
    int j = n / 4;
    float x = map(i, 0, 4, 0, width);    
    float y = map(j, 0, 6, 0, height);
    float w = n == h ? map(m, 0, 60, 0, cellW) : cellW;

    if (!wearAmbient()) {
      fill(#578CB7);
      rect(x, y, cellW, cellH);    
    }

    fill(255);
    text(str(n), x, y, cellW, cellH);    

    if (n <= h) {        
      fill(0, 170);
      rect(x, y, w, cellH);
    }
  }
}

Listing 11-3.Rectangular Hour Grid

因为在这个界面中,我们正在处理文本,所以我们创建了一个等宽字体,其大小由系统变量displayDensity缩放,正如我们在前面章节中所做的那样,以确保文本在具有不同 dpi 的设备上的外观一致。Android 手表的屏幕分辨率通常在 300 到 400 像素之间,但由于屏幕较小,DPI 通常在 xhdpi 范围内(∼320dpi)。

这种设计显然不适用于圆脸手表。我们可以将这个矩形网格替换为极坐标网格,但是在这种情况下,每个单元格的大小都不同,如图 11-4 的左图所示,并且实现对应于当前小时的部分灰色单元格也更加困难。另一种选择是仍然使用矩形网格,这次是 6 × 6,并移除完全或大部分在外接圆之外的六个角单元(图 11-4 中的右图)。

A432415_1_En_11_Fig4_HTML.jpg

图 11-4。

Adapting a 6 × 4 rectangular grid to fit inside a round watch face, either as a polar grid (left) or as a larger 6 × 6 grid with some elements removed (right)

这里的选项将取决于所需的视觉效果,在某些情况下,极坐标网格可能是更好的选择,而修剪矩形网格可能是其他情况。因为我们的首要任务是保持所有元素的大小相同,所以我们选择了后一个选项,如清单 11-4 所示。

import java.util.Arrays;
import java.util.List;
List<Integer> corners = Arrays.asList(1, 2, 5, 6, 7, 12, 25,
                                      30, 31, 32, 35, 36);

void setup() {
  textFont(createFont("Monospaced", 15 * displayDensity));
  textAlign(CENTER, CENTER);
  noStroke();
}

void draw() {
  background(0);
  int h = hour();
  int m = minute();
  float cellW = 0.9 * width/6.0;
  float cellH = 0.9 * height/6.0;
  translate(0.05 * cellW, 0.05 * cellH + wearInsets().bottom/2);
  int n = 0;  
  for (int n0 = 0; n0 < 36; n0++) {
    if (corners.contains(n0 + 1)) continue;

    int i = n0 % 6;
    int j = n0 / 6;
    float x = map(i, 0, 6, 0, width);
    float y = map(j, 0, 6, 0, height);
    float cw = n == h ? map(m, 0, 60, 0, cellW) : cellW;

    if (!wearAmbient()) {
      fill(#578CB7);
      rect(x, y, cellW, cellH);    
    }

    fill(255);
    text(str(n), x, y, cellW, cellH);

    if (n <= h) {
      fill(0, 170);
      rect(x, y, cw, cellH);
    }
    n++;
  }
}

Listing 11-4.Rectangular Grid for a Circular Watch

从清单 11-3 和 11-4 中可以看到这两个表盘草图的输出,如图 11-5 所示。两个草图的代码可以合并成一个单独的表盘草图,根据wearRound()wearSquare()函数返回的值选择合适的可视化。

A432415_1_En_11_Fig5_HTML.jpg

图 11-5。

Hour grid for square (left) and round (right) watches

运用表盘概念

打造表盘需要平衡几个因素。正如本章开头提到的,有一种既定的视觉语言,是人们在交流时间时所期望的。智能手表给了我们充分的空间来扩展这种现有的语言,并提出全新的概念。此外,手表面孔可以是交互式的、可配置的,并增加了附加信息(身体活动、日历事件等)。谷歌( https://www.google.com/design/spec-wear/patterns/interactive-watch-faces.html )的设计指南中涵盖了其中一些考虑因素,但实验可以让我们产生显示时间的新想法。

正如我们在书中的早期项目中看到的,视觉设计的一个基本方法是草图和迭代,这对于设计表盘来说仍然是正确的。一个概念可能是新颖的和有吸引力的,但是它不太可能在第一次实现时就成功。在接下来的部分中,我们将通过多次迭代来实现一个概念,直到达到最终版本。

已用/剩余时间

这款表盘的概念与其说是作为一款功能性时计,不如说是提醒人们从一天开始到结束所经过的时间。为了强调这种进展,我们可以用从午夜开始的总秒数来测量时间,显示它从零开始和向零的连续变化。

作为这一过程的视觉表现,上弦的新月可以作为白天缩短的隐喻(图 11-6 )。当然,这不是唯一可能的视觉表现(有人可能会说这会误导用户认为表盘显示的是月亮的实际相位),但这足以作为我们的第一个设计。

A432415_1_En_11_Fig6_HTML.jpg

图 11-6。

Crescent moon

贝塞尔曲线通过用包含暗区边缘的形状部分覆盖一个椭圆来创建上蜡新月的形状是很方便的,如图 11-7 所示。

A432415_1_En_11_Fig7_HTML.jpg

图 11-7。

Drawing a waxing crescent moon with Bezier curves

随着经过的秒数从 0 增加到 86,400 (24 × 60 × 60),形状顶部和底部的贝塞尔曲线的控制向量开始指向屏幕的左侧(图 11-7 的左侧面板),并逐渐旋转指向另一侧(右侧面板)。因此,我们可以将秒映射到从PI到 0 的角度值,然后使用这个值来旋转控制点。

清单 11-7 中的代码实现了这一想法,并将剩余的秒数显示为文本,根据可见月牙形中的剩余空间进行缩放。

int totSec = 24 * 60 * 60;
PFont font;

void setup() {
  font = createFont("Serif", 62);
  textAlign(LEFT, CENTER);  
}

void draw() {
  background(0);  
  int sec = 60 * 60 * hour() + 60 * minute() + second();
  float a = map(sec, 0, totSec, PI, 0);
  float x = map(sec, 0, totSec, 0, width);  
  float r = sec < totSec/2 ? map(sec, 0, totSec/2, 90, 50) :
                             map(sec, totSec/2, totSec, 50, 90);

  int t = totSec - sec;  
  String strt = str(t);
  int n = strt.length();
  float d = (width - x) / n;
  textFont(font, 1.75 * d);

  float rad = 0.5 * width;
  float diam = width;
  if (wearAmbient()) {
    fill(255);
    text(strt, x, rad);
  } else {
    fill(255);
    ellipse(rad, rad, diam, diam);
    noStroke();
    fill(0);
    beginShape();
    vertex(0, 0);
    vertex(rad, 0);
    float cx = r * cos(a);
    float cy = r * sin(a);
    bezierVertex(rad + cx, cy, x, rad - r, x, rad);
    vertex(x, rad);
    bezierVertex(x, rad + r, rad + cx, diam - cy, rad, diam);
    vertex(0, diam);
    endShape(CLOSE);

    fill(0, 170);
    text(strt, x, rad);
  }  
}

Listing 11-5.Moon Watch Face

在这段代码中,我们为表盘上的数字创建了一个大字体。由于这些数字会随着月牙中可用空间的增长和收缩而改变大小,因此我们将原始字体大小设置为最大可能值(在本例中为 62 像素),以便文本在使用 textFont(font,1.75 * d)调整大小后看起来很好。请记住,当我们创建特定大小的字体,然后为绘图设置更大的尺寸时,文本会看起来模糊。

添加交互

Watch faces 可以通过触摸屏接收触摸事件,就像手机和平板电脑上的常规应用一样。同样,我们可以使用mousePressed()mouseReleased()函数来处理这些事件。然而,watch faces 不支持拖动事件,因为这些事件由 Android 系统捕获,以驱动滑动,从而访问 watch UI 中的不同菜单。也不支持多点触摸事件。

鉴于手表屏幕的尺寸较小,触摸事件通常意味着在表盘上的不同视图之间切换,而不是使用与触摸相关的 x 和 y 坐标来驱动精确的交互。对于我们的表盘,我们可以使用一个简单的触摸来切换显示经过的时间和剩余的秒数。清单 11-6 显示了包括新的交互处理在内的全部代码。

int totSec = 24 * 60 * 60;
boolean showElapsed = false;
PFont font;

void setup() {
  font = createFont("Serif", 62);
  textAlign(LEFT, CENTER);  
}

void draw() {
  background(0);  
  int sec = 60 * 60 * hour() + 60 * minute() + second();
  float a = map(sec, 0, totSec, PI, 0);
  float x = map(sec, 0, totSec, 0, width);  
  float r = sec < totSec/2 ? map(sec, 0, totSec/2, 90, 50) :
                             map(sec, totSec/2, totSec, 50, 90);

  int t = showElapsed ? sec : totSec - sec;  
  String strt = str(t);
  int n = strt.length();
  float d = showElapsed ? x / n : (width - x) / n;
  textFont(font, 1.75 * d);

  float rad = 0.5 * width;
  float diam = width;
  if (wearAmbient()) {    
    fill(255);
    text(strt, x, rad);    
  } else {
    fill(255);
    ellipse(rad, rad, diam, diam);
    noStroke();
    fill(0);
    beginShape();
    vertex(0, 0);
    vertex(rad, 0);
    float cx = r * cos(a);
    float cy = r * sin(a);
    bezierVertex(rad + cx, cy, x, rad - r, x, rad);
    vertex(x, rad);
    bezierVertex(x, rad + r, rad + cx, diam - cy, rad, diam);
    vertex(0, diam);
    endShape(CLOSE);

    if (showElapsed) fill(255, 170);
    else fill(0, 170);
    text(strt, x, rad);    
  }  
}

void mousePressed() {
  showElapsed = !showElapsed;  
  if (showElapsed) textAlign(RIGHT, CENTER);
  else textAlign(LEFT, CENTER);
}

Listing 11-6.Moon Watch Face with Interaction

加载/显示图像

watch faces 支持图片的方式与我们之前讨论的常规和壁纸应用完全相同。我们可以依靠loadImage()将图像文件加载到草图中,并依靠image()函数将图像显示在屏幕上。在这个功能的帮助下,我们可以在清单 11-7 中完成我们的表盘,以一个实际的月球图像作为背景。图 11-8 中包含有背景图像和没有背景图像的表盘的不同输出。

A432415_1_En_11_Fig8_HTML.jpg

图 11-8。

Versions of the “moon” watch face showing remaining seconds in the day, elapsed, and moon texture

int totSec = 24 * 60 * 60;
boolean showElapsed = false;
PFont font;
PImage moon;

void setup() {
  moon = loadImage("moon.png");
  font = createFont("Serif", 62);
  textAlign(LEFT, CENTER);  
}

void draw() {
  background(0);
  int sec = 60 * 60 * hour() + 60 * minute() + second();
  float a = map(sec, 0, totSec, PI, 0);
  float x = map(sec, 0, totSec, 0, width);  
  float r = sec < totSec/2 ? map(sec, 0, totSec/2, 90, 50) :
                             map(sec, totSec/2, totSec, 50, 90);

  int t = showElapsed ? sec : totSec - sec;  
  String strt = str(t);
  int n = strt.length();
  float d = showElapsed ? x / n : (width - x) / n;
  textFont(font, 1.75 * d);

  float rad = 0.5 * width;
  float diam = width;
  if (wearAmbient()) {
    fill(255);
    text(strt, x, rad);    
  } else {
    image(moon, 0, 0, 2*rad, 2*rad);
    noStroke();
    fill(0);
    beginShape();
    vertex(0, 0);
    vertex(rad, 0);
    float cx = r * cos(a);
    float cy = r * sin(a);
    bezierVertex(rad + cx, cy, x, rad - r, x, rad);
    vertex(x, rad);
    bezierVertex(x, rad + r, rad + cx, diam - cy, rad, diam);
    vertex(0, diam);
    endShape(CLOSE);

    if (showElapsed) fill(255, 170);
    else fill(200, 230);
    text(strt, x, rad);    
  }
}

void mousePressed() {
  showElapsed = !showElapsed;  
  if (showElapsed) textAlign(RIGHT, CENTER);
  else textAlign(LEFT, CENTER);
}

Listing 11-7.Moon Watch Face with Background Image

在新版表盘中,我们做了一个小小的改变,就是在剩余的几秒钟内使用不同的颜色。在没有背景图像的版本中,我们使用了深灰色(0,170 ),与白色背景形成了良好的对比。现在,月亮图像太暗了,不能保证文本是可读的,所以我们用颜色值(200,230)切换到更亮的灰色。

摘要

在这一章中,我们看了时间显示的表盘。作为本主题的一部分,我们讨论了一些需要注意的问题,首先是使用时间值来实现时间的动态可视化,然后是如何为圆形和方形手表设计,最后是一个例子,说明了迭代在表盘设计中的重要性。这些材质应该为首次涉足该主题提供指导,许多可能性等待着那些有兴趣深入研究表盘发展的人。

十二、可视化身体活动

在本章中,我们将介绍智能手表和可穿戴设备上的一些身体传感器,以及我们可以用来实时读取和使用这些传感器数据的技术。

身体传感器

许多不同种类的可穿戴设备中都有身体传感器,特别是为个人监测身体活动而设计的健身追踪器。这些设备通常连接到移动应用,帮助用户跟踪他们的进展。大多数 Android 智能手表都配有至少两种身体传感器,一个计步器或计步器,以及一个心率传感器。这表明,活动追踪器(也包括时钟功能)和适当的智能手表之间有一些重叠。一些 Android 智能手表(如 Polaris M600)甚至打算主要用作活动跟踪器。

在前两章中,我们学习了如何使用处理来创建动画手表面部,在此之前,我们详细介绍了如何使用 Android API 和 Ketai 库访问传感器数据。我们现在应该能够结合这些技术来创建手表表面,从手表上的身体传感器读取数据,并通过动态可视化将这些数据呈现给用户。

步进计数器

计步器是监测身体活动最常见的传感器。它从加速度计读取数据,以推断佩戴者的运动模式,特别是与行走或跑步相关的运动模式。步数是总体身体活动的一个替代指标,尽管准确性有限,因为它不能衡量不涉及行走或跑步或运动强度的其他形式的活动。

每天 10,000 步是一个通常被接受的足够水平的身体活动的目标,但围绕这个数字作为健身的普遍目标一直存在一些争议( https://www.ncbi.nlm.nih.gov/pubmed/14715035) )。

心率

心率是一个非常精确的体力消耗指标,智能手表上的心率监视器允许我们实时访问这些信息。光学心率监测器,如智能手表中的光学心率监测器,通常不如其他类型的监测器可靠。光学监视器在一个称为光电容积描记术的过程中测量心率,其中它们将光(通常来自 led)照射到皮肤上,并检测由于血流变化引起的光衍射差异。手表处理这些数据,生成脉搏读数,并显示给用户。

另一方面,心电图(ECG)传感器直接测量心脏活动的电信号,但它们需要将电极连接到身体的不同部位,遵循与二十世纪初第一台心电图仪相同的原理(图 12-1 )。医疗 ECG 传感器可以使用多达 12 个电极,但运动型胸带依赖于放置在心脏附近的单个 ECG 传感器。然而,智能手表和其他可穿戴设备中的光学监视器已经发展到可以足够准确地全天连续监控心率,并且最大限度地减少了不便。

A432415_1_En_12_Fig1_HTML.jpg

图 12-1。

An early electrocardiograph from 1911. Note the arms and one leg of the patient immersed in buckets, which contain a saline solution to conduct the body’s current. Modern ECG sensors are still based on this three-point principle, but optical sensors rely on a completely different physical process to measure heart rate.

实时可视化身体活动

使用处理来实现显示步数或心率数据的表盘并不困难。我们可以像之前处理其他传感器一样检索传感器数据,比如加速度计和陀螺仪。我们需要做的就是创建一个传感器管理器,从中获取相应的传感器对象,并附加一个侦听器,该侦听器将返回硬件测量的实际值。

简单计步器

访问计步传感器不需要任何特殊许可。这个传感器持续运行,即使我们的手表表面没有访问它。一个特点是,它返回自手表上次启动以来的步数,并且只有在系统重新启动时才重置为零。因此,如果我们想显示自启动手表界面以来的步数,我们必须存储从监听器接收的第一步计数值,并从所有后续值中减去它,如清单 12-1 所示。

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;

Context context;
SensorManager manager;
Sensor sensor;
SensorListener listener;

int offset = -1;
int steps;

void setup() {
  fullScreen();
  frameRate(1);
  textFont(createFont("SansSerif", 28 * displayDensity));
  textAlign(CENTER, CENTER);
  initCounter();
}

void draw() {
  background(0);
  translate(0, wearInsets().bottom/2);
  text(steps + " steps", 0, 0, width, height);
}

void initCounter() {
  Context context = getContext();
  manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
  sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
  listener = new SensorListener();
  manager.registerListener(listener, sensor,
                           SensorManager.SENSOR_DELAY_NORMAL);
}

class SensorListener implements SensorEventListener {
  public void onSensorChanged(SensorEvent event) {
    if (offset == -1) offset = (int)event.values[0];
    steps = (int)event.values[0] - offset;
  }
  public void onAccuracyChanged(Sensor sensor, int accuracy) { }
}

Listing 12-1.Displaying the Step Count

这里,我们使用初始化为-1 的offset变量来存储初始步数。此外,如果我们计划创建一个跟踪每日步数的表盘,我们应该实现自己的“夜间重置”,因为 Android 不会在一天结束时自动重置步数。

访问心率传感器

心率需要BODY_SENSORS权限,由于数据的个人性质,该权限被归类为关键或危险权限。与地理定位一样,通过 PDE 中的 Android 权限选择器选择权限是不够的(图 12-2);我们必须在代码中用requestPermission()函数手动请求权限,提供权限请求的结果所调用的函数的名称。见清单 12-2 。

A432415_1_En_12_Fig2_HTML.jpg

图 12-2。

The BODY_SENSORS permission in the selector

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;

Context context;
SensorManager manager;
Sensor sensor;
SensorListener listener;

int bpm;

void setup() {
  fullScreen();
  frameRate(1);
  textFont(createFont("SansSerif", 28 * displayDensity));
  textAlign(CENTER, CENTER);
  requestPermission("android.permission.BODY_SENSORS", "initMonitor");
}

void draw() {
  background(0);
  translate(0, wearInsets().bottom/2);
  text(bpm + " beats/min", 0, 0, width, height);
}

void initMonitor(boolean granted) {
  if (granted) {
    Context context = getContext();
    manager = (SensorManager)context.
              getSystemService(Context.SENSOR_SERVICE);
    sensor = manager.getDefaultSensor(Sensor.TYPE_HEART_RATE);
    listener = new SensorListener();
    manager.registerListener(listener, sensor,
                             SensorManager.SENSOR_DELAY_NORMAL);
  }
}

class SensorListener implements SensorEventListener {
  public void onSensorChanged(SensorEvent event) {
    bpm = int(event.values[0]);
  }
  public void onAccuracyChanged(Sensor sensor, int accuracy) { }
}

Listing 12-2.Displaying the Heart Rate

当我们第一次打开表盘时,我们应该会看到一个类似于图 12-3 的对话框,询问允许或拒绝访问身体传感器数据。

A432415_1_En_12_Fig3_HTML.jpg

图 12-3。

Permission request when launching the watch face

可视化步数数据

可视化活动数据的一种简单方法是使用放射状表示法,描绘朝着设定目标的进展。我们已经使用了arc()函数来显示经过的时间,并且我们可以很容易地修改它来显示朝着期望的计数值的进展;例如,100,如清单 12-3 所示。

...
void setup() {
  frameRate(1);
  strokeCap(ROUND);
  stroke(255);
  noFill();
  textFont(createFont("SansSerif", 18 * displayDensity));
  textAlign(CENTER, CENTER);
  initCounter();
}

void draw() {
  background(0);
  translate(0, wearInsets().bottom/2);
  if (wearAmbient()) strokeWeight(1);
  else strokeWeight(10);
  float angle = map(min(steps, 100), 0, 100, 0, TWO_PI);
  arc(width/2, height/2, width/2, width/2,
      PI + HALF_PI, PI + HALF_PI + angle);
  if (steps == 0) text("0 steps", 0, 0, width, height);
}
...

Listing 12-3.Radial Step-count Visualization

草图的其余部分与清单 12-1 相同。接下来,我们可以添加一个乘数来显示用户已经达到目标的次数,方法是简单地将步数除以目标,在本例中为 100,并将该值作为文本绘制在屏幕中央。更新后的draw()功能如清单 12-4 所示,这款新表盘的输出如图 12-4 所示。

A432415_1_En_12_Fig4_HTML.jpg

图 12-4。

Step-count watch face, with multiplier counter

void draw() {
  background(0);
  translate(0, wearInsets().bottom/2);
  if (wearAmbient()) strokeWeight(1);
  else strokeWeight(10);
  int mult = int(steps / 100);
  float angle = map(steps - mult * 100, 0, 100, 0, TWO_PI);
  noFill();
  arc(width/2, height/2, width/2, width/2,
      PI + HALF_PI, PI + HALF_PI + angle);
  fill(255);
  if (0 < steps) {
    text("x" + (mult + 1), 0, 0, width, height);
  } else {
    text("0 steps", 0, 0, width, height);
  }
}
Listing 12-4.Adding a Multiplier

跳动的心脏

对于心率数据的可视化表示,我们可以依赖一个非常直接的翻译:一颗跳动的心脏,或者为了简单起见,一个跳动的圆圈。我们已经知道如何从传感器获得每分钟心跳数的值;问题是基于这个速率来制作这个圆圈的动画,以便它足够准确地传达心跳的强度和节奏。实际的心电信号如图 12-5 所示,我们看到单次心跳的电信号有多个波峰和波谷。该信号描绘了所谓的窦性节律( https://en.wikipedia.org/wiki/Sinus_rhythm ),其中大的峰值对应于心室的收缩( https://en.wikipedia.org/wiki/Heart#Blood_flow )。

A432415_1_En_12_Fig5_HTML.jpg

图 12-5。

An ECG signal

我们可以用一条“脉冲”曲线来近似这种模式,该曲线呈现快速的初始增加,随后衰减,直到下一次搏动(图 12-6 )。生成该脉冲曲线的简单数学函数如下:

A432415_1_En_12_Fig6_HTML.jpg

图 12-6。

An impulse function, generated with Graph Toy, by Inigo Quilez ( http://www.iquilezles.org/apps/graphtoy/index.html )

float impulse(float k, float t) {
  float h = k * t;
  return h * exp(1.0 - h);
}

在这个公式中,常数 k 决定了脉冲多快达到峰值(峰值的位置正好是t =1/k)。通过检查图 12-5 中的 ECG 信号,我们可以得出结论,心跳的第一个峰值出现在整个心跳持续时间的 25%左右。例如,如果我们的心脏以每分钟 80 次(bpm)的速度跳动,那么单次跳动将持续 60,000/80 = 750 毫秒,其第一个峰值应该出现在大约 0.25 × 750 = 187.5 毫秒。由此,我们可以计算出k,因为 t = 187.5 = 1/k,在这种情况下给出k∞0.0053。一般来说,对于任何测得的 bpm 值,常数k将等于 BPM/(0.25×60000)。

我们可以使用这个脉冲公式来控制任何形状的动画。当然,这个公式并不精确,它的最大值不一定与心室收缩的精确时刻重合,但作为一个近似值应该足以传达跳动的步伐。清单 12-5 扩展了我们之前清单 12-2 中的心率表盘,加入了一个半径遵循脉冲函数的椭圆。和以前一样,我们必须通过 PDE 添加BODY_SENSORS权限,一旦手表界面在设备上或模拟器中启动,就授予这个权限。

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;

Context context;
SensorManager manager;
Sensor sensor;
SensorListener listener;

int bpm;

void setup() {
  fullScreen();
  noStroke();
  textFont(createFont("SansSerif", 28 * displayDensity));
  textAlign(CENTER, CENTER);
  requestPermission("android.permission.BODY_SENSORS", "initMonitor");
}

void draw() {
  background(0);
  translate(0, wearInsets().bottom/2);
  if (wearAmbient()) {
    fill(255);
    text(bpm + " bpm", 0, 0, width, height);
  } else {
    int duration = 750;
    if (0 < bpm) duration = 60000 / bpm;
    float x = millis() % duration;
    float k = 1/(0.25 * duration);
    float a = impulse(k, x);
    float r = map(a, 0, 1, 0.75, 0.9) * width;
    translate(width/2, height/2);
    fill(247, 47, 47);
    ellipse(0, 0, r, r);
  }
}

float impulse(float k, float x) {
  float h = k * x;
  return h * exp(1.0 - h);
}

void initMonitor(boolean granted) {
  if (!granted) return;
  Context context = getContext();
  manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
  sensor = manager.getDefaultSensor(Sensor.TYPE_HEART_RATE);
  listener = new SensorListener();
  manager.registerListener(listener, sensor,
                           SensorManager.SENSOR_DELAY_NORMAL);
}

class SensorListener implements SensorEventListener {
  public void onSensorChanged(SensorEvent event) {
    bpm = int(event.values[0]);
  }
  public void onAccuracyChanged(Sensor sensor, int accuracy) { }
}

Listing 12-5.Creating a Heart-beat Animation

仅当手表处于交互模式时,才会绘制椭圆。我们计算当前 bpm 下单次心跳的持续时间,并将其存储在duration variable中,我们在评估脉冲函数所需的所有其他参数中使用它。由于脉冲范围在 0 和 1 之间,我们将其映射到(0.75,0.9)区间,因此椭圆的大小在收缩和膨胀状态之间变化,既不太小也不太大。我们可以调整这些参数,直到我们得到视觉上满意的结果。

传感器调试

测试使用传感器的表盘可能会很困难,因为我们可能需要四处走动,从计步器或心率传感器获取足够的数据,以确保我们在代码中评估不同的实例。由于处理允许我们轻松地在模式之间来回切换,并且绝大多数处理 API 在 Java 和 Android 模式之间保持不变,因此我们可以使用 Java 模式来测试不依赖于实际传感器的代码部分,尤其是渲染代码。

然而,我们仍然经常需要检查实际的传感器数据,以确定是否有问题,要么是我们对数据的假设,要么是我们在代码中处理数据的方式。我们可以这样做的一个方法是,将传感器的值记录到一个文本文件中,然后从手表中取出这个文件,寻找任何感兴趣的模式。清单 12-6 举例说明了这种保存心率传感器数据的方法。由于我们将数据写入外部存储器中的一个文件,我们需要将WRITE_EXTERNAL_STORAGE权限添加到草图中,并在草图代码中添加相应的请求,因为这也是一个危险的权限。

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.os.Environment;

Context context;
SensorManager manager;
Sensor sensor;
SensorListener listener;

int bpm;
String[] data = { "time,rate" };

void setup() {
  fullScreen();
  frameRate(1);
  textFont(createFont("SansSerif", 28 * displayDensity));
  textAlign(CENTER, CENTER);
  requestPermission("android.permission.BODY_SENSORS", "initMonitor");
  requestPermission("android.permission.WRITE_EXTERNAL_STORAGE");
}

void draw() {
  background(0);
  translate(0, wearInsets().bottom/2);
  text(bpm + " beats/min", 0, 0, width, height);
}

void mousePressed() {
  background(200, 40, 40);
  File sd = Environment.getExternalStorageDirectory();
  String path = sd.getAbsolutePath();
  File directory = new File(path, "out");
  File file = new File(directory, "sensor-data.csv");
  saveStrings(file, data);
}

void initMonitor(boolean granted) {
  if (granted) {
    Context context = getContext();
    manager = (SensorManager)
              context.getSystemService(Context.SENSOR_SERVICE);
    sensor = manager.getDefaultSensor(Sensor.TYPE_HEART_RATE);
    listener = new SensorListener();
    manager.registerListener(listener, sensor,
                             SensorManager.SENSOR_DELAY_NORMAL);
  }
}

class SensorListener implements SensorEventListener {
  public void onSensorChanged(SensorEvent event) {
    bpm = int(event.values[0]);
    data = (String[]) append(data, millis() + "," + bpm);
  }
  public void onAccuracyChanged(Sensor sensor, int accuracy) { }
}

Listing 12-6.Saving Sensor Data to a File

首先,注意对WRITE_EXTERNAL_STORAGE权限的请求是如何不包括在权限被授予(或拒绝)后要调用的函数的。这是因为写入外部存储器不需要额外的初始化。

在这个草图中,每当我们接收到一个新的传感器值,我们就把它和以毫秒为单位的时间一起添加到字符串数组data中。只有在触摸屏幕时,我们才使用 saveStrings()函数将该数组作为 CSV(逗号分隔值)文件保存到设备的外部存储器中。手表中的外部存储在其内部存储中进行模拟,因为智能手表不包括 SD 卡。为了将文件下载到开发计算机,我们可以从终端运行以下命令:

adb -s 127.0.0.1:4444 pull /storage/emulated/0/out/sensor-data.csv

一旦我们下载了数据文件,我们可以在文本编辑器或电子表格软件中阅读它。我们也可以在一个处理草图中使用它作为输入,就像清单 12-7 中的那样,在那里我们用loadTable()函数( https://processing.org/reference/loadTable_.html )读取 CSV 文件。这个函数返回一个Table对象,包含所有组织成行和列的数据。在这种情况下,我们简单地绘制一个带有LINE_STRIP形状的线图,连接每个连续行中的值。该草图的典型结果如图 12-7 所示。

A432415_1_En_12_Fig7_HTML.jpg

图 12-7。

Line plot of heart-rate data

size(700, 200, P2D);
Table table = loadTable("sensor-data.csv", "header");
background(90);
stroke(247, 47, 47);
strokeWeight(4);
beginShape(LINE_STRIP);
for (int i = 0; i < table.getRowCount(); i++) {
  TableRow row = table.getRow(i);
  int r = row.getInt("rate");
  float x = map(i, 0, table.getRowCount() - 1, 0, width);
  float y = map(r, 0, 100, height, 0);
  vertex(x, y);
}
endShape();
Listing 12-7.Plotting Sensor Data in Processing

在这两个示例中,我们的目标是记录传感器数据,以便进行后续分析来识别数据中的任何问题。另一个不同但相关的目的是以更可控的方式调试我们的数据处理代码。我们可以通过生成类似传感器输出的“合成”数据来做到这一点。例如,清单 12-8 显示了前面的心率示例,修改后可以打印连续运行的线程中生成的随机 bpm 值。

int bpm;

void setup() {
  fullScreen();
  frameRate(1);
  textFont(createFont("SansSerif", 28 * displayDensity));
  textAlign(CENTER, CENTER);
  thread("generateData");
}

void draw() {
  background(0);
  translate(0, wearInsets().bottom/2);
  text(bpm + " beats/min", 0, 0, width, height);
}

void generateData() {
  while (true) {
    bpm = int(random(60, 100));
    delay(2000);
  }
}

Listing 12-8.Generating Synthetic Sensor Data

我们在第六章中讨论了线程,包括我们如何使用它们来运行计算,否则会降低我们应用的帧率。这里,我们在一个新线程中在setup()的末尾启动generateData()函数。在该函数中,我们不断生成 60 到 100 之间的随机 bpm 值,每个连续值之间有两秒钟的延迟,这与真实心率传感器的行为非常接近,足以进行测试(延迟间隔也可以是随机的,以增加更多的可变性)。同样在第六章中,我们考虑了使用同步方法来防止不同的线程同时访问相同的数据。在这个最小的例子中,并发不是一个问题,但是我们需要在本章剩余部分将要考虑的最终项目中注意它。

锻炼的同时种一棵树

到目前为止,我们的身体活动手表表面相对简单。我们是否可以通过提供一个视觉输出,不仅跟踪活动水平,还引入一些视觉变化,来使活动跟踪器更有趣(也许更有价值)?如果我们认为步数是一个从 0 开始增长的值,直到达到一个设定的目标,比如 10,000 步,是否有可能用它来驱动我们草图中某些有机元素的“增长”——例如,一株植物或一棵树?

用代码生成一棵看起来自然的树是一个问题,它可以将我们带到数学中令人着迷的想法,如自相似性和分形( https://en.wikipedia.org/wiki/Self-similarity )。我们可以找到几种模拟树木生长的技术,其中一些如图 12-8 所示。

A432415_1_En_12_Fig8_HTML.jpg

图 12-8。

Different algorithms for tree generation. Left: fractal recursion (by Daniel Shiffman, https://processing.org/examples/tree.html) ). Center: branching tree (by Ryan Chao, https://www.openprocessing.org/sketch/186129 ). Right: particle system tree (by Asher Salomon, https://www.openprocessing.org/sketch/144159 )

最重要的是,我们需要一种算法,让我们的树随着步数的增加而增长。图 12-8 中提到的分形递归和粒子系统算法都可以随时间展开。尤其是后者,当它逐渐增长直至达到最大尺寸时,它比分形递归更有组织性。此外,它的代码可以在 OpenProcessing 上获得,所以我们可以用它作为我们项目的起点。

Note

OpenProcessing ( https://www.openprocessing.org/ )是一个在线加工草图库,其中大部分可以在浏览器中运行,可以根据知识共享协议进行修改和共享。

用粒子系统生成树

我们已经在第六章中使用了粒子系统来生成一个跟随图像亮度模式的动画。粒子系统可以在许多不同的场景中使用,以创建有机运动,就像这里的情况一样。对 Asher Salomon 的 OpenProcessing 草图做了一些修改,我们得到了清单 12-9 中的代码,当在 Java 模式下运行时,它给出了如图 12-9 所示的输出。

A432415_1_En_12_Fig9_HTML.jpg

图 12-9。

Output of the tree-generation algorithm

ArrayList<Branch> branches = new ArrayList<Branch>();

void setup() {
  size(500, 500);
  noStroke();
  branches.add(new Branch());
  background(155, 211, 247);
}

void draw() {
  for (int i = 0; i < branches.size(); i++) {
    Branch branch = branches.get(i);
    branch.update();
    branch.display();
  }
}

class Branch {
  PVector position;
  PVector velocity;
  float diameter;

  Branch() {
    position = new PVector(width/2, height);
    velocity = new PVector(0, -1);
    diameter = width/15.0;
  }
  Branch(Branch parent) {
    position = parent.position.copy();
    velocity = parent.velocity.copy();
    diameter = parent.diameter / 1.4142;
    parent.diameter = diameter;
  }
  void update() {
    if (1 < diameter) {
      position.add(velocity);
      float opening = map(diameter, 1, width/15.0, 1, 0);
      float angle = random(PI - opening * HALF_PI,
                           TWO_PI + opening * HALF_PI);
      PVector shake = PVector.fromAngle(angle);
      shake.mult(0.1);
      velocity.add(shake);
      velocity.normalize();
      if (random(0, 1) < 0.04) branches.add(new Branch(this));
    }
  }
  void display() {
    if (1 < diameter) {
      fill(175, 108, 44, 50);
      ellipse(position.x, position.y, diameter, diameter);
    }
  }
}

Listing 12-9.Growing Tree

为了理解这段代码,我们应该看看Branch类是如何被用来表示一群移动的粒子的,当它们在屏幕上留下轨迹时,生成了树的分支。基本思想如下:每个粒子是一个有位置、速度和直径的小椭圆。最初,有一个单个粒子,放置在屏幕的底部,直径为 width/15(后面会详细介绍这个选择),向上的速度为 1。有时,一个新的粒子会从一个现有的粒子中分支出来,所以我们最终会从一个主干中分出许多分支。为了正常工作,我们必须小心只在setup()中调用background(),这样粒子的轨迹不会在下一帧中被删除。

每次在draw()中调用一个粒子的update()方法,它的位置都会根据当前的速度更新,并且速度会被shake向量稍微颠簸一下。该向量的大小为 0.1,方向由angle变量决定。然而,在这个角度的计算方式中有一个重要的细节使得它不是完全随机的。这样做的原因是,我们希望避免树在早期摇摆到两侧,所以我们映射分支的直径,范围从开始的宽度/15 到结束的 1,到 0 和 1 之间的opening变量。如果树枝的直径接近 1,说明这棵树已经长好了,所以树枝不需要直,摇动矢量的角度可以在PI - opening * HALF_PITWO_PI + opening * HALF_PI之间的任何地方。如果opening正好为 1,则角度从整个圆范围内选择。另一方面,当opening开始接近 0°时,角度只能在PITWO_PI之间变化,代表圆的上半部分。这样,当树开始生长时,树枝被迫向上移动。

该算法的另一个关键方面是分支机制:每次粒子更新时,如果 0 和 1 之间的随机抽取小于 0.04,它会在当前位置创建一个新的分支。新分支只是另一个粒子对象,它从其父对象初始化,具有相同的位置和速度,但直径缩小了 1/ $$ \sqrt{2} $$。作为分支的结果,母粒子的直径也以同样的系数减小。

我们刚刚回顾了算法的主要元素。正如我们在代码中看到的,我们可以修改几个数字参数来调整树的外观。一个这样的选择是树枝的初始直径,这里设置为宽度/15,因为它给出了合理大小的树。此外,作为这个特定参数选择的结果,该算法将进行相当明确的迭代次数,以达到一个完全成长的树,在本例中大约是 300(我们将很快回到这个数字)。

结合步数数据

我们有一个树生成算法的工作版本,但它还没有绑定到步数传感器。检查这个传感器返回的值,我们意识到步骤不是一个接一个地被检测,而是我们得到一个以不规则的数量增加的数字。因此,我们可以计算当前和上一次onSensorChanged()事件之间的步数差异,并根据需要多次更新我们的粒子。让我们在清单 12-10 中尝试这种方法。

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;

Context context;
SensorManager manager;
Sensor sensor;
SensorListener listener;

int offset = -1;
int psteps, steps;
int stepInc = 0;

ArrayList<Branch> branches = new ArrayList<Branch>();
PGraphics canvas;

void setup() {
  fullScreen();
  noStroke();
  branches.add(new Branch());
  initCanvas();
  initCounter();
}

void draw() {
  background(0);
  if (wearInteractive()) growTree();
  image(canvas, 0, 0);
}

synchronized void growTree() {
  canvas.beginDraw();
  for (int s = 0; s < stepInc; s++) {
    for (int i = 0; i < branches.size(); i++) {
      Branch branch = branches.get(i);
      branch.update();
      branch.display();
    }
  }
  canvas.endDraw();
  stepInc = 0;
}

synchronized void updateSteps(int value) {
  if (offset == -1) offset = value;
  steps = value - offset;
  stepInc += steps - psteps;
  psteps = steps;
}

void initCanvas() {
  canvas = createGraphics(width, height);
  canvas.beginDraw();
  canvas.background(155, 211, 247);
  canvas.noStroke();
  canvas.endDraw();
}

class Branch {
  PVector position;
  PVector velocity;
  float diameter;

  Branch() {
    position = new PVector(width/2, height);
    velocity = new PVector(0, -1);
    diameter = width/15.0;
  }
  Branch(Branch parent) {
    position = parent.position.copy();
    velocity = parent.velocity.copy();
    diameter = parent.diameter / 1.4142;
    parent.diameter = diameter;
  }
  void update() {
    if (1 < diameter) {
      position.add(velocity);
      float opening = map(diameter, 1, width/15.0, 1, 0);
      float angle = random(PI - opening * HALF_PI,
                           TWO_PI + opening * HALF_PI);
      PVector shake = PVector.fromAngle(angle);
      shake.mult(0.1);
      velocity.add(shake);
      velocity.normalize();
      if (random(0, 1) < 0.04) branches.add(new Branch(this));
    }
  }
  void display() {
    if (1 < diameter) {
      canvas.fill(175, 108, 44, 50);
      canvas.ellipse(position.x, position.y, diameter, diameter);
    }
  }
}

void initCounter() {
  Context context = getContext();
  manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
  sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
  listener = new SensorListener();
  manager.registerListener(listener, sensor,
                           SensorManager.SENSOR_DELAY_NORMAL);
}

class SensorListener implements SensorEventListener {
  synchronized void onSensorChanged(SensorEvent event) {
    updateSteps(int(event.values[0]));
  }
  void onAccuracyChanged(Sensor sensor, int accuracy) { }
}

Listing 12-10.Driving the Growth of the Tree with the Step Count

我们重用了清单 12-9 中的大部分代码,并添加了标准的 Android 事件处理程序。但是有几个新的东西我们应该考虑。首先,我们将分支绘制到屏幕外的表面,我们已经在第九章中使用过了。原因如下:作为一个手表表面,我们的草图将不得不为环境模式呈现不同的输出。因为这需要擦除整个屏幕,我们将会丢失制作树的粒子轨迹。将它们绘制到一个单独的表面,我们可以随时显示,这是解决这个问题的一个简单方法。

我们还有两个同步函数,growTree()updateSteps()。访问并修改stepInc变量,该变量包含自上次onSensorChanged()事件以来计算的步数。由于growTree()是从draw()调用的,因此是从 Processing 的动画线程调用的,而updateSteps()是从onSensorChanged()调用的,而onSensorChanged()又是从应用的主线程触发的,我们需要同步来避免这两个线程对stepInc的并发修改。

调整表盘

我们仍然有两个问题,我们的第一个版本的树看脸。一个是,由于一个步骤相当于一个分支更新周期,树将增长得太快,特别是如果我们希望只有当步骤数足够高时,比如 10,000,树才能达到最大大小。第二个问题是由计步传感器的性质引起的:onSensorChanged()可能以不规则的间隔被调用,在一个时刻有大的步进增加,而在另一个时刻有小的变化。特别是,如果增加非常大,growTree()可能需要很长时间运行,冻结表盘,因为它更新所有粒子的次数与自上次传感器改变事件以来计数的步数一样多。

为了解决第一个问题,让我们回忆一下我们之前的观察,在当前的参数选择下,该算法需要大约 300 次更新来完全生长该树。这意味着一个步骤应该只代表更新迭代的一部分。更准确地说,如果我们的目标是完成 10,000 步的树,那么一个单步对一次更新迭代的贡献将是 300/10,000。

其次,冻结问题的一个简单解决方案可以是删除growTree()函数中的第一个循环,并对每个粒子只运行一次更新,同时将stepInc b y 的值减少一。通过这种方式,我们每帧更新一次,因此表盘不会冻结,而是会一直增长直到stepInc为零。清单 12-11 显示了实现这些调整所需的代码变化。

...
int offset = -1;
int psteps, steps;
float stepInc = 0;
int stepGoal = 10000;
float stepScale = stepGoal / 300.0;
...
synchronized void growTree() {
  if (1 <= stepInc) {
    canvas.beginDraw();
    for (int i = 0; i < branches.size(); i++) {
      Branch branch = branches.get(i);
      branch.update();
      branch.display();
    }
    canvas.endDraw();
    stepInc--;
  }
}

synchronized void updateSteps(int value) {
  if (offset == -1) offset = value;
  steps = value - offset;
  stepInc += (steps - psteps) / stepScale;
  psteps = steps;
}
...

Listing 12-11.Controlling Growth Rate

让树开花

我们非常接近完成我们的手表脸!我们仍然需要一些改进:一个环境模式,它可以只是一个总步数和时间的文本绘图;当接近期望的计步目标时会有一个额外的动画——例如,众所周知的 10,000;以及达到目标后的重启。清单 12-12 增加了这些改进,我们将在代码之后讨论。

import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorManager;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;

Context context;
SensorManager manager;
Sensor sensor;
SensorListener listener;

int offset = -1;
int tsteps, psteps, steps, phour;
float stepInc = 0;
int stepGoal = 10000;
float stepScale = stepGoal / 300.0;

ArrayList<Branch> branches = new ArrayList<Branch>();
PGraphics canvas;
color bloomColor = color(230, 80, 120, 120);

void setup() {
  fullScreen();
  noStroke();
  textFont(createFont("SansSerif-Bold", 28 * displayDensity));
  branches.add(new Branch());
  initCanvas();
  initCounter();
}

void draw() {
  background(0);
  String str = hour() + ":" + nfs(minute(), 2) + ":" +
                              nfs(second(), 2) + "\n" +
               tsteps + " steps";
  if (wearInteractive()) {
    growTree();
    if (stepGoal <= steps) clearTree();
    image(canvas, 0, 0);
    textAlign(CENTER, BOTTOM);
    textSize(20 * displayDensity);
    fill(0, 80);
  } else {
    textAlign(CENTER, CENTER);
    textSize(28 * displayDensity);
    fill(200, 255);
    translate(0, wearInsets().bottom/2);
  }
  text(str, 0, 0, width, height);
}

synchronized void growTree() {
  if (1 <= stepInc) {
    canvas.beginDraw();
    for (int i = 0; i < branches.size(); i++) {
      Branch branch = branches.get(i);
      branch.update();
      branch.display();
      branch.bloom();
    }
    canvas.endDraw();
    stepInc--;
  }
}

synchronized void updateSteps(int value) {
  if (hour() < phour) tsteps = steps;
  if (offset == -1) offset = value;
  steps = value - offset;
  tsteps += steps - psteps;
  stepInc += (steps - psteps) / stepScale;
  psteps = steps;
  phour = hour();
}

synchronized void clearTree() {
  canvas.beginDraw();
  canvas.background(155, 211, 247);
  canvas.endDraw();
  branches.clear();
  branches.add(new Branch());
  offset = -1;
  steps = psteps = 0;
  bloomColor = color(random(255), random(255), random(255), 120);
}

void initCanvas() {
  canvas = createGraphics(width, height);
  canvas.beginDraw();
  canvas.background(155, 211, 247);
  canvas.noStroke();
  canvas.endDraw();
}

class Branch {
  PVector position;
  PVector velocity;
  float diameter;

  Branch() {
    position = new PVector(width/2, height);
    velocity = new PVector(0, -1);
    diameter = width/15.0;
  }
  Branch(Branch parent) {
    position = parent.position.copy();
    velocity = parent.velocity.copy();
    diameter = parent.diameter / 1.4142;
    parent.diameter = diameter;
  }
  void update() {
    if (1 < diameter) {
      position.add(velocity);
      float opening = map(diameter, 1, width/15.0, 1, 0);
      float angle = random(PI - opening * HALF_PI,
                           TWO_PI + opening * HALF_PI);
      PVector shake = PVector.fromAngle(angle);
      shake.mult(0.1);
      velocity.add(shake);
      velocity.normalize();
      if (random(0, 1) < 0.04) branches.add(new Branch(this));
    }
  }
  void display() {
    if (1 < diameter) {
      canvas.fill(175, 108, 44, 50);
      canvas.ellipse(position.x, position.y, diameter, diameter);
    }
  }
  void bloom() {
    if (0.85 * stepGoal < steps && random(0, 1) < 0.001) {
      float x = position.x + random(-10, +10);
      float y = position.y + random(-10, +10);
      float r = random(5, 20);
      canvas.fill(bloomColor);
      canvas.ellipse(x, y, r, r);
    }
  }
}

void initCounter() {
  Context context = getContext();
  manager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
  sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
  listener = new SensorListener();
  manager.registerListener(listener, sensor,
                           SensorManager.SENSOR_DELAY_NORMAL);
}

class SensorListener implements SensorEventListener {
  void onSensorChanged(SensorEvent event) {
    updateSteps(int(event.values[0]));
  }
  void onAccuracyChanged(Sensor sensor, int accuracy) { }
}

Listing 12-12.Adding Flowers, Time, and Step Count

环境模式的实现相当简单:在屏幕中央绘制一条文本消息。树开花也相对简单:一旦我们达到总步长目标的 85%,我们在每个分支粒子的当前位置附近放置一个椭圆,概率为 0.001。我们在最终的观看面中需要的另一个功能是在达到目标后重置,因此画布被清除,所有粒子被移除以从头开始一个新的树。这是在clearTree()函数中完成的,同步是因为它修改了offsetpstepstep变量,这些变量也在每个传感器改变事件中改变。此外,我们还添加了一个“午夜重置”,这样当小时回到零时(这种情况只会在午夜发生),总步数tstep会被设置为当前步数,行if (hour() < phour) tsteps = stepsupdateSteps()中。

图 12-10 显示了一棵树在不同生长阶段的一系列三个屏幕截图,从早期的步数跟踪到接近步数目标时开花。在图 12-11 中,我们可以看到表盘在实际手表上的环境和互动模式下的样子。

A432415_1_En_12_Fig11_HTML.jpg

图 12-11。

Final version of the tree watch face, in ambient (left) and interactive (right) modes

A432415_1_En_12_Fig10_HTML.jpg

图 12-10。

Three stages in the growth of the tree

正如我们前面所讨论的,测试和调试这个表面可能很有挑战性,因为我们需要走足够长的时间来观察树的生长变化。然而,我们可以很容易地生成类似于计步传感器输出的合成数据。清单 12-13 展示了为了使用步数生成器而不是步数监听器,需要对代码进行的修改。这些变化实际上相当小,本质上是启动一个运行数据生成循环的线程。我们可以调整循环中的值,以增加或减少更新的频率,以及值的范围。这给了我们足够的灵活性来在不同的场景下评估我们的代码,这些场景很难用真实的步数数据来测试。

...
void setup() {
  ...
  branches.add(new Branch());
  initCanvas();
  thread("generateData");
}
...
void generateData() {
  int total = 0;
  while (true) {
    total += int(random(10, 20));
    updateSteps(total);
    delay(500);
  }
}
Listing 12-13.Testing with Synthetic Data

开发过程的最后一步是将我们的手表表面上传到谷歌 Play 商店。运行 Wear 2.x 的 Android 设备可以安装独立的应用和观看面孔,而不需要“配套”的移动应用,正如在线开发者文档中关于打包和分发 Wear 应用的说明: https://developer.android.com/training/wearables/apps/packaging.html 。要从 PDE 获取手表表盘的签名包,我们可以遵循第三章中描述的常规应用的相同步骤。

首先,我们必须在草图文件夹内的清单文件中写一个包名。服务和应用的标签是可选的,但强烈建议使用,因为它将在整个手表 UI 中用于识别手表表面。下面提供了树监视面的完整清单文件:

<?xml version="1.0" encoding="UTF-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          android:versionCode="1" android:versionName="1.0"
          package="com.example.running_tree">
    <uses-sdk android:minSdkVersion="25" android:targetSdkVersion="25"/>
    <uses-feature android:name="android.hardware.type.watch"/>
    <application android:icon="@drawable/icon"
                 android:label="Running Tree" android:supportsRtl="true">
        <uses-library android:name="com.google.android.wearable"
                      android:required="false"/>
        <meta-data android:name="com.google.android.wearable.standalone"
                   android:value="true"/>
        <service android:label="Running Tree" android:name=".MainService"
                 android:permission="android.permission.BIND_WALLPAPER">
            <meta-data android:name="android.service.wallpaper"
                       android:resource="@xml/watch_face"/>
            <meta-data android:name=
                       "com.google.android.wearable.watchface.preview"
                       android:resource="@drawable/preview_rectangular"/>
            <meta-data android:name=
                       "com.google.android.wearable.watchface.preview_circular"
                       android:resource="@drawable/preview_circular"/>
            <meta-data android:name=
        "com.google.android.wearable.watchface.companionConfigurationAction"
                       android:value=
                      "com.catinean.simpleandroidwatchface.CONFIG_DIGITAL"/>
            <intent-filter>
                <action android:name=
                        "android.service.wallpaper.WallpaperService"/>
                <category android:name=
               "com.google.android.wearable.watchface.category.WATCH_FACE"/>
            </intent-filter>
        </service>
        <activity android:name="processing.android.PermissionRequestor"/>
    </application>
    <uses-permission android:name="android.permission.WAKE_LOCK"/>
</manifest>

最后,我们需要创建一个完整的图标集,包括所有 DPI 级别的六个应用图标(xxxhdpi 到 l DPI)以及圆形和矩形预览图标(图 12-12 )。虽然目前大多数手表都提供 xhdpi 分辨率,但随着屏幕分辨率更高的设备的出现,这种情况可能会在不久的将来发生变化。

A432415_1_En_12_Fig12_HTML.jpg

图 12-12。

Icon set needed to export the signed package

摘要

本章总结了可穿戴设备和表盘部分。虽然可穿戴开发本身是一个完整的领域,但我们能够应用在前面章节中学习的大多数技术来创建交互式图形和传感器数据的动态可视化,同时考虑到可穿戴设备在屏幕尺寸、分辨率和电池寿命方面的一些独特方面。

十三、处理 3D

我们的虚拟现实(VR)之旅从介绍 3D 编程和处理的基本概念开始:坐标系、3D 变换、照明、纹理和 3D 形状的创建,我们不仅可以在 VR 应用中应用,还可以在任何需要交互式 3D 图形的情况下应用。

P3D 渲染程序

VR 应用的开发有一个重要的先决条件:学习如何创建交互式 3D 图形。到目前为止,我们只使用默认或P2D渲染器绘制了二维图形。尽管 2D 渲染是 3D 的一个特例,但我们需要熟悉 3D 图形的许多运动和交互方面。

处理包括绘制 3D 场景的渲染器,恰当地称为P3D。它支持基本的 3D 功能,如照明和纹理对象,但也支持更高级的功能,如着色器。在setup()期间,我们可以通过设置size()fullScreen()函数中的渲染器参数来使用P3D;例如size(width, height, P3D)fullScreen(P3D)。这样做之后,我们不仅可以使用处理中所有可用的 3D 渲染功能,还可以像以前一样继续绘制 2D 图形。

3D Hello World

让我们先写一个简单的草图来演示 3D 处理的基础:一个旋转的立方体。清单 12-1 包括平移和旋转变换,以及默认照明。

float angle = 0;

void setup() {
  fullScreen(P3D);
  fill(#AD71B7);
}

void draw() {
  background(#81B771);
  lights();
  translate(width/2, height/2);
  rotateY(angle);
  rotateX(angle*2);
  box(300);
  angle += 0.01;
}

Listing 13-1.Basic 3D Sketch

我们将在随后的章节中更仔细地查看这些功能,但是作为对在draw()中发生的事情的概述,我们首先用lights()来“打开”一组默认灯光,然后用translate(width/2, height/2)将整个场景平移到屏幕的中心,并应用两次旋转,一次用rotateY(angle)沿着 y 轴,第二次用rotateX(angle*2)沿着 x 轴。就像在 2D 绘画中一样,这些变换会影响我们之后绘制的所有形状,在这个例子中是用box(300)绘制的立方体。我们通过增加旋转角度来结束,这样我们就有了连续的动画。图 13-1 显示了该草图的输出。

A432415_1_En_13_Fig1_HTML.jpg

图 13-1。

Simple 3D rendering in Processing

照相机

当我们绘制任何 3D 场景时,都有一个“相机”在观察虚拟空间,我们可以将设备的屏幕视为该相机的视口。处理包括一些操作相机的位置和“虚拟”镜头的功能。另一方面,在 VR 草图中,处理相机将由手机在空间中的移动自动控制。在任一情况下,Processing 的摄像机由三个向量定义:眼睛位置、场景中心和“上”向量,如图 13-2 所示。

A432415_1_En_13_Fig2_HTML.jpg

图 13-2。

Vectors defining position and orientation of the camera

这些向量可以使用camera()函数设置:camera(eyeX,eyeY,eyeZ,centerX,centerY,centerZ,upX,upY,upZ)。不带任何参数调用camera()将设置默认摄像机的位置和方向,其中中心为(0,0,0)点,眼睛沿 z 轴放置,向上方向为正 y 向量。通过不断地改变眼睛的位置,我们可以从任何有利的位置观看场景。清单 13-2 说明了如何设置这些参数。

void setup() {
  fullScreen(P3D);
  fill(#AD71B7);
}

void draw() {
  background(#81B771);
  float t = millis()/1000.0;
  float ex = width/2 + 500 * cos(t);
  float ey = height/2 + 500 * sin(t);
  float ez = 1000 + 100 * cos(millis()/1000.0);
  camera(ex, ey, ez, width/2, height/2, 0, 0, 1, 0);
  lights();
  translate(width/2, height/2);
  box(300);
}

Listing 13-2.Camera Parameters

我们不仅可以设置相机的位置和方向,还可以选择如何将场景投影到相机的视口(可以认为是选择相机的“镜头”)。有两种类型的投影:透视投影和正交投影。如图 13-3 所示。透视投影是P3D中的默认设置,它对应于图像在物理世界中的形成方式。其中,对象沿着汇聚到视口后面的“眼睛”位置的视线投影到视口平面上。这模拟了透视的效果,远处的物体看起来更小。事实上,处理使用默认的透视参数,因此 z=0 处的尺寸(0,0,宽度,高度)的矩形正好覆盖整个输出窗口。多亏了这些设置,我们可以像以前一样在P3D中绘制 2D 图形。

A432415_1_En_13_Fig3_HTML.jpg

图 13-3。

Perspective (left) and orthographic (right) projections in Processing

另一方面,在正交投影中,对象沿着垂直于它的线被投影到视口,因此当对象远离或靠近摄影机眼时,大小不会减小或增大。处理过程中正交投影的默认设置是,3D 坐标(0,0,z)和(宽度,高度,z)的点正好落在输出窗口的右上角和左下角,而不考虑 z 的值。

我们可以通过perspective()ortho()功能轻松地在这两种投影模式之间来回切换。这些函数有刚才描述的默认设置,但是我们也可以使用它们和额外的参数来调整视野、相机眼睛的位置和其他参数:perspective(fovy, aspect, zNear, zFar)ortho(left, right, bottom, top, near, far)。在清单 13-3 中,我们通过余弦函数将视野与时间联系起来,因此它在 10 度(非常窄的视野,物体放大到几乎占据整个屏幕)到 80 度(非常宽,物体看起来比正常情况小)之间来回振荡。

float angle = 0;

void setup() {
  fullScreen(P3D);
  fill(#AD71B7);
}

void draw() {
  background(#81B771);
  float fov = radians(map(cos(millis()/1000.0), -1, +1, 10, 80));
  float ratio = float(width)/height;
  perspective(fov, ratio, 1, 2000);
  lights();
  translate(width/2, height/2);
  rotateY(angle);
  rotateX(angle*2);
  box(300);
  angle += 0.01;
}

Listing 13-3.Perspective Parameters

这里需要注意的另外几件事包括宽高比的设置,在大多数情况下应该只是宽度/高度,以及裁剪平面。这些平面通过具有一个近值和一个远值来确定沿 z 方向的可见体积:任何比前者更近或比后者更远的都将被裁剪掉。

Note

camera()perspective()功能使用起来并不特别直观,特别是如果你想在物理空间中考虑相机的移动和调整(如缩放、平移和滚动),但有一些库可以简化相机的操作,其中包括 PeasyCam、obstructive Camera Direction 和 proscene。附录 B 列出了我们可以在 Android 模式下使用的库。

即时渲染与保留渲染

在前面的例子中,我们如何处理 3D 场景的一个重要方面是,我们在每一帧中从头开始创建一个新的盒子,然后立即丢弃它。这种绘制 3D(以及 2D)图形的方式称为即时渲染,如果我们的场景中对象相对较少,这种方式就很好,但是如果我们有一个包含许多形状的更复杂的场景,这种方式可能会降低渲染速度。这与我们编写 VR 应用的目标特别相关,因为我们将在下面的章节中进一步讨论,因为渲染动画应该尽可能平滑,以确保观众不会因低或不均匀的帧速率而产生运动病。

Processing 提供了另一种绘制几何图形的方式,称为retained rendering,以提高性能。使用保留渲染,我们创建一次形状,然后根据需要多次重画它们。我们已经在第四章中使用了这种技术,在绘制复杂场景时它会更快。保留渲染将在以后使用 VR 时变得非常方便。

使用保留渲染很容易,我们只需要将我们的形状存储在一个PShape对象中。处理提供了一些预定义的 3D 形状,比如盒子和球体,它们可以通过一次调用来创建,如清单 13-4 所示。

float angle = 0;
PShape cube;

void setup() {
  fullScreen(P3D);
  perspective(radians(80), float(width)/height, 1, 1000);
  PImage tex = loadImage("mosaic.jpg");
  cube = createShape(BOX, 400);
  cube.setTexture(tex);
}

void draw() {
  background(#81B771);
  lights();
  translate(width/2, height/2);
  rotateY(angle);
  rotateX(angle*2);
  shape(cube);
  angle += 0.01;
}

Listing 13-4.Using Retained Rendering

在这段代码中,一旦我们从createShape()函数中获得了多维数据集对象,我们就可以对它进行修改。例如,我们可以对它应用纹理,这样它的表面就被图像“包裹”起来,看起来更有趣。最终输出如图 13-4 所示。

A432415_1_En_13_Fig4_HTML.jpg

图 13-4。

Drawing a textured PShape

在前四个例子中,我们已经应用了大多数基本的 3D 渲染技术(创建形状、应用变换、定义光照和纹理),我们将在本章的剩余部分更详细地讨论每一个主题。

3D 转换

我们有三种类型的 3D 变换:平移(从 A 点移动到 B 点)、旋转(绕轴旋转)和缩放(均匀或沿一个方向收缩或扩张)。清单 13-5 到 13-7 分别举例说明了每一个。

void setup() {
  fullScreen(P3D);
  fill(120);
}

void draw() {
  background(157);
  float x = map(cos(millis()/1000.0), -1, +1, 0, width);
  translate(x, height/2);
  box(200);
}

Listing 13-5.Applying a Translation

对于旋转,我们需要首先平移到屏幕的中心,因为box()函数将立方体放置在(0,0,0)处。

void setup() {
  fullScreen(P3D);
  fill(120);
}

void draw() {
  background(157);
  translate(width/2, height/2);
  rotateY(millis()/1000.0);
  box(200);
}

Listing 13-6.Applying a Rotation

对于缩放,类似的观察也适用—我们首先应用平移(width/2,height/2 ),因此框出现在屏幕的中心。

void setup() {
  fullScreen(P3D);
  fill(120);
}

void draw() {
  background(157);
  translate(width/2, height/2);
  float f = map(cos(millis()/1000.0), -1, +1, 0.1, 5);
  scale(f);
  box(200);
}

Listing 13-7.Applying Scaling

Note

根据设备的 DPI,所有这些示例中形状的相对大小会有所不同。我们可以使用densityDisplay系统常数来放大或缩小它们,这样它们在不同分辨率和大小的屏幕上看起来就一致了。

组合转换

前面的例子显示了如何单独使用 3D 变换(尽管在旋转和缩放的情况下,最初是平移到屏幕的中心)。但在大多数情况下,我们需要结合平移、旋转和缩放,通常是通过定义一系列变换,将对象放置在 3D 空间中的所需位置,并具有预期的比例。事实上,可以组合变换来创建非常复杂的运动,需要记住几个“规则”:(1)变换的顺序不能互换(例如,应用旋转然后平移并不等同于先平移后旋转,正如我们在第四章中在 2D 绘画的上下文中讨论的那样);(2)可以用pushMatrix()popMatrix()操作一系列转换,用pushMatrix()保存当前的转换组合,从而隔离发生在它和匹配的popMatrix()之间的附加转换的影响。我们在第二章看到了一些这样的例子。

清单 13-8 显示了变换合成的一个应用,其中的想法是创建一个有多个分段的关节“手臂”并制作动画,我们可以在图 13-5 中看到。

A432415_1_En_13_Fig5_HTML.jpg

图 13-5。

Animated arm with combined 3D transformations

float[] r1, r2;

void setup() {
  fullScreen(P3D);
  noStroke();
  r1 = new float[100];
  r2 = new float[100];
  for (int i = 0; i < 100; i++) {
    r1[i] = random(0, 1);
    r2[i] = random(0, 1);
  }
}

void draw() {
  background(157);
  lights();
  translate(width/2, height/2);
  scale(4);
  for (int i = 0; i < 100; i++) {
    float tx = 0, ty = 0, tz = 0;
    float sx = 1, sy = 1, sz = 1;
    if (r1[i] < 1.0/3.0) {
      rotateX(millis()/1000.0);
      tz = sz = 10;
    } else if (1.0/3.0 < r1[i] && r1[i] < 2.0/3.0) {
      rotateY(millis()/1000.0);
      tz = sz = 10;
    } else {
      rotateZ(millis()/1000.0);
      if (r2[i] < 0.5) {
        tx = sx = 10;
      } else {
        ty = sy = 10;
      }
    }
    translate(tx/2, ty/2, tz/2);
    pushMatrix();
    scale(sx, sy, sz);
    box(1);
    popMatrix();
    translate(tx/2, ty/2, tz/2);
  }
}

Listing 13-8.Composing 3D Transformations

本例中的要点是:首先,我们使用随机数(r1r2)来决定在每个关节处,我们围绕哪个轴旋转下一个分段,以及我们沿着哪个轴延伸分段;第二,在两个不同的地方使用scale()——在场景居中之后,增加整个手臂的大小,在绘制每个手臂之前,只沿着位移轴,这样片段就可以正确地相互连接。另外,请注意,随机数是在setup()中预先计算的,并存储在浮点数组中;否则,几何图形会在帧与帧之间完全改变。

三维形状

正如我们在 2D 所做的,我们可以使用处理功能生成原始形状。所有 2D 图元(三角形、椭圆形、矩形和四边形)都可以在 3D 中使用,并增加了两个新的 3D 图元(长方体和球体)。清单 13-9 将所有这些图元绘制在一张草图中,输出如图 13-6 所示。

A432415_1_En_13_Fig6_HTML.jpg

图 13-6。

2D and 3D primitives rendered with P3D

float[] r1, r2;
void setup() {
  fullScreen(P3D);
}

void draw() {
  background(157);

  translate(width/2, height/2);

  pushMatrix();
  translate(-width/3, -height/4);
  rotateY(millis()/2000.0);
  ellipse(0, 0, 200, 200);
  popMatrix();

  pushMatrix();
  translate(0, -height/4);
  rotateY(millis()/2000.0);
  triangle(0, +150, -150, -150, +150, -150);
  popMatrix();

  pushMatrix();
  translate(+width/3, -height/4);
  rotateY(millis()/2000.0);
  rect(-100, -100, 200, 200, 20);
  popMatrix();

  pushMatrix();
  translate(-width/3, +height/4);
  rotateY(millis()/2000.0);
  quad(-40, -100, 120, -80, 120, 150, -80, 150);
  popMatrix();

  pushMatrix();
  translate(0, +height/4);
  rotateY(millis()/2000.0);
  box(200);
  popMatrix();

  pushMatrix();
  translate(+width/3, +height/4);
  rotateY(millis()/2000.0);
  sphere(150);
  popMatrix();
}

Listing 13-9.2D and 3D Primitives

自定义形状

我们在第四章中看到,可以使用beginShape() / vertex() / endShape()函数,用适当的形状类型(POINTSLINESTRIANGLES等)创建自定义形状。).与早期的图元一样,我们为 2D 渲染学习的所有代码都可以在P3D中重用,无需任何更改,但现在有可能添加一个 z 坐标。例如,让我们在清单 13-10 中用QUADSnoise()函数创建一个高度随机的地形。

void setup() {
  fullScreen(P3D);
}

void draw() {
  background(150);
  lights();
  translate(width/2, height/2);
  rotateX(QUARTER_PI);
  beginShape(QUADS);
  float t = 0.0001 * millis();
  for (int i = 0; i < 50; i++) {
    for (int j = 0; j < 50; j++) {
      float x0 = map(i, 0, 50, -width/2, width/2);
      float y0 = map(j, 0, 50, -height/2, height/2);
      float x1 = x0 + width/50.0;
      float y1 = y0 + height/50.0;
      float z1 = 200 * noise(0.1 * i, 0.1 * j, t);
      float z2 = 200 * noise(0.1 * (i + 1), 0.1 * j, t);
      float z3 = 200 * noise(0.1 * (i + 1), 0.1 * (j + 1), t);
      float z4 = 200 * noise(0.1 * i, 0.1 * (j + 1), t);
      vertex(x0, y0, z1);
      vertex(x1, y0, z2);
      vertex(x1, y1, z3);
      vertex(x0, y1, z4);
    }
  }
  endShape();
}

Listing 13-10.Creating a Custom Shape with QUADS

噪波是使用定义网格的顶点的(i, j)索引生成的,因此它确保了相邻四边形之间共享的顶点处的高度位移是一致的。我们可以在图 13-7 中看到结果。

A432415_1_En_13_Fig7_HTML.jpg

图 13-7。

Terrain generated with a QUADS shape

PShape 对象

像我们刚刚做的那样创建一个大的形状可能会导致性能下降,特别是在基本的智能手机上。如果几何图形在草图运行的整个过程中是静态的,我们可以将它存储在一个PShape对象中,以便更快地保留渲染,如清单 13-11 所示。

PShape terrain;

void setup() {
  fullScreen(P3D);
  terrain = createShape();
  terrain.beginShape(QUADS);
  for (int i = 0; i < 50; i++) {
    for (int j = 0; j < 50; j++) {
      float x0 = map(i, 0, 50, -width/2, width/2);
      float y0 = map(j, 0, 50, -height/2, height/2);
      float x1 = x0 + width/50.0;
      float y1 = y0 + height/50.0;
      float z1 = 200 * noise(0.1 * i, 0.1 * j, 0);
      float z2 = 200 * noise(0.1 * (i + 1), 0.1 * j, 0);
      float z3 = 200 * noise(0.1 * (i + 1), 0.1 * (j + 1), 0);
      float z4 = 200 * noise(0.1 * i, 0.1 * (j + 1), 0);
      terrain.vertex(x0, y0, z1);
      terrain.vertex(x1, y0, z2);
      terrain.vertex(x1, y1, z3);
      terrain.vertex(x0, y1, z4);
    }
  }
  terrain.endShape();
}

void draw() {
  background(150);
  lights();
  translate(width/2, height/2);
  rotateX(QUARTER_PI);
  shape(terrain);
}

Listing 13-11.Storing a Custom Shape Inside a PShape

我们仍然可以在创建一个PShape对象后对其进行修改。例如,清单 13-12 使用setVertex()函数为每一帧中的顶点添加了一些随机位移(设置与清单 13-11 相同),因此整个曲面现在是动画的。

...
void draw() {
  background(150);
  lights();
  translate(width/2, height/2);
  rotateX(QUARTER_PI);
  updateShape();
  shape(terrain);
  println(frameRate);
}

void updateShape() {
  float t = 0.0001 * millis();
  int vidx = 0;
  for (int i = 0; i < 50; i++) {
    for (int j = 0; j < 50; j++) {
      float x0 = map(i, 0, 50, -width/2, width/2);
      float y0 = map(j, 0, 50, -height/2, height/2);
      float x1 = x0 + width/50.0;
      float y1 = y0 + height/50.0;
      float z1 = 200 * noise(0.1 * i, 0.1 * j, t);
      float z2 = 200 * noise(0.1 * (i + 1), 0.1 * j, t);
      float z3 = 200 * noise(0.1 * (i + 1), 0.1 * (j + 1), t);
      float z4 = 200 * noise(0.1 * i, 0.1 * (j + 1), t);
      terrain.setVertex(vidx++, x0, y0, z1);
      terrain.setVertex(vidx++, x1, y0, z2);
      terrain.setVertex(vidx++, x1, y1, z3);
      terrain.setVertex(vidx++, x0, y1, z4);
    }
  }
}

Listing 13-12.Modifying a PShape After Creating It

然而,通过修改PShape对象的所有顶点,我们可能会看到草图的性能恢复到即时渲染的水平。如果我们只修改一些顶点,那么性能仍然比直接渲染要好。

我们可以将不同类型的PShape对象作为子对象组合成一个单独的包含PShape的对象。处理会将它们作为一个单独的实体一起渲染,这也将导致比直接渲染或作为单独的PShape对象绘制更高的帧速率。在清单 13-13 中,我们使用loadStrings()从一个文本文件中读取 1000 个点的 3D 坐标,然后将它们绘制成方框或球体。

PVector[] coords;
PShape group;

void setup() {
  fullScreen(P3D);
  textFont(createFont("SansSerif", 20 * displayDensity));
  sphereDetail(10);
  group = createShape(GROUP);
  String[] lines = loadStrings("points.txt");
  coords = new PVector[lines.length];
  for (int i = 0; i < lines.length; i++) {
    String line = lines[i];
    String[] valores = line.split(" ");
    float x = float(valores[0]);
    float y = float(valores[1]);
    float z = float(valores[2]);
    coords[i] = new PVector(x, y, z);
    PShape sh;
    if (random(1) < 0.5) {
      sh = createShape(SPHERE, 20);
      sh.setFill(#E8A92A);
    } else {
      sh = createShape(BOX, 20);
      sh.setFill(#4876B2);
    }
    sh.translate(x, y, z);
    sh.setStroke(false);
    group.addChild(sh);
  }
  noStroke();
}

void draw() {
  background(255);
  fill(0);
  text(frameRate, 50, 50);
  fill(255, 0, 0);
  lights();
  translate(width/2, height/2, 0);
  rotateY(map(mouseX, 0, width, 0, TWO_PI));
  shape(group);
}

Listing 13-13.Creating a Group Shape

我们对该代码应用了一些优化技巧以降低场景的复杂性:我们将球体细节设置为 10(默认为 30)并禁用笔划。尤其是笔画线会给形状增加很多额外的几何图形,从而降低渲染速度。最终的结果应该看起来像图 13-8 ,即使在低端手机上也有合理的性能(fps > 40)。

A432415_1_En_13_Fig8_HTML.jpg

图 13-8。

Group shape containing shapes of different kinds Note

3D 中的性能由几个因素控制,但最重要的因素之一是场景中的总顶点数。重要的是我们不要给场景添加不必要的顶点,记住用户会在一个(相对)小的屏幕上看到我们的草图。其他优化,如使用 PShapes 存储静态几何,也有助于保持高帧速率。

加载 OBJ 形状

OBJ 文件格式( http://paulbourke.net/dataformats/obj/ )是一种简单的基于文本的格式,用于存储 3D 几何图形和材质定义。它是由 Wavefront Technologies 公司在 20 世纪 80 年代创建的,该公司开发了用于电影和其他行业的动画软件。尽管它相当基础,但大多数 3D 建模工具都支持它,并且有许多在线存储库包含这种格式的免费 3D 模型。Processing 的 API 包括loadShape()函数,我们已经用它在 2D 加载了 SVG 形状,它将读取P3D中的 OBJ 形状,如清单 13-14 所示。

PShape model;
PVector center;

void setup() {
  fullScreen(P3D);
  model = loadShape("Deer.obj");
  center = getShapeCenter(model);
  float dim = max(model.getWidth(), model.getHeight(), model.getDepth());
  float factor = width/(3 * dim);
  model.rotateX(PI);
  model.scale(factor);
  center.mult(factor);
  center.y *= -1;
}

void draw() {
  background(157);
  lights();
  translate(width/2, height/2);
  translate(-center.x, -center.y, -center.z);
  rotateY(millis()/1000.0);
  shape(model);
}

PVector getShapeCenter(PShape sh) {
  PVector bot = new PVector(+10000, +10000, +10000);
  PVector top = new PVector(-10000, -10000, -10000);
  PVector v = new PVector();
  for (int i = 0; i < sh.getChildCount(); i++) {
    PShape child = sh.getChild(i);
    for (int j = 0; j < child.getVertexCount(); j++) {
      child.getVertex(j, v);
      bot.x = min(bot.x, v.x);
      bot.y = min(bot.y, v.y);
      bot.z = min(bot.z, v.z);
      top.x = max(top.x, v.x);
      top.y = max(top.y, v.y);
      top.z = max(top.z, v.z);
    }
  }
  return PVector.add(top, bot).mult(0.5);
}

Listing 13-14.Loading an OBJ File

在这段代码中,我们首先加载形状,然后我们计算一些参数,这样它就可以正确地放置在场景中。首先,我们用getShapeCenter()函数计算形状的中心位置,在这里我们遍历形状中的所有顶点,并获得每个轴上的最大值和最小值。包含最小值和最大值的两个向量bottop,是包围整个形状的边界框的对角。边界框的中点是形状的中心,我们希望它与屏幕的中心重合,如图 13-9 所示。

A432415_1_En_13_Fig9_HTML.jpg

图 13-9。

3D shape loaded from an OBJ file

此外,OBJ模型中的坐标可能具有与我们在处理中使用的非常不同的值范围(通常是 0-宽度和 0-高度),因此通过获得模型沿每个轴的尺寸(使用getWidth()getHeight()getDepth(),我们可以计算一个因子,通过该因子来放大或缩小形状以适应屏幕。该形状还需要围绕 x 旋转 180 度,因为它是颠倒的。加载OBJ文件时经常会出现这种情况,因为 3D 图形中的一个常见约定是 y 轴朝上,而处理使用 y 轴朝下,这是图形设计工具中更常见的设置。请注意,我们需要单独缩放和反转中心向量,因为它是根据形状的输入坐标计算的,不受形状变换的影响。

我们通过一个getVertex()调用来检索形状的坐标,其中我们重用了同一个PVector对象v。这是因为如果我们要为每个顶点创建一个新的临时PVector,所有这些对象最终都需要从内存中丢弃。这种内存释放操作虽然非常快,但在释放数千个PVector对象时会造成明显的延迟,可能会导致动画暂停。

照明和纹理

当我们创建 3D 场景时,灯光和纹理是需要考虑的两个关键方面。没有它们,大多数物体看起来就像平面形状,没有任何深度感或表面复杂性。照明和纹理算法模拟了光线和材质在物理世界中如何相互作用,以便 3D 图形足够逼真,能够传达一个可信的空间。我们不需要照片真实感,但是某种程度上接近真实的灯光和材质的组合对于吸引我们的用户是必要的。当使用 VR 时,光线和纹理变得更加重要,因为用户完全被合成的 3D 场景所包围。

处理有几个功能,我们可以应用来创建光源和设置 3D 形状的材质属性,包括纹理。在P3D渲染器中作为这些功能基础的照明模型是更复杂模型的第一近似值。因此,它不能生成阴影或渲染粗糙或凹凸不平的表面,但它可以处理诸如材质的亮度和发射率、光衰减、定向光源和聚光灯等现象。但是,我们可以通过自定义着色器( https://processing.org/tutorials/pshader/ )来实现我们自己的、更真实的光照模型。

光源和材质属性

正在处理的 3D 场景中的形状的最终颜色由其材质属性和光源特征之间的相互作用来确定。简而言之,光源的颜色会影响形状,这取决于形状的相应材质属性是否已设置为在某种程度上与光源颜色相匹配的颜色。最重要的属性是填充颜色。例如,如果光源的 RGB 颜色为(200,150,150),形状的填充颜色为(255,255,20),形状将反射光的全部红色和绿色分量,但仅反射其蓝色分量的一小部分。

加工中有四种类型的光源:

  1. 环境光:代表不是来自特定方向的光;光线反弹得如此厉害,以至于物体从各个方向都被均匀地照亮了。设置环境光的函数是

    ambientLight(c1, c2, c3);
    
    

    环境光的颜色是(c2,c2,c3),根据当前的颜色模式进行解释。

  2. 点光源:在空间中具有特定位置并从该位置(即中心)向所有方向发射的光。它的作用是

    pointLight(c1, c2, c3, x, y, z);
    
    

    点光的位置由(x,y,z)给出,而它的颜色由(c1,c2,c3)给出。

  3. 平行光:表示距离对象足够远的光源,其所有光线都遵循相同的方向(太阳就是平行光源的一个例子)。我们用:

    directionalLight(c1, c2, c3, nx, ny, nz);
    
    

    来配置,平行光的方向由(nx,ny,nz)给出,而它的颜色由(c1,c2,c3)给出。

  4. 聚光灯:照亮以灯光位置为中心的圆锥体内所有对象的光源。聚光灯有几个参数:

    spotLight(c1, c2, c3, x, y, z, nx, ny, nz, angle, concentration)
    
    

    和前面一样,聚光灯的位置由(x,y,z)给出,它的颜色由(c1,c2,c3)给出。附加参数是(nx,ny,nz),圆锥体的方向(但不是光线的方向,因为光线在点光源中是从原点投射出去的),圆锥体的孔径角,以及朝向圆锥体中心的浓度。

现在让我们考虑清单 13-15 来看看这是如何处理一些形状和一些对象的。

void setup() {
  fullScreen(P3D);
  noStroke();
}

void draw() {
  background(20);
  translate(width/2, height/2);

  float pointX = map(mouseX, 0, width, -width/2, +width/2);
  float dirZ = map(mouseY, 0, height, 0, -1);
  pointLight(200, 200, 200, pointX, 0, 600);
  directionalLight(100, 220, 100, 0, 1, dirZ);

  rotateY(QUARTER_PI);

  fill(255, 250, 200);
  box(320);

  translate(-400, 0);
  fill(200, 200, 250);
  sphere(160);

  translate(0, +110, 360);
  fill(255, 200, 200);
  box(100);
}

Listing 13-15.Lighting a 3D Scene

在这个例子中,我们有两个光源:一个亮灰色的点光源和一个绿色的平行光。我们可以通过水平滑动来控制点光的 x 坐标,通过垂直滑动来控制方向光的 z 坐标。由于每个对象都有不同的填充颜色,以不同的程度“反射”每个入射光,因此场景的最终外观可能会根据光的位置和方向发生显著变化,如图 13-10 所示。例如,平行光沿 z 的坐标越大,它对面垂直于 z 的形状的影响就越直接,因此我们看到整体绿色色调的增加。

A432415_1_En_13_Fig10_HTML.jpg

图 13-10。

Color of shapes in a scene change as light sources move around Note

我们应该在每次调用draw()函数时设置灯光;否则,他们不会主动。我们可以通过调用lights()函数来设置默认的照明配置,有时足以进行快速测试。

填充颜色决定了表面如何反射入射光的颜色,它不是我们可以调整的唯一材质属性。我们有以下附加属性:

  1. 发光性:自身发光的能力。它由以下函数控制:

    emissive(c1, c2, c2)
    
    

    其中(c1,c2,c3)是材质的发射色。

  2. 光泽:形状表面的光泽度。我们只需要用

    shininess(s)
    
    

    设置一个参数,其中s是光泽度,从 0(无光泽)到 1(最大光泽)。

  3. 镜面反射:产生镜面反射的能力。它的工作方式是通过调用下面的函数:

    specular(c1, c2, c2)
    
    

    用高光的颜色(c1,c2,c3)。

通过调整这三种材质属性,我们可以生成各种各样的材质表面,即使填充颜色在所有材质中是相同的,如清单 13-16 所示,其输出如图 13-11 所示。

A432415_1_En_13_Fig11_HTML.jpg

图 13-11。

Spheres with different material properties

void setup() {
  fullScreen(P3D);
  noStroke();
}

void draw() {
  background(0);
  translate(width/2, height/2);

  directionalLight(255, 255, 255, 0, 0, -1);

  pushMatrix();
  translate(-width/3, 0);
  fill(250, 100, 50);
  specular(200, 250, 200);
  emissive(0, 0, 0);
  shininess(10.0);
  sphere(200);
  popMatrix();

  pushMatrix();
  fill(250, 100, 50);
  specular(255);
  shininess(1.0);
  emissive(0, 20, 0);
  sphere(200);
  popMatrix();

  pushMatrix();
  translate(+width/3, 0);
  fill(250, 100, 50);
  specular(255);
  shininess(2.0);
  emissive(50, 10, 100);
  sphere(200);
  popMatrix();
}

Listing 13-16.
Material Properties

与处理中的其他属性一样,emissive()specular()shininess()函数为随后绘制的所有形状设置相应的属性。调用pushStyle()popStyle()也作用于材质属性,包括填充、发射率、镜面反射颜色和光泽因子。

纹理映射

填充颜色和其他材质属性的使用为我们定义特定形状在不同照明场景下的外观提供了很大的自由度,从没有灯光(在这种情况下,填充颜色用于均匀地绘制形状)到具有多个光源的更复杂的情况。然而,形状仍然是相对“扁平”的,因为它们看起来像是由单一材质制成的。纹理映射允许我们通过简单地用纹理图像“包裹”3D 形状来解决一致性问题,并轻松创建看起来更复杂的表面,如图 13-12 中的球体所示。

A432415_1_En_13_Fig12_HTML.jpg

图 13-12。

Texture mapping a sphere with an image of Earth

基本形状,比如盒子和球体,可以通过提供合适的图像立即进行纹理处理,如清单 13-17 所示。

PShape earth;
PImage texmap;

void setup() {
  fullScreen(P3D);
  texmap = loadImage("earthmap1k.jpg");
  earth = createShape(SPHERE, 300);
  earth.setStroke(false);
  earth.setTexture(texmap);
}

void draw() {
  background(255);
  translate(width/2, height/2);
  rotateY(0.01 * frameCount);
  shape(earth);
}

Listing 13-17.Texturing a Sphere

当我们创建自定义形状时,我们需要提供一些额外的信息来成功地应用纹理:纹理坐标。这些坐标指示图像中的哪个像素(u,v)到达形状中的哪个顶点(I,j),使用这些规范,P3D能够将图像中的所有像素应用到整个形状上。

最简单的纹理映射是一个矩形,如清单 13-18 所示,我们只需要将图像的角与四边形的四个顶点匹配。

PImage texmap;

void setup() {
  fullScreen(P3D);
  texmap = loadImage("woodstock.png");
  noStroke();
}

void draw() {
  background(255);
  translate(width/2, height/2);
  rotateY(0.01 * frameCount);
  scale(displayDensity);
  beginShape(QUAD);
  texture(texmap);
  vertex(-150, -150, 0, 0);
  vertex(-150, 150, 0, texmap.height);
  vertex(150, 150, texmap.width, texmap.height);
  vertex(150, -150, texmap.width, 0);
  endShape();
}

Listing 13-18.Texturing a QUAD Shape

有时,我们可能会发现使用归一化坐标来指定图像像素更方便,这允许我们在不参考图像的宽度和高度的情况下构造形状。例如,我们可以使用标准化值(0.5,0.5)来表示图像的中心像素,而不是(img.width/2,img.height/2)。我们使用textureMode()函数在默认(IMAGE)和规范化(NORMAL)模式之间切换。一旦我们选择了正常模式,( u,v)值应该在 0 和 1 之间,如清单 13-19 所示。

PImage texmap;

void setup() {
  fullScreen(P3D);
  texmap = loadImage("woodstock.png");
  textureMode(NORMAL);
  noStroke();
}

void draw() {
  background(255);
  translate(width/2, height/2);
  rotateY(0.01 * frameCount);
  beginShape(QUAD);
  texture(texmap);
  vertex(-150, -150, 0, 0);
  vertex(-150, 150, 0, 1);
  vertex(150, 150, 1, 1);
  vertex(150, -150, 1, 0);
  endShape();
}

Listing 13-19.Using Normalized Texture Coordinates

对于更复杂的形状,我们需要确保纹理坐标计算正确,这样最终的纹理对象看起来就像我们想要的那样。例如,如果我们回到清单 13-11 中的地形示例,我们有网格的(I,j)索引,我们可以用它通过map()函数获得相应的归一化纹理坐标。清单 13-20 显示了如何做到这一点,相应的输出如图 13-13 所示。

A432415_1_En_13_Fig13_HTML.jpg

图 13-13。

Terrain shape with a dirt texture applied to it

PShape terrain;

void setup() {
  fullScreen(P3D);
  PImage dirt = loadImage("dirt.jpg");
  textureMode(NORMAL);
  terrain = createShape();
  terrain.beginShape(QUADS);
  terrain.noStroke();
  terrain.texture(dirt);
  for (int i = 0; i < 50; i++) {
    for (int j = 0; j < 50; j++) {
      float x0 = map(i, 0, 50, -width/2, width/2);
      float y0 = map(j, 0, 50, -width/2, width/2);
      float u0 = map(i, 0, 50, 0, 1);
      float v0 = map(j, 0, 50, 0, 1);
      float u1 = map(i + 1, 0, 50, 0, 1);
      float v1 = map(j + 1, 0, 50, 0, 1);
      float x1 = x0 + width/50.0;
      float y1 = y0 + width/50.0;
      float z1 = 200 * noise(0.1 * i, 0.1 * j, 0);
      float z2 = 200 * noise(0.1 * (i + 1), 0.1 * j, 0);
      float z3 = 200 * noise(0.1 * (i + 1), 0.1 * (j + 1), 0);
      float z4 = 200 * noise(0.1 * i, 0.1 * (j + 1), 0);
      terrain.vertex(x0, y0, z1, u0 ,v0);
      terrain.vertex(x1, y0, z2, u1 ,v0);
      terrain.vertex(x1, y1, z3, u1 ,v1);
      terrain.vertex(x0, y1, z4, u0 ,v1);
    }
  }
  terrain.endShape();
}

void draw() {
  background(150);
  lights();
  translate(width/2, height/2);
  rotateX(QUARTER_PI);
  shape(terrain);
}

Listing 13-20.Texturing a Complex Shape

值得注意的是,(u,v)纹理坐标,和(x,y,z)坐标一样,不一定是静态的。即使在一个PShape对象中,我们也可以使用setTextureUV()函数动态修改纹理坐标。

摘要

借助我们在本章中学到的技术,我们将能够在 Android 应用中使用处理来创建交互式 3D 图形,包括光照、纹理、动态创建的对象以及从 OBJ 模型加载的对象,以及性能技巧。不管我们是否对 VR 感兴趣,这些技术都为我们可能想要为游戏、可视化和其他类型的应用进行的任何 3D 开发提供了有用的工具包。

十四、虚拟现实基础

在这一章中,我们将学习一些创建带处理的 VR 应用的基本技术。这些技术涵盖了 VR 空间中的对象选择、交互和移动,以及使用眼睛坐标来创建静态参考框架以促进用户体验。

虚拟现实

我们可能会认为虚拟现实是最近的发明,但它的历史悠久,至少可以追溯到 20 世纪 50 年代( https://en.wikipedia.org/wiki/Virtual_reality#History ),如果我们将十九世纪的立体照片浏览器视为现代虚拟现实的前辈,甚至更早。在几十年前由于电影和早期用于游戏机的 VR 头戴设备而进入流行意识之后,计算机技术的快速进步使得体验具有高度沉浸式图形和交互的 VR 成为可能。近年来,虚拟现实更广泛的吸引力部分是由 Oculus Rift 耳机引领的,它始于 2012 年的 Kickstarter 项目,并引发了一个不断增长的行业,现在包括 HTC Vive、PlayStation VR 和谷歌虚拟现实。

与 Vive 或 Oculus 等需要专用桌面计算机来驱动图形的系统相比,谷歌 VR 只需将智能手机连接到廉价的纸板耳机上即可体验。这有优点也有缺点:一方面,它使虚拟现实变得非常容易接近和易于尝试,而另一方面,体验可能不如使用更复杂的虚拟现实系统丰富。

纸板和白日梦

来自 Google 的 VR 平台支持两种硬件:原来的 Cardboard 和更新的 Daydream。使用纸板,耳机可以像折叠的纸板切口一样简单,以支撑手机,并与塑料透镜配对以供观看。纸板耳机不能长时间佩戴,因为我们必须像拿望远镜一样拿着它。大多数最新的 Android 手机都可以与纸板耳机一起使用(下一节将介绍硬件要求)。随着 Daydream 的推出,谷歌推出了一款更加精致的面料耳机,以确保长时间佩戴时的轻便。Daydream 还使用独立智能手机来推动 VR 体验;然而,Daydream 兼容的硬件更受限制,因为它必须比 Cardboard 使用的容量更高。所有为 Cardboard 设计的 VR 内容应该都能在 Daydream 上工作,但反过来就不一定了。

硬件要求

Cardboard 需要一部至少带有陀螺仪的智能手机,这样才能正确跟踪头部运动。这是一个相当最低的要求,因为过去几年市场上的大多数 Android 手机都包括一个陀螺仪。Daydream 由高端设备支持,如谷歌 Pixel 和华硕 ZenFone AR ( https://vr.google.com/daydream/smartphonevr/phones/ )。总的来说,对于 Cardboard 和 Daydream 来说,建议使用处理器速度快的智能手机,否则动画可能不够流畅,从而大大影响 VR 体验的质量。

处理中的虚拟现实

面向 Android 的 Processing 包括一个 VR 库,它充当谷歌 VR 的简化界面,并根据手机传感器的头部跟踪数据自动配置 Processing 的 3D 视图。在处理中使用 VR 需要两步。首先在 PDE 中的 Android 菜单中选择 VR 选项,如图 14-1 所示。

A432415_1_En_14_Fig1_HTML.jpg

图 14-1。

Enabling the VR option in the Android menu

第二步,将 VR 库导入到我们的代码中,在fullScreen()函数中设置STEREO渲染器。该渲染器在我们的草图中绘制 3D 场景,需要进行相机变换来跟随 VR 空间中的头部移动,并考虑每只眼睛的视点之间的差异。清单 14-1 显示了处理中的最小虚拟现实草图。

import processing.vr.*;

void setup() {
  fullScreen(STEREO);
  fill(#AD71B7);
}

void draw() {
  background(#81B771);
  translate(width/2, height/2);
  lights();
  rotateY(millis()/1000.0);
  box(500);
}

Listing 14-1.Basic VR Sketch

我们可以看到图 14-2 中的结果——VR 中的一个旋转立方体!如果我们把手机放在一个纸板或 Daydream 耳机里,当我们绕着它转动我们的头时,我们将能够从不同的角度看到立方体。然而,在物理空间中行走不会对虚拟现实观看产生任何影响,因为谷歌虚拟现实耳机(在撰写本文时)不支持位置跟踪。

A432415_1_En_14_Fig2_HTML.jpg

图 14-2。

Output of a simple VR sketch Note

为了在 Daydream 耳机上运行我们的草图,我们需要手动编辑草图文件夹中的清单文件,并将意图过滤器部分中的类别com.google.intent.category.CARDBOARD替换为com.google.intent.category.DAYDREAM

立体渲染

正如我们在第一个例子中注意到的,一个 VR 草图生成了场景的两个副本,一个用于左眼,另一个用于右眼。它们略有不同,因为它们对应于从每只眼睛观看场景的方式。这样做的结果是在每一帧中调用两次draw()函数(我们将进一步讨论这个方面)。

我们在上一章看到的使用 P3D 渲染器进行 3D 绘制的所有技术几乎不加修改就可以移植到 VR 上。我们可以像以前一样使用形状、纹理和灯光。默认情况下,XYZ 轴的方向与 P3D 渲染器中的方向相同,这意味着原点位于屏幕的左上角,y 轴指向下方。清单 14-2 实现了一个简单的场景来可视化那些设置(图 14-3 )。

A432415_1_En_14_Fig3_HTML.jpg

图 14-3。

Default coordinate axes in VR

import processing.vr.*;

void setup() {
  fullScreen(STEREO);
  strokeWeight(2);
}

void draw() {
  background(0);
  translate(width/2, height/2);
  lights();
  drawAxis();
  drawGrid();
}

void drawAxis() {
  line(0, 0, 0, 200, 0, 0);
  drawBox(200, 0, 0, 50, #E33E3E);
  line(0, 0, 0, 0, -200, 0);
  drawBox(0, -200, 0, 50, #3E76E3);
  line(0, 0, 0, 0, 0, 200);
  drawBox(0, 0, 200, 50, #3EE379);
}

void drawGrid() {
  beginShape(LINES);
  stroke(255);
  for (int x = -10000; x < +10000; x += 500) {
    vertex(x, +500, +10000);
    vertex(x, +500, -10000);
  }
  for (int z = -10000; z < +10000; z += 500) {
    vertex(+10000, +500, z);
    vertex(-10000, +500, z);
  }
  endShape();
}

void drawBox(float x, float y, float z, float s, color c) {
  pushStyle();
  pushMatrix();
  translate(x, y, z);
  noStroke();
  fill(c);
  box(s);
  popMatrix();
  popStyle();
}

Listing 14-2.Axes in VR

由于原点在屏幕的左上角,我们需要调用translate(width/2, height/2)来将场景置于屏幕的中间。此外,我们可以看到,放置在(0,-200,0)处的蓝色框位于视线上方,与 y 轴的向下方向一致。

Note

大多数开发 VR 应用的框架都使用一个坐标系,其中原点位于屏幕的中心,y 轴指向上。在针对 Android 的处理中,我们可以通过调用setup()中的cameraUp()来切换到这个系统。

单视场渲染

处理 VR 库包括另一个渲染器,我们可以用它来绘制响应手机移动的 3D 场景,但不是立体模式。如果我们只是想在没有 VR 头戴设备的情况下窥视 3D 空间,这可能会很有用。我们代码中唯一需要的改变是使用MONO渲染器代替STEREO,就像我们在清单 14-3 中所做的那样。结果如图 14-4 所示。

A432415_1_En_14_Fig4_HTML.jpg

图 14-4。

Monoscopic rendering

import processing.vr.*;

float angle = 0;
PShape cube;

void setup() {
  fullScreen(MONO);
  PImage tex = loadImage("mosaic.jpg");
  cube = createShape(BOX, 400);
  cube.setTexture(tex);
}

void draw() {
  background(#81B771);
  translate(width/2, height/2);
  lights();
  rotateY(angle);
  rotateX(angle*2);
  shape(cube);
  angle += 0.01;
}

Listing 14-3.Using the MONO Renderer

VR 互动

到目前为止的代码示例已经向我们展示了在处理中创建 VR 场景是相当简单的:我们所需要的就是在 PDE 中选择 VR 模式,将 VR 库导入到我们的草图中,并使用STEREO渲染器。有了这些步骤,我们就可以应用之前学过的所有 3D 渲染技术了。但是一旦我们开始思考 VR 中的用户交互,我们就会发现新的挑战。首先,纸板耳机不像更贵的耳机那样包括任何控制器。手动输入仅限于一个触发屏幕触摸的按钮,一些基本的耳机甚至没有这个按钮。我们必须问自己几个关于 VR 中交互的基本问题:我们如何选择 3D 对象/UI 元素,以及我们如何在 VR 空间中移动?

开发人员一直在尝试各种解决这些问题的方法,谷歌 Play 商店上的 VR 应用概述可以给我们一些提示。一种常见的交互技术是凝视选择:应用检测到我们正在看哪个对象,然后一次触摸按压(或盯着它足够长的时间)就会触发所需的动作。所有的 VR 应用都以这样或那样的方式使用这种技术,并结合其他有趣的想法:头部手势(倾斜等。)、利用 VR 空间中的特殊区域放置 UI 元素(即向上或向下看)、某些动作的自动化(行走、拍摄)。

Note

一个成功的虚拟现实体验需要特别注意交互,让用户感觉他们确实在这个空间里。鉴于虚拟现实耳机在图形真实感和控制方面的限制,我们需要非常仔细地设计交互,以便在我们试图传达的特定体验方面有意义。

眼睛和世界坐标

在我们开始研究虚拟现实的交互技术之前,我们需要熟悉我们在开发虚拟现实应用时将要处理的坐标系。有两个系统需要记住:世界坐标系和眼睛坐标系,如图 14-5 所示。

A432415_1_En_14_Fig5_HTML.jpg

图 14-5。

Eye coordinate system with forward, right, and up vectors at the eye position, and world coordinate system

我们一直使用世界坐标,因为在 2D 和 3D 中,处理依赖于这些坐标来表征形状的位置和运动。虽然眼睛坐标是新的,但它非常具体地体现了从头部跟踪信息为我们自动构建虚拟现实视图的方式。眼睛坐标系由三个向量定义:向前、向右和向上(图 14-5 )。向前的矢量代表我们视线的方向,向右和向上的矢量完成了这个系统。这些向量会在每一帧中自动更新,以反映头部的运动。

眼睛坐标是代表形状和其他需要与我们的视图对齐的图形元素的自然选择,例如我们眼前的文本信息或提供静态参考框架的一片几何图形;例如头盔或宇宙飞船的内部。眼睛坐标的使用使得正确绘制那些元素变得非常容易;例如,我们眼前的一个盒子会有坐标(0,0,200)。

处理让我们简单地通过调用eye()函数从世界坐标切换到眼睛坐标,如清单 14-4 所示。四边形、方框和文本总是在我们的视线前面,如图 14-6 所示。

A432415_1_En_14_Fig6_HTML.jpg

图 14-6。

Geometry defined in eye coordinates

import processing.vr.*;

public void setup() {
  fullScreen(STEREO);
  textFont(createFont("SansSerif", 30));
  textAlign(CENTER, CENTER);
}

public void draw() {
  background(255);
  translate(width/2, height/2);
  lights();
  fill(#EAB240);
  noStroke();
  rotateY(millis()/1000.0);
  box(300);
  drawEye();
}

void drawEye() {
  eye();

  float s = 50;
  float d = 200;
  float h = 100;

  noFill();
  stroke(0);
  strokeWeight(10);
  beginShape(QUADS);
  vertex(-s, -s, d);
  vertex(+s, -s, d);
  vertex(+s, +s, d);
  vertex(-s, +s, d);
  endShape();

  pushMatrix();
  translate(0, 0, d);
  rotateX(millis()/1000.0);
  rotateY(millis()/2000.0);
  fill(#6AA4FF);
  noStroke();
  box(50);
  popMatrix();

  fill(0);
  text("Welcome to VR!", 0, -h * 0.75, d);
}

Listing 14-4.Drawing in Eye Coordinates

视线

在 VR 空间中最直接的交互方式就是四处张望!为了实现视线选择,我们可以参考图 14-5 ,图中显示了从眼睛(或相机)位置沿着前向矢量延伸的实际视线。如果一个 3D 对象在这条线的路径上,我们可以断定它正在被用户查看(除非有另一个对象阻挡了视图)。那么,怎样才能画出视线呢?正如我们在上一节中看到的,眼睛坐标应该是答案,因为这条线从(0,0,0)开始,延伸到(0,0,L),其中 L 是我们希望沿着这条线走多远。

在清单 14-5 中,我们在原点沿 x 和 y 画了一条偏移的视线,这样我们可以看到它与放置在世界系统中心的一个盒子相交的位置(否则,它将完全垂直于我们的视图,因此很难看到)。

import processing.vr.*;

PMatrix3D mat = new PMatrix3D();

void setup() {
  fullScreen(STEREO);
  hint(ENABLE_STROKE_PERSPECTIVE);
}

void draw() {
  background(120);
  translate(width/2, height/2);
  lights();

  noStroke();
  pushMatrix();
  rotateY(millis()/1000.0);
  fill(#E3993E);
  box(150);
  popMatrix();

  eye();
  stroke(#2FB1EA);
  strokeWeight(50);
  line(100, -100, 0, 0, 0, 10000);
}

Listing 14-5.Drawing the Line of Sight

在这段代码中,我们还使用了ENABLE_STROKE_PERSPECTIVE提示,这样线条在远离眼睛时会变细(图 14-7 )。

A432415_1_En_14_Fig7_HTML.jpg

图 14-7。

Line of sight intersecting a box placed at the origin of coordinates Note

提示是渲染器的特殊设置,通过向hint()函数传递一个ENABLE_name常量来启用,通过传递相应的DISABLE_name常量来禁用。

我们也可以通过在眼睛坐标(0,0)处画一个点来显示屏幕中心的准确位置,就像我们在清单 14-6 中做的那样。任何穿过屏幕中心的 3D 形状都与视线相交,所以这给了我们另一种方式来指示用户可能正在看什么对象。

import processing.vr.*;

void setup() {
  fullScreen(STEREO);
}

void draw() {
  background(120);
  translate(width/2, height/2);

  lights();

  noStroke();
  fill(#E3993E);
  beginShape(QUAD);
  vertex(-75, -75);
  vertex(+75, -75);
  vertex(+75, +75);
  vertex(-75, +75);
  endShape(QUAD);

  eye();
  stroke(47, 177, 234, 150);
  strokeWeight(50);
  point(0, 0, 100);
}

Listing 14-6.Drawing a Circular Aim

我们用功能point()画的点画,可以通过用strokeWeight()设置权重,变得我们需要的那么大。它服务于“视图目标”的目的,用它来指向虚拟现实中的对象。图 14-8 显示了重量为 50 的目标。在下一节中,我们将学习如何确定一个 3D 点是否落在目标内。

A432415_1_En_14_Fig8_HTML.jpg

图 14-8。

View aim drawn with a point stroke

选择带有屏幕坐标的形状

正如我们刚刚看到的,确定 3D 空间中的顶点是否在我们的视线范围内的一个看似简单的方法是确定它的“屏幕坐标”是否足够靠近屏幕的中心。这种情况很容易通过在屏幕的正中心绘制一个视图来直观地检查,就像我们在清单 14-6 中所做的那样。然而,我们需要一种用代码检查条件的方法。Processing 有函数screenX()screenY(),它们允许我们这样做。这些函数将 3D 空间中某点的坐标(x,y,z)作为参数,并在投影到屏幕上时返回该点的屏幕坐标(sx,sy)。如果这些屏幕坐标足够接近(width/2,height/2),那么我们可以断定用户正在选择该形状。让我们在清单 14-7 中使用这种技术。

import processing.vr.*;

void setup() {
  fullScreen(STEREO);
}

void draw() {
  background(120);
  translate(width/2, height/2);
  lights();
  drawGrid();
  drawAim();
}

void drawGrid() {
  for (int i = 0; i < 4; i++) {
    for (int j = 0; j < 4; j++) {
      beginShape(QUAD);
      float x = map(i, 0, 3, -315, +315);
      float y = map(j, 0, 3, -315, +315);
      float sx = screenX(x, y, 0);
      float sy = screenY(x, y, 0);
      if (abs(sx - 0.5 * width) < 50 && abs(sy - 0.5 * height) < 50) {
        strokeWeight(5);
        stroke(#2FB1EA);
        if (mousePressed) {
          fill(#2FB1EA);
        } else {
          fill(#E3993E);
        }
      } else {
        noStroke();
        fill(#E3993E);
      }
      vertex(x - 100, y - 100);
      vertex(x + 100, y - 100);
      vertex(x + 100, y + 100);
      vertex(x - 100, y + 100);
      endShape(QUAD);
    }
  }
}

void drawAim() {
  eye();
  stroke(47, 177, 234, 150);
  strokeWeight(50);
  point(0, 0, 100);
}

Listing 14-7.Gaze Selection with Button Press

如果我们按下耳机中的按钮,变量mousePressed将被设置为true,允许我们确认正在查看的形状的选择,并高亮显示整个矩形,如图 14-9 所示。然而,如果耳机缺少一个按钮,我们需要一个不同的策略。我们可以通过查看形状一段特定的时间来确认选择,我们在清单 14-8 中就是这么做的(只显示了与前面清单不同的代码)。

A432415_1_En_14_Fig9_HTML.jpg

图 14-9。

Selecting a quad using screen coordinates

import processing.vr.*;

int seli = -1;
int selj = -1;
int startSel, selTime;
...
void drawGrid() {
  boolean sel = false;
  for (int i = 0; i < 4; i++) {
    for (int j = 0; j < 4; j++) {
      ...
      if (abs(sx - 0.5 * width) < 50 && abs(sy - 0.5 * height) < 50) {
        strokeWeight(5);
        stroke(#2FB1EA);
        if (seli == i && selj == j) {
          selTime = millis() - startSel;
        } else {
          startSel = millis();
          selTime = 0;
        }
        seli = i;
        selj = j;
        sel = true;
        if (2000 < selTime) {
          fill(#2FB1EA);
        } else {
          fill(#E3993E);
        }
      } else {
      ...
  }
  if (!sel) {
    seli = -1;
    selj = -1;
    selTime = 0;
  }
}

Listing 14-8.Gaze Selection with Staring Time

这里的想法是跟踪当前选择的矩形的索引,并且仅在selTime变量大于期望的阈值时确认选择,在这种情况下,阈值被设置为 2000 毫秒。

边界框选择

通过计算 3D 对象的屏幕坐标来选择 3D 对象的技术适用于简单的形状,并且在创建 UI 时非常有用。然而,当投影到屏幕平面上时,它可能不太适合具有不规则轮廓的更复杂的对象。

确定 3D 中对象选择的一种常用方法是边界框相交。边界框是完全包围给定 3D 对象的立方体。如果视线不与对象的边界框相交,我们可以确定该对象没有被选择,如果它被选择,我们可以选择它或执行更详细的测试。轴对齐边界框(AABB)是一种特殊类型的边界框,其边缘与坐标轴对齐。这一特性使得计算更简单、更快速,这在 VR 应用的环境中非常重要,我们可能需要测试数百甚至数千个边界框相交。3D 对象的 AABB 可以通过获取对象中顶点的 xyz 坐标的最小值和最大值并将其存储在一对向量中来轻松计算,这对向量完全确定了 AABB。

有许多算法可以用来测试一条直线与 AABB 的交点( http://www.realtimerendering.com/intersections.html) )。Amy Williams 及其合作者在 2005 年提出了一种高效且易于实现的方法( http://dl.acm.org/citation.cfm?id=1198748) )。在该算法中,我们需要提供定义 AABB 的最小和最大向量,以及沿线的一个点及其方向向量(在与视线相交的情况下,这些是眼睛位置和向前的向量)。问题是,如果我们对对象应用变换,它的边界框可能不再与轴对齐。我们可以通过对线应用逆变换来解决这一问题,使线和 AABB 的相对方向与我们对边界框应用变换时的方向相同。这个逆变换被编码在所谓的对象矩阵中,我们可以用getObjectMatrix()函数获得它。

正如我们已经指出的,这个算法需要眼睛位置和前向矢量。这些是我们之前使用eye()功能切换到眼睛坐标时使用的“眼睛矩阵”的一部分。为了获得这个矩阵的副本,我们还在处理 API 中使用了getEyeMatrix()。清单 14-9 通过将 Williams 的算法应用于一个盒子网格将所有这些放在一起(参见图 14-10 中的结果)。

A432415_1_En_14_Fig10_HTML.jpg

图 14-10。

Selecting a box with Williams’ algorithm

import processing.vr.*;

PMatrix3D eyeMat = new PMatrix3D();
PMatrix3D objMat = new PMatrix3D();
PVector cam = new PVector();
PVector dir = new PVector();
PVector front = new PVector();
PVector objCam = new PVector();
PVector objFront = new PVector();
PVector objDir = new PVector();
float boxSize = 140;
PVector boxMin = new PVector(-boxSize/2, -boxSize/2, -boxSize/2);
PVector boxMax = new PVector(+boxSize/2, +boxSize/2, +boxSize/2);
PVector hit = new PVector();

void setup() {
  fullScreen(PVR.STEREO);
}

void draw() {
  getEyeMatrix(eyeMat);
  cam.set(eyeMat.m03, eyeMat.m13, eyeMat.m23);
  dir.set(eyeMat.m02, eyeMat.m12, eyeMat.m22);
  PVector.add(cam, dir, front);
  background(120);
  translate(width/2, height/2);
  lights();
  drawGrid();
  drawAim();
}

void drawGrid() {
  for (int i = 0; i < 4; i++) {
    for (int j = 0; j < 4; j++) {
      float x = map(i, 0, 3, -350, +350);
      float y = map(j, 0, 3, -350, +350);
      pushMatrix();
      translate(x, y);
      rotateY(millis()/1000.0);
      getObjectMatrix(objMat);
      objMat.mult(cam, objCam);
      objMat.mult(front, objFront);
      PVector.sub(objFront, objCam, objDir);
      boolean res = intersectsLine(objCam, objDir, boxMin, boxMax,
                                   0, 1000, hit);
      if (res) {
        strokeWeight(5);
        stroke(#2FB1EA);
        if (mousePressed) {
          fill(#2FB1EA);
        } else {
          fill(#E3993E);
        }
      } else {
        noStroke();
        fill(#E3993E);
      }
      box(boxSize);
      popMatrix();
    }
  }
}

void drawAim() {
  eye();
  stroke(47, 177, 234, 150);
  strokeWeight(50);
  point(0, 0, 100);
}

boolean intersectsLine(PVector orig, PVector dir,
  PVector minPos, PVector maxPos, float minDist, float maxDist, PVector hit) {
  PVector bbox;
  PVector invDir = new PVector(1/dir.x, 1/dir.y, 1/dir.z);

  boolean signDirX = invDir.x < 0;
  boolean signDirY = invDir.y < 0;
  boolean signDirZ = invDir.z < 0;

  bbox = signDirX ? maxPos : minPos;
  float txmin = (bbox.x - orig.x) * invDir.x;
  bbox = signDirX ? minPos : maxPos;
  float txmax = (bbox.x - orig.x) * invDir.x;
  bbox = signDirY ? maxPos : minPos;
  float tymin = (bbox.y - orig.y) * invDir.y;
  bbox = signDirY ? minPos : maxPos;
  float tymax = (bbox.y - orig.y) * invDir.y;

  if ((txmin > tymax) || (tymin > txmax)) {
    return false;
  }
  if (tymin > txmin) {
    txmin = tymin;
  }
  if (tymax < txmax) {
    txmax = tymax;
  }

  bbox = signDirZ ? maxPos : minPos;
  float tzmin = (bbox.z - orig.z) * invDir.z;
  bbox = signDirZ ? minPos : maxPos;
  float tzmax = (bbox.z - orig.z) * invDir.z;

  if ((txmin > tzmax) || (tzmin > txmax)) {
    return false;
  }
  if (tzmin > txmin) {
    txmin = tzmin;
  }
  if (tzmax < txmax) {
    txmax = tzmax;
  }
  if ((txmin < maxDist) && (txmax > minDist)) {
    hit.x = orig.x + txmin * dir.x;
    hit.y = orig.y + txmin * dir.y;
    hit.z = orig.z + txmin * dir.z;
    return true;
  }
  return false;
}

Listing 14-9.AABB-Line of Sight Intersection

网格中的每个框都有不同的对象矩阵,因为变换是不同的(相同的旋转,但不同的平移)。一旦我们获得了物体矩阵,我们必须将它应用于眼睛位置和前向矢量,因为它们定义了我们想要与物体的 AABB 相交的线。我们通过矩阵向量乘法来应用变换。眼睛位置存储在 cam 变量中,直接用 objMat.mult(cam,objCam)变换到物体空间。但是,前向矢量是方向,不是位置,所以不能那样变换。相反,首先我们需要用 objMat.mult(front,objFront)变换前向量,它存储了沿视线从眼睛向前一个单位的点的位置,只有这样我们才能通过用 pvector sub(objFront,objCam,objDir)计算变换后的前位置和眼睛位置之间的差来计算物体坐标中的方向向量。

眼睛位置和前向向量在眼睛矩阵中被编码为其第三和第四列,因此我们可以获得矩阵的各个分量,(m02m12m22)和(m03m13m23),然后将它们分别复制到dircam向量中。

intersectsLine()函数保存了 Williams 算法的实现。它是完全独立的,所以我们可以在其他草图中重用它。请注意,除了根据直线是否与 AABB 相交返回 true 或 false 之外,该算法还会返回hit向量中交点的坐标,如果检测到几个交点,该坐标可用于确定离摄像机最近的交点。

虚拟现实中的运动

运动是任何虚拟现实体验的一个关键方面,我们需要仔细考虑,因为它受到一些约束和要求的影响。一方面,我们的目标是说服用户停止怀疑,沉浸在虚拟环境中。在这种环境中拥有一定程度的自由是很重要的。另一方面,这种虚拟运动不会完全符合我们的感官,这可能会导致晕动病,这是 VR 应用中要不惜一切代价避免的事情。相反,如果我们戴着谷歌虚拟现实耳机在物理空间中移动,我们会体验到视觉和身体感官之间的另一种脱节。

尽管有这些限制,我们仍然可以在 VR 空间中创建令人信服的运动。一个技巧是在视野中放置某种固定的参照物,与我们在物理空间中的静止状态相匹配。例如,在清单 14-10 中,我们为此加载了一个OBJ形状,将其放置在眼睛坐标中的相机位置。这个形状是一个十二面体(图 14-11 ),在我们通过虚拟现实进行导航时,它就像是一个“头盔”。

A432415_1_En_14_Fig11_HTML.jpg

图 14-11。

Using an OBJ shape as a reference in our field of vision

import processing.vr.*;

PShape frame;

void setup() {
  fullScreen(STEREO);
  frame = loadShape("dodecahedron.obj");
  prepare(frame, 500);
}

void draw() {
  background(180);
  lights();
  translate(width/2, height/2);
  eye();
  shape(frame);
}

void prepare(PShape sh, float s) {
  PVector min = new PVector(+10000, +10000, +10000);
  PVector max = new PVector(-10000, -10000, -10000);
  PVector v = new PVector();
  for (int i = 0; i < sh.getChildCount(); i++) {
    PShape child = sh.getChild(i);
    for (int j = 0; j < child.getVertexCount(); j++) {
      child.getVertex(j, v);
      min.x = min(min.x, v.x);
      min.y = min(min.y, v.y);
      min.z = min(min.z, v.z);
      max.x = max(max.x, v.x);
      max.y = max(max.y, v.y);
      max.z = max(max.z, v.z);
    }
  }
  PVector center = PVector.add(max, min).mult(0.5f);
  sh.translate(-center.x, -center.y, -center.z);
  float maxSize = max(sh.getWidth(), sh.getHeight(), sh.getDepth());
  float factor = s/maxSize;
  sh.scale(factor);
}

Listing 14-10.Drawing a Stationary Reference Object

prepare()函数将形状放在原点的中心,并将其缩放到与我们的场景尺寸相当的大小。这一步在加载OBJ文件时很重要,因为它们可能是用不同范围的坐标值定义的,所以它们可能看起来很小或很大。在这种情况下,我们放置十二面体形状,使其以(cameraXcameraYcameraZ)为中心,从而为我们在 VR 中的视觉提供参考。接下来我们将看到如何在适当的位置移动这个引用。

自动运动

在某些情况下,我们可以创建不受用户控制的运动,从而消除界面的复杂性。例如,如果目标是带用户通过预定的路径,或者在两个检查点之间转换,这可能是一个好的解决方案。

一旦我们构建了场景几何体,我们可以对其应用任何变换以创建运动,将它们包含在pushMatrix()popMatrix()之间,以防止变换影响相对于观察者固定的任何形状。清单 14-11 展示了如何模拟围绕圆形轨迹的旋转。

import processing.vr.*;

PShape frame;
PShape track;

public void setup() {
  fullScreen(STEREO);

  frame = loadShape("dodecahedron.obj");
  prepare(frame, 500);

  track = createShape();
  track.beginShape(QUAD_STRIP);
  track.fill(#2D8B47);
  for (int i = 0; i <= 40; i++) {
    float a = map(i, 0, 40, 0, TWO_PI);
    float x0 = 1000 * cos(a);
    float z0 = 1000 * sin(a);
    float x1 = 1400 * cos(a);
    float z1 = 1400 * sin(a);
    track.vertex(x0, 0, z0);
    track.vertex(x1, 0, z1);
  }
  track.endShape();
}

public void draw() {
  background(255);
  translate(width/2, height/2);

  directionalLight(200, 200, 200, 0, +1, -1);

  translate(1200, +300, 500);
  rotateY(millis()/10000.0);
  shape(track);

  eye();
  shape(frame);
}

void prepare(PShape sh, float s) {
...

Listing 14-11.Moving Along a Predefined Path

在这段代码中,我们将圆形轨迹存储在一个PShape对象中,将平移应用到相机的右侧,以便用户从轨迹的顶部开始,然后应用旋转来创建围绕轨迹中心的所需移动。该草图的结果如图 14-12 所示。

A432415_1_En_14_Fig12_HTML.jpg

图 14-12。

Using an OBJ shape as a reference in our field of vision

自由活动

与前面的例子不同,在前面的例子中,移动是预先定义的,用户只能四处张望,现在我们让用户在 VR 空间中自由漫游。这并不难实现;我们所需要的就是沿着正向向量平移场景中的物体,就像清单 14-12 中所做的那样。然而,这里我们第一次使用了calculate()函数,这是 VR 草图中的一个重要函数,它让我们可以运行每帧只需执行一次的计算。

import processing.vr.*;

PShape frame;
PShape cubes;
PMatrix3D eyeMat = new PMatrix3D();
float tx, ty, tz;
float step = 5;

public void setup() {
  fullScreen(STEREO);

  frame = loadShape("dodecahedron.obj");
  prepare(frame, 500);

  cubes = createShape(GROUP);
  float v = 5 * width;
  for (int i = 0; i < 50; i++) {
    float x = random(-v, +v);
    float y = random(-v, +v);
    float z = random(-v, +v);
    float s = random(100, 200);
    PShape sh = createShape(BOX, s);
    sh.setFill(color(#74E0FF));
    sh.translate(x, y, z);
    cubes.addChild(sh);
  }
}

void calculate() {
  getEyeMatrix(eyeMat);
  if (mousePressed) {
    tx -= step * eyeMat.m02;
    ty -= step * eyeMat.m12;
    tz -= step * eyeMat.m22;
  }
}

public void draw() {
  background(255);
  translate(width/2, height/2);

  directionalLight(200, 200, 200, 0, +1, -1);

  translate(tx, ty, tz);
  shape(cubes);

  eye();
  shape(frame);
}

void prepare(PShape sh, float s) {
...

Listing 14-12.Moving Freely in VR Space

在每一帧中,calculate()函数只被调用一次,就在draw()被调用两次之前,每只眼睛调用一次。这在这个例子中很有用,因为如果我们将翻译代码放在draw()中,我们将增加两倍的翻译量,导致不正确的翻译。重要的是,我们要考虑哪些操作应该在draw()内部完成——通常是任何与绘图相关的内容——哪些操作应该在calculate()内部完成,比如以同样方式影响左右视图的代码。

虚拟现实空间中完全无界运动的一个问题是,它可能会让许多人迷失方向。一个更容易处理的情况是将运动限制在 XZ 平面上。这可以像以前一样用正向矢量来完成,但是你只能用它的 x 和 z 分量来更新平移,如清单 14-13 所示。

import processing.vr.*;

PShape cubes;
PShape grid;
PMatrix3D eyeMat = new PMatrix3D();
float tx, tz;
float step = 10;
PVector planeDir = new PVector();

public void setup() {
  fullScreen(STEREO);

  grid = createShape();
  grid.beginShape(LINES);
  grid.stroke(255);
  for (int x = -10000; x < +10000; x += 500) {
    grid.vertex(x, +200, +10000);
    grid.vertex(x, +200, -10000);
  }
  for (int z = -10000; z < +10000; z += 500) {
    grid.vertex(+10000, +200, z);
    grid.vertex(-10000, +200, z);
  }
  grid.endShape();

  cubes = createShape(GROUP);
  float v = 5 * width;
  for (int i = 0; i < 50; i++) {
    float x = random(-v, +v);
    float z = random(-v, +v);
    float s = random(100, 300);
    float y = +200 - s/2;
    PShape sh = createShape(BOX, s);
    sh.setFill(color(#FFBC6A));
    sh.translate(x, y, z);
    cubes.addChild(sh);
  }
}

void calculate() {
  getEyeMatrix(eyeMat);
  if (mousePressed) {
    planeDir.set(eyeMat.m02, 0, eyeMat.m22);
    float d = planeDir.mag();
    if (0 < d) {
      planeDir.mult(1/d);
      tx -= step * planeDir.x;
      tz -= step * planeDir.z;
    }
  }
}

public void draw() {
  background(0);
  translate(width/2, height/2);
  pointLight(50, 50, 200, 0, 1000, 0);
  directionalLight(200, 200, 200, 0, +1, -1);
  translate(tx, 0, tz);
  shape(grid);
  shape(cubes);
}

Listing 14-13.Moving in a 2D Plane

calculate()函数中,我们从眼睛矩阵的m02m22分量构建一个平面方向向量。我们需要对这个向量进行归一化,以确保我们在移动时保持均匀的步幅,即使我们在向上看,向前的向量在 x 和 z 轴上的坐标非常小。该草图的视图如图 14-13 所示。

A432415_1_En_14_Fig13_HTML.jpg

图 14-13。

Movement constrained to a 2D plane

摘要

虚拟现实带来了令人兴奋的新可能性以及有趣的挑战。在这一章中,我们学习了一些处理技术来应对这些挑战,并探索虚拟现实的可能性可以带我们去哪里。特别是,我们讨论了直观的交互和运动是如何创造引人入胜的虚拟现实体验的关键。

十五、在虚拟现实中绘图

在这最后一章中,我们将一步一步地使用 Android 的处理来开发一个全功能的 VR 绘图应用。我们将应用到目前为止我们所学的所有技术,包括凝视控制的移动和用户界面(UI)。

创造成功的虚拟现实体验

在前面的章节中,我们学习了 3D API 在处理方面的基础知识,以及一些我们可以用来在 VR 中创建交互式图形的技术。创造一个成功的虚拟现实体验是一个令人兴奋的挑战。与“传统的”计算机图形相反,在“传统的”计算机图形中,我们可以依赖用户熟悉的表示(例如,透视图与等轴视图)和交互约定(例如,基于鼠标或触摸的手势),VR 是一种新的媒体,它提供了许多可能性,但也带来了独特的约束和限制。

可以说,虚拟现实的一个中心目标是让用户暂停怀疑,沉浸在虚拟空间中,至少是一小会儿,即使图形不是照片般逼真或交互有限。虚拟现实创造了一种不寻常的体验,那就是置身于一个没有身体的合成 3D 空间中。最近在技术和游戏展上演示的输入硬件(例如,在虚拟现实中骑自行车的自行车支架,产生触摸幻觉的气压,甚至低放电)说明了体现虚拟现实体验的持续努力。

我们还需要了解 Android 处理所支持的 Cardboard 和 Daydream 平台的具体特征。由于我们是用智能手机生成图形,所以它们比由 PC 驱动的 VR 设备生成的图形更受限制。首先,重要的是要确保手机能够处理我们场景的复杂性,并能保持平稳的帧率。否则,断断续续的动画会导致用户头晕和恶心。第二,纸板耳机的交互输入有限,通常只有一个按钮。此外,由于我们需要用双手握住它们(见图 15-1 ),因此我们不能依赖外部输入设备。我们的 VR 应用中的交互应该考虑到体验的所有这些方面。

A432415_1_En_15_Fig1_HTML.jpg

图 15-1。

Students using Google Cardboard during class activities

在虚拟现实中绘图

在虚拟现实中创建 3D 对象可能是一项非常有趣的活动,我们不仅可以不受屏幕或物理定律的限制来塑造人物,还可以与我们的虚拟作品处于同一空间。Google Tilt Brush(图 15-2 )是一个很好的例子,说明了一个设计良好的 VR 体验是如何变得极具沉浸感和趣味性的。受这些想法的启发,实现我们自己的 Cardboard/Daydream 的绘图 VR app 岂不是一个很好的练习?

A432415_1_En_15_Fig2_HTML.jpg

图 15-2。

Google Tilt Brush VR drawing app

正如我们刚刚讨论的,我们将不得不处理更有限的图形和交互能力。如果我们假设没有可用的输入设备,并且我们只有一个按钮来进行单次按压,那么我们基本上只能将凝视作为我们的铅笔来使用。出于这个原因,前一章中使用视线在 VR 空间中选择 3D 元素的一些技术将会派上用场。

初始草图

虚拟现实绘画的一个很好的类比是雕刻。根据这个类比,我们可以从一个基座或讲台开始,在其上我们将创建我们的 VR 绘图/雕塑,当我们需要改变我们工作的角度时,用一些 UI 控件来旋转它。我们必须记住,谷歌虚拟现实不会跟踪位置的变化,只会跟踪头部的旋转,这不足以从所有可能的角度进行 3D 绘图。图 15-3 为应用勾勒出一个纸笔概念。

A432415_1_En_15_Fig3_HTML.jpg

图 15-3。

Pen-and-paper concept sketch for our VR drawing app

我们的视线到达讲台上方空间的点可能是我们的铅笔尖。关键的细节是在不干扰用户界面的情况下,将这支铅笔与我们头部的运动联系起来。我们可以使用按钮按压作为启用/禁用绘图的机制,因此当我们不按按钮时,我们可以自由地移动我们的头部并与 UI 交互。

因此,该应用可以如下工作:当我们处于绘画模式时,我们停留在静态的有利位置,从那里我们在讲台上绘画。我们可以添加一个额外的沉浸元素,让用户在绘画完成后自由移动,这样就可以从不同寻常的角度观看。我们在前一章已经看到了如何在 VR 中实现自由移动,所以我们也可以在我们的应用中使用这种技术。

一个简单的虚拟现实界面

让我们首先创建一个应用的初始版本,它只有绘图的基础和一些初始的 UI 元素,但还没有实际的绘图功能。作为清单 15-1 的临时占位符,我们显示了一个虚拟形状,我们可以使用 UI 旋转它。

import processing.vr.*;

PShape base;

void setup() {
  fullScreen(STEREO);
  createBase(300, 70, 20);
}

void draw() {
  background(0);
  translate(width/2, height/2);
  directionalLight(200, 200, 200, 0, +1, -1);
  drawBase();
  drawBox();
}

void drawBase() {
  pushMatrix();
  translate(0, +300, 0);
  shape(base);
  popMatrix();
}

void drawBox() {
  pushMatrix();
  translate(0, +100, 0);
  noStroke();
  box(200);
  popMatrix();
}

void createBase(float r, float h, int ndiv) {
  base = createShape(GROUP);
  PShape side = createShape();
  side.beginShape(QUAD_STRIP);
  side.noStroke();
  side.fill(#59C5F5);
  for (int i = 0; i <= ndiv; i++) {
    float a = map(i, 0, ndiv, 0, TWO_PI);
    float x = r * cos(a);
    float z = r * sin(a);
    side.vertex(x, +h/2, z);
    side.vertex(x, -h/2, z);
  }
  side.endShape();
  PShape top = createShape();
  top.beginShape(TRIANGLE_FAN);
  top.noStroke();
  top.fill(#59C5F5);
  top.vertex(0, 0, 0);
  for (int i = 0; i <= ndiv; i++) {
    float a = map(i, 0, ndiv, 0, TWO_PI);
    float x = r * cos(a);
    float z = r * sin(a);
    top.vertex(x, -h/2, z);
  }
  top.endShape();
  base.addChild(side);
  base.addChild(top);
}

Listing 15-1.Starting Point of Our Drawing App

底座只是一个存储在PShape中的圆柱体,加上一个用作顶面的椭圆。正如我们之前看到的,我们可以在一个组中存储不同的处理形状,并且为了绘制这个基础形状,我们只需要得到的PShape组。图 15-4 展示了这个应用的第一个版本。

A432415_1_En_15_Fig4_HTML.jpg

图 15-4。

First step in our VR drawing app: a base shape and a dummy object

作为 UI 的第一次迭代,我们将添加三个按钮:两个用于沿 y 轴旋转底座和盒子,另一个用于重置旋转。清单 15-2 实现了这个初始 UI(省略了createBase(), drawBase(),drawBox()函数,因为它们与前面的代码相同)。

import processing.vr.*;

PShape base;
float angle;
Button leftButton, rightButton, resetButton;

void setup() {
  fullScreen(STEREO);
  textureMode(NORMAL);
  createBase(300, 70, 20);
  createButtons(300, 100, 380, 130);
}

void calculate () {
  if (mousePressed) {
    if (leftButton.selected) angle -= 0.01;
    if (rightButton.selected) angle += 0.01;
    if (resetButton.selected) angle = 0;
  }
}

void draw() {
  background(0);
  translate(width/2, height/2);
  directionalLight(200, 200, 200, 0, +1, -1);
  drawBase();
  drawBox();
  drawUI();
}
...
void createButtons(float dx, float hlr, float ht, float s) {
  PImage left = loadImage("left-icon.png");
  leftButton = new Button(-dx, hlr, 0, s, left);
  PImage right = loadImage("right-icon.png");
  rightButton = new Button(+dx, hlr, 0, s, right);
  PImage cross = loadImage("cross-icon.png");
  resetButton = new Button(0, +1.0 * ht, +1.1 * dx, s, cross);
}

void drawUI() {
  leftButton.display();
  rightButton.display();
  resetButton.display();
  drawAim();
}

void drawAim() {
  eye();
  pushStyle();
  stroke(220, 180);
  strokeWeight(20);
  point(0, 0, 100);
  popStyle();
}

boolean centerSelected(float d) {
  float sx = screenX(0, 0, 0);
  float sy = screenY(0, 0, 0);
  return abs(sx - 0.5 * width) < d && abs(sy - 0.5 * height) < d;
}

class Button {
  float x, y, z, s;
  boolean selected;
  PImage img;

  Button(float x, float y, float z, float s, PImage img) {
    this.x = x;
    this.y = y;
    this.z = z;
    this.s = s;
    this.img = img;
  }

  void display() {
    float l = 0.5 * s;
    pushStyle();
    pushMatrix();
    translate(x, y, z);
    selected = centerSelected(l);
    beginShape(QUAD);
    if (selected) {
      stroke(220, 180);
      strokeWeight(5);
    } else {
      noStroke();
    }
    tint(#59C5F5);
    texture(img);
    vertex(-l, +l, 0, 1);
    vertex(-l, -l, 0, 0);
    vertex(+l, -l, 1, 0);
    vertex(+l, +l, 1, 1);
    endShape();
    popMatrix();
    popStyle();
  }
}

Listing 15-2.Adding a Basic UI

封装选择功能的Button类是这段代码的核心元素。它的构造函数接受五个参数:按钮中心的(x,y,z)坐标、大小和用作按钮纹理的图像。在display()方法的实现中,我们通过使用上一章的屏幕坐标技术来确定按钮是否被选中:如果(screenX,screenY)足够靠近屏幕中心,那么它被认为是被选中的,在这种情况下,按钮得到一条笔划线。UI 事件在calculate()方法中触发,以避免在检测到“鼠标”按下(对应于 VR 头戴式耳机中可用的物理触发器)事件时再次应用它们。

按钮的位置由createButtons()功能决定,左右旋转按钮位于底座两侧,复位按钮位于稍下方。我们还在眼睛坐标中的(0,0,100)处绘制了一个笔划点,作为帮助绘制和选择的目标。

此时,再次回顾Button类中的display()函数是很重要的,在这里,除了绘制按钮,我们还测试它是否被选中。这看起来可能是放置该逻辑的错误位置,因为显示功能应该只处理绘图任务。事实证明,screenX()screenY()调用要求影响按钮的 3D 变换是当前的,否则它们将返回不正确的结果。因为在绘制几何图形时应用了转换,所以我们也在该阶段执行交互检测。这个草图的结果应该如图 15-5 所示。

A432415_1_En_15_Fig5_HTML.jpg

图 15-5。

Adding buttons to the UI

三维绘图

当我们在第二章中实现绘图应用时,我们只需要担心在 2D 画笔画。这很容易,感谢处理中的pmouseX/YmouseX/Y变量,它们允许我们在先前和当前鼠标位置之间画一条线。在三维空间中,想法实际上是一样的:笔画是连续位置之间的一系列线条,不再局限于屏幕平面内。但是如果我们在 VR 空间中没有实际的 3D 指针,我们需要仅从凝视信息来推断 3D 空间中的方向性。

我们知道我们凝视的方向包含在向前的向量中,它会自动更新以反映任何头部运动。如果在每一帧,我们向场景的中心投射一个固定量的前向向量,我们将有一个滑动点,可以在我们的绘图中生成笔划。事实上,这与我们为 2D 绘画所做的没有太大的不同,在那里笔画是由(mouseX, mouseY)位置的序列定义的。我们还可以计算当前矢量和前一个矢量之间的差值,类似于 2D 的矢量(mouseX – pmouseX, mouseY – pmouseY),以确定我们是否需要在绘图中添加新的点。图 15-6 显示了图中差矢量与相应位移之间的关系。

A432415_1_En_15_Fig6_HTML.jpg

图 15-6。

Calculating displacement using previous and current forward vectors

3D 和 2D 情况之间的一个重要区别是,在后者中,我们不需要跟踪所有过去的位置,只需要跟踪先前的位置。这是因为如果我们不使用background()清除屏幕,我们可以简单地在已经绘制的线条上添加最后一条线条。但是在 3D 中,我们必须在每一帧刷新屏幕,因为摄像机的位置不是静态的,所以场景需要不断更新。这意味着,从绘图中记录的第一个位置开始,所有过去的行都必须在每一帧中重新绘制。为此,我们需要将所有位置存储在一个数组中。

然而,如果我们想在用户停止按下耳机中的触发器时中断笔画,那么将绘图中的所有位置存储在一个数组中是不够的。我们还需要保存中断发生的地方。一种可能性是将每个连续的笔划存储在一个单独的数组中,并拥有一个包含所有过去笔划的数组。

有了这些想法,我们就可以开始工作了。因为草图变得相当复杂,所以最好将它分成单独的选项卡,每个选项卡中有相关的代码。例如,我们可以有如图 15-7 所示的标签结构。

A432415_1_En_15_Fig7_HTML.jpg

图 15-7。

Tabs to organize our increasingly complex VR drawing sketch

让我们分别检查每个选项卡。清单 15-3A 所示的主选项卡包含标准的setup()calculate()draw()mouseReleased()功能。我们从主选项卡调用的其余功能在其他选项卡中实现。

import processing.vr.*;

float angle;

void setup() {
  fullScreen(STEREO);
  textureMode(NORMAL);
  createBase(300, 70, 20);
  createButtons(300, 100, 380, 130);
}

void calculate() {
  if (mousePressed) {
    if (leftButton.selected) angle -= 0.01;
    if (rightButton.selected) angle += 0.01;
  }
  if (mousePressed && !selectingUI()) {
    updateStrokes();
  }
}

void draw() {
  background(0);
  translate(width/2, height/2);
  directionalLight(200, 200, 200, 0, +1, -1);
  drawBase();
  drawStrokes();
  drawUI();
}

void mouseReleased() {
  if (resetButton.selected) {
    clearDrawing();
    angle = 0;
  } else {
    startNewStroke();
  }
}

Listing 15-3A.Main Tab

关于鼠标事件处理程序的一些重要观察。旋转角度的更新保留在calculate()中,因为这允许我们通过以 0.01 步增加/减少角度来连续旋转场景,只要我们一直按下耳机上的触发按钮。相比之下,mousePressed()/mouseReleased()处理程序仅在按下事件开始或结束时调用,因此只要按钮处于按下状态,它们就不能用于执行某些任务。但是,这种行为对于实现只应在按下或释放按钮时执行的任务很有用。清除绘图就是这种任务的一个例子,这就是为什么clearDrawing()被放在mouseReleased()里面的原因。

Note

加工草图中的选项卡结构完全是可选的,不影响草图的运行方式。它允许我们组织代码,使其更具可读性。

转到清单 15-3B 中的绘图选项卡,我们可以检查在updateStrokes()中向当前笔划添加新位置的代码,并通过用drawStrokes()中的线连接所有连续位置来绘制当前和先前的笔划。

ArrayList<PVector> currentStroke = new ArrayList<PVector>();
ArrayList[] previousStrokes = new ArrayList[0];

PMatrix3D eyeMat = new PMatrix3D();
PMatrix3D objMat = new PMatrix3D();
PVector pos = new PVector();
PVector pforward = new PVector();
PVector cforward = new PVector();

void updateStrokes() {
  translate(width/2, height/2);
  rotateY(angle);
  getEyeMatrix(eyeMat);
  float cameraX = eyeMat.m03;
  float cameraY = eyeMat.m13;
  float cameraZ = eyeMat.m23;
  float forwardX = eyeMat.m02;
  float forwardY = eyeMat.m12;
  float forwardZ = eyeMat.m22;
  float depth = dist(cameraX, cameraY, cameraZ, width/2, height/2, 0);
  cforward.x = forwardX;
  cforward.y = forwardY;
  cforward.z = forwardZ;
  if (currentStroke.size() == 0 || 0 < cforward.dist(pforward)) {
    getObjectMatrix(objMat);
    float x = cameraX + depth * forwardX;
    float y = cameraY + depth * forwardY;
    float z = cameraZ + depth * forwardZ;
    pos.set(x, y, z);
    PVector tpos = new PVector();
    objMat.mult(pos, tpos);
    currentStroke.add(tpos);
  }
  pforward.x = forwardX;
  pforward.y = forwardY;
  pforward.z = forwardZ;
}

void drawStrokes() {
  pushMatrix();
  rotateY(angle);
  strokeWeight(5);
  stroke(255);
  drawStroke(currentStroke);
  for (ArrayList p: previousStrokes) drawStroke(p);
  popMatrix();
}

void drawStroke(ArrayList<PVector> positions) {
  for (int i = 0; i < positions.size() - 1; i++) {
    PVector p = positions.get(i);
    PVector p1 = positions.get(i + 1);
    line(p.x, p.y, p.z, p1.x, p1.y, p1.z);
  }
}

void startNewStroke() {
  previousStrokes = (ArrayList[]) append(previousStrokes, currentStroke);
  currentStroke = new ArrayList<PVector>();
}

void clearDrawing() {
  previousStrokes = new ArrayList[0];
  currentStroke.clear();
}

Listing 15-3B.
Drawing Tab

这个选项卡中有许多变量,从保存当前笔画位置的PVector对象的数组列表开始,还有一个数组列表的数组,其中每个列表都是一个完整的笔画。一旦检测到鼠标释放事件(在主选项卡中定义的mouseReleased()函数中),调用startNewStroke()函数将当前笔划附加到先前笔划的数组中,并为下一个笔划初始化一个空数组列表。

其余的变量用于计算当前笔划的新位置。该代码基于我们之前关于将正向向量扩展预定义量depth的讨论(图 15-6 )。这将“铅笔尖”放在绘图底部的正上方,因为depth是相机和场景中心之间的距离。不应该忽视updateStrokes()中对象矩阵objMat的使用。有必要确保正确绘制笔划,即使存在应用于场景的变换(在这种情况下,如围绕 y 的平移和旋转)。注意我们如何在updateStrokes()的开头应用这些转换。即使从calculate()调用updateStrokes(),它不做任何绘制,我们仍然需要应用我们稍后在draw()中使用的相同变换,以确保我们用getObjectMatrix()检索的矩阵将应用笔画顶点上的所有变换。

在清单 15-3C 中看到的 UI 标签中,我们有所有的Button类的定义和所有我们目前在界面中使用的按钮对象。

Button leftButton, rightButton, resetButton;

void createButtons(float dx, float hlr, float ht, float s) {
  PImage left = loadImage("left-icon.png");
  leftButton = new Button(-dx, hlr, 0, s, left);
  PImage right = loadImage("right-icon.png");
  rightButton = new Button(+dx, hlr, 0, s, right);
  PImage cross = loadImage("cross-icon.png");
  resetButton = new Button(0, +1.0 * ht, +1.1 * dx, s, cross);
}

void drawUI() {
  leftButton.display();
  rightButton.display();
  resetButton.display();
  drawAim();
}

void drawAim() {
  eye();
  pushStyle();
  stroke(220, 180);
  strokeWeight(20);
  point(0, 0, 100);
  popStyle();
}

boolean selectingUI() {
  return leftButton.selected || rightButton.selected ||
         resetButton.selected;
}

boolean centerSelected(float d) {
  float sx = screenX(0, 0, 0);
  float sy = screenY(0, 0, 0);
  return abs(sx - 0.5 * width) < d && abs(sy - 0.5 * height) < d;
}

class Button {
  float x, y, z, s;
  boolean selected;
  PImage img;

  Button(float x, float y, float z, float s, PImage img) {
    this.x = x;
    this.y = y;
    this.z = z;
    this.s = s;
    this.img = img;
  }

  void display() {
    float l = 0.5 * s;
    pushStyle();
    pushMatrix();
    translate(x, y, z);
    selected = centerSelected(l);
    beginShape(QUAD);
    if (selected) {
      stroke(220, 180);
      strokeWeight(5);
    } else {
      noStroke();
    }
    tint(#59C5F5);
    texture(img);
    vertex(-l, +l, 0, 1);
    vertex(-l, -l, 0, 0);
    vertex(+l, -l, 1, 0);
    vertex(+l, +l, 1, 1);
    endShape();
    popMatrix();
    popStyle();
  }
}

Listing 15-3C.
UI Tab

清单 15-3D 中显示的 Geo 选项卡暂时只包含创建和绘制基础的代码,与之前的一样。

PShape base;

void drawBase() {
  pushMatrix();
  translate(0, +300, 0);
  rotateY(angle);
  shape(base);
  popMatrix();
}

void createBase(float r, float h, int ndiv) {
  base = createShape(GROUP);
  PShape side = createShape();
  side.beginShape(QUAD_STRIP);
  side.noStroke();
  side.fill(#59C5F5);
  for (int i = 0; i <= ndiv; i++) {
    float a = map(i, 0, ndiv, 0, TWO_PI);
    float x = r * cos(a);
    float z = r * sin(a);
    side.vertex(x, +h/2, z);
    side.vertex(x, -h/2, z);
  }
  side.endShape();
  PShape top = createShape();
  top.beginShape(TRIANGLE_FAN);
  top.noStroke();
  top.fill(#59C5F5);
  top.vertex(0, 0, 0);
  for (int i = 0; i <= ndiv; i++) {
    float a = map(i, 0, ndiv, 0, TWO_PI);
    float x = r * cos(a);
    float z = r * sin(a);
    top.vertex(x, -h/2, z);
  }
  top.endShape();
  base.addChild(side);
  base.addChild(top);
}

Listing 15-3D.
Geo Tab

做完这些工作,我们应该有一个 VR 的工作绘图 app 了!我们可以在硬纸板或 Daydream 耳机上试用它,如果一切顺利,我们将能够使用它来创建线条画,如图 15-8 所示。

A432415_1_En_15_Fig8_HTML.jpg

图 15-8。

Our VR drawing app in action!

到处飞

使用当前形式的 VR 绘图应用,我们可以用我们的目光指引笔画,并围绕水平方向旋转绘图,以从不同角度添加新的笔画。尽管这应该给我们的用户提供了很多可以玩的东西,我们仍然可以在许多不同的方面改进这个应用。

到目前为止,一个限制是我们停留在绘画讲台前的一个固定位置。例如,虽然我们可以通过移动我们的头部和绕水平轴旋转图形来改变我们的视点,但是我们不能更接近它。我们在前一章中看到了如何在 VR 中实现自由移动,因此我们可以在应用中应用该代码来实现这一功能。

因为我们想通过绘图创建一个飞越,所以当我们四处移动时,我们可以在我们的视图前面添加一对动画翅膀。这些翅膀,相对于我们的位置固定,将提供一个视觉参考,以帮助用户不感到迷失方向。

我们将回顾我们应该引入到先前版本草图的标签中的所有变化。让我们从清单 15-4A 中的主选项卡开始。

import processing.vr.*;

float angle;
boolean flyMode = false;
PVector flyStep = new PVector();

void setup() {
  fullScreen(STEREO);
  textureMode(NORMAL);
  createBase(300, 70, 20);
  createButtons(300, 100, 380, 130);
}

void calculate() {
  if (mousePressed) {
    if (leftButton.selected) angle -= 0.01;
    if (rightButton.selected) angle += 0.01;
    if (flyMode) {
      getEyeMatrix(eyeMat);
      flyStep.add(2 * eyeMat.m02, 2 * eyeMat.m12, 2 * eyeMat.m22);
    }
  }
  if (mousePressed && !selectingUI() && !flyMode) {
    updateStrokes();
  }
}

void draw() {
  background(0);
  translate(width/2, height/2);
  ambientLight(40, 40, 40);
  directionalLight(200, 200, 200, 0, +1, -1);
  translate(-flyStep.x, -flyStep.y, -flyStep.z);
  drawBase();
  drawStrokes();
  if (flyMode) drawWings();
  drawUI();
}

void mouseReleased() {
  if (resetButton.selected) {
    clearDrawing();
    angle = 0;
  } else if (flyToggle.selected) {
    flyToggle.toggle();
    if (flyToggle.state == 0) {
      flyMode = false;
      flyStep.set(0, 0, 0);
    } else {
      flyMode = true;
    }
  } else {
    startNewStroke();
  }
}

Listing 15-4A.Main Tab with Fly Mode Modifications

我们引入了几个额外的变量:一个flyMode布尔变量来跟踪我们是否处于飞行模式,以及实际的位移向量flyStep,当耳机按钮被按下时,我们通过沿着前进向量前进来更新它。此外,我们添加了一个环境光,这样即使翅膀没有接收到来自定向光源的直射光,它们也是可见的。

我们还必须在mouseReleased()中添加一些额外的交互处理。问题是,现在我们需要另一个 UI 元素来在正常的绘制模式和新的飞行模式之间切换。我们通过实现一个专门的切换按钮来实现这一点,该按钮有两个可选的图像来指示我们可以切换到哪个模式。这个切换按钮的位置不明显;当我们启动应用时,它可能就在我们眼前,但如果我们处于飞行模式,它就不可见,我们最终会迷失在虚拟现实中的某个地方。如果这个按钮在我们做一些特定的手势时总是可见的话会更好;比如抬头。如果切换按钮不受飞行运动的影响,并且始终位于摄像机位置的正上方,我们就可以实现这一点。清单 15-4B 中的代码就是这样做的。

Button leftButton, rightButton, resetButton;
Toggle flyToggle;

void createButtons(float dx, float hlr, float ht, float s) {
  ...
  PImage fly = loadImage("fly-icon.png");
  PImage home = loadImage("home-icon.png");
  flyToggle = new Toggle(-ht, s, fly, home);
}

void drawUI() {
  leftButton.display();
  rightButton.display();
  resetButton.display();
  noLights();
  flyToggle.display();
  if (!flyMode) drawAim();
}
...
boolean selectingUI() {
  return leftButton.selected || rightButton.selected ||
         resetButton.selected || flyToggle.selected;
}
...
class Toggle {
  float h, s;
  boolean selected;
  int state;
  PImage[] imgs;
  color[] colors;

  Toggle(float h, float s, PImage img0, PImage img1) {
    this.h = h;
    this.s = s;
    imgs = new PImage[2];
    imgs[0] = img0;
    imgs[1] = img1;
    colors = new color[2];
    colors[0] = #F2674E;
    colors[1] = #59C5F5;
  }

  void display() {
    float l = 0.5 * s;
    pushStyle();
    pushMatrix();
    getEyeMatrix(eyeMat);
    translate(eyeMat.m03 + flyStep.x - width/2,
              eyeMat.m13 + h + flyStep.y - height/2,
              eyeMat.m23 + flyStep.z);
    selected = centerSelected(l);
    beginShape(QUAD);
    if (selected) {
      stroke(220, 180);
      strokeWeight(5);
    } else {
      noStroke();
    }
    tint(colors[state]);
    texture(imgs[state]);
    vertex(-l, 0, +l, 0, 0);
    vertex(+l, 0, +l, 1, 0);
    vertex(+l, 0, -l, 1, 1);
    vertex(-l, 0, -l, 0, 1);
    endShape();
    popMatrix();
    popStyle();
  }

  void toggle() {
    state = (state + 1) % 2;
  }
}

Listing 15-4B.UI Tab

with Fly Mode Modifications

Toggle类类似于Button,但是它有两个图像纹理,每个图像对应一个切换状态。我们可以通过平移to (eyeMat.m03 + flyStep.x - width/2, eyeMat.m13 + h + flyStep.y - height/2, eyeMat.m23 + flyStep.z)使切换按钮总是在用户上方,这取消了我们在draw()中应用的平移,所以它被精确地放置在(eyeMat.m03, eyeMat.m13 + h, eyeMat.m23),相机坐标加上沿垂直方向的位移h

最后,清单 15-4C 显示了我们在飞行模式下绘制的动画翅膀的代码。几何体非常简单:两个较大的旋转四边形用于翅膀,两个较小的矩形用于创建身体。

...
void drawWings() {
  pushMatrix();
  eye();

  translate(0, +50, 100);
  noStroke();
  fill(#F2674E);

  beginShape(QUAD);
  vertex(-5, 0, -50);
  vertex(+5, 0, -50);
  vertex(+5, 0, +50);
  vertex(-5, 0, +50);
  endShape();

  pushMatrix();
  translate(-5, 0, 0);
  rotateZ(map(cos(millis()/1000.0), -1, +1, -QUARTER_PI, +QUARTER_PI));
  beginShape(QUAD);
  vertex(-100, 0, -50);
  vertex(   0, 0, -50);
  vertex(   0, 0, +50);
  vertex(-100, 0, +50);
  endShape();
  popMatrix();

  pushMatrix();
  translate(+5, 0, 0);
  rotateZ(map(cos(millis()/1000.0), -1, +1, +QUARTER_PI, -QUARTER_PI));
  beginShape(QUAD);
  vertex(+100, 0, -50);
  vertex(   0, 0, -50);
  vertex(   0, 0, +50);
  vertex(+100, 0, +50);
  endShape();
  popMatrix();

  popMatrix();
}

Listing 15-4C.
Drawing Tab

with Fly Mode Modifications

有了这些附加功能,我们可以切换到飞行模式来快速浏览我们的绘图,并切换回默认的绘图模式来继续绘图或开始一个新的绘图,我们可以在图 15-9 中看到一系列的步骤。

A432415_1_En_15_Fig9_HTML.jpg

图 15-9。

Transition between draw and fly modes

最终调整和打包

我们已经有了一个简单但功能齐全的 VR 绘图应用!在这个过程中,我们遇到了 VR 开发特有的挑战:构建沉浸式 3D 环境,添加可以单独使用凝视访问的 UI 元素,以及在 VR 中自由移动。我们的应用利用一些技术来解决这些挑战,我们应该在未来的 VR 项目中进一步探索。目前,我们只需要做一些最后的调整,就可以在 Play Store 上发布绘图应用了。

介绍文本

当用户第一次打开我们的应用时,我们不能指望他们知道该做什么,所以一个好主意是提供一个介绍来解释体验的机制。我们应该让这个介绍尽可能简短,因为大多数用户不希望经历非常冗长或复杂的说明,一个成功的 VR 体验应该尽可能不言自明。

我们可以在眼睛坐标中绘制介绍页面,这样它就面向用户,而不考虑他们的头部位置,当用户按下耳机按钮继续时,它就会消失。清单 15-5 显示了实现一个简单介绍的附加代码,结果如图 15-10 所示。

A432415_1_En_15_Fig10_HTML.jpg

图 15-10。

Intro screen with some instructions on how to use the app

import processing.vr.*;

float angle;
boolean flyMode = false;
PVector flyStep = new PVector();
boolean showingIntro = true;

void setup() {
  fullScreen(STEREO);
  textureMode(NORMAL);
  textFont(createFont("SansSerif", 30));
  textAlign(CENTER, CENTER);
  ...
}
...
void mouseReleased() {
  if (showingIntro) {
    showingIntro = false;
  } else if (resetButton.selected) {
  ...
}
...
void drawUI() {
  leftButton.display();
  rightButton.display();
  resetButton.display();
  noLights();
  flyToggle.display();
  if (showingIntro) drawIntro();
  else if (!flyMode) drawAim();
}

void drawIntro() {
  noLights();
  eye();
  fill(220);
  text("Welcome to VR Draw!\nLook around while clicking to draw.\n" +
       "Click on the side buttons\nto rotate the podium,\n" +
       "and on the X slightly below\nto reset.\n\n" +
       "Search for the wings to fly", 0, 0, 300);
}
...

Listing 15-5.Adding an Intro Screen

intro 屏幕的逻辑如下:我们使用showingIntro变量来指示我们是否应该绘制 intro,并默认将其设置为true。一旦用户释放第一个按钮,介绍就会消失。

图标和包导出

创建应用的最后步骤是设计图标,在清单文件中设置最终的包名、标签和版本,然后导出准备上传到 Play Store 的已签名包,所有这些我们在第三章中都有介绍。

至于图标,我们需要全套,包括 192 × 192 (xxxhdpi)、144 × 144 (xxhdpi)、96 × 96 (xhdpi)、72 × 72 (hdpi)、48 × 48 (mdpi)、32 × 32 (ldpi)版本,如图 15-11 所示。

A432415_1_En_15_Fig11_HTML.jpg

图 15-11。

App icons in all required resolutions

导出的包的清单文件应该包括唯一的完整包名、版本代码和名称,以及在 UI 中用于识别应用的 Android 标签。下面是一个填充了所有这些值的示例:

import processing.vr.*;
<?xml version="1.0" encoding="UTF-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          android:versionCode="1" android:versionName="1.0"
          package="com.example.vr_draw">
    <uses-sdk android:minSdkVersion="19" android:targetSdkVersion="25"/>
    <uses-permission android:name="android.permission.VIBRATE"/>
    <uses-permission android:name=
                     "android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-feature android:name=
                     "android.hardware.sensor.accelerometer"
                     android:required="true"/>
    <uses-feature android:name="android.hardware.sensor.gyroscope"
                  android:required="true"/>
    <uses-feature android:name="android.software.vr.mode"
                  android:required="false"/>
    <uses-feature android:name="android.hardware.vr.high_performance"
                  android:required="false"/>
    <uses-feature android:glEsVersion="0x00020000" android:required="true"/>
    <application android:icon="@drawable/icon"
                 android:label="VR Draw"
                 android:theme="@style/VrActivityTheme">
        <activity android:configChanges=
                  "orientation|keyboardHidden|screenSize"
                  android:name=".MainActivity"
                  android:resizeableActivity="false"
                  android:screenOrientation="landscape">
            <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>

摘要

我们刚刚完成了书中的最后一个项目!这是最复杂的,但希望它有助于认识到创建虚拟现实应用所涉及的挑战,并学习如何让用户以直观的方式与他们的虚拟现实环境进行交互。通过解决此类应用中涉及的挑战,我们发现了如何应用 Processing 的 3D API 来实现身临其境的图形和交互。现在你有工具来创建新的和原创的 Android 应用,不仅适用于 VR,还适用于手表、手机和平板电脑。享受将你的想法变为现实的乐趣吧!

第一部分:Android Processing 的第一步

第二部分:绘图和交互

第三部分:传感器

第四部分:可穿戴设备和表盘

第五部分:3D 和 VR