“看音乐,听舞蹈,”俄罗斯出生的著名编舞家、美国芭蕾舞之父乔治·巴兰钦说。
我们不会试图提高艺术形式的水平,但是,也许在我们的手机上可视化播放列表会很有趣。在这个项目中,我们将创建三维动画抽象图形,随着您的音乐节拍起舞。你可能对 2D 的音乐可视化很熟悉,但是它在虚拟现实中会是什么样子呢?为了获得灵感,试着用短语几何战争谷歌搜索图像,例如 XBox 的经典游戏!
一个可视化应用从安卓音频系统获取输入,并显示可视化效果。在这个项目中,我们将利用安卓Visualizer
类,它让应用捕捉部分当前播放的音频,而不是全保真度音乐细节,而是足以可视化的低质量音频内容。
在这个项目中,我们将:
- 设置新项目
- 构建一个名为 VisualizerBox 的 Java 类架构
- 从手机的音频播放器中捕获波形数据
- 构建几何可视化
- 构建基于纹理的可视化
- 捕获快速傅立叶变换数据并构建快速傅立叶变换可视化
- 添加一个三步走模式
- 支持多个并发可视化
这个项目的源代码可以在 Packt Publishing 网站和 GitHub 上的https://github.com/cardbookvr/visualizevr找到(每个主题作为一个单独的提交)。
为了构建这个项目,我们将使用在第 5 章RenderBox 引擎中创建的 RenderBox 库。您可以使用您的,或者从本书或我们的 GitHub repo 提供的可下载文件中获取一份副本(使用提交标记的after-ch8
—https://GitHub . com/cardbookr/renderboxlib/releases/tag/after-ch8)。关于如何导入RenderBox
库的更详细的描述,请参考第 5 章、 RenderBox 引擎的最后一节【在未来项目中使用 RenderBox】。要创建新项目,请执行以下步骤:
- Android Studio 打开后,创建一个新项目。让我们将其命名为
VisualizeVR
,并以空活动为目标安卓 4.4 KitKat (API 19) 。 - 使用文件 | 新模块 | 导入,为每个
renderbox
、common
和core
包创建新模块。JAR/。AAR 包装。 - 使用文件 | 项目结构,将模块设置为应用的依赖项。
- 按照第 2 章、框架纸板项目中的说明编辑
build.gradle
文件,根据 SDK 22 进行编译。 - 更新
/res/layout/activity_main.xml
和AndroidManifest.xml
,如前几章所述。 - 将
MainActivity
编辑为class MainActivity extends CardboardActivity implements IRenderBox
,实现接口方法存根( Ctrl + I )。
我们可以在MainActivity
中定义onCreate
方法。该类现在具有以下代码:
public class MainActivity extends CardboardActivity implements IRenderBox {
private static final String TAG = "MainActivity";CardboardView cardboardView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
cardboardView = (CardboardView) findViewById(R.id.cardboard_view);
cardboardView.setRenderer(new RenderBox(this, this));
setCardboardView(cardboardView);
}
@Override
public void setup() {
}
@Override
public void preDraw() {
// code run beginning each frame
}
@Override
public void postDraw() {
// code run end of each frame
}
}
您可以暂时在场景中添加一个立方体,以确保一切设置正确。将其添加到setup
方法中,如下所示:
public void setup() {
new Transform()
.setLocalPosition(0,0,-7)
.setLocalRotation(45,60,0)
.addComponent(new Cube(true));
}
如果你记得的话,一个Cube
就是一个Component
,加到一个Transform
上。Cube
定义其几何形状(例如顶点)。Transform
定义其在三维空间中的位置、旋转和缩放。
你应该可以在没有编译错误的情况下点击运行‘app’,并在你的安卓设备上看到立方体和纸板的分屏视图。
使用 Android Visualizer
类(http://developer . Android . com/reference/Android/media/audio FX/visualizer . html,我们可以以指定的采样率检索当前正在播放的部分音频数据。您可以选择将数据捕获为波形和/或频率数据:
- 波形:这个是单声道音频波形字节的数组,或者脉冲编码调制 ( PCM )数据,代表音频幅度的样本序列
- 频率:这个是快速傅立叶变换 ( FFT )字节的数组,代表音频的采样
数据被限制在 8 位,所以它对回放没有用处,但对可视化来说足够了。您可以指定采样率,尽管它必须是 2 的幂。
有了这些知识,我们现在就开始实现一个架构,它可以捕获音频数据,并使您可以构建的可视化呈现器可以使用它。
音乐可视化工具通常看起来很酷,尤其是刚开始的时候。但一段时间后,它们可能会显得过于重复,甚至令人厌烦。因此,在我们的设计中,我们将构建排队许多不同可视化的能力,然后,一段时间后,从一个过渡到下一个。
为了开始我们的实现,我们将定义一个可扩展的架构结构,并让我们开发新的可视化。
然而,即使在此之前,我们也必须确保该应用有权使用我们需要的安卓音频功能。在AndroidManifest.xml
中增加以下指令:
<!-- Visualizer permissions -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
请记住,RenderBox
库最初是在第 5 章、 *RenderBox Engine、*中开发的,它允许MainActivity
将大部分图形和纸板虚拟现实工作委托给RenderBox
类和关联类(Component
、Material
等)。我们将遵循类似的设计模式,建立在RenderBox
之上。MainActivity
可以实例化特定的可视化,然后将工作委托给VisualizerBox
类。
VisualizerBox
类将为安卓Visualizer
类提供回调函数。让我们首先定义这个的框架实现。创建一个VisualizerBox
Java 类,如下所示:
public class VisualizerBox {
static final String TAG = "VisualizerBox";
public VisualizerBox(final CardboardView cardboardView){
}
public void setup() {
}
public void preDraw() {
}
public void postDraw() {
}
}
将VisualizerBox
整合到MainActivity
中,在类的顶部增加一个visualizerBox
变量。在MainActivity
中,增加以下一行:
VisualizerBox visualizerBox;
在onCreate
中初始化:
visualizerBox = new VisualizerBox(cardboardView);
另外,在MainActivity
中,调用每个IRenderBox
接口方法的相应版本:
@Override
public void setup() {
visualizerBox.setup();
}
@Override
public void preDraw() {
visualizerBox.preDraw();
}
@Override
public void postDraw() {
visualizerBox.postDraw();
}
很好。现在我们将设置VisualizerBox
让你建立和使用一个或多个可视化。因此,首先让我们在Visualization.java
文件中定义抽象的Visualization
类,如下所示:
public abstract class Visualization {
VisualizerBox visualizerBox; //owner
public Visualization(VisualizerBox visualizerBox){
this.visualizerBox = visualizerBox;
}
public abstract void setup();
public abstract void preDraw();
public abstract void postDraw();
}
现在,我们有了一种机制来为应用创建各种可视化实现。在我们开始编写其中的一个之前,让我们也提供与VisualizerBox
的集成。在VisualizerBox
类的顶部,向当前的activeViz
对象添加一个变量:
public Visualization activeViz;
然后,从接口方法调用它:
public void setup() {
if(activeViz != null)
activeViz.setup();
}
public void preDraw() {
if(activeViz != null)
activeViz.preDraw();
}
public void postDraw() {
if(activeViz != null)
activeViz.postDraw();
}
当然,我们甚至还没有使用安卓Visualizer
类,也没有在屏幕上渲染任何东西。接下来就是这个了。
现在,让我们为可视化创建一个占位符。在项目中创建新文件夹visualizations
。右键点击你的 Java 代码文件夹(例如java/com/cardbookvr/visualizevr/
,进入新建 | 包,命名为visualizations
。然后,右键点击新建visualizations
文件夹,转到新建 | Java 类,命名为BlankVisualization
。然后,将其定义为extends Visualization
如下:
public class BlankVisualization extends Visualization {
static final String TAG = "BlankVisualization";
public BlankVisualization(VisualizerBox visualizerBox) {
super(visualizerBox);
}
@Override
public void setup() {
}
@Override
public void preDraw() {
}
@Override
public void postDraw() {
}
}
我们将能够使用它作为特定可视化工具的模板。每种方法的目的都不言自明:
setup
:这个初始化可视化的变量、转换和材质preDraw
:该代码在每帧开始时执行;例如,使用当前捕获的音频数据postDraw
:该代码在每帧结束时执行
现在让我们给这个框架加点肉。
正如前面提到的,安卓Visualizer
类允许我们定义回调来捕获音频数据。这些数据有两种格式:波形和快速傅立叶变换。我们现在只将波形数据添加到VisualizerBox
类。
首先,定义我们将用于捕获的音频数据的变量,如下所示:
Visualizer visualizer;
public static int captureSize;
public static byte[] audioBytes;
使用 API,我们可以确定可用的最小捕获大小,然后将其用作我们的捕获样本大小。
然后,在构造函数中初始化它们,如下所示。首先,实例化一个安卓Visualizer
。然后设置要使用的捕获大小,并分配我们的缓冲区:
public VisualizerBox(final CardboardView cardboardView){
visualizer = new Visualizer(0);
captureSize = Visualizer.getCaptureSizeRange()[0];
visualizer.setCaptureSize(captureSize);
// capture audio data
// Visualizer.OnDataCaptureListener captureListener = ...
visualizer.setDataCaptureListener(captureListener, Visualizer.getMaxCaptureRate(), true, true);
visualizer.setEnabled(true);
}
出于各种原因,我们希望使用最小尺寸。首先,它会更快,在虚拟现实中,速度是最重要的。其次,它将我们的快速傅立叶变换样本(如后面所讨论的)组织到更少的桶中。这很有帮助,因为每个桶在更宽的频率范围内捕捉更多的活动。
请注意,我们在定义捕获侦听器的地方留下了注释,然后在可视化工具中设置它。确保您启用了可视化工具,使其始终处于侦听状态。
我们先把captureListener
对象只写波形数据。我们定义并实例化了一个实现Visualizer.OnDataCaptureListener
的新匿名类,并为其提供了一个名为onWaveFormDataCapture
的函数,该函数接收波形字节并将其存储为我们的Visualization
代码(即将发布):
// capture audio data
Visualizer.OnDataCaptureListener captureListener = new Visualizer.OnDataCaptureListener() {
@Override
public void onWaveFormDataCapture(Visualizer visualizer, byte[] bytes, int samplingRate) {
audioBytes = bytes;
}
@Override
public void onFftDataCapture(Visualizer visualizer, byte[] bytes, int samplingRate) {
}
};
接口仍然要求我们提供一个onFftDataCapture
方法,但我们暂时将其留空。
现在我们准备给这个宝宝添加一些图形。
对于我们的第一个可视化,我们将创建一个基本的均衡器波形图。它将是一个由一系列立方体组成的矩形块,根据音频波形数据进行缩放。我们将使用已经在RenderBox
库中的内置Cube
组件及其基本顶点颜色照明材质。
在visualizations/
文件夹中,创建一个名为GeometricVisualization
的新 Java 类,开始如下:
public class GeometricVisualization extends Visualization {
static final String TAG = "GeometricVisualization";
public GeometricVisualization(VisualizerBox visualizerBox) {
super(visualizerBox);
}
}
在类的顶部,声明立方体变换的Transform
数组和RenderObjects
的对应数组:
Transform[] cubes;
Cube[] cubeRenderers;
然后,在setup
方法中初始化它们。我们将分配立方体阵列,对齐并缩放为一组相邻的块,创建一个波浪形块的 3D 表示。设置方法可以如下实现:
public void setup() {
cubes = new Transform[VisualizerBox.captureSize / 2];
cubeRenderers = new Cube[VisualizerBox.captureSize / 2];
float offset = -3f;
float scaleFactor = (offset * -2) / cubes.length;
for(int i = 0; i < cubes.length; i++) {
cubeRenderers[i] = new Cube(true);
cubes[i] = new Transform()
.setLocalPosition(offset, -2, -5)
.addComponent(cubeRenderers[i]);
offset += scaleFactor;
}
}
现在在每一个帧上,我们只需要根据来自音频源的当前波形数据修改每个立方体的高度(如在VisualizerBox
中获得的)。执行preDraw
方法如下:
public void preDraw() {
if (VisualizerBox.audioBytes != null) {
float scaleFactor = 3f / cubes.length;
for(int i = 0; i < cubes.length; i++) {
cubes[i].setLocalScale(scaleFactor, VisualizerBox.audioBytes[i] * 0.01f, 1);
}
}
}
public void postDraw() {
}
我们还需要为postDraw
实现添加一个存根。然后,实例化可视化,并使其成为活动的可视化。在MainActivity
中,onCreate
的末尾添加以下一行代码:
visualizerBox.activeViz = new GeometricVisualization(visualizerBox);
这就是我们现在所需要的。
开始在手机上播放一些音乐。然后,运行应用。你会看到这样的东西:
如您所见,我们将单位立方体保留在场景中,因为它有助于澄清正在发生的事情。每个音频数据都是一个薄的“片”(或扁平的立方体),其高度随音频值而变化。如果您正在查看前面屏幕图像的彩色版本,您会注意到可视化立方体的彩色面就像单独的立方体,因为它们使用相同的对象和材质进行渲染。
这种可视化是使用音频波形数据动态修改 3D 几何图形的一个非常基本的例子。让你的想象力尽情发挥,创造属于你自己的。音频字节可以控制任何变换参数,包括比例、位置和旋转。请记住,我们在一个三维虚拟现实空间中,您可以使用它的所有功能——将您的东西四处、上下甚至身后移动。我们有一些基本的原始几何形状(立方体、球体、平面、三角形等等)。但是你也可以使用音频数据来参数化地生成新的形状和模型。另外,你甚至可以整合上一章的ModelObject
类来加载有趣的 3D 模型!
在下一个主题中,我们将了解如何在基于纹理的材质着色器中使用音频波形数据。
第二次可视化也将是波形数据的基本示波器式显示。然而,以前,我们使用音频数据来缩放 3D 切片立方体;这一次,我们将使用使用音频数据作为输入的着色器在 2D 平面上渲染它们。
我们的RenderBox
库允许我们定义新的材质和着色器。在之前的项目中,我们构建了使用位图图像进行纹理映射到渲染的几何图形上的材质。在这个项目中,我们将使用音频字节数组绘制四边形,使用字节值来控制我们设置更亮颜色的位置。(注意第七章、 360 度画廊中RenderBox
lib 增加了Plane
类。)
首先,让我们生成一个纹理结构来保存我们的纹理数据。在VisualizerBox
类中,添加以下方法在 GLES 设置纹理。我们不能使用我们正常的纹理管道,因为它被设计成直接从图像数据中分配一个纹理。我们的数据是一维的,所以使用Texture2D
资源可能看起来很奇怪,但是我们将高度设置为一个像素:
public static int genTexture(){
final int[] textureHandle = new int[1];
GLES20.glGenTextures(1, textureHandle, 0);
RenderBox.checkGLError("VisualizerBox GenTexture");
if (textureHandle[0] != 0) {
// Bind to the texture in OpenGL
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureHandle[0]);
// Set filtering
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST);
}
if (textureHandle[0] == 0){
throw new RuntimeException("Error loading texture.");
}
return textureHandle[0];
}
然后添加对setup
的调用,包括一个静态变量来保存生成的纹理句柄:
public static int audioTexture = -1;
public void setup() {
audioTexture = genTexture();
if(activeViz != null)
activeViz.setup();
}
现在我们可以从音频字节数据填充纹理。在安卓Visualizer
监听器中,在onWaveFormDataCapture
方法中添加对loadTexture
的调用:
public void onWaveFormDataCapture(Visualizer visualizer, byte[] bytes, int samplingRate){
audioBytes = bytes;
loadTexture(cardboardView, audioTexture, bytes);
}
让我们定义loadTexture
如下。它将音频字节复制到一个新的数组缓冲区中,并通过glBindTexture
和glTexImage2D
调用将其传递给 OpenGL ES。
(参考http://stackoverflow . com/questions/14290096/如何在安卓中从字节数组创建 opengl 纹理。):
public static void loadTexture(CardboardView cardboardView, final int textureId, byte[] bytes){
if(textureId < 0)
return;
final ByteBuffer buffer = ByteBuffer.allocateDirect(bytes.length * 4);
final int length = bytes.length;
buffer.order(ByteOrder.nativeOrder());
buffer.put(bytes);
buffer.position(0);
cardboardView.queueEvent(new Runnable() {
@Override
public void run() {
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_LUMINANCE, length, 1, 0,
GLES20.GL_LUMINANCE, GLES20.GL_UNSIGNED_BYTE, buffer);
}
});
}
现在到了编写着色器程序的时候了,除了其他事情之外,着色器程序将规定需要在Material
类中设置的参数和属性。
如有必要,为着色器res/raw/
创建一个资源目录。然后,创建waveform_vertex.shader
和waveform_fragment.shader
文件。定义如下。
waveform_vertex.shader
文件与我们使用的unlit_tex_vertex
着色器相同。严格来说,我们可以重用这个文件,并在createProgram
函数中指定它的资源,但是定义单独的着色器文件是一个很好的实践,除非您明确遵循某种模式,在该模式中,您在给定的着色器上使用了许多变体。
文件:res/raw/waveform_vertex.shader
:
uniform mat4 u_MVP;
attribute vec4 a_Position;
attribute vec2 a_TexCoordinate;
varying vec2 v_TexCoordinate;
void main() {
// pass through the texture coordinate
v_TexCoordinate = a_TexCoordinate;
// final point in normalized screen coordinates
gl_Position = u_MVP * a_Position;
}
对于waveform_fragment
着色器,我们为纯色(u_Color
)和阈值宽度(u_Width
)添加变量。然后,添加一点逻辑来决定当前渲染像素的 y 坐标是否在样本的u_Width
内。
文件:res/raw/waveform_fragment.shader
precision mediump float; // default medium precision
uniform sampler2D u_Texture; // the input texture
varying vec2 v_TexCoordinate; // interpolated texture coordinate per fragment
uniform vec4 u_Color;
uniform float u_Width;
// The entry point for our fragment shader.
void main() {
vec4 color;
float dist = abs(v_TexCoordinate.y - texture2D(u_Texture, v_TexCoordinate).r);
if(dist < u_Width){
color = u_Color;
}
gl_FragColor = color;
}
现在我们为着色器定义Material
类。创建一个名为WaveformMaterial
的新 Java 类,并定义如下:
public class WaveformMaterial extends Material {
private static final String TAG = "WaveformMaterial";
}
为纹理标识、边框、宽度和颜色添加材质变量。然后,为着色器程序引用和缓冲区添加变量,如以下代码所示:
static int program = -1; //Initialize to a totally invalid value for setup state
static int positionParam;
static int texCoordParam;
static int textureParam;
static int MVPParam;
static int colorParam;
static int widthParam;
public float borderWidth = 0.01f;
public float[] borderColor = new float[]{0.6549f, 0.8392f, 1f, 1f};
FloatBuffer vertexBuffer;
FloatBuffer texCoordBuffer;
ShortBuffer indexBuffer;
int numIndices;
现在我们可以添加一个构造函数。正如我们之前看到的,它调用一个setupProgram
辅助方法来创建着色器程序并获取对其参数的引用:
public WaveformMaterial() {
super();
setupProgram();
}
public static void setupProgram() {
if(program > -1) return;
//Create shader program
program = createProgram( R.raw.waveform_vertex, R.raw.waveform_fragment );
RenderBox.checkGLError("Bitmap GenTexture");
//Get vertex attribute parameters
positionParam = GLES20.glGetAttribLocation(program, "a_Position");
RenderBox.checkGLError("Bitmap GenTexture");
texCoordParam = GLES20.glGetAttribLocation(program, "a_TexCoordinate");
RenderBox.checkGLError("Bitmap GenTexture");
//Enable them (turns out this is kind of a big deal ;)
GLES20.glEnableVertexAttribArray(positionParam);
RenderBox.checkGLError("Bitmap GenTexture");
GLES20.glEnableVertexAttribArray(texCoordParam);
RenderBox.checkGLError("Bitmap GenTexture");
//Shader-specific parameters
textureParam = GLES20.glGetUniformLocation(program, "u_Texture");
MVPParam = GLES20.glGetUniformLocation(program, "u_MVP");
colorParam = GLES20.glGetUniformLocation(program, "u_Color");
widthParam = GLES20.glGetUniformLocation(program, "u_Width");
RenderBox.checkGLError("Waveform params");
}
同样,我们添加一个setBuffers
方法,由RenderObject
组件(Plane
)调用:
public WaveformMaterial setBuffers(FloatBuffer vertexBuffer, FloatBuffer texCoordBuffer, ShortBuffer indexBuffer, int numIndices) {
//Associate VBO data with this instance of the material
this.vertexBuffer = vertexBuffer;
this.texCoordBuffer = texCoordBuffer;
this.indexBuffer = indexBuffer;
this.numIndices = numIndices;
return this;
}
添加将从Camera
组件调用的draw
代码,以渲染缓冲区中准备的几何图形(通过setBuffers
)。draw
方法是这样的:
@Override
public void draw(float[] view, float[] perspective) {
GLES20.glUseProgram(program);
// Set the active texture unit to texture unit 0.
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
// Bind the texture to this unit.
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, VisualizerBox.audioTexture);
// Tell the texture uniform sampler to use this texture in //the shader by binding to texture unit 0.
GLES20.glUniform1i(textureParam, 0);
Matrix.multiplyMM(modelView, 0, view, 0, RenderObject.model, 0);
Matrix.multiplyMM(modelViewProjection, 0, perspective, 0, modelView, 0);
// Set the ModelViewProjection matrix for eye position.
GLES20.glUniformMatrix4fv(MVPParam, 1, false, modelViewProjection, 0);
GLES20.glUniform4fv(colorParam, 1, borderColor, 0);
GLES20.glUniform1f(widthParam, borderWidth);
//Set vertex attributes
GLES20.glVertexAttribPointer(positionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);
GLES20.glVertexAttribPointer(texCoordParam, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer);
GLES20.glDrawElements(GLES20.GL_TRIANGLES, numIndices, GLES20.GL_UNSIGNED_SHORT, indexBuffer);
RenderBox.checkGLError("WaveformMaterial draw");
}
还有一件事;让我们提供一种销毁现有材质的方法:
public static void destroy(){
program = -1;
}
现在我们可以创建一个新的可视化对象。在visualizations/
文件夹下,创建一个名为WaveformVisualization
的新 Java 类,并将其定义为extends Visualization
:
public class WaveformVisualization extends Visualization {
static final String TAG = "WaveformVisualization";
public WaveformVisualization(VisualizerBox visualizerBox) {
super(visualizerBox);
}
@Override
public void setup() {
}
@Override
public void preDraw() {
}
@Override
public void postDraw() {
}
}
为我们将要创建的Plane
组件声明一个变量:
RenderObject plane;
按照以下方法在中创建它。将材质设置为新的WaveformMaterial
,并向左放置:
public void setup() {
plane = new Plane().setMaterial(new WaveformMaterial()
.setBuffers(Plane.vertexBuffer, Plane.texCoordBuffer, Plane.indexBuffer, Plane.numIndices));
new Transform()
.setLocalPosition(-5, 0, 0)
.setLocalRotation(0, 90, 0)
.addComponent(plane);
}
现在在MainActivity
的onCreate
中,用这个替换之前的可视化:
visualizerBox.activeViz = new WaveformVisualization(visualizerBox);
当你运行这个项目时,你会得到一个像这样的可视化:
对于下一个可视化,我们将介绍 FFT 数据(而不是波形数据)的使用。与前面的例子一样,我们将从数据中动态生成纹理,并编写一个材质和着色器来渲染它。
首先,我们需要将数据捕获添加到我们的VisualizerBox
类中。我们将从添加我们需要的变量开始:
public static byte[] fftBytes, fftNorm;
public static float[] fftPrep;
public static int fftTexture = -1;
我们需要分配快速傅立叶变换数据阵列,为此,我们需要知道它们的大小。我们可以问安卓Visualizer
API 它能给我们多少数据。现在,我们将选择最小大小,然后按如下方式分配阵列:
public VisualizerBox(final CardboardView cardboardView){
. . .
fftPrep = new float[captureSize / 2];
fftNorm = new byte[captureSize / 2];
...
捕捉 FFT 数据类似于捕捉波形数据。但是我们会在保存之前对它进行一些预处理。根据 Android Visualizer
API 文档,(http://developer . Android . com/reference/Android/media/audiofx/visualizer . html # GetFFt(byte[]getFfT
函数提供如下指定的数据:
- 捕获是一个 8 位幅度的快速傅立叶变换;覆盖的频率范围为 0 (DC)至
getSamplingRate()
返回的采样率的一半 - 捕捉返回等于捕捉大小的一半加一的多个频率点的实部和虚部
注意,对于第一个点(DC)和最后一个点(采样频率/2 )只返回实部。
返回的字节数组中的布局如下:
- n 是
getCaptureSize()
返回的捕获大小 Rfk
和Ifk
分别是 kth 频率分量的实部和虚部- 如果
Fs
是getSamplingRate()
返回的采样频率, kth 频率为: (kFs)/(n/2)*
同样,我们将把传入的捕获数据准备成一个介于 0 和 255 之间的规范化值数组。我们的实现如下。在onWaveFormDataCapture
方法之后立即添加onFftDataCapture
声明(在OnDataCaptureListener
实例中):
@Override
public void onFftDataCapture(Visualizer visualizer, byte[] bytes, int samplingRate) {
fftBytes = bytes;
float max = 0;
for(int i = 0; i < fftPrep.length; i++) {
if(fftBytes.length > i * 2) {
fftPrep[i] = (float)Math.sqrt(fftBytes[i * 2] * fftBytes[i * 2] + fftBytes[i * 2 + 1] * fftBytes[i * 2 + 1]);
if(fftPrep[i] > max){
max = fftPrep[i];
}
}
}
float coeff = 1 / max;
for(int i = 0; i < fftPrep.length; i++) {
if(fftPrep[i] < MIN_THRESHOLD){
fftPrep[i] = 0;
}
fftNorm[i] = (byte)(fftPrep[i] * coeff * 255);
}
loadTexture(cardboardView, fftTexture, fftNorm);
}
请注意,我们的算法使用 1.5 的MIN_THRESHOLD
值来过滤掉不重要的值:
final float MIN_THRESHOLD = 1.5f;
现在在setup()
中,用生成的纹理初始化fftTexture
,就像我们对audioTexture
变量所做的那样:
public void setup() {
audioTexture = genTexture();
fftTexture = genTexture();
if(activeViz != null)
activeViz.setup();
}
现在我们需要编写着色器程序。
如有必要,为着色器res/raw/
创建一个资源目录。fft_vertex.shader
与之前创建的waveform_vertext.shader
相同,所以你可以复制它。
对于fft_fragment
着色器,我们添加了一点逻辑来决定当前坐标是否被渲染。在这种情况下,我们没有指定宽度,只是渲染低于该值的所有像素。观察差异的一种方式是,我们的波形着色器是一个线图(实际上是散点图),我们的快速傅立叶变换着色器是一个条形图。
文件:res/raw/fft_fragment.shader
precision mediump float; // default medium precision
uniform sampler2D u_Texture; // the input texture
varying vec2 v_TexCoordinate; // interpolated texture coordinate per fragment
uniform vec4 u_Color;
void main() {
vec4 color;
if(v_TexCoordinate.y < texture2D(u_Texture, v_TexCoordinate).r){
color = u_Color;
}
gl_FragColor = color;
}
FFTMaterial
类的代码与我们为WaveformMaterial
类所做的非常相似。为了简洁起见,只需将该文件复制到一个名为FFTMaterial.java
的新文件中。然后,修改如下。
确保类名和构造器方法名现在读作FFTMaterial
:
public class FFTMaterial extends Material {
private static final String TAG = "FFTMaterial";
...
public FFTMaterial(){
...
我们决定将borderColor
数组更改为不同的色调:
public float[] borderColor = new float[]{0.84f, 0.65f, 1f, 1f};
在setupProgram
中,确保您正在引用R.raw.fft_vertex
和R.raw.fft_fragment
着色器:
program = createProgram( R.raw.fft_vertex, R.raw.fft_fragment);
然后,确保设置了适当的着色器特定参数。这些着色器使用u_Color
(但不是u_Width
变量):
//Shader-specific parameters
textureParam = GLES20.glGetUniformLocation(program, "u_Texture");
MVPParam = GLES20.glGetUniformLocation(program, "u_MVP");
colorParam = GLES20.glGetUniformLocation(program, "u_Color");
RenderBox.checkGLError("FFT params");
现在,在draw
方法中,我们将使用VisualizerBox.fftTexture
值(而不是VisualizerBox.audioTexture
值)进行绘制,因此将调用更改为GLES20.glBindTexture
,如下所示:
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, VisualizerBox.fftTexture);
确保设置了colorParam
参数(但与WaveformMaterial
类不同,这里没有宽度参数):
GLES20.glUniform4fv(colorParam, 1, borderColor, 0);
我们现在可以添加快速傅立叶变换数据的可视化。在visualizations/
文件夹中,将WaveformVisualization.java
文件复制到名为FFTVisualization.java
的新文件中。确保其定义如下:
public class FFTVisualization extends Visualization {
在它的setup
方法中,我们将创建一个Plane
组件并用FFTMaterial
类对它进行纹理化,就像这样(还要注意修改位置和旋转值):
public void setup() {
plane = new Plane().setMaterial(new FFTMaterial()
.setBuffers(Plane.vertexBuffer, Plane.texCoordBuffer, Plane.indexBuffer, Plane.numIndices));
new Transform()
.setLocalPosition(5, 0, 0)
.setLocalRotation(0, -90, 0)
.addComponent(plane);
}
现在在MainActivity
的onCreate
中,用这个替换之前的可视化:
visualizerBox.activeViz = new FFTVisualization(visualizerBox);
当你运行这个项目时,我们会看到一个像这样的可视化,旋转并放置在右边:
这个简单的例子说明了快速傅立叶变换数据将音频的空间频率分离成离散的数据值。即使不了解底层数学(这并不重要),知道数据随着音乐同步变化和流动也就足够了。我们用它来驱动纹理贴图。快速傅立叶变换也可以像我们在第一个例子中使用波形数据一样使用,以驱动场景中 3D 对象的属性,包括位置、比例和旋转,以及参数化定义的几何图形。事实上,对于这种目的,它通常是更好的数据通道。每个条对应于一个单独的频率范围,因此您可以指定特定的对象来响应高频和低频。
如果你是渴望迷幻模拟,我们将在我们的可视化中引入一个“迷途模式”!实现被添加到RenderBox
库本身。如果您正在使用已完成的RenderBox
库,那么只需在您的应用中打开模式。例如,在MainActivity
的setup()
中,在末尾添加以下一行代码:
RenderBox.mainCamera.trailsMode = true;
要在你的RenderBox
库中实现它,打开那个项目(在 Android Studio 中)。在Camera
类(components/Camera.java
文件)中,添加public boolean trailsMode
:
public boolean trailsMode;
然后,在onDrawEye
中,我们将在整个帧上绘制一个全屏四边形,带有阿尔法透明度,而不是为新帧擦除屏幕,从而留下最后一帧的幽灵般的褪色图像。随后的每一帧都被更多的半透明黑色透支,导致它们随着时间的推移而淡出。按如下方式定义颜色值:
public static float[] customClearColor = new float[]{0,0,0,0.05f};
然后,修改onDrawEye
,内容如下:
public void onDrawEye(Eye eye) {
if(trailsMode) {
GLES20.glEnable(GLES20.GL_BLEND);
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA);
customClear(customClearColor);
GLES20.glEnable(GLES20.GL_DEPTH_TEST);
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT);
} else {
GLES20.glEnable(GLES20.GL_DEPTH_TEST);
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);
}
...
customClear
方法跳过清除调用,留下前一帧的颜色。取而代之的是,它只是绘制了一个半透明的全屏黑色四边形,具有透明度,每一帧都会略微变暗“旧”图像。在我们这样做之前,相机需要一个着色器程序来绘制全屏纯色。
fullscreen_solid_color_vertex.shader
如下:
attribute vec4 v_Position;
void main() {
gl_Position = v_Position;
}
fullscreen_solid_color_fragment.shader
如下:
precision mediump float;
uniform vec4 u_Color;
void main() {
gl_FragColor = u_Color;
}
现在回到Camera
组件。我们设置了程序,并定义了全屏四边形网格、缓冲区和其他变量。首先,我们定义我们需要的变量:
static int program = -1;
static int positionParam, colorParam;
static boolean setup;
public static FloatBuffer vertexBuffer;
public static ShortBuffer indexBuffer;
public static final int numIndices = 6;
public boolean trailsMode;
public static final float[] COORDS = new float[] {
-1.0f, 1.0f, 0.0f,
1.0f, 1.0f, 0.0f,
-1.0f, -1.0f, 0.0f,
1.0f, -1.0f, 0.0f
};
public static final short[] INDICES = new short[] {
0, 1, 2,
1, 3, 2
};
public static float[] customClearColor = new float[]{0,0,0,0.05f};
然后,定义一种设置程序的方法:
public static void setupProgram(){
if(program > -1) //This means program has been set up //(valid program or error)
return;
//Create shader program
program = Material.createProgram(R.raw.fullscreen_solid_color_vertex, R.raw.fullscreen_solid_color_fragment);
//Get vertex attribute parameters
positionParam = GLES20.glGetAttribLocation(program, "v_Position");
//Enable vertex attribute parameters
GLES20.glEnableVertexAttribArray(positionParam);
//Shader-specific parameters
colorParam = GLES20.glGetUniformLocation(program, "u_Color");
RenderBox.checkGLError("Fullscreen Solid Color params");
}
定义一种方法来分配缓冲区:
public static void allocateBuffers(){
setup = true;
vertexBuffer = RenderObject.allocateFloatBuffer(COORDS);
indexBuffer = RenderObject.allocateShortBuffer(INDICES);
}
然后,从Camera
初始化器调用这些:
public Camera(){
transform = new Transform();
setupProgram();
allocateBuffers();
}
最后,我们可以实现customClear
方法:
public static void customClear(float[] clearColor){
GLES20.glUseProgram(program);
// Set the position buffer
GLES20.glVertexAttribPointer(positionParam, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer);
GLES20.glUniform4fv(colorParam, 1, clearColor, 0);
GLES20.glDrawElements(GLES20.GL_TRIANGLES, numIndices, GLES20.GL_UNSIGNED_SHORT, indexBuffer);
}
重建RenderBox
模块,将库文件复制回这个VisualizeVR
项目。别忘了将trailsMode
设置为true
!
现在,当你运行这个应用时,它看起来很奇怪,很酷!
现在我们有了可视化的集合,我们可以增强应用一次运行多个,并在它们之间切换。
要支持多个并发可视化,请将VisualizerBox
中的activeViz
变量替换为visualizations
列表:
public List<Visualization> visualizations = new ArrayList<Visualization|();
然后,在每个使用它的VisualizerBox
方法中循环列表。我们总是想把它们都设置好,但是只画出(preDraw
、postDraw
)活动的:
public void setup() {
audioTexture = genTexture();
fftTexture = genTexture();
for (Visualization viz : visualizations) {
viz.setup();
}
}
public void preDraw() {
for (Visualization viz : visualizations) {
viz.preDraw();
}
}
public void postDraw() {
for (Visualization viz : visualizations) {
viz.postDraw();
}
}
我们可以控制MainActivity
中的场景。修改MainActivity
类的onCreate
方法以填充visualizations
列表,如下所示:
visualizerBox = new VisualizerBox(cardboardView);
visualizerBox.visualizations.add( new GeometricVisualization(visualizerBox));
visualizerBox.visualizations.add( new WaveformVisualization(visualizerBox));
visualizerBox.visualizations.add( new FFTVisualization(visualizerBox));
运行项目,我们有一个充满可视化的三维场景!
随着时间的推移,我们可以通过添加和删除可视化来切换它们。在下面的例子中,我们从一个活动的可视化开始,然后每隔几秒钟,打开或关闭一个随机的可视化。
首先,向抽象的Visualization
类添加一个activate
方法,该方法接受一个布尔使能的参数。布尔活动变量是只读的:
public boolean active = true;
public abstract void activate(boolean enabled);
它的实现将取决于具体的可视化。RenderBox
库提供了一个enabled
标志,在我们渲染对象时使用。实例化单个Plane
组件的组件是最容易的,例如WaveformVisualization
和FFTVisualization
。向其中的每一个添加以下代码:
@Override
public void activate(boolean enabled) {
active = enabled;
plane.enabled = enabled;
}
对于GeometricVisualization
类,我们可以启用(和禁用)每个组件立方体:
@Override
public void activate(boolean enabled) {
active = enabled;
for(int i = 0; i < cubes.length; i++) {
cubeRenderers[i].enabled = enabled;
}
}
现在我们可以在MainActivity
类内控制这一点。
从每个不活动的visualizations
开始。将此初始化添加到MainActivity
的setup()
中:
for (Visualization viz : visualizerBox.visualizations) {
viz.activate(false);
}
在MainActivity
的preDraw
中,我们将检查当前时间(使用RenderBox
库的Time
类),并在每 3 秒钟后切换一次随机可视化。首先,在类的顶部添加几个变量:
float timeToChange = 0f;
final float CHANGE_DELAY = 3f;
final Random rand = new Random();
现在可以修改preDraw
查看时间,修改visualizations
列表:
public void preDraw() {
if (Time.getTime() > timeToChange) {
int idx = rand.nextInt( visualizerBox.visualizations.size() );
Visualization viz = visualizerBox.visualizations.get(idx);
viz.activate(!viz.active);
timeToChange += CHANGE_DELAY;
}
visualizerBox.preDraw();
}
类似的时间控制结构(或增量时间)可用于实现多种动画,如改变可视化对象的位置、旋转和/或比例,或随时间演变几何本身。
我们希望我们已经给了你一些工具来让你继续你自己的音乐可视化。正如我们在本章中所建议的,选项是无限的。不幸的是,空间阻止我们在这里对越来越多的东西进行编码。
- 动画:我们已经将最简单的变换应用到我们的每一个可视化中:一个简单的位置、比例,也许还有 90 度旋转。自然,位置、旋转和缩放可以被动画化,也就是说,与音乐协调地为每个帧更新,或者使用
Time.deltaTime
独立于音乐。东西可以在你周围飞来飞去! - 高级纹理和着色器:我们的着色器和数据驱动纹理是最基础的:从根本上渲染对应音频字节值的单个颜色像素。音频数据可以被输入到更加复杂和有趣的算法中,以生成新的图案和颜色和/或用于变形预加载的纹理。
- 纹理贴图:将项目中的纹理材质简单的映射到一个平面上。嘿,伙计,这是虚拟现实!将纹理映射到光球或其他几何图形上,让用户完全沉浸其中。
- 渲染到纹理:我们的轨迹模式对于这些可视化效果来说看起来不错,但是对于足够复杂的东西来说可能会变得一团糟。相反,你可以只在你的纹理平面的表面使用它。设置 RTs 很复杂,超出了本书的范围。本质上,您将另一个相机引入场景,指导 OpenGL 将后续的绘制调用渲染到您创建的新曲面上,并将该曲面用作要渲染到其上的对象的纹理缓冲区。RT 是一个强大的概念,支持反射和游戏内安全摄像头等技术。此外,您可以对表面应用变换,使轨迹看起来像是飞向远方,这是传统可视化工具中的一种流行效果,如的 milk drop(https://en.wikipedia.org/wiki/MilkDrop)。
- 参数几何:音频数据可以用来驱动不同复杂度的 3D 几何模型的定义和渲染。想想分形、晶体和三维多面体。来看看戈德堡多面体(参考http://schoengeometry.com/)和神圣几何(参考http://www.geometrycode.com/sacred-geometry/)获得灵感。
我们邀请您与本书的其他读者和整个纸板社区分享您自己的可视化。一种方法是通过我们的 GitHub 存储库。如果你创建了一个新的可视化,在https://github.com/cardbookvr/visualizevr把它作为拉取请求提交给项目,或者创建你自己的整个项目的分叉!
在本章中,我们构建了一个音乐可视化工具,作为一个纸板虚拟现实应用运行。我们设计了一个通用的架构,允许您定义多个可视化,将它们插入应用,并在它们之间转换。该应用使用安卓Visualization
应用编程接口从手机当前的音频播放器中捕获波形和快速傅立叶变换数据。
首先,我们定义了VisualizerBox
类,负责安卓Visualizer
应用编程接口的活动和回调函数。然后,我们定义了一个抽象的Visualization
类来实现各种可视化。然后,我们将波形音频数据捕捉添加到VisualizerBox
中,并使用它来参数化动画化一系列立方体,以制作 3D 波形框。接下来,我们编写了第二个可视化工具;这一次使用波形数据来动态生成纹理,该纹理是用材质着色器程序渲染的。最后,我们捕获了快速傅立叶变换音频数据,并将其用于第三次可视化。然后,我们添加了更多的乐趣与一个三步走模式和多个并发的可视化,随机过渡到和退出。
我们承认视觉示例非常简单,但希望它们能激发你的想象力。我们挑战你建立你自己的 3D 虚拟现实音乐可视化,也许利用本项目中的技术以及本书中的其他东西的组合。
我们希望你喜欢这个介绍,并通过为安卓开发纸板虚拟现实的旅程。在这本书里,我们探索了谷歌纸板 Java SDK、OpenGL ES 2.0 图形和安卓开发。我们谈到了许多虚拟现实的最佳实践,并看到了移动平台上低级图形开发的局限性。尽管如此,如果你坚持下去,你已经成功地实现了一个合理的三维图形和虚拟现实开发通用库。您创建了各种各样的虚拟现实应用,包括应用启动器、太阳系模拟、360 度媒体画廊、3D 模型查看器和音乐可视化工具。
自然,我们期望 Cardboard Java SDK 从这一点开始改变、发展和成熟。没有人真正知道未来会怎样,甚至谷歌也不知道。然而,我们正处在一个大胆的新未来的边缘。预测未来的最好方法是帮助发明未来。现在轮到你了!