Tutorial de OpenGL para Android: Creación de un generador de conjuntos de Mandelbrot

Publicado: 2022-03-11

OpenGL es una poderosa API multiplataforma que permite un acceso muy cercano al hardware del sistema en una variedad de entornos de programación.

Entonces, ¿por qué deberías usarlo?

Proporciona un procesamiento de muy bajo nivel para gráficos tanto en 2D como en 3D. En general, esto evitará cualquier clunk que tengamos debido a lenguajes de programación interpretados o de alto nivel. Sin embargo, lo que es más importante, también proporciona acceso a nivel de hardware a una característica clave: GPU.

La GPU puede acelerar significativamente muchas aplicaciones, pero tiene un papel muy específico en una computadora. Los núcleos de GPU son, de hecho, más lentos que los núcleos de CPU. Si tuviéramos que ejecutar un programa que es notablemente serial sin actividad concurrente, entonces casi siempre será más lento en un núcleo de GPU que en un núcleo de CPU. La principal diferencia es que la GPU admite procesamiento paralelo masivo. Podemos crear pequeños programas llamados sombreadores que se ejecutarán de manera efectiva en cientos de núcleos a la vez. Esto significa que podemos tomar tareas que de otro modo serían increíblemente repetitivas y ejecutarlas simultáneamente.

Generador de conjuntos OpenGL y Mandelbrot

En este artículo, crearemos una aplicación de Android simple que usa OpenGL para representar su contenido en la pantalla. Antes de comenzar, es importante que ya esté familiarizado con el conocimiento de cómo escribir aplicaciones de Android y la sintaxis de algún lenguaje de programación similar a C. El código fuente completo de este tutorial está disponible en GitHub.

Tutorial de OpenGL y Android

Para demostrar el poder de OpenGL, escribiremos una aplicación relativamente básica para un dispositivo Android. Ahora, OpenGL en Android se distribuye bajo un subconjunto llamado OpenGL for Embedded Systems (OpenGL ES). Básicamente, podemos pensar en esto como una versión simplificada de OpenGL, aunque la funcionalidad principal que se necesita seguirá estando disponible.

En lugar de escribir un "Hola mundo" básico, escribiremos una aplicación engañosamente simple: un generador de conjuntos de Mandelbrot. El conjunto de Mandelbrot se basa en el campo de los números complejos. El análisis complejo es un campo maravillosamente vasto, por lo que nos centraremos en el resultado visual más que en las matemáticas reales detrás de él.

¡Con OpenGL, construir un generador de conjuntos de Mandelbrot es más fácil de lo que piensas!
Pío

Soporte de versión

soporte de versión opengl

Cuando creamos la aplicación, queremos asegurarnos de que solo se distribuya a aquellos con soporte adecuado para OpenGL. Comience declarando el uso de OpenGL 2.0 en el archivo de manifiesto, entre la declaración de manifiesto y la aplicación:

 <uses-feature android:glEsVersion="0x00020000" android:required="true" />

En este punto, el soporte para OpenGL 2.0 es omnipresente. OpenGL 3.0 y 3.1 están ganando en compatibilidad, pero escribir para cualquiera dejará fuera aproximadamente el 65% de los dispositivos, así que solo tome la decisión si está seguro de que necesitará funcionalidad adicional. Se pueden implementar configurando la versión en '0x000300000' y '0x000300001' respectivamente.

Arquitectura de la aplicación

arquitectura de aplicaciones opengl

Al crear esta aplicación OpenGL en Android, generalmente tendrá tres clases principales que se usan para dibujar la superficie: su MainActivity , una extensión de GLSurfaceView y una implementación de GLSurfaceView.Renderer . A partir de ahí, crearemos varios Modelos que encapsularán dibujos.

MainActivity , llamado FractalGenerator en este ejemplo, esencialmente solo va a crear una instancia de su GLSurfaceView y enrutar cualquier cambio global en el futuro. Aquí hay un ejemplo que esencialmente será su código repetitivo:

 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(); } }

Esta también será la clase en la que querrá poner cualquier otro modificador de nivel de actividad (como pantalla completa inmersiva).

Una clase más profunda, tenemos una extensión de GLSurfaceView , que actuará como nuestra vista principal. En esta clase, configuramos la versión, configuramos un Renderer y controlamos los eventos táctiles. En nuestro constructor, solo necesitamos configurar la versión de OpenGL con setEGLContextClientVersion(int version) y también crear y configurar nuestro renderizador:

 public FractalSurfaceView(Context context){ super(context); setEGLContextClientVersion(2); mRenderer = new FractalRenderer(); setRenderer(mRenderer); }

Además, podemos establecer atributos como el modo de renderizado con setRenderMode(int renderMode) . Debido a que generar un conjunto de Mandelbrot puede ser muy costoso, RENDERMODE_WHEN_DIRTY , que solo representará la escena en la inicialización y cuando se realicen llamadas explícitas a requestRender() . Se pueden encontrar más opciones de configuración en la API de GLSurfaceView .

Una vez que tengamos el constructor, probablemente querremos anular al menos otro método: onTouchEvent(MotionEvent event) , que se puede usar para la entrada de usuario táctil general. No voy a entrar en demasiados detalles aquí, ya que ese no es el enfoque principal de la lección.

Finalmente, llegamos a nuestro Renderer, que será donde sucederá la mayor parte del trabajo de iluminación o quizás cambios en la escena. Primero, tendremos que analizar un poco cómo funcionan y funcionan las matrices en el mundo de los gráficos.

Lección rápida de álgebra lineal

OpenGL se basa en gran medida en el uso de matrices. Las matrices son una forma maravillosamente compacta de representar secuencias de cambios generalizados en coordenadas. Normalmente, nos permiten hacer rotaciones arbitrarias, dilataciones/contracciones y reflejos, pero con un poco de delicadeza también podemos hacer traslaciones. Esencialmente, todo esto significa que puede realizar fácilmente cualquier cambio razonable que desee, incluido mover una cámara o hacer crecer un objeto. Al multiplicar nuestras matrices por un vector que representa nuestra coordenada, podemos producir efectivamente el nuevo sistema de coordenadas.

La clase Matrix proporcionada por OpenGL brinda varias formas listas para usar de calcular las matrices que necesitaremos, pero comprender cómo funcionan es una idea inteligente, incluso cuando se trabaja con transformaciones simples.

Primero, podemos repasar por qué usaremos vectores y matrices de cuatro dimensiones para trabajar con coordenadas. En realidad, esto se remonta a la idea de afinar nuestro uso de coordenadas para poder hacer traducciones: mientras que una traducción en el espacio 3D es imposible usando solo tres dimensiones, agregar una cuarta dimensión habilita la capacidad.

Para ilustrar esto, podemos usar una escala general muy básica/matriz de traducción:

escala opengl y matriz de traducción

Como nota importante, las matrices de OpenGL son por columnas, por lo que esta matriz se escribiría como {a, 0, 0, 0, 0, b, 0, 0, 0, 0, c, 0, v_a, v_b, v_c, 1} , que es perpendicular a cómo se leerá normalmente. Esto se puede racionalizar asegurándose de que los vectores, que aparecen en la multiplicación como una columna, tengan el mismo formato que las matrices.

Volver al Código

Armados con este conocimiento de las matrices, podemos volver a diseñar nuestro Renderer. Por lo general, crearemos una matriz en esta clase que se forma a partir del producto de tres matrices: Modelo, Vista y Proyección. Esto se llamaría, apropiadamente, MVPMatrix. Puede obtener más información sobre los detalles aquí, ya que vamos a utilizar un conjunto de transformaciones más básico: el conjunto de Mandelbrot es un modelo de pantalla completa en 2 dimensiones, y realmente no requiere la idea de una cámara.

Primero, configuremos la clase. Tendremos que implementar los métodos necesarios para la interfaz Renderer: onSurfaceCreated(GL10 gl, EGLConfig config) , onSurfaceChanged(GL10 gl, int width, int height) y onDrawFrame(GL10 gl) . La clase completa terminará luciendo algo como esto:

 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 }

También se utilizan dos métodos de utilidad en el código proporcionado, checkGLError y loadShaders para ayudar en la depuración y el uso de sombreadores.

En todo esto, seguimos pasando la cadena de mando por la línea para encapsular las diferentes partes del programa. Finalmente hemos llegado al punto en que podemos escribir lo que nuestro programa realmente hace , en lugar de cómo podemos hacerle cambios teóricos. Al hacer esto, necesitamos crear una clase de modelo que contenga la información que debe mostrarse para cualquier objeto dado en la escena. En escenas 3D complejas, esto podría ser un animal o una tetera, pero vamos a hacer un fractal como un ejemplo 2D mucho más simple.

En las clases modelo, escribimos la clase completa; no hay superclases que deban usarse. Solo necesitamos tener un constructor y algún tipo de método de dibujo que tome cualquier parámetro.

Dicho esto, todavía hay una serie de variables que necesitaremos tener que son esencialmente repetitivas. Echemos un vistazo al constructor exacto utilizado en la clase 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); }

Todo un bocado, ¿no? Por suerte, esta es una parte del programa que no tendrás que cambiar en absoluto, salvo el nombre del modelo. Siempre que cambie las variables de clase de manera adecuada, esto debería funcionar bien para las formas básicas.

Para discutir partes de esto, veamos algunas declaraciones de variables:

 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

En squareCoords , especificamos todas las coordenadas del cuadrado. Tenga en cuenta que todas las coordenadas en la pantalla se representan como una cuadrícula con (-1,-1) en la parte inferior izquierda y (1,1) en la parte superior derecha.

En drawOrder , especificamos el orden de las coordenadas en función de los triángulos que formarían el cuadrado. Particularmente por consistencia y velocidad, OpenGL usa triángulos para representar todas las superficies. Para hacer un cuadrado, simplemente corta una diagonal (en este caso, 0 a 2 ) para obtener dos triángulos.

Para agregar ambos al programa, primero debe convertirlos en un búfer de bytes sin formato para conectar directamente el contenido de la matriz con la interfaz OpenGL. Java almacena matrices como objetos que contienen información adicional que no es directamente compatible con las matrices C basadas en punteros que utiliza la implementación de OpenGL. Para remediar esto, se utilizan ByteBuffers , que almacenan el acceso a la memoria sin procesar de la matriz.

Después de haber ingresado los datos para los vértices y el orden de dibujo, debemos crear nuestros sombreadores.

sombreadores

Al crear un modelo, se deben crear dos shaders: un Vertex Shader y un Fragment (Pixel) Shader. Todos los sombreadores están escritos en GL Shading Language (GLSL), que es un lenguaje basado en C con la adición de una serie de funciones integradas, modificadores de variables, primitivas y entrada/salida predeterminada. En Android, estos se pasarán como cadenas finales a través loadShader(int type, String shaderCode) , uno de los dos métodos de recursos en Renderer. Primero repasemos los diferentes tipos de calificadores:

  • const : cualquier variable final se puede declarar como una constante para que su valor se pueda almacenar para facilitar el acceso. Los números como π se pueden declarar como constantes si se usan con frecuencia en todo el shader. Es probable que el compilador declare automáticamente los valores no modificados como constantes, según la implementación.
  • uniform : las variables uniformes son aquellas que se declaran constantes para cualquier representación única. Se utilizan esencialmente como argumentos estáticos para sus shaders.
  • varying : si una variable se declara como variable y se establece en un sombreador de vértices, entonces se interpola linealmente en el sombreador de fragmentos. Esto es útil para crear cualquier tipo de degradado de color y está implícito para los cambios de profundidad.
  • attribute : los atributos se pueden considerar como argumentos no estáticos para un sombreador. Denotan el conjunto de entradas que son específicas de vértice y solo aparecerán en Vertex Shaders.

Además, debemos discutir otros dos tipos de primitivas que se han agregado:

  • vec2 , vec3 , vec4 : Vectores de punto flotante de dimensión dada.
  • mat2 , mat3 , mat4 : Matrices de punto flotante de dimensión dada.

Se puede acceder a los vectores por sus componentes x , y , z y w o r , g , b y a . También pueden generar vectores de cualquier tamaño con múltiples índices: para vec3 a , a.xxyz devuelve un vec4 con los valores correspondientes de a .

Las matrices y los vectores también se pueden indexar como matrices, y las matrices devolverán un vector con un solo componente. Esto significa que para mat2 matrix , matrix[0].a es válido y devolverá matrix[0][0] . Cuando trabaje con estos, recuerde que actúan como primitivos, no como objetos. Por ejemplo, considere el siguiente código:

 vec2 a = vec2(1.0,1.0); vec2 b = a; bx=2.0;

Esto deja a=vec2(1.0,1.0) y b=vec2(2.0,1.0) , que no es lo que uno esperaría del comportamiento del objeto, donde la segunda línea le daría a b un puntero a a .

En el conjunto de Mandelbrot, la mayoría del código estará en el sombreador de fragmentos, que es el sombreador que se ejecuta en cada píxel. Nominalmente, los sombreadores de vértices funcionan en cada vértice, incluidos los atributos que serán por vértice, como cambios de color o profundidad. Echemos un vistazo al sombreador de vértices extremadamente simple para un fractal:

 private final String vertexShaderCode = "attribute vec4 vPosition;" + "void main() {" + " gl_Position = vPosition;" + "}";

En este, gl_Position es una variable de salida definida por OpenGL para registrar las coordenadas de un vértice. En este caso, pasamos en una posición para cada vértice en el que establecemos gl_Position . En la mayoría de las aplicaciones, multiplicaríamos vPosition por una MVPMatrix , transformando nuestros vértices, pero queremos que el fractal siempre esté a pantalla completa. Todas las transformaciones se realizarán con un sistema de coordenadas local.

Fragment Shader será donde se realice la mayor parte del trabajo para generar el conjunto. Estableceremos fragmentShaderCode en lo siguiente:

 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; }

Gran parte del código es simplemente la matemática y el algoritmo de cómo funciona el conjunto. Tenga en cuenta el uso de varias funciones integradas: fract , abs , mix , sin y clamp , que operan en vectores o escalares y devuelven vectores o escalares. Además, se usa dot , que toma argumentos vectoriales y devuelve un escalar.

Ahora que tenemos nuestros sombreadores configurados para su uso, tenemos un último paso, que es implementar la función draw en nuestro modelo:

 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"); }

La función pasa todos los argumentos a los sombreadores, incluida la matriz de transformación uniform y la posición del attribute .

Después de ensamblar todas las partes del programa, finalmente podemos ejecutarlo. Siempre que se maneje el soporte táctil adecuado, se pintarán escenas absolutamente fascinantes:

tutorial abierto

tutorial opengl mandelbrot

tutorial del generador de mandelbrot

Precisión de punto flotante

Si nos acercamos un poco más, comenzamos a notar un desglose en la imagen:

Esto no tiene absolutamente nada que ver con las matemáticas del conjunto detrás de él y todo que ver con la forma en que los números se almacenan y procesan en OpenGL. Si bien se ha realizado un soporte más reciente para la double precisión, OpenGL 2.0 no admite de forma nativa nada más que float s. Los designamos específicamente para que sean los flotantes de mayor precisión disponibles con precision highp float en nuestro sombreador, pero incluso eso no es suficiente.

Para solucionar este problema, la única forma sería emular double s usando dos float s. Este método en realidad se encuentra dentro de un orden de magnitud de la precisión real de uno implementado de forma nativa, aunque la velocidad tiene un costo bastante alto. Esto se dejará como ejercicio para el lector, si desea tener un mayor nivel de precisión.

Conclusión

Con algunas clases de soporte, OpenGL puede sostener rápidamente la representación en tiempo real de escenas complejas. La creación de un diseño compuesto por un GLSurfaceView , la configuración de su Renderer y la creación de un modelo con sombreadores culminaron en la visualización de una hermosa estructura matemática. ¡Espero que encuentre tanto interés en desarrollar una aplicación OpenGL ES!