Учебное пособие по OpenGL для Android: создание генератора множеств Мандельброта

Опубликовано: 2022-03-11

OpenGL — это мощный межплатформенный API, обеспечивающий очень тесный доступ к аппаратному обеспечению системы в различных средах программирования.

Итак, почему вы должны его использовать?

Он обеспечивает очень низкий уровень обработки графики как в 2D, так и в 3D. В общем, это позволит избежать любых помех, которые у нас есть из-за интерпретируемых языков или языков программирования высокого уровня. Однако, что еще более важно, он также обеспечивает доступ на аппаратном уровне к ключевой функции: графическому процессору.

Графический процессор может значительно ускорить работу многих приложений, но на компьютере он играет очень специфическую роль. Ядра GPU на самом деле медленнее, чем ядра CPU. Если бы нам пришлось запускать программу, которая является последовательной без какой-либо параллельной активности, то она почти всегда будет работать медленнее на ядре графического процессора, чем на ядре ЦП. Основное отличие состоит в том, что GPU поддерживает массивную параллельную обработку. Мы можем создавать небольшие программы, называемые шейдерами, которые будут эффективно работать на сотнях ядер одновременно. Это означает, что мы можем брать задачи, которые в противном случае невероятно повторялись бы, и выполнять их одновременно.

Генератор множеств OpenGL и Мандельброта

В этой статье мы создадим простое приложение для Android, использующее OpenGL для отображения содержимого на экране. Прежде чем мы начнем, важно, чтобы вы уже были знакомы со знанием написания приложений для Android и синтаксисом некоторого C-подобного языка программирования. Весь исходный код этого руководства доступен на GitHub.

Учебник по OpenGL и Android

Чтобы продемонстрировать мощь OpenGL, мы напишем относительно простое приложение для Android-устройства. Теперь OpenGL для Android распространяется под названием OpenGL для встраиваемых систем (OpenGL ES). По сути, мы можем просто думать об этом как об урезанной версии OpenGL, хотя основные функции, которые необходимы, все еще будут доступны.

Вместо простого «Hello World» мы напишем обманчиво простое приложение: генератор множеств Мандельброта. Множество Мандельброта основано на комплексных числах. Комплексный анализ — это прекрасно обширная область, поэтому мы сосредоточимся на визуальном результате больше, чем на фактической математике, стоящей за ним.

С OpenGL создать генератор множеств Мандельброта проще, чем вы думаете!
Твитнуть

Поддержка версии

поддержка opengl версии

Когда мы делаем приложение, мы хотим убедиться, что оно распространяется только среди тех, у кого есть надлежащая поддержка OpenGL. Начните с объявления использования OpenGL 2.0 в файле манифеста между объявлением манифеста и приложением:

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

На данный момент поддержка OpenGL 2.0 является повсеместной. OpenGL 3.0 и 3.1 становятся все более совместимыми, но при написании для любого из них будет исключено примерно 65% устройств, поэтому принимайте решение только в том случае, если вы уверены, что вам понадобятся дополнительные функции. Их можно реализовать, установив версию «0x000300000» и «0x000300001» соответственно.

Архитектура приложения

архитектура приложения opengl

При создании этого OpenGL-приложения на Android у вас обычно будет три основных класса, которые используются для рисования поверхности: ваша 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 , которое будет нашим основным представлением. В этом классе мы устанавливаем версию, настраиваем средство визуализации и управление сенсорными событиями. В нашем конструкторе нам нужно только установить версию OpenGL с помощью setEGLContextClientVersion(int version) , а также создать и установить наш рендерер:

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

Кроме того, мы можем установить такие атрибуты, как режим рендеринга, с помощью setRenderMode(int renderMode) . Поскольку создание множества Мандельброта может быть очень дорогостоящим, мы будем использовать RENDERMODE_WHEN_DIRTY , который будет отображать сцену только при инициализации и при явных вызовах requestRender() . Дополнительные параметры настроек можно найти в GLSurfaceView API.

После того, как у нас есть конструктор, мы, вероятно, захотим переопределить по крайней мере еще один метод: onTouchEvent(MotionEvent event) , который можно использовать для обычного сенсорного ввода пользователя. Я не буду вдаваться в подробности, так как это не является основной темой урока.

Наконец, мы переходим к нашему рендереру, где будет происходить большая часть работы по освещению или, возможно, изменениям сцены. Во-первых, нам нужно немного разобраться в том, как матрицы работают и действуют в графическом мире.

Быстрый урок линейной алгебры

OpenGL сильно зависит от использования матриц. Матрицы — удивительно компактный способ представления последовательностей обобщенных изменений координат. Обычно они позволяют нам делать произвольные повороты, расширения/сжатия и отражения, но с небольшим изяществом мы также можем делать переводы. По сути, все это означает, что вы можете легко выполнить любое разумное изменение, которое вы хотите, включая перемещение камеры или увеличение объекта. Умножая наши матрицы на вектор, представляющий нашу координату, мы можем эффективно создать новую систему координат.

Класс Matrix, предоставляемый OpenGL, предоставляет ряд готовых способов вычисления матриц, которые нам понадобятся, но понимание того, как они работают, является хорошей идеей даже при работе с простыми преобразованиями.

Во-первых, мы можем обсудить, почему мы будем использовать четырехмерные векторы и матрицы для работы с координатами. На самом деле это восходит к идее усовершенствовать наше использование координат, чтобы иметь возможность выполнять переводы: в то время как перевод в трехмерном пространстве невозможен, используя только три измерения, добавление четвертого измерения дает возможность.

Чтобы проиллюстрировать это, мы можем использовать очень простую общую матрицу масштаба/перемещения:

шкала opengl и матрица перевода

Важно отметить, что матрицы OpenGL являются столбцовыми, поэтому эта матрица будет записана как {a, 0, 0, 0, 0, b, 0, 0, 0, 0, c, 0, v_a, v_b, v_c, 1} , что перпендикулярно тому, как это обычно читается. Это можно рационализировать, обеспечив, чтобы векторы, которые появляются при умножении в виде столбцов, имели тот же формат, что и матрицы.

Назад к Кодексу

Вооружившись этими знаниями о матрицах, мы можем вернуться к разработке нашего визуализатора. Обычно мы создаем матрицу в этом классе, которая формируется из произведения трех матриц: Модель, Вид и Проекция. Соответственно, это будет называться MVPMatrix. Вы можете узнать больше о специфике здесь, так как мы собираемся использовать более простой набор преобразований — набор Мандельброта — это двухмерная полноэкранная модель, и на самом деле для нее не требуется идея камеры.

Во-первых, давайте настроим класс. Нам нужно реализовать необходимые методы для интерфейса 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-пример.

В классах модели мы пишем весь класс — нет никаких суперклассов, которые должны использоваться. Нам нужен только конструктор и какой-то метод рисования, который принимает любые параметры.

Тем не менее, нам все еще нужно иметь ряд переменных, которые по сути являются шаблонными. Давайте взглянем на точный конструктор, используемый в классе 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 хранит массивы как объекты, которые содержат дополнительную информацию, несовместимую напрямую с массивами C на основе указателей, которые использует реализация OpenGL. Чтобы исправить это, используются ByteBuffers , которые хранят доступ к необработанной памяти массива.

После того, как мы ввели данные для вершин и порядка отрисовки, мы должны создать наши шейдеры.

Шейдеры

При создании модели необходимо сделать два шейдера: Вершинный шейдер и Фрагментный (пиксельный) шейдер. Все шейдеры написаны на языке GL Shading Language (GLSL), который является языком на основе C с добавлением ряда встроенных функций, модификаторов переменных, примитивов и ввода/вывода по умолчанию. В Android они будут переданы как окончательные строки через loadShader(int type, String shaderCode) , один из двух методов ресурсов в Renderer. Давайте сначала рассмотрим различные типы квалификаторов:

  • const : любая конечная переменная может быть объявлена ​​как константа, чтобы ее значение можно было сохранить для легкого доступа. Такие числа, как π, могут быть объявлены как константы, если они часто используются в шейдере. Вполне вероятно, что компилятор автоматически объявит неизмененные значения как константы, в зависимости от реализации.
  • 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 .

В наборе Мандельброта большая часть кода будет находиться во фрагментном шейдере, то есть шейдере, работающем с каждым пикселем. Номинально вершинные шейдеры работают с каждой вершиной, включая атрибуты, которые будут применяться для каждой вершины, например, изменения цвета или глубины. Давайте взглянем на чрезвычайно простой вершинный шейдер для фрактала:

 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

Учебник Мандельброта opengl

учебник по генератору мандельброта

Точность с плавающей запятой

Если мы немного увеличим масштаб, мы начнем замечать разрыв изображения:

Это не имеет абсолютно никакого отношения к математике множества, стоящей за этим, и все, что связано с тем, как числа хранятся и обрабатываются в OpenGL. В то время как более поздняя поддержка double точности была сделана, OpenGL 2.0 изначально не поддерживает ничего, кроме чисел с float . Мы специально обозначили их как плавающие с наивысшей точностью, доступные с precision highp float в нашем шейдере, но даже этого недостаточно.

Чтобы обойти эту проблему, единственным способом было бы эмулировать double с использованием двух float s. Этот метод на самом деле находится в пределах порядка величины фактической точности встроенного метода, хотя скорость требует довольно серьезных затрат. Это будет оставлено читателю в качестве упражнения, если кто-то хочет иметь более высокий уровень точности.

Заключение

Благодаря нескольким классам поддержки OpenGL может быстро поддерживать визуализацию сложных сцен в реальном времени. Создание макета, состоящего из GLSurfaceView , установка его Renderer и создание модели с шейдерами, завершилось визуализацией красивой математической структуры. Я надеюсь, что вы найдете столько же интереса к разработке приложения OpenGL ES!