Анимация миллиона букв с помощью Three.js

Ilmari Heikkinen

Введение

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

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

Написание эффективного кода WebGL

Написание эффективного кода WebGL требует определенного мышления. Обычный способ рисования с использованием WebGL — это настройка форм, буферов и шейдеров для каждого объекта с последующим вызовом рисования объекта. Этот способ рисования работает при рисовании небольшого количества объектов. Чтобы отрисовать большое количество объектов, вам следует минимизировать количество изменений состояния WebGL. Для начала нарисуйте все объекты, используя один и тот же шейдер, друг за другом, чтобы вам не приходилось менять шейдеры между объектами. Для простых объектов, таких как частицы, вы можете объединить несколько объектов в один буфер и редактировать его с помощью JavaScript. Таким образом, вам нужно будет только повторно загрузить буфер вершин вместо того, чтобы менять форму шейдера для каждой отдельной частицы.

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

В коде статьи используется библиотека Three.js , которая позволяет абстрагироваться от всех утомительных шаблонов, связанных с написанием кода WebGL. Вместо того, чтобы писать сотни строк настройки состояния WebGL и обработки ошибок, с Three.js вам нужно написать всего пару строк кода. Также легко подключиться к системе шейдеров WebGL из Three.js.

Рисование нескольких объектов с помощью одного вызова отрисовки

Вот небольшой пример псевдокода того, как можно нарисовать несколько объектов, используя один вызов отрисовки. Традиционный способ — рисовать по одному объекту, вот так:

for (var i=0; i<objects.length; i++) {
  // each added object requires a separate WebGL draw call
  scene.add(createNewObject(objects[i]));
}
renderer.render(scene, camera);

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

var geo = new THREE.Geometry();
for (var i=0; i<objects.length; i++) {
  // bundle the objects into a single geometry
  // so that they can be drawn with a single draw call
  addObjectToGeometry(geo, objects[i]);
}
// GOOD! Only one object to add to the scene!
scene.add(new THREE.Mesh(geo, material));
renderer.render(scene, camera);

Хорошо, теперь, когда у вас есть основная идея, давайте вернемся к написанию демо-версии и начнем анимировать эти миллионы букв!

Настройка геометрии и текстур

В качестве первого шага я собираюсь создать текстуру с растровыми изображениями букв. Для этого я использую 2D-холст. В полученной текстуре есть все буквы, которые я хочу нарисовать. Следующим шагом будет создание буфера с координатами текстуры для листа спрайтов букв. Хотя это простой и понятный метод настройки букв, он немного расточителен, поскольку в качестве координат текстуры используется два числа с плавающей запятой на каждую вершину. Более короткий способ — оставленный читателю в качестве упражнения — состоит в том, чтобы упаковать буквенный индекс и угловой индекс в одно число и преобразовать его обратно в координаты текстуры в вершинном шейдере.

Вот как я создаю текстуру буквы с помощью Canvas 2D:

var fontSize = 16;

// The square letter texture will have 16 * 16 = 256 letters, enough for all 8-bit characters.
var lettersPerSide = 16;

var c = document.createElement('canvas');
c.width = c.height = fontSize*lettersPerSide;
var ctx = c.getContext('2d');
ctx.font = fontSize+'px Monospace';

// This is a magic number for aligning the letters on rows. YMMV.
var yOffset = -0.25;

// Draw all the letters to the canvas.
for (var i=0,y=0; y<lettersPerSide; y++) {
  for (var x=0; x<lettersPerSide; x++,i++) {
    var ch = String.fromCharCode(i);
    ctx.fillText(ch, x*fontSize, yOffset*fontSize+(y+1)*fontSize);
  }
}

// Create a texture from the letter canvas.
var tex = new THREE.Texture(c);
// Tell Three.js not to flip the texture.
tex.flipY = false;
// And tell Three.js that it needs to update the texture.
tex.needsUpdate = true;

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

Создание геометрии для книги:

var geo = new THREE.Geometry();

var i=0, x=0, line=0;
for (i=0; i<BOOK.length; i++) {
  var code = BOOK.charCodeAt(i); // This is the character code for the current letter.
  if (code > lettersPerSide * lettersPerSide) {
    code = 0; // Clamp character codes to letter map size.
  }
  var cx = code % lettersPerSide; // Cx is the x-index of the letter in the map.
  var cy = Math.floor(code / lettersPerSide); // Cy is the y-index of the letter in the map.

  // Add letter vertices to the geometry.
  var v,t;
  geo.vertices.push(
    new THREE.Vector3( x*1.1+0.05, line*1.1+0.05, 0 ),
    new THREE.Vector3( x*1.1+1.05, line*1.1+0.05, 0 ),
    new THREE.Vector3( x*1.1+1.05, line*1.1+1.05, 0 ),
    new THREE.Vector3( x*1.1+0.05, line*1.1+1.05, 0 )
  );
  // Create faces for the letter.
  var face = new THREE.Face3(i*4+0, i*4+1, i*4+2);
  geo.faces.push(face);
  face = new THREE.Face3(i*4+0, i*4+2, i*4+3);
  geo.faces.push(face);

  // Compute texture coordinates for the letters.
  var tx = cx/lettersPerSide, 
      ty = cy/lettersPerSide,
      off = 1/lettersPerSide;
  var sz = lettersPerSide*fontSize;
  geo.faceVertexUvs[0].push([
    new THREE.Vector2( tx, ty+off ),
    new THREE.Vector2( tx+off, ty+off ),
    new THREE.Vector2( tx+off, ty )
  ]);
  geo.faceVertexUvs[0].push([
    new THREE.Vector2( tx, ty+off ),
    new THREE.Vector2( tx+off, ty ),
    new THREE.Vector2( tx, ty )
  ]);

  // On newline, move to the line below and move the cursor to the start of the line.
  // Otherwise move the cursor to the right.
  if (code == 10) {
    line--;
    x=0;
  } else {
    x++;
  }
}

Вершинный шейдер для анимации букв

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

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

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

Округление координат вершины вниз, чтобы найти верхний левый угол буквы.
Округление координат вершины вниз, чтобы найти верхний левый угол буквы.

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

varying float vUv;

void main() {
  // modelViewMatrix, position and projectionMatrix are magical
  // attributes that Three.js defines for us.

  // Transform current vertex by the modelViewMatrix
  // (bundled model world position & camera world position matrix).
  vec4 mvPosition = modelViewMatrix * position;

  // Project camera-space vertex to screen coordinates
  // using the camera's projection matrix.
  vec4 p = projectionMatrix * mvPosition;

  // uv is another magical attribute from Three.js.
  // We're passing it to the fragment shader unchanged.
  vUv = uv;

  gl_Position = p;
}

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

uniform float uTime;
uniform float uEffectAmount;

varying float vZ;
varying vec2 vUv;

// rotateAngleAxisMatrix returns the mat3 rotation matrix
// for given angle and axis.
mat3 rotateAngleAxisMatrix(float angle, vec3 axis) {
  float c = cos(angle);
  float s = sin(angle);
  float t = 1.0 - c;
  axis = normalize(axis);
  float x = axis.x, y = axis.y, z = axis.z;
  return mat3(
    t*x*x + c,    t*x*y + s*z,  t*x*z - s*y,
    t*x*y - s*z,  t*y*y + c,    t*y*z + s*x,
    t*x*z + s*y,  t*y*z - s*x,  t*z*z + c
  );
}

// rotateAngleAxis rotates a vec3 over the given axis by the given angle and
// returns the rotated vector.
vec3 rotateAngleAxis(float angle, vec3 axis, vec3 v) {
  return rotateAngleAxisMatrix(angle, axis) * v;
}

void main() {
  // Compute the index of the letter (assuming 80-character max line length).
  float idx = floor(position.y/1.1)*80.0 + floor(position.x/1.1);

  // Round down the vertex coords to find the bottom-left corner point of the letter.
  vec3 corner = vec3(floor(position.x/1.1)*1.1, floor(position.y/1.1)*1.1, 0.0);

  // Find the midpoint of the letter.
  vec3 mid = corner + vec3(0.5, 0.5, 0.0);

  // Rotate the letter around its midpoint by an angle and axis dependent on
  // the letter's index and the current time.
  vec3 rpos = rotateAngleAxis(idx+uTime,
    vec3(mod(idx,16.0), -8.0+mod(idx,15.0), 1.0), position - mid) + mid;

  // uEffectAmount controls the amount of animation applied to the letter.
  // uEffectAmount ranges from 0.0 to 1.0.
  float effectAmount = uEffectAmount;

  vec4 fpos = vec4( mix(position,rpos,effectAmount), 1.0 );
  fpos.x += -35.0;

  // Apply spinning motion to individual letters.
  fpos.z += ((sin(idx+uTime*2.0)))*4.2*effectAmount;
  fpos.y += ((cos(idx+uTime*2.0)))*4.2*effectAmount;

  vec4 mvPosition = modelViewMatrix * fpos;

  // Apply wavy motion to the entire text.
  mvPosition.y += 10.0*sin(uTime*0.5+mvPosition.x/25.0)*effectAmount;
  mvPosition.x -= 10.0*cos(uTime*0.5+mvPosition.y/25.0)*effectAmount;

  vec4 p = projectionMatrix * mvPosition;

  // Pass texture coordinates and the vertex z-coordinate to the fragment shader.
  vUv = uv;
  vZ = p.z;

  // Send the final vertex position to WebGL.
  gl_Position = p;
}

Чтобы использовать вершинный шейдер, я использую THREE.ShaderMaterial , тип материала, который позволяет вам использовать собственные шейдеры и указывать для них униформу. Вот как я использую THREE.ShaderMaterial в демо:

// First, set up uniforms for the shader.
var uniforms = {

  // map contains the letter map texture.
  map: { type: "t", value: 1, texture: tex },

  // uTime is the urrent time.
  uTime: { type: "f", value: 1.0 },

  // uEffectAmount controls the amount of animation applied to the letters.
  uEffectAmount: { type: "f", value: 0.0 }
};

// Next, set up the THREE.ShaderMaterial.
var shaderMaterial = new THREE.ShaderMaterial({
  uniforms: uniforms,

  // I have my shaders inside HTML elements like
  // <script id="vertex" type="text/x-glsl-vert">... shaderSource ... <script>

  // The below gets the contents of the vertex shader script element.
  vertexShader: document.querySelector('#vertex').textContent,

  // The fragment shader is a bit special as well, drawing a rotating
  // rainbow gradient.
  fragmentShader: document.querySelector('#fragment').textContent
});

// I set depthTest to false so that the letters don't occlude each other.
shaderMaterial.depthTest = false;

В каждом кадре анимации я обновляю форму шейдера:

// I'm controlling the uniforms through a proxy control object.
// The reason I'm using a proxy control object is to
// have different value ranges for the controls and the uniforms.
var controller = {
  effectAmount: 0
};

// I'm using <a href="http://code.google.com/p/dat-gui/">DAT.GUI</a> to do a quick & easy GUI for the demo.
var gui = new dat.GUI();
gui.add(controller, 'effectAmount', 0, 100);

var animate = function(t) {
  uniforms.uTime.value += 0.05;
  uniforms.uEffectAmount.value = controller.effectAmount/100;
  bookModel.position.y += 0.03;

  renderer.render(scene, camera);
  requestAnimationFrame(animate, renderer.domElement);
};
animate(Date.now());

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

Остающиеся проблемы

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

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

Другая проблема заключается в том, что 256 символов слишком мало для текста, отличного от ASCII. Если увеличить размер карты текстуры до 4096x4096, уменьшив при этом размер шрифта до 8 пикселей, вы сможете поместить весь набор символов UCS-2 в карту текстуры. Однако размер шрифта 8 пикселей не очень читаем. Чтобы сделать шрифт большего размера, вы можете использовать для него несколько текстур. Пример см. в этой демонстрации атласа спрайтов . Еще одна вещь, которая может помочь, — это создать только те буквы, которые используются в вашем тексте.

Краткое содержание

В этой статье я рассказал вам о реализации демонстрации анимации на основе вершинного шейдера с использованием Three.js. Демо-версия анимирует миллион букв в реальном времени на MacBook Air 2010 года. Реализация объединила всю книгу в один геометрический объект для эффективного рисования. Отдельные буквы были анимированы путем выяснения того, какие вершины принадлежат какой букве, и анимации вершин на основе индекса буквы в тексте книги.

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