2 of October - MS Stage Free Online conference: .NET, MS SQL, MS Azure, Cosmos DB. REGISTER
×Закрыть

Применим возможности видеокарты в вашей 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.0F);
        int w[] = { 0 };
        int h[] = { 0 };
        glfwGetFramebufferSize(this.handle, w, h);
            glViewport(0, 0, w[0], h[0]);
            glClearColor(1.0F, 1.0F, 1.0F, 1.0F);            
        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 popped 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.0F, 1.0F, 1.0F, 1.0F, 0.0F, 0.0F,
            1.0F, 1.0F,-1.0F, 1.0F, 0.0F, 0.0F,
            1.0F,-1.0F,-1.0F, 1.0F, 0.0F, 0.0F,
            1.0F,-1.0F, 1.0F, 1.0F, 0.0F, 0.0F,
            // front
            -1.0F, 1.0F, 1.0F, 0.0F, 0.0F, 1.0F,
             1.0F, 1.0F, 1.0F, 0.0F, 0.0F, 1.0F,
             1.0F,-1.0F, 1.0F, 0.0F, 0.0F, 1.0F,
            -1.0F,-1.0F, 1.0F, 0.0F, 0.0F, 1.0F,
            // top
            -1.0F, 1.0F, 1.0F, 0.0F, 1.0F, 0.0F,
            -1.0F, 1.0F,-1.0F, 0.0F, 1.0F, 0.0F,
             1.0F, 1.0F,-1.0F, 0.0F, 1.0F, 0.0F,
             1.0F, 1.0F, 1.0F, 0.0F, 1.0F, 0.0F,
            // bottom
            -1.0F,-1.0F, 1.0F, 0.0F,-1.0F, 0.0F,
            -1.0F,-1.0F,-1.0F, 0.0F,-1.0F, 0.0F,
             1.0F,-1.0F,-1.0F, 0.0F,-1.0F, 0.0F, 
             1.0F,-1.0F, 1.0F, 0.0F,-1.0F, 0.0F,
            // right
            -1.0F, 1.0F, 1.0F,-1.0F, 0.0F, 0.0F,
            -1.0F, 1.0F,-1.0F,-1.0F, 0.0F, 0.0F,
            -1.0F,-1.0F,-1.0F,-1.0F, 0.0F, 0.0F,
            -1.0F,-1.0F, 1.0F,-1.0F, 0.0F, 0.0F,
            // back
            -1.0F, 1.0F,-1.0F, 0.0F, 0.0F,-1.0F,
             1.0F, 1.0F,-1.0F, 0.0F, 0.0F,-1.0F,
             1.0F,-1.0F,-1.0F, 0.0F, 0.0F,-1.0F,
            -1.0F,-1.0F,-1.0F, 0.0F, 0.0F,-1.0F 
     };
        
        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.5F,0.5F,-5.5F,1.0F,
   0.0F,0.0F,0.0F,1.0F,
   1.0F,1.0F,1.0F,1.0F,
   1.0F,1.0F,1.0F,0.0F   
 };

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

private static final float SHININESS = 32.0F;

... 
     // 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.0F;
    float w = h * aspectRatio;
            
        final Matrix4f projection = new Matrix4f().frustum( -w, w, -h, h, 2.0F, 10.0F);
            
   	final Matrix4f modelView = new Matrix4f().identity();
    modelView.translate(0, 0, -5f);	     
    modelView.rotateXYZ((float) Math.toRadians(20.0), -(float) Math.toRadians(45.0f), 0.0F);
    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. Если статья найдет отклик, более сложные приемы и техники рассмотрим в следующих частях.

LinkedIn

17 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Если кто хочет на джаве паралелить вычисления на видяхе — советую глянуть на jcuda.org

Так CUDA это и есть проблема привязываться только к нвидиа? Зачем? Одинаковые кернелы на OpenCL можно запускать практически на всех GPU от мобильных Qualcom Adreno или Mali (то еще глюкало) до интеловских или от amd.

если заняться этим всерьез, а не побаловаться на досуге, то привязка к нвидии совсем не страшна

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

А, мобайл, понятно. Когда-то ковырялся с Нвидиевским профайлером, удалось выжать производительность чуть меньше максимальной теоретической, как с этим в OpenCL зоопарке — без понятия.

Спасибо за статью. Как оцениваете перспективы opencl касательно десктопа в общем, и вин/линукс в частности ? Вижу нвидия не торопится дальше 1.2, у интела пока нет карт (да и на встройках 2.1 максимум поддержка, тогда как 2.2 уже лет 5 существует), амд забила на винду и развивает непортируемый альтернативный стек только под линуксом

Вот тут пожалуй становится понятно почему именно 1.2 www.khronos.org/opencl
На днях вышел стандарт 3.0 — который стандартизирует 1.2 sub set +загрузку шейдеров предварительно скопилированных в SPIR-V (введено в 2.2). Все остальное теперь расширения.

„OpenCL is the most pervasive, cross-vendor, open standard for low-level heterogeneous parallel programming—widely used by applications, libraries, engines, and compilers that need to reach the widest range of diverse processors. OpenCL 2.X delivers significant functionality, but OpenCL 1.2 has proven itself as the baseline needed by all vendors and markets. OpenCL 3.0 integrates tightly organized optionality into the monolithic 2.2 specification, boosting deployment flexibility that will enable OpenCL to raise the bar on pervasively available functionality in future core specifications.”
Neil Trevett
Vice President at NVIDIA, President of the Khronos Group and OpenCL Working Group Chair

Особенности OpenCL 2.0 — SVM (при создании буфера добавляете еще один параметр в маску и CPU и GPU начинают работать с памятью в общем адресном просторанстве, что улучшает производительность). 2.0 так-же добавляются атомарные операции из С11 — которых по идее нужно избегать. 2.1 и 2.2 — делают из OpenCL C — OpenCL C++ 14

Далее по теме habr.com/ru/news/t/499616

+1 к предыдущему коменту хоть убей не пойму зачем всё это «делать на джаве» если можно написать на чём-то нормальном ту самую «низкоуровневую часть» и просто проложить к ней какой-нибудь универсальный интерфейс доступный что в джаве что в «условных си плюсах»

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

из статьи не понятно какую именно практическую либо теоретическую либо академическую цель преследует данный подход

ЗЫ: тем более насколько я понимаю речь не идёт об собственных разработках а только об «условно практичных примерах использования» уже готовой и видимо чужой библиотеки LWJGL или даже нескольких библиотек + JOML т.е. как общая цель конкретной статьи показать что такие библиотеки вообще есть в джава и что в общем смысле они могут примерно вот такое и что «остальные примеры по теме написаны на си++» ну и круто вот и вопрос скорее таки в том а зачем они вообще на джава? ок понятно они есть и они работают и статья это показала )) но зачем?

тем более насколько я понимаю речь не идёт об собственных разработках а только об «условно практичных примерах использования» уже готовой и видимо чужой библиотеки LWJGL

 LWJGL не более чем биндинг к OpenGL, OpenCL — условно это API к драйверу видеокарты. Просто скачав библиотеку, без примеров и враппер классов — на два «hello world» уйдет порядком недели и то при условии что вы это уже делали на С/C++ и владетее JNI. Если хотите этим занятся — каркас можете взять в моем репозитории, причем безплатно.

+1 к предыдущему коменту хоть убей не пойму зачем всё это «делать на джаве» если можно написать на чём-то нормальном ту самую «низкоуровневую часть»

Что вы понимаете под «чем-то нормальным» — C/C++? Положим вам нужна небольшая функциональность которую можно сщественно ускорить применив GPU, скажем некий JavaScript хочет получить с сервера картинку некоего товара по его 3D модели, с произвольного ракурса. Ну или нужно распознать лица на изображении. Есть выбор — написать все на C++ и JNI (и найти специалиста по JNI, потому-что JNI далеко не так прост как кажется) решить массу проблем с загрузкой и распаковкой бинырных dll, совместимости с JVM (Linux,Windows,Mac JVM не всегда одинаковые), с тем как эффективно передавать данные из Java в С++ и обратно и т.д. При этом на деле ваш С++ код просто будет вызвать функции API, и от аналогичного LWJGL ничем приципиально отличается не будет (скорее всего он будет сильно хуже, если вы не JNI профессионал). В таком случае мой выбор будет в сторону LWJGL, но решать конечно вам.

зачем JNI если

скажем некий JavaScript хочет получить с сервера картинку некоего товара по его 3D модели, с произвольного ракурса.

общается с сервисом по http и чему-либо поверх пусть будет json и задача принимающей стороны просто взять и передать одно от клиента на соотв. сервис и потом просто взять и передать в зад другое уже от соотв. сервиса клиенту всё

с тем как эффективно передавать данные из Java в С++ и обратно и т.д.

именно потому я и спрашиваю что вы не мыслите на уровне «а зачем?» и вот даже в вашем примере «а зачем?» но зачем там «передавать данные из джава в си++ и обратно» если в самой джава они вообще не нужны от слова совсем?

При этом на деле ваш С++ код просто будет вызвать функции API, и от аналогичного LWJGL ничем приципиально отличается не будет (скорее всего он будет сильно хуже, если вы не JNI профессионал). В таком случае мой выбор будет в сторону LWJGL, но решать конечно вам.

так я об том же ж и говорю что вы со своей стороны к этому

на деле ваш С++ код просто будет вызвать функции API

предлагается просто доложить JNI и вызывать всё то же ж же «функции API» только теперь на джава и через прослойку JNI проблемы которой вы уже сами себе описали и более того вы дальше сами и написали что

и от аналогичного LWJGL ничем приципиально отличается не будет

и только 1 условие поставили когда таки хуже

(скорее всего он будет сильно хуже, если вы не JNI профессионал)

но мне ну нужен JNI мне нужен JS который на входе даёт какую-то... вообще скорее всего просто координатную точку вида а сама модель уже лежит на сервере в готовом виде и всё что нужно это передать назад картинку обратно же ж наверняка просто http т.с. raw http вряд ли кому придёт в голову даже заворачивать картинку в json

т.е. по сути мне даже джава не нужна а достаточно только отдельного входа на сервере который принял бы б координаты view и пусть номер модели и вернул бы б картинку с видом всё нет больше никаких JNI и соотв. нет больше никаких

(скорее всего он будет сильно хуже, если вы не JNI профессионал)

так зачем? ))

Если вам вообще не нужна Java. Вы все можете делать сами — микро сервис/отдельное приложениее на Boost,POCO ... вам не нужны: Spring, Hibernate, Apache Tiles, Apache POI и т.п.. Вы не расширяте IDE или любую дргую программу/фреймверк уже написанную на Java своим плугином — можете обойтись и без — Java. С++ прекрасный язык, один из моих любимых.

Ну и чистый маркетинг. Условно С++ надо учить кроме собственно языка нужно еще очень много чего настройки IDE, особенности компиляторов и линкеров, Cmake, API операционных систем и т.д. Условно java-исту проще использовать библиотеку нежели изучить С++. В С++ скудная стандартная библиотека, особенно в сравнении с Java. В ней нет : XML и JSON парсеров, сетевой библиотеки, средств работы с изображениями и много еще чего. Т.е. нужно «тащить за собой DLL — hell». В Java «из коробки» есть довольно много. Компиляция программ С++ — это боль, иногда часами идет — в Java с этим все лучше. Таким образом как минимум для быстрого прототипирования — Java подходит лучше. В С++ пока-что нет рефлексии поэтому и нет и технологий типа JAXB, JAXRS, Hibernate, Jackson и и.п. существенно упрощающих жизнь программисту. Вот чего в Java-нет так это производительности С++, но это не всегда критично, не все проекты — снимают данные с оборудования в реальном времени, и не все игры — ААА класса. P.S. Таким-же точно образом можно сравнить С++ и Assembler

Это все, конечно, похвально.
Но мало нам софта на Electron, не хватает только игр на Java :)

А кто сказал что графику или gpjpu можно применять исключительно для игр? Например мы применяли OpenGL в Java для эмулятора мобильного телефона, вращая модель на экране можно было выдавать значения акселерометра. Отдельный компонент писать на C++, под три операционные системы + куча jni кода было делать невыгодно. Парочка классов на Java все решила. Аналогично никто не заставляет строить нейронную сеть исключительно на python. Ну — а игровые движки пишут и без того: и на Java, и на C#, и Python,и Lua и много ещё на чем.

Толстые данные на питоне вращают же, тогда почему бы и не игры на джаве, на дотнете ж есть)

Играм на Java сто лет в обед.

Включая первый клиент для RuneScape, Minecraft и другие.
Есть 2D/3D движки.
Например, LibGDX, который позволяет создавать кросс-платформенные игры для Android/iOS/PC/HTML+JS
Под капотом там как раз упомянутая LWJGL в том числе

Ну а так-то есть еще Unity где при желании можно вообще логику на JavaScript писать.

Есть один ньюанс виндовых игр на C/C++, очень часто разработчики имея возможность перекомпилить какую-то общудоступную библиотеку — это сделают обязательно, либо с целью оптимизации, либо ещё для чего-то, ну просто потому что могут. Те кто пишут на Java этого не делают, может потому что не умеют или просто влом. Но факт остаётся фактом. Когда я работал над эффектами в OpenAL для OpenALsoft я всё время пытался найти годный тест и нашёл minecraft. Т.к. OpenAL поставлялся с LWJGL, но древней версии, то я просто заменил dll. После путём простого создания конфига — я включил свеже созданную поддержку HRTF и прослезился, игра преобразилась. Я эту тему начал продавливать с LWJGL — они обновили OpenAL, потом Minecraft — они обновили LWJGL. И через три недели уже был minecraft с обновлёнными либами. Сейчас это включается просто:

www.reddit.com/...​enal_including_minecraft

Но по умолчанию так и не включили в майнкрафте HRTF — на некоторых машинах тормозило сильно тогда. Попробовал сделать тоже самое на некоторых играх написанных на C/C++ — хрен там, разработчики модифицировали OpenALsoft для своих нужд (вот, нахуа?) и ванильная чистая OpenALsoft не заработала. Так что я впервые оценили плюсы Java для minecraft’а :)

Подписаться на комментарии