Three.js рендер графики Mathematica или как отправить свои иллюстрации из блокнота в браузер

Наверняка вы замечали, как тяжел в обращении стандартный рендер трехмерных сцен в системе компьютерной алгебры 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 древа

In[]:=
Graphics3D[Sphere[]]
Out[]=
S1AAAAAElFTkSuQmCC
In[]:=
ExportString[%,«ExpressionJSON«]
Out[]=
[ «Graphics3D«, [ «Sphere«, [ «List«, 0, 0, 0 ] ]]

Структура достаточно очевидна, каждый её элемент можно интерпретировать как функцию с названием и некоторым числом аргументов. К примеру, аргументы Sphere[] по порядку

  1. Координаты центра — функция «List» с тремя числовыми аргументами (x,y,z).
  2. Радиус — число.

Вообще говоря, функция «List» — это больше, чем список элементов в языке Wolfram. Из них состоят тензоры, а также она позволяет ограничивать область видимости переменных. К примеру код, устанавливающий локальную прозрачность для некоторых фигур

In[]:=
Graphics3D[{{Opacity[0.3],Cylinder[]},Cuboid[]}]
Out[]=
w+IXkc63EZUlAAAAABJRU5ErkJggg==

Таким образом, можно изолировать действие и других модификаторов, таких как цвет, толщина и д. р.

Парадигма в разработке интерпретатора

Мне нравится 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[]

In[]:=
Graphics3D[{Polygon[{{1,0,0},{1,0,0},{0,1,0}}]},BoxedFalse]
Out[]=
FNuSGhOvouPDum9wAIfe2PgDQC7kBQuQGCJEbIERugBC5AULkBgiRGyBEboAQuQFC5AYIkRsgRG6AELkBQuQGCJEbIERugBC5AULkBgiRGyBEboAQuQFC5AYIkRsgRG6AELkBQuQGCJEbIERugBC5AULkBgiRGyBEboAQuQFC5AYIkRsgRG6AELkBQuQGCJEbIERugBC5AULkBgiRGyBEboAQuQFC5AYIkRsgRG6AELkBQuQGCJEbIERugBC5AULkBgiRGyBEboAQuQFC5AYIkRsgRG6AELkBQuQGCJEbIERugBC5AULkBgiRGyBEboAQuQFC5AYIkRsgRG6AELkBQuQGCJEbIERugBC5AULkBgiRGyBEboAQuQFC5AYIkRsgRG6AELkBQuQGCJEbIERugBC5AULkBgiRGyBEboAQuQFC5AYIkRsgRG6AELkBQuQGCJEbIERugBC5AULkBgiRGyBEboAQuQFC5AYIkRsgRG6AELkBQuQGCJEbIERugBC5AULkBgiRGyBEboAQuQFC5AYIkRsgRG6AELkBQuQGCJEbIERugBC5AUL+A2uwu2WKfsc8AAAAAElFTkSuQmCC

В 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[], скажем

In[]:=
SphericalPlot3D[1,{θ,0,π},{ϕ,0,2π},PerformanceGoal«Speed«][[1]]
GraphicsComplex{0.`,0.`,1.`},{0.`,0.`,1.`},{0.`,0.`,1.`},{0.`,0.`,1.`},{0.`,0.`,1.`},...,Polygon
1cfba4ead734e52e7946cf2c7bf34dbd9f8727beef2fd5e2ac29e1e00e4cbad7
accc4cc31017e03cfb748ba3a91ad20455ccba16fe0a409cd506461bdef3c7f6
Number of points: 81
Embedding dimension: 4
,{},{},VertexNormals{{0.1733023866668769`,0.0630769102740352`,0.9828466748002181`}...

Обратите внимание, теперь функция 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.

Использование

  1. Возьмите и нарисуйте что-нибудь, скажем, сферическую функцию
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}}]
  }]
  1. Экспортируйте в JSON
ExportString[%//N, "ExpressionJSON"]
[
	"Graphics3D",
	[
		"List",
		[
			"GraphicsComplex",
			[
				"List",
				["List",
					0.0,
					0.0,
					1.2615662610100797
				]
				,
				["List",
					0.0,
					0.0,
					1.2615662610100797
				]
				,...
  1. Скопируйте в формате PlainText в файл data.js
//\data.js

var JSONThree = [...
  1. Запустите основной файл index.html

Одностраничный экспорт в единый .html для работы оффлайн

Пожалуй, самое простой и полезный способ использования. В блокноте Export2ThreeJS.nb на Github или здесь внизу страницы запустите функцию Export2ThreeJS[] с аргументом в виде трехмерной графики. Скрипт предложит вам сохранить единственную страничку в формате .html, в которую уже будут встроены все библиотеки и т.п.

Планы на разработку

Сейчас парсер и рендер работают последовательно и в одном лице. Это неправильно — при загрузке страницы разбирать один и тот же документ, а затем рисовать его. Я планирую разделить их, чтобы при последующей отрисовки страницы использовались данные сцены Three.js уже в преобразованном виде. К тому же, это упростит встраивание на web страницы.

Это можно сделать с помощью функции THREE.GLTFExporter() и THREE.GLTFLoader(). Если вы хотите в этом поучаствовать, то прошу на Github.


2
2

About Кирилл Васин

Прохожий из шапки сайта

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *