使用HTML和CSS构建三维世界
去年,我做了一个程序示例,来展示CSS的三维转换功能如何被用于创建三维环境的。在当时,这个例子是一个关于通过CSS能够达到什么效果的示例,但是我想要看自己能在这件事情上走多远,所以,在过去的这几个月中,我做了一个新版本。该版本有更多复杂的模型、更真实的光线、阴影和冲突检测机制。本文记录了我的制作方法以及使用的技术。
创建一个三维对象
在当今的3D引擎中,一个对象图形被存储在类似于一个点(或定点)的集合中,每个点都有一个XYZ属性,用来定义它在三维空间中的位置。例如,通过4个顶点定义一个矩形,每个顶点都对应一个角。每一个角都能被单独操纵,通过在xyz方向上移动顶点来将矩形拉伸到不同形状。3D引擎会使用这种顶点数据和一些巧妙的算法,来实现在二维屏幕上展示三维元素。
通过CSS转换,这些完全被颠覆了。由于HTML元素几乎都是矩形,它用两个空间属性,类似于上边界、左边界、宽度和高度,来决定它的位置和大小,因而我们不能通过一系列的点来定义任意的形状。大多数情况下,这让我们处理三维起来更加容易,因为不需要考虑太多复杂的算法,只是实现一个CSS转换就能让一个元素沿着一个轴旋转,你做到了。
纵使一开始通过矩形创建一个元素看起来有非常多的限制,但是通过它们,你也能做到一系列令人惊叹的东西,特别是你开始使用PNG阿尔法通道的时候。在如下图片中,你能看见虽然是由矩形组成的,桶的顶部和轮子都还显得很圆滑。
一个完全由矩形 < div>元素建立的三维对象的实例
通过JavaScript使用一个小的集合函数就创建了所有的元素,这个集合函数用来创建一个原始的矩形。你能创建的最简单的对象就是一个平面,它也基本上是一个元素。平面能够被动态的放入元素,(一个容器< div>元素)允许所有元素被当作一个单独旋转和移动。一个管道可被当作一个沿着轴旋转的动态包含平面,一个桶就是一个管道加上上平面和另一个下平面的组合。
下面的例子展示了这个练习,看看JS选项页的内容:
See the Pen 3D objects in CSS by Keith Clark (@keithclark) on CodePen.
一个用矩形<div>元素建立的三维油桶
光线
光线是这个项目中最大的困难。我没有撒谎,数学几乎伤害到了我,但是这是值得的。因为光线带来了一个难以置信的纵深和氛围的感觉,而不是一个平面的毫无生气的环境。 一个无灯光的房间的屏幕截图 如我之前提到的,在普通的三维引擎中我们用一系列的顶点来定义一个对象。为了计算出光线,这些定点需要计算出一个标准(normal),该标准能够决定一个表面中心点所受多少光照。但这却带来了一个麻烦,因为当我们使用HTML创建三维对象时,这些顶点并不存在。所以,第一个挑战就是让光线变得可计算,为此我需要写一系列的方法来计算一个对象已经被CSS转换了的四个顶点(每个角一个)。一旦这些明朗起来,我就开始试验用不同的方法点亮对象。第一个试验中,我是用多背景图片来模拟光线照射到一个表面,通过叠加线性渐变和图片实现。使用一个渐变在开始和结束的位置使用相同的RGBA值,减少了僵硬的颜色快。改变阿尔法通道的值让底层图片渗出颜色块,也创造出了明暗的感觉。 使用渐变使质地带有阴影效果的例子 为了达到上述图片中第二黑暗的效果,我对元素使用了如下的样式:
1 2 3 |
element { background: linear-gradient(rgba(0,0,0,.8), rgba(0,0,0,.8)), url("texture.png"); } |
在试验中,这些样式并不是在样式表中预先定义好的,而是使用JavaScript动态计算和直接加载到元素的样式属性上去的。
See the Pen 3D objects in CSS with shading by Keith Clark (@keithclark) on CodePen.
一个平面阴影的三维油桶
该技术与平面阴影有关。这是一个产生阴影的有效方法,然而,这会让一整个表面都有同样的细节。例如,如果我创建一个延伸到远处的三维墙,它的整个长度上的阴影都会是一致的。我需要某些看起来更加真实的效果。
光照的第二次尝试
对于一个仿真的光照,表面需要在他们延伸到超过光照区域时候变暗。而且如果受到多重光线的照射,表面必须相应产生变化的阴影。
要给表面加上平面阴影,我只需要计算照射到中心点的光线量。但是现在我需要尝试表面上各个点的光线,来决定每个点的明暗程度。创造这个光照数据所使用的数学方法和平面阴影是完全相同的。
一开始,我在早先的线性渐变的光照数据的尝试中加入径向渐变的效果。结果倒是更加真实,但是多重光线仍旧是一个问题,因为更多层渐变相互叠加,会导致底图越来越黑。如果CSS能够支持图案合成和混合模式(它们来了),才可能让径向渐变发挥作用。
解决方法是使用一个<canvas>元素用编程的方式实现一个新的底图,这个底图能够被当作一个光线图使用。通过计算的光线数据,我能够画出一系列黑像素,根据光线将会照射到表面上这个点的量,改变每个像素的阿尔法通道。最后,使用canvas.toDataURL()方法将图像编码,然后用在第一个试验中我使用线性渐变的地方。重复这个过程,直到一个真实的光线效果覆盖整个环境。
计算和绘制这些底图是一个强化工作。地下室和地板都是1000 x 2000像素大小,创建一个底图来覆盖这些区域并不实际。所以我只是简单的照亮每12个像素,这样就产生了一个只有它覆盖的表面1/12大小的面积。设置背景大小100%会让浏览器使用双线性(或类似的)过滤器缩放背景图片,这样它就适应了表面区域。缩放影响导致了光线图生成的每个单独像素几乎渐变的结果。
用来实现一个表面的光线图和底图背景样式的规则大概像这样子:
1 2 3 |
element { background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACoAAAAyCAYAAAAqRkmtAAACiUlEQVRoQ9VZa0vEQAy8+/8/yPcbFVFEEREREcS/4eNSmpKmmU12t/XYD6W3beGmk8kk2a5Xq9XR5vjeHD+B47d/xjrTtdSxud3dl+d+OTmt+yvDmX4c9n8eAbtVoAeCyRRYBknMb4XRfRVyC6wEqYFK0Cj0HG4OfSr8HG56ZhR6AhoJO2u4JPxInxK4BDYCyYu9YOglk/xbsymvy8RhpjlrNEArqWRCrWlBQEsYRWC98EfAmlm/W8FoBKzWp2aT11KbZugJ6NKMzpJMFlANPJX1Wpdyjdj0NKozv9PoTjD0pRYVDb2b9VGgVtZHNKrLpuelEzb5DRhoRKel1alGox1wGfpmgFIYPbCIUa+MRiuTpdMJox7QrSdTM/bUlOFzZ7SERqM+6pbQZpqSZtq8ZhrnZkaRZoa7ZsblZjYgjgM1vmYClYOd1+LBnpRM9iQItLTFQ/0o6vLhXH9aCTTaOWnAehp1K9NZP4rUbufo2UnP9ajV82b6Tg70FudiZtKtHmrtoiOIpU9PpzD0Fwoo2n6sbZo9gJJZcwq9DGi0JpFyhjuoU7pxtSCjtXP9aDfv2gFaOoJofaKst5JJ+ukwM91kMirtyMp0a5MsOtyZicSobxMaLd3K0dZksZlt+HcZoUdsImZTY0g20PtA6OeypohFQR99MCqTDnnqA0NKp7My+ggYjerz34A+LQRUsolCrnWaNPznSqCoeyo1e/bV0T4+LV4KgCJwJAPNZI41WfV+MPzXAFDLluY2e83kqDoR2jcD6ByJhJoRrUtea31OgL4roCU9aORDWMRDIav0Fh890JR3lnz/1GVU1nsGlJX1n4UaRQkV6ZpQ+Uwm05ej0TkSycp8i2Hoo3+utUtvDhk9pwAAAABJRU5ErkJggg==") 0 0 / 100% 100%, url("texture.png") 0 0 / auto auto; } |
产生最终光线表面的样子如下:
可视化的低分辨率光线图等比放大后,叠加到底图上
投射阴影
随着光线通过canvas的解决,也让投射阴影变得可能。阴影投射背后的逻辑也变得更加容易。通过按离光源的距离来安排表面,让我不仅仅要为一个表面产生一个光线图,同时也要判断是否在该表面的前面有一个表面已经被当前的光线照射到了。如果是被挡住,我就会设置相关光线图上的像素为阴影。这种技术让一张图片能够在光照和阴影两种情况下使用。
一张最终的拥有光线和阴影的房间的截图
碰撞
碰撞检查使用一个高度图——一个自顶向下的图片,在其中用颜色来描绘物体高度。用白色代表最低,黑色代表最高玩家能达到的位置。当玩家在平面移动时,我将他们的位置转换为二维坐标,然后用这些坐标来检查高度图中的颜色。高度图中的颜色值是否比玩家最后一次下降的位置更亮,颜色是否比玩家能够走进或跳入的对象稍微暗一点。如果颜色较暗,玩家就会被停阻止,我一般用这种颜色来作为墙和围栏。现在,这幅图片是手工制作的,但是我将会寻找自动创建的方法来完成它。
高度图的图片和我如何将它和平面进行关联
接下来是什么?
当然,这个项目接下来自然就成为一个游戏,这项技术能够有多大的可扩展性值得期待。简而言之,我已经开始在卓越的Three.js上使用一个标准的CSS3渲染器,这个JS库使用类似的技术通过真实的三维引擎来渲染几何体和光线。