赚取 10 万颗星星

Michael Chang
Michael Chang

您好!我叫 Michael Chang,在 Google 的 Data Arts 团队工作。我们最近完成了 10 万颗恒星,这是一个可视化附近恒星的 Chrome 实验。该项目使用 THREE.js 和 CSS3D 构建。在本案例研究中,我将简要介绍发现过程,分享一些编程技巧,最后提出一些关于未来改进的想法。

本文讨论的主题将相当广泛,需要具备一定的 THREE.js 知识,但我希望您仍然可以将其视为技术总结。您可以随时使用右侧的目录按钮跳转到感兴趣的区域。首先,我将展示项目的渲染部分,然后是着色器管理,最后是如何将 CSS 文本标签与 WebGL 结合使用。

10 万颗恒星,Data Arts 团队的 Chrome 实验
10 万颗恒星使用 THREE.js 可视化银河系中的附近恒星

探索太空

在完成 Small Arms Globe 后不久,我尝试使用 THREE.js 粒子演示制作景深效果。我注意到,通过调整应用的效果量,我可以更改场景的解读“比例”。当景深效果非常明显时,远处的物体会变得非常模糊,类似于倾斜移轴摄影给人的错觉,仿佛在观看微观场景。相反,调低该效果后,您会感觉自己仿佛在凝视深空。

我开始寻找可用于注入粒子位置的数据,最终找到了 astronexus.com 的 HYG 数据库,该数据库汇集了三个数据源(Hipparcos、耶鲁亮星目录和 Gliese/Jahreiss 目录),并附带了预先计算的 xyz 笛卡尔坐标。我们开始吧!

绘制星图。
第一步是将目录中的每颗星绘制为单个粒子。
已命名的恒星。
目录中的一些恒星有正式名称,在此处进行了标记。

我花了一个小时左右的时间,拼凑出了一个将星数据放置在 3D 空间中的程序。数据集中的恒星数量正好为 119,617 颗,因此使用一个粒子来表示每颗恒星对于现代 GPU 来说不成问题。此外,还有 87 颗单独标识的恒星,因此我使用与“小型武器地球”中描述的相同技术创建了 CSS 标记叠加层。

当时,我刚刚玩完《质量效应》系列游戏。在游戏中,玩家可以探索星系,扫描各个行星并阅读其完全虚构的维基百科式历史记录:哪些物种曾在该行星上繁衍生息、该行星的地质历史等等。

鉴于目前有大量关于恒星的实际数据,人们可以想象,以同样的方式呈现有关星系的真实信息。此项目的最终目标是让这些数据栩栩如生,让观看者能够像在《质量效应》中那样探索星系,了解恒星及其分布,并希望激发人们对太空的敬畏和好奇。好,

在继续介绍本案例研究之前,我应该先声明一下,我绝不是天文学家,这项研究是业余研究,并得到了一些外部专家的建议。这个项目绝对应该被视为艺术家对空间的诠释。

打造 Galaxy

我的计划是程序化地生成一个星系模型,将恒星数据置于上下文中,并希望能够呈现出我们在银河系中的位置的壮观景象。

星系的早期原型。
Milky Way 粒子系统的早期原型。

为了生成银河系,我生成了 10 万个粒子,并通过模拟银河臂的形成方式将它们放置在螺旋形中。我并不太担心旋臂形成的具体细节,因为这将是一个表示性模型,而不是数学模型。不过,我确实尝试让旋臂的数量大致正确,并以“正确的方向”旋转。

在后来的银河系模型版本中,我不再强调使用粒子,而是使用星系的平面图像来搭配粒子,希望使其更具摄影效果。实际图片是距离我们约 7, 000 万光年的螺旋星系 NGC 1232,经过图像处理后看起来像银河系。

了解银河系的规模。
每个 GL 单位都是光年。在这种情况下,球体的宽度为 11 万光年,包含整个粒子系统。

我一开始就决定将一个 GL 单位(基本上是 3D 中的一个像素)表示为一个光年,这种约定统一了所有可视化内容的放置位置,但遗憾的是,后来给我带来了严重的精度问题。

我决定的另一个惯例是旋转整个场景,而不是移动相机,这我在其他几个项目中也做过。一个优势是,所有内容都放置在“转盘”上,因此向左和向右拖动鼠标会旋转相关对象,而放大只是更改 camera.position.z 的问题。

相机的视野 (FOV) 也是动态的。随着镜头向外拉伸,视野会逐渐变宽,从而拍摄到越来越多的星系。当向内朝向恒星移动时,视场会变窄。这样一来,相机就可以通过将视场压缩到类似上帝之眼的放大镜来查看微小的事物(与星系相比),而无需处理近平面剪裁问题。

呈现星系的不同方式。
(上图)早期粒子星系。(下方)伴随有图像平面的粒子。

从这里,我能够将太阳“放置”在距离银河系核心一定数量的单位处。我还通过绘制 Kuiper Cliff(我最终选择绘制 Oort Cloud)的半径来直观呈现太阳系的相对大小。在这个模型太阳系中,我还可以直观地看到地球的简化轨道,以及太阳的实际半径。

太阳系。
太阳周围环绕着行星,以及代表柯伊伯带的球体。

太阳很难渲染。我不得不尽可能多地使用我所知道的实时图形技术来作弊。太阳表面是热气腾腾的等离子体,会随着时间的推移而不断变化。这是通过太阳表面的红外图像的位图纹理模拟的。表面着色器会根据此纹理的灰度进行颜色查找,并在单独的颜色渐变中执行查找。当此查找表随时间推移而发生变化时,就会产生这种类似熔岩的扭曲效果。

太阳日冕也使用了类似的技术,不过它是一个始终面向摄像头的平面 sprite 卡,使用的是 https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js

渲染 Sol。
太阳的早期版本。

太阳耀斑是通过应用于环面的顶点和片段着色器创建的,环面在太阳表面的边缘附近旋转。顶点着色器具有噪声函数,可使其以类似 Blob 的方式编织。

正是在这里,我开始遇到因 GL 精度而导致的一些 z-fighting 问题。所有与精度相关的变量都在 THREE.js 中预先定义,因此如果不投入大量精力,我无法切实提高精度。在原点附近,精度问题并不严重。不过,当我开始对其他恒星系统进行建模时,这个问题就出现了。

星级模型。
用于渲染太阳的代码后来经过泛化,可用于渲染其他恒星。

我采用了一些技巧来缓解 Z-fighting 问题。THREE 的 Material.polygonoffset 属性可让多边形在不同的感知位置进行渲染(据我所知)。此参数用于强制使日冕平面始终渲染在太阳表面之上。在此下方,系统渲染了一个太阳“光晕”,以呈现从球体发出的锐利光线。

与精度相关的另一个问题是,随着场景放大,星形模型会开始抖动。为了解决这个问题,我必须将场景旋转“归零”,然后单独旋转星体模型和环境贴图,以营造出您正在绕星体运行的错觉。

创建镜头光晕

能力越强,责任也就越重。
能力越强,责任也就越重。

在太空可视化效果中,我觉得可以放心地使用过多的镜头光晕。THREE.LensFlare 可实现此目的,我只需添加一些变形六边形和少许 JJ Abrams 风格。以下代码段展示了如何在场景中构建它们。

// 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 扩展程序。如果您有任何建议或更好的方法,请告诉我!

THREE.js 顶部的 CSS 文本标签

在我们的上一个项目“Small Arms Globe”中,我尝试让文字标签显示在 THREE.js 场景的顶部。我使用的方法会计算我希望文字显示的绝对模型位置,然后使用 THREE.Projector() 解析屏幕位置,最后使用 CSS“top”和“left”将 CSS 元素放置在所需位置。

此项目的早期迭代版本也使用了这种技术,但我一直很想尝试 Luis Cruz 介绍的另一种方法

基本思路:将 CSS3D 的矩阵转换与 THREE 的相机和场景相匹配,您就可以在 3D 空间中“放置”CSS 元素,就像它们位于 THREE 的场景之上一样。不过,此功能存在一些限制,例如您无法让文字显示在 THREE.js 对象下方。这仍然比尝试使用“top”和“left”CSS 属性执行布局快得多。

文本标签。
使用 CSS3D 转换将文本标签放置在 WebGL 之上。

您可以在此处找到此演示(以及查看源代码中的代码)。不过,我发现 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“失去”其从场景继承的方向。这种技术称为“广告牌”,而陀螺仪非常适合实现这种效果。

真正妙的地方在于,所有常规 DOM 和 CSS 仍然可以正常使用,例如能够将鼠标悬停在 3D 文本标签上,并使其在阴影的衬托下发光。

文本标签。
通过将文本标签附加到 THREE.Gyroscope(),使文本标签始终面向相机。

放大时,我发现排版的缩放导致了定位问题。这可能是因为文字的字距和边衬区?另一个问题是,由于 DOM 渲染器将渲染的文本视为纹理四边形,因此放大时文本会变得像素化,使用此方法时需要注意这一点。回想起来,我本可以只使用超大字号的文本,也许这是未来可以探索的方向。在此项目中,我还使用了前面介绍的“顶部/左侧”CSS 位置文本标签,用于太阳系中伴随行星的极小元素。

音乐播放和循环播放

《质量效应》“银河系地图”中播放的乐曲由 Bioware 作曲家 Sam Hulick 和 Jack Wall 创作,其中蕴含的情感正是我希望参观者感受到的。我们希望在项目中加入一些音乐,因为我们认为音乐是营造氛围的重要组成部分,有助于营造我们想要的那种敬畏和惊奇感。

我们的制作人 Valdean Klump 联系了 Sam,他有许多《质量效应》的“剪辑室”音乐,非常慷慨地让我们使用。该曲目的标题为“In a Strange Land”。

我使用音频标记播放音乐,但即使在 Chrome 中,“loop”属性也不可靠,有时会无法循环播放。最后,此双音频标记 hack 用于检查播放结束并循环到另一个标记以进行播放。令人失望的是,此静止图像并非始终完美循环播放,唉,我觉得这已经是我的极限了。

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 可以大大改进的方面,例如在单独的文件中定义材质数据,最好可以在某种上下文中查看和调整,并且可以带回到主项目中。

我们的同事 Ray McClure 还花了一些时间制作出一些很棒的生成式“太空噪音”,但由于 Web 音频 API 不稳定,导致 Chrome 经常崩溃,因此不得不将其剪掉。这很遗憾,但确实让我们在未来的工作中更多地考虑声音方面的问题。在撰写本文时,我得知 Web Audio API 已得到修补,因此现在可能可以正常运行,但未来仍需留意。

将排版元素与 WebGL 搭配使用仍然是一个挑战,我并不完全确定我们在这里所做的是正确的方法。但仍感觉像是一种黑客行为。或许未来的 THREE 版本(带有即将推出的 CSS 渲染器)可以更好地将这两个世界融合在一起。

积分

感谢 Aaron Koblin 让我放手去做这个项目。感谢 Jono Brandel 提供的出色界面设计和实现、排版处理以及导览实现。Valdean Klump 为项目命名并撰写了所有文案。Sabah Ahmed 负责清理数据和图片来源的数百万吨使用权。Clem Wright,感谢他联系了合适的出版方。Doug Fritz,表彰其卓越的技术能力。George Brower,感谢他教我 JS 和 CSS。当然,还有 THREE.js 的作者 Mr. Doob。

参考