Tutorial OpenGL para Android: Construindo um Gerador de Conjunto Mandelbrot
Publicados: 2022-03-11OpenGL é uma poderosa API multiplataforma que permite acesso muito próximo ao hardware do sistema em uma variedade de ambientes de programação.
Então, por que você deve usá-lo?
Ele fornece um processamento de nível muito baixo para gráficos em 2D e 3D. Em geral, isso evitará qualquer ruído que tenhamos por causa de linguagens de programação interpretadas ou de alto nível. Mais importante, porém, também fornece acesso em nível de hardware a um recurso importante: GPU.
A GPU pode acelerar significativamente muitos aplicativos, mas tem um papel muito específico em um computador. Os núcleos de GPU são de fato mais lentos que os núcleos de CPU. Se executarmos um programa notavelmente serial sem atividade simultânea, quase sempre será mais lento em um núcleo de GPU do que em um núcleo de CPU. A principal diferença é que a GPU suporta processamento paralelo massivo. Podemos criar pequenos programas chamados shaders que serão executados efetivamente em centenas de núcleos de uma só vez. Isso significa que podemos pegar tarefas que são incrivelmente repetitivas e executá-las simultaneamente.
Neste artigo, construiremos um aplicativo Android simples que usa OpenGL para renderizar seu conteúdo na tela. Antes de começarmos, é importante que você já esteja familiarizado com o conhecimento de escrita de aplicativos Android e a sintaxe de alguma linguagem de programação semelhante a C. Todo o código-fonte deste tutorial está disponível no GitHub.
Tutorial OpenGL e Android
Para demonstrar o poder do OpenGL, escreveremos um aplicativo relativamente básico para um dispositivo Android. Agora, o OpenGL no Android é distribuído em um subconjunto chamado OpenGL for Embedded Systems (OpenGL ES). Podemos essencialmente pensar nisso como uma versão simplificada do OpenGL, embora a funcionalidade principal necessária ainda esteja disponível.
Em vez de escrever um “Hello World” básico, estaremos escrevendo um aplicativo enganosamente simples: um gerador de conjuntos Mandelbrot. O conjunto de Mandelbrot é baseado no campo dos números complexos. A análise complexa é um campo maravilhosamente vasto, por isso vamos nos concentrar mais no resultado visual do que na matemática real por trás dele.
Suporte de versão
Quando estamos fazendo o aplicativo, queremos garantir que ele seja distribuído apenas para aqueles com suporte adequado ao OpenGL. Comece declarando o uso do OpenGL 2.0 no arquivo de manifesto, entre a declaração do manifesto e a aplicação:
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
Neste ponto, o suporte para OpenGL 2.0 é onipresente. OpenGL 3.0 e 3.1 estão ganhando em compatibilidade, mas escrever para qualquer um deles deixará de fora cerca de 65% dos dispositivos, portanto, tome a decisão apenas se tiver certeza de que precisará de funcionalidades adicionais. Eles podem ser implementados definindo a versão para '0x000300000' e '0x000300001', respectivamente.
Arquitetura do aplicativo
Ao fazer este aplicativo OpenGL no Android, você geralmente terá três classes principais que são usadas para desenhar a superfície: seu MainActivity
, uma extensão de GLSurfaceView
e uma implementação de um GLSurfaceView.Renderer
. A partir daí, criaremos vários modelos que encapsularão os desenhos.
MainActivity
, chamado FractalGenerator
neste exemplo, é essencialmente apenas instanciar seu GLSurfaceView
e rotear quaisquer alterações globais na linha. Aqui está um exemplo que será essencialmente seu código clichê:
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(); } }
Essa também será a classe na qual você desejará colocar quaisquer outros modificadores de nível de atividade (como tela cheia imersiva).
Uma classe mais profunda, temos uma extensão de GLSurfaceView
, que atuará como nossa visão primária. Nesta classe, configuramos a versão, configuramos um Renderer e controlamos os eventos de toque. Em nosso construtor, precisamos apenas definir a versão OpenGL com setEGLContextClientVersion(int version)
e também criar e definir nosso renderizador:
public FractalSurfaceView(Context context){ super(context); setEGLContextClientVersion(2); mRenderer = new FractalRenderer(); setRenderer(mRenderer); }
Além disso, podemos definir atributos como o modo de renderização com setRenderMode(int renderMode)
. Como gerar um conjunto de Mandelbrot pode ser muito caro, usaremos RENDERMODE_WHEN_DIRTY
, que renderizará a cena apenas na inicialização e quando chamadas explícitas forem feitas para requestRender()
. Mais opções para configurações podem ser encontradas na API GLSurfaceView
.
Depois que tivermos o construtor, provavelmente desejaremos substituir pelo menos um outro método: onTouchEvent(MotionEvent event)
, que pode ser usado para entrada geral do usuário baseada em toque. Não vou entrar em muitos detalhes aqui, pois esse não é o foco principal da lição.
Por fim, chegamos ao nosso Renderer, que será onde a maior parte do trabalho de iluminação ou talvez mudanças de cena acontecerão. Primeiro, teremos que ver um pouco como as matrizes funcionam e operam no mundo gráfico.
Aula rápida de álgebra linear
O OpenGL depende muito do uso de matrizes. As matrizes são uma maneira maravilhosamente compacta de representar sequências de mudanças generalizadas nas coordenadas. Normalmente, eles nos permitem fazer rotações, dilatações/contrações e reflexões arbitrárias, mas com um pouco de sutileza também podemos fazer traduções. Essencialmente, tudo isso significa que você pode realizar facilmente qualquer alteração razoável que desejar, incluindo mover uma câmera ou fazer um objeto crescer. Multiplicando nossas matrizes por um vetor representando nossa coordenada, podemos efetivamente produzir o novo sistema de coordenadas.
A classe Matrix fornecida pelo OpenGL oferece várias maneiras prontas de calcular matrizes que precisaremos, mas entender como elas funcionam é uma ideia inteligente, mesmo ao trabalhar com transformações simples.
Primeiro, podemos explicar por que usaremos vetores e matrizes de quatro dimensões para lidar com coordenadas. Isso na verdade remonta à ideia de refinar nosso uso de coordenadas para poder fazer traduções: enquanto uma tradução no espaço 3D é impossível usando apenas três dimensões, adicionar uma quarta dimensão permite a capacidade.
Para ilustrar isso, podemos usar uma matriz geral de escala/tradução muito básica:
Como uma nota importante, as matrizes OpenGL são por colunas, então esta matriz seria escrita como {a, 0, 0, 0, 0, b, 0, 0, 0, 0, c, 0, v_a, v_b, v_c, 1}
, que é perpendicular a como normalmente será lido. Isso pode ser racionalizado garantindo que os vetores, que aparecem na multiplicação como uma coluna, tenham o mesmo formato das matrizes.
De volta ao código
Armados com esse conhecimento de matrizes, podemos voltar a projetar nosso Renderer. Normalmente, criaremos uma matriz nesta classe que é formada a partir do produto de três matrizes: Model, View e Projection. Isso seria chamado, apropriadamente, de MVMatrix. Você pode aprender mais sobre os detalhes aqui, pois usaremos um conjunto mais básico de transformações—o conjunto Mandelbrot é um modelo de tela cheia bidimensional e não requer a ideia de uma câmera.
Primeiro, vamos configurar a classe. Precisaremos implementar os métodos necessários para a interface Renderer: onSurfaceCreated(GL10 gl, EGLConfig config)
, onSurfaceChanged(GL10 gl, int width, int height)
e onDrawFrame(GL10 gl)
. A classe completa acabará ficando assim:
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 }
Há também dois métodos utilitários usados no código fornecido, checkGLError
e loadShaders
para auxiliar na depuração e no uso de shaders.
Em tudo isso, continuamos passando a cadeia de comando pela linha para encapsular as diferentes partes do programa. Finalmente chegamos ao ponto em que podemos escrever o que nosso programa realmente faz , em vez de como podemos fazer mudanças teóricas nele. Ao fazer isso, precisamos criar uma classe de modelo que contenha as informações que precisam ser exibidas para qualquer objeto na cena. Em cenas 3D complexas, isso pode ser um animal ou uma chaleira, mas vamos fazer um fractal como um exemplo 2D muito mais simples.
Nas classes Model, escrevemos a classe inteira – não há superclasses que devem ser usadas. Precisamos apenas ter um construtor e algum tipo de método de desenho que receba quaisquer parâmetros.
Dito isto, ainda há uma série de variáveis que precisaremos ter que são essencialmente clichê. Vamos dar uma olhada no construtor exato usado na classe 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); }
Bastante bocado, não é? Felizmente, esta é uma parte do programa que você não terá que alterar, salve o nome do modelo. Desde que você altere as variáveis de classe apropriadamente, isso deve funcionar bem para formas básicas.

Para discutir partes disso, vejamos algumas declarações de variáveis:
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
Em squareCoords
, especificamos todas as coordenadas do quadrado. Observe que todas as coordenadas na tela são representadas como uma grade com (-1,-1)
no canto inferior esquerdo e (1,1)
no canto superior direito.
Em drawOrder
, especificamos a ordem das coordenadas com base nos triângulos que formariam o quadrado. Particularmente para consistência e velocidade, o OpenGL usa triângulos para representar todas as superfícies. Para fazer um quadrado, basta cortar uma diagonal (neste caso, 0
a 2
) para dar dois triângulos.
Para adicionar ambos ao programa, primeiro você deve convertê-los em um buffer de byte bruto para conectar diretamente o conteúdo do array com a interface OpenGL. Java armazena arrays como objetos que contêm informações adicionais que não são diretamente compatíveis com os arrays C baseados em ponteiro que a implementação do OpenGL usa. Para remediar isso, são usados ByteBuffers
, que armazenam o acesso à memória bruta do array.
Depois de inserirmos os dados dos vértices e a ordem do desenho, devemos criar nossos sombreadores.
Tonalizadores
Ao criar um modelo, dois sombreadores devem ser feitos: um Vertex Shader e um Fragment (Pixel) Shader. Todos os shaders são escritos em GL Shading Language (GLSL), que é uma linguagem baseada em C com a adição de várias funções internas, modificadores de variáveis, primitivos e entrada/saída padrão. No Android, eles serão passados como Strings finais por meio loadShader(int type, String shaderCode)
, um dos dois métodos de recurso no Renderer. Vamos primeiro examinar os diferentes tipos de qualificadores:
-
const
: Qualquer variável final pode ser declarada como uma constante para que seu valor possa ser armazenado para facilitar o acesso. Números como π podem ser declarados como constantes se forem usados com frequência em todo o sombreador. É provável que o compilador declare automaticamente valores não modificados como constantes, dependendo da implementação. -
uniform
: Variáveis uniformes são aquelas que são declaradas constantes para qualquer renderização única. Eles são usados essencialmente como argumentos estáticos para seus shaders. -
varying
: Se uma variável é declarada como variando e é definida em um sombreador de vértice, então ela é interpolada linearmente no sombreador de fragmento. Isso é útil para criar qualquer tipo de gradiente de cor e está implícito para alterações de profundidade. -
attribute
: Os atributos podem ser considerados argumentos não estáticos para um sombreador. Eles denotam o conjunto de entradas que são específicas de vértice e só aparecerão em Vertex Shaders.
Além disso, devemos discutir dois outros tipos de primitivas que foram adicionadas:
-
vec2
,vec3
,vec4
: Vetores de ponto flutuante de determinada dimensão. -
mat2
,mat3
,mat4
: Matrizes de ponto flutuante de determinada dimensão.
Os vetores podem ser acessados por seus componentes x
, y
, z
e w
ou r
, g
, b
e a
. Eles também podem gerar qualquer vetor de tamanho com vários índices: para vec3 a
, a.xxyz
retorna um vec4
com os valores correspondentes de a
.
Matrizes e vetores também podem ser indexados como matrizes, e as matrizes retornarão um vetor com apenas um componente. Isso significa que para mat2 matrix
, matrix[0].a
é válida e retornará matrix[0][0]
. Ao trabalhar com eles, lembre-se de que eles agem como primitivos, não como objetos. Por exemplo, considere o seguinte código:
vec2 a = vec2(1.0,1.0); vec2 b = a; bx=2.0;
Isso deixa a=vec2(1.0,1.0)
e b=vec2(2.0,1.0)
, que não é o que se esperaria do comportamento do objeto, onde a segunda linha daria a b
um ponteiro para a
.
No conjunto de Mandelbrot, a maior parte do código estará no sombreador de fragmento, que é o sombreador executado em cada pixel. Nominalmente, os sombreadores de vértice funcionam em todos os vértices, incluindo atributos que serão por vértice, como alterações de cor ou profundidade. Vamos dar uma olhada no sombreador de vértices extremamente simples para um fractal:
private final String vertexShaderCode = "attribute vec4 vPosition;" + "void main() {" + " gl_Position = vPosition;" + "}";
Neste, gl_Position
é uma variável de saída definida pelo OpenGL para registrar as coordenadas de um vértice. Nesse caso, passamos uma posição para cada vértice para o qual definimos gl_Position
. Na maioria das aplicações, multiplicaríamos vPosition
por um MVPMatrix
, transformando nossos vértices, mas queremos que o fractal fique sempre em tela cheia. Todas as transformações serão feitas com um sistema de coordenadas local.
O Fragment Shader será onde a maior parte do trabalho será feita para gerar o conjunto. Vamos definir fragmentShaderCode
para o seguinte:
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; }
Grande parte do código é meramente a matemática e o algoritmo de como o conjunto funciona. Observe o uso de várias funções incorporadas: fract
, abs
, mix
, sin
e clamp
, que operam em vetores ou escalares e retornam vetores ou escalares. Além disso, dot
é usado, que recebe argumentos vetoriais e retorna um escalar.
Agora que temos nossos shaders configurados para uso, temos um último passo, que é implementar a função draw
em nosso 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"); }
A função passa todos os argumentos para os sombreadores, incluindo a matriz de transformação uniform
e a posição do attribute
.
Depois de montar todas as partes do programa, podemos finalmente executá-lo. Desde que o suporte ao toque adequado seja tratado, cenas absolutamente fascinantes serão pintadas:
Precisão de ponto flutuante
Se ampliarmos um pouco mais, começamos a notar uma quebra na imagem:
Isso não tem absolutamente nada a ver com a matemática do conjunto por trás dele e tudo a ver com a forma como os números são armazenados e processados no OpenGL. Embora o suporte mais recente para precisão double
tenha sido feito, o OpenGL 2.0 não oferece suporte nativo a nada além de float
s. Nós os designamos especificamente para serem os floats de maior precisão disponíveis com precision highp float
em nosso shader, mas mesmo isso não é bom o suficiente.
Para contornar esse problema, a única maneira seria emular double
s usando dois float
s. Este método realmente vem dentro de uma ordem de magnitude da precisão real de um implementado nativamente, embora haja um custo bastante severo para acelerar. Isso será deixado como exercício para o leitor, se desejar ter um nível mais alto de precisão.
Conclusão
Com algumas classes de suporte, o OpenGL pode sustentar rapidamente a renderização em tempo real de cenas complexas. Criar um layout composto por um GLSurfaceView
, configurar seu Renderer
e criar um modelo com shaders culminou na visualização de uma bela estrutura matemática. Espero que você encontre tanto interesse em desenvolver um aplicativo OpenGL ES!