赚取 10 万颗星星

Michael Chang
Michael Chang

您好!我叫 Michael Chang,是 Google 数据艺术团队的成员。我们最近完成了10 万颗恒星,这是一项可直观呈现附近恒星的 Chrome Experiments 实验。该项目是使用 THREE.js 和 CSS3D 构建的。在本案例研究中,我将简要介绍发现问题的过程,分享一些编程技巧,并最后分享一些关于未来改进的想法。

这里讨论的主题非常广泛,需要具备一些 THREE.js 知识,但我希望您仍能从中获得技术总结方面的乐趣。您可以随意使用右侧的目录按钮跳转到感兴趣的区域。首先,我将展示项目的渲染部分,然后是着色器管理,最后是如何将 CSS 文本标签与 WebGL 结合使用。

“10 万颗星星”,由数据艺术团队开展的 Chrome 实验
“10 万颗恒星”使用 THREE.js 可视化银河系中的附近恒星

探索空间

在完成 Small Arms Globe 后不久,我开始尝试使用具有景深的 THREE.js 粒子演示。我发现,我可以通过调整应用的效果量来更改场景的解读“缩放比例”。当景深效果非常极端时,远处的物体会变得非常模糊,这与倾斜移焦摄影的运作方式类似,会让人产生在看显微镜下场景的错觉。反之,如果将此效果调低,则会给人一种凝视深空的感觉。

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

绘制恒星数据。
第一步是将目录中的每颗恒星都绘制为单个粒子。
命名后的恒星。
目录中的某些恒星具有专有名称,此处已标记出来。

大约花了一个小时的时间,我便拼凑出了一个将恒星数据放置在 3D 空间中的东西。数据集中有 119,617 颗恒星,因此对于新型 GPU 来说,使用粒子来表示每颗恒星并不难。还有 87 颗单独标识的恒星,因此我使用我在《Small Arms Globe》中介绍的相同技术创建了 CSS 标记叠加层。

那段时间,我刚刚玩完 Mass Effect 系列。在游戏中,玩家可以探索银河系,扫描各种星球并阅读它们完全虚构的维基百科风格历史:哪些物种曾在该星球上繁衍昌盛、它的地质历史等等。

我们知道,关于恒星的实际数据非常丰富,因此可以想象,我们也能以同样的方式呈现有关星系的真实信息。该项目的最终目标是将这些数据呈现出来,让观看者能够像《质量效应》中那样探索星系,了解恒星及其分布,并希望能激发观看者对宇宙的敬畏和好奇。呼!

在继续介绍本案例研究的其余内容之前,我应该先说明一下,我绝不是天文学家,这项研究是业余爱好者完成的,并得到了外部专家的建议。此项目绝对应被视为艺术家对空间的解读。

构建 Galaxy

我的计划是按照流程生成一个星系模型,以便将恒星数据置于背景中,并希望能以令人惊叹的方式展示我们在银河系中的位置。

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

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

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

确定银河的规模。
每个 GL 单位都是 1 光年。在本例中,球体宽 11 万光年,包含粒子系统。

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

我决定采用的另一种惯例是旋转整个场景,而不是移动摄像头,我之前在一些其他项目中就采用过这种做法。其中一个优势是,所有内容都放置在“旋转台”上,因此鼠标向左或向右拖动会旋转相关对象,但放大只需更改 camera.position.z 即可。

相机的视野范围 (FOV) 也是动态的。随着镜头拉远,视野会变得更广阔,包含的星系也会越来越多。反之,当向星星移动时,视野会变窄。这样一来,摄像头便可将视野范围压缩到类似于神奇放大镜的大小,从而查看与星系相比微不足道的物体,而无需处理近平面剪裁问题。

渲染星系的不同方式。
(上图)早期粒子星系。(下方)带有图像平面的粒子。

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

太阳系。
太阳及其周围的行星和代表柯伊伯带的球体。

很难渲染太阳。我不得不使用我所知道的尽可能多的实时图形技术来作弊。太阳表面是热烈的等离子泡沫,需要随时间脉动和变化。这是通过太阳表面红外图像的位图纹理进行模拟的。Surface 着色器会根据此纹理的灰度进行颜色查找,并在单独的颜色梯度中执行查找。当此查找随着时间推移而发生偏移时,就会产生这种类似于熔岩的失真效果。

我们对太阳的光晕也采用了类似的技术,只不过它是一个平面精灵卡片,始终使用 https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js 朝向摄像头。

渲染 Sol。
早期版本的 Sun。

太阳耀斑是通过应用于环面体顶点着色器和片段着色器而创建的,这些着色器会在太阳表面边缘附近旋转。顶点着色器有一个噪声函数,导致它以类似于 Blob 的方式编织。

在此过程中,我开始遇到一些 z-fighting 问题,原因是 GL 精度。精度方面的所有变量都是在 THREE.js 中预定义的,因此除非付出大量工作,否则我无法实际提高精度。在原点附近,精度问题没有那么严重。不过,当我开始模拟其他恒星系统时,就遇到了问题。

明星模式。
用于渲染太阳的代码后来被推广为渲染其他恒星。

我采用了一些技巧来减少 Z 轴争用。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;

}
}

轻松实现纹理滚动

灵感源自 Homeworld。
一种直角坐标系,有助于确定空间中的空间方向。

对于“空间定位平面”,我们创建了一个巨大的 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 渲染程序会将渲染的文本视为纹理四边形,因此文本在放大后会变得像素化,使用此方法时请注意这一点。现在回想起来,我本可以使用超大号字体,或许这是一个值得探索的方向。在此项目中,我还使用了前面介绍的“top/left”CSS 展示位置文本标签,为太阳系中行星旁边的非常小元素添加了标签。

音乐播放和循环播放

《质量效应》的“银河系地图”中播放的音乐由 Bioware 作曲家 Sam Hulick 和 Jack Wall 创作,能够传达我希望访问者体验的情感。我们希望在项目中加入一些音乐,因为我们认为音乐是氛围的重要组成部分,有助于营造我们力求达到的敬畏和惊奇感。

我们的制作人 Valdean Klump 联系了 Sam,Sam 拥有大量《质量效应》的“剪辑室”音乐,并非常慷慨地允许我们使用。曲目名为“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 未来可以大力改进的一个方面,例如在单独的文件中定义材质数据,最好是在某些上下文中可查看和调整,并且可以带回主项目中。

我们的同事 Ray McClure 还花了一些时间创作了一些非常棒的生成式“太空噪音”,但由于 Web Audio 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 的 Doob 先生。

参考