案例研究:找到通往奥兹的路

发布于 2022-01-10 12:51:41 字数 34434 浏览 1244 评论 0

Find Your Way to Oz 是迪士尼在网络上推出的一项新的 Google Chrome 实验。 它使您可以通过堪萨斯马戏团进行互动之旅,在您被一场大风暴席卷后,将带您前往奥兹国。

我们的目标是将电影的丰富性与浏览器的技术能力相结合,创造一种有趣的、身临其境的体验,用户可以与之建立紧密的联系。

这项工作有点太大,无法在本文中完整捕捉,因此我们深入研究并提取了一些我们认为有趣的技术故事章节。 在此过程中,我们提取了一些难度越来越大的重点教程。

许多人努力使这种体验成为可能:这里不一一列举。 请 访问该网站 以查看完整故事菜单部分下的学分页面。

引擎盖下的一瞥

Find Your Way to Oz 桌面版是一个丰富的沉浸式世界。 我们使用 3D 和多层受传统电影制作启发的效果相结合,创造出近乎真实的场景。 最突出的技术是带有 Three.js 的 WebGL、自定义构建的着色器和使用 CSS3 功能的 DOM 动画元素。 除此之外,交互式体验的 getUserMedia API (WebRTC) 允许用户直接从网络摄像头和 WebAudio 添加他们的图像以获得 3D 声音。

But the magic of a technological experience like this is how it comes together. This is also one of the main challenges: how to blend visual effects and interactive elements together in one scene to create a consistent whole? This visual complexity was difficult to manage: making it difficult to tell what stage in development we were at any one time.

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

在我们分享我们的秘密之前,我们想警告您它可能会崩溃,就像您要在汽车发动机内四处寻找一样。 确保你没有任何重要的东西,然后去访问网站的主 url 并将 附加 ?debug=on 到地址。 等待站点加载,一旦您进入(按下?)键 Ctrl-I ,您将看到右侧出现一个下拉菜单。 如果取消选中“退出相机路径”选项,您可以使用 A、W、S、D 键和鼠标在空间中自由移动。

我们不会在这里介绍所有设置,但我们鼓励您进行实验:按键在不同场景中显示不同的设置。 在最终的风暴序列中,还有一个附加键: Ctrl-A ,您可以使用它来切换动画播放并四处飞行。 在此场景中,如果您按 Esc (退出鼠标锁定功能)并再次 按 Ctrl-I, 您可以访问特定于暴风雨场景的设置。 环顾四周,捕捉一些漂亮的明信片视图,如下图所示。

为了实现这一点并确保它足够灵活地满足我们的需求,我们使用了一个名为 dat.gui 的可爱库( 请参见此处 有关如何使用它的过去教程, )。 它使我们能够快速更改向网站访问者公开的设置。

有点像哑光绘画

在许多经典的迪斯尼电影和动画中,创造场景意味着结合不同的层次。 有多层实景动作、细胞动画,甚至是物理布景,以及在玻璃上绘画创造的顶层:一种称为哑光绘画的技术。

在许多方面,我们创造的体验结构是相似的; 即使某些“层”不仅仅是静态视觉效果。 事实上,它们会根据更复杂的计算影响事物的外观。 尽管如此,至少在我们处理视图的大局层面上,一个叠加在另一个之上。 在顶部,您会看到一个 UI 层,其下方有一个 3D 场景:它本身由不同的场景组件组成。

顶部界面层是使用 DOM 和 CSS3 创建的,这意味着编辑交互可以通过多种方式完成,独立于 3D 体验,根据选择的事件列表在两者之间进行通信。 此通信使用 Backbone Router + onHashChange HTML5 事件,该事件控制应在哪些区域进行动画输入/输出。 (项目来源:/develop/coffee/router/Router.coffee)。

教程:Sprite Sheets 和 Retina 支持

我们对界面依赖的一种有趣的优化技术是将许多界面覆盖图像组合在一个 PNG 中以减少服务器请求。 在这个项目中,界面由预先加载的 70 多张图像(不包括 3D 纹理)组成,以减少网站的延迟。 您可以在此处查看实时精灵表:

正常显示 - http://findyourwaytooz.com/img/home/interface_1x.png
视网膜显示 - http://findyourwaytooz.com/img/home/interface_2x.png

以下是我们如何利用 Sprite Sheets 的一些技巧,以及如何将它们用于 Retina 设备并使界面尽可能清晰和整洁。

创建精灵表

为了创建 SpriteSheets,我们使用了 TexturePacker ,它以您需要的任何格式输出。 在这种情况下,我们导出为 EaselJS ,它非常干净,也可以用来创建动画精灵。

使用生成的 Sprite Sheet

创建 Sprite Sheet 后,您应该会看到如下 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 指的是精灵表的 URL
  • frame 是每个 UI 元素的坐标 [x, y, width, height]
  • 动画是每个资产的名称

请注意,我们使用高密度图像来创建 Sprite 表,然后我们创建了普通版本,只是将其大小调整到其大小的一半。

把所有东西放在一起

现在我们都准备好了,我们只需要一个 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'));

Download the full example here

要了解有关可变像素密度的更多信息,您可以阅读 Boris Smus 的这篇文章

3D 内容管道

环境体验设置在 WebGL 层上。 当您考虑 3D 场景时,最棘手的问题之一是您将如何确保创建能够从建模、动画和效果方面发挥最大表达潜力的内容。 在许多方面,这个问题的核心是内容管道:为 3D 场景创建内容的商定流程。

我们想创造一个令人敬畏的世界; 所以我们需要一个可靠的流程,让 3D 艺术家能够创建它。 他们需要在 3D 建模和动画软件中获得尽可能多的表达自由; 我们需要通过代码在屏幕上渲染它。

我们一直在研究这类问题一段时间,因为过去每次创建 3D 站点时,我们都会发现我们可以使用的工具存在局限性。 所以我们创建了这个工具,称为 3D Librarian:一项内部研究。 它已经准备好应用于真正的工作了。

这个工具有一些历史:最初它是用于 Flash,它允许您将一个大型 Maya 场景作为一个压缩文件引入,该文件针对解压缩运行时进行了优化。 它之所以是最佳的,是因为它有效地将场景打包在与渲染和动画期间操作的基本相同的数据结构中。 加载时几乎不需要对文件进行解析。 在 Flash 中解包非常快,因为文件是 AMF 格式,Flash 可以本地解包。 在 WebGL 中使用相同的格式需要在 CPU 上做更多的工作。 事实上,我们必须重新创建一个数据解包 Javascript 代码层,这实际上会解压缩这些文件并重新创建 WebGL 工作所需的数据结构。 解包整个 3D 场景是一个稍微占用 CPU 的操作: 解包 场景 1 Find Your Way To Oz 中的 在中高端机器上 需要大约 2 秒。 因此,这是在“场景设置”时(在场景实际启动之前)使用 Web Workers 技术完成的,以免为用户挂起体验。

这个方便的工具可以导入大部分 3D 场景:模型、纹理、骨骼动画。 您创建一个库文件,然后可以由 3D 引擎加载。 您将场景中需要的所有模型填充到这个库中,然后,瞧,将它们生成到您的场景中。

然而,我们遇到的一个问题是,我们现在正在处理 WebGL:新来的人。 这是一个非常强硬的孩子:这为基于浏览器的 3D 体验设定了标准。 因此,我们创建了一个特殊的 Javascript 层,它将采用 3D Librarian 压缩的 3D 场景文件,并将它们正确地转换为 WebGL 可以理解的格式。

教程:让风

“Find Your Way To Oz”中反复出现的主题是风。 故事情节的主线被构造成风的渐强。

嘉年华的第一幕相对平静。 在经历各种场景时,用户会体验到越来越强的风,最终进入最终场景,风暴。

因此,提供身临其境的风效果非常重要。

为了创建这个,我们在 3 个狂欢节场景中填充了柔软的物体,因此应该会受到风的影响,例如帐篷、照相亭表面的旗帜和气球本身。

如今,桌面游戏通常围绕核心物理引擎构建。 因此,当需要在 3D 世界中模拟软对象时,会为其运行完整的物理模拟,从而创建可信的软行为。

在 WebGL / Javascript 中,我们(还)没有能力运行完整的物理模拟。 所以在 Oz 中,我们必须找到一种方法来创建风的效果,而无需实际模拟它。

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

我们通过生成包含 的图像来做到这一点 Perlin Noise 。 该图像旨在覆盖某个“风区”。 因此,考虑它的一个好方法是想象一张像云一样的噪声图片被放置在 3D 场景的某个矩形区域上。 此图像的每个像素(灰度值)指定“周围”的 3D 区域中特定时刻风的强度。

为了产生风的效果,图像会及时、以恒定的速度、在特定的方向上移动;风的方向。为了确保“有风区域”不会影响场景中的所有内容,我们将风图像环绕在边缘,仅限于影响区域。

一个简单的 3D 风教程

现在让我们用 Three.js 在一个简单的 3D 场景中创建风的效果。

我们将在一个简单的“程序草场”中创建风。

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

布满草的地形

下面是如何 在 创建这个简单的场景 Three.js 中 使用 CoffeeScript 。

首先,我们将设置 Three.js,并将其与相机、鼠标控制器和 Some Light 连接起来:

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()

initGrass initTerrain 函数调用填充分别草和地形场景:

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 场景,并添加了一些由程序生成的反向圆锥和简单地形组成的草。

到目前为止没有什么花哨的。

click here to open in a separate window

You can download the code of this demo from here .

现在,是时候开始添加风了。 首先,我们要将风敏感信息嵌入到草地 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 ,而不是 的 MeshPhongMaterial 之前使用 。 WindMaterial 包装了 将在 的 稍后 WindMeshShader 我们 看到 。

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

我们需要的另一个成分是将实际的风添加到我们的场景中。 如前所述,我们将为此使用 Perlin 噪声。 我们将按程序生成 Perlin 噪声纹理。

为了清楚起见,我们将把这个纹理分配给地形本身,代替之前的绿色纹理。 这将使您更容易了解风的情况。

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

Perlin 噪声是通过称为 的着色器程序生成的 NoiseShader 。 此着色器使用来自: : 3d 单纯形噪声算法 https //github.com/ashima/webgl-noise 的 。 WebGL 版本是从 MrDoob 的 Three.js 样本之一中逐字获取的,位于: ://mrdoob.github.com/three.js/examples/webgl_terrain_dynamic.html http

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 函数,使用 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;

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

这个 vWindForce 值与 的顶点特定的 组合在一起 windFactor 上面讨论 自定义属性 ,以计算顶点需要多少变形。 我们还有一个全局 windScale 参数,用于控制风的整体强度,以及一个 windDirection 向量,用于指定风变形需要发生的方向。

因此,这会产生基于风的草片变形。 但是,我们还没有完成。 就像现在一样,这种变形是静态的,不会传达出风区的效果。

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

这是通过随时间推移 的 vOffset 传递给 NoiseShader 统一来完成的。 这是一个 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
...

就是这样! 我们刚刚创建了一个受风影响的“程序草”场景。

click here to open in a separate window

You can download the code of this demo from here .

在混合物中加入灰尘。

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

Adding dust

毕竟,灰尘应该会受到风的影响,所以在我们的风场景中让灰尘四处飞扬是非常合理的。

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 噪声纹理中 值。 然后,它使用这个值来修改灰尘粒子的位置。

至此,我们的场景就完成了。 这是它的样子:

click here to open in a separate window

You can download the code of this demo from here .

暴风雨中的骑手

我们的 WebGL 场景中最具冒险精神的可能是最后一个场景,如果您通过气球点击进入龙卷风的眼中以到达站点旅程的终点​​,您可以看到该场景,以及即将发布的独家视频。

Balloon ride scene

当我们创建这个场景时,我们知道我们需要一个具有影响力的体验的核心功能。 旋转的龙卷风将作为核心,其他内容层将塑造此功能以产生戏剧性的效果。 为了实现这一点,我们围绕这个奇怪的着色器构建了一个相当于电影制片厂的东西。

我们使用混合方法来创建逼真的合成。 有些是视觉技巧,例如用于制作镜头光晕效果的灯光形状,或在您正在查看的场景顶部动画为图层的雨滴。 在其他情况下,我们将平面绘制成似乎在四处移动,例如根据粒子系统代码移动的低空云层。 虽然围绕龙卷风运行的碎片在 3D 场景中是分层的,但在龙卷风前后移动。

我们必须以这种方式构建场景的主要原因是确保我们有足够的 GPU 来处理龙卷风着色器,以平衡我们正在应用的其他效果。 最初我们有很大的 GPU 平衡问题,但后来这个场景被优化并变得比主要场景更轻。

教程:风暴着色器

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

一个完全不同的项目最终为我们提供了答案。 一个涉及科学游戏的平行项目绘制了老鼠的大脑, 马克斯普朗克研究所 (brainflight.org) 的 产生了有趣的视觉效果。 我们已经设法使用自定义体积着色器创建了鼠标神经元内部的电影。

Inside of a mouse neuron using a custom volumetric shader

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

着色器技术涉及一个技巧,它基本上使用单个 GLSL 着色器来渲染整个对象,并使用一种简化的渲染算法,称为具有距离场的光线行进渲染。 在这种技术中,创建了一个像素着色器,它估计屏幕上每个点到表面的最近距离。

可以在 iq 的概述中找到对该算法的一个很好的参考: 用两个三角形渲染世界 – 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 的幂,不要太大,并且在循环中查找纹理数据时无论如何都要“小心”。

我们在兼容性方面遇到的最大问题是风暴的照明效果。 我们使用了包裹在龙卷风周围的预制纹理,这样我们就可以给它的一缕缕上色。 这是一个华丽的效果,可以很容易地将龙卷风融入场景颜色,但需要很长时间才能尝试在其他平台上运行。


Click here to open in a separate window

The source for our early tornado shader example can be downloaded here . 它与 glsl.heroku.com 包装器一起提供,以便为您提供实时编辑功能。

移动网站

移动体验不能直接翻译桌面版本,因为技术和处理要求太重了。 我们必须构建一些新的东西,专门针对移动用户。

我们认为将桌面上的 Carnival Photo-Booth 作为使用用户移动相机的移动 Web 应用程序会很酷。 到目前为止我们还没有看到过的事情。

为了增加风味,我们在 CSS3 中编写了 3D 转换。 将它与陀螺仪和加速度计联系起来后,我们能够为体验增加很多深度。 该网站会响应您持有、移动和查看手机的方式。

在撰写本文时,我们认为值得为您提供一些关于如何顺利运行移动开发过程的提示。 他们来了! 来吧,看看你能从中学到什么!

移动提示和技巧

预加载器是需要的,而不是应该避免的。 我们知道,有时会发生后者。 这主要是因为随着项目的增长,您需要继续维护您预加载的内容列表。 更糟糕的是,如果您同时拉取不同的资源,并且其中许多资源同时拉取,那么您应该如何计算加载进度并不是很清楚。 这就是我们自定义且非常通用的抽象类“任务”派上用场的地方。 它的主要思想是允许无限嵌套的结构,其中一个任务可以有它自己的子任务,子任务可以有它们的等等……此外,每个任务都会根据其子任务的进度(而不是父任务的进度)计算其进度。 让所有 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)、移动视网膜 (-2x.ext)、平板电脑标准 (-tab.ext) 和平板电脑视网膜 (-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 = $('').hide().css('background-image', 'url(' + url + ')');
    this.$preloadContainer.append($bg);
  } else {
    var $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 mobile 时,我们发现我们实际上花了很多时间玩照相亭而不是工作那只是因为它很有趣。 因此,我们制作了一个演示供您使用。

Mobile photo booth

You can download the source code here

您可以在此处查看现场演示(在您的 iPhone 或 Android 手机上运行): http://u9html5rocks.appspot.com/demos/mobile_photo_booth

要设置它,您需要一个免费的 Google App Engine 应用程序实例,您可以在其中运行后端。 前端代码并不复杂,但有几个可能的陷阱。 现在让我们来看看它们:

1、允许的图像文件类型

我们希望人们只能上传图片(因为它是照相亭,而不是视频亭)。 理论上,您可以只在 HTML 中指定过滤器,如下所示:

<input 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();
    }
  }
});

取消上传或文件选择

我们在开发过程中注意到的另一个不一致之处是不同设备如何通知取消的文件选择。 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 的庞大规模,以及所涉及的不同技术的广泛组合,在本文中,我们只能介绍我们使用的一些方法。

如果您对探索整个墨西哥卷饼感到好奇,请随时 中 的完整源代码 在此链接 查看 Find Your Way To Oz

相关链接

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

月牙弯弯

暂无简介

0 文章
0 评论
458 人气
更多

推荐作者

qq_Yqvrrd

文章 0 评论 0

2503248646

文章 0 评论 0

浮生未歇

文章 0 评论 0

养猫人

文章 0 评论 0

第七度阳光i

文章 0 评论 0

新雨望断虹

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文