案例研究 - 走进奥兹国

unit9 com
unit9 com

简介

“找寻前往奥兹国的路径”是由迪士尼面向网上推出的一项新 Google Chrome 实验性功能。在这里,你可以体验堪萨斯马戏团的互动之旅,在遭遇暴风雨的袭击后,它会带你前往奥兹国的陆地。

我们的目标是将电影的丰富性与浏览器的技术功能相结合,打造一种有趣的沉浸式体验,使用户能够建立紧密的联系。

这项工作有点庞大,无法全部涵盖在这部分中,因此我们深入探讨了我们认为有趣的技术故事,并划出了一些章节。在此过程中,我们提取了一些关于难度不断增加的重点教程。

许多人为打造这样的体验付出了很多努力:太多了,无法在这里一一列出。请访问此网站,查看菜单版块下的演职员表页面,了解完整故事。

幕后探秘

桌面设备上的“找寻前往奥兹国的路径”将为你提供丰富多彩的沉浸式体验。我们使用 3D 和多层传统电影制作启发的效果,结合这些效果来打造近乎逼真的场景。其中最突出的技术是采用 Three.js 的 WebGL、自定义构建的着色器,以及使用 CSS3 功能的 DOM 动画元素。除此之外,提供互动体验的 getUserMedia API (WebRTC) 允许用户直接从摄像头和 WebAudio 添加图片,以实现 3D 声音。

但这样的技术体验巧妙结合在一起就是一种神奇的技术。这也是主要挑战之一:如何将视觉效果和互动元素融合到一个场景中,从而形成一致的整体?这种视觉复杂性难以管理,因此很难分辨我们曾处于哪个开发阶段。

为了解决视觉效果和优化相互关联的问题,我们大量使用了控制面板,以捕获我们当时正在查看的所有相关设置。你可以在浏览器中实时调整场景,从亮度、景深、灰度系数等任何方面调整...任何人都可以尝试调整体验中重要参数的值,并参与发现效果最佳的方面。

在公布我们的秘密之前,我们想警告你,它可能会崩溃,就像你在汽车引擎里四处走动一样。确保您手头没有任何重要邮件,请访问网站的主要网址并将 ?debug=on 附加到地址。等待网站加载,当您进入(按?)键 Ctrl-I 后,页面右侧会显示一个下拉菜单。如果您取消选中“退出相机路径”选项,可以使用 A、W、S、D 键和鼠标在空间中随意移动。

相机路径。

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

风暴景观

为了实现这一点,并确保其足够灵活,可以满足我们的需求,我们使用了一个名为 dat.gui 的漂亮库(如需查看以往的用法教程,请参阅此处)。它让我们能够快速更改向网站访问者显示哪些设置。

有点像哑光画

在许多经典的迪士尼电影和动画中,创造场景意味着结合不同的层次。该图层包含实景图层、细胞动画,甚至包括实体场景,以及通过在玻璃上绘画创作的顶层,这是一种称为哑光绘制的技术。

从很多方面来说,我们打造的体验结构是相似的;尽管某些“层次”远不止静态视觉元素。事实上,它们会根据更复杂的计算来影响事物的外观。不过,至少在大局上,我们处理的视图是视图,一个叠放在另一个之上。在顶部,您可以看到一个界面层,其下方有一个 3D 场景:该层本身由不同的场景组件组成。

顶层界面层是使用 DOM 和 CSS 3 创建的,这意味着,可以根据选定的事件列表,通过许多方式修改互动,而不考虑 3D 体验和两者之间的通信。这种通信使用 Backbone Router + onHashChange HTML5 事件,该事件用于控制哪个区域应为进出动画显示动画。(项目来源:/develop/coffee/router/Router.coffee)。

教程:Sprite 表格和 Retina 支持

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

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

下面介绍了一些提示,介绍了我们如何利用 Sprite 工作表以及如何将其用于视网膜设备,并让您的界面尽可能清晰简洁。

创建雪碧图

为了创建 SpriteSheet,我们使用了 TexturePacker,它可以以您需要的任何格式输出结果。在本例中,我们已导出为 EaselJS,它非常简洁,也可用于创建动画精灵。

使用生成的雪碧图

创建雪碧图后,您应该会看到如下所示的 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]
   },
}

其中:

  • “图片”是指雪碧图的网址
  • 帧是每个界面元素的坐标 [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 图书管理员的工具,这是我们内部研究的一项成果。然后,我们马上就可以开始实际工作了。

该工具有一些历史:最初是针对 Flash 的,它允许您以单个压缩文件的形式呈现一个宏大的 Maya 场景,并针对解压运行时进行了优化。其最优的原因是它能有效地将场景填充到与渲染和动画期间操作的数据结构基本相同。在文件加载后,几乎不需要解析文件。在 Flash 中解压缩非常快,因为该文件采用 AMF 格式,而 Flash 可以本地解压缩。在 WebGL 中使用相同格式需要对 CPU 执行更多工作。事实上,我们不得不重新创建数据解压缩 JavaScript 层,这实质上是解压缩这些文件,并重新创建 WebGL 正常工作所需的数据结构。解压整个 3D 场景的操作略微占用 CPU 资源:在中高端计算机上,解压《找寻前往奥兹国》中的场景 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 Noise 的图像来做到这一点。这张图片旨在覆盖某个“风力区域”。要理解这一点,设想一下:在 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 的自定义顶点属性。对于草地模型的底部(也就是应该与地形接触的位置),将此风能系数设置为 0;对于草模型的顶部,此值为 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 利用时间、刻度和参数偏移量集作为统一模型,然后输出 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 Noise 渲染为纹理。这在 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 函数,使用噪声映射作为纹理:

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 着色器的副本。不过,我们再来看看 Vertex 着色器中经过修改的风相关部分。

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 坐标用于根据珀林噪声风纹理查找风力 vWindForce

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

这让我们的草片因风而变形。但我们的工作仍未完成。和现在一样,这种变形是静态的,无法表现风力地区的影响。

正如前面提到的,我们需要随着时间的推移将噪声纹理滑过风区域,以便玻璃可以挥动。

这是通过随时间移位来实现的,即传递给 NoiseShader 的 vOffset uniform。这是一个 vec2 参数,通过该参数,我们可以指定沿特定方向(风向)的噪音偏移。

我们在每一帧都会调用的“渲染”函数中执行此操作:

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
...

大功告成!我们刚刚创建了一个场景,其中包含受风影响的“程序化草地”。

为混合物添加灰尘

现在,让我们为场景增添一点情趣。我们要添加一些沙尘,让场景更有趣。

增加粉尘
添加除尘

毕竟,沙尘会受风的影响,所以在我们的风场景中到处飞扬灰尘是明智之举。

Dust 在 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 值。然后,它会使用以下值修改灰尘颗粒的位置。

风暴骑行者

我们 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_iters, 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 行。这意味着更糟糕的情况是,我们最终会遇到更加模糊的龙卷风,但至少它可以在没有用户控制权的情况下正常运行。

接下来的优化步骤需要深入研究算法。着色器中的驱动计算系数是对每个像素执行的迭代,以尝试估算 Surface 函数的距离,即光线行进循环的迭代次数。使用更大的步长,我们可以在脱离多云表面时通过减少迭代次数获得龙卷风表面。在该模式下,我们可以减小步长以确保精确率,并且能够混合值来产生模糊效果。此外,创建一个边界圆柱来估算投射光线的深度估计值,加快了速度。

问题的下一个部分是确保此着色器能够在不同的显卡上运行。我们每次都进行了一些测试,并开始建立对我们可能遇到的兼容性问题类型的直觉。我们无法比直觉更好地进行调试的原因是,我们有时无法获得关于错误的良好调试信息。典型的场景只是 GPU 错误,几乎没有问题,甚至系统崩溃了!

跨视频板兼容性问题也有类似的解决方案:请确保输入的静态常量按照定义的确切数据类型输入,即 IE:0.0 表示浮点型,0 表示整型。编写较长的函数时要小心,最好将内容分解为多个较简单的函数和临时变量,因为编译器似乎无法正确处理某些情况。请确保纹理均为 2 的幂且不会过大,并且在任何情况下,在循环查询纹理数据时都要小心谨慎。

我们在兼容性方面遇到的最大问题来自风暴的照明效果。我们使用了围绕龙卷风的预制纹理,以便为其细线着色。这种效果非常绚丽,让龙卷风轻松融入场景颜色,但要尝试在其他平台上运行却需要花费很长时间。

龙卷风

移动网站

移动体验无法直接翻译桌面版本,因为技术和处理要求过于繁琐。我们不得不打造一种专门针对移动用户的新工具。

我们认为,将桌面设备上的 Carnival Photo-Booth 打造成移动网络应用(需要使用用户的移动设备相机)会很酷。这是我们迄今为止从未见过的。

为添加风格,我们在 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 通信(通过共享任务实现)外,还值得注意如何加载依赖于平台的资源。基本上,我们有四种图像。移动设备标准格式(.ext,其中 ext 为文件扩展名,通常为 .png 或 .jpg)、移动设备 Retina (-2x.ext)、平板电脑标准 (-tab.ext) 和平板电脑 Retina (-tab-2x.ext)。我们无需在 MainPreloadTask 中执行检测并对四个资源数组进行硬编码,而是只需说出要预加载的资源的名称和扩展名,以及资源是否取决于平台(响应 = true / false)。然后,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 Photo Booth (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,因此在选择文件后,我们需要针对正则表达式添加一项额外的检查:
   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();            
    }
    }

其余部分可跨平台运行,非常顺畅。乐在其中!

总结

鉴于“找寻前往奥兹国的路径”的规模庞大,以及涉及的各种不同技术,在本文中,我们仅介绍了我们采用的几种方法。

如果您好奇整个辣酱玉米饼的味道,请随时点击此链接,查阅“找寻奥兹国之旅”的完整源代码。

赠金

点击此处可查看完整的赠金列表

参考