Наверняка вы замечали, как тяжел в обращении стандартный рендер трехмерных сцен в системе компьютерной алгебры Wolfram Mathematica. Однако, производительность Graphics3D[]
— это одна из проблем, вторая, и куда более важная, может возникнуть при желании поделиться материалом с другими людьми.
Записывать анимации — значительно портит потенциальную интерактивность моделей. Использование CDF Player также сомнительно, особенно в web-приложениях, а Wolfram Cloud обязывает встраивать весь блокнот целиком и вызывает сомнения по производительности.
С другой стороны по встраиваемости и интерактивности оболочка Jupyter уже давно дышит в спину: open-source, markdown, latex (сразу из коробки), даже некоторые аналоги Manipulate[]
. Однако лично для автора поддержка ввода выражений в наиболее «натуральном» виде x = \Big\{ a,b,c \Big\}/(h!) и полноценная реализация символьных вычислений делают этого закрытого и глючного гиганта за 125$
Святым Граалем для научной работы.
Да что уж там, сравнительно неплохо сейчас уже выглядит Mathics, который пишется на Python36 и старается полностью копировать функционал Mathematica. Отличные ребята.
Итак, как оказалось, я был вовсе не один, а существует даже обсуждение такой задачи на StackOverflow — как превратить Graphics3D
в нечто, что будет отрисовывать небезызвестная библиотека Three.js с хорошим аппаратным ускорением.
Готовые решения редко удается найти, что ж. Да будет парсер!
Примитивный парсер на JS
Любой код, да и все, что вы видите на экране, включая графику, в блокноте WM можно представить (у меня есть подозрения, что он в таком виде и эволюционирует) с абсолютной точностью в виде JSON древа
Структура достаточно очевидна, каждый её элемент можно интерпретировать как функцию с названием и некоторым числом аргументов. К примеру, аргументы Sphere[]
по порядку
- Координаты центра — функция «List» с тремя числовыми аргументами (x,y,z).
- Радиус — число.
Вообще говоря, функция «List» — это больше, чем список элементов в языке Wolfram. Из них состоят тензоры, а также она позволяет ограничивать область видимости переменных. К примеру код, устанавливающий локальную прозрачность для некоторых фигур
Таким образом, можно изолировать действие и других модификаторов, таких как цвет, толщина и д. р.
Парадигма в разработке интерпретатора
Мне нравится JS в смысле прототипирования. За пару минут можно набросать следующую конструкцию
var interpretate = function (d, parent=undefined, params=undefined, mesh=undefined) {
if (typeof d !== 'object') return d;
var func = {
name: d[0],
args: d.slice(1,d.length),
parent: parent
};
switch(func.name) {
case 'List':
var copy = Object.assign({}, params);
var mess = [];
func.args.forEach(function(el) {
mess.push(interpretate(el, func, copy, mesh));
});
return mess;
//...
default:
throw "Undefined function : "+func.name;
}
return(undefined);
}
Очевидно, что проходить по древу JSON нужно будет рекурсивно, переиспользуя одни и те же методы. С собой нужно будет «захватить» текущие параметры params
(прозрачность, цвета), ссылку на родителя parent
(для некоторых особенно сложных функций) и сетку mesh
, которая как раз и хранит всю геометрию. Проход древа идет от корня к листьям.
Видно из первых строчек, что каждая запись интерпретируется как функция, а затем, примитивнейший автомат switch case
раздаёт ее аргументы на обработку. Очевидно, что важнейшая функция — это, как ни странно List, выполняющая несколько задач
- полностью клонировать текущие параметры окружения
Object.assign()
для того, чтобы последующие их модификации (прозрачность или цвет) в ветках были изолированы от лежащих выше вызова List по иерархии; - пройтись интерпретатором по каждому аргументу (элементу списка) и записать результаты в массив;
- вернуть получившийся массив.
Последнее обстоятельство очень удобно, хотя кажется не таким необходимым. Ведь все равно вся геометрия лежит в mesh, которая глобальная и может «наполняться» внутри каждой ветки? Однако это позволяет достаточно естественно обрабатывать аргументы у примитивов
DrawSphere(interpretate(args[0]), interpretate(args[1]))
В таком псевдокоде все выглядит понятно, первый аргумент может быть «Листом» трех чисел, а второй просто числом. Когда результат — это число, то такие вещи нужно отлавливать отдельно.
Другой пример — это как раз модификаторы окружения (params
)
case 'RGBColor':
if (func.args.length !== 3) throw "RGB values should be triple!"
var r = Math.round(255*interpretate(func.args[0]));
var g = Math.round(255*interpretate(func.args[1]));
var b = Math.round(255*interpretate(func.args[2]));
params.color = new THREE.Color("rgb("+r+","+g+","+b+")");
break;
case 'Opacity':
var o = interpretate(func.args[0]);
if (typeof o !== 'number') throw "Opacity must have number value!";
console.log(o);
params.opacity = o;
break;
Графические примитивы
Важно понимать, что Three.js во многом, как мне кажется, унаследовала подходы в программировании графики из GL. Любой объект — это набор вершин, которые объединены в грани, а последние в свою очередь как-то затеняются и освещаются. Да, и отрисовывается все проверенными временем треугольниками.
Все, что потребуется для материализации на экране любого трехмерного объекта по иерархии
- сетка (
THREE.Mesh
)- геометрия объекта (
THREE.SphereGeometry
и т.п.), где лежат все треугольники - материал объекта (
THREE.MeshLambertMaterial
или модель Фонга), куда в том числе записан цвет и прозрачность
- геометрия объекта (
Геометрические преобразования обычно проводятся уже над готовой сеткой. К примеру, так выглядит функция Sphere в моей интерпретации
case 'Sphere':
var radius = 1;
if (func.args.length > 1) radius = func.args[1];
var material = new THREE.MeshLambertMaterial({
color:params.color,
transparent:false,
opacity:params.opacity,
});
function addSphere(cr) {
var origin = new THREE.Vector4(...cr, 1);
var geometry = new THREE.SphereGeometry( radius, 20, 20 );
var sphere = new THREE.Mesh( geometry, material );
sphere.position.x = origin.x;
sphere.position.y = origin.y;
sphere.position.z = origin.z;
mesh.add( sphere );
geometry.dispose();
}
var list = interpretate(func.args[0]);
if (list.length == 1) list=list[0];
if (list.length == 1) list=list[0];
if (list.length == 3) {
addSphere(list);
} else if (list.length > 3) {
list.forEach(function(el) {
addSphere(el);
});
} else {
console.log(list);
throw "List of coords. for sphere object is less 1";
}
material.dispose();
break;
Как можно заметить, как и в GL здесь повсеместно используются четырехмерные векторы и матрицы. Зачем?
Как известно, любой линейный оператор G характеризуется
- G[X+Y] = G[X] + G[Y]
- G[\alpha X] = \alpha G[X]
где \alpha — это число, хоть комплексное, а X, Y — в общем смысле любые объекты (тензоры, матрицы, векторы, жёны Адама, пылинки), удовлетворяющие аксиомам векторного пространства.
Вот простейший пример
G(X) = \alpha X
Производная, интеграл при обобщении на векторного пространство также будут линейными операциями. В трехмерной графике — это обычно G — матрицы некого геометрического преобразования, а X, Y — наборы координат вершин.
Какие преобразования можно производить над вершинами объектов, представленными в виде набора из трех чисел — векторами трехмерного пространства?
\begin{bmatrix} cos(\alpha) & -sin(\alpha) & 0 \\ sin(\alpha) & cos(\alpha) & 0 \\ 0 & 0 & 1 \end{bmatrix} \cdot \begin{bmatrix} x \\ y \\z \end{bmatrix} = \begin{bmatrix} x' \\ y' \\ z' \end{bmatrix}, \quad \begin{bmatrix} s_x & 0 & 0 \\ 0 & s_y & 0 \\ 0 & 0 & s_z \end{bmatrix} \cdot \begin{bmatrix} x \\ y \\z \end{bmatrix} = \begin{bmatrix} x' \\ y' \\ z' \end{bmatrix}, \quad \begin{bmatrix} 1 & 0 & 0 \\ tan(\alpha) & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix} \cdot \begin{bmatrix} x \\ y \\z \end{bmatrix} = \begin{bmatrix} x' \\ y' \\ z' \end{bmatrix}
- вращать вокруг округ осей
- масштабировать
- скашивать
А как насчет такой операции?
G(x) = \alpha x + y
В школе ее также называли линейной, однако это неверно. Подобные операторы уже не удовлетворяют обоим свойствам линейности и называются аффинными операторами.
Это невозможно воспроизвести с помощью перемножения матрицы на трехмерный вектор. А ведь это ограничивает нас в очень важной операции преобразования — трансляции. Представьте, как было бы красиво, если бы все базовые операции сводились к умножению матриц
Ко всему прочему, перемножение матриц можно хорошо распараллеливать на современных графических процессорах (точнее за читателей это уже очень давно сделали в одном из слоев абстракций того же GL) .
Расширение базиса
Как известно, мы обычно используем три ортонормированных вектора в качестве базиса. Их достаточно, чтобы точно задать положение любой точки в нашем мире. Трансляция оказывается нелинейной в трехмерном базисе и ее невозможно свести к умножению матриц 3х3.
Поразительно, но если расширить базис до четырехмерного, трехмерная операция трансляции в нём становится линейной! И этот оператор можно записать в виде матрицы
\begin{bmatrix} 1 & 0 & 0 & T_x \\ 0 & 1 & 0 & T_y \\ 0 & 0 & 1 & T_z \\ 0 & 0 & 0 & 1 \end{bmatrix} \cdot \begin{bmatrix} x \\ y \\z \\ 1 \end{bmatrix} = \begin{bmatrix} x + T_x \\ y+ T_y \\ z + T_z \\ 1\end{bmatrix}
Чисто интуитивно можно представлять это как некий поворот вокруг четвёртой оси в четырехмерном пространстве, который сводится к операции трансляции в трехмерном. Впрочем, по всей строгости линейной алгебры, его на самом деле нельзя считать поворотом, так как при вращении системы координат нормы (или длины) всех векторов обязаны сохраняться. Последнее здесь не происходит.
В достаточно низкоуровневых языках (по отношению в WM) программирования, вы всегда найдете четырехмерные векторы и матрицы в графической части. С ними гораздо удобнее.
Видно, что добавление объектов происходит именно в переменную mesh
, которая передается в качестве аргумента интерпретатору. В будущем это сыграет большую роль при геометрических преобразованиях.
А зачем нужны странные преобразования с переменной list
, которая является аргументом Sphere[]
? Язык Wolfram достаточно гибкий и позволяет рисовать несколько сфер таким образом Sphere[{{1,0,0},{0,0,0},{-1,0,0}}]
.
С тетраэдром все гораздо интереснее. Он задается четырьмя вершинами
case 'Tetrahedron':
var points = interpretate(func.args[0]);
var faces = [];
console.log("Points of tetrahedron:");
console.log(points);
faces.push([points[0], points[1], points[2]]);
faces.push([points[0], points[1], points[3]]);
faces.push([points[1], points[2], points[3]]);
faces.push([points[0], points[3], points[2]]);
var fake = ["List"];
var listVert = function(cord) {
return([
"List",
cord[0],
cord[1],
cord[2]
]);
}
faces.forEach(function(fs) {
var struc = [
"Polygon",
[
"List",
listVert(fs[0]),
listVert(fs[1]),
listVert(fs[2])
]
];
fake.push(struc);
});
console.log(fake);
interpretate(fake, func, params, mesh);
break;
Здесь видно переиспользование других, более низкоуровневых функций, а именно Polygon[]
. Т. е. используя четыре вершины, строятся несколько функции Polygon[]
в новой искусственно созданной ветке JSON древа. Затем этой ветке прогоняется интерпретатор.
Сложные графические объекты
Их сложность заключается в том, что строятся они на основе множества графических примитивов, которое нужно как-то эффективно описывать.
Одна из важнейших функций — это Polygon[]
В Mathematica полигоны могут состоять даже из 5-ти вершин, однако, в Three.js — это лишь треугольники, это нужно будет учесть. Посмотрим на реализацию
case 'Polygon':
var geometry = new THREE.Geometry();
var points = interpretate(func.args[0]);
points.forEach(function(el) {
if (typeof el[0] !== 'number') throw "not a triple of number"+el;
geometry.vertices.push(
new THREE.Vector3(el[0],el[1],el[2])
);
});
console.log("points");
console.log(points);
switch(points.length) {
case 3:
geometry.faces.push(new THREE.Face3(0,1,2));
break;
case 4:
geometry.faces.push(new THREE.Face3(0,1,2));
geometry.faces.push(new THREE.Face3(0,2,3));
break;
case 5:
geometry.faces.push(new THREE.Face3(0,1,4));
geometry.faces.push(new THREE.Face3(1,2,3));
geometry.faces.push(new THREE.Face3(1,3,4));
break;
default:
console.log(points);
throw "Cant build complex polygon ::";
}
var material = new THREE.MeshLambertMaterial({
color:params.color,
transparent: params.opacity < 0.9? true : false,
opacity:params.opacity,
//depthTest: false
//depthWrite: false
});
console.log(params.opacity);
material.side = THREE.DoubleSide;
geometry.computeFaceNormals();
//complex.computeVertexNormals();
var poly = new THREE.Mesh(geometry, material);
//poly.frustumCulled = false;
mesh.add(poly);
material.dispose();
break;
Принцип тот же, что для сфер, кубиков, лишь только с оговоркой, что геометрию теперь задает не функция-помошник, а мы, вручную вставляя каждую вершину в geometry.vertices
и составляя из них грани через THREE.Face3
.
Однако есть одна необычная вещь, если мы посмотрим на вывод от SphericalPlot3D[], скажем
Обратите внимание, теперь функция Polygon[]
, находящаяся внутри GraphicsComplex[]
принимает не наборы координат вершин, а наборы номеров вершин из Листа в первом аргументе GraphicsComplex[]
. По этой причине в обработчике этой функции нужно предусмотреть хранении этих вершин в окружении params
, который будет передаваться вниз по иерархии
case 'GraphicsComplex':
var copy = Object.assign({}, params);
copy.geometry = new THREE.Geometry();
interpretate(func.args[0]).forEach(function(el) {
if (typeof el[0] !== 'number') throw "not a triple of number"+el;
copy.geometry.vertices.push(
new THREE.Vector3(el[0],el[1],el[2])
);
});
var group = new THREE.Group();
interpretate(func.args[1], func, copy, mesh);
mesh.add(group);
copy.geometry.dispose();
break;
И затем, добавить новое свойство функции Polygon, которая будет проверять этот случай по наличию вершин в окружении
if (params.hasOwnProperty('geometry')) {
var geometry = params.geometry.clone();
var createFace = function(c) {
switch(c.length) {
case 3:
geometry.faces.push(new THREE.Face3(c[0]-1,c[1]-1,c[2]-1));
break;
case 4:
geometry.faces.push(new THREE.Face3(c[0]-1,c[1]-1,c[2]-1));
geometry.faces.push(new THREE.Face3(c[0]-1,c[2]-1,c[3]-1));
break;
case 5:
geometry.faces.push(new THREE.Face3(c[0]-1,c[1]-1,c[4]-1));
geometry.faces.push(new THREE.Face3(c[1]-1,c[2]-1,c[3]-1));
geometry.faces.push(new THREE.Face3(c[1]-1,c[3]-1,c[4]-1));
break;
default:
console.log(c);
console.log(c.length);
throw "Cant produce complex polygons! at"+c;
}
}
var a = interpretate(func.args[0]);
if (a.length === 1) {
a = a[0];
}
if (typeof a[0] === 'number') {
console.log("Create single face");
createFace(a);
} else {
console.log("Create multiple face");
console.log(a);
a.forEach(function(el) {
createFace(el);
});
}
} else {
Таким образом, мы уже покрываем все нужны функций Plot3D[]
, SphericalPlot3D[]
и подобных.
Геометрические преобразования
Как минимум нужны функции Translate[] и GeometricTransformation[], которая может делать уже любые аффинные преобразования. Помните про изоляцию List по параметрам окружения params
? Здесь нужно пойти дальше и изолировать сетку mesh
case 'GeometricTransformation':
//***Создаем изолированную группу сетки
var group = new THREE.Group();
//***Считываем матрицу преобразования
var p = interpretate(func.args[1]);
var centering = false;
var centrans = [];
//Особенности матрицы. Записываем все в matrix
if (p.length === 1) { p = p[0]; }
if (p.length === 1) { p = p[0]; }
else if (p.length === 2) {
console.log(p);
if (p[1] === 'Center') {
centering = true;
} else {
console.log("NON CENTERING ISSUE!!!");
console.log(p);
centrans = p[1];
console.log("???");
}
//return;
p = p[0];
}
if (p.length === 3) {
if (typeof p[0] === 'number') {
var dir = p;
var matrix = new THREE.Matrix4().makeTranslation(...dir,1);
} else {
//make it like Matrix4
p.forEach(function(el) {
el.push(0);
});
p.push([0,0,0,1]);
var matrix = new THREE.Matrix4();
console.log("Apply matrix to group::");
matrix.set(...flatten(p));
}
} else {
console.log(p);
throw "Unexpected length matrix: :: " + p;
}
//Изолируем также параметры
var copy = Object.assign({}, params);
//***Рисуем дальше в изолированной сетке
interpretate(func.args[0], func, copy, group);
console.log(matrix);
//Особенности связанные с центрированием
if (centering || centrans.length > 0) {
console.log("::CENTER::");
var bbox = new THREE.Box3().setFromObject(group);
console.log(bbox);
var center = new THREE.Vector3().addVectors(bbox.max,bbox.min).divideScalar(2);
if (centrans.length > 0) {
console.log("CENTRANS");
center = center.fromArray(centrans);
}
console.log(center);
//***Применяем преобразования
var translate = new THREE.Matrix4().makeTranslation(-center.x,-center.y,-center.z,1);
group.applyMatrix(translate);
group.applyMatrix(matrix);
translate = new THREE.Matrix4().makeTranslation(center.x,center.y,center.z,1);
group.applyMatrix(translate);
} else {
group.applyMatrix(matrix);
}
//***Добавляем преобразованную сетку в общую
mesh.add(group);
break;
Основные действия выделены ***, а остальное — это особенности обработки входных аргументов функции. Кроме того, есть одно свойство у этой функции "Center"
, которое обязывает выполнять преобразования относительно центра изолированной группы. Соответственно, чтобы понять где, центр, нужно сначала пройтись по дереву вниз, и лишь, после этого выполнить операцию преобразования.
Освещение и манипуляции с графикой
Эту часть кода я честно скопировал у проекта Mathics. Мне понравилось, как хорошо они мимикрировали под оригинальный рендер Mathematica. Управление стандартно: Drag
— rotate; Ctrl+Drag
— zoom; Shift+Drag
— drag.
Живая демонстрация
Все исходники проекта, как и живая демонстрация лежат на Github.
Использование
- Возьмите и нарисуйте что-нибудь, скажем, сферическую функцию
Graphics3D[{
SphericalPlot3D[
2 SphericalHarmonicY[2, 0, t, p], {t, 0, Pi}, {p, 0, 2 Pi},
PerformanceGoal -> "Speed"][[1]],
Opacity[0.6],
Tetrahedron[{{1, 1, 1}, {-1, -1, 1}, {1, -1, -1}, {-1, 1, -1}}]
}]
- Экспортируйте в JSON
ExportString[%//N, "ExpressionJSON"]
[
"Graphics3D",
[
"List",
[
"GraphicsComplex",
[
"List",
["List",
0.0,
0.0,
1.2615662610100797
]
,
["List",
0.0,
0.0,
1.2615662610100797
]
,...
- Скопируйте в формате PlainText в файл
data.js
//\data.js
var JSONThree = [...
- Запустите основной файл
index.html
Одностраничный экспорт в единый .html для работы оффлайн
Пожалуй, самое простой и полезный способ использования. В блокноте Export2ThreeJS.nb
на Github или здесь внизу страницы запустите функцию Export2ThreeJS[]
с аргументом в виде трехмерной графики. Скрипт предложит вам сохранить единственную страничку в формате .html, в которую уже будут встроены все библиотеки и т.п.
Планы на разработку
Сейчас парсер и рендер работают последовательно и в одном лице. Это неправильно — при загрузке страницы разбирать один и тот же документ, а затем рисовать его. Я планирую разделить их, чтобы при последующей отрисовки страницы использовались данные сцены Three.js уже в преобразованном виде. К тому же, это упростит встраивание на web страницы.
Это можно сделать с помощью функции THREE.GLTFExporter()
и THREE.GLTFLoader()
. Если вы хотите в этом поучаствовать, то прошу на Github.