制造 100,000 颗星星
你好! 我叫 Michael Chang,在 Google 的数据艺术团队工作。 最近,我们完成 100,000 Stars ,这是一个 Chrome 实验 可视化附近星星 该项目是用 THREE.js 和 CSS3D 构建的。 在这个案例研究中,我将概述发现过程,分享一些编程技术,并提出一些未来改进的想法。
这里讨论的主题相当广泛,并且需要一些 THREE.js 的知识,尽管我希望你仍然可以享受这作为技术事后分析的乐趣。 使用右侧的目录按钮随意跳转到感兴趣的区域。 首先,我将展示项目的渲染部分,然后是着色器管理,最后是如何结合使用 CSS 文本标签和 WebGL。
100,000 Stars 使用 THREE.js 可视化银河系附近的恒星
发现空间
后不久 Small Arms Globe ,我正在试验一个带有景深的 THREE.js 粒子演示。 我注意到我可以通过调整应用效果的数量来改变场景的解释“比例”。 当景深效果非常极端时,远处的物体变得非常模糊,类似于移轴摄影的工作方式,给人一种看微观场景的错觉。 相反,关闭效果会使您看起来好像在凝视深空。
我开始寻找可以用来注入粒子位置的数据,一条通往 astronexus.com 的 HYG 数据库的路径,以及三个数据源(Hipparcos、耶鲁亮星目录和 Gliese/Jahreiss 目录)的汇编通过预先计算的 xyz 笛卡尔坐标。 让我们开始!
第一步是将目录中的每颗恒星绘制为单个粒子。
目录中的一些星星有专有名称,在此处标记。
花了大约一个小时才把星星数据放在 3D 空间中。 数据集中正好有 119,617 颗恒星,因此用粒子表示每颗恒星对于现代 GPU 来说不是问题。 还有 87 颗单独识别的星星,所以我使用我 描述 在 Small Arms Globe 中
在此期间,我刚刚完成了 质量效应 系列。 在游戏中,玩家被邀请探索银河系并 扫描各个行星,并阅读 它们完全虚构的、听起来像维基百科的历史:在这个星球上繁衍生息的物种、它的地质历史等等。
了解有关恒星的大量实际数据后,可以想象以同样的方式呈现有关银河系的真实信息。 该项目的最终目标是让这些数据栩栩如生,让观众通过质量效应探索星系,了解恒星及其分布,并希望激发对太空的敬畏感和好奇感。 呸!
在本案例研究的其余部分前,我可能应该说 我绝不是天文学家,这是 由外部专家的一些建议支持的业余研究工作。 这个项目绝对应该被解释为艺术家对空间的诠释。
建造银河
我的计划是通过程序生成一个星系模型,可以将恒星数据放在上下文中——并希望能对我们在银河系中的位置提供一个很棒的视图。
银河系粒子系统的早期原型。
为了生成银河系,我生成了 100,000 个粒子,并通过模拟银河臂的形成方式将它们放置在一个螺旋中。 我不太担心螺旋臂形成的细节,因为这将是一个代表性模型而不是数学模型。 然而,我确实试图让旋臂的数量或多或少正确,并朝着“正确的方向”旋转。
在银河系模型的后期版本中,我不再强调粒子的使用,而是使用星系的平面图像来伴随粒子,希望给它更多的摄影外观。 银河系 。 距离我们大约 7000 万光年的螺旋星系 NGC 1232,经过图像处理后看起来像
每个 GL 单位是一光年。 在这种情况下,球体的宽度为 110,000 光年,包含了粒子系统。
我很早就决定将一个 GL 单元(基本上是 3D 中的一个像素)表示为一光年——这是一种统一所有可视化位置的约定,但不幸的是,后来给我带来了严重的精度问题。
我决定的另一个惯例是旋转整个场景而不是移动相机,这是我在其他几个项目中所做的。 一个优点是所有东西都放在一个“转盘”上,这样鼠标左右拖动就可以旋转有问题的对象,但放大只是改变 camera.position.z 的问题。
摄像机的视野(或 FOV)也是动态的。 当一个人向外拉时,视野变宽,越来越多的银河系被吸收。 当向内朝向恒星移动时,情况正好相反,视野变窄。 这允许相机通过将 FOV 压缩到神一样的放大镜来查看无限小的事物(与银河系相比),而无需处理近平面剪裁问题。
(上图)早期粒子星系。 (下)带有图像平面的粒子。
从这里我能够将太阳“放置”在远离银河核心的一些单位。 的半径来可视化太阳系的相对大小 柯伊伯悬崖 (我最终选择可视化 奥尔特云 )。 在这个太阳系模型中,我还可以将地球的简化轨道形象化,并与太阳的实际半径进行比较。
太阳由行星和代表柯伊伯带的球体环绕。
太阳很难渲染。 我不得不用我所知道的尽可能多的实时图形技术作弊。 太阳表面是等离子体的热泡沫,需要随时间脉动和变化。 这是通过太阳表面红外图像的位图纹理模拟的。 表面着色器基于此纹理的灰度进行颜色查找,并在单独的色带中执行查找。 当这种查找随着时间的推移而发生变化时,它会产生这种熔岩般的扭曲。
太阳的日冕也使用了类似的技术,只是它是一个平面精灵卡,总是使用 THREE.Gyroscope() 。
太阳的早期版本。
太阳耀斑是通过应用于圆环的顶点和片段着色器创建的,围绕太阳表面的边缘旋转。 顶点着色器具有噪声函数,使其以类似斑点的方式编织。
正是在这里,由于 GL 精度,我开始遇到一些 z-fighting 问题。 所有精度变量都是在 THREE.js 中预定义的,所以如果没有大量工作,我实际上无法提高精度。 精度问题在原点附近并没有那么糟糕。 然而,一旦我开始对其他恒星系统进行建模,这就会成为一个问题。
渲染太阳的代码后来被推广到渲染其他恒星。
我采用了一些技巧来减轻 z 战斗。 三的 Material.polygonoffset 是一个属性,它允许在不同的感知位置渲染多边形(据我所知)。 这用于强制日冕平面始终渲染在太阳表面之上。 在此之下,渲染了一个太阳“光晕”,以提供远离球体的尖锐光线。
另一个与精度相关的问题是,随着场景的放大,星形模型会开始抖动。为了解决这个问题,我必须将场景旋转“归零”并分别旋转星形模型和环境贴图,以产生您正在绕轨道运行的错觉星。
创建镜头光晕
拥有权利的同时也被赋予了重大的责任。
空间可视化是我觉得我可以摆脱过度使用 lensflare 的地方。 THREE.LensFlare 服务于这个目的,我需要做的就是加入一些变形六边形和一些 JJ Abrams 。 下面的片段显示了如何在场景中构建它们。
// This function retuns 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 制服中,您可以像这样将着色器传递给它:
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 的相机和场景相匹配,您可以将 CSS 元素“放置”在 3D 中,就好像它在 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 “丢失”其从场景中继承的方向。 这种技术被称为“广告牌”,而 Gyroscope 非常适合这样做。
真正令人高兴的是,所有正常的 DOM 和 CSS 仍然可以播放,例如能够将鼠标悬停在 3D 文本标签上并使其带有阴影。
通过将文本标签附加到 THREE.Gyroscope(),让文本标签始终面向相机。
放大时,我发现排版的缩放会导致定位问题。 也许这是由于文本的字距和填充? 另一个问题是文本在放大时变得像素化,因为 DOM 渲染器将渲染的文本视为纹理四边形,使用此方法时需要注意这一点。 回想起来,我本可以使用巨大的字体大小的文本,也许这对未来的探索有好处。 在这个项目中,我还使用了前面描述的“顶部/左侧”CSS 放置文本标签,用于太阳系中伴随行星的非常小的元素。
音乐播放和循环
在质量效应的“银河地图”中播放的音乐是由 Bioware 作曲家 Sam Hulick 和 Jack Wall 创作的,它具有我希望参观者体验的那种情感。 我们想要在我们的项目中加入一些音乐,因为我们觉得它是氛围的重要组成部分,有助于营造我们努力追求的敬畏感和惊奇感。
我们的制作人 Valdean Klump 联系了 Sam,他有一堆来自《质量效应》的“cutting floor”音乐,他非常慷慨地让我们使用。 这首歌的标题是“在陌生的土地上”。
我使用音频标签来播放音乐,但即使在 Chrome 中,“循环”属性也不可靠——有时它会循环失败。 最后,这个双音频标签黑客被用来检查播放结束并循环到另一个标签进行播放。 令人失望的是,这个*仍然*并不是一直完美循环,唉,我觉得这是我能做的最好的。
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 也花了一些时间创造了一些令人敬畏的生成“空间噪音”,由于网络音频 API 不稳定,因此不得不削减这些噪音,经常让 Chrome 崩溃。 很不幸……但它确实让我们对未来工作的声音空间进行了更多思考。 在撰写本文时,我被告知 Web Audio API 已被修补,因此它现在可能正在工作,未来需要注意。
与 WebGL 配对的印刷元素仍然是一个挑战,我不能 100% 确定我们在这里所做的是正确的方法。 它仍然感觉像一个黑客。 也许未来的 THREE 版本及其 即将推出的 CSS Renderer 可以用来更好地加入这两个世界。
参考
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: 视差效果介绍和使用
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论