public class Box{ public Vector3 Center; public Vector3 Size; public Quaternion Quaternion; public Box(Vector3 center, Vector3 size, Quaternion quaternion) { this.Center = center; this.Size = size; this.Quaternion = quaternion; } // дополнительный конструктор, который // позволяет сгенерировать объект на основе GameObject public Box(GameObject obj) { Center = obj.transform.position; Size = obj.transform.lossyScale; Quaternion = obj.transform.rotation; }}
private static Vector3 QuanRotation(Vector3 v,Quaternion q){ float u0 = v.x * q.x + v.y * q.y + v.z * q.z; float u1 = v.x * q.w - v.y * q.z + v.z * q.y; float u2 = v.x * q.z + v.y * q.w - v.z * q.x; float u3 = -v.x * q.y + v.y * q.x + v.z * q.w; Quaternion M = new Quaternion(u1,u2,u3,u0); Vector3 resultVector; resultVector.x = q.w * M.x + q.x * M.w + q.y * M.z - q.z * M.y; resultVector.y = q.w * M.y - q.x * M.z + q.y * M.w + q.z * M.x; resultVector.z = q.w * M.z + q.x * M.y - q.y * M.x + q.z * M.w; return resultVector;}
private static Vector3[] GetPoint(Box box){ //Тут будут храниться координаты вершин Vector3[] point = new Vector3[8]; //получаем координаты //.... return point;}
//... //первые четыре вершины point[0] = box.Center - box.Size/2; point[1] = point[0] + new Vector3(box.Size.x , 0, 0); point[2] = point[0] + new Vector3(0, box.Size.y, 0); point[3] = point[0] + new Vector3(0, 0, box.Size.z);//таким же образом находим оставшееся точки point[4] = box.Center + box.Size / 2; point[5] = point[4] - new Vector3(box.Size.x, 0, 0); point[6] = point[4] - new Vector3(0, box.Size.y, 0); point[7] = point[4] - new Vector3(0, 0, box.Size.z);//...
//... for (int i = 0; i < 8; i++) { point[i] -= box.Center;//перенос центра в начало координат point[i] = QuanRotation(point[i], box.Quaternion);//поворот point[i] += box.Center;//обратный перенос }//...
private static Vector3[] GetPoint(Box box){ Vector3[] point = new Vector3[8]; //получаем координаты вершин point[0] = box.Center - box.Size/2; point[1] = point[0] + new Vector3(box.Size.x , 0, 0); point[2] = point[0] + new Vector3(0, box.Size.y, 0); point[3] = point[0] + new Vector3(0, 0, box.Size.z);//таким же образом находим оставшееся точки point[4] = box.Center + box.Size / 2; point[5] = point[4] - new Vector3(box.Size.x, 0, 0); point[6] = point[4] - new Vector3(0, box.Size.y, 0); point[7] = point[4] - new Vector3(0, 0, box.Size.z); //поворачиваем вершины кватернионом for (int i = 0; i < 8; i++) { point[i] -= box.Center;//перенос центра в начало координат point[i] = QuanRotation(point[i], box.Quaternion);//поворот point[i] += box.Center;//обратный перенос } return point;}
private static List<Vector3> GetAxis(Vector3[] a, Vector3[] b){ //ребра Vector3 A; Vector3 B; //потенциальные разделяющие оси List<Vector3> Axis = new List<Vector3>(); //нормали плоскостей первого куба A = a[1] - a[0]; B = a[2] - a[0]; Axis.Add(Vector3.Cross(A,B).normalized); A = a[2] - a[0]; B = a[3] - a[0]; Axis.Add(Vector3.Cross(A,B).normalized); A = a[1] - a[0]; B = a[3] - a[0]; Axis.Add(Vector3.Cross(A,B).normalized); //нормали второго куба A = b[1] - b[0]; B = b[2] - b[0]; Axis.Add(Vector3.Cross(A,B).normalized); A = b[1] - b[0]; B = b[3] - b[0]; Axis.Add(Vector3.Cross(A,B).normalized); A = b[2] - b[0]; B = b[3] - b[0]; Axis.Add(Vector3.Cross(A,B).normalized); //...}
private static List<Vector3> GetAxis(Vector3[] a, Vector3[] b){ //ребра Vector3 A; Vector3 B;//потенциальные разделяющие оси List<Vector3> Axis = new List<Vector3>();//нормали плоскостей первого куба for (int i = 1; i < 4; i++) { A = a[i] - a[0]; B = a[(i+1)%3+1] - a[0]; Axis.Add(Vector3.Cross(A,B).normalized); }//нормали второго куба for (int i = 1; i < 4; i++) { A = b[i] - b[0]; B = b[(i+1)%3+1] - b[0]; Axis.Add(Vector3.Cross(A,B).normalized); } //...}
private static List<Vector3> GetAxis(Vector3[] a, Vector3[] b){ //... //получение нормалей //... //Теперь добавляем все векторные произведения for (int i = 1; i < 4; i++) { A = a[i] - a[0]; for (int j = 1; j < 4; j++) { B = b[j] - b[0]; if (Vector3.Cross(A,B).magnitude != 0) { Axis.Add(Vector3.Cross(A,B).normalized); } } } return Axis;}
private static List<Vector3> GetAxis(Vector3[] a, Vector3[] b){//ребра Vector3 A; Vector3 B;//потенциальные разделяющие оси List<Vector3> Axis = new List<Vector3>();//нормали плоскостей первого куба for (int i = 1; i < 4; i++) { A = a[i] - a[0]; B = a[(i+1)%3+1] - a[0]; Axis.Add(Vector3.Cross(A,B).normalized); }//нормали второго куба for (int i = 1; i < 4; i++) { A = b[i] - b[0]; B = b[(i+1)%3+1] - b[0]; Axis.Add(Vector3.Cross(A,B).normalized); } //Теперь добавляем все векторные произведения for (int i = 1; i < 4; i++) { A = a[i] - a[0]; for (int j = 1; j < 4; j++) { B = b[j] - b[0]; if (Vector3.Cross(A,B).magnitude != 0) { Axis.Add(Vector3.Cross(A,B).normalized); } } } return Axis;}
private static float ProjVector3(Vector3 v, Vector3 a){ a = a.normalized; return Vector3.Dot(v, a) / a.magnitude; }
private static Vector3 IntersectionOfProj(Vector3[] a, Vector3[] b, List<Vector3> Axis){ for (int j = 0; j < Axis.Count; j++) { //в этом цикле проверяем каждую ось //будем определять пересечение проекций на разделяющие оси из списка кандидатов } //Если мы в цикле не нашли разделяющие оси, то кубы пересекаются, и нам нужно //определить глубину и нормаль пересечения.}
private static void ProjAxis(out float min, out float max, Vector3[] points, Vector3 Axis){ max = ProjVector3(points[0], Axis); min = ProjVector3(points[0], Axis); for (int i = 1; i < points.Length; i++) { float tmp = ProjVector3(points[i], Axis); if (tmp > max) { max = tmp; } if (tmp < min) { min= tmp; } }}
private static Vector3 IntersectionOfProj(Vector3[] a, Vector3[] b, List<Vector3> Axis){ for (int j = 0; j < Axis.Count; j++) { //проекции куба a float max_a; float min_a; ProjAxis(out min_a,out max_a,a,Axis[j]); //проекции куба b float max_b; float min_b; ProjAxis(out min_b,out max_b,b,Axis[j]); //... } //...}
float[] points = {min_a, max_a, min_b, max_b}; Array.Sort(points);
//... //Сумма отрезков float sum = (max_b - min_b) + (max_a - min_a); //Длина крайних точек float len = Math.Abs(p[3] - p[0]); if (sum <= len) { //разделяющая ось существует // объекты непересекаются return Vector3.zero; } //Предполагаем, что кубы пересекаются и рассматриваем текущую ось далее //....
private static Vector3 IntersectionOfProj(Vector3[] a, Vector3[] b, List<Vector3> Axis){ Vector3 norm = new Vector3(10000,10000,10000); for (int j = 0; j < Axis.Count; j++) { //... } //в случае, когда нашелся вектор с минимальным пересечением, возвращаем его return norm;{
//...if (sum <= len){ //разделяющая ось существует // объекты не пересекаются return new Vector3(0,0,0);}//Предполагаем, что кубы пересекаются и рассматриваем текущий вектор далее//пересечение проекций - это расстояние между 2 и 1 элементом в нашем массиве//(см. рисунок на котором изображен случай пересечения отрезков)float dl = Math.Abs(points[2] - points[1]);if (dl < norm.magnitude){ norm = Axis[j] * dl; //ориентация нормали if(points[0] != min_a) norm = -norm;}//...
private static Vector3 IntersectionOfProj(Vector3[] a, Vector3[] b, List<Vector3> Axis){ Vector3 norm = new Vector3(10000,10000,10000); for (int j = 0; j < Axis.Count; j++) { //проекции куба a float max_a; float min_a; ProjAxis(out min_a,out max_a,a,Axis[j]); //проекции куба b float max_b; float min_b; ProjAxis(out min_b,out max_b,b,Axis[j]); float[] points = {min_a, max_a, min_b, max_b}; Array.Sort(points); float sum = (max_b - min_b) + (max_a - min_a); float len = Math.Abs(points[3] - points[0]); if (sum <= len) { //разделяющая ось существует // объекты не пересекаются return new Vector3(0,0,0); } float dl = Math.Abs(points[2] - points[1]); if (dl < norm.magnitude) { norm = Axis[j] * dl; //ориентация нормы if(points[0] != min_a) norm = -norm; } } return norm;}
screenColor.r = shScreenTex.SampleLevel( shPointClampSampler, (IN.baseTC.xy - 0.5) * (1 + 2 * psParams[0].x * CV_ScreenSize.zw) + 0.5, 0.0f).r;screenColor.b = shScreenTex.SampleLevel( shPointClampSampler, (IN.baseTC.xy - 0.5) * (1 - 2 * psParams[0].x * CV_ScreenSize.zw) + 0.5, 0.0f).b;
half distanceStrength = pow(length(IN.baseTC - 0.5), falloff);half2 direction = normalize(IN.baseTC.xy - 0.5);half2 velocity = direction * blur * distanceStrength;
blur
. Blur
и falloff
это параметры, которые передаются извне и
являются просто множителями для настройки аберрации. Также извне
прокидывается параметр sampleCount
, который отвечает
не только за количество сэмплов, но и, по сути, за шаг между
точками сэмплирования, так как
half2 offsetDecrement = velocity * stepMultiplier / half(sampleCount);
sampleCount
раз
от данной точки текстуры, сдвигаясь каждый раз на
offsetDecrement
, домножать каналы на соответствующие
веса волн и делить на сумму этих весов. Что ж, пришла пора
поговорить о втором пункте нашей глобальной цели.
def get_color(waveLength): if waveLength >= 380 and waveLength < 440: red = -(waveLength - 440.0) / (440.0 - 380.0) green = 0.0 blue = 1.0 elif waveLength >= 440 and waveLength < 490: red = 0.0 green = (waveLength - 440.0) / (490.0 - 440.0) blue = 1.0 elif waveLength >= 490 and waveLength < 510: red = 0.0 green = 1.0 blue = -(waveLength - 510.0) / (510.0 - 490.0) elif waveLength >= 510 and waveLength < 580: red = (waveLength - 510.0) / (580.0 - 510.0) green = 1.0 blue = 0.0 elif waveLength >= 580 and waveLength < 645: red = 1.0 green = -(waveLength - 645.0) / (645.0 - 580.0) blue = 0.0 elif waveLength >= 645 and waveLength < 781: red = 1.0 green = 0.0 blue = 0.0 else: red = 0.0 green = 0.0 blue = 0.0 factor = 0.0 if waveLength >= 380 and waveLength < 420: factor = 0.3 + 0.7*(waveLength - 380.0) / (420.0 - 380.0) elif waveLength >= 420 and waveLength < 701: factor = 1.0 elif waveLength >= 701 and waveLength < 781: factor = 0.3 + 0.7*(780.0 - waveLength) / (780.0 - 700.0) gamma = 0.80 R = (red * factor)**gamma if red > 0 else 0 G = (green * factor)**gamma if green > 0 else 0 B = (blue * factor)**gamma if blue > 0 else 0 return R, G, B
half3 accumulator = (half3) 0;half2 offset = (half2) 0;half3 WeightSum = (half3) 0;half3 Weight = (half3) 0;half3 color;half waveLength; for (int i = 0; i < sampleCount; i++){ waveLength = lerp(startWaveLength, endWaveLength, (half)(i) / (sampleCount - 1.0)); Weight.r = GetRedWeight(waveLength); Weight.g = GetGreenWeight(waveLength); Weight.b = GetBlueWeight(waveLength); offset -= offsetDecrement; color = tex2Dlod(baseMap, half4(IN.baseTC + offset, 0, 0)).rgb; accumulator.rgb += color.rgb * Weight.rgb; WeightSum.rgb += Weight.rgb;} OUT.Color.rgb = half4(accumulator.rgb / WeightSum.rgb, 1.0);
sampleCount
,
тем меньше шаг у нас между точками сэмпла, и тем подробнее
дисперсируем свет (учитываем больше волн с различными длинами).startWaveLength
и endWaveLength
, и как
будут реализованы функции GetRed(Green,
Blue)Weight
.
wave_arange = numpy.arange(380, 780, 0.001)red_func = numpy.polynomial.polynomial.Polynomial.fit(wave_arange, red, 4)
saturate
. Для красного цвета,
например, получается функция:
half GetRedWeight(half x){ return saturate(0.8004883122689207 + 1.3673160565954385 * (-2.9000047500568042 + 0.005000012500149485 * x) - 1.244631137356407 * pow(-2.9000047500568042 + 0.005000012500149485 * x, 2) - 1.6053230172845554 * pow(-2.9000047500568042 + 0.005000012500149485*x, 3)+ 1.055933936470091 * pow(-2.9000047500568042 + 0.005000012500149485*x, 4));}
startWaveLength
и
endWaveLength
в данном случае являются 780 нм и 380
нм, соответственно. Результат на практике с
sampleCount=3
получается следующий (см. края
картинки):sampleCount
до
400, то всё становится лучше:saturate
также свести к диапазону значений [0, 1]. По
всем трём цветам мы получаем следующий результат с учётом
сатурации:
half GetRedWeight(half x){ return saturate(0.5764348105166407 + 0.4761860550080825 * (-15.571636738012254 + 0.0285718367412005 * x) - 0.06265740390367036 * pow(-15.571636738012254 + 0.0285718367412005 * x, 2));}
sampleCount=3
:
bool isNotAberrated = abs(offsetDecrement.x * g_VS_ScreenSize.x) < 1.0 && abs(offsetDecrement.y * g_VS_ScreenSize.y) < 1.0;if (isNotAberrated){ OUT.Color.rgb = tex2Dlod(baseMap, half4(IN.baseTC, 0, 0)).rgb; return OUT;}
На волне ажиотажа вокруг новых карточек от Nvidia с поддержкой RTX, я, сканируя хабр в поисках интересных статей, с удивлением обнаружил, что такая тема, как трассировка путей, здесь практически не освящена. "Так дело не пойдет" - подумал я и решил, что неплохо бы сделать что-нибудь небольшое на эту тему, да и так, чтоб другим полезно было. Тут как кстати API собственного движка нужно было протестировать, поэтому решил: запилю-ка я свой простенький path-tracer. Что же из этого вышло вы думаю уже догадались по превью к данной статье.
Трассировка пути является одним из частных случаев трассировки лучей и представляет собой наиболее точный с физической точки зрения способ рендеринга. Используя непосредственное моделирование распространения света для расчета освещения, этот алгоритм позволяет с минимальными усилиями получать весьма реалистичные сцены, для рендеринга которых традиционным способом ушло бы неимоверное количество усилий.
Как и в большинстве классических алгоритмов трассировки лучей, при трассировке путей мы испускаем лучи из позиции камеры на сцену, и смотрим, с чем каждый из них сталкивается. По сути, мы "трассируем путь" фотона, прошедшего от источника света до нашего глаза, но только делаем это в обратную сторону, начиная от позиции наблюдателя (ведь нет смысла рассчитывать свет, который в итоге не дойдет до нашего глаза).
трассировка лучей от позиции наблюдателяОдним из первых вопросов, которым можно задаться при рассмотрении данного алгоритма: а до какого вообще момента нам нужно моделировать движение луча? С точки зрения физики, практически каждый объект, что нас окружает, отражает хоть сколько-то света. Соответственно, естественный способ моделирования пути фотона - вычисление траектории движения вплоть до тех пор, пока количество отраженного света от объекта станет настолько малым, что им можно пренебречь. Такой метод безусловно имеет место быть, однако является крайне непроизводительным, и поэтому в любом трассирующем лучи алгоритме все же приходится жертвовать физической достоверностью изображения ради получения картинки за хоть сколько-то вменяемое время. Чаще всего выбор числа отражений луча зависит от сцены - для грубых диффузных поверхностей требуется гораздо меньше итераций, нежели для зеркальных или металлических (можете вспомнить классический пример с двумя зеркалами в лифте - свет в них отражается огромное число раз, создавая эффект бесконечного туннеля, и мы должны уметь это моделировать).
различные материалы, отрисованные физически-корректным рендерингомДругой вопрос, который у нас возникает - какие объекты могут быть на нашей сцене, и какими свойствами они обладают. И тут уже все не так очевидно и чаще всего зависит от конкретной реализации трассировщика. Для примера: в классическом физически-корректном рендеринге для описания объектов используют заранее заготовленный материалы с варьирующимися параметрами: албедо (диффузная отражательная способность материала), шероховатость (параметр, аппроксимирующий неровности поверхности на микро-уровне) и металличность (параметр, задающий отражательную способность материала). Иногда к ним добавляют и другие свойства, такие как прозрачность и показатель преломления.
Я же для своего алгоритма решил условно выделить для материала объекта следующие параметры:
Отражательная способность (reflectance) - какое количество и какой волны свет отражает каждый объект
Шероховатость поверхности (roughness) - насколько сильно лучи рассеиваются при столкновении с объектом
Излучение энергии (emittance) - количество и длина волны света, которую излучает объект
Прозрачность (transparency/opacity) - отношение пропущенного сквозь объект света к отраженному
В принципе, этих параметров достаточно, чтобы смоделировать источники света, зеркальные и стеклянные поверхности, а также диффузные материалы, поэтому я решил, что на данный момент лучше остановиться на них и сохранить простоту получившегося трассировщика.
К сожалению (или к счастью?) сегодня мы не будем делать профессиональный трассировщик путей, и ограничимся лишь базовым алгоритмом с возможностью трассировать на сцене параллелепипеды и сферы. Для них относительно легко находить пересечения c лучем, рассчитывать нормали, да и в целом такого набора примитивов уже достаточно, чтобы отрендерить классический cornell-box.
один из вариантов cornell box'a для тестирования корректности рендерингаОсновной алгоритм мы будем делать во фрагментном GLSL шейдере. В
принципе, даже если вы не знакомы с самим языком, код будет
достаточно понятным, так как во многом совпадает с языком С: в
нашем распоряжении есть функции и структуры, возможность писать
условные конструкции и циклы, и ко всему прочему добавляется
возможность пользоваться встроенными примитивами для математических
расчетов - vec2
, vec3
, mat3
и т.д.
Ну что же, давайте к коду! Для начала зададим наши примитивы и структуру материала:
struct Material{ vec3 emmitance; vec3 reflectance; float roughness; float opacity;};struct Box{ Material material; vec3 halfSize; mat3 rotation; vec3 position;};struct Sphere{ Material material; vec3 position; float radius;};
Для примитивов реализуем проверки пересечения с лучем: будем принимать начало луча и его направление, а возвращать расстояние до ближайшей точки пересечения и нормаль объекта в этой точке, если, конечно, такое пересечение в принципе произошло:
bool IntersectRaySphere(vec3 origin, vec3 direction, Sphere sphere, out float fraction, out vec3 normal){ vec3 L = origin - sphere.position; float a = dot(direction, direction); float b = 2.0 * dot(L, direction); float c = dot(L, L) - sphere.radius * sphere.radius; float D = b * b - 4 * a * c; if (D < 0.0) return false; float r1 = (-b - sqrt(D)) / (2.0 * a); float r2 = (-b + sqrt(D)) / (2.0 * a); if (r1 > 0.0) fraction = r1; else if (r2 > 0.0) fraction = r2; else return false; normal = normalize(direction * fraction + L); return true;}bool IntersectRayBox(vec3 origin, vec3 direction, Box box, out float fraction, out vec3 normal){ vec3 rd = box.rotation * direction; vec3 ro = box.rotation * (origin - box.position); vec3 m = vec3(1.0) / rd; vec3 s = vec3((rd.x < 0.0) ? 1.0 : -1.0, (rd.y < 0.0) ? 1.0 : -1.0, (rd.z < 0.0) ? 1.0 : -1.0); vec3 t1 = m * (-ro + s * box.halfSize); vec3 t2 = m * (-ro - s * box.halfSize); float tN = max(max(t1.x, t1.y), t1.z); float tF = min(min(t2.x, t2.y), t2.z); if (tN > tF || tF < 0.0) return false; mat3 txi = transpose(box.rotation); if (t1.x > t1.y && t1.x > t1.z) normal = txi[0] * s.x; else if (t1.y > t1.z) normal = txi[1] * s.y; else normal = txi[2] * s.z; fraction = tN; return true;}
В рамках данной статьи мы не будем вдаваться в реализацию алгоритмов пересечений - это расписано уже много-много раз. В частности, код двух функций, приведенных в блоке выше, я нагло взял с этого сайта.
Хранить все наши объекты будем в обычных массивах, благо, GLSL создавать их также позволяет. Сцена у нас небольшая, поэтому никаких оптимизаций при поиске пересечения не реализуем - просто проходим по всем объектам и вычисляем ближайшее расстояние до точки пересечения:
#define FAR_DISTANCE 1000000.0#define SPHERE_COUNT 3#define BOX_COUNT 8Sphere spheres[SPHERE_COUNT];Box boxes[BOX_COUNT];bool CastRay(vec3 rayOrigin, vec3 rayDirection, out float fraction, out vec3 normal, out Material material){ float minDistance = FAR_DISTANCE; for (int i = 0; i < SPHERE_COUNT; i++) { float D; vec3 N; if (IntersectRaySphere(rayOrigin, rayDirection, spheres[i], D, N) && D < minDistance) { minDistance = D; normal = N; material = spheres[i].material; } } for (int i = 0; i < BOX_COUNT; i++) { float D; vec3 N; if (IntersectRayBox(rayOrigin, rayDirection, boxes[i], D, N) && D < minDistance) { minDistance = D; normal = N; material = boxes[i].material; } } fraction = minDistance; return minDistance != FAR_DISTANCE;}
Функция выше позволяет нам найти точку столкновения луча с поверхностью. Это работает, но мы получаем информацию только о прямом освещении. Мы же хотим учитывать еще и непрямое освещение, поэтому давайте немного подумаем над тем, как меняется поток света при столкновениях с объектами на нашей сцене.
В нашей реализации каждый объект может излучать свет, отражать свет, и поглощать (случай с преломлением пока опустим). В таком случае формулу для расчета отраженного света от поверхности можно задать следующим образом: L' = E + f*L, где E - излучаемый объектом свет (emittance), f - отражаемый объектом свет (reflectance), L - свет, упавший на объект, и L' - то, что объект в итоге излучает.
И в итоге такое выражение легко представить в виде итеративного алгоритма:
// максимальное количество отражений луча#define MAX_DEPTH 8vec3 TracePath(vec3 rayOrigin, vec3 rayDirection){ vec3 L = vec3(0.0); // суммарное количество света vec3 F = vec3(1.0); // коэффициент отражения for (int i = 0; i < MAX_DEPTH; i++) { float fraction; vec3 normal; Material material; bool hit = CastRay(rayOrigin, rayDirection, fraction, normal, material); if (hit) { vec3 newRayOrigin = rayOrigin + fraction * rayDirection; vec3 newRayDirection = ... // рассчитываем, куда отразится луч rayDirection = newRayDirection; rayOrigin = newRayOrigin; L += F * material.emmitance; F *= material.reflectance; } else { // если столкновения не произошло - свет ничто не испускает F = vec3(0.0); } } // возвращаем суммарный вклад освещения return L;}
Если бы мы писали наш код на условном C++, можно было бы
напрямую получать L как результат работы
рекурсивно вызываемой функции CastRay
. Однако, GLSL не
разрешает рекурсивные вызовы функций в любом виде, поэтому
приходится развернуть наш алгоритм так, чтобы он работал
итеративно. С каждым отражением мы уменьшаем коэффициент, на
который умножается испускаемый или отражаемый объектом свет, и тем
самым повторяем описанную выше формулу. В моей реализации
потенциально каждый объект может излучать какое-то количество
света, поэтому emittance учитывается при каждом столкновении. Если
же луч ни с чем не сталкивается, мы считаем, что никакого света до
нас не дошло. В принципе для таких случаев можно добавить выборку
из карты окружения или задать "дневной свет", но после
экспериментов с этим я понял, что больше всего мне нравится текущая
реализация, с пустотой вокруг сцены.
Теперь давайте решим следующий вопрос: а по какому же принципу луч отражается от объекта? Очевидно, что в нашем path-tracer'е это будет зависеть от нормали в точке падения и микро-рельефа поверхности. Если обратиться к реальному миру, мы увидим, что для гладких материалов (таких как отполированный металл, стекло, вода) отражение будет очень четким, так как все лучи, падающие на объект под одним углом, будут и отражаться примерно под одинаковым углом (см. specular на картинке ниже), когда как для шероховатых, неровных поверхностей мы наблюдаем очень размытые отражения, чаще всего диффузные (см. diffuse на картинке ниже), так как лучи распространяются по полусфере относительно нормали объекта. Именно этой закономерностью мы и воспользуемся, задав итоговое направление отраженного луча как D = normalize(a * R + (1 - a) * T), где a - коэффициент шероховатости/гладкости поверхности, R - идеально отраженный луч, T - луч, отраженный в случаном направлении в полусфере относительно нормали. Очевидно, что при коэффициенте a = 1 в такой формуле мы всегда будет получать идеальное отражение луча, а при a = 0, наоборот, равномерно распределенное по полусфере. При коэффициенте шероховатости, лежащем в интервале от 0 до 1, на выходе будем иметь некоторое распределение лучей, ориентированное по углу отражения, что в вполне корректно и как раз характерно для глянцевых поверхностей (см. glossy на картинке ниже).
распределение лучей для различных типов поверхностейВернемся к коду. Давайте начнем в этот раз с самого сложного - напишем реализацию функции, которая бы возвращала нам случайный луч в полусфере относительно нормали. Для этого сначала возьмем какой-нибудь набор случайно распределенных чисел, и сгенерируем по ним луч, лежащий в на границе сферы единичного радиуса, а затем тривиальным образом спроецируем его в ту же полусферу, где находится нормаль объекта:
#define PI 3.1415926535vec3 RandomSpherePoint(vec2 rand){ float cosTheta = sqrt(1.0 - rand.x); float sinTheta = sqrt(rand.x); float phi = 2.0 * PI * rand.y; return vec3( cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta );}vec3 RandomHemispherePoint(vec2 rand, vec3 n){ vec3 v = RandomSpherePoint(rand); return dot(v, n) < 0.0 ? -v : v;}
Не забываем: сгенерированный нами луч хоть и лежит в одной полусфере с нормалью, но множество таких случайных лучей все еще не ориентировано под нужным нам углом. Чтобы это исправить, давайте спроецируем их в пространство нормали: зададим еще один случайный вектор, затем найдем третий через векторное произведение, и соединим их всех в матрицу трансформации:
vec3 hemisphereDistributedDirection = RandomHemispherePoint(Random2D(), normal);vec3 randomVec = normalize(2.0 * Random3D() - 1.0);vec3 tangent = cross(randomVec, normal);vec3 bitangent = cross(normal, tangent);mat3 transform = mat3(tangent, bitangent, normal);vec3 newRayDirection = transform * hemisphereDistributedDirection;
Небольшое примечание: здесь и далее
Random?D
генерирует случайные числа в интервале от 0
до 1. В GLSL шейдере делать это можно разными способами. Я
использую следующую функцию, генерирующую случайным шум без явных
паттернов (любезно взята со StackOverflow по первому запросу):
float RandomNoise(vec2 co){ return fract(sin(dot(co.xy, vec2(12.9898, 78.233))) * 43758.5453);}
В комбинации с такими параметрами как координатой текущего пикселя (gl_FragCoord), временем работы приложения, и еще какими-то независимыми переменными можно сгенерировать достаточно много псевдослучайных чисел. Вы также можете пойти иначе и просто передавать массив случайных чисел в шейдер, но это уже дело вкуса.
отражения с различной шероховатостьюПосле всех наших модификацийкод нашей функции
TracePath
будет выглядеть вот так:
vec3 TracePath(vec3 rayOrigin, vec3 rayDirection){ vec3 L = vec3(0.0); vec3 F = vec3(1.0); for (int i = 0; i < MAX_DEPTH; i++) { float fraction; vec3 normal; Material material; bool hit = CastRay(rayOrigin, rayDirection, fraction, normal, material); if (hit) { vec3 newRayOrigin = rayOrigin + fraction * rayDirection; vec3 hemisphereDistributedDirection = RandomHemispherePoint(Random2D(), normal); randomVec = normalize(2.0 * Random3D() - 1.0); vec3 tangent = cross(randomVec, normal); vec3 bitangent = cross(normal, tangent); mat3 transform = mat3(tangent, bitangent, normal); vec3 newRayDirection = transform * hemisphereDistributedDirection; vec3 idealReflection = reflect(rayDirection, normal); newRayDirection = normalize(mix(newRayDirection, idealReflection, material.roughness)); // добавим небольшое смещение к позиции отраженного луча // константа 0.8 тут взята произвольно // главное, чтобы луч случайно не пересекался с тем же объектом, от которого отразился newRayOrigin += normal * 0.8; rayDirection = newRayDirection; rayOrigin = newRayOrigin; L += F * material.emmitance; F *= material.reflectance; } else { F = vec3(0.0); } } return L;}
Давайте рассмотрим еще такой важный для нас эффект, как преломление света. Все же помнят, как соломка, находящаяся в стакане, кажется сломанной в том месте, где она пересекается с водой? Этот эффект происходит потому, что свет, переходя между двумя средами с разными свойствами, меняет свою волновую скорость. Вдаваться в подробности того, как это работает с физической точки зрения мы не будем, вспомним лишь, что если свет падаем под углом a, то угол преломления b можно посчитать по следующей несложной формуле (см. закон Снеллиуса): b = arcsin(sin(a) * n1 / n2), где n1 - показатель преломления среды, из которой пришел луч, a n2 - показатель преломления среды, в которую луч вошел. И к счастью для нас, показатели преломления уже рассчитаны для интересующих нас сред, достаточно лишь открыть википедию, или, накрайняк, учебник по физике.
Угол падения, отражения и преломленияСтоит заметить следующий интересный факт: sin(a) принимает значения от 0 для 1 для острых углов. Относительный показатель преломления n1 / n2 может быть любым, в том числе большим 1. Но тогда выходит, что аргумент sin(a) * n1 / n2 не всегда находится в области определения функции arcsin. Что же происходит с углом преломления? Почему наша формула не работает для такого случая, хотя с физической точки зрения ситуация вполне возможная?
Ответ кроется в том, что в реальном мире при таких условиях луч света попросту не преломится, а отразится! Данный эффект абсолютно логичен, если вспомнить, что в момент столкновения луча с границей раздела двух сред, он по сути "делится на двое", и лишь часть света преломляется, когда как остальная отражается. И с увеличением наклона под которым падает этот луч, все больше и больше света будет отражаться, пока не дойдет до того вырожденного, критического момента. Данное явление в физике носит название внутреннее отражение, а частный его случай, когда абсолютно весь свет отражается от границы раздела сред, называется полным внутренним отражением. И этот факт нужно будет учитывать в реализации нашего трассировщика путей.
эффект ФренеляСколько же света в итоге будет отражено от поверхности, а сколько пройдет сквозь нее? Чтобы ответить на этот вопрос, нам необходимо обратиться к формулам Френеля, которые как раз и используются для расчета коэфициентов отрадения и пропускания. Но не спешите ужасаться - в нашем трассировщике мы не будем расписывать эти громоздкие выражения. Давайте воспользуемся более простой аппроксимирующей формулой за авторством Кристофе Шлика - аппроксимацией Шлика. Она достаточно простая в реализации и дает приемлимые визуальные результаты, поэтому не вижу причин не добавить ее в наш код:
float FresnelSchlick(float nIn, float nOut, vec3 direction, vec3 normal){ float R0 = ((nOut - nIn) * (nOut - nIn)) / ((nOut + nIn) * (nOut + nIn)); float fresnel = R0 + (1.0 - R0) * pow((1.0 - abs(dot(direction, normal))), 5.0); return fresnel;}
Ну что же, применим весь наш пройденный материал: первым делом реализуем функцию, которая бы возвращала преломленный луч с учетом полного внутреннего отражения, о котором мы говорили выше:
vec3 IdealRefract(vec3 direction, vec3 normal, float nIn, float nOut){ // проверим, находимся ли мы внутри объекта // если да - учтем это при рассчете сред и направления луча bool fromOutside = dot(normal, direction) < 0.0; float ratio = fromOutside ? nOut / nIn : nIn / nOut; vec3 refraction, reflection; refraction = fromOutside ? refract(direction, normal, ratio) : -refract(-direction, normal, ratio); reflection = reflect(direction, normal); // в случае полного внутренного отражения refract вернет нам 0.0 return refraction == vec3(0.0) ? reflection : refraction;}
Чтобы учитывать эффект френеля, будем просто генерировать случайное число, и на его основе решать, преломится ли луч или отразится. Также не забываем, что у наших объектов есть параметр плотности, который также отвечает за отношение преломленного света к отраженному. Соединить оба этих условия можно по-разному, но я взял самый примитивный вариант:
bool IsRefracted(float rand, vec3 direction, vec3 normal, float opacity, float nIn, float nOut){ float fresnel = FresnelSchlick(nIn, nOut, direction, normal); return opacity > rand && fresnel < rand;}
Теперь наконец можно склеить все вместе: добавим в нашу функцию
TracePath
логику, которая бы рассчитывала преломление
света и при этом учитывала и шереховатость объекта - ее для
полупрозрачных тел никто не отменял:
#define N_IN 0.99#define N_OUT 1.0vec3 TracePath(vec3 rayOrigin, vec3 rayDirection){ vec3 L = vec3(0.0); vec3 F = vec3(1.0); for (int i = 0; i < MAX_DEPTH; i++) { float fraction; vec3 normal; Material material; bool hit = CastRay(rayOrigin, rayDirection, fraction, normal, material); if (hit) { vec3 newRayOrigin = rayOrigin + fraction * rayDirection; vec3 hemisphereDistributedDirection = RandomHemispherePoint(Random2D(), normal); randomVec = normalize(2.0 * Random3D() - 1.0); vec3 tangent = cross(randomVec, normal); vec3 bitangent = cross(normal, tangent); mat3 transform = mat3(tangent, bitangent, normal); vec3 newRayDirection = transform * hemisphereDistributedDirection; // проверяем, преломится ли луч. Если да, то меняем логику рассчета итогового направления bool refracted = IsRefracted(Random1D(), rayDirection, normal, material.opacity, N_IN, N_OUT); if (refracted) { vec3 idealRefraction = IdealRefract(rayDirection, normal, N_IN, N_OUT); newRayDirection = normalize(mix(-newRayDirection, idealRefraction, material.roughness)); newRayOrigin += normal * (dot(newRayDirection, normal) < 0.0 ? -0.8 : 0.8); } else { vec3 idealReflection = reflect(rayDirection, normal); newRayDirection = normalize(mix(newRayDirection, idealReflection, material.roughness)); newRayOrigin += normal * 0.8; } rayDirection = newRayDirection; rayOrigin = newRayOrigin; L += F * material.emmitance; F *= material.reflectance; } else { F = vec3(0.0); } } return L;}
Для коэффициентов преломления N_IN
и
N_OUT
я взял два очень близких числаdoo. Это не совсем
физически-корректно, однако создает желаемый эффект того, что
поверхности сделаны из стекла (как шар на первом скриншоте статьи).
Можете смело их изменить и посмотреть, как поменяется угол
преломления лучей, проходящих сквозь объект.
Дело осталось за малым: инициализировать нашу сцену в начале
шейдера, передать внутрь все параметры камеры, и запустить лучи по
направлению взгляда. Начнем с камеры: от нее нам потребуется
несколько параметров: direction
- направление взгляда
в трехмерном пространстве. up
- направление "вверх"
относительно взгляда (нужен чтобы задать матрицу перевода в мировое
пространство), а также fov
- угол обзора камеры. Также
передадим для рассчета чисто утилитарные вещи - экранную позицию
обрабатываемого пикселя (от 0 до 1 по x и y) и размер окна для
рассчета отношения сторон. В математику в коде тоже особо
углубляться не буду - о том, как переводить из пространство экрана
в пространство мира можно почитать к примеру в этой замечательной статье.
vec3 GetRayDirection(vec2 texcoord, vec2 viewportSize, float fov, vec3 direction, vec3 up){ vec2 texDiff = 0.5 * vec2(1.0 - 2.0 * texcoord.x, 2.0 * texcoord.y - 1.0); vec2 angleDiff = texDiff * vec2(viewportSize.x / viewportSize.y, 1.0) * tan(fov * 0.5); vec3 rayDirection = normalize(vec3(angleDiff, 1.0f)); vec3 right = normalize(cross(up, direction)); mat3 viewToWorld = mat3( right, up, direction ); return viewToWorld * rayDirection;}
Как бы ни прискорбно это было заявлять, но законы, по которым отражаются наши лучи имеют некоторую случайность, и одного семпла на пиксель нам будет мало. И даже 16 семплов на пиксель не достаточно. Но не расстраивайтесь! Давайте найдем компромисс: каждый кадр будем считать от 4 до 16 лучей, но при этом результаты кадров аккамулировать в одну текстуру. В итоге мы делаем не так много работы каждый кадр, можем летать по нашей сцене (хоть и испытывая на своих глазах ужасные шумы), а при статичной картинке качество рендера будет постепенно расти, пока не упрется в точность float'а. Преимущества такого подхода видны невооруженным взглядом:
рендер одного кадра и нескольких, сложенных вместеВ итоге наша функция main
будет выглядеть примерно
следующим образом (в алгоритме нет ничего сложного - просто
запускаем несколько лучей и считаем среднее от результата
TracePath
):
// ray_tracing_fragment.glslin vec2 TexCoord;out vec4 OutColor;uniform vec2 uViewportSize;uniform float uFOV;uniform vec3 uDirection;uniform vec3 uUp;uniform float uSamples;void main(){ // заполняем нашу сцену объектами InitializeScene(); vec3 direction = GetRayDirection(TexCoord, uViewportSize, uFOV, uDirection, uUp); vec3 totalColor = vec3(0.0); for (int i = 0; i < uSamples; i++) { vec3 sampleColor = TracePath(uPosition, direction); totalColor += sampleColor; } vec3 outputColor = totalColor / float(uSamples); OutColor = vec4(outputColor, 1.0);}
Давайте закроем вопрос с тем, как мы будем отображать результат работы трассировщика. Очевидно, что если мы решили накапливать кадры в одной текстуре, то классический вариант с форматом вида RGB (по байту на каждый канал) нам не подойдет. Лучше взять что-то вроде RGB32F (проще говоря формат, поддерживающий числа с плавающей точкой одинарной точности). Таким образом мы сможем накапливать достаточно большое количество кадров прежде чем упремся в потолок из-за потерь точности вычислений.
Также сходу напишем шейдер, принимающий нашу аккамулирующую текстуру и вычисляющий среднее от множества кадров. Тут же применим тональную коррекцию изображения, а затем гамма-коррекцию (в коде я использую самый простой вариант tone-mapping'а, вы можете взять что-то посложнее, к примеру кривую Рейнгарда):
// post_process_fragment.glslin vec2 TexCoord;out vec4 OutColor;uniform sampler2D uImage;uniform int uImageSamples;void main(){ vec3 color = texture(uImage, TexCoord).rgb; color /= float(uImageSamples); color = color / (color + vec3(1.0)); color = pow(color, vec3(1.0 / 2.2)); OutColor = vec4(color, 1.0);}
И на этом часть с GLSL кодом можно считать оконченной. Как будет выглядеть наш трассировщий снаружи - вопрос открытый и зависит полностью от вас. Ниже я конечно же приведу реализацию рендеринга в контексте API моего движка, на котором я и писал данный трассировщик, но информация эта скорее опциональная. Думаю вам не составит труда написать связующий код на том фреймворке или графическом API, с которым лично вам удобнее всего работать:
virtual void OnUpdate() override{ // получаем текущую камеру и текстуру, в которую осуществляется рендер auto viewport = Rendering::GetViewport(); auto output = viewport->GetRenderTexture(); // получим текущие параметры камеры (позицию, угол обзора и т.д.) auto viewportSize = Rendering::GetViewportSize(); auto cameraPosition = MxObject::GetByComponent(*viewport).Transform.GetPosition(); auto cameraRotation = Vector2{ viewport->GetHorizontalAngle(), viewport->GetVerticalAngle() }; auto cameraDirection = viewport->GetDirection(); auto cameraUpVector = viewport->GetDirectionUp(); auto cameraFOV = viewport->GetCamera<PerspectiveCamera>().GetFOV(); // проверим, что камера неподвижна. От этого зависит, нужно ли очищать предыдущий кадр bool accumulateImage = oldCameraPosition == cameraPosition && oldCameraDirection == cameraDirection && oldFOV == cameraFOV; // при движении снизим количество семплов ради приемлемой частоты кадров int raySamples = accumulateImage ? 16 : 4; // установим все униформы в шейдере, осуществляющем трассировку лучей this->rayTracingShader->SetUniformInt("uSamples", raySamples); this->rayTracingShader->SetUniformVec2("uViewportSize", viewportSize); this->rayTracingShader->SetUniformVec3("uPosition", cameraPosition); this->rayTracingShader->SetUniformVec3("uDirection", cameraDirection); this->rayTracingShader->SetUniformVec3("uUp", cameraUpVector); this->rayTracingShader->SetUniformFloat("uFOV", Radians(cameraFOV)); // меняем тип блендинга в зависимости от того, аккамулируем ли мы кадры в одну текстуру // также считаем количество кадров, чтобы потом получить среднее значение if (accumulateImage) { Rendering::GetController().GetRenderEngine().UseBlending(BlendFactor::ONE, BlendFactor::ONE); Rendering::GetController().RenderToTextureNoClear(this->accumulationTexture, this->rayTracingShader); accumulationFrames++; } else { Rendering::GetController().GetRenderEngine().UseBlending(BlendFactor::ONE, BlendFactor::ZERO); Rendering::GetController().RenderToTexture(this->accumulationTexture, this->rayTracingShader); accumulationFrames = 1; } // рассчитаем среднее от множества кадров и сохраним в рендер-текстуру камеры this->accumulationTexture->Bind(0); this->postProcessShader->SetUniformInt("uImage", this->accumulationTexture->GetBoundId()); this->postProcessShader->SetUniformInt("uImageSamples", this->accumulationFrames); Rendering::GetController().RenderToTexture(output, this->postProcessShader); // обновим сохраненные параметры камеры this->oldCameraDirection = cameraDirection; this->oldCameraPosition = cameraPosition; this->oldFOV = cameraFOV;}
Ну что же, на этом все! Всем спасибо за прочтение этой небольшой статьи. Хоть мы и не успели рассмотреть множество интересных эффектов в path-tracing'е, опустили обсуждение различных ускоряющих трассировку структур данных, не сделали денойзинг, но все равно итоговый результат получился весьма сносным. Надеюсь вам понравилось и вы нашли для себя что-то новое. Я же по традиции приведу в конце несколько скриншотов, полученных с помощью path-tracer'а:
эффект бесконечного туннеля от двух параллельных зеркалпреломление света при взгляде сквозь прозрачную сферунепрямое освещение и мягкие тениИсходный код Path-Tracer'а можно найти на GitHub: https://github.com/MomoDeve/PathTracer
Мой основной проект, над которым я сейчас работаю: игровой движок MxEngine
Крутая статья от @haqreu на смежную тему о рейтрейсинге: http://personeltest.ru/aways/habr.com/ru/post/436790
О физически-корректном рендеринге, importance-sampling'е и много о чем еще от @MrShoor: http://personeltest.ru/aways/habr.com/ru/post/326852/
У меня смешанные чувства по поводу этого поста, который я собираюсь написать. С одной стороны, это нечто очевидное и элементарное. Данные техники используются во многих блогах о графике. С другой стороны, я видел множество постов в блогах, дискуссий среди программистов и даже научных статей, которые данный вопрос игнорируют. Поэтому я считаю эту тему довольно важной, и я предложу вам некоторые идеи, так что давайте начнём.
Представьте, что вы работаете над чем-то (новая фича? крупная оптимизация, которая немного жертвует качеством? новый пайпалайн?) в течение нескольких дней. У вас есть некоторые многообещающие результаты. Арт-директору нравится. Осталась только пара мелких изменений, и вы представите всем свой результат. Вы смотрите на свои результаты ежедневно (ну, на самом деле, вы смотрите на них всё время), но вдруг вы решили попросить другого художника или программиста помочь вам оценить вашу работу, и вы начинаете обсуждать/спорить/сомневаться, должно ли оно выглядеть именно так?
Что ж, это прекрасный вопрос. Не слишком ли яркое изображение? Или может непрямое освещение недостаточно сильное? Правдоподобна ли ваша аппроксимация площадных источников света и сохраняет ли она энергию? А как насчет математики: не забыл ли я (печально известное) деление на пи? Не был ли мой монитор декалиброван моим котом, или, может, арт-директор посмотрел ранее под другим углом?
Честно говоря, я перестаю замечать, что выглядит корректно, а что - нет, после пары итераций разработки и возвращаюсь к частям концепт-арта, отзывам арт-директора или фотографиям.
Ответить на все эти вопросы практически невозможно, просто разглядывая полученную картинку. Также это крайне сложно сделать без комплексного и долгого анализа кода и математики. Именно поэтому необходимо иметь референсы/версии для сравнения.
Просто для ясности. Я не говорю об автоматизированном тестировании. Это важный вопрос. Многие компании используют его, и это действительно необходимо, но мой пост совсем не про это. Относительно просто не сломать то, что уже сделано верно (или уже принято, как сделанное верно), но весьма трудно сделать что-то правильно, когда вы не знаете, как должен выглядеть финальный результат.
Итак, что подразумевается под этой референсной версией? Я имею в виду, что вам нужно brute-force решение для задачи, над которой вы работаете. Наивная, без каких-либо аппроксимаций/оптимизаций, требующая, возможно, даже нескольких секунд вместо целевых 16/33 миллисекунд.
Годами люди, работавшие над 3D графикой в играх, не использовали референсные версии, и для этого есть прекрасное объяснение. Игры были далеки от CGI рендеринга, так что не было никаких причин для сравнения результатов. Хорошая графика в играх была результатом хитроумных хаков, трюков, оптимизаций и интересных арт решений. Таким образом, многие программисты и художники старой школы всё ещё имеют привычку просто проверить, что нечто выглядит норм, или довести это нечто до такого состояния. Хотя художественные решения всегда будут наиболее важной частью визуализации 3D, мы изучили всю мощь физически корректного шейдинга и начали использовать техники такие, как GI / AO /PBR / площадные источники света, и другие, и поэтому у нас нет пути назад, некоторые трюки должны быть переделаны в терминах физической/математической корректности. К счастью, мы можем сравнить их с эталонами.
Далее, я приведу несколько примеров, как использовать и реализовывать референсы для некоторых задач.
Вообще, именно тема площадных источников света была одной из причин для написания этого поста. Мы видели множество статей и презентаций по этому поводу, различные обсуждения сохранения энергии и формы финального отражения света, но многие ли из них имеют сравнение с эталоном? Я не говорю только о сравнении входящей энергии в Mathematica для конкретного вида источника света/BRDF. Это, конечно, важно, но я уверен, что сравнение результатов в реальном времени в вашем движке более полезно.
Только подумайте, насколько просто реализовать цикл хотя бы размера 64x64, который интегрирует площадный источник света, суммируя точечные источники. Он будет работать 10 секунд на вашей GTX Titan, но вы сможете незамедлительно сравнить вашу аппроксимацию с эталоном. Вы увидите краевые случаи, где есть расхождение с ожидаемым результатом, и сможете оценить ваше решение с помощью вашей системы источников света.
Вы даже можете провести вычисления на CPU и создать сетку из 64x64 источников света, поддерживающих тени, и проверить ошибку в (полу-) тенях у этих площадных источников света. Насколько это полезно для проверки ваших PCSS полутеней?
Это весьма важная часть, поскольку алиасинг сигнала - одна из базовых проблем компьютерной графики реального времени. Недавно было сделано много докладов об алиасинге геометрии, текстурном алиасинге, асиасинге шейдинга, проблемах с альфа-тестовой геометрией и т.д. Многие из презентаций и статей, к счастью, представляли сравнения с референсной версией, но делали ли вы сравнение в своём движке? Вы уверены, что всё правильно поняли?
Возможно, у вас есть какой-то баг в MSAA, может, ваш AA на основе изображения весьма плох в движении, или, может, ваши веса в темпоральном AA совсем неверные? Возможно, ваша реализация AA для диффуза или спекуляра не точна, или в ней есть опечатка? Может быть, вертексный или пиксельный шейдер, сделанный художником, добавляет некоторый процедурный алиасинг? А может, у вас есть алиасинг шейдинга, связанный с нормалями геометрии (распространённые техники, такие как Toksvig, работают только в пространстве карты нормалей)? Возможно, ваш алгоритм карты теней добавляет некоторое мерцание/темпоральную нестабильность?
Есть куча других потенциальных проблем с алиасингом, которые возникают по разным причинам (мы же всё время пытаемся просемплировать данные из источника информации, частота которого сильно выше частоты Найквиста), но мы должны знать, что является источником проблемы в каждом конкретном случае.
Очевидно, что в данном случае поможет создание правильного референсного изображения в большем разрешении и его ресемплинг. Я бы порекомендовал здесь два возможных решения:
Честный суперсемплинг. Этот вариант определенно наиболее прост для реализации и близок к эталону, но обычно ограничения памяти делают этот метод слишком дорогим для высоких параметров суперсемплинга, так что сильно этот вариант не поможет
In-place суперсемплинг. Олдскульная техника, которая базируется либо на сшивании изображений/тайлов (тайловые скриншоты в Unreal Engine), либо на субпиксельных сдвигах (в игре Ведьмак 2 для скриншотов используется этот суперсемплинг, и кадр отрисовывается 256 раз!)
У меня довольно большой опыт со вторым методом (поскольку он хорошо работает с эффектами пост-процесса, которые требуют размытия, например, bloom), чтобы сделать всё правильно, надо не забывать маленький и простой трюк: добавьте отрицательное смещение mip-уровней (~log2 от уровня суперсемплинга по одной оси) и смещение для LODов геометрии. Факт, который я нахожу забавным: мы сделали этот вариант рендеринга в Ведьмак 2 в качестве настройки графики для игроков в будущем (мы по-настоящему гордились финальной графикой в игре и думали, что будет замечательно, если она будет выглядеть прекрасно и через 10 лет, верно?), но большая часть ПК энтузиастов возненавидели нас за это! Они выставляли все опции на максимум, чтобы протестировать свои ПК за 3-5k$ (и оправдать расходы), но эта опция внезапно (даже не смотря на предупреждение в меню) уменьшала производительность GPU, например, в 4 раза.
Вероятно, наиболее спорная тема из-за сложности реализации. Я не буду здесь обсуждать все потенциальные проблемы, но разработка референсной реализации GI (глобального освещения) может занять недели, а отрисовка займёт секунды/минуты. Ваши материалы могут выглядеть иначе. CPU/GPU решения требуют совершенно разных реализаций.
Но я всё же думаю, что это весьма важно, поскольку у меня было несколько бесконечных дискуссий вроде достаточно ли у нас отражений света здесь?, тут слишком ярко/слишком темно и т.д. и если честно, я никогда не был уверен в ответе
Дела обстоят проще для тех, кто использует Maya/другой 3D редактор в качестве игрового движка, но, вероятно, это проблематично для всех остальных. Тем не менее, вы можете работать над этим шаг за шагом - сделать простой BVH/kd-Tree и вычислять AO с помощью трассировки лучей, это должно быть довольно просто для написания (максимум пара дней). Это поможет вам оценить ваш SSAO и алгоритмы AO большего масштаба. В будущем вы можете расширить своё решение для вычисления GI с несколькими отражениями света. С PBR и играми нового поколения, я думаю, это будет решающим фактором с позиции реального ускорения вашего R&D отдела и финального продакшена, поскольку художники, привыкшие работать с CGI/фильмами, будут получать те же правильные результаты прямо в игровом движке.
Прекрасный пример представил Brian Karis на последнем курсе Physically Based Shading на конференции Siggraph 2013 по теме ДФОС окружения. Интегрируя по полной полусфере значения ДФОС для входящего излучения от вашей карты окружения, вы можете увидеть, как оно действительно должно выглядеть. Я рекомендую делать это без какой-либо выборки по значимости для начала, потому что вы можете допустить ошибку или ввести погрешность/смещение таким методом!
Имея такую референсную версию, намного проще проверить вашу аппроксимацию: вы немедленно увидите краевые случаи и потенциальные отклонения данной аппроксимации. С таким инструментом в вашем движке вы проверите, правильно ли выбирается mip уровень, или что вы забыли умножить/разделить на какой-то коэффициент. Вы увидите, как много вы теряете, игнорируя анизотропную долю или разделяя компоненты интеграла. Сделайте это, это не займет больше нескольких часов со всем необходимым тестированием!
Пара мыслей о том, как всё это реализовывать: я думаю, что это большая проблема, где вы хотите разместить ваше решение на линии между двумя крайними точками:
Удобство реализации
Удобство сравнения
С одной стороны, если разработка референсной версии занимает слишком много времени, вы не будете этим заниматься. Даже самое неудобное в использовании решение лучше, чем никакого решения. Если вы будете напуганы (или не получите разрешения от своего менеджера) реализацией референсной версии, потому что это занимает слишком много времени, вы не получите каких-либо преимуществ.
С другой стороны, если переключение между версиями занимает слишком много времени, вам нужно ждать секунды, прежде чем вы увидите результаты, или вам даже нужно вручную перекомпилировать шейдеры, или сравнить версии в фотошопе; достоинства от референсной версии будут меньше и, может быть, вообще нет смысла её использовать.
Каждый случай отличается от других : возможно написание референсного интегратора ДФОС займёт минуты, а референсы глобального освещения (скриншоты/смена режима в реальном времени) могут занять недели для завершения. По этой причине, я только могу дать вам совет подходить к этому разумно.
Ещё одна вещь для размышления - наличие в движке или редакторе поддержки/фреймворка, который делает использование сравнения различных пассов проще. Только взгляните на такое приложение для фотографий, как прекрасный Adobe Lightroom. Там есть как слайдер, чтобы разделить изображения с разными режимами, так и возможность расположить сравниваемые изображения на разных мониторах.
Также всегда доступна кнопка предпросмотр. Она может быть полезна в других местах: представьте, как наличие такой кнопки для освещения/эффектов пост-процесса сделает проще жизнь ваших художников по освещению! Один клик для сравнения с тем, что он сделал 10 минут назад - прекрасная помощь для ответа на классический вопрос двигаюсь ли я в правильном направлении?. Наличие такого инструмента в вашем цикле разработки, вероятно, не то что нужно сделать немедленно; вам понадобится помощь программистов, занимающихся инструментами, но я думаю, что это окупится довольно быстро.
Наличие референсных версий поможет вам в ваших разработках и оптимизациях. Эталонная версия - объективная референсная точка в отличие от суждений людей, которые могут быть смещены, субъективны или зависят от эмоциональных/не технических факторов (посмотрите список когнитивных искажений в психологии! Удивительная проблема, которую нужно учитывать, работая не только с другими людьми, но и в одиночку). Реализация референсной версии может занять различное время (от минут, до недель) и, возможно, иногда нужно сделать слишком много работы, так что к этому вопросу нужно подходить с умом (особенно если вы работаете на производстве, не в академической среде), но просто не забывать об этом поможет вам решить некоторые проблемы и объяснить их другим людям (художникам, другим программистам).
В предыдущей заметке мы рассказали о том, как мы решали задачу из области промышленной дефектоскопии методами современного машинного зрения. В частности, мы упомянули, что одним из подходов к обогащению данных обучающей выборки является генератор синтетических данных. В этой заметке мы расскажем:
Заметка от партнера IT-центра МАИ и организатора магистерской программы VR/AR & AI компании PHYGITALISM.
Напомним, что генератор фотореалистичных 3D моделей промышленных труб и их дефектов нужен был для того, чтобы обогатить набор обучающих данных для нейросетевых алгоритмов детекции дефектов на изображениях (дефекты труб на ТЭЦ, снятые при помощи беспилотников). Беспилотники и автоматическая детекция дефектов на практике должна минимизировать время и расходы на проведение сервисного обслуживания станции.
Использование синтетических данных потенциально может улучшить качество работы нейронных сетей и различных алгоритмов машинного обучения (больше данных для обучения лучше качество). Однако, недостаточно добиться от генератора только количественного выигрыша по данным, немаловажно получить их в хорошем качестве. В случае, если распределение данных генератора будет отличаться от реальных, использование смешанного датасета приведет к ухудшению качества работы алгоритма.
Если данные сгенерированы корректно, то генератор позволяет:
Поскольку генератор позволяет получать 3D объекты, он способен стать источником новых данных не только для алгоритмов классического компьютерного зрения (CV), но и для целого ряда задач геометрического глубокого обучения (3D ML, GDL).
Применение 3D ML подходов может дать преимущество при решении задач дефектоскопии, так как пространственные сканеры / камеры глубины (RGB-D, Lidar и пр.) позволяют находить менее очевидные человеческому глазу дефекты и реконструировать изучаемые объекты (например, вздутие трубы не всегда можно обнаружить, не потрогав трубу руками или чувствительным щупом).
Рис.1 Примеры данных, сгенерированных в проекте. Слева направо:
меш труб, отрендеренные изображения с текстурами, битовые маски
дефектов (авторазметка), ограничивающие прямоугольники дефектов
(авторазметка).
Вся работа по созданию искусственного набора данных была осуществлена в Blender, с использованием скриптов на языке Python. Исключение составила лишь программа преобразования растровой разметки в формат Yolo, написанная на языке Rust.
В нашем проекте Python генерировал змеевики, а Rust получал разметку ржавчины.
Рис.2 Рабочее окно Blender с плагином генератора труб.
На начальных этапах работы тестировалось построение сцены (и трубы и сами дефекты) средствами полигонального моделирования. При таком подходе к генерации данных встает вопрос о том, как внести разнообразия в процесс генерации и о том, как удобнее создавать разметку, ведь отмечать поврежденные участки в меше группами вершин не самый удобный способ. Из этих соображений было принято решение объединить создание дефектов и разметки с помощью шейдеров.
Рис. 3 Пример сцены, из которой рендерились наборы изображений
с разметкой поперечных трещин.
Задача создания генератора синтетических данных была разделена на следующие этапы:
Рис.4 Камера с осветителем на сцене в Blender.
Объект с самосветящимся материалом, имитирующий кольцевой осветитель, и всенаправленная лампа назначены дочерними объектами камеры. Размер сенсора, угол обзора, относительное отверстие объектива и разрешение получаемого изображения настроены в соответствии с реальными характеристиками камеры DJI Mavic 2 Zoom.
Рис.5 Сцена, наполненная змеевиками труб, полученными с помощью
скрипта из этого раздела.
В первую очередь нужно отметить, что вся геометрия труб строилась по NURBs без прямой конвертации в полигональные модели (Про использование NURBS в 3D моделировании на хабре писали в этой заметке). Все дефекты, в том числе, геометрические создавались посредством материалов на этапе рендера.
Не было никакой необходимости выстраивать совершенно новую сцену в отношении геометрии для каждого кадра, достаточно было сделать несколько заготовок и менять ракурсы, освещение и материалы. Выяснилось, что наиболее распространённая схема размещения труб простой массив. Создать массив параллельных труб не представляло никакой сложности, для этого есть модификатор Array. Другой распространенной схемой оказались змеевики, в том числе облегающие цилиндрические поверхности. Для быстрого создания таких труб был написан скрипт на языке Python, в котором можно настроить радиус цилиндрической поверхности, шаг змеевика, его направление и количество повторений.
import bpyimport timefrom math import sin, cos, pi, radians# Генерация змеевиков в плоскости внутри прямоугольника со сторонами# sizeX, sizeYdef create_flat_curve(sizeX, sizeY): points = [] for y in range(sizeY): for x in range(sizeX): if y % 2 == 0: point = [x, y] else: point = [sizeX - x - 1, y] points.append(point) curve_name = "Pipe_Flat_" + str(time.time_ns()) curveData = bpy.data.curves.new(curve_name, type='CURVE') curveData.dimensions = '3D' curveData.resolution_u = 2 polyline = curveData.splines.new('NURBS') polyline.points.add(len(points)-1) for i, point in enumerate(points): x,y = point polyline.points[i].co = (x, y, 0, 1) curveData.bevel_depth = 0.4 #polyline.use_endpoint_u = True curveOB = bpy.data.objects.new(curve_name, curveData) bpy.context.collection.objects.link(curveOB)# Генерация змеевиков в пространствеdef create_cyl_curve(radius, angle, height, density, horizontal): phi = radians(angle) steps = int(density * phi / (pi*2)) print("Steps:", steps) points = [] if horizontal: for z in range(height): for step in range(steps): if z % 2 == 0: x = radius * cos(step * phi / steps) y = radius * sin(step * phi / steps) else: x = radius * cos(phi - (step+1) * phi / steps) y = radius * sin(phi - (step+1) * phi / steps) point = [x, y, z] points.append(point) if not horizontal: for step in range(steps): for z in range(height): x = radius * cos(step * phi / steps) y = radius * sin(step * phi / steps) if step % 2 == 0: point = [x, y, height-z-1] else: point = [x, y, z] points.append(point) print("Points:", len(points)) curve_name = "Pipe_Cylinder_" + str(time.time_ns()) curveData = bpy.data.curves.new(curve_name, type='CURVE') curveData.dimensions = '3D' curveData.resolution_u = 2 polyline = curveData.splines.new('NURBS') polyline.points.add(len(points)-1) for i, point in enumerate(points): x,y,z = point polyline.points[i].co = (x, y, z, 1) curveData.bevel_depth = 0.3 #polyline.use_endpoint_u = True curveOB = bpy.data.objects.new(curve_name, curveData) bpy.context.collection.objects.link(curveOB)
От использования готовых наборов текстур из изображений пришлось отказаться по нескольким причинам:
Запекание сгенерированных текстур, например из Substance Painter, было исключено чтобы не множить инструменты и сущности (такая вот бритва Оккама у нас вышла).
Рис.6 Группа нод (назовем ее супернодой) для настройки
материалов, объединенные в одну большую ноду.
Все ноды базовых материалов были объединены в группу с выведенными в интерфейс основными параметрами. В зависимости от конкретной сцены к тем или иным параметрам материала подключались генераторы псевдо-случайных чисел, собранные из ноды белого шума, произвольного индекса объекта, анимированного значения и математических нод.
Здесь и далее для конструирования шейдеров используется интерфейс с нодами, поскольку разработчик данного решения CG художник, и этот подход был для него предпочтительным =)
Рис. 7 Содержание суперноды из рис.6: материалы внутри группы
собирались преимущественно из шумов и градиентов.
Рис.8 Пример сгенерированных труб с дефектами коррозии (слева)
и цветами побежалости (справа).
Такие дефекты, как коррозия и цвета побежалости, создавались непосредственно из шума Перлина, градиентов и смещения (Displacement) геометрии модели по нормали к поверхности (само смещение производилась в шейдере, при этом геометрия неподвижна).
Рис.9 Создание дефекта Разрыв трубы в Blender.
В основе каждой трещины лежит процедурная текстура сферический градиент (трещины имеют форму эллипса, подверженного многочисленным деформациям через изменение его UV координат). Границы трещин подвержены, как и в случае с коррозией смещениями по нормали. Повреждённая часть визуализируется шейдером прозрачности, поэтому в зависимости от освещения через трещины иногда можно разглядеть тыльную поверхность трубы.
Рис.10 Создание дефекта Выход трубы из ряда в Blender.
Для таких дефектов, как выход трубы из ряда и разрыв, использовалось векторное смещение по выбранной оси в системе координат объекта. Создание такого чисто геометрического результата средствами шейдеров обусловлено удобством вывода данных для разметки как значения материала (примеры таких разметок смотри во второй части заметки).
Для получения набора разнообразных изображений из одной сцены мы анимировали позицию и поворот камеры, яркость источников света и параметры материалов в одном ключе с использованием анимационного модификатора Noise с заданными пороговыми значениями. Таким образом, можно было не беспокоиться о количестве кадров последующего рендера, ведь сколько бы их не оказалось, каждый был уникальным безо всяких закономерностей.
Рис.11 Применение шума на анимационных кривых позиции, поворота
камеры, интенсивности и позиции источника света для процедурной
съемки сцены.
Для вывода черно-белых масок разметки дефектов использовался канал Arbitrary Output Value (AOV), в ноду которого подавался коэффициент смешивания базового материала и материала дефекта. Иногда использовалась бинарная математическая нода Greater Than (на выходе 0, если входное значение меньше порогового, иначе 1).
В композиторе было настроено две выводящих ноды: одна сохраняла изображение, вторая маску. Сцены рендерились как анимированные, то есть на каждый кадр в заданном диапазоне сохранялось два файла. Изображения отправлялись в директорию с данными согласно соглашениям разметки Yolo, одноименные маски сохранялись во временной директории для последующего преобразования в разметку.
Рис.12 Директории с сохраненными изображениями (слева) и
разметкой (справа).
Формат разметки YOLO предполагает обозначение участков изображения ограничивающими прямоугольниками. Текстовый файл должен содержать нормированные координаты центров ограничивающих прямоугольников и их габариты. Для получения такого вида разметки была написана программа, рекурсивно проходящая по соседним пикселям маски, значения которых отличны от нуля, и сохраняющая минимальные и максимальные координаты связанных пикселей, после чего абсолютные координаты вершин прямоугольников нормализовались. Выбор языка Rust для написания этой программы был обусловлен скоростью выполнения и возможностью с лёгкостью реализовать одновременную обработку нескольких изображений на разных потоках процессора. Ниже приведен код на Python для поиска группы пикселей изображения, относящейся к одному дефекту.
Рис.13 Сгенерированные трубы с дефектом трещины (слева) и
соответствующая маска для данного изображения (справа).
import bpyimport colorsysimage = bpy.data.images["two_cubes.png"]sizeX = 64sizeY = 64image.scale(sizeX, sizeY)pixels = image.pixelssize = [sizeX, sizeY]print(len(pixels))grid = [[ [0] for y in range(size[1])] for x in range(size[0])] print("LEN:", len(grid))print(len(pixels)/4, " == ", sizeX*sizeY)def rgb_to_hex(rgb): hex_string = "" for c in rgb: hex_string += str(hex(max(min(int(c * 255 + 0.5), 255), 0)))[2::] return hex_string.upper()def search_neighbours(grid, x, y, color, l,b,r,t): grid[x][y] = 0 #print("FROM", x, y) if x < l: l = x if x > r: r = x if y < b: b = y if y > t: t = y if x < size[0]-1: if grid[x+1][y] == color: #print("RIGHT") l,b,r,t = search_neighbours(grid, x+1, y, color, l, b, r, t) if y < size[1]: if grid[x][y+1] == color: #print("UP") l,b,r,t = search_neighbours(grid, x, y+1, color, l, b, r, t) if x > 0: if grid[x-1][y] == color: #print("LEFT") l,b,r,t = search_neighbours(grid, x-1, y, color, l, b, r, t) if y > 0: if grid[x][y-1] == color: #print("DOWN") l,b,r,t = search_neighbours(grid, x, y-1, color, l, b, r, t) return (l,b,r,t) for i in range(0, int(len(pixels) / 4)): if pixels[i*4] > 0:#sum(pixels[i*4:i*4+3]) > 0: x = (i) % size[1] y = int(i / size[1]) #color = pixels[i*4:i*4+2] #hex_col = rgb_to_hex(pixels[i*4:i*4+3]) grid[x][y] = 1 #print(x,y)print("GRID FINISHED") color = 1islands = []for y in range(size[1]): for x in range(size[0]): if grid[x][y] == color: "LOOKING FOR NEW ISLAND" print(search_neighbours(grid, x, y, color, x, y, x, y))
Рис.14 Исходная тестовая сцена в Blender.
В этой части мы постараемся больше показывать, нежели рассказывать. На основе одной тестовой сцены в Blender (рис. 14) покажем, какие возможно сгенерировать полезные для задач компьютерного зрения изображения из трехмерной сцены.
Итоговое изображение в Blender получается как сумма различных пассов (проход лучей в сцене до момента попадания в пиксель итогового изображения): то есть для каждого пикселя мы складываем его интенсивность из нескольких компонентов (недавно вышло вот такое хорошее образовательное видео, которое может помочь разобраться в азах компьютерной графики тем, кто только начинает познавать эту науку).
Рис. 15 Combined Pass: итоговый рендер сцены с учетом всех
компонент.
Рис.16 Depth Pass: карта глубины для данной сцены.
Канал глубины позволяет передать информацию о позициях пикселей в пространстве через их удалённость от камеры. Используя пару изображений Combined+Depth можно тренировать нейросети, восстанавливающие трёхмерные сцены из изображений.
Рис. 17 Normal Pass: карта нормалей сцены.
Канал нормалей снабжает изображение информацией о нормалях поверхностей, что дает возможность не только менять освещение при постобработке, но и понимать форму объектов (хотя информации об их расположении в пространстве относительно друг друга отсутствует).
Пассы различных типов лучей рендер-движка Cycles могут предоставить разметку для разных типов материалов, что тоже может быть полезным при анализе изображений.
Рис. 18 Diffuse Color Pass: рассеивающая составляющая
материалов (например высокое значение у зеркальных металлических
поверхностей и низкое значение у шероховатых
диэлектриков).
Рис. 19 Glossy Color Pass: отражающая способность материала
(блики).
Рис. 20 Emission Pass: самосвечение материалов.
Рис. 21 Ambient Occlusion Pass: суммарная интенсивность света в
каждой точке.
Рис. 22 Shadow Pass: тени (для каждой точки пространства
просчитываются относительно источников света на сцене).
Одной из распространенных задач в генерации синтетических данных является создание фото-реалистичных изображений, сопровождающихся разметкой определенных объектов или их частей. Blender предлагает несколько способов создания масок, которые могут быть использованы в качестве разметки.
Маски можно создавать на индивидуальные объекты и их группы, материалы, а также на произвольные параметры материалов.
Рис. 23 Cryptomatte Object Pass: разметка различных объектов
случайным цветом.
Рис. 24 Cryptomatte Material Pass: разметка различных
материалов случайными цветами.
В пассах Cryptomatte всем объектам и материалам присваиваются уникальные цвета.
Допустим, мы хотим создать две маски: на одной будут отмечены все обезьянки, на другой геометрические примитивы. Всем объектам нужно назначить Object ID (он же Pass Index), для обезьянок это будет 1, для примитивов 2, 0 останется для пола. Для удобства объекты разных классов можно распределить по коллекциям и написать скрипт, который присваивает всем объектам коллекции свой Object ID.
Чтобы получить необходимую маску, нужно использовать ноду ID
Mask в композиторе.
Также в композиторе можно настроить одновременный вывод пассов и
масок в отдельные файлы (см. рисунок ниже).
Рис. 25 monkeys mask: маскирование объектов класса
обезьяна.
Рис. 26 primitives mask: маскирование объектов класса
геометрические примитивы.
Если мы хотим отметить каждый интересующий нас объект по отдельности, им нужно присвоить свои уникальные Object ID.
Blender позволяет выводить в изображение любые параметры материалов. Для этого в настройках пассов нужно создать слой AOV, в который будут сохраняться значения из шейдеров в виде RGB или числа с плавающей точкой. В соответствующем материале нужно создать ноду Output/AOV и присвоить ей имя слоя.
Разберём этот материал:
Рис. 27 Combined Pass, умноженный на маску материала.
Здесь текстура шума подана на параметр Scale шейдера подповерхностного рассеивания (см. рис. выше). Допустим, мы хотим получить маску на те области поверхности, в которых параметр Scale больше 1.8.
В результате получим маску:
Проделаем теперь подобное с другим материалом и выделим красные
области, подав в AOV фактор смешивания синего и красного
цветов:
AOV даёт более широкие возможности для разметки, этот подход
можно использовать для обозначения областей объектов, подверженных
смещению (Displacement). На объектах на изображении ниже
использовалось смещение по нормали поверхности для имитации
повреждений:
Нужно отметить, что на этих объектах разные материалы, но для каждого из них включен вывод значения смещения в один канал AOV, значения при этом складываются.
Отдельным примером может служить использование AOV для разметки повреждённых областей объектов, на которых основной материал заменяется на прозрачный. На этой обезьянке применено трёхмерное смещение (Vector Displacement), то есть каждый участок, подверженный такому эффекту смещается не по нормали к исходной поверхности, а по трём осям согласно значениям из цвета, подаваемого на вход (R,G и B соответствуют X, Y и Z).
Если же мы хотим обозначить в разметке только поврежденные участки видимой поверхности, иными словами края, нужно выбрать области разметки, значение в которых не превышает пороговое.
Используя драйверы, можно передавать в шейдер параметры любых других объектов и, как следствие, создавать разметку не только для видимых элементов и настроек материалов, но и для чего угодно в сцене, например для скорости одного объекта относительно другого или имитировать карты температур.
Blender обладает богатым набором инструментов для создания изображений из трёхмерных сцен, которые также можно использовать для генерации и визуализации многомерных данных. Каналы и пассы позволяют создавать маски для участков изображения, представляющих интерес для разметки. Выгодной особенностью Blender также является возможность расширения его функционала за счет скриптов на языке Python.
В будущем мы постараемся рассказать и про другие наши эксперименты связанные с 3D ML вобще и с Blender в частности, а пока можете подписаться на наш канал в Telegram 3D ML, где мы рассказываем несколько раз в неделю о новостях и достижениях в этой науке)