本文概述
OpenGL是功能强大的跨平台API, 它允许在各种编程环境中非常紧密地访问系统的硬件。
那么, 为什么要使用它呢?
它为2D和3D图形提供了非常低级的处理。通常, 这将避免由于解释型或高级编程语言而造成的任何麻烦。不过, 更重要的是, 它还提供对关键功能的硬件级别访问:GPU。
GPU可以显着加快许多应用程序的运行速度, 但是它在计算机中具有非常特殊的作用。实际上, GPU内核比CPU内核慢。如果我们运行的程序是串行的, 并且没有并发活动, 那么在GPU内核上它总是总是比在CPU内核上慢。主要区别在于GPU支持大规模并行处理。我们可以创建称为着色器的小程序, 这些程序可以一次在数百个内核上有效运行。这意味着我们可以执行原本难以置信的重复任务, 并同时运行它们。
在本文中, 我们将构建一个使用OpenGL在屏幕上呈现其内容的简单Android应用程序。在开始之前, 重要的是你已经熟悉编写Android应用程序的知识和某些C语言编程语言的语法。本教程的完整源代码可在GitHub上找到。
OpenGL教程和Android
为了演示OpenGL的功能, 我们将为Android设备编写一个相对基本的应用程序。现在, Android上的OpenGL分布在称为嵌入式系统OpenGL(OpenGL ES)的子集下。我们基本上可以将其视为精简版的OpenGL, 尽管所需的核心功能仍将可用。
我们将编写一个看似简单的应用程序:Mandelbrot集生成器, 而不是编写基本的” Hello World”。 Mandelbrot集基于复数字段。复杂分析是一个美丽而广阔的领域, 因此我们将重点放在视觉结果上, 而不是背后的实际数学上。
使用OpenGL, 构建Mandelbrot集生成器比你想象的要容易!
鸣叫
版本支持
在制作应用程序时, 我们要确保仅将其分发给具有适当OpenGL支持的用户。首先在清单文件声明和应用程序之间声明清单文件中使用OpenGL 2.0:
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
至此, 对OpenGL 2.0的支持无处不在。 OpenGL 3.0和3.1的兼容性不断提高, 但是为其中任何一种编写将大约占设备的65%, 因此只有在确定需要其他功能时才做出决定。可以通过将版本分别设置为” 0x000300000″和” 0x000300001″来实现。
应用架构
在Android上制作此OpenGL应用程序时, 通常会使用三个主要类来绘制表面:MainActivity, GLSurfaceView的扩展和GLSurfaceView.Renderer的实现。从那里, 我们将创建各种将封装图纸的模型。
MainActivity在此示例中称为FractalGenerator, 实际上仅是要实例化你的GLSurfaceView并将所有全局更改向下传递。这是一个示例, 实际上将是你的样板代码:
public class FractalGenerator extends Activity {
private GLSurfaceView mGLView;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//Create and set GLSurfaceView
mGLView = new FractalSurfaceView(this);
setContentView(mGLView);
}
//[...]
@Override
protected void onPause() {
super.onPause();
mGLView.onPause();
}
@Override
protected void onResume() {
super.onResume();
mGLView.onResume();
}
}
这也是将要在其中放置其他任何活动级别修饰符(例如沉浸式全屏)的类。
更深入一类, 我们对GLSurfaceView进行了扩展, 它将作为我们的主要视图。在此类中, 我们设置版本, 设置渲染器并控制触摸事件。在我们的构造函数中, 我们只需要使用setEGLContextClientVersion(int version)设置OpenGL版本, 并创建并设置渲染器即可:
public FractalSurfaceView(Context context){
super(context);
setEGLContextClientVersion(2);
mRenderer = new FractalRenderer();
setRenderer(mRenderer);
}
另外, 我们可以使用setRenderMode(int renderMode)设置属性, 例如渲染模式。由于生成Mandelbrot集的成本可能非常高, 因此我们将使用RENDERMODE_WHEN_DIRTY, 它将仅在初始化时以及显式调用requestRender()时呈现场景。可以在GLSurfaceView API中找到更多设置选项。
有了构造函数后, 我们可能要重写至少一个其他方法:onTouchEvent(MotionEvent event), 该方法可用于基于常规触摸的用户输入。在这里, 我将不做过多的详细介绍, 因为这不是本课程的重点。
最后, 我们进入渲染器, 这将是大多数照明工作或场景变化发生的地方。首先, 我们必须稍微了解一下矩阵在图形世界中的工作方式和操作方式。
线性代数快速入门
OpenGL在很大程度上依赖于矩阵的使用。矩阵是表示坐标中广义变化序列的一种非常紧凑的方式。通常, 它们使我们能够进行任意旋转, 膨胀/收缩和反射, 但是只要稍加技巧, 我们就可以进行翻译。从本质上讲, 所有这些都意味着你可以轻松地进行所需的任何合理更改, 包括移动相机或使对象增长。通过将矩阵乘以代表坐标的矢量, 我们可以有效地产生新的坐标系。
OpenGL提供的Matrix类提供了许多我们需要的现成的计算矩阵方式, 但是即使使用简单的转换, 了解它们的工作原理也是一个聪明的主意。
首先, 我们可以探讨为什么我们将使用四个维矢量和矩阵来处理坐标。这实际上可以追溯到我们对坐标的使用进行精细化处理以能够进行平移的想法:虽然仅使用三个维度就不可能在3D空间中进行平移, 但是增加第四个维度就可以实现此功能。
为了说明这一点, 我们可以使用一个非常基本的通用比例/转换矩阵:
请注意, OpenGL矩阵是逐列的, 因此此矩阵将写为{a, 0, 0, 0, 0, b, 0, 0, 0, 0, c, 0, v_a, v_b, v_c, 1}, 与通常的阅读方式垂直。通过确保向量(以乘法形式显示为一列)具有与矩阵相同的格式, 可以使其合理化。
返回代码
有了矩阵知识, 我们可以回到设计渲染器的过程。通常, 我们将在此类中创建一个矩阵, 该矩阵由以下三个矩阵的乘积构成:模型, 视图和投影。适当地, 这将被称为MVPMatrix。你将在此处了解更多详细信息, 因为我们将使用一组更基本的转换-Mandelbrot集是二维的全屏模型, 它实际上并不需要相机的概念。
首先, 让我们设置课程。我们需要为Renderer界面实现所需的方法:onSurfaceCreated(GL10 gl, EGLConfig配置), onSurfaceChanged(GL10 gl, int宽度, int高度)和onDrawFrame(GL10 gl)。完整的类最终看起来像这样:
public class FractalRenderer implements GLSurfaceView.Renderer {
//Provide a tag for logging errors
private static final String TAG = "FractalRenderer";
//Create all models
private Fractal mFractal;
//Transformation matrices
private final float[] mMVPMatrix = new float[16];
//Any other private variables needed for transformations
@Override
public void onSurfaceCreated(GL10 unused, EGLConfig config) {
// Set the background frame color
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
//Instantiate all models
mFractal = new Fractal();
}
@Override
public void onDrawFrame(GL10 unused) {
//Clear the frame of any color information or depth information
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);
//Create a basic scale/translate matrix
float[] mMVPMatrix = new float[]{
-1.0f/mZoom, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f/(mZoom*mRatio), 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, -mX, -mY, 0.0f, 1.0f};
//Pass the draw command down the line to all models, giving access to the transformation matrix
mFractal.draw(mMVPMatrix);
}
@Override
public void onSurfaceChanged(GL10 unused, int width, int height) {
//Create the viewport as being fullscreen
GLES20.glViewport(0, 0, width, height);
//Change any projection matrices to reflect changes in screen orientation
}
//Other public access methods for transformations
}
所提供的代码中还使用了两种实用程序方法checkGLError和loadShaders来帮助调试和使用着色器。
在所有这些方面, 我们一直沿命令行传递命令, 以封装程序的不同部分。最终, 我们可以编写程序实际执行的操作, 而不是对程序进行理论上的更改。这样做时, 我们需要创建一个模型类, 其中包含场景中任何给定对象需要显示的信息。在复杂的3D场景中, 这可能是动物或茶壶, 但是我们将做一个分形作为一个更简单的2D示例。
在模型类中, 我们编写了整个类, 没有必须使用的超类。我们只需要有一个构造函数和某种接受任何参数的draw方法。
这就是说, 我们仍然需要具有许多变量, 这些变量本质上是样板。让我们看一下Fractal类中使用的确切构造函数:
public Fractal() {
// initialize vertex byte buffer for shape coordinates
ByteBuffer bb = ByteBuffer.allocateDirect(
// (# of coordinate values * 4 bytes per float)
squareCoords.length * 4);
bb.order(ByteOrder.nativeOrder());
vertexBuffer = bb.asFloatBuffer();
vertexBuffer.put(squareCoords);
vertexBuffer.position(0);
// initialize byte buffer for the draw list
ByteBuffer dlb = ByteBuffer.allocateDirect(
// (# of coordinate values * 2 bytes per short)
drawOrder.length * 2);
dlb.order(ByteOrder.nativeOrder());
drawListBuffer = dlb.asShortBuffer();
drawListBuffer.put(drawOrder);
drawListBuffer.position(0);
// Prepare shaders
int vertexShader = FractalRenderer.loadShader(
GLES20.GL_VERTEX_SHADER, vertexShaderCode);
int fragmentShader = FractalRenderer.loadShader(
GLES20.GL_FRAGMENT_SHADER, fragmentShaderCode);
// create empty OpenGL Program
mProgram = GLES20.glCreateProgram();
// add the vertex shader to program
GLES20.glAttachShader(mProgram, vertexShader);
// add the fragment shader to program
GLES20.glAttachShader(mProgram, fragmentShader);
// create OpenGL program executables
GLES20.glLinkProgram(mProgram);
}
满口, 不是吗?幸运的是, 这是程序的一部分, 你无需进行任何更改, 只需保存模型名称即可。只要你适当地更改类变量, 这对于基本形状就应该很好地工作。
为了讨论其中的一部分, 让我们看一些变量声明:
static float squareCoords[] = {
-1.0f, 1.0f, 0.0f, // top left
-1.0f, -1.0f, 0.0f, // bottom left
1.0f, -1.0f, 0.0f, // bottom right
1.0f, 1.0f, 0.0f }; // top right
private final short drawOrder[] = { 0, 1, 2, 0, 2, 3 }; // order to draw vertices
在squareCoords中, 我们指定正方形的所有坐标。请注意, 屏幕上的所有坐标都表示为一个网格, 其左下角为(-1, -1), 右上角为(1, 1)。
在drawOrder中, 我们根据构成正方形的三角形指定坐标的顺序。特别是为了一致性和速度, OpenGL使用三角形表示所有曲面。要制作一个正方形, 只需减少对角线(在这种情况下为0到2)即可得到两个三角形。
为了将这两个都添加到程序中, 首先必须将它们转换为原始字节缓冲区, 以将数组的内容直接与OpenGL接口连接。 Java将数组存储为包含其他信息的对象, 这些信息与OpenGL实现使用的基于指针的C数组不直接兼容。为了解决这个问题, 使用了ByteBuffers, 它存储对阵列原始内存的访问。
输入顶点数据和绘制顺序后, 必须创建着色器。
着色器
创建模型时, 必须制作两个着色器:”顶点”着色器和”片段(像素)”着色器。所有着色器均以GL着色语言(GLSL)编写, GL着色语言是一种基于C的语言, 并添加了许多内置函数, 变量修饰符, 基元和默认输入/输出。在Android上, 这些将作为最终字符串通过loadShader(int type, String shaderCode)传递, 这是Renderer中的两个资源方法之一。首先, 让我们研究一下不同类型的限定词:
- const:任何最终变量都可以声明为常量, 因此可以存储其值以便于访问。如果在整个着色器中经常使用像π这样的数字, 则可以将其声明为常量。取决于实现, 编译器可能会自动将未修改的值声明为常量。
- 统一变量:统一变量是对于任何单个渲染都声明为常量的变量。它们本质上用作着色器的静态参数。
- 可变:如果变量被声明为可变并且在顶点着色器中设置, 则在片段着色器中对其进行线性插值。这对于创建各种颜色的渐变很有用, 对于深度更改而言是隐式的。
- attribute:可将属性视为着色器的非静态参数。它们表示一组特定于顶点的输入, 仅在”顶点着色器”中显示。
此外, 我们应该讨论已添加的其他两种原始类型:
- vec2, vec3, vec4:给定尺寸的浮点向量。
- mat2, mat3, mat4:给定尺寸的浮点矩阵。
向量可以通过其分量x, y, z和w或r, g, b和a进行访问。它们还可以生成具有多个索引的任何大小的向量:对于vec3 a, a.xxyz返回带有相应a值的vec4。
矩阵和向量也可以索引为数组, 并且矩阵将返回仅包含一个分量的向量。这意味着对于mat2矩阵, matrix [0] .a是有效的, 并将返回matrix [0] [0]。当使用它们时, 请记住它们是作为基元而不是对象。例如, 考虑以下代码:
vec2 a = vec2(1.0, 1.0);
vec2 b = a;
b.x=2.0;
这样就留下了a = vec2(1.0, 1.0)和b = vec2(2.0, 1.0), 这不是对象行为所期望的, 第二行将给b指向a的指针。
在Mandelbrot集中, 大部分代码将位于片段着色器中, 片段着色器是在每个像素上运行的着色器。名义上, 顶点着色器可在每个顶点上使用, 包括将基于每个顶点的属性, 例如颜色或深度的更改。让我们来看看一个非常简单的分形顶点着色器:
private final String vertexShaderCode =
"attribute vec4 vPosition;" +
"void main() {" +
" gl_Position = vPosition;" +
"}";
在这种情况下, gl_Position是OpenGL定义的用于记录顶点坐标的输出变量。在这种情况下, 我们为我们设置gl_Position的每个顶点传递一个位置。在大多数应用程序中, 我们将vPosition乘以MVPMatrix, 以转换我们的顶点, 但是我们希望分形始终是全屏的。所有转换都将使用本地坐标系完成。
片段着色器将完成大部分工作以生成集合。我们将fragmentShaderCode设置为以下内容:
precision highp float;
uniform mat4 uMVPMatrix;
void main() {
//Scale point by input transformation matrix
vec2 p = (uMVPMatrix * vec4(gl_PointCoord, 0, 1)).xy;
vec2 c = p;
//Set default color to HSV value for black
vec3 color=vec3(0.0, 0.0, 0.0);
//Max number of iterations will arbitrarily be defined as 100. Finer detail with more computation will be found for larger values.
for(int i=0;i<100;i++){
//Perform complex number arithmetic
p= vec2(p.x*p.x-p.y*p.y, 2.0*p.x*p.y)+c;
if (dot(p, p)>4.0){
//The point, c, is not part of the set, so smoothly color it. colorRegulator increases linearly by 1 for every extra step it takes to break free.
float colorRegulator = float(i-1)-log(((log(dot(p, p)))/log(2.0)))/log(2.0);
//This is a coloring algorithm I found to be appealing. Written in HSV, many functions will work.
color = vec3(0.95 + .012*colorRegulator , 1.0, .2+.4*(1.0+sin(.3*colorRegulator)));
break;
}
}
//Change color from HSV to RGB. Algorithm from https://gist.github.com/patriciogonzalezvivo/114c1653de9e3da6e1e3
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 m = abs(fract(color.xxx + K.xyz) * 6.0 - K.www);
gl_FragColor.rgb = color.z * mix(K.xxx, clamp(m - K.xxx, 0.0, 1.0), color.y);
gl_FragColor.a=1.0;
}
许多代码只是该集合如何工作的数学和算法。注意使用几个内置函数:fract, abs, mix, sin和钳位, 它们全部对矢量或标量以及返回矢量或标量进行运算。另外, 使用点接受矢量参数并返回标量。
现在我们已经设置好着色器以供使用, 我们还有最后一步, 就是在模型中实现draw函数:
public void draw(float[] mvpMatrix) {
// Add program to OpenGL environment
GLES20.glUseProgram(mProgram);
// get handle to vertex shader's vPosition member
mPositionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition");
mMVPMatrixHandle = GLES20.glGetUniformLocation(mProgram, "uMVPMatrix");
//Pass uniform transformation matrix to shader
GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mvpMatrix, 0);
//Add attribute array of vertices
GLES20.glEnableVertexAttribArray(mPositionHandle);
GLES20.glVertexAttribPointer(
mPositionHandle, COORDS_PER_VERTEX, GLES20.GL_FLOAT, false, vertexStride, vertexBuffer);
// Draw the square
GLES20.glDrawElements(
GLES20.GL_TRIANGLES, drawOrder.length, GLES20.GL_UNSIGNED_SHORT, drawListBuffer);
// Disable vertex array
GLES20.glDisableVertexAttribArray(mPositionHandle);
FractalRenderer.checkGlError("Test");
}
该函数将所有参数传递给着色器, 包括统一变换矩阵和属性位置。
组装完程序的所有部分后, 我们终于可以运行它。只要能够正确处理触摸, 就可以绘制出绝对令人着迷的场景:
浮点精度
如果再放大一点, 就会开始注意到图像出现了故障:
这与背后的数学运算完全无关, 也与OpenGL中存储和处理数字的方式无关。尽管最近已经对双精度提出了支持, 但OpenGL 2.0本身并不支持浮点数。我们专门将它们指定为着色器中精度最高的浮点数中可用的最高精度的浮点数, 但即使这样还不够好。
为了解决此问题, 唯一的方法是使用两个浮点数模拟double。该方法实际上要比本地实现的方法的实际精度高一个数量级, 尽管要付出相当高的速度成本。如果希望更高的准确性, 这将留给读者练习。
总结
通过一些支持类, OpenGL可以快速维持复杂场景的实时渲染。创建一个由GLSurfaceView组成的布局, 设置其Renderer, 并创建一个包含着色器的模型, 最终以美观的数学结构可视化为最终结果。希望你会对开发OpenGL ES应用程序感兴趣!