赚取 10 万颗星星

Michael Chang
Michael Chang

你好!我叫 Michael Chang,在 Google 数据艺术团队工作。最近,我们完成了 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、Yale Bright Star Catalog 和 Gliese/Jahreiss Catalog)以及预先计算的笛卡尔坐标系的汇总数据。我们开始吧!

绘制星形数据。
第一步是将目录中的每个星体绘制为单个粒子。
命名的星星。
目录中的某些明星有专有名词,并在此处标出。

我们花了大约一个小时的时间将一些星球数据整合到 3D 空间中。数据集中正好有 119,617 颗恒星,因此用粒子表示每颗恒星对于现代 GPU 来说不是问题。此外,游戏还具有 87 颗单独识别的星星,因此我使用我在《小臂环球》中描述的相同技术创建了一个 CSS 标记叠加层。

在此期间,我刚刚读完了大范围效果系列。在游戏中,玩家被邀请探索银河系,扫描各个星球,了解它们完全虚构的维基百科历史:哪些物种在星球上茁壮成长、它的地质历史,等等。

知道了关于恒星的大量真实数据,我们或许能以同样的方式呈现关于银河系的真实信息。此项目的最终目标是生动呈现这些数据,让观看者探索银河系中的质量效应,了解恒星及其分布,并希望能够激发人们对太空的敬畏和好奇心。呼!

在本次案例研究的剩余部分,我应该在前面说到,我绝不是天文学家,这是一份业余研究,获得了外部专家的一些建议。这个项目绝对应该解读为艺术家对太空的解读。

建造星系

我的计划是按程序生成银河系模型,以便结合相关恒星数据,而且希望能真切地展示我们银河中所处的位置。

银河系的早期原型。
银河系粒子系统的早期原型。

为了生成银河,我生成了 10 万个粒子,并通过模拟银河臂的形成方式将它们放置成螺旋形。我不太担心螺旋臂构造的细节,因为这是一个表征模型,而不是数学模型。不过,我尽力让旋涡臂数量变得或多或少正确,并朝“正确方向”旋转。

在较新版本的银河系模型中,我取消了使用粒子的强调,转而使用银河的平面图像来伴随粒子,希望它更具摄影感。实际图像是距离我们约 7000 万光年的螺旋星系 NGC 1232,经过处理后看起来就像银河系。

算出银河系的规模。
每个 GL 单位都是一个光年。在本示例中,球体宽度为 110,000 光年,包含粒子系统。

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

我决定的另一个惯例是旋转整个场景而不是移动镜头,我在其他几个项目中已经做过。其中一个优点是,所有内容都放置在“转盘”上,以便通过鼠标左右拖动来旋转相关对象,但放大操作只是更改 camera.position.z 而已。

镜头的视野(或 FOV)也是动态的。向外拉伸时,视野会变大,从而接受越来越多的银河系。相反,当朝向恒星向内移动时,视野就会变窄。这样一来,相机就可以通过将 FOV 压缩到类似上帝的放大镜的物体中观察无限小的事物(与银河系相比),而无需处理近平面的裁剪问题。

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

在这里,我得以将太阳“放置”在距离银河核心一定距离的位置。此外,我还可以绘制库珀悬崖的半径,直观呈现太阳系的相对大小(我最终选择了直观呈现奥尔特云)。在这个太阳系模型中,我还可以直观地看到地球的简化轨道,以及太阳的实际半径。

太阳系。
太阳以行星和地球为中心,代表柯伊伯带。

太阳很难呈现。我不得不用我所知的尽可能多的实时图形技术来作弊。太阳表面是等离子体的热泡沫,需要随着时间而脉冲和变化。这是通过太阳表面红外图像的位图纹理模拟的。Surface 着色器根据此纹理的灰度进行颜色查询,并在单独的颜色梯度中执行查询。当这种查找方式随时间变化时,就会产生类似熔岩的扭曲。

太阳冠状图采用了类似的技术,只不过它是一张平面精灵卡,始终使用 https://github.com/mrdoob/three.js/blob/master/src/extras/core/Gyroscope.js 面向相机。

渲染太阳镜
Sun 的早期版本。

太阳耀斑是通过应用于环面的顶点和片段着色器形成的,这些着色器正好绕太阳表面边缘旋转。顶点着色器有一个噪声函数,使其以类似于 blob 的方式编织。

从这里开始,由于 GL 精度,我开始遇到一些 Z-fighting 问题。所有的精确率变量都是在 THREE.js 中预定义的,所以如果没有进行大量的工作,我实际上无法提高精确率。精确率问题在原点附近并不是那么严重。然而,一旦我开始对其他恒星系统进行建模,这就成为了一个问题。

星星模型。
用于渲染太阳的代码后来被泛化为渲染其他恒星。

我采用了一些黑客手段来缓解 Z-fighting 问题。THREE 的 Material.polygonoffset 是一个属性,可让多边形在不同的感知位置(据我所知)渲染。这曾用于迫使冠状病毒平面始终渲染在太阳表面上。在这层楼下方,我们渲染了太阳“光晕”,发出强烈的光线离开球体。

与精确率相关的另一个问题是,星星模型会随着场景放大而开始抖动。为了解决这个问题,我必须将场景旋转“归零”,并分别旋转星体模型和环境图,从而营造出您绕着恒星绕行的感觉。

正在创建 Lensflare

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

在太空可视化中,我常常使用镜头光晕,让我感觉自己逃脱不了。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 函数。设置其偏移量会导致纹理沿该轴“滚动”,而垃圾内容需要更新 = 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 uniform,您可以将着色器传递给它,如下所示:

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 元素放置在所需位置。

此项目的早期迭代也使用了相同的技术,但我一直渴望尝试路易斯·克鲁兹介绍的另一种方法

基本思路:将 CSS3D 的矩阵转换与 THREE 的镜头和场景相匹配,然后将 CSS 元素“放置”到 3D 中,就像它们放在 THREE 的场景上一样。但这也是有限制的,例如,您无法将文本置于 THREE.js 对象下。这仍然比尝试使用“顶部”和“左侧”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 展示位置文本标签,用于绘制太阳系中行星陪伴的非常小的元素。

音乐播放和循环播放

《质量效应》的《银河地图》期间播放的音乐是由生物软件作曲家山姆·胡利克和杰克·沃尔创作的,它具有我希望访客所体验的那种情感。我们希望在项目中加入一些音乐,因为我们认为音乐是氛围的重要组成部分,有助于营造我们试图实现的敬畏感和惊喜感。

我们的制作人 Valdean Klump 联系了 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 时可以有很大的改进,例如在单独的文件中定义 Material 数据(最好在某些情况下可以查看和调整),还可以重新添加到主项目中。

我们的同事 Ray McClure 也花时间创作了一些很棒的生成式“空间噪音”,由于 Web Audio API 不稳定,Chrome 经常崩溃,因此必须剪切这些噪声。很遗憾...但这无疑让我们在音乐领域有了更深入的思考,为未来的工作做好准备。在撰写本文时,我得知 Web Audio API 已经修补,因此现在可能正常运行,日后需要注意。

将排版元素与 WebGL 搭配使用仍然是一个挑战,而且我无法 100% 确定我们这样做的方法是否正确。这还是像个小菜。也许 THREE 的未来版本及其即将推出的 CSS 渲染程序可以用于更好地加入这两个世界。

赠金

感谢 Aaron Koblin 让我全身心投入这个项目。Jono Brandel,他在界面设计 + 实现、字体处理和导览实现方面都表现出色。因为瓦尔迪安·克朗普为项目指定了名称和所有文案。沙巴·艾哈迈德 (Sabah Ahmed) 清算了数据和图片来源的大量使用权。克莱姆·赖特 (Clem Wright) - 与合适的受众联系以发布内容。道格·弗里茨 (Doug Fritz) 追求卓越技术。George Brower 教我 JS 和 CSS。当然还有 THREE.js 的 Doob 先生。

参考