Three.js kullanarak bir milyon harfi canlandırma

Ilmari Heikkinen

Giriş

Bu makaledeki amacım, ekrana sorunsuz bir kare hızında bir milyon animasyonlu harf çizmektir. Bu görev modern GPU'larla mümkün olacaktır. Her harf iki dokulu üçgenden oluşur, yani kare başına sadece iki milyon üçgenden bahsediyoruz.

Geleneksel bir JavaScript animasyon arka planından geliyorsanız bunların hepsi kulağa çılgınca geliyor. Her karede iki milyon üçgenin güncellenmesi, bugün JavaScript ile kesinlikle yapmak istediğiniz bir şey değildir. Ama neyse ki modern GPU'ların muhteşem gücünden yararlanmamızı sağlayan WebGL'miz var. Modern bir GPU ve biraz da gölgelendirici sihirle iki milyon animasyonlu üçgen oluşturmak çok kolay.

Verimli WebGL kodu yazma

Verimli WebGL kodu yazmak için belirli bir düşünce yapısı gerekir. WebGL kullanarak çizim yapmanın normal yolu, her bir nesne için üniformalarınızı, arabelleklerinizi ve gölgelendiricilerinizi ayarlamak ve ardından nesneyi çizmek için bir çağrı yapmaktır. Bu çizim yöntemi az sayıda nesne çizilirken işe yarar. Çok sayıda nesne çizmek için WebGL durum değişikliği miktarını en aza indirmelisiniz. İlk olarak, nesneler arasında gölgelendiricileri değiştirmek zorunda kalmamak için tüm nesneleri aynı gölgelendiriciyi kullanarak çizin. Parçacıklar gibi basit nesneler için, birkaç nesneyi tek bir arabelleğe yerleştirebilir ve JavaScript kullanarak düzenleyebilirsiniz. Bu şekilde, her bir parçacık için gölgelendirici üniformalarını değiştirmek yerine yalnızca köşe arabelleğini yeniden yüklemeniz gerekir.

Ancak, çok hızlı hareket etmek için hesaplamalarınızın büyük kısmını gölgelendiricilere aktarmanız gerekir. Bunu yapmaya çalışıyorum. Gölgelendiricileri kullanarak bir milyon harfi canlandırın.

Makalenin kodu, tüm yorucu ortak metinleri WebGL kodu yazma işleminden soyutlayan Three.js kitaplığını kullanır. Three.js ile yüzlerce satır WebGL durum kurulumu ve hata işleme yazmak zorunda kalmak yerine sadece birkaç satır kod yazmanız yeterli. Three.js'den WebGL gölgelendirici sistemine de kolayca dokunabilirsiniz.

Tek bir çizim çağrısı kullanarak birden çok nesne çizme

Aşağıda, tek bir çizim çağrısı kullanarak birden çok nesneyi nasıl çizebileceğinize dair küçük bir yapay kod örneği verilmiştir. Geleneksel yöntem, aşağıdaki gibi her seferinde bir nesne çizmektir:

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

Ancak yukarıdaki yöntem, her nesne için ayrı bir çizim çağrısı gerektirir. Aynı anda birden fazla nesne çizmek için nesneleri tek bir geometride gruplandırabilir ve tek bir çizim çağrısıyla devam edebilirsiniz:

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

Pekala, artık temel fikri öğrendiğinize göre demoyu yazmaya geri dönelim ve milyonlarca harften animasyon oluşturmaya başlayalım.

Geometriyi ve dokuları ayarlama

İlk adım olarak, üzerinde harf bit eşlemleriyle bir doku oluşturacağım. Bunun için 2D kanvas kullanıyorum. Ortaya çıkan doku, çizmek istediğim tüm harfleri içeriyor. Sonraki adım, harf model sayfasının doku koordinatlarıyla bir arabellek oluşturmaktır. Bu, harfleri ayarlamak için kolay ve basit bir yöntem olsa da, doku koordinatları için köşe başına iki kayan reklam kullandığından biraz israfa neden olur. Daha kısa bir yol (okuyucu için alıştırma olarak bırakmak), harf dizinini ve köşe dizinini tek bir sayıya paketlemek ve bunu tekrar köşe gölgelendiricisinde doku koordinatlarına dönüştürmektir.

Tuval 2D'yi kullanarak harf dokusunu şu şekilde oluştururum:

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;

Ayrıca üçgen diziyi GPU'ya da yüklüyorum. Bu köşeler, harfleri ekrana yerleştirmek için köşe gölgelendirici tarafından kullanılır. Köşeler, metindeki harf konumlarına ayarlanır. Böylece, üçgen dizisini olduğu gibi oluşturursanız, metnin temel düzen oluşturma işlemini elde edersiniz.

Kitabın geometrisini oluşturma:

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++;
  }
}

Harfleri canlandırmak için köşe gölgelendirici

Basit bir köşe gölgelendiriciyle, metnin düz bir görünümünü elde ediyorum. Karmaşık bir çözüme gerek yok. İyi çalışıyor, ancak canlandırmak istersem, animasyonu JavaScript'te yapmam gerekiyor. JavaScript, ilgili altı milyon köşeyi canlandırmak için biraz yavaştır, özellikle de bunu her karede yapmak istiyorsanız. Belki daha hızlı bir yol vardır.

Neden evet, prosedürsel animasyon yapabiliriz. Diğer bir deyişle, tüm konum ve döndürme hesaplamalarımızı köşe gölgelendiricide yaparız. Artık köşelerin konumlarını güncellemek için herhangi bir JavaScript çalıştırmama gerek yok. Köşe gölgelendirici çok hızlı çalışıyor ve her karede ayrı ayrı canlandırılan milyonlarca üçgenle bile akıcı bir kare hızı elde ediyorum. Üçgenleri tek tek ele almak için, bir harf dörtlüsünün dört noktasının tümü tek bir benzersiz koordinatla eşlenecek şekilde tepe noktası koordinatlarını aşağı yuvarlıyorum. Şimdi söz konusu harf için animasyon parametrelerini ayarlamak üzere bu koordinatı kullanabilirim.

Koordinatları başarılı bir şekilde yuvarlayabilmek için, iki farklı harften alınan koordinatlar üst üste bindirilemez. Bunu yapmanın en kolay yolu, harfin sağ tarafındaki çizgiyle sağ tarafındaki çizgiyi ayıran küçük bir ofsete sahip kare harf dörtlüleri kullanmaktır. Örneğin, harfler için 0,5 genişlik ve yükseklik kullanabilir ve tam sayı koordinatlarındaki harfleri hizalayabilirsiniz. Şimdi, herhangi bir harf köşesinin koordinatını aşağı yuvarladığınızda, harfin sol alt koordinatını elde edersiniz.

Bir harfin sol üst köşesini bulmak için köşe koordinatlarını aşağı yuvarlama.
Bir harfin sol üst köşesini bulmak için köşe koordinatlarını aşağı yuvarlama.

Animasyonlu köşe gölgelendiricisini daha iyi anlamak için önce basit bir köşe gölgelendiricisini göstereceğim. Ekrana bir 3D model çizdiğinizde, normalde bu durum gerçekleşir. Modelin köşeleri, her bir 3D köşe noktasını 2D ekrana yansıtmak için birkaç dönüşüm matrisi tarafından dönüştürülür. Bu köşelerden üçüyle tanımlanan bir üçgen görüntü alanının içine girdiğinde, kapladığı pikseller parça gölgelendirici tarafından işlenerek renklendirilir. Her neyse, işte basit köşe gölgelendirici:

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

Şimdi de animasyonlu köşe gölgelendirici. Temel olarak basit köşe gölgelendiriciyle aynı işlevi görür, ancak küçük bir değişikliği vardır. Her bir tepe noktasını yalnızca dönüşüm matrisleriyle dönüştürmek yerine, zamana bağlı bir animasyonlu dönüşüm de uygular. Her bir harfi biraz farklı bir şekilde canlandırmak için, animasyonlu köşe gölgelendiricisi de animasyonu harfin koordinatlarına göre değiştirir. Basit köşe gölgelendiricisinden daha karmaşık bir görünüme sahip olabilir, çünkü daha karmaşık olduğunu.

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

Köşe gölgelendiricisini kullanmak için, özel gölgelendiriciler kullanmanıza ve üniformalar belirlemenize olanak tanıyan bir malzeme türü olan THREE.ShaderMaterial kullanıyorum. Demoda THREE.ShaderMaterial'ı şu şekilde kullanıyorum:

// 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;

Her animasyon karesinde gölgelendiricinin üniformalarını güncelliyorum:

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

İşte burada, gölgelendiriciye dayalı animasyon. Oldukça karmaşık görünmektedir, ancak gerçekte yaptığı tek şey harfleri o anki saate ve her bir harfin dizinine bağlı olarak hareket ettirmektir. Performans meselesi değilse, bu mantığı JavaScript'te çalıştırabilirsiniz. Ancak, on binlerce animasyonlu nesne söz konusu olduğunda JavaScript artık geçerli bir çözüm olmaktan çıkıyor.

Devam eden endişeler

Şu anki sorunlardan biri, JavaScript'in tanecik konumlarını bilmemesidir. Parçacıklarınızın nerede olduğunu gerçekten bilmeniz gerekiyorsa, JavaScript'te köşe gölgelendirici mantığını çoğaltabilir ve bu konumlara her ihtiyacınız olduğunda bir web çalışanı kullanarak köşe noktası konumlarını yeniden hesaplayabilirsiniz. Böylece oluşturma ileti dizinizin matematiği beklemesine gerek kalmaz ve animasyonunuza sorunsuz bir kare hızında devam edebilirsiniz.

Daha kontrol edilebilir animasyonlar için, JavaScript tarafından sağlanan iki konum grubu arasında animasyon oluşturmak üzere oluşturdan dokuya işlevselliğini kullanabilirsiniz. Önce, geçerli konumları bir doku olarak oluşturun, ardından JavaScript tarafından sağlanan ayrı bir dokuda tanımlanan konumlara doğru hareket edin. Bunun iyi tarafı, JavaScript tarafından sağlanan konumların her kare için küçük bir bölümünü güncelleyebilmeniz ve köşe noktası gölgelendirici ile konumları aratarak her karede tüm harfleri canlandırmaya devam edebilmenizdir.

Başka bir sorun da 256 karakterin, ASCII olmayan metinler için çok az olmasıdır. Doku haritası boyutunu 4096x4096'ya iterken yazı tipi boyutunu 8 piksele düşürüyorsanız UCS-2 karakter kümesinin tamamını doku haritasına sığdırabilirsiniz. Ancak 8 piksel yazı tipi boyutu çok okunaklı değildir. Daha büyük yazı tipi boyutları kullanmak amacıyla yazı tipiniz için birden çok doku kullanabilirsiniz. Örnek için bu sprite atlas demosuna bakın. Yardımcı olabilecek bir başka şey de yalnızca metninizde kullanılan harfleri oluşturmaktır.

Özet

Bu makalede, Three.js kullanarak köşe gölgelendirici tabanlı animasyon demosunu uygulama konusunda size adım adım yol göstereceğim. Demo, 2010 MacBook Air'de gerçek zamanlı olarak bir milyon harf animasyonu gösteriyor. Uygulama, verimli çizim için kitabın tamamını tek bir geometrik nesnede birleştirdi. Tek tek harflere, hangi köşelerin hangi harfe ait olduğu tespit edilerek ve kitap metnindeki harf dizinine göre köşelerin animasyonları uygulandı.

Referanslar