Статьи

Как использовать OpenGL ES в приложениях для Android

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

В настоящее время существует два разных API, которые вы можете использовать для взаимодействия с графическим процессором устройства Android: Vulkan и OpenGL ES . Хотя Vulkan доступен только на устройствах под управлением Android 7.0 или выше, OpenGL ES поддерживается всеми версиями Android.

В этом руководстве я помогу вам начать использовать OpenGL ES 2.0 в приложениях для Android.

Чтобы следовать этому руководству, вам понадобится:

  • последняя версия Android Studio
  • Android-устройство, которое поддерживает OpenGL ES 2.0 или выше
  • последняя версия Blender или любого другого программного обеспечения для 3D-моделирования

OpenGL, сокращение от Open Graphics Library, является независимым от платформы API, который позволяет создавать аппаратную 3D-графику. OpenGL ES, сокращение от OpenGL для встраиваемых систем, является подмножеством API.

OpenGL ES — очень низкоуровневый API. Другими словами, он не предлагает никаких методов, которые позволят вам быстро создавать или манипулировать трехмерными объектами. Вместо этого, работая с ним, вы должны вручную управлять такими задачами, как создание отдельных вершин и граней 3D-объектов, расчет различных 3D-преобразований и создание различных типов шейдеров.

Стоит также отметить, что Android SDK и NDK вместе позволяют писать код, связанный с OpenGL ES, как на Java, так и на C.

Поскольку API OpenGL ES являются частью платформы Android, вам не нужно добавлять какие-либо зависимости в ваш проект, чтобы иметь возможность их использовать. Однако в этом руководстве мы будем использовать библиотеку Apache Commons IO для чтения содержимого нескольких текстовых файлов. Поэтому добавьте его как зависимость compile в файл build.gradle модуля вашего приложения:

1
compile ‘commons-io:commons-io:2.5’

Кроме того, чтобы запретить пользователям Google Play, у которых нет устройств, поддерживающих версию OpenGL ES, которая вам нужна, установить ваше приложение, добавьте в тег манифеста вашего проекта следующий <uses-feature> :

1
2
<uses-feature android:glEsVersion=»0x00020000″
             android:required=»true» />

Платформа Android предлагает два виджета, которые могут выступать в качестве основы для вашей трехмерной графики: GLSurfaceView и TextureView . Большинство разработчиков предпочитают использовать GLSurfaceView и выбирают TextureView только тогда, когда они намереваются наложить свою 3D-графику на другой виджет View . Для приложения, которое мы будем создавать в этом уроке, достаточно GLSurfaceView .

Добавление виджета GLSurfaceView в файл макета ничем не отличается от добавления любого другого виджета.

1
2
3
4
5
<android.opengl.GLSurfaceView
   android:layout_width=»300dp»
   android:layout_height=»300dp»
   android:id=»@+id/my_surface_view»
   />

Обратите внимание, что мы сделали ширину нашего виджета равной его высоте. Это важно, потому что система координат OpenGL ES является квадратом. Если вы должны использовать прямоугольный холст, не забудьте включить его соотношение сторон при расчете матрицы проекции. Вы узнаете, что такое матрица проекции, на следующем этапе.

Инициализировать виджет GLSurfaceView внутри класса Activity так же просто, как вызвать метод findViewById() и передать ему его идентификатор.

1
mySurfaceView = (GLSurfaceView)findViewById(R.id.my_surface_view);

Кроме того, мы должны вызвать метод setEGLContextClientVersion() чтобы явно указать версию OpenGL ES, которую мы будем использовать для рисования внутри виджета.

1
mySurfaceView.setEGLContextClientVersion(2);

Хотя в Java можно создавать трехмерные объекты путем ручного кодирования координат X, Y и Z всех их вершин, это очень громоздко. Использование инструментов трехмерного моделирования гораздо проще. Blender — один из таких инструментов. Это открытый исходный код, мощный и очень простой в освоении.

Запустите Blender и нажмите X, чтобы удалить куб по умолчанию. Затем нажмите Shift-A и выберите Mesh> Torus . Теперь у нас есть довольно сложный трехмерный объект, состоящий из 576 вершин.

Торус в Блендере

Чтобы использовать тор в нашем приложении для Android, мы должны экспортировать его в виде файла Wavefront OBJ. Поэтому перейдите в File> Export> Wavefront (.obj) . На следующем экране присвойте имя файлу OBJ, убедитесь, что выбраны параметры « Триангуляция граней» и « Сохранить порядок вершин» , и нажмите кнопку « Экспорт OBJ» .

Экспорт 3D-объекта в виде файла Wavefront OBJ

Теперь вы можете закрыть Blender и переместить файл OBJ в папку ресурсов вашего проекта Android Studio.

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

OBJ файл открывается в текстовом редакторе

В файле каждая строка, которая начинается с «v», представляет одну вершину. Точно так же каждая линия, начинающаяся с «f», представляет одну треугольную грань. В то время как каждая линия вершины содержит координаты X, Y и Z вершины, каждая линия грани содержит индексы трех вершин, которые вместе образуют грань. Это все, что вам нужно знать для разбора файла OBJ.

Прежде чем начать, создайте новый класс Java под названием Torus и добавьте два объекта List , один для вершин и один для граней, в качестве переменных-членов.

01
02
03
04
05
06
07
08
09
10
11
12
13
public class Torus {
 
    private List<String> verticesList;
    private List<String> facesList;
 
    public Torus(Context context) {
        verticesList = new ArrayList<>();
        facesList = new ArrayList<>();
 
        // More code goes here
    }
 
}

Самый простой способ прочитать все отдельные строки файла OBJ — это использовать класс Scanner и его nextLine() . Выполняя цикл по строкам и startsWith() два списка, вы можете использовать метод startsWith() класса String , чтобы проверить, начинается ли текущая строка с «v» или «f».

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
// Open the OBJ file with a Scanner
Scanner scanner = new Scanner(context.getAssets().open(«torus.obj»));
 
// Loop through all its lines
while(scanner.hasNextLine()) {
    String line = scanner.nextLine();
    if(line.startsWith(«v «)) {
        // Add vertex line to list of vertices
        verticesList.add(line);
    } else if(line.startsWith(«f «)) {
        // Add face line to faces list
        facesList.add(line);
    }
}
 
// Close the scanner
scanner.close();

Вы не можете напрямую передавать списки вершин и граней в методы, доступные в API OpenGL ES. Вы должны сначала преобразовать их в буферные объекты. Для хранения данных координат вершины нам понадобится объект FloatBuffer . Для данных лица, которые просто состоят из индексов вершин, ShortBuffer объекта ShortBuffer .

Соответственно, добавьте следующие переменные-члены в класс Torus :

1
2
private FloatBuffer verticesBuffer;
private ShortBuffer facesBuffer;

Чтобы инициализировать буферы, мы должны сначала создать объект ByteBuffer используя метод ByteBuffer allocateDirect() . Для буфера вершин выделите четыре байта для каждой координаты, причем координаты являются числами с плавающей точкой. После ByteBuffer объекта ByteBuffer вы можете преобразовать его в FloatBuffer , вызвав его asFloatBuffer() .

1
2
3
4
// Create buffer for vertices
ByteBuffer buffer1 = ByteBuffer.allocateDirect(verticesList.size() * 3 * 4);
buffer1.order(ByteOrder.nativeOrder());
verticesBuffer = buffer1.asFloatBuffer();

Аналогичным образом создайте еще ByteBuffer объект ByteBuffer для буфера граней. На этот раз выделите два байта для каждого индекса вершины, потому что индексы являются unsigned short литералами unsigned short . Также убедитесь, что вы используете метод asShortBuffer() для преобразования объекта ByteBuffer в ShortBuffer .

1
2
3
4
// Create buffer for faces
ByteBuffer buffer2 = ByteBuffer.allocateDirect(facesList.size() * 3 * 2);
buffer2.order(ByteOrder.nativeOrder());
facesBuffer = buffer2.asShortBuffer();

Заполнение буфера вершин включает циклический просмотр содержимого verticesList , извлечение координат X, Y и Z из каждого элемента и вызов метода put() для помещения данных в буфер. Поскольку verticesList содержит только строки, мы должны использовать parseFloat() для преобразования координат из строк в значения с float .

01
02
03
04
05
06
07
08
09
10
for(String vertex: verticesList) {
    String coords[] = vertex.split(» «);
    float x = Float.parseFloat(coords[1]);
    float y = Float.parseFloat(coords[2]);
    float z = Float.parseFloat(coords[3]);
    verticesBuffer.put(x);
    verticesBuffer.put(y);
    verticesBuffer.put(z);
}
verticesBuffer.position(0);

Обратите внимание, что в приведенном выше коде мы использовали метод position() для сброса позиции буфера.

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

01
02
03
04
05
06
07
08
09
10
for(String face: facesList) {
    String vertexIndices[] = face.split(» «);
    short vertex1 = Short.parseShort(vertexIndices[1]);
    short vertex2 = Short.parseShort(vertexIndices[2]);
    short vertex3 = Short.parseShort(vertexIndices[3]);
    facesBuffer.put((short)(vertex1 — 1));
    facesBuffer.put((short)(vertex2 — 1));
    facesBuffer.put((short)(vertex3 — 1));
}
facesBuffer.position(0);

Чтобы иметь возможность визуализировать наш 3D-объект, мы должны создать вершинный шейдер и фрагментный шейдер для него. На данный момент вы можете представить себе шейдер как очень простую программу, написанную на C-подобном языке, называемом OpenGL Shading Language или GLSL для краткости.

Вершинный шейдер, как вы уже догадались, отвечает за обработку вершин трехмерного объекта. Фрагментный шейдер, также называемый пиксельным шейдером, отвечает за окрашивание пикселей 3D-объекта.

Создайте новый файл с именем vertex_shader.txt в папке res / raw вашего проекта.

Вершинный шейдер должен иметь глобальную переменную attribute для получения данных о положении вершины из вашего Java-кода. Кроме того, добавьте uniform глобальную переменную, чтобы получить матрицу проекции вида из кода Java.

Внутри функции main() вершинного шейдера вы должны установить значение gl_position , встроенной переменной GLSL, которая определяет конечную позицию вершины. Сейчас вы можете просто установить его значение на произведение uniform и attribute глобальных переменных.

Соответственно, добавьте следующий код в файл:

1
2
3
4
5
6
attribute vec4 position;
uniform mat4 matrix;
 
void main() {
    gl_Position = matrix * position;
}

Создайте новый файл frag_shader.txt в папке res / raw вашего проекта.

Чтобы этот урок был коротким, мы сейчас создадим очень минималистичный фрагментный шейдер, который просто назначает оранжевый цвет всем пикселям. Чтобы назначить цвет пикселю, внутри функции main() фрагментного шейдера вы можете использовать встроенную переменную gl_FragColor .

1
2
3
4
5
precision mediump float;
 
void main() {
    gl_FragColor = vec4(1, 0.5, 0, 1.0);
}

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

Вернувшись в класс Torus , вы должны добавить код для компиляции двух созданных вами шейдеров. Однако перед этим вы должны преобразовать их из необработанных ресурсов в строки. Класс IOUtils , который является частью библиотеки ввода-вывода Apache Commons, имеет метод toString() для этого. Следующий код показывает вам, как его использовать:

01
02
03
04
05
06
07
08
09
10
11
12
13
// Convert vertex_shader.txt to a string
InputStream vertexShaderStream =
        context.getResources().openRawResource(R.raw.vertex_shader);
String vertexShaderCode =
        IOUtils.toString(vertexShaderStream, Charset.defaultCharset());
vertexShaderStream.close();
 
// Convert fragment_shader.txt to a string
InputStream fragmentShaderStream =
        context.getResources().openRawResource(R.raw.fragment_shader);
String fragmentShaderCode =
        IOUtils.toString(fragmentShaderStream, Charset.defaultCharset());
fragmentShaderStream.close();

Код шейдеров должен быть добавлен в шейдерные объекты OpenGL ES. Чтобы создать новый объект шейдера, используйте метод glCreateShader() класса GLES20 . В зависимости от типа объекта шейдера, который вы хотите создать, вы можете передать ему GL_VERTEX_SHADER или GL_FRAGMENT_SHADER . Метод возвращает целое число, которое служит ссылкой на объект шейдера. Вновь созданный шейдерный объект не содержит никакого кода. Чтобы добавить код шейдера в объект шейдера, вы должны использовать метод glShaderSource() .

Следующий код создает объекты шейдеров как для вершинного шейдера, так и для фрагментного шейдера:

1
2
3
4
5
int vertexShader = GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER);
GLES20.glShaderSource(vertexShader, vertexShaderCode);
 
int fragmentShader = GLES20.glCreateShader(GLES20.GL_FRAGMENT_SHADER);
GLES20.glShaderSource(fragmentShader, fragmentShaderCode);

Теперь мы можем передать объекты шейдера в метод glCompileShader() для компиляции кода, который они содержат.

1
2
GLES20.glCompileShader(vertexShader);
GLES20.glCompileShader(fragmentShader);

При рендеринге 3D-объекта вы не используете шейдеры напрямую. Вместо этого вы присоединяете их к программе и используете программу. Поэтому добавьте переменную-член в класс Torus для хранения ссылки на программу OpenGL ES.

1
private int program;

Чтобы создать новую программу, используйте метод glCreateProgram() . Чтобы прикрепить к нему объекты вершинного и фрагментного шейдеров, используйте метод glAttachShader() .

1
2
3
program = GLES20.glCreateProgram();
GLES20.glAttachShader(program, vertexShader);
GLES20.glAttachShader(program, fragmentShader);

На этом этапе вы можете связать программу и начать ее использовать. Для этого используйте glLinkProgram() и glUseProgram() .

1
2
GLES20.glLinkProgram(program);
GLES20.glUseProgram(program);

Когда шейдеры и буферы готовы, у нас есть все, что нужно для рисования тора. Добавьте новый метод в класс Torus именем draw :

1
2
3
public void draw() {
    // Drawing code goes here
}

На более раннем этапе, внутри вершинного шейдера, мы определили переменную position для получения данных позиции вершины из кода Java. Теперь пришло время отправить в него данные о положении вершины. Для этого мы должны сначала получить дескриптор переменной position в нашем Java-коде, используя метод glGetAttribLocation() . Кроме того, дескриптор должен быть включен с помощью метода glEnableVertexAttribArray() .

Соответственно, добавьте следующий код внутри метода draw() :

1
2
int position = GLES20.glGetAttribLocation(program, «position»);
GLES20.glEnableVertexAttribArray(position);

Чтобы указать указатель position на наш буфер вершин, мы должны использовать метод glVertexAttribPointer() . В дополнение к самому буферу вершин метод ожидает количество координат на вершину, тип координат и смещение байтов для каждой вершины. Поскольку у нас есть три координаты на вершину, и каждая координата является float , смещение байта должно быть 3 * 4 .

1
2
GLES20.glVertexAttribPointer(position,
           3, GLES20.GL_FLOAT, false, 3 * 4, verticesBuffer);

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

Матрица проекции вида является просто продуктом матриц вида и проекции. Матрица просмотра позволяет вам указать местоположение вашей камеры и точку, на которую она смотрит. Матрица проекции, с другой стороны, позволяет не только отобразить квадратную систему координат OpenGL ES на прямоугольный экран устройства Android, но также указать ближнюю и дальнюю плоскости усеченного просмотра .

Чтобы создать матрицы, вы можете просто создать три массива с float размером 16 :

1
2
3
4
float[] projectionMatrix = new float[16];
float[] viewMatrix = new float[16];
 
float[] productMatrix = new float[16];

Для инициализации матрицы проекции вы можете использовать метод frustumM() класса Matrix . Он ожидает расположения левой, правой, нижней, верхней, ближней и дальней плоскостей отсечения. Поскольку наш холст уже является квадратом, вы можете использовать значения -1 и 1 для левой и правой сторон, а также для нижней и верхней плоскостей отсечения. Для ближних и дальних плоскостей отсечения не стесняйтесь экспериментировать с различными значениями

1
2
3
4
Matrix.frustumM(projectionMatrix, 0,
               -1, 1,
               -1, 1,
                2, 9);

Чтобы инициализировать матрицу представления, используйте метод setLookAtM() . Он ожидает положения камеры и точки, на которую он смотрит. Вы снова можете экспериментировать с различными значениями.

1
2
3
4
Matrix.setLookAtM(viewMatrix, 0,
                 0, 3, -4,
                 0, 0, 0,
                 0, 1, 0);

Наконец, для расчета матрицы продуктов используйте метод multiplyMM() .

1
2
3
Matrix.multiplyMM(productMatrix, 0,
                 projectionMatrix, 0,
                 viewMatrix, 0);

Чтобы передать матрицу продукта в вершинный шейдер, вы должны получить glGetUniformLocation() на его matrix переменную, используя метод glGetUniformLocation() . Получив дескриптор, вы можете указать его на матрицу продукта с помощью glUniformMatrix() .

1
2
int matrix = GLES20.glGetUniformLocation(program, «matrix»);
GLES20.glUniformMatrix4fv(matrix, 1, false, productMatrix, 0);

Вы, должно быть, заметили, что мы все еще не использовали буфер лиц. Это означает, что мы до сих пор не рассказали OpenGL ES, как соединить вершины в треугольники, которые будут служить гранями нашего трехмерного объекта.

Метод glDrawElements() позволяет использовать буфер граней для создания треугольников. В качестве аргументов он ожидает общее количество индексов вершин, тип каждого индекса и буфер граней.

1
2
GLES20.glDrawElements(GLES20.GL_TRIANGLES,
       facesList.size() * 3, GLES20.GL_UNSIGNED_SHORT, facesBuffer);

Наконец, не забудьте отключить обработчик attribute вы включили ранее, чтобы передавать данные вершин в вершинный шейдер.

1
GLES20.glDisableVertexAttribArray(position);

Нашему виджету GLSurfaceView необходим объект GLSurfaceView.Renderer чтобы иметь возможность визуализировать трехмерную графику. Вы можете использовать setRenderer() чтобы связать рендер с ним.

1
2
3
mySurfaceView.setRenderer(new GLSurfaceView.Renderer() {
    // More code goes here
});

Внутри метода визуализации onSurfaceCreated() вы должны указать, как часто 3D-графика должна отображаться. А пока давайте отрендерим только когда 3D-графика изменится. Для этого RENDERMODE_WHEN_DIRTY константу setRenderMode() метод setRenderMode() . Кроме того, инициализируйте новый экземпляр объекта Torus .

1
2
3
4
5
@Override
public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
    mySurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);
    torus = new Torus(getApplicationContext());
}

Внутри метода визуализации onSurfaceChanged() вы можете определить ширину и высоту glViewport() просмотра с помощью glViewport() .

1
2
3
4
@Override
public void onSurfaceChanged(GL10 gl10, int width, int height) {
    GLES20.glViewport(0,0, width, height);
}

Внутри onDrawFrame() средства визуализации добавьте вызов метода draw() класса Torus для фактического рисования тора.

1
2
3
4
@Override
public void onDrawFrame(GL10 gl10) {
    torus.draw();
}

На этом этапе вы можете запустить свое приложение, чтобы увидеть оранжевый тор.

Приложение, отображающее тор

Теперь вы знаете, как использовать OpenGL ES в приложениях для Android. В этом руководстве вы также узнали, как анализировать OBJ-файл Wavefront и извлекать из него данные вершин и граней. Я предлагаю вам сгенерировать еще несколько 3D-объектов с помощью Blender и попытаться отобразить их в приложении.

Хотя мы сосредоточились только на OpenGL ES 2.0, все же понимаем, что OpenGL ES 3.x обратно совместим с OpenGL ES 2.0. Это означает, что если вы предпочитаете использовать OpenGL ES 3.x в своем приложении, вы можете просто заменить класс GLES31 классами GLES30 или GLES31 .

Чтобы узнать больше об OpenGL ES, вы можете обратиться к его справочным страницам . А чтобы узнать больше о разработке приложений для Android, обязательно ознакомьтесь с некоторыми другими нашими учебными пособиями здесь, в Envato Tuts +!

  • Как начать работу с собственным комплектом разработки для Android

  • Android Things: Периферийный ввод / вывод

  • Как обезопасить приложение для Android

  • Кодирование Android-приложения с флаттером и дротиком