Здравствуйте! Меня зовут Майкл Чанг, я работаю в команде по обработке данных в Google. Недавно мы завершили проект Chrome «100 000 звёзд» — эксперимент по визуализации близлежащих звёзд. Проект был создан с использованием THREE.js и CSS3D. В этом примере я опишу процесс поиска, поделюсь некоторыми приёмами программирования и поделюсь идеями по дальнейшему развитию.
Темы, обсуждаемые здесь, довольно обширны и потребуют некоторых знаний THREE.js, хотя я надеюсь, что вы всё равно сможете воспользоваться этим техническим анализом. Вы можете легко перейти к интересующей вас теме, нажав кнопку «Оглавление» справа. Сначала я покажу рендеринг проекта, затем управление шейдерами и, наконец, как использовать текстовые метки CSS в сочетании с WebGL.

Открытие космоса
Вскоре после завершения работы над Small Arms Globe я экспериментировал с демо-версией частиц THREE.js с глубиной резкости. Я заметил, что могу менять интерпретируемый «масштаб» сцены, регулируя интенсивность применяемого эффекта. При очень сильной глубине резкости удалённые объекты становились очень размытыми, подобно тому, как работает тилт-шифт-фотография, создавая иллюзию микроскопической сцены. И наоборот, уменьшение интенсивности эффекта создавало впечатление, будто вы смотрите в глубокий космос.
Я начал искать данные, которые можно было бы использовать для ввода координат частиц. Путь привёл меня к базе данных HYG на astronexus.com – сборнику данных из трёх источников (Hipparcos, Yale Bright Star Catalog и Gliese/Jahreiss Catalog), сопровождаемому предварительно рассчитанными декартовыми координатами xyz. Итак, начнём!


На создание трёхмерного представления данных о звёздах ушло около часа. В наборе данных ровно 119 617 звёзд, поэтому представление каждой звезды частицей не составит труда для современного графического процессора. Кроме того, имеется 87 индивидуально идентифицированных звёзд, поэтому я создал CSS-маркер, используя ту же технику, что и в Small Arms Globe.
В то время я как раз закончил серию Mass Effect . В игре игроку предлагается исследовать галактику, сканировать различные планеты и читать об их полностью вымышленной истории, напоминающей историю из Википедии: какие виды процветали на планете, её геологическая история и так далее.
Зная обилие фактических данных о звёздах, можно, вероятно, представить реальную информацию о галактике таким же образом. Конечной целью этого проекта было бы оживить эти данные, позволить зрителю исследовать галактику в стиле Mass Effect, узнать о звёздах и их распределении и, надеемся, вызвать чувство благоговения и восхищения космосом. Уф!
Пожалуй, стоит начать с предисловия к этому исследованию, сказав, что я ни в коем случае не астроном, и что это работа любительского исследования, поддержанная некоторыми консультациями сторонних экспертов. Этот проект определённо следует рассматривать как художественную интерпретацию пространства.
Строим Галактику
Мой план состоял в том, чтобы процедурно создать модель галактики, которая могла бы поместить данные о звездах в контекст и, как я надеялся, дать потрясающее представление о нашем месте в Млечном Пути.

Чтобы создать Млечный Путь, я создал 100 000 частиц и разместил их в спираль, имитируя процесс формирования галактических рукавов. Меня не слишком беспокоили особенности формирования спиральных рукавов, поскольку это была бы репрезентативная, а не математическая модель. Тем не менее, я попытался более-менее точно определить количество спиральных рукавов и их вращение в «правильном направлении».
В более поздних версиях модели Млечного Пути я отказался от использования частиц в пользу плоского изображения галактики, сопровождающей эти частицы, надеясь придать ей более фотографический вид. Изображение представляет собой спиральную галактику NGC 1232, находящуюся примерно в 70 миллионах световых лет от нас, и было обработано таким образом, чтобы оно напоминало Млечный Путь.

С самого начала я решил представить одну единицу GL, по сути пиксель в 3D, как один световой год — соглашение, которое унифицирует размещение всего визуализированного, и, к сожалению, впоследствии вызвало у меня серьезные проблемы с точностью.
Ещё одним принятым мной решением было вращение всей сцены, а не перемещение камеры, как я уже делал в нескольких других проектах. Преимущество заключается в том, что всё располагается на «поворотном круге», так что перетаскивание мыши влево и вправо вращает нужный объект, а для увеличения масштаба достаточно изменить положение камеры по оси z.
Поле зрения (или FOV) камеры также динамично. По мере приближения к внешней стороне, поле зрения расширяется, охватывая всё большую часть галактики. Обратное происходит при приближении к звезде: поле зрения сужается. Это позволяет камере видеть объекты, бесконечно малые (по сравнению с галактикой), сжимая поле зрения до размеров, сравнимых с увеличительным стеклом, без необходимости сталкиваться с проблемой клиппинга в ближней плоскости.

Отсюда я смог «разместить» Солнце на некотором расстоянии от ядра галактики. Я также смог визуализировать относительные размеры Солнечной системы, нанеся на карту радиус обрыва Койпера (в итоге я решил визуализировать облако Оорта ). В рамках этой модели Солнечной системы я также смог визуализировать упрощённую орбиту Земли и сравнить её с фактическим радиусом Солнца.

Солнце было сложно визуализировать. Мне пришлось использовать все известные мне методы обработки в реальном времени. Поверхность Солнца представляет собой горячую плазменную пену, которая должна пульсировать и меняться со временем. Это было смоделировано с помощью растровой текстуры инфракрасного изображения солнечной поверхности. Поверхностный шейдер выполняет поиск цвета на основе оттенков серого этой текстуры и выполняет поиск в отдельной цветовой шкале. Смещение этого поиска со временем создаёт искажение, похожее на лаву.
Похожая техника использовалась для короны Солнца, за исключением того, что это была плоская спрайт-карта, которая всегда была обращена к камере, с использованием https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js .

Солнечные вспышки были созданы с помощью вершинных и фрагментных шейдеров, применённых к тору, вращающемуся по краю солнечной поверхности. Вершинный шейдер имеет функцию шума, заставляющую его переплетаться, подобно каплевидным пятнам.
Именно здесь я начал испытывать проблемы с z-конфликтом из-за точности GL. Все переменные, отвечающие за точность, были предопределены в THREE.js, поэтому я не мог реально повысить точность без значительных усилий. Вблизи начала координат проблемы с точностью были не столь существенны. Однако, как только я начал моделировать другие звёздные системы, это стало проблемой.

Я применил несколько уловок для уменьшения z-конфликта. Material.polygonoffset в THREE — это свойство, позволяющее рендерить полигоны в разных воспринимаемых местах (насколько я понимаю). Это использовалось для того, чтобы заставить плоскость короны всегда рендериться поверх поверхности Солнца. Ниже неё рендерилось солнечное «гало», создающее резкие лучи света, исходящие от сферы.
Другая проблема, связанная с точностью, заключалась в том, что модели звезд начинали дрожать при увеличении масштаба сцены. Чтобы исправить это, мне пришлось «обнулить» вращение сцены и отдельно вращать модель звезды и карту окружения, чтобы создать иллюзию вращения вокруг звезды.
Создание бликов

Визуализация пространства — это то, где, как мне кажется, я могу позволить себе чрезмерное использование бликов. THREE.LensFlare отлично подходит для этих целей. Мне нужно было лишь добавить несколько анаморфных шестиугольников и немного Дж. Дж. Абрамса . Ниже показано, как создать их в вашей сцене.
// This function returns a lesnflare THREE object to be .add()ed to the scene graph
function addLensFlare(x,y,z, size, overrideImage){
var flareColor = new THREE.Color( 0xffffff );
lensFlare = new THREE.LensFlare( overrideImage, 700, 0.0, THREE.AdditiveBlending, flareColor );
// we're going to be using multiple sub-lens-flare artifacts, each with a different size
lensFlare.add( textureFlare1, 4096, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
lensFlare.add( textureFlare2, 512, 0.0, THREE.AdditiveBlending );
// and run each through a function below
lensFlare.customUpdateCallback = lensFlareUpdateCallback;
lensFlare.position = new THREE.Vector3(x,y,z);
lensFlare.size = size ? size : 16000 ;
return lensFlare;
}
// this function will operate over each lensflare artifact, moving them around the screen
function lensFlareUpdateCallback( object ) {
var f, fl = this.lensFlares.length;
var flare;
var vecX = -this.positionScreen.x _ 2;
var vecY = -this.positionScreen.y _ 2;
var size = object.size ? object.size : 16000;
var camDistance = camera.position.length();
for( f = 0; f < fl; f ++ ) {
flare = this.lensFlares[ f ];
flare.x = this.positionScreen.x + vecX * flare.distance;
flare.y = this.positionScreen.y + vecY * flare.distance;
flare.scale = size / camDistance;
flare.rotation = 0;
}
}
Простой способ прокрутки текстуры

Для «плоскости пространственной ориентации» был создан гигантский объект THREE.CylinderGeometry(), центрированный на Солнце. Чтобы создать «волну света», расходящуюся наружу, я постепенно корректировал смещение его текстуры следующим образом:
mesh.material.map.needsUpdate = true;
mesh.material.map.onUpdate = function(){
this.offset.y -= 0.001;
this.needsUpdate = true;
}
map
— это текстура, принадлежащая материалу, для которой можно задать функцию onUpdate, которую можно перезаписать. Установка смещения приводит к «прокрутке» текстуры вдоль этой оси, а постоянное указание needsUpdate = true зациклит это поведение.
Использование цветовых гамм
Каждая звезда имеет свой цвет, определяемый «индексом цвета», присвоенным ей астрономами. Как правило, красные звёзды холоднее, а сине-фиолетовые — горячее. В этом градиенте присутствует полоса белого и промежуточного оранжевого цветов.
При рендеринге звёзд я хотел придать каждой частице свой цвет на основе этих данных. Этого удалось добиться с помощью «атрибутов», присвоенных материалу шейдера, применяемому к частицам.
var shaderMaterial = new THREE.ShaderMaterial( {
uniforms: datastarUniforms,
attributes: datastarAttributes,
/_ ... etc _/
});
var datastarAttributes = {
size: { type: 'f', value: [] },
colorIndex: { type: 'f', value: [] },
};
Заполнение массива colorIndex присвоит каждой частице уникальный цвет в шейдере. Обычно для этого передаётся цветовой вектор vec3, но в данном случае я передаю число с плавающей точкой для окончательного поиска цветовой шкалы.

Цветовая шкала выглядела так, однако мне нужно было получить доступ к её растровым данным цвета из JavaScript. Я сделал это следующим образом: сначала загрузил изображение в DOM, отобразил его в элементе холста, а затем получил доступ к растровому изображению холста.
// make a blank canvas, sized to the image, in this case gradientImage is a dom image element
gradientCanvas = document.createElement('canvas');
gradientCanvas.width = gradientImage.width;
gradientCanvas.height = gradientImage.height;
// draw the image
gradientCanvas.getContext('2d').drawImage( gradientImage, 0, 0, gradientImage.width, gradientImage.height );
// a function to grab the pixel color based on a normalized percentage value
gradientCanvas.getColor = function( percentage ){
return this.getContext('2d').getImageData(percentage \* gradientImage.width,0, 1, 1).data;
}
Этот же метод затем используется для раскрашивания отдельных звезд в представлении звездной модели.

Обработка шейдеров
В ходе проекта я обнаружил, что мне нужно писать всё больше и больше шейдеров для достижения всех визуальных эффектов. Я написал для этого собственный загрузчик шейдеров, потому что мне надоело хранить шейдеры в index.html.
// list of shaders we'll load
var shaderList = ['shaders/starsurface', 'shaders/starhalo', 'shaders/starflare', 'shaders/galacticstars', /*...etc...*/];
// a small util to pre-fetch all shaders and put them in a data structure (replacing the list above)
function loadShaders( list, callback ){
var shaders = {};
var expectedFiles = list.length \* 2;
var loadedFiles = 0;
function makeCallback( name, type ){
return function(data){
if( shaders[name] === undefined ){
shaders[name] = {};
}
shaders[name][type] = data;
// check if done
loadedFiles++;
if( loadedFiles == expectedFiles ){
callback( shaders );
}
};
}
for( var i=0; i<list.length; i++ ){
var vertexShaderFile = list[i] + '.vsh';
var fragmentShaderFile = list[i] + '.fsh';
// find the filename, use it as the identifier
var splitted = list[i].split('/');
var shaderName = splitted[splitted.length-1];
$(document).load( vertexShaderFile, makeCallback(shaderName, 'vertex') );
$(document).load( fragmentShaderFile, makeCallback(shaderName, 'fragment') );
}
}
Функция loadShaders() принимает список имён файлов шейдеров (ожидается расширение .fsh для фрагментных и .vsh для вершинных шейдеров), пытается загрузить их данные, а затем просто заменяет список объектами. Конечный результат — в юниформах THREE.js, в которые можно передавать шейдеры следующим образом:
var galacticShaderMaterial = new THREE.ShaderMaterial( {
vertexShader: shaderList.galacticstars.vertex,
fragmentShader: shaderList.galacticstars.fragment,
/_..._/
});
Вероятно, я мог бы использовать require.js, хотя это потребовало бы перекомпилировать код специально для этой цели. Это решение, хоть и гораздо проще, можно улучшить, возможно, даже сделать расширением THREE.js. Если у вас есть предложения или способы улучшить это, пожалуйста, дайте мне знать!
Текстовые метки CSS поверх THREE.js
В нашем последнем проекте, Small Arms Globe, я экспериментировал с отображением текстовых меток поверх сцены THREE.js. Метод, который я использовал, вычисляет абсолютную позицию модели, где должен располагаться текст, затем определяет положение на экране с помощью THREE.Projector() и, наконец, использует CSS-свойства «top» и «left» для размещения CSS-элементов в нужном месте.
Ранние версии этого проекта использовали ту же технику, однако мне не терпелось испробовать другой метод, описанный Луисом Крузом.
Основная идея: сопоставить матричное преобразование CSS3D с камерой и сценой THREE, и можно будет «размещать» CSS-элементы в 3D, как если бы они находились поверх сцены THREE. Однако есть ограничения: например, нельзя разместить текст под объектом THREE.js. Это всё равно гораздо быстрее, чем пытаться выполнить вёрстку с помощью CSS-атрибутов «top» и «left».

Демо-версию (и код в исходном коде) можно найти здесь. Однако я обнаружил, что порядок матриц в THREE.js с тех пор изменился. Функция, которую я обновил:
/_ Fixes the difference between WebGL coordinates to CSS coordinates _/
function toCSSMatrix(threeMat4, b) {
var a = threeMat4, f;
if (b) {
f = [
a.elements[0], -a.elements[1], a.elements[2], a.elements[3],
a.elements[4], -a.elements[5], a.elements[6], a.elements[7],
a.elements[8], -a.elements[9], a.elements[10], a.elements[11],
a.elements[12], -a.elements[13], a.elements[14], a.elements[15]
];
} else {
f = [
a.elements[0], a.elements[1], a.elements[2], a.elements[3],
a.elements[4], a.elements[5], a.elements[6], a.elements[7],
a.elements[8], a.elements[9], a.elements[10], a.elements[11],
a.elements[12], a.elements[13], a.elements[14], a.elements[15]
];
}
for (var e in f) {
f[e] = epsilon(f[e]);
}
return "matrix3d(" + f.join(",") + ")";
}
Поскольку всё преобразовано, текст больше не обращён к камере. Решением стало использование метода THREE.Gyroscope() , который заставляет Object3D «потерять» унаследованную от сцены ориентацию. Этот приём называется «биллбордингом», и Gyroscope идеально подходит для этой цели.
Что действительно приятно, так это то, что все обычные DOM и CSS по-прежнему работают, например, можно навести курсор на 3D-текстовую метку и увидеть, как она начинает светиться тенями.

При увеличении масштаба я обнаружил, что масштабирование типографики вызывает проблемы с позиционированием. Возможно, это связано с кернингом и отступами текста? Другая проблема заключалась в том, что текст становился пикселизированным при увеличении масштаба, поскольку DOM-рендерер обрабатывает отрисованный текст как текстурированный четырёхугольник, что следует учитывать при использовании этого метода. Оглядываясь назад, я мог бы просто использовать текст с гигантским размером шрифта, и, возможно, это тема для будущих исследований. В этом проекте я также использовал CSS-метки размещения текста «сверху/слева», описанные ранее, для очень маленьких элементов, сопровождающих планеты в Солнечной системе.
Воспроизведение и зацикливание музыки
Музыка, звучащая в «Галактической карте» Mass Effect, была написана композиторами Bioware Сэмом Халиком и Джеком Уоллом, и она вызывала именно те эмоции, которые я хотел вызвать у посетителя. Мы хотели, чтобы музыка была частью нашего проекта, потому что чувствовали, что она играет важную роль в атмосфере, помогая создать то чувство благоговения и изумления, к которому мы стремились.
Наш продюсер Валдин Кламп связался с Сэмом, у которого была куча музыки для «нарезки» из Mass Effect, которую он любезно разрешил нам использовать. Трек называется «In a Strange Land».
Я использовал тег аудио для воспроизведения музыки, однако даже в Chrome атрибут «loop» оказался ненадёжным — иногда просто не получалось зациклить трек. В итоге этот двойной тег аудио был использован для проверки окончания воспроизведения и переключения на другой тег для воспроизведения. Разочаровало то, что и это не всегда получалось идеально зациклить трек. Увы, мне кажется, это лучшее, что я мог сделать.
var musicA = document.getElementById('bgmusicA');
var musicB = document.getElementById('bgmusicB');
musicA.addEventListener('ended', function(){
this.currentTime = 0;
this.pause();
var playB = function(){
musicB.play();
}
// make it wait 15 seconds before playing again
setTimeout( playB, 15000 );
}, false);
musicB.addEventListener('ended', function(){
this.currentTime = 0;
this.pause();
var playA = function(){
musicA.play();
}
// otherwise the music will drive you insane
setTimeout( playA, 15000 );
}, false);
// okay so there's a bit of code redundancy, I admit it
musicA.play();
Возможности для улучшения
Поработав некоторое время с THREE.js, я понял, что мои данные слишком сильно смешиваются с кодом. Например, при определении материалов, текстур и геометрических инструкций в строке я фактически «моделировал 3D-кодом». Это было очень неприятно, и это область, которую можно было бы значительно улучшить в будущих проектах с THREE.js, например, определив данные о материалах в отдельном файле, желательно с возможностью просмотра и настройки в каком-либо контексте, и который можно было бы вернуть в основной проект.
Наш коллега Рэй МакКлюр также потратил некоторое время на создание потрясающих генеративных «космических шумов», которые пришлось вырезать из-за нестабильности API веб-аудио, периодически приводившей к сбоям в работе Chrome. Это печально… но это определённо заставило нас задуматься о будущем в области звука. На момент написания статьи мне сообщили, что API веб-аудио было исправлено, так что, возможно, всё работает, и стоит обратить на это внимание в будущем.
Сочетание типографских элементов с WebGL всё ещё представляет собой сложную задачу, и я не уверен на 100%, что мы делаем правильно. Всё ещё кажется, что это хак. Возможно, будущие версии THREE с его перспективным CSS Renderer позволят лучше объединить эти два мира.
Кредиты
Спасибо Аарону Коблину за то, что позволил мне заняться этим проектом. Джоно Бранделю за превосходный дизайн и реализацию пользовательского интерфейса, работу со шрифтами и создание тура. Валдину Клампу за то, что дал проекту название и весь текст. Сабаху Ахмеду за оформление огромного количества прав на использование исходных данных и изображений. Клему Райту за то, что он связался с нужными людьми для публикации. Дугу Фрицу за техническое мастерство. Джорджу Брауэру за то, что научил меня работать с JS и CSS. И, конечно же, мистеру Дубу за THREE.js.