Застосуємо можливості відеокарти у вашій Java-програмі

Сучасні відеокарти мають вбудований графічний процесор, який може виробляє не властиві для центрального процесора паралельні обчислення, знімаючи їх з нього. Графічний процесор, він же GPU (Graphical Processing Unit) — це програмований пристрій, яке можна задіяти у вашій програмі, щоб одержати істотне підвищення продуктивності для специфічних завдань, як-то відображення графіки, і загальних обчислень (GPGPU — General-purpose computing on graphics processing units ), які застосовуються у задачах: комп'ютерного зору, розпізнавання мови, машинного навчання і так далі. Можливості застосування графіки і обчислень обмежуються хіба що вашою фантазією.

Як правило, можливості GPU використовують у програмах, написаних на С/C++. Стандартна бібліотека платформи Java не містить API для безпосередньої роботи з графічним прискорювачем, однак це не означає, що його не можна використовувати.

У цій статті ми розглянемо застосування OpenGL API для графіки і OpenCL API для GPGPU реалізації LWJGL (Lightweight Java Game Library). OpenGL і OpenCL — це крос-платформні API, стандартизовані промисловим консорціумом Khronos Group. Java-програми, які застосовують ці API, зможуть працювати в Windows, Mac OS X і Linux.

LWJGL — це нативна прив'язка OpenGL, OpenCL, OpenAL і безлічі допоміжних широко використовуються в комп'ютерній графіці бібліотек. Незважаючи на Lightweight в назві, 3-й версії бібліотеки досить широкі, зокрема на основі LWJGL розроблений движок відомої комп'ютерної гри Minecraft.

Варто зазначити, що для кожної з операційних систем, яку ви плануєте підтримувати, все ж доведеться підготувати окрему збірку. Так як в місці з програмою потрібно буде поставити нативні бібліотеки — прив'язки, специфічні для конкретної платформи. Проте з цим завданням легко впорається складальний сценарій Maven.

У випадку з Linux також потрібно переконатися в тому, що встановлені драйвера відеокарти, апаратно реалізують OpenGL і OpenCL. В іншому випадку обчислення будуть виконуватися на центральному процесорі.

Якщо ваша програма буде працювати на сервері, переконайтеся, що він має GPU. Планують розгорнути додаток в хмарі в разі AWS потрібні спеціальні Accelerated Computing instances замість звичайних EC2. У разі Azure потрібні GPU optimized virtual machine.

Для запуску прикладів з цієї статті вам знадобиться пакет JDK версії 1.8 і вище, будь-яка Java IDE (наприклад, Eclipse) і Apache Maven для збірки.

Паралельні обчислення

Застосування GPU може прискорити вашу програму? CPU (central processing unit ) — універсальний програмований пристрій, оптимізоване для послідовного виконання команд. В сучасних CPU 4-8 ядер, GPU ядер — сотні. Втім, ці ядра — інші, вони простіше, ніж ядра CPU, тому з допомогою GPU написати всю програму не вийде.

CPU добре підходить для задач зразок компіляції вихідного коду, формування HTML, розбору XML, JSON. Однак з операціями над матрицями CPU справляється гірше, тому що обробляє дані послідовно. GPU дозволяє обробляти їх паралельно , що дає приріст продуктивності.

Фігура 1. Послідовна і паралельна обробка даних

Шейдери

Шейдер (shader «затеняющий») — це особлива програма, призначена для виконання GPU. Шейдери складаються на одному зі спеціалізованих мов програмування, наприклад OpenCL C, GLSL (OpenGL і Vulkan), HLSL (DirectX), Nvidia Cg (CUDA). Після цього передаються драйвера відеокарти як скрипт або байт-код SPIR-V , попередньо скомпільовані спеціальним компілятором. Після успішного завантаження шейдера в GPU в нього можна передавати параметри з основної програми і зчитувати результати, якщо в цьому є необхідність.

OpenGL, Vulkan API і OpenCL — індустріальні стандарти, впроваджувані консорціумом Khronos Group . Ці API підтримуються переважною більшістю виробників графічного обладнання та операційними системами. Інші API мають платформеними або апаратними обмеженнями.

Незалежно від API програмування з застосуванням GPU виглядає наступним чином.

  1. Основна CPU-програма створює контекст, що зв'язує її з графічним драйвером, і середовище виконання шейдерів — шейдерну програму.
  2. Шейдери завантажуються в шейдерну програму.
  3. CPU готує дані, які будуть передані в шейдерну програму в потрібному форматі, копіює їх в відеопам'ять або вказує шейдеру адресу основної пам'яті для читання або запису даних.
  4. Шейдерна програма запускається, після чого результати її роботи відображаються на екрані або зчитуються для подальшого використання.

Особливості LWJGL

У стандартній бібліотеці Java немає підтримки OpenGL і OpenCL. LWJGL — це набір нативних JNI-прив'язок (binding) до бібліотек OpenGL, OpenCL, GLFW, Asimp та інших. Тому програмування з допомогою LWJGL не позбавлене всіх особливостей нативного низькорівневого коду. Програма, яка LWJGL, — це щось середнє між C і Java. У першу чергу це стосується управління пам'яттю. У класичній Java-програмі ми звикли використовувати масиви примітивних типів зразок new float[32] і структури даних — колекції. Збирач сміття JVM стежить за часом життя масивів та об'єктів в пам'яті замість нас.

Якщо б ми створювали JNI-код самостійно, без застосування LWJGL, наш C/C++ JNI-код читав би дані з масивів і колекцій. Це не ефективно з точки зору продуктивності, тому що вимагає копіювання даних з Java купи в проміжні блоки пам'яті, виділені через malloc. OpenGL - і OpenCL-функції чекають покажчики на області пам'яті, ArrayList їм не підійде. Більш розумно виділяти блоки оперативної пам'яті, які можна передати нативним функцій, прямо з Java.

Як правило, в JNI для цього застосовуються класи стандартної бібліотеки, спадкоємці — java.nio.Buffer. Використовуючи буфери, ми будемо змушені керувати пам'яттю вручну, як і у випадку з С/С++. Головна бібліотека lwjgl.jar містить функціональність для роботи з буферами. В рамках прикладів цієї статті нам буде достатньо стекового розподільника пам'яті. Докладна інформація про розподільниках пам'яті LWJGL в документації .

У цьому підході є свої переваги, недоліки і особливості. Основною перевагою є можливість вивільнити блок пам'яті відразу після того, як він перестав бути потрібний програмі, не чекаючи збірки сміття. У випадку з тривимірними моделями і текстурами це дуже до речі, так як вони можуть займати досить багато пам'яті. Своєчасне її звільнення покращує продуктивність програми в цілому.

Головний недолік — якщо ви помилилися, приготуйтеся до найгіршого. На звичне Java-програміста виняток разом зі стеком викликів розраховувати не доводиться — чекайте аварійної зупинки віртуальної машини Java разом з crash-dump файлом. Налагодження таких програм може бути дуже болючою.

Основна особливість — якщо програмі не вистачає пам'яті, пам'ять, доступну віртуальній машині і задається опціями -xms і -xmx, слід зменшувати, а не збільшувати. Блок пам'яті, виділений збирача сміття, з точки зору операційної системи вже зайнятий, в незалежності від того, зберігає JVM в ній дані чи ні. Працюючи з буферами, ми беремо пам'ять з купи процесу, а не купи Java.

Неспеціалізовані обчислення

Реалізуємо класичний OpenCL приклад на Java: перемножимо всі елементи двох масивів один з одним.

OpenCL-шейдер виглядає так:

kernel void mul_arrays(global const float *a, global const float *b, global float *answer) {
 unsigned int xid = get_global_id(0);
 answer[xid] = a[xid] * b[xid];
}

Публічні функції шейдера OpenCL, доступні основній програмі, носять назву ядер — kernel. В одному шейдере може бути відразу декілька ядер. Код ядра — це операція, яку ми застосуємо до даних паралельно. Метод названий Single instruction, multiple data (SIMD). Це можна порівняти з тілом циклу в Java-програмі. CPU застосовує тіло циклу до даних послідовно, тоді як GPU застосовує ядро OpenCL відразу до великої кількості даних. У нашому прикладі — до всіх елементів масивів одночасно.

Як тепер використовувати це ядро з Java? На жаль, потрібно багато службового коду. Щоб не роздмухувати обсяг цієї статті, для роботи з OpenCL ми будемо використовувати декілька службових класів — обгорток. Повний вихідний код прикладів до статті можна знайти в GitHub-репозиторії .

Для початку створимо складальний скрипт — Maven , який скаче LWJGL з центрального сховища. У складальному скрипті визначимо кілька профайлів для різних операційних систем. А в кожному з них — властивість os.family.

<profiles>
<profile>
<id>linux_profile</id>
<activation>
<os>
<family>unix</family>
</os>
</activation>
<properties>
<os.family>linux</os.family>
</properties>
</profile>
<profile>
<id>windows_profile</id>
<activation>
<os>
<family>windows</family>
</os>
</activation>
<properties>
<os.family>windows</os.family>
</properties>
</profile>
<profile>
<id>osx_profile</id>
<activation>
<os>
<family>mac</family>
</os>
</activation>
<properties>
<os.family>mac</os.family>
</properties>
</profile>
</profiles>

Далі додамо Maven-залежність і вкажемо classifier:

<dependency>
<groupId>org.lwjgl</groupId>
<artifactId>lwjgl</artifactId>
<version>${lwjgl.version}</version>
<classifier>natives-${os.family}</classifier>
</dependency>

Тут і застосуємо os.family-властивість. Тепер додамо maven-enforcer-plugin, щоб профайл для поточної операційної системи вибирався автоматично.

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.0.0-M3</version>
<executions>
<execution>
<id>enforce-os</id>
<goals>
<goal>enforce</goal>
</goals>
</execution>
</executions>
</plugin>

Перейдемо до коду програми. Щоб створити OpenCL-контекст, що зв'язує нашу програму драйвер відеокарти, потрібно встановити пристрій, що підтримує OpenCL. В комп'ютері може бути більше одного. Наприклад, вбудована в центральний процесор і дискретної відеокарти.

Отримати список підтримуваних пристроїв OpenCL можна за допомогою двох функцій — clGetPlatformIds і clGetDeviceIds. У прикладі будемо використовувати клас — фасад, що надає простий інтерфейс над низькорівневим API OpenCL — CLRuntime. CLRuntime спрощує ініціалізацію OpenCL-пристроїв, контексту, черги команд і інших об'єктів бібліотеки OpenCL. Код цього класу занадто великий, щоб приводити його в статті, з ним можна ознайомитись в GitHub-репозиторії .

Виберемо пристрій за промовчанням, як правило, це наша дискретна відеокарта, і створимо OpenCL-контекст. Потім створимо об'єкт — шейдерну програму і завантажимо в неї код OpenCL-шейдера.

Через простоти ядра задамо шейдер як рядковий літерал. Складні шейдери краще зберігати у ресурсах. Далі виділимо три буфера, два з яких наповнимо масивами з вихідними даними, в третій будемо поміщати результати. Для виділення пам'яті використаємо утиліту LWJGL — MemoryStack, це стековий розподільник пам'яті. MemoryStack реалізує інтерфейс AutoClosable, його можна використовувати в try with resource блоці. Коли блок буде завершено, вся пам'ять, виділена стековим розподільником, вивільниться. В нашому прикладі і в переважній більшості випадків достатньо стекового розподільника пам'яті.

Після виділення буферів створимо чергу команд OpenCL і зв'яжемо буфери з контекстом OpenCL. Зазначимо бібліотеці, що вихідні дані слід читати з основної оперативної пам'яті, а результат повинен зберігається у відеопам'яті. Після обчислень ми його звідти копіюємо в основну пам'ять. Знайдемо потрібну нам ядро з шейдерів та передамо в нього вихідні дані і буфер відеопам'яті, де буде збережений результат. Потім запустимо ядро, передавши в нього розмір вихідних масивів у байтах — тобто простір індексів (global work size). Після того як ядро спрацює на GPU, вважаємо результат в основну оперативну пам'ять з відеопам'яті і виведемо результат на консоль.

OpenCL може записувати результат і в основну оперативну пам'ять, що розумно використовувати, коли результат не потрібен для подальших обчислень на GPU. У даному прикладі я вирішив продемонструвати обидва підходи.

public class MultArrays {

 private static final String KERNEL = 
 "kernel void mul_arrays(global const float *a, global const float *b, global float *answer) {"
+ "unsigned int xid = get_global_id(0); answer[xid] = a[xid] * b[xid]; }";

 private static final float[] LEFT_ARRAY = { 1F, 3F, 5F, 7F};
 private static final float[] RIGHT_ARRAY = { 2F, 4F, 6F, 8F};

 private static void printSequence(String label, FloatBuffer sequence, PrintStream to) {
to.print(label);
 to.print(": [ ");
 for (int i = 0; i < sequence.limit(); i++) {
 to.print(' ');
to.print(Float.toString(sequence.get(i)));
 to.print(' ');

}
 to.println(" ]");
}

 public static void main(String[] args) {
 try (ClRuntime cl = new ClRuntime(); MemoryStack stack = MemoryStack.stackPush();) {
 ClRuntime.Platform platform = cl.getPlatforms().first();
 ClRuntime.Device device = platform.getDefault();
 try (ClRuntime.Context context = device.createContext();
 ClRuntime.Program program = context.createProgramWithSource(KERNEL)) {

 FloatBuffer lhs = stack.floats(LEFT_ARRAY);
 FloatBuffer rhs = stack.floats(RIGHT_ARRAY);

 printSequence("Left hand statement: ", lhs, System.out);
 printSequence("Right hand statement: ", rhs, System.out);

 int gws = LEFT_ARRAY.length * Float.BYTES;

 ClRuntime.CommandQueue cq = program.getCommandQueue();

 final ClRuntime.VideoMemBuffer first = cq.hostPtrReadBuffer(MemoryUtil.memAddressSafe(lhs), gws);
 final ClRuntime.VideoMemBuffer second = cq.hostPtrReadBuffer(MemoryUtil.memAddressSafe(rhs), gws);
 final ClRuntime.VideoMemBuffer answer = cq.createReadWriteBuffer(gws);

cq.flush();

 ClRuntime.Kernel sumVectors = program.createKernel("mul_arrays");
sumVectors.arg(first).arg(second).arg(answer).executeAsDataParallel(gws);

 ByteBuffer result = MemoryUtil.memAlloc(answer.getCapacity());
 cq.readVideoMemory(answer, result);

 printSequence("Result: ", result.asFloatBuffer(), System.out);

 } catch (ExecutionException exc) {
System.err.println(exc.getMessage());
System.exit(-1);
}
}
}

}

Як бачимо, така програма навіть із застосуванням службових класів багато складніше звичайного циклу for. Тому застосовувати OpenCL потрібно обережно, чітко розуміючи завдання і вигоди, які може дати розпаралелювання. Якщо вам просто потрібно перемножити елементи двох масивів, то має сенс це робити, якщо їх розмір дуже великий або множити потрібно велике (тисячі) кількість разів. В іншому випадку витрати на створення контексту не будуть виправдані.

Більш практичним прикладом застосування OpenCL може служити бібліотека лінійної алгебри clBLAS або криптографічна бібліотека Hashcat .

Тривимірна графіка

У рамках однієї статті неможливо описати бібліотеки OpenGL. Це, швидше, формат книги. Моя мета — продемонструвати можливість застосування OpenGL мовою програмування Java. Приклад з цієї статті можна використовувати як каркас для подальшого самостійного вивчення графіки OpenGL зокрема. Реалізуємо класичний OpenGL-приклад — намалюємо куб.

Підготовка вікна і контексту

Перед тим як почати малювати, створіть вікно (Window) та пов'язаний з ним контекст OpenGL. Це не просте завдання, реалізація залежить від операційної системи. На щастя, існує широко використовується крос-платформна бібліотека GLFW, яка спрощує цей процес. GLFW написана на С, в LWJGL є зв'язка (binding) c GLFW. Нею і скористаємося. Всю роботу з вікном помістимо в клас Window. Виклик glfwCreateWindow створює вікно і OpenGL-контекст, пов'язаний з ним.

public class Window implements AutoCloseable {
 private static final int CLEAR_FLAGS = GL_ACCUM_BUFFER_BIT | GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT;

 private final long handle;

 public Window(int w, int h, String title) {

glfwDefaultWindowHints();
 glfwWindowHint(GLFW_DOUBLEBUFFER, GLFW_TRUE);
 glfwWindowHint(GLFW_CONTEXT_RELEASE_BEHAVIOR, GLFW_RELEASE_BEHAVIOR_FLUSH);

 // the window will stay hidden after creation
 glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);
 // the window will be resizable
 glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE);
 glfwWindowHint(GLFW_CONTEXT_CREATION_API, GLFW_NATIVE_CONTEXT_API);

 this.handle = glfwCreateWindow(w, h, title, MemoryUtil.NULL, MemoryUtil.NULL);
 if (this.handle == MemoryUtil.NULL) {
 throw new IllegalStateException("Failed to create the GLFW window");
}
 // Close window and exit program on user press ESC
 glfwSetKeyCallback(handle, new GLFWKeyCallback() {
@Override
 public void invoke(long window, int key, int scancode, int action, int mods) {
 if(key == GLFW_KEY_ESCAPE && action == GLFW_RELEASE) {
 glfwSetWindowShouldClose(window, true);
}
}});
glfwMakeContextCurrent(this.handle);
glfwSwapInterval(1);
 // load OpenGL native
 GLCapabilities glCapabilities = GL.createCapabilities(false);
 if(null == glCapabilities) {
 throw new IllegalStateException("Failed to load OpenGL native");
}
 // Enable depth testing for z-culling
glEnable(GL_DEPTH_TEST);
 // Set the type of depth-test
glDepthFunc(GL_LEQUAL);
 // Enable smooth shading
glShadeModel(GL_SMOOTH);
 glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST); 
}

 public void show(Renderable render) {
glfwShowWindow(this.handle);
 while (!glfwWindowShouldClose(this.handle)) {
glClear(CLEAR_FLAGS);
glClearDepth(1.0 F);
 int w[] = { 0 };
 int h[] = { 0 };
 glfwGetFramebufferSize(this.handle, w, h);
 glViewport(0, 0, w[0], h[0]);
 glClearColor(1.0 F, 1.0 F, 1.0 F, 1.0 F); 
 if(null != render) {
 render.render(w[0], h[0]);
}
glfwSwapBuffers(this.handle);
glfwWaitEvents();
}
}

 public void screenCenterify() {
 // Get the thread stack and push a new frame
 try (MemoryStack stack = MemoryStack.stackPush()) {
 IntBuffer pWidth = stack.mallocInt(1);
 IntBuffer pHeight = stack.mallocInt(1);
 // Get the window size passed to glfwCreateWindow
 glfwGetFramebufferSize(this.handle, pWidth, pHeight);
 // Get the resolution of the primary monitor
 GLFWVidMode vidmode = glfwGetVideoMode(glfwGetPrimaryMonitor());
 // Center the window
 glfwSetWindowPos(this.handle, (vidmode.width() - pWidth.get(0))/2, (vidmode.height() - pHeight.get(0))/2);
 } // the stack frame is з'явилися automatically
}

@Override
 public void close() throws IllegalStateException {
glfwDestroyWindow(this.handle);
}
}

Цей клас лише готує вікно і контекст OpenGL, відображення простору перекладається на коллбек Renderable, що передається параметром в метод show.

Шейдерна програма

Сучасний (modern) OpenGL (версії 3.0 і вище) використовує програмований конвеєр . Блоки команд glBegin ... glEnd оголошені застарілими, і ми не будемо їх розглядати в рамках даної статті. OpenGL дозволяє малювати крапками, лініями й трикутниками. Всі інші примітиви і поверхні можна реалізувати з допомогою трикутників. OpenGL використовує відмінний від OpenCL мова шейдерів — GLSL (OpenGL Shading Language).

Конвеєр команд мінімально містить два шейдера, верховий і фрагментний. Завдання вершинного — генерувати координати простору відсікання, тобто задавати модель в просторі. Завдання фрагментного шейдерів — встановлювати колір для поточного пікселя.

За створення шейдерної програми, включаючи завантаження з ресурсів та компіляцію шейдерів, виділення буферів відеопам'яті OpenGL, їх прив'язку до вхідних аргументів і зовні заданим константам, відповідає клас Program. Код Program та супутніх класів занадто великий, щоб включати в статтю, його можна переглянути у GitHub . Розглянемо верховий і фрагментные шейдери, які ми завантажимо в нашу програму.

Вершинний шейдер обчислює положення точки спостерігача в просторі, напрям вектора нормалі і передає далі по конвеєру команд у фрагментний шейдер. Потім він просто задає координати вершини в просторі відсікання, значення присвоюється іменованого блоку gl_Position .

#version 420 compatibility

#pragma optimize(on)

#ifdef GL_ES
precision mediump float;
#else
precision highp float;
#endif

invariant gl_Position;

uniform mat4 mvp;
uniform mat4 mv;
uniform mat4 nm;

layout(location = 0) in vec3 vertex_coord;
layout(location = 1) in vec3 vertex_normal;

out vec4 eye_norm;
out vec4 eye_pos;

void main(void) {
 vec4 vcoord = vec4( vertex_coord, 1.0 );
 eye_norm = normalize( nm * vec4(vertex_normal,0.0) );
 eye_pos = mv * vcoord;
 gl_Position = mvp * vcoord;
}

Фрагментний шейдер обчислює затінення по Фонгу для одного джерела світла:

#version 420 compatibility

#pragma optimize(on)

#ifdef GL_ES
precision mediump float;
#else
precision highp float;
#endif

uniform mat4 light_pads;
uniform mat4 material_adse;
uniform float material_shininess;

in vec4 eye_norm;
in vec4 eye_pos;

invariant out vec4 frag_color;

vec4 phong_shading(vec4 norm) {
 vec4 s;
 if(0.0 == light_pads[0].w)
 s = normalize( light_pads[0] );
else
 s = normalize( light_pads[0] - eye_pos );
 vec4 v = normalize( -eye_pos );
 vec4 r = normalize( - reflect( s norm ) );
 vec4 ambient = light_pads[1] * material_adse[0];
 float cos_theta = clamp( dot(s norm).xyz, 0.0, 1.0 );
 vec4 diffuse = ( light_pads[2] * material_adse[1] ) * cos_theta;
 if( cos_theta > 0.0 ) {
 float shininess = pow( max( dot(r,v), 0.0 ), material_shininess );
 vec4 specular = (light_pads[3] * material_adse[2]) * shininess;
 return ambient + clamp(diffuse,0.0, 1.0) + clamp(specular, 0.0, 1.0);
}
 return ambient + clamp(diffuse,0.0, 1.0);
}

void main(void) {
 vec4 diffuse_color = material_adse[1];
 if( gl_FrontFacing ) {
 frag_color = diffuse_color + phong_shading(eye_norm); 
 } else {
 frag_color = diffuse_color + phong_shading(-eye_norm);
}
} 

Без затінення модель буде виглядати на екрані плямою, а не кубом.

Геометрія

Куб — це шість граней, кожна з яких — квадрат. Квадрати ми можемо сформувати з двох трикутників. Щоб намалювати куб, нам потрібно виконати наступні дії.

Підготувати масив вершин (VBO — vertex buffer object). Щоб позиціонувати куб в тривимірному просторі, потрібно задати 6 граней. Кожна грань визначається 4 вершинами. Вершина описується її декартовими координатами x, y, z і ще трьома x, y, z, що задають напрямок вектора нормалі до поверхні від цієї координати.

Для визначення всього куба потрібно задати 24 вершини. Вони передаються у шейдер як вхідні аргументи vertex_coord і vertex_normal . Їх можна передати як два незалежних відеобуфера, однак для кращої продуктивності рекомендується упакувати вершини в один масив, де трійка float'ів вектора нормалей слід одразу за трійкою float'ів координат (Interleaved Vertex Data). Тобто отримуємо багатовимірний масив формату [24][ [3] [3] ] у суцільному блоці пам'яті. Program.passVertexAttribArray вказує схему розмітки, згідно з якою вони будуть передані в конвеєр з відеопам'яті.

program.passVertexAttribArray(
vbo,
 false, 
 Attribute.of("vertex_coord", 3), 
 Attribute.of("vertex_normal", 3));

Щоб не дублювати вершини для кожного з трикутників, формують грані куба, і тим самим зберегти відеопам'ять, програма задає масив індексів вершин (IBO — index buffer object). Шейдерна програма буде малювати куб, обходячи масив VBO в порядку індексів, що зберігаються в масиві IBO, згідно схеми розмітки. Тобто індекс 0 означає першу шістку float'ів, 1 — другу і так далі. Таким чином на одну грань куба потрібно 4?6 координат і нормалей, плюс 6 індексів — по три на трикутник. З двох трикутників ми отримаємо грань куба — тобто квадрат.

Фігура 2. Модель куба OpenGL

Складні моделі, як правило, читають з файлів, геометрія може займати мегабайти. Для цього прикладу обійдемося двома текстовими значеннями — масивами. Обидва повинні бути передані в відеопам'ять, щоб OpenGL могла ними скористатися.

private static final float[] VERTEX = {
 // position | normal
 // left
 1.0 F, 1.0 F, 1.0 F, 1.0 F, 0.0 F, 0.0 F,
 1.0 F, 1.0 F,-1.0 F, 1.0 F, 0.0 F, 0.0 F,
 1.0 F,-1.0 F,-1.0 F, 1.0 F, 0.0 F, 0.0 F,
 1.0 F,-1.0 F, 1.0 F, 1.0 F, 0.0 F, 0.0 F,
 // front
 -1.0 F, 1.0 F, 1.0 F, 0.0 F, 0.0 F, 1.0 F,
 1.0 F, 1.0 F, 1.0 F, 0.0 F, 0.0 F, 1.0 F,
 1.0 F,-1.0 F, 1.0 F, 0.0 F, 0.0 F, 1.0 F,
 -1.0 F,-1.0 F, 1.0 F, 0.0 F, 0.0 F, 1.0 F,
 // top
 -1.0 F, 1.0 F, 1.0 F, 0.0 F, 1.0 F, 0.0 F,
 -1.0 F, 1.0 F,-1.0 F, 0.0 F, 1.0 F, 0.0 F,
 1.0 F, 1.0 F,-1.0 F, 0.0 F, 1.0 F, 0.0 F,
 1.0 F, 1.0 F, 1.0 F, 0.0 F, 1.0 F, 0.0 F,
 // bottom
 -1.0 F,-1.0 F, 1.0 F, 0.0 F,-1.0 F, 0.0 F,
 -1.0 F,-1.0 F,-1.0 F, 0.0 F,-1.0 F, 0.0 F,
 1.0 F,-1.0 F,-1.0 F, 0.0 F,-1.0 F, 0.0 F, 
 1.0 F,-1.0 F, 1.0 F, 0.0 F,-1.0 F, 0.0 F,
 // right
 -1.0 F, 1.0 F, 1.0 F,-1.0 F, 0.0 F, 0.0 F,
 -1.0 F, 1.0 F,-1.0 F,-1.0 F, 0.0 F, 0.0 F,
 -1.0 F,-1.0 F,-1.0 F,-1.0 F, 0.0 F, 0.0 F,
 -1.0 F,-1.0 F, 1.0 F,-1.0 F, 0.0 F, 0.0 F,
 // back
 -1.0 F, 1.0 F,-1.0 F, 0.0 F, 0.0 F,-1.0 F,
 1.0 F, 1.0 F,-1.0 F, 0.0 F, 0.0 F,-1.0 F,
 1.0 F,-1.0 F,-1.0 F, 0.0 F, 0.0 F,-1.0 F,
 -1.0 F,-1.0 F,-1.0 F, 0.0 F, 0.0 F,F -1.0 
};

 private static final short[] INDICES = {
 // first triangle, second triangle
 0,1,3, 1,2,3, // left quad
 4,5,7, 5,6,7, // front quad
 8,9,11, 9,10,11, // top quad
 12,13,15, 13,14,15, // bottom quad 
 16,17,19, 17,18,19, // right quad
 20,21,23, 21,22,23 // back quad 
};

Створимо VAO — vertex array object і передамо в нього координати вершин, напрям векторів нормалей для граней куба та індекси переходу вершин. VAO — це по суті комбінація VBO і IBO з даними моделі, які ми хочемо передати конвеєру.

try (MemoryStack stack = MemoryStack.stackPush()) {

 VideoBuffer vbo = program.createVideoBuffer(
stack.floats(VERTEX),
VideoBuffer.Type.ARRAY_BUFFER,
VideoBuffer.Usage.STATIC_DRAW);

 VideoBuffer vio = program.createVideoBuffer(
 stack.shorts(INDICES), 
VideoBuffer.Type.ELEMENT_ARRAY_BUFFER,
VideoBuffer.Usage.STATIC_DRAW);
 // Create VAO
 IntBuffer px = stack.mallocInt(1);
glGenVertexArrays(px);
 this.vao = px.get();

glBindVertexArray(vao);

vio.bind();

program.passVertexAttribArray(
vbo,
 false, 
 Attribute.of("vertex_coord", 3), 
 Attribute.of("vertex_normal", 3));

glBindVertexArray(0);
 } 

Графічне зображення тривимірних об'єктів виходить шляхом проекції тривимірного простору на площину . Площиною виступає екран монітора. Проекцію будує за нас OpenGL, але перед цим нам потрібно задати математичні параметри віртуального тривимірного простору — сцени. Для опису сцени OpenGL використовує лінійну алгебру, віртуальний простір задається матрицями 4?4. Опис координатної системи OpenGL і матриць займає цілу статтю, з нею можна ознайомитись на сайті learnopengl.com . У даному прикладі використовується перспективна проекція, де площину близького відсікання віддалена від центру по осі z на 2, далекого на 10. Положення інших площин розраховується на підставі ширини та висоти області видимості вікна, в яке ми виводимо зображення.

Нам потрібно передати шейдерної програмі OpenGL три матриці: твір матриць виду і моделі (model-view), зворотну їй матрицю нормалей (normal) і матрицю, задає простір відсікання MVP (model view projection). Model-view і normal матриці використовуються шейдерами для обчислення затінення по Фонгу. Модель повернемо на 20 градусів вертикально по осі X і на 45 градусів горизонтально, також перенесемо її від себе z на 5. Так ми зможемо спостерігати куб, а не тільки його грань.

Modern OpenGL перекладає роботу з матрицями на програміста, в Java з матрицями зручно працювати через бібліотеку лінійної алгебри JOML , спеціально призначену для OpenGL. JOML повторює API OpenGL старших версій і застарілого розширення GLU. В C++ для тих же цілей застосовується подібна бібліотека GLM. GLM-код з прикладів на С++ легко переноситься на JOML. Матриці передаються шейдерної програмі як зовнішні константи — uniform .

Також поставимо оптичні властивості матеріалу моделі, стан і властивості джерела світла . Джерело світла зробимо точковим, відсунемо на себе, трохи вліво і ще трохи вгору від центру. Матеріал задамо злегка блискучим, імітує пластик .

private static float[] LIGHT = {
-0.5 F,0.5 F,-5.5 F,1.0 F,
0.0 F,0.0 F,0.0 F,1.0 F,
1.0 F,1.0 F,1.0 F,1.0 F,
 1.0 F,1.0 F,1.0 F,F 0.0 
};

private static float[] MATERIAL = { 
 0.0 F, 0.0 F, 0.0 F, 1.0 F,
 0.4 F, 0.4 F, 0.4 F, 1.0 F,
 0.7 F, 0.0 F, 0.0 F, 1.0 F,
 0.0 F, 0.0 F, 0.0 F, F 1.0
};

private static final float SHININESS = F 32.0;

... 
 // locate unifroms 
 this.mvpUL = program.getUniformLocation("mvp");
 this.mvUL = program.getUniformLocation("mv");
 this.nmUL = program.getUniformLocation("nm");

 this.lightUL = program.getUniformLocation("light_pads");
 this.materialUL = program.getUniformLocation("material_adse");
 this.materialShininessUL = program.getUniformLocation("material_shininess");
...
 public void render(int width, int height) {
 float fovY = (float) height/(float) width;
 float aspectRatio = (float) width/(float) height;

 float h = fovY * 2.0 F;
 float w = h * aspectRatio;

 final Matrix4f projection = new Matrix4f().frustum( -w, w-h, h, 2.0 F, F 10.0);

 final Matrix4f modelView = new Matrix4f().identity();
 modelView.translate(0, 0, -5f); 
 modelView.rotateXYZ((float) Math.toRadians(20.0), -(float) Math.toRadians(45.0 f), 0.0 F);
 final Matrix4f normal = new Matrix4f();
modelView.normal(normal);

 final Matrix4f modelVeiwProjection = new Matrix4f().identity().mul(projection).mul(modelView);

 try (MemoryStack stack = MemoryStack.stackPush()) {
 FloatBuffer mv = stack.callocFloat(16);
modelView.get(mv);
 FloatBuffer nm = stack.callocFloat(16);
normal.get(nm);
 FloatBuffer mvp = stack.callocFloat(16);
modelVeiwProjection.get(mvp);

program.start();

 glUniformMatrix4fv(mvpUL, false, mvp);
 glUniformMatrix4fv(mvUL, false, mv);
 glUniformMatrix4fv(nmUL, false, nm);

 glUniformMatrix4fv(lightUL, false, LIGHT);
 glUniformMatrix4fv(materialUL, false, MATERIAL);
 glUniform1f(materialShininessUL, SHININESS);

 glBindVertexArray(vao); 
 nglDrawElements(GL11.GL_TRIANGLES, 
 INDECIES.length, 
 GLType.UNSIGNED_SHORT.glEnum(), 
 MemoryUtil.NULL ); 
glBindVertexArray(0);

program.stop();
}
 } 

Малюнок 1: Затінення по Фонгу

Подивимося, як проекцію з тривимірного простору на площину виконав OpenGL. Затінення по Фонгу порахували наші шейдери.

Висновок

Ми розглянули використання апаратно прискореного API OpenCL, OpenGL в Java з допомогою простих прикладів. Ця стаття лише демонструє таку можливість, але не описує теоретичні основи тривимірної графіки і лінійної алгебри. Цей матеріал можна почерпнути зі спеціальної літератури. Варто зазначити, що література в основному орієнтована на мову С++, а не Java. Якщо стаття знайде відгук, більш складні прийоми і техніки розглянемо в наступних частинах.

Опубліковано: 08/05/20 @ 10:00
Розділ Різне

Рекомендуємо:

Самооцінка програміста: три правильних і три хибних спосібі скласти собі ціну
Infrastructure as Code: базові принципи vs інструменти, що еволюціонують
Понад 57 млн грн. Як IT-компанії та спеціалісти допомагають боротися з епідемією COVID-19
Front-end дайджест #39: COVID-19 у світі розробки інтерфейсів
gRPC-автогенерація Front-end-у