Застосуємо можливості відеокарти у вашій 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 виглядає наступним чином.
- Основна CPU-програма створює контекст, що зв'язує її з графічним драйвером, і середовище виконання шейдерів — шейдерну програму.
- Шейдери завантажуються в шейдерну програму.
- CPU готує дані, які будуть передані в шейдерну програму в потрібному форматі, копіює їх в відеопам'ять або вказує шейдеру адресу основної пам'яті для читання або запису даних.
- Шейдерна програма запускається, після чого результати її роботи відображаються на екрані або зчитуються для подальшого використання.
Особливості 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-у