Створення движка для 3D-рендерингу на Java


Дізнайтесь більше про нові кар'єрні можливості в EchoUA. Цікаві проекти, ринкова оплата, гарний колектив. Надсилайте резюме та приєднуйтеся до нас.

Сучасні движки для 3D-рендерингу, використовувані в іграх і мультимедіа, вражають своєю складністю з математики і програмування. Відповідно, результат їх роботи чудовий.

Багато розробників помилково вважають, що створення навіть простого 3D-додатка з нуля потребує надлюдських знань і неймовірних зусиль. На щастя, це не зовсім так. Більше того, за наявності комп’ютера і вільного часу, можна створити щось подібне самостійно. Давайте розглянемо процес розробки власного движка для 3D-рендерингу.

 

Отже, для чого це потрібно? По-перше, створення движка для 3D-рендерингу допоможе зрозуміти, як саме працюють сучасні движки зсередини. По-друге, сам движок, за бажання, можна використати у власному додатку, не вдаючись до виклику зовнішніх залежностей. У випадку з Java це означає, що Ви можете створити власний додаток для перегляду 3D-зображень без залежностей (далеких від API Java), яке працюватиме практично скрізь і вміщатиметься в 50 КБ!

Зрозуміло, якщо Ви хочете створити великий 3D-додаток з плавною анімацією, то краще використати OpenGL/WebGL. Проте, маючи базове уявлення про те, як влаштовані подібні движки, робота зі складнішими движками здаватиметься в рази простіше.

У цій статті я спробую пояснити базовий 3D-рендеринг з ортографічною проекцією, просте трикутне растеризування (процес, зворотний векторизації), Z-буферизацію і плоске затінювання. Я не зосереджуватимусь на таких речах, як оптимізація, текстури і різні налаштування освітлення, якщо для Вас це важливо, то спробуйте використати відповідні для цього інструменти, типу OpenGL (є багато бібліотек, що дозволяють Вам працювати з OpenGL, навіть використовуючи Java).

Приклади коду будуть на Java, але самі ідеї можуть бути застосовані для будь-якої іншої мови на Ваший вибір.

Давайте приступимо до справи!

GUI

Спершу розмістимо що-небудь на екран. Для цього я використовуватиму простий додаток, в якому відображатиметься наше отрендерене зображення і два скролери для обертання.

import javax.swing.*;import java.awt.*;public class DemoViewer { public static void main (String[] args) { JFrame frame = new JFrame (); Container pane = frame.getContentPane (); pane.setLayout (new BorderLayout()); // slider to control horizontal rotation JSlider headingSlider = new JSlider (0, 360, 180); pane.add (headingSlider, BorderLayout.SOUTH); // slider to control vertical rotation JSlider pitchSlider = new JSlider (SwingConstants.VERTICAL, - 90, 90, 0); pane.add (pitchSlider, BorderLayout.EAST); // panel to display render results JPanel renderPanel = new JPanel (){ public void paintComponent (Graphics g) { Graphics2D g2 =  (Graphics2D) g; g2.setColor (Color.BLACK);
g2.fillRect (0, 0, getWidth (), getHeight()); // rendering magic will happen here } }; pane.add (renderPanel, BorderLayout.CENTER); frame.setSize (400, 400); frame.setVisible (true); }}

Результат має виглядати так:

Тепер давайте додамо деякі моделі – вершини і трикутники. Вершина – це просто структура для зберігання наших трьох координат (X, Y і Z), а трикутник сполучає разом три вершини і містить їх колір.

class Vertex { double x; double y; double z; Vertex (double x, double y, double z) { this.x = x; this.y = y; this.z = z; }}class Triangle { Vertex v1; Vertex v2; Vertex v3; Color color;
Triangle (Vertex v1, Vertex v2, Vertex v3, Color color) { this.v1 = v1; this.v2 = v2; this.v3 = v3; this.color = color; }}

Тут я вважатиму, що X означає переміщення ліворуч-праворуч, Y – вгору-вниз, а Z буде глибиною (так, що вісь Z перпендикулярна Вашому екрану). Позитивна Z означатиме “ближче до користувача”.

За приклад я вибрав тетраедр як просту фігуру, про яку згадав, – треба всього 4 трикутники, щоб описати її.

Код також буде простим – ми створюємо 4 трикутники і додаємо їх в ArrayList:

List tris = new ArrayList<>();tris.add (new Triangle (new Vertex (100, 100, 100), new Vertex (- 100, - 100, 100),
new Vertex (- 100, 100, - 100), Color.WHITE));tris.add (new Triangle (new Vertex (100, 100, 100), new Vertex (- 100, - 100, 100), new Vertex (100, - 100, - 100), Color.RED));tris.add (new Triangle (new Vertex (- 100, 100, - 100), new Vertex (100, - 100, - 100), new Vertex (100, 100, 100), Color.GREEN));tris.add (new Triangle (new Vertex (- 100, 100, - 100), new Vertex (100, - 100, - 100), new Vertex (- 100, - 100, 100), Color.BLUE));

У результаті ми отримаємо фігуру, центр якої знаходиться на початку координат (0, 0, 0), що зручно, оскільки ми обертатимемо фігуру відносно цієї точки.

Тепер давайте додамо все це на екран. Спершу ми не додаватимемо можливість обертання і просто відмалюємо каркасне представлення фігури. Оскільки ми використовуємо ортографічну проекцію, це виконати просто – достатньо прибрати координату Z і намалювати наші трикутники.

g2.translate (getWidth () / 2, getHeight () / 2);g2.setColor (Color.WHITE);for (Triangle t: tris) { Path2D path = new Path2D.Double (); path.moveTo (t.v1.x, t.v1.y); path.lineTo (t.v2.x, t.v2.y); path.lineTo (t.v3.x, t.v3.y); path.closePath (); g2.draw (path);}

Зверніть увагу на те, що зараз я виконав усі перетворення до відмальовки трикутників. Це зроблено для того, щоб помістити наш центр (0, 0, 0) у центр екрана – за умовчанням, початок координат знаходиться в лівому верхньому куті екрана. Після компіляції Ви повинні отримати:

Ви можете не повірити, але це наш тетраедр в ортогональній проекції, чесно!

Тепер нам треба додати обертання. Для цього мені треба буде трохи відійти від теми і поговорити про використання матриць і про те, як за їх допомогою досягти тривимірної трансформації 3D-точок.

Є багато шляхів маніпулювання 3D-точками, але найгнучкіший з них – це використання матричного множення. Ідея полягає в тому, щоб показати точки у вигляді вектора розміру 3×1, а перехід – це, власне, домноження на матрицю розміру 3×3.

Візьмемо наш вхідний вектор A:

Помножимо його на так звану матрицю трансформації T, щоб отримати у результаті вихідний вектор B:

Наприклад, ось як виглядатиме трансформація, якщо ми помножимо на 2:

Ви не можете описати будь-яку можливу трансформацію, використовуючи матриці розміру 3×3, наприклад, якщо перехід відбувається за межі простору. Ви можете використати матриці розміру 4×4, роблячи перекіс у 4D-простір, але про це йтиметься в іншій статті.

Трансформації, які нам згодяться тут, – масштабування і обертання.

Будь-яке обертання в 3D-просторі може бути виражене в 3 примітивних обертаннях: обертання в площині XY, обертання в площині YZ і обертання в площині XZ. Ми можемо записати матриці трансформації для кожного з цих обертань у такий спосіб:

  • Матриця обертання XY:

  • Матриця обертання YZ:

  • Матриця обертання XZ:

Саме тут і починається магія: якщо Вам треба спочатку виконати обертання точки в площині XY, використовуючи матрицю трансформації T1, і потім вчинити обертання цієї точки в площині YZ, використовуючи матрицю трансформації T2, то Ви можете просто помножити T1 на T2 і отримати одну матрицю, яка опише все обертання:

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

Що ж, достатньо математики, повернімося до коду. Створимо службовий клас Matrix3, який оброблятиме перемножування типу “матриця-матриця” і “вектор-матриця”:

class Matrix3 { double[] values; Matrix3 (double[] values) { this.values = values; } Matrix3 multiply (Matrix3 other) { double[] result = new double[9]; for (int row = 0; row < 3; row++) { for (int col = 0; col < 3; col++) { for (int i = 0; i < 3; i++) { result[row * 3 + col] += this.values[row * 3 + i] * other.values[i * 3 + col]; } } } return new Matrix3 (result); }
Vertex transform (Vertex in) { return new Vertex (in.x * values[0] + in.y * values[3] + in.z * values[6], in.x * values[1] + in.y * values[4] + in.z * values[7], in.x * values[2] + in.y * values[5] + in.z * values[8]); }}

Тепер можна і оживити наші скролери обертання. Горизонтальний скролер контролюватиме обертання ліворуч-праворуч (XZ), а вертикальний скролер контролюватиме обертання вгору-вниз (YZ).

Давайте створимо нашу матрицю обертання:

double heading = Math.toRadians (headingSlider.getValue());Matrix3 transform = new Matrix3 (new double[] {Math.cos (heading), 0, - Math.sin (heading), 0, 1, 0, Math.sin (heading), 0, Math.cos (heading)});
g2.translate (getWidth () / 2, getHeight () / 2);g2.setColor (Color.WHITE);for (Triangle t: tris) { Vertex v1 = transform.transform (t.v1); Vertex v2 = transform.transform (t.v2); Vertex v3 = transform.transform (t.v3); Path2D path = new Path2D.Double (); path.moveTo (v1.x, v1.y); path.lineTo (v2.x, v2.y); path.lineTo (v3.x, v3.y); path.closePath (); g2.draw (path);}

Вам також треба буде  додати слухачів на скролери, щоб забезпечити оновлення зображення, коли Ви тягнутимете їх вгору-вниз або праворуч-ліворуч.

headingSlider.addChangeListener (e -> renderPanel.repaint()); pitchSlider.addChangeListener (e -> renderPanel.repaint());

Як Ви, напевно, вже помітили, обертання вгору-вниз ще не працює. Додамо ці рядки в код:

Matrix3 headingTransform = new Matrix3 (new double[] {Math.cos (heading), 0, Math.sin (heading), 0, 1, 0, - Math.sin (heading), 0, Math.cos (heading)}); double pitch = Math.toRadians (pitchSlider.getValue()); Matrix3 pitchTransform = new Matrix3 (new double[] {1, 0, 0, 0, Math.cos (pitch), Math.sin (pitch), 0, - Math.sin (pitch), Math.cos (pitch)}); Matrix3 transform = headingTransform.multiply (pitchTransform);

Дотепер ми відмальовували тільки каркасне представлення нашої фігури. Тепер давайте заповнимо його чимось. Для цього нам треба спочатку растеризувати трикутник – представити його у вигляді пікселів на екрані.

Я використовуватиму простий, але вкрай неефективний метод – растеризування через барицентричні координати. Справжні 3D-движки використовують растеризування, задіюючи “залізо” комп’ютера, що дуже швидко і ефективно, але ми не можемо використати нашу відеокарту, так що робитимемо все вручну, через код.

Ідея полягає в тому, щоб обчислити барицентричну координату для кожного пікселя, який може лежати всередині нашого трикутника і виключити ті, що поза його межами. Наступний фрагмент містить алгоритм. Зверніть увагу на те, як ми стали безпосередньо звертатися до пікселів зображення.

BufferedImage img = new BufferedImage (getWidth (), getHeight (), BufferedImage.TYPE_INT_ARGB);for (Triangle t: tris) { Vertex v1 = transform.transform (t.v1); Vertex v2 = transform.transform (t.v2);
Vertex v3 = transform.transform (t.v3); // since we are not using Graphics2D anymore, we have to do translation manually v1.x += getWidth () / 2; v1.y += getHeight () / 2; v2.x += getWidth () / 2; v2.y += getHeight () / 2; v3.x += getWidth () / 2; v3.y += getHeight () / 2; // compute rectangular bounds for triangle int minX =  (int) Math.max (0, Math.ceil (Math.min (v1.x, Math.min (v2.x, v3.x)))); int maxX =  (int) Math.min (img.getWidth () - 1, Math.floor (Math.max (v1.x, Math.max (v2.x, v3.x)))); int minY =  (int) Math.max (0, Math.ceil (Math.min (v1.y, Math.min (v2.y, v3.y)))); int maxY =  (int) Math.min (img.getHeight () - 1, Math.floor (Math.max (v1.y, Math.max (v2.y, v3.y)))); double triangleArea =  (v1.y - v3.y)  *  (v2.x - v3.x)  +  (v2.y - v3.y)  *  (v3.x - v1.x); for (int y = minY; y <= maxY; y++) { for (int x = minX; x <= maxX; x++) { double b1 = ((y - v3.y) * (v2.x - v3.x) + (v2.y - v3.y) * (v3.x - x)) / triangleArea; double b2 = ((y - v1.y) * (v3.x - v1.x) + (v3.y - v1.y) * (v1.x - x)) / triangleArea; double b3 = ((y - v2.y) * (v1.x - v2.x) + (v1.y - v2.y) * (v2.x - x)) / triangleArea; if (b1 >= 0 && b1 <= 1 && b2 >= 0 && b2 <= 1 && b3 >= 0 && b3 <= 1) { img.setRGB (x, y, t.color.getRGB()); } } }}g2.drawImage (img, 0, 0, null);

Багато коду, але тепер у нас є кольоровий тетраедр на екрані.

Якщо Ви пограєтеся з демо, то помітите, що не все зроблено ідеально – наприклад, синій трикутник завжди вищий за інші. Так відбувається тому, що ми відмальовуємо наші трикутники один за одним. Синій тут – останній, тому він відмальовується поверх інших.

Щоб виправити це, давайте розглянемо Z-буферизацію. Ідея полягає в тому, щоб у процесі растеризування створити проміжний масив, який зберігатиме в собі відстань до останнього видимого елемента на кожному з пікселів. Роблячи растеризування трикутників, ми перевірятимемо, чи менше відстань до пікселя, ніж відстань до попереднього, і зафарбовувати його тільки у тому випадку, якщо він знаходиться поверх інших.

double[] zBuffer = new double[img.getWidth () * img.getHeight ()];// initialize array with extremely far away depths
for (int q = 0; q < zBuffer.length; q++) { zBuffer[q] = Double.NEGATIVE_INFINITY;}for (Triangle t: tris) { // handle rasterization... // for each rasterized pixel: double depth = b1 * v1.z + b2 * v2.z + b3 * v3.z; int zIndex = y * img.getWidth () + x; if (zBuffer[zIndex] < depth) { img.setRGB (x, y, t.color.getRGB()); zBuffer[zIndex] = depth; }}

Тепер видно, що у нашого тетраедра є одна біла сторона:

Ось ми і отримали працюючий движок для 3D-рендерингу!

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

У комп’ютерній графіці ми можемо досягти подібного ефекту за допомогою так званого “затінювання” – зміни кольору поверхні залежно від кута нахилу і відстані відносно джерела світла.

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

Спершу нам треба обчислити вектор нормалі для нашого трикутника. Якщо у нас є трикутник ABC, то ми можемо обчислити його вектор нормалі, розрахувавши векторний добуток векторів AB і AC і поділивши отриманий вектор на його довжину.

Векторний добуток – це бінарна операція на двох векторах, які визначені в 3D просторі як:

Візуальне представлення того, що робить наш векторний добуток:

for (Triangle t: tris) { // transform vertices before calculating normal... Vertex norm = new Vertex (ab.y * ac.z - ab.z * ac.y, ab.z * ac.x - ab.x * ac.z, ab.x * ac.y - ab.y * ac.x); double normalLength = Math.sqrt (norm.x * norm.x + norm.y * norm.y + norm.z * norm.z); norm.x /= normalLength; norm.y /= normalLength; norm.z /= normalLength;}

Тепер нам треба обчислити косинус між нормаллю трикутника і напрямом світла. Для спрощення вважатимемо, що наше джерело світла розташоване прямо за камерою на певній відстані (така конфігурація називається “спрямоване світло”), отже, наше джерело світла знаходитиметься в точці (0, 0, 1).

Косинус кута між векторами можна обчислити за формулою:

Де ||A|| – довжина вектора, а чисельник – скалярний добуток векторів A і B:

Зверніть увагу на те, що довжина вектора напряму світла дорівнює 1, так само, як і довжина нормалі трикутника (ми вже нормалізували це). Таким чином, формула перетворюється на це:

Зверніть увагу, що тільки Z компонент напряму світла не дорівнює нулю, так що ми можемо все спростити:

У коді це виглядає тривіально:

double angleCos = Math.abs (norm.z);

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

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

public static Color getShade (Color color, double shade) { int red =  (int) (color.getRed () * shade); int green =  (int) (color.getGreen () * shade); int blue =  (int) (color.getBlue () * shade); return new Color (red, green, blue);}

Код даватиме нам деякі ефекти затінювання, але спадати вони будуть набагато швидше, ніж нам треба. Так відбувається, тому що в Java використовується спектр кольорів sRGB.

Нам треба конвертувати кожен колір у лінійний формат, застосувати затінювання і потім конвертувати назад. Реальний перехід з sRGB до лінійного RGB – трудомісткий процес, тому я не виконуватиму повний перелік задач тут. Замість цього, я зроблю щось наближене до цього.

public static Color getShade (Color color, double shade) {
double redLinear = Math.pow (color.getRed (), 2.4)  * shade; double greenLinear = Math.pow (color.getGreen (), 2.4)  * shade; double blueLinear = Math.pow (color.getBlue (), 2.4)  * shade; int red =  (int) Math.pow (redLinear, 1/2.4); int green =  (int) Math.pow (greenLinear, 1/2.4); int blue =  (int) Math.pow (blueLinear, 1/2.4); return new Color (red, green, blue);}

І тепер ми бачимо, як наш тетраедр пожвавлюється. У нас є працюючий движок для 3D-рендерингу з кольорами, освітленням, затінюванням, і зайняло це близько 200 рядків коду – непогано!

Ось невеликий бонус для Вас – можете швидко створити фігуру, наближену до сфери зі свого тетраедра. Цього можна досягти шляхом розбивання кожного трикутника на 4 маленьких і “надуваючи”.

public static List inflate (List tris) {
List result = new ArrayList<>(); for (Triangle t: tris) { Vertex m1 = new Vertex ((t.v1.x + t.v2.x)/2, (t.v1.y + t.v2.y) /2, (t.v1.z + t.v2.z) /2); Vertex m2 = new Vertex ((t.v2.x + t.v3.x)/2, (t.v2.y + t.v3.y) /2, (t.v2.z + t.v3.z) /2); Vertex m3 = new Vertex ((t.v1.x + t.v3.x)/2, (t.v1.y + t.v3.y) /2, (t.v1.z + t.v3.z) /2); result.add (new Triangle (t.v1, m1, m3, t.color)); result.add (new Triangle (t.v2, m1, m2, t.color)); result.add (new Triangle (t.v3, m2, m3, t.color)); result.add (new Triangle (m1, m2, m3, t.color)); } for (Triangle t: result) { for (Vertex v: new Vertex[] { t.v1, t.v2, t.v3 }) { double l = Math.sqrt (v.x * v.x + v.y * v.y + v.z * v.z)  / Math.sqrt (30000); v.x /= l; v.y /= l; v.z /= l; } } return result;}

Ось що має вийти:

Я б закінчив цю статтю, порекомендувавши одну цікаву книгу: “Засади 3D-математики для графіки і розробки ігор”. У ній Ви можете знайти детальне пояснення процесу рендерингу і математики в цьому процесі. Її варто прочитати, якщо Вам цікаві движки для рендерингу.

Джерело: blog.rogach.org

Київ, Харків, Одеса, Дніпро, Запоріжжя, Кривий Ріг, Вінниця, Херсон, Черкаси, Житомир, Хмельницький, Чернівці, Рівне, Івано-Франківськ, Кременчук, Тернопіль, Луцьк, Ужгород, Кам'янець-Подільський, Стрий - за статистикою саме з цих міст програмісти найбільше переїжджають працювати до Львова. А Ви розглядаєте relocate?


Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *