Практический пример – Найдите свой путь в страну Оз

Введение

«Найди свой путь в страну Оз» — это новый эксперимент Google Chrome, представленный в сети компанией Disney. Он позволяет вам совершить интерактивное путешествие по цирку Канзаса, которое приведет вас в страну Оз после того, как вы попали в сильный шторм.

Нашей целью было объединить богатство кино с техническими возможностями браузера, чтобы создать увлекательный, захватывающий опыт, с которым пользователи смогут сформировать прочную связь.

Эта работа слишком велика, чтобы ее можно было охватить целиком в этой статье, поэтому мы углубились в нее и выделили несколько глав из истории технологий, которые, на наш взгляд, интересны. Попутно мы извлекли несколько целенаправленных руководств возрастающей сложности.

Многие люди упорно трудились, чтобы сделать этот опыт возможным: слишком много, чтобы их можно было перечислить здесь. Пожалуйста, посетите сайт , чтобы просмотреть страницу кредитов в разделе меню, чтобы узнать всю историю.

Заглянем под капот

Find Your Way to Oz на настольном компьютере — это богатый захватывающий мир. Мы используем 3D и несколько слоев традиционных эффектов кинопроизводства, которые в совокупности создают почти реалистичную сцену. Наиболее известными технологиями являются WebGL с Three.js, пользовательские шейдеры и анимированные элементы DOM с использованием функций CSS3. Помимо этого, API getUserMedia (WebRTC) для интерактивного взаимодействия позволяет пользователю добавлять свое изображение непосредственно с веб-камеры и WebAudio для 3D-звука.

Но магия такого технологического опыта заключается в том, как он складывается воедино. Это также одна из главных задач: как объединить визуальные эффекты и интерактивные элементы в одной сцене, чтобы создать единое целое? С этой визуальной сложностью было трудно справиться: было трудно определить, на какой стадии разработки мы находились в тот или иной момент времени.

Чтобы решить проблему взаимосвязанных визуальных эффектов и оптимизации, мы активно использовали панель управления, которая фиксировала все соответствующие настройки, которые мы проверяли на тот момент. Сцену можно было настроить в браузере в зависимости от яркости, глубины резкости, гаммы и т. д. Любой мог попробовать настроить значения важных параметров и принять участие в определении того, что работает лучше всего.

Прежде чем поделиться нашим секретом, мы хотим предупредить вас, что он может выйти из строя, как если бы вы покопались в двигателе автомобиля. Убедитесь, что у вас нет ничего важного, перейдите на основной URL-адрес сайта и добавьте к адресу ?debug=on . Подождите, пока сайт загрузится, и как только вы окажетесь внутри (нажмите?), нажмите клавишу Ctrl-I , и справа вы увидите раскрывающийся список. Если вы снимите флажок «Выйти из пути камеры», вы сможете использовать клавиши A, W, S, D и мышь для свободного перемещения по пространству.

Путь камеры.

Мы не будем здесь рассматривать все настройки, но рекомендуем вам поэкспериментировать: клавиши показывают разные настройки в разных сценах. В финальной сцене шторма есть дополнительная клавиша: Ctrl-A , с помощью которой вы можете переключать воспроизведение анимации и летать. В этой сцене, если вы нажмете Esc (чтобы выйти из функции блокировки мыши) и снова нажмете Ctrl-I , вы сможете получить доступ к настройкам, специфичным для сцены шторма. Осмотритесь вокруг и сделайте несколько красивых открыток, подобных изображенным ниже.

Сцена шторма

Чтобы это произошло и чтобы обеспечить достаточную гибкость для наших нужд, мы использовали прекрасную библиотеку dat.gui ( см. здесь прошлый урок о том, как ее использовать). Это позволило нам быстро изменить настройки, доступные посетителям сайта.

Немного похоже на матовую живопись

Во многих классических фильмах и мультфильмах Диснея создание сцен означало объединение разных слоев. Были слои живого действия, клеточной анимации, даже физические декорации, а верхние слои были созданы путем рисования на стекле: техника, называемая матовой живописью.

Во многом структура созданного нами опыта схожа; хотя некоторые из «слоев» представляют собой нечто большее, чем просто статичные визуальные эффекты. Фактически, они влияют на то, как все выглядит в соответствии с более сложными вычислениями. Тем не менее, по крайней мере на уровне общей картины, мы имеем дело с взглядами, наложенными одно на другое. Вверху вы видите слой пользовательского интерфейса, а под ним — 3D-сцену, состоящую из различных компонентов сцены.

Верхний уровень интерфейса был создан с использованием DOM и CSS 3, а это означало, что редактирование взаимодействий можно было выполнять разными способами независимо от трехмерного взаимодействия с взаимодействием между ними в соответствии с выбранным списком событий. В этом сообщении используется событие 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

Вот несколько советов о том, как мы использовали Sprite Sheets и как использовать их для устройств Retina и сделать интерфейс максимально четким и аккуратным.

Создание спрайтов

Для создания SpriteSheets мы использовали TextPacker , который выводит данные в любой нужный вам формат. В данном случае мы экспортировали как 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]
   },
}

Где:

  • изображение относится к URL-адресу листа спрайтов
  • фреймы — это координаты каждого элемента пользовательского интерфейса [x, y, ширина, высота]
  • анимации — это названия каждого актива

Обратите внимание, что мы использовали изображения высокой плотности для создания листа спрайтов, затем мы создали обычную версию, просто изменив ее размер до половины ее размера.

Собираем все вместе

Теперь, когда все готово, нам просто нужен фрагмент 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'));

Чтобы узнать больше о переменной плотности пикселей, вы можете прочитать эту статью Бориса Смуса .

Конвейер 3D-контента

Опыт среды настраивается на уровне WebGL. Когда вы думаете о 3D-сцене, один из самых сложных вопросов заключается в том, как вы собираетесь создавать контент, который обеспечивает максимальный выразительный потенциал со стороны моделирования, анимации и эффектов. Во многих отношениях в основе этой проблемы лежит конвейер контента: согласованный процесс, которому необходимо следовать для создания контента для 3D-сцены.

Мы хотели создать внушающий трепет мир; поэтому нам нужен был надежный процесс, который позволил бы 3D-художникам создать его. Им нужно будет предоставить как можно больше свободы выражения в их программном обеспечении для 3D-моделирования и анимации; и нам нужно будет отобразить его на экране с помощью кода.

Мы уже некоторое время работали над подобной проблемой, потому что каждый раз, когда мы создавали 3D-сайт в прошлом, мы обнаруживали ограничения в инструментах, которые мы могли использовать. Итак, мы создали инструмент под названием 3D Librarian: результат внутреннего исследования. И он был почти готов к использованию в реальной работе.

У этого инструмента была некоторая история: изначально он предназначался для Flash и позволял вам перенести большую сцену Maya в виде одного сжатого файла, оптимизированного для распаковки во время выполнения. Причина, по которой он был оптимальным, заключалась в том, что он эффективно упаковывал сцену в ту же самую структуру данных, которой манипулируют во время рендеринга и анимации. При загрузке файла необходимо выполнить очень небольшой анализ. Распаковка во Flash прошла довольно быстро, поскольку файл был в формате AMF, который Flash мог распаковать в исходном виде. Использование того же формата в WebGL требует немного больше нагрузки на процессор. Фактически нам пришлось заново создать уровень кода Javascript для распаковки данных, который по существу распаковал бы эти файлы и воссоздал структуры данных, необходимые для работы WebGL. Распаковка всей 3D-сцены — это операция с умеренной нагрузкой на процессор: распаковка сцены 1 в Find Your Way To Oz требует около 2 секунд на машине среднего и высокого класса. Поэтому это делается с использованием технологии Web Workers во время «настройки сцены» (до фактического запуска сцены), чтобы не зависать от работы пользователя.

Этот удобный инструмент позволяет импортировать большую часть 3D-сцены: модели, текстуры, анимацию костей. Вы создаете один файл библиотеки, который затем может быть загружен 3D-движком. Вы помещаете все модели, которые вам нужны в вашей сцене, в эту библиотеку и, вуаля, создаете их в своей сцене.

Проблема, однако, заключалась в том, что теперь мы имели дело с WebGL: новичком на рынке. Это был довольно трудный ребенок: он устанавливал стандарты для браузерного 3D-опыта. Поэтому мы создали специальный слой Javascript, который будет брать сжатые файлы 3D-сцен 3D Librarian и правильно переводить их в формат, понятный WebGL.

Учебник: Да будет ветер

Постоянной темой в «Найди свой путь в страну Оз» был ветер. Нить сюжетной линии построена так, чтобы представлять собой крещендо ветра.

Первая сцена карнавала относительно спокойна. Проходя различные сцены, пользователь испытывает все более сильный ветер, кульминацией которого становится финальная сцена — шторм.

Поэтому было важно обеспечить иммерсивный эффект ветра.

Чтобы создать это, мы наполнили три сцены карнавала объектами, которые были мягкими и, следовательно, должны были подвергаться воздействию ветра, такими как палатки, флаги, поверхность фотобудки и сам воздушный шар.

Мягкая ткань.

В наши дни настольные игры обычно строятся на основе основного физического движка. Поэтому, когда мягкий объект необходимо смоделировать в трехмерном мире, для него запускается полная физическая симуляция, создавая правдоподобное мягкое поведение.

В WebGL/Javascript мы (пока) не можем позволить себе роскошь запустить полноценную физическую симуляцию. Поэтому в Озе нам пришлось найти способ создать эффект ветра, не имитируя его.

Информацию о «чувствительности к ветру» для каждого объекта мы встроили в саму 3D-модель. Каждая вершина 3D-модели имела «Атрибут ветра», который определял, насколько сильно ветер должен влиять на эту вершину. Итак, это заданная чувствительность 3D-объектов к ветру. Затем нам нужно было создать сам ветер.

Мы сделали это, создав изображение, содержащее Perlin Noise . Это изображение предназначено для освещения определенной «области ветра». Итак, хороший способ подумать об этом — представить себе изображение облачного шума, наложенного на определенную прямоугольную область 3D-сцены. Каждый пиксель, значение уровня серого, этого изображения определяет, насколько силен ветер в определенный момент в трехмерной области, «окружающей его».

Чтобы создать эффект ветра, изображение перемещается во времени с постоянной скоростью в определенном направлении; направление ветра. И чтобы убедиться, что «область ветра» не влияет на все в сцене, мы оборачиваем изображение ветра по краям, ограничивая область эффекта.

Простое руководство по 3D-ветеру

Давайте теперь создадим эффект ветра в простой 3D-сцене в Three.js.

Мы собираемся создать ветер на простом «процедурном травяном поле».

Давайте сначала создадим сцену. У нас будет простой, текстурированный плоский ландшафт. И тогда каждый кусочек травы будет просто представлен в виде перевернутого 3D-конуса.

Травяной ландшафт
Травяной ландшафт

Вот как создать эту простую сцену в Three.js с помощью CoffeeScript .

Прежде всего мы собираемся настроить 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()

Вызовы функций 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 на 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 вместо MeshPhongMaterial , который мы использовали ранее. WindMaterial оборачивает WindMeshShader , который мы увидим через минуту.

Итак, код в экземпляреGrass проходит по всем вершинам модели травы и для каждой вершины добавляет собственный атрибут вершины, называемый WindFactor . Для этого параметра WindFactor установлено значение 0 для нижнего края модели травы (там, где он должен касаться местности), и значение 1 для верхнего края модели травы.

Другой ингредиент, который нам нужен, — это добавить в нашу сцену настоящий ветер. Как уже говорилось, мы собираемся использовать для этого шум Перлина. Мы процедурно сгенерируем текстуру шума Перлина.

Для ясности мы собираемся назначить эту текстуру самой местности вместо предыдущей зеленой текстуры, которая у нее была. Это облегчит понимание того, что происходит с ветром.

Итак, эта текстура шума Перлина будет пространственно покрывать протяженность нашей местности, и каждый пиксель текстуры будет определять интенсивность ветра в той области местности, куда попадает этот пиксель. Прямоугольник местности будет нашей «областью ветра».

Шум Перлина процедурно генерируется с помощью шейдера NoiseShader . Этот шейдер использует алгоритмы симплексного 3D-шума из: https://github.com/ashima/webgl-noise . Версия WebGL была дословно взята из одного из примеров Three.js MrDoob по адресу: http://mrdoob.github.com/three.js/examples/webgl_terrain_dynamic.html .

NoiseShader принимает время, масштаб и набор параметров смещения в качестве униформ и выводит красивое двумерное распределение шума Перлина.

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

...

Мы собираемся использовать этот шейдер для рендеринга шума Перлина в текстуру. Это делается в функции 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 из текстуры шума ветра Перлина.

Это значение 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
...

И это все! Мы только что создали сцену с «процедурной травой», на которую влияет ветер.

Добавление пыли в смесь

Теперь давайте немного оживим нашу сцену. Давайте добавим немного летящей пыли, чтобы сделать сцену интереснее.

Добавление пыли
Добавление пыли

В конце концов, на пыль влияет ветер, поэтому вполне логично, что в нашей сцене с ветром летает пыль.

Пыль настраивается в функции 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;

Этот вершинный шейдер не сильно отличается от того, что мы использовали для деформации травы ветром. Он принимает текстуру шума Перлина в качестве входных данных и в зависимости от положения мира пыли ищет значение vWindForce в текстуре шума. Затем он использует это значение для изменения положения частицы пыли.

Всадники на шторме

Самой авантюрной из наших сцен WebGL, вероятно, была последняя сцена, которую вы можете увидеть, если щелкнете по воздушному шару в глазу торнадо, чтобы добраться до конца своего путешествия на сайте, а также эксклюзивное видео предстоящего выпуска.

Сцена полета на воздушном шаре

Когда мы создавали эту сцену, мы знали, что нам нужна центральная особенность опыта, которая будет впечатляющей. Вращающийся торнадо будет центральным элементом, а слои другого контента будут формировать эту особенность, создавая драматический эффект. Чтобы добиться этого, мы построили что-то вроде киностудии вокруг этого странного шейдера.

Мы использовали смешанный подход для создания реалистичной композиции. Некоторые из них представляли собой визуальные трюки, такие как световые фигуры, создающие эффект бликов, или капли дождя, которые анимировались в виде слоев поверх сцены, на которую вы смотрите. В других случаях у нас были нарисованы плоские поверхности, которые создавали впечатление движущихся, как слои низко летящих облаков, движущихся в соответствии с кодом системы частиц. В то время как кусочки мусора, вращающиеся вокруг торнадо, представляли собой слои в трехмерной сцене, отсортированные так, чтобы двигаться спереди и сзади торнадо.

Основная причина, по которой нам пришлось построить сцену таким образом, заключалась в том, чтобы гарантировать, что у нас достаточно графического процессора для обработки шейдера торнадо в балансе с другими эффектами, которые мы применяли. Изначально у нас были большие проблемы с балансировкой графического процессора, но позже эта сцена была оптимизирована и стала легче основных сцен.

Учебник: Шейдер Шторма

Для создания финальной сцены шторма было использовано множество различных техник, но центральным элементом этой работы был собственный шейдер GLSL, похожий на торнадо. Мы испробовали много разных техник: от вершинных шейдеров для создания интересных геометрических водоворотов до анимации на основе частиц и даже 3D-анимации закрученных геометрических фигур. Ни один из эффектов, похоже, не воссоздавал ощущение торнадо и не требовал слишком сложной обработки.

Совершенно другой проект в конечном итоге дал нам ответ. Параллельный проект Института Макса Планка (brainflight.org), включавший научные игры по картированию мозга мыши, создал интересные визуальные эффекты. Нам удалось создать видеоролики внутренней части нейрона мыши с помощью специального объемного шейдера.

Внутри нейрона мыши с использованием специального объемного шейдера
Внутри нейрона мыши с использованием специального объемного шейдера

Мы обнаружили, что внутренняя часть клетки мозга немного напоминает воронку торнадо. А поскольку мы использовали объемную технику, мы знали, что можем видеть этот шейдер со всех сторон пространства. Мы могли бы настроить рендеринг шейдера так, чтобы он сочетался со сценой шторма, особенно если он расположен под слоями облаков и над драматическим фоном.

Техника шейдеров включает в себя трюк, который по сути использует один шейдер GLSL для рендеринга всего объекта с помощью упрощенного алгоритма рендеринга, называемого рендерингом с лучевым маршированием с полем расстояний. В этом методе создается пиксельный шейдер, который оценивает ближайшее расстояние до поверхности для каждой точки на экране.

Хорошую ссылку на алгоритм можно найти в обзоре iq: Rendering Worlds With Two Triangles — Iñigo Quilez . Также просматривая галерею шейдеров на glsl.heroku.com, можно найти множество примеров этой техники, с которыми можно поэкспериментировать.

Сердце шейдера начинается с основной функции: она настраивает трансформации камеры и входит в цикл, который неоднократно оценивает расстояние до поверхности. Вызов 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 (чтобы избежать блочного вида) @line 1107 в StormTest.coffee. Это означает, что в худшем случае мы получим более размытый торнадо, но, по крайней мере, он работает, не лишая пользователя контроля.

Следующий шаг оптимизации требует погружения в алгоритм. Ведущим вычислительным фактором в шейдере является итерация, выполняемая для каждого пикселя, чтобы попытаться аппроксимировать расстояние функции поверхности: количество итераций цикла марширования лучей. Используя больший размер шага, мы могли бы получить оценку поверхности торнадо с меньшим количеством итераций, находясь за пределами его облачной поверхности. Внутри мы уменьшим размер шага для точности и возможности смешивать значения для создания туманного эффекта. Также хорошее ускорение дало создание ограничивающего цилиндра для оценки глубины брошенного луча.

Следующая часть проблемы заключалась в том, чтобы убедиться, что этот шейдер будет работать на разных видеокартах. Каждый раз мы проводили небольшое тестирование и начинали интуитивно понимать, с какими проблемами совместимости мы можем столкнуться. Причина, по которой мы не могли добиться большего, чем интуиция, заключается в том, что мы не всегда могли получить хорошую отладочную информацию об ошибках. Типичный сценарий — это просто ошибка графического процессора, и ничего больше не происходит, или даже сбой системы!

Проблемы совместимости между видеокартами имели аналогичные решения: убедитесь, что статические константы введены точного типа данных, как определено, IE: 0.0 для float и 0 для int. Будьте осторожны при написании более длинных функций; предпочтительнее разбить все на несколько более простых функций и промежуточных переменных, потому что компиляторы, похоже, неправильно обрабатывают некоторые случаи. Убедитесь, что все текстуры имеют степень двойки, не слишком велики и в любом случае соблюдайте «осторожность» при поиске данных текстуры в цикле.

Самые большие проблемы с совместимостью у нас были из-за эффекта освещения грозы. Мы использовали готовую текстуру, обернутую вокруг торнадо, чтобы раскрасить его клочья. Это был великолепный эффект, позволяющий легко смешать торнадо с цветами сцены, но потребовалось много времени, чтобы попытаться запустить его на других платформах.

торнадо

Мобильный веб-сайт

Мобильная версия не могла быть прямым переводом настольной версии, потому что требования к технологиям и обработке были слишком высокими. Нам нужно было создать что-то новое, специально ориентированное на мобильных пользователей.

Мы подумали, что было бы здорово иметь Carnival Photo-Booth для настольного компьютера в виде мобильного веб-приложения, которое использовало бы мобильную камеру пользователя. То, чего мы еще не видели.

Чтобы добавить изюминку, мы закодировали 3D-преобразования в CSS3. Связав его с гироскопом и акселерометром, мы смогли добавить глубины этому опыту. Сайт реагирует на то, как вы держите, двигаетесь и смотрите на свой телефон.

При написании этой статьи мы подумали, что стоит дать вам несколько советов о том, как организовать процесс мобильной разработки без проблем. Вот они! Идите вперед и посмотрите, чему вы можете научиться из этого!

Мобильные советы и рекомендации

Preloader — это то, что необходимо, а не то, чего следует избегать. Мы знаем, что иногда случается последнее. Это происходит главным образом потому, что вам необходимо поддерживать список вещей, которые вы предварительно загружаете, по мере роста вашего проекта. Что еще хуже, не очень понятно, как следует рассчитывать ход загрузки, если вы извлекаете разные ресурсы, причем многие из них одновременно. Именно здесь нам пригодится наш специальный и очень общий абстрактный класс Task. Его основная идея состоит в том, чтобы разрешить бесконечно вложенную структуру, в которой Задача может иметь свои собственные подзадачи, которые могут иметь свои и т. д. Кроме того, каждая задача вычисляет свой прогресс относительно прогресса своих подзадач (но не прогресса родительской задачи). Сделав все MainPreloadTask, AssetPreloadTask и TemplatePreFetchTask производными от Task, мы создали структуру, которая выглядит следующим образом:

Прелоадер

Благодаря такому подходу и классу Task мы можем легко узнать глобальный прогресс (MainPreloadTask), или просто прогресс ресурсов (AssetPreloadTask), или прогресс загрузки шаблонов (TemplatePreFetchTask). Даже прогресс конкретного файла. Чтобы увидеть, как это делается, взгляните на класс Task в /m/javascripts/raw/util/Task.js и реальные реализации задач в /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). Вместо того, чтобы выполнять обнаружение в 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 Mobile мы обнаружили, что тратим много времени на игры с фотокабиной, а не на работу :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 и широкий набор различных задействованных технологий, в этой статье мы смогли охватить лишь некоторые из использованных нами подходов.

Если вам интересно изучить всю энчиладу, не стесняйтесь просмотреть полный исходный код Find Your Way To Oz по этой ссылке .

Кредиты

Нажмите здесь, чтобы просмотреть полный список кредитов

Рекомендации