适用于 Android 的 OpenGL 教程:构建 Mandelbrot 集生成器
已发表: 2022-03-11OpenGL 是一个强大的跨平台 API,它允许在各种编程环境中非常接近地访问系统硬件。
那么,为什么要使用它呢?
它为 2D 和 3D 图形提供了非常低级的处理。 一般来说,这将避免我们因解释或高级编程语言而遇到的任何麻烦。 不过,更重要的是,它还提供了对关键功能的硬件级别访问:GPU。
GPU 可以显着加速许多应用程序,但它在计算机中具有非常特殊的作用。 GPU 内核实际上比 CPU 内核慢。 如果我们要运行一个特别是没有并发活动的串行程序,那么它在 GPU 内核上几乎总是比 CPU 内核慢。 主要区别在于GPU支持大规模并行处理。 我们可以创建称为着色器的小程序,这些程序将同时在数百个内核上有效运行。 这意味着我们可以将原本极其重复的任务同时运行。
在本文中,我们将构建一个简单的 Android 应用程序,该应用程序使用 OpenGL 在屏幕上呈现其内容。 在开始之前,重要的是您已经熟悉编写 Android 应用程序的知识和一些类 C 编程语言的语法。 本教程的完整源代码可在 GitHub 上找到。
OpenGL教程和Android
为了演示 OpenGL 的强大功能,我们将为 Android 设备编写一个相对基本的应用程序。 现在,Android 上的 OpenGL 分布在一个名为 OpenGL for Embedded Systems (OpenGL ES) 的子集下。 我们基本上可以将其视为 OpenGL 的精简版,尽管所需的核心功能仍然可用。
我们将编写一个看似简单的应用程序,而不是编写一个基本的“Hello World”:Mandelbrot 集合生成器。 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.Renderer
的扩展和GLSurfaceView
的实现。 从那里,我们将创建各种封装图纸的模型。
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 config)
、 onSurfaceChanged(GL10 gl, int width, int height)
和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) 编写的,这是一种基于 C 的语言,添加了许多内置函数、变量修饰符、原语和默认输入/输出。 在 Android 上,这些将作为最终字符串通过loadShader(int type, String shaderCode)
传递,这是渲染器中的两个资源方法之一。 让我们首先回顾一下不同类型的限定符:
-
const
:任何最终变量都可以声明为常量,因此可以存储其值以便于访问。 如果像 π 这样的数字在整个着色器中经常使用,则可以将它们声明为常量。 编译器可能会自动将未修改的值声明为常量,具体取决于实现。 -
uniform
:Uniform 变量是对于任何单个渲染都被声明为常量的变量。 它们本质上用作着色器的静态参数。 -
varying
:如果一个变量被声明为可变并在顶点着色器中设置,那么它在片段着色器中被线性插值。 这对于创建任何类型的颜色渐变很有用,并且对于深度变化是隐含的。 -
attribute
:属性可以被认为是着色器的非静态参数。 它们表示特定于顶点的输入集,并且只会出现在顶点着色器中。
此外,我们应该讨论已添加的另外两种类型的原语:
-
vec2
,vec3
,vec4
:给定维度的浮点向量。 -
mat2
,mat3
,mat4
:给定维度的浮点矩阵。
向量可以通过它们的分量x
、 y
、 z
和w
或r
、 g
、 b
和a
来访问。 它们还可以生成具有多个索引的任何大小的向量:对于vec3 a
, a.xxyz
返回一个vec4
以及对应的a
值。
矩阵和向量也可以索引为数组,矩阵将返回一个只有一个分量的向量。 这意味着对于mat2 matrix
, matrix[0].a
是有效的并且将返回matrix[0][0]
。 使用这些时,请记住它们充当原语,而不是对象。 例如,考虑以下代码:
vec2 a = vec2(1.0,1.0); vec2 b = a; bx=2.0;
这留下a=vec2(1.0,1.0)
和b=vec2(2.0,1.0)
,这不是人们对对象行为的期望,第二行会给b
一个指向a
的指针。
在 Mandelbrot Set 中,大部分代码将在片段着色器中,这是在每个像素上运行的着色器。 名义上,顶点着色器适用于每个顶点,包括将基于每个顶点的属性,例如颜色或深度的更改。 让我们看一下分形的极其简单的顶点着色器:
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(px*px-py*py,2.0*px*py)+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
和clamp
,它们都对向量或标量进行操作并返回向量或标量。 此外,使用dot
接受向量参数并返回一个标量。
现在我们已经设置好要使用的着色器,我们还有最后一步,即在我们的模型中实现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"); }
该函数将所有参数传递给着色器,包括uniform
变换矩阵和attribute
位置。
在组装程序的所有部分之后,我们终于可以运行它了。 如果处理了适当的触摸支持,将绘制绝对令人着迷的场景:
浮点精度
如果我们再放大一点,我们开始注意到图像中的一个故障:
这与它背后的数学运算完全无关,也与数字在 OpenGL 中存储和处理的方式有关。 虽然最近对double
精度进行了支持,但 OpenGL 2.0 本身并不支持float
以外的任何东西。 我们专门将它们指定为在我们的着色器中具有precision highp float
的最高精度浮点,但即使这样还不够好。
为了解决这个问题,唯一的方法是使用两个float
模拟double
。 这种方法实际上在本地实现的实际精度的一个数量级范围内,尽管速度成本相当高。 如果希望获得更高的准确性,这将作为练习留给读者。
结论
通过一些支持类,OpenGL 可以快速支持复杂场景的实时渲染。 创建一个由GLSurfaceView
组成的布局,设置它的Renderer
,并创建一个带有着色器的模型,所有这些都在一个漂亮的数学结构的可视化中达到了高潮。 我希望您会对开发 OpenGL ES 应用程序产生同样的兴趣!