案例研究 - 走进奥兹国

unit9 com
unit9 com

简介

“Find Your Way to Oz” 是迪士尼在网络上推出的一项全新 Google Chrome 实验。您可以通过这款游戏以互动方式游览堪萨斯州马戏团,并在被一场巨大风暴卷走后前往奥兹国。

我们的目标是将影院的丰富性与浏览器的技术能力相结合,打造一种有趣的沉浸式体验,让用户能够与之建立强烈的联系。

这项工作太过庞大,无法在本文中完整呈现,因此我们深入研究,从中抽出一些我们认为有趣的技术故事章节。在此过程中,我们提炼出了一些难度逐渐递增的专题教程。

许多人为此付出了辛勤的努力,在此无法一一列举。请访问该网站,查看菜单部分下的制作人员名单页面,了解完整故事。

解密底层原理

桌面版《Find Your Way to Oz》是一个丰富的沉浸式世界。我们使用 3D 和多层传统电影制作效果,将其组合起来营造出逼真的场景。最显眼的技术是 WebGL 与 Three.js、自定义构建的着色器以及使用 CSS3 功能的 DOM 动画元素。此外,getUserMedia API (WebRTC) 可用于交互式体验,让用户能够直接通过摄像头添加自己的图片,并通过 WebAudio 实现 3D 音效。

但这类技术体验的魅力就在于其相辅相成。这也是主要挑战之一:如何在一个场景中将视觉效果和交互元素融合在一起,以打造一个一致的整体?这种视觉复杂性很难管理,很难看出我们在开发的哪个阶段。

为了解决视觉效果和优化之间相互关联的问题,我们大量使用了控制台,该控制台会捕获我们当时正在审核的所有相关设置。用户可以在浏览器中实时调整场景的任何内容,包括亮度、景深、伽玛等。任何人都可以尝试调整体验中重要参数的值,并参与发现最佳效果的活动。

在分享我们的秘诀之前,我们想提醒您,它可能会崩溃,就像您在汽车引擎内部探查一样。请确保您没有任何重要事务,然后访问该网站的主网址,并将 ?debug=on 附加到该网址。等待网站加载完毕,进入后按 Ctrl-I 键,您会在右侧看到一个下拉菜单。如果您取消选中“退出摄像头路径”选项,则可以使用 A、W、S、D 键和鼠标在空间中自由移动。

相机路径。

我们不会在此处介绍所有设置,但建议您进行实验:这些按键会在不同场景中显示不同的设置。在最后的暴风序列中,有一个额外的按键:Ctrl-A,您可以使用它来切换动画播放和四处飞行。在此场景中,如果您按 Esc(退出鼠标锁定功能),然后再次按 Ctrl-I,则可以访问特定于风暴场景的设置。环顾四周,拍摄一些像下面这样美丽的明信片风景。

风暴场景

为了实现这一点并确保其足够灵活地满足我们的需求,我们使用了一个名为 dat.gui 的优秀库(点击此处可查看有关如何使用它的旧版教程)。这样,我们就可以快速更改向网站访问者显示的设置。

有点像平面设计

在许多经典的迪士尼电影和动画中,场景的制作意味着要组合不同的图层。其中包含多层实景、单格动画,甚至实体布景,以及在玻璃上绘制而成的顶层:这种技术称为“Matte-painting”。

在许多方面,我们打造的体验结构都很相似;即使某些“层”远远不止是静态视觉效果。事实上,它们会根据更复杂的计算来影响物体的外观。不过,至少在宏观层面上,我们要处理的是堆叠在一起的视图。顶部显示的是界面层,下方是 3D 场景:该场景本身由不同的场景组件组成。

顶层界面层是使用 DOM 和 CSS 3 创建的,这意味着,可以通过多种方式独立于 3D 体验来修改互动,并且这两者之间可以根据所选事件列表进行通信。此通信使用 Backbone Router + onHashChange HTML5 事件,用于控制哪个区域应进行动画进入/退出。(项目源代码:/develop/coffee/router/Router.coffee)。

教程:精灵贴片和 Retina 支持

我们在界面中采用了一种有趣的优化技术,即将许多界面叠加图像合并到一个 PNG 中,以减少服务器请求。在此项目中,界面由 70 多张图片(不包括 3D 纹理)组成,这些图片会预先加载,以缩短网站的延迟时间。您可以在此处查看实时精灵图片表:

普通显示屏 - http://findyourwaytooz.com/img/home/interface_1x.png Retina 显示屏 - http://findyourwaytooz.com/img/home/interface_2x.png

下面列出了一些提示,介绍了我们如何充分利用精灵贴图,以及如何在视网膜设备上使用精灵贴图,以使界面尽可能清晰整洁。

创建精灵贴图

如需创建精灵贴图,我们使用了 TexturePacker,它可以输出您需要的任何格式。在本例中,我们将其导出为 EaselJS,这种格式非常简洁,还可以用于创建动画精灵。

使用生成的 Sprite 图片集

创建精灵贴图后,您应该会看到一个类似于以下内容的 JSON 文件:

{
   "images": ["interface_2x.png"],
   "frames": [
       [2, 1837, 88, 130],
       [2, 2, 1472, 112],
       [1008, 774, 70, 68],
       [562, 1960, 86, 86],
       [473, 1960, 86, 86]
   ],

   "animations": {
       "allow_web":[0],
       "bottomheader":[1],
       "button_close":[2],
       "button_facebook":[3],
       "button_google":[4]
   },
}

其中:

  • image 是指雪碧图的网址
  • 帧是每个界面元素的坐标 [x, y, width, height]
  • 动画是每个素材资源的名称

请注意,我们使用高密度图片创建了精灵贴片,然后只需将其大小调整为原来的一半,即可创建正常版本。

小结

现在,一切都已准备就绪,我们只需一个 JavaScript 代码段即可使用它。

var SSAsset = function (asset, div) {
  var css, x, y, w, h;

  // Divide the coordinates by 2 as retina devices have 2x density
  x = Math.round(asset.x / 2);
  y = Math.round(asset.y / 2);
  w = Math.round(asset.width / 2);
  h = Math.round(asset.height / 2);

  // Create an Object to store CSS attributes
  css = {
    width                : w,
    height               : h,
    'background-image'   : "url(" + asset.image_1x_url + ")",
    'background-size'    : "" + asset.fullSize[0] + "px " + asset.fullSize[1] + "px",
    'background-position': "-" + x + "px -" + y + "px"
  };

  // If retina devices

  if (window.devicePixelRatio === 2) {

    /*
    set -webkit-image-set
    for 1x and 2x
    All the calculations of X, Y, WIDTH and HEIGHT is taken care by the browser
    */

    css['background-image'] = "-webkit-image-set(url(" + asset.image_1x_url + ") 1x,";
    css['background-image'] += "url(" + asset.image_2x_url + ") 2x)";

  }

  // Set the CSS to the DIV
  div.css(css);
};

具体使用方法如下:

logo = new SSAsset(
{
  fullSize     : [1024, 1024],               // image 1x dimensions Array [x,y]
  x            : 1790,                       // asset x coordinate on SpriteSheet         
  y            : 603,                        // asset y coordinate on SpriteSheet
  width        : 122,                        // asset width
  height       : 150,                        // asset height
  image_1x_url : 'img/spritesheet_1x.png',   // background image 1x URL
  image_2x_url : 'img/spritesheet_2x.png'    // background image 2x URL
},$('#logo'));

如需详细了解可变像素密度,您可以参阅 Boris Smus 撰写的这篇文章

3D 内容流水线

环境体验是在 WebGL 图层上设置的。在构思 3D 场景时,最棘手的问题之一是如何确保您可以创作出在建模、动画和特效方面能够最大限度发挥表现潜力的内容。在许多方面,这个问题的核心是内容流水线:为 3D 场景创建内容时遵循的约定流程。

我们希望打造一个令人惊叹的世界,因此需要一个可让 3D 艺术家创作该世界的可靠流程。我们需要尽可能让他们在 3D 建模和动画软件中拥有最大的表现自由度;而我们则需要通过代码在屏幕上渲染这些内容。

我们已经在解决此类问题上花费了一些时间,因为过去每次创建 3D 网站时,我们都会发现可用的工具存在限制。因此,我们创建了一款名为 3D Librarian 的工具,这是一次内部研究。它已经可以应用于实际工作了。

此工具的历史悠久:它最初是用于 Flash 的,可让您将大型 Maya 场景作为一个经过优化以便在运行时解压缩的压缩文件导入。之所以是最佳选择,是因为它能够有效地将场景打包到与渲染和动画期间处理的数据结构基本相同的数据结构中。加载时,对文件进行的解析工作非常少。由于文件采用的是 Flash 可以原生解压缩的 AMF 格式,因此在 Flash 中解压缩非常快。在 WebGL 中使用相同的格式需要在 CPU 上执行一些额外的工作。事实上,我们不得不重新创建一个数据解压缩 JavaScript 代码层,该层实际上会解压缩这些文件,并重新创建 WebGL 正常运行所需的数据结构。解压缩整个 3D 场景是一项 CPU 负载较轻的操作:在中高端机器上,解压缩 Find Your Way To Oz 中的场景 1 需要大约 2 秒。因此,此操作是在“场景设置”时间(实际启动场景之前)使用 Web Workers 技术完成的,以免用户体验卡顿。

此实用工具可以导入大多数 3D 场景:模型、纹理、骨骼动画。您可以创建一个库文件,然后 3D 引擎可以加载该文件。您将场景中所需的所有模型塞入此库中,然后,瞧,它们就会生成在场景中。

不过,我们遇到了一个问题,那就是我们现在要处理的是 WebGL:这个新手。这是一个非常棘手的项目:它为基于浏览器的 3D 体验设定了标准。因此,我们创建了一个临时 JavaScript 层,该层会接受 3D 图书馆压缩的 3D 场景文件,并将其正确转换为 WebGL 能够理解的格式。

教程:让风吹起来

“Find Your Way To Oz” 中反复出现的主题是风。故事情节的线索结构为风的逐渐加大。

狂欢节的第一个场景相对平静。在浏览各种场景时,用户会体验到风力逐渐增强,最终达到暴风的场景。

因此,提供沉浸式风效至关重要。

为了实现这一点,我们在 3 个嘉年华场景中添加了柔软的物体,这些物体应该会受到风的影响,例如帐篷、旗帜、照片打印亭的表面和气球本身。

软布。

如今,桌面游戏通常以核心物理引擎为基础构建。因此,当需要在 3D 世界中模拟柔软对象时,系统会为其运行完整的物理模拟,从而创建逼真的柔软行为。

在 WebGL / JavaScript 中,我们还无法运行完整的物理模拟。因此,在《绿野仙踪》中,我们必须想办法营造出风的效果,而无需实际模拟风。

我们在 3D 模型本身中嵌入了每个对象的“风敏感度”信息。3D 模型的每个顶点都有一个“风属性”,用于指定该顶点应受到风的影响程度。因此,这是 3D 对象的指定风敏感度。然后,我们需要创建风本身。

为此,我们生成了一个包含 Perlin 噪声的图片。此图片旨在覆盖特定的“风区”。因此,一个很好的理解方法是,想象一张云朵般的噪声图片叠加在 3D 场景的特定矩形区域上。此图片中的每个像素(灰度值)都指定了“周围”3D 区域在某个时间点的风速。

为了产生风效应,系统会在特定方向(风向)以恒定速度在时间上移动图片。为了确保“有风的区域”不会影响场景中的所有内容,我们会将风图片沿边缘环绕,使其仅限于影响区域。

简单的 3D 风教程

现在,我们在 Three.js 中创建一个简单的 3D 场景,并在其中实现风的效果。

我们将在简单的“程序化草地”中创建风。

我们先创建场景。我们将创建一个简单的纹理平坦地形。然后,每根草都会用倒置的 3D 圆锥来表示。

草地地形
草地地形

下面介绍了如何使用 CoffeeScriptThree.js 中创建此简单场景。

首先,我们将设置 Three.js,并将其与摄像头、鼠标控制器和一些光源等相关联:

constructor: ->

   @clock =  new THREE.Clock()

   @container = document.createElement( 'div' );
   document.body.appendChild( @container );

   @renderer = new THREE.WebGLRenderer();
   @renderer.setSize( window.innerWidth, window.innerHeight );
   @renderer.setClearColorHex( 0x808080, 1 )
   @container.appendChild(@renderer.domElement);

   @camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 1, 5000 );
   @camera.position.x = 5;
   @camera.position.y = 10;
   @camera.position.z = 40;

   @controls = new THREE.OrbitControls( @camera, @renderer.domElement );
   @controls.enabled = true

   @scene = new THREE.Scene();
   @scene.add( new THREE.AmbientLight 0xFFFFFF )

   directional = new THREE.DirectionalLight 0xFFFFFF
   directional.position.set( 10,10,10)
   @scene.add( directional )

   # Demo data
   @grassTex = THREE.ImageUtils.loadTexture("textures/grass.png");
   @initGrass()
   @initTerrain()

   # Stats
   @stats = new Stats();
   @stats.domElement.style.position = 'absolute';
   @stats.domElement.style.top = '0px';
   @container.appendChild( @stats.domElement );
   window.addEventListener( 'resize', @onWindowResize, false );
   @animate()

initGrassinitTerrain 函数调用会分别使用草地和地形填充场景:

initGrass:->
   mat = new THREE.MeshPhongMaterial( { map: @grassTex } )
   NUM = 15
   for i in [0..NUM] by 1
       for j in [0..NUM] by 1
           x = ((i/NUM) - 0.5) * 50 + THREE.Math.randFloat(-1,1)
           y = ((j/NUM) - 0.5) * 50 + THREE.Math.randFloat(-1,1)
           @scene.add( @instanceGrass( x, 2.5, y, 5.0, mat ) )

instanceGrass:(x,y,z,height,mat)->
   geometry = new THREE.CylinderGeometry( 0.9, 0.0, height, 3, 5 )
   mesh = new THREE.Mesh( geometry, mat )
   mesh.position.set( x, y, z )
   return mesh

在这里,我们将创建一个 15 x 15 个草地位元的网格。我们会为每个草地位置添加一些随机性,以免它们像士兵一样排列整齐,看起来很奇怪。

此地形只是一个水平平面,放置在草块的底部 (y = 2.5)。

initTerrain:->
  @plane = new THREE.Mesh( new THREE.PlaneGeometry(60, 60, 2, 2), new THREE.MeshPhongMaterial({ map: @grassTex }))
  @plane.rotation.x = -Math.PI/2
  @scene.add( @plane )

到目前为止,我们所做的只是创建了一个 Three.js 场景,并添加了一些由程序生成的倒置圆锥形草地和简单的地形。

目前没有什么特别的。

现在,我们开始添加风。首先,我们需要将风敏感度信息嵌入到草地 3D 模型中。

我们将为草地 3D 模型的每个顶点嵌入此信息作为自定义属性。我们将使用以下规则:草模型的底端(圆锥的顶端)的灵敏度为零,因为它固定在地面上。草模型的顶部(圆锥底部)对风最敏感,因为它离地面最远。

下面展示了如何重新编码 instanceGrass 函数,以将风敏感度添加为草地 3D 模型的自定义属性。

instanceGrass:(x,y,z,height)->

  geometry = new THREE.CylinderGeometry( 0.9, 0.0, height, 3, 5 )

  for i in [0..geometry.vertices.length-1] by 1
      v = geometry.vertices[i]
      r = (v.y / height) + 0.5
      @windMaterial.attributes.windFactor.value[i] = r * r * r

  # Create mesh
  mesh = new THREE.Mesh( geometry, @windMaterial )
  mesh.position.set( x, y, z )
  return mesh

现在,我们使用自定义材质 windMaterial,而不是之前使用的 MeshPhongMaterialWindMaterial 封装了我们稍后将要看到的 WindMeshShader

因此,instanceGrass 中的代码会循环遍历草模型的所有顶点,并为每个顶点添加一个名为 windFactor 的自定义顶点属性。对于草地模型的底端(应与地形接触的位置),此 windFactor 设为 0;对于草地模型的顶端,此 windFactor 设为 1。

我们需要的另一个元素是向场景中添加实际的风。如前所述,我们将使用 Perlin 噪声来实现此目的。我们将程序化生成 Perlin 噪声纹理。

为方便起见,我们将此纹理分配给地形本身,而不是之前的绿色纹理。这样,您就可以更轻松地了解风的变化情况。

因此,此 Perlin 噪声纹理将在空间上覆盖地形的延伸部分,并且纹理的每个像素都将指定该像素所处地形区域的风速。地形矩形将成为我们的“风区”。

Perlin 噪声是通过名为 NoiseShader 的着色器程序化生成的。此着色器使用 https://github.com/ashima/webgl-noise 中的 3D 简单立方噪声算法。此示例的 WebGL 版本是从 MrDoob 的某个 Three.js 示例中照搬过来的,网址为:http://mrdoob.github.com/three.js/examples/webgl_terrain_dynamic.html

NoiseShader 将时间、缩放比例和一组偏移参数作为 uniform 接受,并输出漂亮的 Perlin 噪声 2D 分布。

class NoiseShader

  uniforms:     
    "fTime"  : { type: "f", value: 1 }
    "vScale"  : { type: "v2", value: new THREE.Vector2(1,1) }
    "vOffset"  : { type: "v2", value: new THREE.Vector2(1,1) }

...

我们将使用此着色器将 Perlin 噪声渲染到纹理。这在 initNoiseShader 函数中完成。

initNoiseShader:->
  @noiseMap  = new THREE.WebGLRenderTarget( 256, 256, { minFilter: THREE.LinearMipmapLinearFilter, magFilter: THREE.LinearFilter, format: THREE.RGBFormat } );
  @noiseShader = new NoiseShader()
  @noiseShader.uniforms.vScale.value.set(0.3,0.3)
  @noiseScene = new THREE.Scene()
  @noiseCameraOrtho = new THREE.OrthographicCamera( window.innerWidth / - 2, window.innerWidth / 2,  window.innerHeight / 2, window.innerHeight / - 2, -10000, 10000 );
  @noiseCameraOrtho.position.z = 100
  @noiseScene.add( @noiseCameraOrtho )

  @noiseMaterial = new THREE.ShaderMaterial
      fragmentShader: @noiseShader.fragmentShader
      vertexShader: @noiseShader.vertexShader
      uniforms: @noiseShader.uniforms
      lights:false

  @noiseQuadTarget = new THREE.Mesh( new THREE.PlaneGeometry(window.innerWidth,window.innerHeight,100,100), @noiseMaterial )
  @noiseQuadTarget.position.z = -500
  @noiseScene.add( @noiseQuadTarget )

上述代码的作用是将 noiseMap 设置为 Three.js 渲染目标,为其配备 NoiseShader,然后使用正交相机对其进行渲染,以避免透视失真。

如前所述,我们现在还将此纹理用作地形的主要渲染纹理。这对于风效应本身来说并不是必需的。不过,有这样的图表会很有用,因为我们可以直观地了解风力发电的情况。

下面是经过改进的 initTerrain 函数,它使用 noiseMap 作为纹理:

initTerrain:->
  @plane = new THREE.Mesh( new THREE.PlaneGeometry(60, 60, 2, 2), new THREE.MeshPhongMaterial( { map: @noiseMap, lights: false } ) )
  @plane.rotation.x = -Math.PI/2
  @scene.add( @plane )

现在,我们已经添加了风纹纹理,接下来我们来看看 WindMeshShader,它负责根据风来变形草地模型。

为了创建此着色器,我们从标准的 Three.js MeshPhongMaterial 着色器开始,并对其进行了修改。这是一种快速而粗糙的入门方法,可让您快速获得可用的着色器,而无需从头开始。

我们不会在此处复制整个着色器代码(您可以随时在源代码文件中查看该代码),因为其中大部分代码都是 MeshPhongMaterial 着色器的复制版本。不过,我们先来看看顶点着色器中经过修改的与风相关的部分。

vec4 wpos = modelMatrix * vec4( position, 1.0 );
vec4 wpos = modelMatrix * vec4( position, 1.0 );

wpos.z = -wpos.z;
vec2 totPos = wpos.xz - windMin;
vec2 windUV = totPos / windSize;
vWindForce = texture2D(tWindForce,windUV).x;

float windMod = ((1.0 - vWindForce)* windFactor ) * windScale;
vec4 pos = vec4(position , 1.0);
pos.x += windMod * windDirection.x;
pos.y += windMod * windDirection.y;
pos.z += windMod * windDirection.z;

mvPosition = modelViewMatrix *  pos;

因此,此着色器首先会根据顶点的 2D xz(水平)位置计算 windUV 纹理查找坐标。此 UV 坐标用于从 Perlin 噪声风纹理中查找风力 vWindForce

vWindForce 值会与特定于顶点的 windFactor(上文中所述的自定义属性)合并,以计算顶点需要的变形量。我们还提供了一个全局 windScale 参数来控制风的总体强度,以及一个 windDirection 矢量,用于指定风变形需要朝向哪个方向。

这样,我们的草就会根据风向发生形变。不过,我们还没有完成。目前,这种变形是静态的,无法传达风力较大的区域的效果。

如前所述,我们需要让噪声纹理随时间推移在风吹区域中滑动,以便玻璃可以摆动。

为此,我们会将传递给 NoiseShader 的 vOffset 均匀值随时间推移。这是一个 vec2 参数,可让我们指定沿特定方向(风向)的噪声偏移。

我们可以在每个帧调用的 render 函数中执行此操作:

render: =>
  delta = @clock.getDelta()

  if @windDirection
      @noiseShader.uniforms[ "fTime" ].value += delta * @noiseSpeed
      @noiseShader.uniforms[ "vOffset" ].value.x -= (delta * @noiseOffsetSpeed) * @windDirection.x
      @noiseShader.uniforms[ "vOffset" ].value.y += (delta * @noiseOffsetSpeed) * @windDirection.z
...

就是这样!我们刚刚创建了一个场景,其中包含受风影响的“程序化草地”。

增加粉尘

现在,让我们为场景增添一些趣味。我们来添加一些飞舞的尘埃,让场景更有趣。

添加灰尘
添加灰尘

毕竟,灰尘应该会受到风的影响,因此在风景场景中让灰尘四处飞舞是完全合理的。

initDust 函数中,将尘埃设置为粒子系统。

initDust:->
  for i in [0...5] by 1
      shader = new WindParticleShader()
      params = {}
      params.fragmentShader = shader.fragmentShader
      params.vertexShader   = shader.vertexShader
      params.uniforms       = shader.uniforms
      params.attributes     = { speed: { type: 'f', value: [] } }

      mat  = new THREE.ShaderMaterial(params)
      mat.map = shader.uniforms["map"].value = THREE.ImageUtils.loadCompressedTexture("textures/dust#{i}.dds")
      mat.size = shader.uniforms["size"].value = Math.random()
      mat.scale = shader.uniforms["scale"].value = 300.0
      mat.transparent = true
      mat.sizeAttenuation = true
      mat.blending = THREE.AdditiveBlending
      shader.uniforms["tWindForce"].value      = @noiseMap
      shader.uniforms[ "windMin" ].value       = new THREE.Vector2(-30,-30 )
      shader.uniforms[ "windSize" ].value      = new THREE.Vector2( 60, 60 )
      shader.uniforms[ "windDirection" ].value = @windDirection            

      geom = new THREE.Geometry()
      geom.vertices = []
      num = 130
      for k in [0...num] by 1

          setting = {}

          vert = new THREE.Vector3
          vert.x = setting.startX = THREE.Math.randFloat(@dustSystemMinX,@dustSystemMaxX)
          vert.y = setting.startY = THREE.Math.randFloat(@dustSystemMinY,@dustSystemMaxY)
          vert.z = setting.startZ = THREE.Math.randFloat(@dustSystemMinZ,@dustSystemMaxZ)

          setting.speed =  params.attributes.speed.value[k] = 1 + Math.random() * 10
          
          setting.sinX = Math.random()
          setting.sinXR = if Math.random() < 0.5 then 1 else -1
          setting.sinY = Math.random()
          setting.sinYR = if Math.random() < 0.5 then 1 else -1
          setting.sinZ = Math.random()
          setting.sinZR = if Math.random() < 0.5 then 1 else -1

          setting.rangeX = Math.random() * 5
          setting.rangeY = Math.random() * 5
          setting.rangeZ = Math.random() * 5

          setting.vert = vert
          geom.vertices.push vert
          @dustSettings.push setting

      particlesystem = new THREE.ParticleSystem( geom , mat )
      @dustSystems.push particlesystem
      @scene.add particlesystem

此时,系统会创建 130 个尘埃粒子。请注意,每个粒子都配备了特殊的 WindParticleShader

现在,我们将在每一帧中使用 CoffeeScript 稍微移动一下粒子,而不会受到风的影响。代码如下。

moveDust:(delta)->

  for setting in @dustSettings

    vert = setting.vert
    setting.sinX = setting.sinX + (( 0.002 * setting.speed) * setting.sinXR)
    setting.sinY = setting.sinY + (( 0.002 * setting.speed) * setting.sinYR)
    setting.sinZ = setting.sinZ + (( 0.002 * setting.speed) * setting.sinZR) 

    vert.x = setting.startX + ( Math.sin(setting.sinX) * setting.rangeX )
    vert.y = setting.startY + ( Math.sin(setting.sinY) * setting.rangeY )
    vert.z = setting.startZ + ( Math.sin(setting.sinZ) * setting.rangeZ )

此外,我们还将根据风向偏移每个粒子的位置。这在 WindParticleShader 中完成。具体而言,是在顶点着色器中。

此着色器的代码是 Three.js ParticleMaterial 的修改版,其核心如下所示:

vec4 mvPosition;
vec4 wpos = modelMatrix * vec4( position, 1.0 );
wpos.z = -wpos.z;
vec2 totPos = wpos.xz - windMin;
vec2 windUV = totPos / windSize;
float vWindForce = texture2D(tWindForce,windUV).x;
float windMod = (1.0 - vWindForce) * windScale;
vec4 pos = vec4(position , 1.0);
pos.x += windMod * windDirection.x;
pos.y += windMod * windDirection.y;
pos.z += windMod * windDirection.z;

mvPosition = modelViewMatrix *  pos;

fSpeed = speed;
float fSize = size * (1.0 + sin(time * speed));

#ifdef USE_SIZEATTENUATION
    gl_PointSize = fSize * ( scale / length( mvPosition.xyz ) );
#else,
    gl_PointSize = fSize;
#endif

gl_Position = projectionMatrix * mvPosition;

此顶点着色器与我们用于基于风的草地变形的着色器没有太大区别。它将 Perlin 噪声纹理作为输入,并根据尘埃世界位置在噪声纹理中查找 vWindForce 值。然后,它会使用此值来修改尘埃粒子的位移。

Riders On The Storm

我们的 WebGL 场景中最具冒险精神的可能就是最后一个场景,您可以点击气球进入龙卷风的中心,在网站上完成旅程,并观看即将发布的独家视频。

热气球飞行场景

在制作此场景时,我们就知道需要为用户提供一项有影响力的核心体验。旋转的龙卷风将作为中心元素,其他内容层将塑造此元素,以产生戏剧性效果。为此,我们围绕这个奇怪的着色器构建了一个类似于电影摄影棚的场景。

我们采用了混合方法来制作逼真的合成图像。有些是视觉特效,例如用光形状来制作镜头光晕效果,或者在您正在观看的场景上方以图层形式呈现动画效果的雨滴。在其他情况下,我们绘制了平面,使其看起来在移动,例如根据粒子系统代码移动的低空云层。而围绕龙卷风旋转的碎片是 3D 场景中的各个层,这些层会按顺序在龙卷风前后移动。

我们之所以必须以这种方式构建场景,主要是为了确保我们有足够的 GPU 来处理龙卷风着色器,并与我们应用的其他效果保持平衡。最初,我们遇到了严重的 GPU 平衡问题,但后来此场景经过优化,比主要场景更轻量。

教程:风暴着色器

为了制作最终的暴风序列,我们结合使用了许多不同的技术,但这项工作的核心是看起来像龙卷风的自定义 GLSL 着色器。我们尝试了许多不同的技术,从顶点着色器(用于创建有趣的几何漩涡)到基于粒子的动画,甚至扭曲几何形状的 3D 动画。这些特效似乎都无法重现龙卷风的感觉,或者需要过多的处理。

最终,一个完全不同的项目为我们提供了答案。马克斯·普朗克研究所 (brainflight.org) 开展了一项平行项目,旨在通过游戏来绘制出老鼠的大脑图谱,并产生了有趣的视觉效果。我们设法使用自定义体积着色器制作了小鼠神经元内部的影片。

使用自定义体积着色器的鼠标神经元内部
使用自定义体积着色器的鼠标神经元内部

我们发现脑细胞内部有点像龙卷风的漏斗。由于我们使用的是体积渲染技术,因此我们知道可以从空间中的所有方向查看此着色器。我们可以将着色器的渲染设置为与风暴场景相结合,尤其是在云层之间和壮观的背景上方。

着色器技术涉及一种技巧,该技巧基本上使用单个 GLSL 着色器通过一种简化的渲染算法(称为“使用距离场进行光线漫游”渲染)渲染整个对象。在此技术中,系统会创建一个像素着色器,用于估算屏幕上每个点与表面的最短距离。

如需有关该算法的参考信息,请参阅 iq 的概览:Rendering Worlds With Two Triangles - Iñigo Quilez。您还可以探索 glsl.heroku.com 上的着色器库,在其中找到许多可供实验的此类技术示例。

着色器的核心从 main 函数开始,该函数会设置相机转换并进入一个循环,该循环会反复评估与表面的距离。调用 RaytraceFoggy( direction_vector, max_iterations, color, color_multiplier ) 是核心光线漫游计算发生的位置。

for(int i=0;i < number_of_steps;i++) // run the ray marching loop
{
  old_d=d;
  float shape_value=Shape(q); // find out the approximate distance to or density of the tornado cone
  float density=-shape_value;
  d=max(shape_value*step_scaling,0.0);// The max function clamps values smaller than 0 to 0

  float step_dist=d+extra_step; // The point is advanced by larger steps outside the tornado,
  //  allowing us to skip empty space quicker.

  if (density>0.0) {  // When density is positive, we are inside the cloud
    float brightness=exp(-0.6*density);  // Brightness decays exponentially inside the cloud

    // This function combines density layers to create a translucent fog
    FogStep(step_dist*0.2,clamp(density, 0.0,1.0)*vec3(1,1,1), vec3(1)*brightness, colour, multiplier); 
  }
  if(dist>max_dist || multiplier.x < 0.01) { return;  } // if we've gone too far stop, we are done
  dist+=step_dist; // add a new step in distance
  q=org+dist*dir; // trace its direction according to the ray casted
}

其基本思想是,随着我们深入到龙卷风的形状中,我们会定期向像素的最终颜色值添加颜色贡献,以及向光线沿着的透明度添加贡献。这样可以为龙卷风的纹理营造出层次分明的柔软质感。

龙卷风的下一个核心方面是实际形状本身,它是通过组合多个函数创建的。它最初是圆锥形,由噪声组成,以创建有机粗糙边缘,然后沿主轴扭曲并随时间旋转。

mat2 Spin(float angle){
  return mat2(cos(angle),-sin(angle),sin(angle),cos(angle)); // a rotation matrix
}

// This takes noise function and makes ridges at the points where that function crosses zero
float ridged(float f){ 
  return 1.0-2.0*abs(f);
}

// the isosurface shape function, the surface is at o(q)=0 
float Shape(vec3 q) 
{
    float t=time;

    if(q.z < 0.0) return length(q);

    vec3 spin_pos=vec3(Spin(t-sqrt(q.z))*q.xy,q.z-t*5.0); // spin the coordinates in time

    float zcurve=pow(q.z,1.5)*0.03; // a density function dependent on z-depth

    // the basic cloud of a cone is perturbed with a distortion that is dependent on its spin 
    float v=length(q.xy)-1.5-zcurve-clamp(zcurve*0.2,0.1,1.0)*snoise(spin_pos*vec3(0.1,0.1,0.1))*5.0; 

    // create ridges on the tornado
    v=v-ridged(snoise(vec3(Spin(t*1.5+0.1*q.z)*q.xy,q.z-t*4.0)*0.3))*1.2; 

    return v;
}

创建此类着色器的工作非常棘手。除了与您创建的操作的抽象化相关的问题之外,您还需要跟踪并解决严重的优化和跨平台兼容性问题,然后才能在生产环境中使用这些工作。

问题的第一部分:针对我们的场景优化此着色器。为此,我们需要采用一种“安全”的方法,以防着色器过重。为此,我们以与场景其余部分不同的采样分辨率合成了龙卷风着色器。这是来自文件 stormTest.coffee 的内容(是的,这是一个测试!)。

我们先创建一个与场景宽度和高度匹配的 renderTarget,以便让龙卷风着色器的分辨率与场景无关。然后,我们会根据获得的帧速率动态确定风暴着色器的分辨率下采样。

...
Line 1383
@tornadoRT = new THREE.WebGLRenderTarget( @SCENE_WIDTH, @SCENE_HEIGHT, paramsN )

... 
Line 1403 
# Change settings based on FPS
if @fpsCount > 0
    if @fpsCur < 20
        @tornadoSamples = Math.min( @tornadoSamples + 1, @MAX_SAMPLES )
    if @fpsCur > 25
        @tornadoSamples = Math.max( @tornadoSamples - 1, @MIN_SAMPLES )
    @tornadoW = @SCENE_WIDTH  / @tornadoSamples // decide tornado resWt
    @tornadoH = @SCENE_HEIGHT / @tornadoSamples // decide tornado resHt

最后,我们使用简化的 sal2x 算法(以避免出现块状外观)将龙卷风渲染到屏幕上(stormTest.coffee 中的第 1107 行)。也就是说,在最糟糕的情况下,我们最终得到的龙卷风会更模糊,但至少它会正常运行,不会剥夺用户的控制权。

下一步优化需要深入了解算法。着色器中的驱动计算因子是对每个像素执行的迭代次数,用于尝试近似计算表面函数的距离:光线追踪循环的迭代次数。使用更大的步长,我们可以在云层外部通过更少的迭代次数估算龙卷风表面。在室内时,我们会减小步长以提高精度,并能够混合值以产生雾蒙蒙的效果。此外,创建一个边界圆柱以获取投射光线的深度估算值也带来了显著的速度提升。

问题的下一部分是确保此着色器能够在不同的显卡上运行。我们每次都会进行一些测试,并开始直观地了解我们可能会遇到的兼容性问题类型。之所以效果不如直觉,是因为我们无法总是获得有关错误的良好调试信息。典型场景只是 GPU 错误,没有其他问题,甚至系统崩溃!

跨视频板兼容性问题的解决方法类似:确保输入的静态常量为定义的精确数据类型,例如:0.0 为浮点数,0 为整数。编写较长函数时请务必小心;最好将内容拆分为多个更简单的函数和临时变量,因为编译器似乎无法正确处理某些情况。确保纹理都是 2 的幂,并且不太大。在循环中查找纹理数据时,请务必“谨慎”。

我们在兼容性方面遇到的最大问题来自暴风的光效。我们使用了缠绕在龙卷风周围的预制纹理,以便为其羽毛着色。这是一个非常漂亮的效果,可以轻松将龙卷风融入场景颜色,但在尝试在其他平台上运行时花了好长时间。

龙卷风

移动网站

移动版体验无法直接照搬桌面版,因为技术和处理要求太高。我们必须打造一款专门针对移动用户的新产品。

我们认为,将桌面版嘉年华打印照片亭作为移动 Web 应用(可使用用户的移动设备相机)会很酷。这是我们到目前为止还没有看到过的。

为了增添趣味,我们在 CSS3 中编写了 3D 转换。将其与陀螺仪和加速度计相关联后,我们能够为用户提供更深入的体验。网站会根据您握持、移动和观看手机的方式做出响应。

在撰写本文时,我们认为有必要为您提供一些关于如何顺利进行移动开发流程的提示。下面是这些信息!快来看看您可以从中学习到什么!

移动设备提示和技巧

引导加载程序是必需的,而不是应避免的。我们知道,有时会出现后一种情况。这主要是因为随着项目的发展,您需要不断维护预加载内容的列表。更糟糕的是,如果您同时拉取多个不同的资源,则不太清楚应如何计算加载进度。这时,我们自定义的非常通用的抽象类“Task”就派上用场了。其主要思想是允许无限嵌套的结构,其中任务可以有自己的子任务,子任务可以有自己的子任务,以此类推。此外,每个任务都会根据其子任务的进度(而非父任务的进度)计算其进度。将所有 MainPreloadTask、AssetPreloadTask 和 TemplatePreFetchTask 派生自 Task,我们创建了一个如下所示的结构:

预加载器

得益于这种方法和 Task 类,我们可以轻松了解全局进度 (MainPreloadTask),或仅了解资源进度 (AssetPreloadTask),或了解加载模板的进度 (TemplatePreFetchTask)。甚至特定文件的进度。如需了解具体实现方式,请参阅 /m/javascripts/raw/util/Task.js 中的 Task 类以及 /m/javascripts/preloading/task 中的实际任务实现。下面是设置 /m/javascripts/preloading/task/MainPreloadTask.js 类(即最终的预加载封装容器)的摘要:

Package('preloading.task', [
  Import('util.Task'),
...

  Class('public MainPreloadTask extends Task', {

    _public: {
      
  MainPreloadTask : function() {
        
    var subtasks = [
      new AssetPreloadTask([
        {name: 'cutout/cutout-overlay-1', ext: 'png', type: ImagePreloader.TYPE_BACKGROUND, responsive: true},
        {name: 'journey/scene1', ext: 'jpg', type: ImagePreloader.TYPE_IMG, responsive: false}, ...
...
      ]),

      new TemplatePreFetchTask([
        'page.HomePage',
        'page.CutoutPage',
        'page.JourneyToOzPage1', ...
...
      ])
    ];
    
    this._super(subtasks);

      }
    }
  })
]);

在 /m/javascripts/preloading/task/subtask/AssetPreloadTask.js 类中,除了注意它如何与 MainPreloadTask 通信(通过共享的 Task 实现)之外,还值得注意我们如何加载平台依赖的资源。基本上,我们有四种类型的图片。移动设备标准(.ext,其中 ext 是文件扩展名,通常为 .png 或 .jpg)、移动设备 Retina(-2x.ext)、平板电脑标准(-tab.ext)和平板电脑 Retina(-tab-2x.ext)。我们只需说明要预加载的资源的名称和扩展名,以及资源是否受平台限制(响应式 = true / false),而无需在 MainPreloadTask 中进行检测并对四个资源数组进行硬编码。然后,AssetPreloadTask 会为我们生成文件名:

resolveAssetUrl : function(assetName, extension, responsive) {
  return AssetPreloadTask.ASSETS_ROOT + assetName + (responsive === true ? ((Detection.getInstance().tablet ? '-tab' : '') + (Detection.getInstance().retina ? '-2x' : '')) : '') + '.' +  extension;
}

在类链的下方,执行资源预加载的实际代码如下所示 (/m/javascripts/raw/util/ImagePreloader.js):

loadUrl : function(url, type, completeHandler) {
  if(type === ImagePreloader.TYPE_BACKGROUND) {
    var $bg = $('<div>').hide().css('background-image', 'url(' + url + ')');
    this.$preloadContainer.append($bg);
  } else {
    var $img= $('<img />').attr('src', url).hide();
    this.$preloadContainer.append($img);
  }

  var image = new Image();
  this.cache[this.generateKey(url)] = image;
  image.onload = completeHandler;
  image.src = url;
}

generateKey : function(url) {
  return encodeURIComponent(url);
}

教程:HTML5 自助照相机 (iOS6/Android)

在开发 OZ 移动版时,我们发现自己花了很多时间在玩照片打印机,而不是工作 :D 这只是因为它很有趣。因此,我们制作了一个演示版供您试用。

移动自拍亭
移动自助照相机

您可以点击此处查看实际演示(在 iPhone 或 Android 手机上运行):

http://u9html5rocks.appspot.com/demos/mobile_photo_booth

如需进行设置,您需要一个可运行后端的免费 Google App Engine 应用实例。前端代码并不复杂,但可能存在一些问题。现在,我们来逐一了解一下:

  1. 允许上传的图片文件类型 我们希望用户只能上传图片(因为这是照相亭,而不是视频亭)。从理论上讲,您只需在 HTML 中指定过滤条件,如下所示:input id="fileInput" class="fileInput" type="file" name="file" accept="image/*" 不过,这似乎仅适用于 iOS,因此我们需要在选择文件后对 RegExp 进行额外检查:
   this.$fileInput.fileupload({
          
   dataType: 'json',
   autoUpload : true,
   
   add : function(e, data) {
     if(!data.files[0].name.match(/(\.|\/)(gif|jpe?g|png)$/i)) {
      return self.onFileTypeNotSupported();
     }
   }
   });
  1. 取消上传或文件选择 在开发过程中,我们发现了另一项不一致之处,即不同设备在取消文件选择时发送通知的方式。iOS 手机和平板电脑不会执行任何操作,完全不会发送通知。因此,在这种情况下,我们无需执行任何特殊操作,不过,Android 手机无论是否选择了文件,都会触发 add() 函数。您可以通过以下方式解决此问题:
    add : function(e, data) {

    if(data.files.length === 0 || (data.files[0].size === 0 && data.files[0].name === "" && data.files[0].fileName === "")) {
            
    return self.onNoFileSelected();

    } else if(data.files.length > 1) {

    return self.onMultipleFilesSelected();            
    }
    }

其余功能在各个平台上运行都非常顺畅。乐在其中!

总结

鉴于“Find Your Way To Oz”的庞大规模以及涉及的各种不同技术,本文仅介绍了我们采用的部分方法。

如果您想探索整个 enchilada,请随时点击此链接查看“Find Your Way To Oz”的完整源代码

赠金

点击此处查看完整制作人员名单

参考