原文:Processing for Android
协议:CC BY-NC-SA 4.0
十四、虚拟现实基础
在这一章中,我们将学习一些创建带处理的 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 所示。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
我们可以看到图 14-2 中的结果——VR 中的一个旋转立方体!如果我们把手机放在一个纸板或 Daydream 耳机里,当我们绕着它转动我们的头时,我们将能够从不同的角度看到立方体。然而,在物理空间中行走不会对虚拟现实观看产生任何影响,因为谷歌虚拟现实耳机(在撰写本文时)不支持位置跟踪。
图 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 )。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
由于原点在屏幕的左上角,我们需要调用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 所示。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
VR 互动
到目前为止的代码示例已经向我们展示了在处理中创建 VR 场景是相当简单的:我们所需要的就是在 PDE 中选择 VR 模式,将 VR 库导入到我们的草图中,并使用STEREO
渲染器。有了这些步骤,我们就可以应用之前学过的所有 3D 渲染技术了。但是一旦我们开始思考 VR 中的用户交互,我们就会发现新的挑战。首先,纸板耳机不像更贵的耳机那样包括任何控制器。手动输入仅限于一个触发屏幕触摸的按钮,一些基本的耳机甚至没有这个按钮。我们必须问自己几个关于 VR 中交互的基本问题:我们如何选择 3D 对象/UI 元素,以及我们如何在 VR 空间中移动?
开发人员一直在尝试各种解决这些问题的方法,谷歌 Play 商店上的 VR 应用概述可以给我们一些提示。一种常见的交互技术是凝视选择:应用检测到我们正在看哪个对象,然后一次触摸按压(或盯着它足够长的时间)就会触发所需的动作。所有的 VR 应用都以这样或那样的方式使用这种技术,并结合其他有趣的想法:头部手势(倾斜等。)、利用 VR 空间中的特殊区域放置 UI 元素(即向上或向下看)、某些动作的自动化(行走、拍摄)。
Note
一个成功的虚拟现实体验需要特别注意交互,让用户感觉他们确实在这个空间里。鉴于虚拟现实耳机在图形真实感和控制方面的限制,我们需要非常仔细地设计交互,以便在我们试图传达的特定体验方面有意义。
眼睛和世界坐标
在我们开始研究虚拟现实的交互技术之前,我们需要熟悉我们在开发虚拟现实应用时将要处理的坐标系。有两个系统需要记住:世界坐标系和眼睛坐标系,如图 14-5 所示。
图 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 所示。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
视线
在 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
在这段代码中,我们还使用了ENABLE_STROKE_PERSPECTIVE
提示,这样线条在远离眼睛时会变细(图 14-7 )。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
我们用功能point()
画的点画,可以通过用strokeWeight()
设置权重,变得我们需要的那么大。它服务于“视图目标”的目的,用它来指向虚拟现实中的对象。图 14-8 显示了重量为 50 的目标。在下一节中,我们将学习如何确定一个 3D 点是否落在目标内。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
如果我们按下耳机中的按钮,变量mousePressed
将被设置为true
,允许我们确认正在查看的形状的选择,并高亮显示整个矩形,如图 14-9 所示。然而,如果耳机缺少一个按钮,我们需要一个不同的策略。我们可以通过查看形状一段特定的时间来确认选择,我们在清单 14-8 中就是这么做的(只显示了与前面清单不同的代码)。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
这里的想法是跟踪当前选择的矩形的索引,并且仅在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 中的结果)。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
- 102
- 103
- 104
- 105
- 106
- 107
- 108
- 109
- 110
- 111
- 112
- 113
- 114
- 115
- 116
- 117
- 118
- 119
- 120
- 121
- 122
- 123
网格中的每个框都有不同的对象矩阵,因为变换是不同的(相同的旋转,但不同的平移)。一旦我们获得了物体矩阵,我们必须将它应用于眼睛位置和前向矢量,因为它们定义了我们想要与物体的 AABB 相交的线。我们通过矩阵向量乘法来应用变换。眼睛位置存储在 cam 变量中,直接用 objMat.mult(cam,objCam)变换到物体空间。但是,前向矢量是方向,不是位置,所以不能那样变换。相反,首先我们需要用 objMat.mult(front,objFront)变换前向量,它存储了沿视线从眼睛向前一个单位的点的位置,只有这样我们才能通过用 pvector sub(objFront,objCam,objDir)计算变换后的前位置和眼睛位置之间的差来计算物体坐标中的方向向量。
眼睛位置和前向向量在眼睛矩阵中被编码为其第三和第四列,因此我们可以获得矩阵的各个分量,(m02
、m12
、m22
)和(m03
、m13
、m23
),然后将它们分别复制到dir
和cam
向量中。
intersectsLine()
函数保存了 Williams 算法的实现。它是完全独立的,所以我们可以在其他草图中重用它。请注意,除了根据直线是否与 AABB 相交返回 true 或 false 之外,该算法还会返回hit
向量中交点的坐标,如果检测到几个交点,该坐标可用于确定离摄像机最近的交点。
虚拟现实中的运动
运动是任何虚拟现实体验的一个关键方面,我们需要仔细考虑,因为它受到一些约束和要求的影响。一方面,我们的目标是说服用户停止怀疑,沉浸在虚拟环境中。在这种环境中拥有一定程度的自由是很重要的。另一方面,这种虚拟运动不会完全符合我们的感官,这可能会导致晕动病,这是 VR 应用中要不惜一切代价避免的事情。相反,如果我们戴着谷歌虚拟现实耳机在物理空间中移动,我们会体验到视觉和身体感官之间的另一种脱节。
尽管有这些限制,我们仍然可以在 VR 空间中创建令人信服的运动。一个技巧是在视野中放置某种固定的参照物,与我们在物理空间中的静止状态相匹配。例如,在清单 14-10 中,我们为此加载了一个OBJ
形状,将其放置在眼睛坐标中的相机位置。这个形状是一个十二面体(图 14-11 ),在我们通过虚拟现实进行导航时,它就像是一个“头盔”。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
prepare()
函数将形状放在原点的中心,并将其缩放到与我们的场景尺寸相当的大小。这一步在加载OBJ
文件时很重要,因为它们可能是用不同范围的坐标值定义的,所以它们可能看起来很小或很大。在这种情况下,我们放置十二面体形状,使其以(cameraX
、cameraY
、cameraZ
)为中心,从而为我们在 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
在这段代码中,我们将圆形轨迹存储在一个PShape
对象中,将平移应用到相机的右侧,以便用户从轨迹的顶部开始,然后应用旋转来创建围绕轨迹中心的所需移动。该草图的结果如图 14-12 所示。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
在每一帧中,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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
在calculate()
函数中,我们从眼睛矩阵的m02
和m22
分量构建一个平面方向向量。我们需要对这个向量进行归一化,以确保我们在移动时保持均匀的步幅,即使我们在向上看,向前的向量在 x 和 z 轴上的坐标非常小。该草图的视图如图 14-13 所示。
图 14-13。
Movement constrained to a 2D plane
摘要
虚拟现实带来了令人兴奋的新可能性以及有趣的挑战。在这一章中,我们学习了一些处理技术来应对这些挑战,并探索虚拟现实的可能性可以带我们去哪里。特别是,我们讨论了直观的交互和运动是如何创造引人入胜的虚拟现实体验的关键。
十五、在虚拟现实中绘图
在这最后一章中,我们将一步一步地使用 Android 的处理来开发一个全功能的 VR 绘图应用。我们将应用到目前为止我们所学的所有技术,包括凝视控制的移动和用户界面(UI)。
创造成功的虚拟现实体验
在前面的章节中,我们学习了 3D API 在处理方面的基础知识,以及一些我们可以用来在 VR 中创建交互式图形的技术。创造一个成功的虚拟现实体验是一个令人兴奋的挑战。与“传统的”计算机图形相反,在“传统的”计算机图形中,我们可以依赖用户熟悉的表示(例如,透视图与等轴视图)和交互约定(例如,基于鼠标或触摸的手势),VR 是一种新的媒体,它提供了许多可能性,但也带来了独特的约束和限制。
可以说,虚拟现实的一个中心目标是让用户暂停怀疑,沉浸在虚拟空间中,至少是一小会儿,即使图形不是照片般逼真或交互有限。虚拟现实创造了一种不寻常的体验,那就是置身于一个没有身体的合成 3D 空间中。最近在技术和游戏展上演示的输入硬件(例如,在虚拟现实中骑自行车的自行车支架,产生触摸幻觉的气压,甚至低放电)说明了体现虚拟现实体验的持续努力。
我们还需要了解 Android 处理所支持的 Cardboard 和 Daydream 平台的具体特征。由于我们是用智能手机生成图形,所以它们比由 PC 驱动的 VR 设备生成的图形更受限制。首先,重要的是要确保手机能够处理我们场景的复杂性,并能保持平稳的帧率。否则,断断续续的动画会导致用户头晕和恶心。第二,纸板耳机的交互输入有限,通常只有一个按钮。此外,由于我们需要用双手握住它们(见图 15-1 ),因此我们不能依赖外部输入设备。我们的 VR 应用中的交互应该考虑到体验的所有这些方面。
图 15-1。
Students using Google Cardboard during class activities
在虚拟现实中绘图
在虚拟现实中创建 3D 对象可能是一项非常有趣的活动,我们不仅可以不受屏幕或物理定律的限制来塑造人物,还可以与我们的虚拟作品处于同一空间。Google Tilt Brush(图 15-2 )是一个很好的例子,说明了一个设计良好的 VR 体验是如何变得极具沉浸感和趣味性的。受这些想法的启发,实现我们自己的 Cardboard/Daydream 的绘图 VR app 岂不是一个很好的练习?
图 15-2。
Google Tilt Brush VR drawing app
正如我们刚刚讨论的,我们将不得不处理更有限的图形和交互能力。如果我们假设没有可用的输入设备,并且我们只有一个按钮来进行单次按压,那么我们基本上只能将凝视作为我们的铅笔来使用。出于这个原因,前一章中使用视线在 VR 空间中选择 3D 元素的一些技术将会派上用场。
初始草图
虚拟现实绘画的一个很好的类比是雕刻。根据这个类比,我们可以从一个基座或讲台开始,在其上我们将创建我们的 VR 绘图/雕塑,当我们需要改变我们工作的角度时,用一些 UI 控件来旋转它。我们必须记住,谷歌虚拟现实不会跟踪位置的变化,只会跟踪头部的旋转,这不足以从所有可能的角度进行 3D 绘图。图 15-3 为应用勾勒出一个纸笔概念。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
底座只是一个存储在PShape
中的圆柱体,加上一个用作顶面的椭圆。正如我们之前看到的,我们可以在一个组中存储不同的处理形状,并且为了绘制这个基础形状,我们只需要得到的PShape
组。图 15-4 展示了这个应用的第一个版本。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
- 100
- 101
封装选择功能的Button
类是这段代码的核心元素。它的构造函数接受五个参数:按钮中心的(x,y,z)坐标、大小和用作按钮纹理的图像。在display()
方法的实现中,我们通过使用上一章的屏幕坐标技术来确定按钮是否被选中:如果(screenX,screenY)足够靠近屏幕中心,那么它被认为是被选中的,在这种情况下,按钮得到一条笔划线。UI 事件在calculate()
方法中触发,以避免在检测到“鼠标”按下(对应于 VR 头戴式耳机中可用的物理触发器)事件时再次应用它们。
按钮的位置由createButtons()
功能决定,左右旋转按钮位于底座两侧,复位按钮位于稍下方。我们还在眼睛坐标中的(0,0,100)处绘制了一个笔划点,作为帮助绘制和选择的目标。
此时,再次回顾Button
类中的display()
函数是很重要的,在这里,除了绘制按钮,我们还测试它是否被选中。这看起来可能是放置该逻辑的错误位置,因为显示功能应该只处理绘图任务。事实证明,screenX()
和screenY()
调用要求影响按钮的 3D 变换是当前的,否则它们将返回不正确的结果。因为在绘制几何图形时应用了转换,所以我们也在该阶段执行交互检测。这个草图的结果应该如图 15-5 所示。
图 15-5。
Adding buttons to the UI
三维绘图
当我们在第二章中实现绘图应用时,我们只需要担心在 2D 画笔画。这很容易,感谢处理中的pmouseX/Y
和mouseX/Y
变量,它们允许我们在先前和当前鼠标位置之间画一条线。在三维空间中,想法实际上是一样的:笔画是连续位置之间的一系列线条,不再局限于屏幕平面内。但是如果我们在 VR 空间中没有实际的 3D 指针,我们需要仅从凝视信息来推断 3D 空间中的方向性。
我们知道我们凝视的方向包含在向前的向量中,它会自动更新以反映任何头部运动。如果在每一帧,我们向场景的中心投射一个固定量的前向向量,我们将有一个滑动点,可以在我们的绘图中生成笔划。事实上,这与我们为 2D 绘画所做的没有太大的不同,在那里笔画是由(mouseX, mouseY)
位置的序列定义的。我们还可以计算当前矢量和前一个矢量之间的差值,类似于 2D 的矢量(mouseX – pmouseX, mouseY – pmouseY)
,以确定我们是否需要在绘图中添加新的点。图 15-6 显示了图中差矢量与相应位移之间的关系。
图 15-6。
Calculating displacement using previous and current forward vectors
3D 和 2D 情况之间的一个重要区别是,在后者中,我们不需要跟踪所有过去的位置,只需要跟踪先前的位置。这是因为如果我们不使用background()
清除屏幕,我们可以简单地在已经绘制的线条上添加最后一条线条。但是在 3D 中,我们必须在每一帧刷新屏幕,因为摄像机的位置不是静态的,所以场景需要不断更新。这意味着,从绘图中记录的第一个位置开始,所有过去的行都必须在每一帧中重新绘制。为此,我们需要将所有位置存储在一个数组中。
然而,如果我们想在用户停止按下耳机中的触发器时中断笔画,那么将绘图中的所有位置存储在一个数组中是不够的。我们还需要保存中断发生的地方。一种可能性是将每个连续的笔划存储在一个单独的数组中,并拥有一个包含所有过去笔划的数组。
有了这些想法,我们就可以开始工作了。因为草图变得相当复杂,所以最好将它分成单独的选项卡,每个选项卡中有相关的代码。例如,我们可以有如图 15-7 所示的标签结构。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
关于鼠标事件处理程序的一些重要观察。旋转角度的更新保留在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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
这个选项卡中有许多变量,从保存当前笔画位置的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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
清单 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
做完这些工作,我们应该有一个 VR 的工作绘图 app 了!我们可以在硬纸板或 Daydream 耳机上试用它,如果一切顺利,我们将能够使用它来创建线条画,如图 15-8 所示。
图 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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
我们引入了几个额外的变量:一个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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
有了这些附加功能,我们可以切换到飞行模式来快速浏览我们的绘图,并切换回默认的绘图模式来继续绘图或开始一个新的绘图,我们可以在图 15-9 中看到一系列的步骤。
图 15-9。
Transition between draw and fly modes
最终调整和打包
我们已经有了一个简单但功能齐全的 VR 绘图应用!在这个过程中,我们遇到了 VR 开发特有的挑战:构建沉浸式 3D 环境,添加可以单独使用凝视访问的 UI 元素,以及在 VR 中自由移动。我们的应用利用一些技术来解决这些挑战,我们应该在未来的 VR 项目中进一步探索。目前,我们只需要做一些最后的调整,就可以在 Play Store 上发布绘图应用了。
介绍文本
当用户第一次打开我们的应用时,我们不能指望他们知道该做什么,所以一个好主意是提供一个介绍来解释体验的机制。我们应该让这个介绍尽可能简短,因为大多数用户不希望经历非常冗长或复杂的说明,一个成功的 VR 体验应该尽可能不言自明。
我们可以在眼睛坐标中绘制介绍页面,这样它就面向用户,而不考虑他们的头部位置,当用户按下耳机按钮继续时,它就会消失。清单 15-5 显示了实现一个简单介绍的附加代码,结果如图 15-10 所示。
图 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!
Look around while clicking to draw.
" +
"Click on the side buttons
to rotate the podium,
" +
"and on the X slightly below
to reset.
" +
"Search for the wings to fly", 0, 0, 300);
}
...
Listing 15-5.Adding an Intro Screen
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
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 所示。
图 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>
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
摘要
我们刚刚完成了书中的最后一个项目!这是最复杂的,但希望它有助于认识到创建虚拟现实应用所涉及的挑战,并学习如何让用户以直观的方式与他们的虚拟现实环境进行交互。通过解决此类应用中涉及的挑战,我们发现了如何应用 Processing 的 3D API 来实现身临其境的图形和交互。现在你有工具来创建新的和原创的 Android 应用,不仅适用于 VR,还适用于手表、手机和平板电脑。享受将你的想法变为现实的乐趣吧!