用Three.js可视化TSNE地图

2020-08-31 14:38:54

在过去一年左右的时间里,耶鲁大学的DHLab进行了一系列围绕视觉文化分析组织的实验。其中一些实验涉及识别相似的图像,并将这一过程中发现的模式可视化。在这篇文章中,我想讨论我们如何使用令人惊叹的Three.js库构建基于WebGL的可视化,可以在交互式3D环境中显示数以万计的图像[单击进入]:

如果您有兴趣创建类似的代码,请随时查看完整的代码。

Tree.js是一个JavaScript库,它为WebGL生成低级代码,WebGL是在Web浏览器中进行3D渲染的标准API。使用Three.js,人们可以构建复杂的3D环境,而在原始WebGL中构建这些环境需要更多的代码。有关其他人使用该库构建的项目的快速示例,请查看Three.js主页。

要开始使用Three.js,需要提供一些带有Three.js页面的三个基本元素的样板代码:

//指定场景中随时可见的部分(以度为单位)var fieldOfView=75;//指定摄像机的纵横比var aspectRatio=Window。内侧宽度/窗口。InnerHeight;//指定近剪裁平面和远剪裁平面。场景中将只渲染//这些平面之间的对象//(这些值有助于控制//在任何给定时间渲染的项目数)var near Plane=0.1;var farPlane=1000;//使用上面指定的值创建摄影机var Camera=new 3。PerspectiveCamera(fieldOfView,aspectRatio,Near Plane,FarPlane);//最后,设置相机在z维相机中的位置。位置。Z=5;

//使用渲染器创建画布,并告诉//渲染器清理锯齿状的别名线var renender=new Three。WebGLRenender({antialias:true});//指定画布渲染器的大小。SetSize(窗口。内侧宽度,窗。InnerHeight);//将画布添加到DOM文档。身体。AppendChild(渲染器。DomElement);

上面的代码创建场景,添加相机,并将画布渲染到DOM。现在我们需要做的就是向场景中添加一些对象。

在Three.js场景中渲染的每个项目都有一个几何体和一个材质。几何体使用顶点(点)和面(由顶点描述的多边形)来定义对象的形状,而材质使用纹理和颜色来定义该形状的外观。几何体和材质可以组合成网格,该网格是完全合成的对象,可以添加到场景中。

//创建宽度、高度和深度设置为1的立方体。变量几何=新的三。BoxGeometry(1,1,1);//使用简单材质,指定十六进制颜色变量Material=new 3。MeshBasicMaterial({color:0xffff00});//将几何体和材质组合成网格变量立方体=new Three。Mesh(几何体,材质);//将网格添加到场景场景。添加(立方体);

最后,要在页面上呈现场景,必须调用Render()方法,将场景和摄影机作为参数传入:

这很棒,但是场景是静止的。要向场景中添加一些动画,可以定期更新立方体的旋转属性,然后重新渲染场景。为此,可以将上面的renderer.ender()行替换为递归调用自身的呈现循环。下面是Three.js中的标准呈现循环:

函数Animate(){requestAnimationFrame(Animate);渲染器。Render(Scene,Camera);//每个动画帧立方体旋转对象一点。旋转。Y+=0.01;立方体。旋转。Z+=0.01;}Animate();

向场景添加灯光可以更容易区分立方体的面。要向上面的场景添加灯光,我们首先要更改立方体的材质,因为正如文档所述,MeshBasicMaterial不受灯光的影响。让我们将上面定义的材质替换为MeshPhongMaterial:

接下来,让我们将灯光指向立方体,以便立方体的不同面捕捉不同数量的光线:

//添加#FFF颜色、.7强度和0距离变量灯光的点光源=新3。PointLight(0xffffff,.。7,0);//指定光源在x、y和z维度光源中的位置。位置。Set(1,1,100);//将灯光添加到场景场景中。添加(灯光)。

上面的片段快速概述了Three.js场景的核心元素。下一节将以这些想法为基础来创建图像的TSNE地图。

要构建图像查看器,我们需要将一些图像文件加载到一些Three.js材质中。我们可以使用TextureLoader执行此操作:

//创建纹理加载器,这样我们就可以加载图像文件var loader=new Three。TextureLoader();//指定图像的路径var url=';https://s3.amazonaws.com/duhaime/blog/tsne-webgl/assets/cat.jpg';;//将图像文件加载到MeshLambert材质中var Material=new Three。MeshLambertMaterial({map:loader.。Load(Url)});

现在材质已准备好,剩下的步骤是从图像生成几何体,将材质和Geoemtry合并到网格中,然后将网格添加到场景中,就像上面的立方体示例一样。因为图像是二维平面,所以我们可以对该对象的几何体使用简单的Plane Geometry:

//为图像创建一个宽度为10//、高度保持图像的纵横比为新3的平面几何体(';Var GEOMETRY=NEW 3)。Plane Geometry(10,10*.。75);//将图像几何体和材质组合成网格变量Mesh=new 3。Mesh(几何体,材质);//设置图像网格在x,y,z维度网格中的位置。位置。Set(0,0,0)//将图像添加到场景场景中。添加(网格);

请参阅CodePen上Douglas Duhaime(@DuHaime)所著的The Pen Adding an Image to a Three.js Scene(The Pen Adding an Image to a Three.js Scene)。

值得注意的是,可以用PlanarGeometry替换其他几何体,Three.js会自动将材质包裹在新几何体上。例如,下面的示例将PlanarGeometry替换为更有趣的二十面体几何体,并在渲染循环内旋转二十面体:

//使用二十面体几何体,而不是平面几何体变量GEOMETRY=new Three。IcosahedronGeometry();//在每个动画帧函数Animate(){requestAnimationFrame(Animate);渲染器上旋转二十面体。渲染(场景、摄影机);网格化。旋转。X+=0.01;网格。旋转。Y+=0.01;网格。旋转。Z+=0.01;}Animate();

上面的示例使用了几个内置到Three.js中的不同几何图形。这些几何图形基于基本的THREE.Geometry类,这是一个可以用来创建自定义几何图形的基本几何图形。THREE.Geometry比上面使用的预构建几何体级别更低,但它提供了性能提升,这使它值得付出努力。

让我们通过调用不带参数的THREE.Geometry构造函数来创建自定义几何体:

此几何体对象尚未执行很多操作,因为它没有任何顶点可用来细化图形。让我们向几何体添加四个顶点,每个顶点对应于图像的每个角。每个顶点都有三个参数,分别定义顶点的x、y和z位置:

//识别图片的宽度和高度var imageSize={width:10,Height:7.5};//识别图片应该放置的x,y,z坐标//场景变量坐标={x:-5,y:-3.75,z:0};//按照左下角、右下角、右上角、左上角的顺序添加一个顶点。顶点。推送(新三号。向量3(和弦。X,坐标。Y,坐标。Z),新的三个。向量3(和弦。X+imageSize。宽度,余弦。Y,坐标。Z),新的三个。向量3(和弦。X+imageSize。宽度,余弦。Y+ImageSize。高度,坐标。Z),新的三个。向量3(和弦。X,坐标。Y+ImageSize。高度,坐标。Z);

现在顶点已就位,我们需要向几何体添加一些面。下面的代码将图像建模为两个三角面,因为三角形在WebGL世界中是可执行的基本体。第一个三角形将组合图像的左下角、右下角和右上角顶点,第二个三角形将对图像的左下角、右上角和左上角顶点进行三角剖分:

//添加第一个面(右下角三角形)var faceOne=new Three。面3(几何体。顶点。长度-4,几何体。顶点。长度-3,几何体。顶点。Length-2)//添加第二个面(左上角三角形)var faceTwo=new Three。面3(几何体。顶点。长度-4,几何体。顶点。长度-2,几何体。顶点。LENGTH-1)//将这些面添加到几何体几何体。面孔。PUSH(faceOne,faceTwo);

太棒了,我们现在有一个几何体,它有四个顶点来描述图像的角,两个面来描述图像的右下角和左上角三角形。下一步是描述CAT图像的哪些部分应该出现在几何体的每个面中。若要执行此操作,必须将一些faceVertexUV添加到几何体,因为faceVertexUV指示纹理的哪些部分应显示在几何体的哪些部分中。

面顶点UV将纹理表示为二维平面,该平面在x维度上从0延伸到1,在y维度上从0延伸到1。在该坐标系中,0,0表示纹理的左下角区域,1,1表示纹理的右上角区域。给定此坐标系,我们可以将图像的右下角三角形映射到上面创建的第一个面,并且可以将图像的左上角三角形映射到上面创建的第二个面:

//将左下角、//右下角和右上角顶点所描述的图像区域映射到几何体//的第一个面。FaceVertexUvs[0]。推([新三.。向量2(0,0),新三。向量2(1,0),新的3。Vector2(1,1)]));//将左下角、//右上角和左上角顶点所描述的图像区域映射到几何体的第二面//。FaceVertexUvs[0]。推([新三.。向量2(0,0),新三。向量2(1,1),新的3。向量2(0,1)]);

UV坐标就位后,用户可以如上所述在场景中渲染自定义几何体:

对于上面使用一行PlanarGeometry声明所实现的相同结果,这看起来似乎需要做很多工作。如果一个场景只需要一个图像,而不需要其他任何图像,那么您当然可以使用PlanarGeometry就到此为止。

但是,添加到Three.js场景的每个网格都需要额外的“绘制调用”,并且每个绘制调用都需要浏览器代理的CPU将所有与网格相关的数据发送到浏览器代理的GPU。这些绘制调用在每个动画帧期间针对每个网格发生,因此如果场景以每秒60帧的速度运行,则该场景中的每个网格将需要每秒将数据从CPU传输到GPU 60次。简而言之,更多的绘制调用意味着主机设备的工作更多,因此,如果要保持动画流畅且接近每秒60帧,则减少绘制调用的数量是必不可少的。

所有这一切的结果是,包含数万个PlanarGeometry网格的场景将使浏览器陷入停顿。若要在场景中渲染大量图像,使用上图所示的自定义几何体,并将大量顶点、面和顶点UV推入该几何体中,效果会好得多。我们将在下面更多地探讨这个想法。

鉴于上面的说明,接下来让我们构建包含多个图像的单个几何体。为此,我们需要将许多图像加载到运行场景的页面中。完成此任务的一种方法是将一系列URL传递给纹理加载器,然后分别加载每个图像。这种方法的问题在于,它需要为每个要加载的图像发出一个新的HTTP请求,并且给定浏览器一次可以向给定域发出的HTTP请求的数量有上限。

此问题的常见解决方案是加载“图像图集”,即将小图像组合成单个较大图像的蒙太奇:

然后,人们可以像Google这样注重性能的网站使用spritesheet一样使用蒙太奇。如果安装了ImageMagick,则可以使用剪辑命令创建以下剪辑之一:

#图像下载目录wget https://s3.amazonaws.com/duhaime/blog/tsne-webgl/data/100-imgs.tar.gz tar-zxf100-imgs.tar.gz#创建列出要包含在剪辑中的所有文件的文件ls 100-imgs/*>;image_to_montage.txt#从目录中的图像创建单个剪辑图像`cat image_to_montage.txt`-Geometry+0+0-背景非平铺10x100-img-atlas.jpg

最后一个命令将创建一个图像地图集,每列有10个图像,并且图集中的图像之间没有填充。示例目录100-imgs.tar.gz包含100幅图像,蒙太奇命令中的-tile参数指示输出图集应该有10列,因此上面的命令将生成大小为1280px x 1280px的10x10网格。

//创建纹理加载器,这样我们就可以加载图像文件var loader=new Three。TextureLoader();//将图像文件加载到自定义材质var Material=new Three中。MeshBasicMaterial({map:loader.。Load(';https://s3.amazonaws.com/duhaime/blog/tsne-webgl/data/100-img-atlas.jpg';)});

一旦加载了图像地图集,我们就需要创建一些辅助对象来标识地图集及其子图像的大小。然后可以使用这些辅助对象来计算几何体中每个面的顶点UV:

//标识px var image={width:128,Height:128}中的子图大小;//标识图像图集var atlas={width:1280,Height:1280,cols:10,row:10}的总行数(&;R);

上面的自定义几何体示例使用四个顶点和两个面来渲染单个图像。要表示图像图集中的所有100个图像,我们可以为要显示的100个图像中的每一个创建四个顶点和两个面。然后,我们可以将图像贴图集材质的适当区域与几何体的200个面中的每个面相关联:

//创建一个返回int{-700700}的助手函数。//我们将使用此函数设置每个子图像的x和//y坐标位置函数getRandomInt(){var val=Math。随机()*700;返回数学。Random()>;0.5?-val:val;}//创建空几何体变量GEOMETRY=new 3。Geometry();//对于剪辑中的100个子图像中的每一个,按以下顺序添加四个//顶点(每个角一个)://(var i=0;i<;100;i++){//为该子图像创建x,y,z坐标var cocords={x:getRandomInt(),y:getRandomInt(),z:-400};GEOMETRY://为(var i=0;i<;100;i++){//为该子图像创建x,y,z坐标={x:getRandomInt(),y:getRandomInt(),z:-400};GEOMETRY。顶点。推送(新三号。向量3(和弦。X,坐标。Y,坐标。Z),新的三个。向量3(和弦。X+图像。宽度,余弦。Y,坐标。Z),新的三个。向量3(和弦。X+图像。宽度,余弦。Y+图像。高度,坐标。Z),新的三个。向量3(和弦。X,坐标。Y+图像。高度,坐标。Z));//添加第一个面(右下角三角形)var faceOne=new Three。面3(几何体。顶点。长度-4,几何体。顶点。长度-3,几何体。顶点。Length-2)//添加第二个面(左上角三角形)var faceTwo=new Three。面3(几何体。顶点。长度-4,几何体。顶点。长度-2,几何体。顶点。LENGTH-1)//将这些面添加到几何体几何体。面孔。Push(faceOne,faceTwo);//识别该子图像在x维度上的偏移//xOffset为0表示子图像开始与//地图集的左边缘齐平变量xOffset=(I%10)*(图像。宽度/地图集。Width);//标识子图像在y维度中的偏移//yOffset为0表示子图像开始与//地图集var yOffset=Math的顶边齐平。楼层(I/10)*(图像。高度/地图集。高度);//使用xOffset和yOffset(以及知道//每行和每列只包含10张图像)来指定//当前图像几何图形的区域。FaceVertexUvs[0]。推([新三.。向量2(xOffset,yOffset),新的三个。矢量2(xOffset+.。1,yOffset),新的三个。矢量2(xOffset+.。1,yOffset+。1)]);//将左下角、//右上角、左上角顶点所描述的图像区域映射到`faceTwo`几何体。FaceVertexUvs[0]。推([新三.。向量2(xOffset,yOffset),新的三个。矢量2(xOffset+.。1,yOffset+。1),新三个。矢量2(xOffset,yOffset+。1)]));}//将图像几何体和材质组合成网格变量Mesh=new Three。Mesh(几何体,材质);//设置图像网格在x,y,z维度网格中的位置。位置。Set(0,0,0)//将图像添加到场景场景中。添加(网格);

在这里,我们仅用一个网格表示100个图像!这比为每个图像提供自己的网格要好得多,因为它将所需的绘制调用次数减少了两个数量级。然而,值得注意的是,最终确实需要创建额外的网格。许多图形设备在单个网格中只能处理2^16个顶点,因此,如果需要在多种设备上运行场景,最好确保每个网格包含65,536个或更少的顶点。

了解了如何使用单个网格可视化多个图像之后,我们现在可以放大图像集合的

增加可视化图像数量的一种方法是将更多图像压缩到图像图集中。然而,事实证明,许多设备支持的最大纹理大小是2048x2048px,因此下面的代码将坚持使用该大小的atlas文件。

对于下面的示例,我拍摄了大约20,480张图片,将每张图片的大小调整到32px拇指,然后使用上面讨论的蒙太奇技术构建以下图集文件:1、2、3、4、5。一旦这些图集文件加载到静态文件服务器上,就可以通过一个简单的循环将每个图集加载到场景中:

//创建一个存储,将每个atlas文件的索引位置//映射到其材质var Materials={};//创建一个纹理加载器,以便我们可以加载图像文件var loader=new Three。TextureLoader();for(var i=0;i<;5;i++){var url=';https://s3.amazonaws.com/duhaime/blog/tsne-webgl/data/atlas_files/32px/atlas-';+i+39;.jpg;加载器。加载(url,handleTexture。Bind(null,i));}//将纹理添加到纹理列表的回调函数//如果所有纹理都加载了函数handleTexture(idx,Texture){Material[IDX]=new Three,则调用几何构建器。MeshBasicMaterial({map:Texture})If(Object.。钥匙(材质)。长度=5){文档。QuerySelector(';#正在加载';)。风格。Display=';无';;buildGeometry();}}。

然后,buildGeometry函数将为贴图集文件中的20,000个图像创建顶点和面。设置这些后,用户可以将这些几何体泵入某些网格,并将网格添加到场景中(单击代码链接以获取完整的代码更新):

到目前为止,我们已经使用随机坐标在场景中放置图像。现在让我们将图像放置在其他外观相似的图像附近。为此,我们将创建每个图像的矢量化表示,将这些向量向下投影到2D嵌入中,然后使用每个图像在2D坐标空间中的位置来定位。

.