安卓-Processing-教程-四-

201 阅读30分钟

安卓 Processing 教程(四)

原文:Processing for Android

协议:CC BY-NC-SA 4.0

十一、可视化时间

显示时间是手表的主要功能之一,在这一章中,我们将学习如何应用 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 开发提供了有用的工具包。