3D Grafikler: Bir WebGL Eğitimi

Yayınlanan: 2022-03-11

3D grafikler dünyasına girmek çok korkutucu olabilir. İster sadece etkileşimli bir 3B logo oluşturmak, ister tam teşekküllü bir oyun tasarlamak isteyin, 3B oluşturmanın ilkelerini bilmiyorsanız, birçok şeyi özetleyen bir kitaplık kullanmak zorunda kalırsınız.

Bir kitaplık kullanmak sadece doğru araç olabilir ve JavaScript'in üç.js biçiminde harika bir açık kaynak kodu vardır. Yine de önceden hazırlanmış çözümleri kullanmanın bazı dezavantajları vardır:

  • Kullanmayı planlamadığınız birçok özelliğe sahip olabilirler. Küçültülmüş temel Three.js özelliklerinin boyutu yaklaşık 500 kB'dir ve herhangi bir ekstra özellik (gerçek model dosyalarını yüklemek bunlardan biridir) yükü daha da büyük hale getirir. Bu kadar veriyi sadece web sitenizde dönen bir logo göstermek için aktarmak israf olur.
  • Ekstra bir soyutlama katmanı, aksi takdirde kolay değişikliklerin yapılmasını zorlaştırabilir. Ekranda bir nesneyi gölgelendirmenin yaratıcı yolu, uygulaması kolay olabilir veya kitaplığın soyutlamalarına dahil etmek için onlarca saat çalışma gerektirebilir.
  • Kitaplık çoğu senaryoda çok iyi bir şekilde optimize edilmiş olsa da, kullanım durumunuz için çok sayıda zil ve ıslık kesilebilir. Oluşturucu, belirli prosedürlerin grafik kartında milyonlarca kez çalışmasına neden olabilir. Böyle bir prosedürden kaldırılan her talimat, daha zayıf bir grafik kartının içeriğinizi sorunsuz bir şekilde işleyebileceği anlamına gelir.

Üst düzey bir grafik kitaplığı kullanmaya karar verseniz bile, kaputun altındaki şeyler hakkında temel bilgilere sahip olmak onu daha verimli kullanmanızı sağlar. Kitaplıklar ayrıca ShaderMaterial three.js gibi gelişmiş özelliklere sahip olabilir. Grafik oluşturma ilkelerini bilmek, bu tür özellikleri kullanmanıza olanak tanır.

Bir WebGL tuvali üzerinde bir 3D Toptal logosunun çizimi

Amacımız, 3D grafikler oluşturmanın ve bunları uygulamak için WebGL'yi kullanmanın arkasındaki tüm temel kavramlara kısa bir giriş yapmaktır. En sık yapılan, 3B nesneleri boş bir alanda göstermek ve taşımak olduğunu göreceksiniz.

Son kod, çatallamanız ve oynamanız için mevcuttur.

3B Modelleri Temsil Etme

Anlamanız gereken ilk şey, 3B modellerin nasıl temsil edildiğidir. Bir model, bir üçgen ağından yapılmıştır. Her üçgen, üçgenin köşelerinin her biri için üç köşe ile temsil edilir. Köşelere bağlı en yaygın üç özellik vardır.

tepe konumu

Konum, bir tepe noktasının en sezgisel özelliğidir. 3B koordinat vektörü ile temsil edilen 3B uzaydaki konumdur. Uzaydaki üç noktanın tam koordinatlarını biliyorsanız, aralarında basit bir üçgen çizmek için ihtiyacınız olan tüm bilgilere sahip olursunuz. Modellerin işlendiğinde gerçekten iyi görünmesini sağlamak için, oluşturucuya sağlanması gereken birkaç şey daha var.

Köşe Normal

Düz ve pürüzsüz gölgeleme uygulanmış, aynı tel kafese sahip küreler

Yukarıdaki iki modeli düşünün. Aynı köşe konumlarından oluşurlar, ancak işlendiklerinde tamamen farklı görünürler. Bu nasıl mümkün olabilir?

Oluşturucuya bir tepe noktasının nereye yerleştirilmesini istediğimizi söylemenin yanı sıra, yüzeyin tam olarak bu konumda nasıl eğimli olduğuna dair bir ipucu da verebiliriz. İpucu, bir 3B vektörle temsil edilen, modelin o belirli noktasındaki yüzeyin normali biçimindedir. Aşağıdaki resim size bunun nasıl ele alındığına dair daha açıklayıcı bir bakış sağlayacaktır.

Düz ve pürüzsüz gölgeleme için normaller arasında karşılaştırma

Sol ve sağ yüzey, sırasıyla önceki görüntüdeki sol ve sağ topa karşılık gelir. Kırmızı oklar, bir tepe noktası için belirtilen normalleri temsil ederken, mavi oklar, oluşturucunun, köşeler arasındaki tüm noktalar için normalin nasıl görünmesi gerektiğine ilişkin hesaplamalarını temsil eder. Resim 2B uzay için bir gösterimi göstermektedir, ancak aynı prensip 3B için de geçerlidir.

Normal, ışıkların yüzeyi nasıl aydınlatacağına dair bir ipucudur. Bir ışık ışınının yönü normale ne kadar yakınsa, nokta o kadar parlaktır. Normal yönde kademeli değişiklikler ışık gradyanlarına neden olurken, aralarında hiçbir değişiklik olmayan ani değişiklikler, üzerlerinde sürekli aydınlatma bulunan yüzeylere ve bunlar arasında ani aydınlatma değişikliklerine neden olur.

Doku Koordinatları

Son önemli özellik, genellikle UV eşleme olarak adlandırılan doku koordinatlarıdır. Bir modeliniz ve ona uygulamak istediğiniz bir dokunuz var. Dokunun üzerinde, modelin farklı bölümlerine uygulamak istediğimiz görüntüleri temsil eden çeşitli alanlar vardır. Dokunun hangi kısmı ile hangi üçgenin temsil edilmesi gerektiğini işaretlemenin bir yolu olmalı. İşte burada doku eşleme devreye giriyor.

Her tepe noktası için, U ve V olmak üzere iki koordinatı işaretliyoruz. Bu koordinatlar doku üzerinde bir konumu temsil eder; U yatay ekseni ve V dikey ekseni temsil eder. Değerler piksel cinsinden değil, görüntü içindeki bir yüzde konumudur. Resmin sol alt köşesi iki sıfırla, sağ üst köşesi iki sıfırla temsil edilir.

Bir üçgen, üçgendeki her bir köşenin UV koordinatlarını alarak ve bu koordinatlar arasında yakalanan görüntüyü dokuya uygulayarak boyanır.

Bir yama vurgulanmış ve modelde görünen dikişler ile UV haritalamanın gösterimi

Yukarıdaki resimde UV haritalamanın bir gösterimini görebilirsiniz. Küresel model alındı ​​ve 2 boyutlu bir yüzey üzerine düzleştirilebilecek kadar küçük parçalar halinde kesildi. Kesiklerin yapıldığı dikişler daha kalın çizgilerle işaretlenmiştir. Yamalardan biri vurgulandı, böylece işlerin nasıl eşleştiğini güzel bir şekilde görebilirsiniz. Ayrıca gülümsemenin ortasından geçen bir dikişin ağzın bölümlerini iki farklı parçaya nasıl yerleştirdiğini de görebilirsiniz.

Tel çerçeveler dokunun bir parçası değildir, ancak işlerin nasıl bir araya geldiğini görebilmeniz için görüntünün üzerine bindirilir.

OBJ Modeli Yükleme

İster inanın ister inanmayın, kendi basit model yükleyicinizi oluşturmak için bilmeniz gereken tek şey bu. OBJ dosya formatı, birkaç kod satırında bir ayrıştırıcı uygulamak için yeterince basittir.

Dosya köşe konumlarını v <float> <float> <float> biçiminde, işleri basit tutmak için göz ardı edeceğimiz isteğe bağlı dördüncü bir kayan nokta ile listeler. Köşe normalleri benzer şekilde vn <float> <float> <float> ile temsil edilir. Son olarak, doku koordinatları vt <float> <float> ile temsil edilir ve göz ardı edeceğimiz isteğe bağlı üçüncü bir şamandıra bulunur. Her üç durumda da, şamandıralar ilgili koordinatları temsil eder. Bu üç özellik, üç dizide toplanır.

Yüzler, köşe gruplarıyla temsil edilir. Her bir köşe, özelliklerin her birinin indeksi ile temsil edilir, bu sayede indeksler 1'den başlar. Bunun temsil edilmesinin çeşitli yolları vardır, ancak biz f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 formatına bağlı kalacağız. , üç özelliğin hepsinin sağlanmasını gerektirir ve yüz başına köşe sayısını üç ile sınırlandırır. Tüm bu sınırlamalar, yükleyiciyi olabildiğince basit tutmak için yapılıyor, çünkü diğer tüm seçenekler, WebGL'nin sevdiği bir formatta olmadan önce bazı ekstra önemsiz işlemler gerektiriyor.

Dosya yükleyicimiz için birçok gereksinim belirledik. Bu sınırlayıcı gelebilir, ancak 3B modelleme uygulamaları, bir modeli OBJ dosyası olarak dışa aktarırken size bu sınırlamaları belirleme yeteneği verme eğilimindedir.

Aşağıdaki kod, bir OBJ dosyasını temsil eden bir dizeyi ayrıştırır ve bir dizi yüz biçiminde bir model oluşturur.

 function Geometry (faces) { this.faces = faces || [] } // Parses an OBJ file, passed as a string Geometry.parseOBJ = function (src) { var POSITION = /^v\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var NORMAL = /^vn\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var UV = /^vt\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var FACE = /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/ lines = src.split('\n') var positions = [] var uvs = [] var normals = [] var faces = [] lines.forEach(function (line) { // Match each line of the file against various RegEx-es var result if ((result = POSITION.exec(line)) != null) { // Add new vertex position positions.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = NORMAL.exec(line)) != null) { // Add new vertex normal normals.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = UV.exec(line)) != null) { // Add new texture mapping point uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2]))) } else if ((result = FACE.exec(line)) != null) { // Add new face var vertices = [] // Create three vertices from the passed one-indexed indices for (var i = 1; i < 10; i += 3) { var part = result.slice(i, i + 3) var position = positions[parseInt(part[0]) - 1] var uv = uvs[parseInt(part[1]) - 1] var normal = normals[parseInt(part[2]) - 1] vertices.push(new Vertex(position, normal, uv)) } faces.push(new Face(vertices)) } }) return new Geometry(faces) } // Loads an OBJ file from the given URL, and returns it as a promise Geometry.loadOBJ = function (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(Geometry.parseOBJ(xhr.responseText)) } } xhr.open('GET', url, true) xhr.send(null) }) } function Face (vertices) { this.vertices = vertices || [] } function Vertex (position, normal, uv) { this.position = position || new Vector3() this.normal = normal || new Vector3() this.uv = uv || new Vector2() } function Vector3 (x, y, z) { this.x = Number(x) || 0 this.y = Number(y) || 0 this.z = Number(z) || 0 } function Vector2 (x, y) { this.x = Number(x) || 0 this.y = Number(y) || 0 }

Geometry yapısı, işlenecek bir modeli grafik kartına göndermek için gereken kesin verileri tutar. Bunu yapmadan önce, muhtemelen modeli ekranda hareket ettirme yeteneğine sahip olmak istersiniz.

Mekansal Dönüşümler Gerçekleştirme

Yüklediğimiz modeldeki tüm noktalar koordinat sistemine göredir. Modeli çevirmek, döndürmek ve ölçeklendirmek istiyorsak, tek yapmamız gereken bu işlemi koordinat sistemi üzerinde yapmaktır. Koordinat sistemi A, koordinat sistemi B'ye göre, merkezinin bir vektör p_ab olarak konumu ve bu eksenin yönünü temsil eden x_ab , y_ab ve z_ab eksenlerinin her biri için vektör tarafından tanımlanır. Yani bir nokta koordinat sistemi A'nın x ekseni üzerinde 10 ile hareket ederse, o zaman - koordinat sistemi B'de - x_ab yönünde hareket edecek ve 10 ile çarpılacaktır.

Tüm bu bilgiler aşağıdaki matris formunda saklanır:

 x_ab.x y_ab.x z_ab.x p_ab.x x_ab.y y_ab.y z_ab.y p_ab.y x_ab.z y_ab.z z_ab.z p_ab.z 0 0 0 1

3B vektörü q dönüştürmek istiyorsak, dönüşüm matrisini vektörle çarpmamız yeterlidir:

 qx qy qz 1

Bu, noktanın yeni x ekseni boyunca qx , yeni y ekseni boyunca qy ve yeni z ekseni boyunca qz kadar hareket etmesine neden olur. Son olarak, noktanın p vektörü tarafından ek olarak hareket etmesine neden olur, bu yüzden çarpmanın son elemanı olarak bir tane kullanmamızın nedeni budur.

Bu matrisleri kullanmanın en büyük avantajı, tepe noktasında gerçekleştirecek birden fazla dönüşümümüz varsa, tepe noktasının kendisini dönüştürmeden önce matrislerini çarparak bunları tek bir dönüşümde birleştirebilmemizdir.

Gerçekleştirilebilecek çeşitli dönüşümler var ve biz önemli olanlara bir göz atacağız.

Dönüşüm Yok

Dönüşüm olmazsa, p vektörü bir sıfır vektörüdür, x vektörü [1, 0, 0] , y [0, 1, 0] ve z [0, 0, 1] dir. Bundan sonra bu değerlere bu vektörler için varsayılan değerler olarak bakacağız. Bu değerleri uygulamak bize bir kimlik matrisi verir:

 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1

Bu, dönüşümleri zincirlemek için iyi bir başlangıç ​​noktasıdır.

Tercüme

Çeviri için çerçeve dönüşümü

Çevirme yaptığımızda, p vektörü dışındaki tüm vektörlerin varsayılan değerleri vardır. Bu, aşağıdaki matrisle sonuçlanır:

 1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1

ölçekleme

Ölçeklendirme için çerçeve dönüştürme

Bir modeli ölçeklendirmek, her bir koordinatın bir noktanın konumuna katkıda bulunduğu miktarı azaltmak anlamına gelir. Ölçeklemenin neden olduğu tek tip bir ofset yoktur, bu nedenle p vektörü varsayılan değerini korur. Varsayılan eksen vektörleri, aşağıdaki matrisle sonuçlanan ilgili ölçeklendirme faktörleriyle çarpılmalıdır:

 s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1

Burada s_x , s_y ve s_z her eksene uygulanan ölçeklemeyi temsil eder.

döndürme

Z ekseni etrafında döndürme için çerçeve dönüşümü

Yukarıdaki resim, koordinat çerçevesini Z ekseni etrafında döndürdüğümüzde ne olduğunu gösterir.

Döndürme, tek tip bir kayma ile sonuçlanmaz, bu nedenle p vektörü varsayılan değerini korur. Şimdi işler biraz daha zorlaşıyor. Döndürmeler, orijinal koordinat sisteminde belirli bir eksen boyunca hareketin farklı bir yönde hareket etmesine neden olur. Dolayısıyla, bir koordinat sistemini Z ekseni etrafında 45 derece döndürürsek, orijinal koordinat sisteminin x ekseni boyunca hareket etmek, yeni koordinat sisteminde x ve y ekseni arasında çapraz yönde harekete neden olur.

İşleri basitleştirmek için, size dönüşüm matrislerinin ana eksenler etrafındaki dönüşleri nasıl aradığını göstereceğiz.

 Around X: 1 0 0 0 0 cos(phi) sin(phi) 0 0 -sin(phi) cos(phi) 0 0 0 0 1 Around Y: cos(phi) 0 sin(phi) 0 0 1 0 0 -sin(phi) 0 cos(phi) 0 0 0 0 1 Around Z: cos(phi) -sin(phi) 0 0 sin(phi) cos(phi) 0 0 0 0 1 0 0 0 0 1

uygulama

Tüm bunlar, matrisleri sütun ana düzeninde saklayan 16 sayı depolayan bir sınıf olarak uygulanabilir.

 function Transformation () { // Create an identity transformation this.fields = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } // Multiply matrices, to chain transformations Transformation.prototype.mult = function (t) { var output = new Transformation() for (var row = 0; row < 4; ++row) { for (var col = 0; col < 4; ++col) { var sum = 0 for (var k = 0; k < 4; ++k) { sum += this.fields[k * 4 + row] * t.fields[col * 4 + k] } output.fields[col * 4 + row] = sum } } return output } // Multiply by translation matrix Transformation.prototype.translate = function (x, y, z) { var mat = new Transformation() mat.fields[12] = Number(x) || 0 mat.fields[13] = Number(y) || 0 mat.fields[14] = Number(z) || 0 return this.mult(mat) } // Multiply by scaling matrix Transformation.prototype.scale = function (x, y, z) { var mat = new Transformation() mat.fields[0] = Number(x) || 0 mat.fields[5] = Number(y) || 0 mat.fields[10] = Number(z) || 0 return this.mult(mat) } // Multiply by rotation matrix around X axis Transformation.prototype.rotateX = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[5] = c mat.fields[10] = c mat.fields[9] = -s mat.fields[6] = s return this.mult(mat) } // Multiply by rotation matrix around Y axis Transformation.prototype.rotateY = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[10] = c mat.fields[2] = -s mat.fields[8] = s return this.mult(mat) } // Multiply by rotation matrix around Z axis Transformation.prototype.rotateZ = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[5] = c mat.fields[4] = -s mat.fields[1] = s return this.mult(mat) }

Kameradan Bakmak

Ekranda nesneleri sunmanın en önemli kısmı geliyor: kamera. Bir kameranın iki temel bileşeni vardır; yani konumu ve gözlenen nesneleri ekrana nasıl yansıttığı.

Kamera konumu, basit bir numara ile ele alınır. Kamerayı bir metre ileri hareket ettirmekle, tüm dünyayı bir metre geriye götürmek arasında görsel bir fark yoktur. Doğal olarak, matrisin tersini bir dönüşüm olarak uygulayarak ikincisini yaparız.

İkinci anahtar bileşen, gözlenen nesnelerin merceğe yansıtılma şeklidir. WebGL'de ekranda görünen her şey bir kutu içinde bulunur. Kutu, her eksende -1 ile 1 arasında uzanır. Görünen her şey o kutunun içinde. Bir projeksiyon matrisi oluşturmak için aynı dönüşüm matrisleri yaklaşımını kullanabiliriz.

Ortografik projeksiyon

Ortografik projeksiyon kullanılarak uygun çerçeve arabelleği boyutlarına dönüştürülen dikdörtgen alan

En basit izdüşüm ortografik izdüşümdür. Uzayda, merkezinin sıfır konumunda olduğu varsayımıyla genişlik, yükseklik ve derinliği gösteren bir kutu alıyorsunuz. Ardından yansıtma, kutuyu WebGL'nin nesneleri gözlemlediği daha önce açıklanan kutuya sığdırmak için yeniden boyutlandırır. Her boyutu ikiye yeniden boyutlandırmak istediğimizden, her ekseni 2/size ile ölçeklendiririz, burada size ilgili eksenin boyutudur. Küçük bir uyarı, Z eksenini bir negatif ile çarptığımız gerçeğidir. Bu, o boyutun yönünü çevirmek istediğimiz için yapılır. Son matris bu forma sahiptir:

 2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1

Perspektif Projeksiyonu

Frustum, perspektif projeksiyon kullanılarak uygun çerçeve arabelleği boyutlarına dönüştürülür

Bu projeksiyonun nasıl tasarlandığının ayrıntılarına girmeyeceğiz, ancak şimdiye kadar hemen hemen standart olan son formülü kullanacağız. İzdüşümünü x ve y ekseninde sıfır konumuna yerleştirerek, sağ/sol ve üst/alt limitleri sırasıyla width/2 ve height/2 eşitleyerek basitleştirebiliriz. n ve f parametreleri, bir noktanın kamera tarafından yakalanabileceği en küçük ve en büyük mesafe olan near ve far kırpma düzlemlerini temsil eder. Yukarıdaki resimde frustum'un paralel kenarları ile temsil edilirler.

Bir perspektif projeksiyon genellikle bir görüş alanı (dikey olanı kullanacağız), en boy oranı ve yakın ve uzak düzlem mesafeleriyle temsil edilir. Bu bilgi, width ve height hesaplamak için kullanılabilir ve ardından aşağıdaki şablondan matris oluşturulabilir:

 2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0

Genişliği ve yüksekliği hesaplamak için aşağıdaki formüller kullanılabilir:

 height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height

FOV (görüş alanı), kameranın lensiyle yakaladığı dikey açıyı temsil eder. En boy oranı, görüntünün genişliği ve yüksekliği arasındaki oranı temsil eder ve oluşturduğumuz ekranın boyutlarına bağlıdır.

uygulama

Şimdi bir kamerayı, kamera konumunu ve projeksiyon matrisini saklayan bir sınıf olarak temsil edebiliriz. Ayrıca ters dönüşümleri nasıl hesaplayacağımızı da bilmemiz gerekir. Genel matris inversiyonlarını çözmek sorunlu olabilir, ancak özel durumumuz için basitleştirilmiş bir yaklaşım var.

 function Camera () { this.position = new Transformation() this.projection = new Transformation() } Camera.prototype.setOrthographic = function (width, height, depth) { this.projection = new Transformation() this.projection.fields[0] = 2 / width this.projection.fields[5] = 2 / height this.projection.fields[10] = -2 / depth } Camera.prototype.setPerspective = function (verticalFov, aspectRatio, near, far) { var height_div_2n = Math.tan(verticalFov * Math.PI / 360) var width_div_2n = aspectRatio * height_div_2n this.projection = new Transformation() this.projection.fields[0] = 1 / height_div_2n this.projection.fields[5] = 1 / width_div_2n this.projection.fields[10] = (far + near) / (near - far) this.projection.fields[10] = -1 this.projection.fields[14] = 2 * far * near / (near - far) this.projection.fields[15] = 0 } Camera.prototype.getInversePosition = function () { var orig = this.position.fields var dest = new Transformation() var x = orig[12] var y = orig[13] var z = orig[14] // Transpose the rotation matrix for (var i = 0; i < 3; ++i) { for (var j = 0; j < 3; ++j) { dest.fields[i * 4 + j] = orig[i + j * 4] } } // Translation by -p will apply R^T, which is equal to R^-1 return dest.translate(-x, -y, -z) }

Ekranda bir şeyler çizmeye başlamadan önce ihtiyacımız olan son parça bu.

WebGL Graphics Pipeline ile Nesne Çizimi

Çizebileceğiniz en basit yüzey bir üçgendir. Aslında, 3B uzayda çizdiğiniz şeylerin çoğu çok sayıda üçgenden oluşur.

Grafik işlem hattının hangi adımlarının yaptığına temel bir bakış

Anlamanız gereken ilk şey, ekranın WebGL'de nasıl temsil edildiğidir. x , y ve z ekseninde -1 ile 1 arasında uzanan 3B bir alandır. Varsayılan olarak bu z ekseni kullanılmaz, ancak 3B grafiklerle ilgileniyorsunuz, bu nedenle hemen etkinleştirmek isteyeceksiniz.

Bunu akılda tutarak, aşağıdakiler bu yüzeye bir üçgen çizmek için gereken üç adımdır.

Çizmek istediğiniz üçgeni temsil edecek üç köşe tanımlayabilirsiniz. Bu verileri seri hale getirir ve GPU'ya (grafik işleme birimi) gönderirsiniz. Bütün bir model mevcutken, bunu modeldeki tüm üçgenler için yapabilirsiniz. Verdiğiniz köşe konumları yüklediğiniz modelin yerel koordinat uzayındadır. Basitçe söylemek gerekirse, sağladığınız konumlar, matris dönüşümlerini gerçekleştirdikten sonra elde ettiğiniz konumlar değil, dosyadaki konumlardır.

Artık GPU'ya köşeleri verdiğinize göre, köşeleri ekrana yerleştirirken hangi mantığı kullanacağını GPU'ya söylersiniz. Bu adım, matris dönüşümlerimizi uygulamak için kullanılacaktır. GPU, birçok 4x4 matrisi çarpma konusunda çok iyidir, bu yüzden bu yeteneği iyi bir şekilde kullanacağız.

Son adımda, GPU bu üçgeni rasterleştirecektir. Rasterleştirme, vektör grafikleri alma ve o vektör grafik nesnesinin görüntülenmesi için ekranın hangi piksellerinin boyanması gerektiğini belirleme işlemidir. Bizim durumumuzda GPU, her üçgenin içinde hangi piksellerin bulunduğunu belirlemeye çalışıyor. Her piksel için GPU size hangi renge boyanmasını istediğinizi soracaktır.

Bunlar, istediğiniz herhangi bir şeyi çizmek için gereken dört unsurdur ve bir grafik ardışık düzeninin en basit örneğidir. Aşağıda, her birine bir bakış ve basit bir uygulama yer almaktadır.

Varsayılan Çerçeve Tamponu

Bir WebGL uygulaması için en önemli unsur WebGL bağlamıdır. Buna gl = canvas.getContext('webgl') ile erişebilir veya şu anda kullanılan tarayıcının henüz tüm WebGL özelliklerini desteklememesi durumunda yedek olarak 'experimental-webgl' kullanabilirsiniz. canvas , üzerine çizim yapmak istediğimiz tuvalin DOM öğesidir. Bağlam, aralarında varsayılan çerçeve arabelleğinin de bulunduğu birçok şey içerir.

Çerçeve arabelleğini, üzerine çizebileceğiniz herhangi bir arabellek (nesne) olarak gevşek bir şekilde tanımlayabilirsiniz. Varsayılan olarak, varsayılan çerçeve arabelleği, WebGL bağlamının bağlı olduğu tuvalin her pikseli için rengi saklar. Bir önceki bölümde anlatıldığı gibi framebuffer üzerinde çizim yaptığımızda her piksel x ve y ekseninde -1 ile 1 arasında yer alır. WebGL'nin varsayılan olarak z eksenini kullanmadığından da bahsettiğimiz bir şeydir. Bu işlevsellik, gl.enable(gl.DEPTH_TEST) çalıştırılarak etkinleştirilebilir. Harika, ama derinlik testi nedir?

Derinlik testinin etkinleştirilmesi, bir pikselin hem rengi hem de derinliği saklamasını sağlar. Derinlik, o pikselin z koordinatıdır. Bir piksele belirli bir derinlikte çizim yaptıktan sonra z , o pikselin rengini güncellemek için kameraya daha yakın olan bir z konumunda çizim yapmanız gerekir. Aksi takdirde, beraberlik denemesi dikkate alınmayacaktır. Bu, 3B yanılsamasına izin verir, çünkü diğer nesnelerin arkasındaki nesneleri çizmek, bu nesnelerin önlerindeki nesneler tarafından kapatılmasına neden olur.

Yaptığınız herhangi bir çekiliş, siz onlara temizlenmelerini söyleyene kadar ekranda kalır. Bunu yapmak için gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) . Bu, hem renk hem de derinlik arabelleğini temizler. Temizlenen piksellerin ayarlandığı rengi seçmek için gl.clearColor(red, green, blue, alpha) kullanın.

Bir tuval kullanan ve istek üzerine temizleyen bir oluşturucu oluşturalım:

 function Renderer (canvas) { var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') gl.enable(gl.DEPTH_TEST) this.gl = gl } Renderer.prototype.setClearColor = function (red, green, blue) { gl.clearColor(red / 255, green / 255, blue / 255, 1) } Renderer.prototype.getContext = function () { return this.gl } Renderer.prototype.render = function () { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) loop() function loop () { renderer.render() requestAnimationFrame(loop) }

Bu komut dosyasını aşağıdaki HTML'ye eklemek size ekranda parlak mavi bir dikdörtgen verecektir.

 <!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>

requestAnimationFrame çağrısı, önceki çerçevenin oluşturulması tamamlanır tamamlanmaz ve tüm olay işleme biter bitmez döngünün yeniden çağrılmasına neden olur.

Vertex Tampon Nesneleri

Yapmanız gereken ilk şey, çizmek istediğiniz köşeleri tanımlamaktır. Bunu, onları 3B uzayda vektörler aracılığıyla tanımlayarak yapabilirsiniz. Bundan sonra, yeni bir Vertex Tampon Nesnesi (VBO) oluşturarak bu verileri GPU RAM'e taşımak istiyorsunuz.

Tampon Nesnesi genel olarak GPU'da bir dizi bellek parçası depolayan bir nesnedir. Bir VBO olması, GPU'nun belleği ne için kullanabileceğini gösterir. Çoğu zaman, oluşturduğunuz Tampon Nesneleri VBO'lar olacaktır.

VBO'yu, sahip olduğumuz tüm N köşelerini alarak ve köşe konumu ve köşe normal VBO'ları için 3N elemanlı ve doku koordinatları VBO için 2N olan bir yüzer dizi oluşturarak doldurabilirsiniz. UV koordinatları için üç kayan noktalı her grup veya iki kayan nokta, bir tepe noktasının ayrı koordinatlarını temsil eder. Daha sonra bu dizileri GPU'ya iletiyoruz ve köşelerimiz boru hattının geri kalanı için hazır.

Veriler artık GPU RAM'de olduğu için genel amaçlı RAM'den silebilirsiniz. Yani, daha sonra değiştirmek ve yeniden yüklemek istemiyorsanız. JS dizilerimizdeki değişiklikler gerçek GPU RAM'deki VBO'lar için geçerli olmadığından, her değişikliğin ardından bir yükleme yapılması gerekir.

Aşağıda açıklanan tüm işlevleri sağlayan bir kod örneği verilmiştir. Yapılması gereken önemli bir not, GPU'da depolanan değişkenlerin çöp olarak toplanmamasıdır. Bu, artık kullanmak istemediğimizde onları manuel olarak silmemiz gerektiği anlamına gelir. Bunun burada nasıl yapıldığına dair size sadece bir örnek vereceğiz ve bu konsepte daha fazla odaklanmayacağız. Değişkenleri GPU'dan silmek, yalnızca program boyunca belirli geometriyi kullanmayı bırakmayı planlıyorsanız gereklidir.

Ayrıca Geometry sınıfımıza ve içindeki elementlere serileştirme ekledik.

 Geometry.prototype.vertexCount = function () { return this.faces.length * 3 } Geometry.prototype.positions = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.position answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.normals = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.normal answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.uvs = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.uv answer.push(vx, vy) }) }) return answer } //////////////////////////////// function VBO (gl, data, count) { // Creates buffer object in GPU RAM where we can store anything var bufferObject = gl.createBuffer() // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject) // Write the data, and set the flag to optimize // for rare changes to the data we're writing gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW) this.gl = gl this.size = data.length / count this.count = count this.data = bufferObject } VBO.prototype.destroy = function () { // Free memory that is occupied by our buffer object this.gl.deleteBuffer(this.data) }

VBO veri türü, ikinci parametre olarak iletilen diziye dayalı olarak, geçirilen WebGL bağlamında VBO'yu oluşturur.

gl bağlamına yapılan üç çağrıyı görebilirsiniz. createBuffer() çağrısı arabelleği oluşturur. bindBuffer() çağrısı, WebGL durum makinesine, aksi söyleninceye kadar, gelecekteki tüm işlemler için bu özel belleği geçerli VBO ( ARRAY_BUFFER ) olarak kullanmasını söyler. Bundan sonra, bufferData() ile mevcut VBO'nun değerini sağlanan verilere ayarladık.

Ayrıca, deleteBuffer() kullanarak arabellek nesnemizi GPU RAM'den silen bir yok etme yöntemi de sağlıyoruz.

Bir ağın tüm özelliklerini konumuyla birlikte açıklamak için üç VBO ve bir dönüşüm kullanabilirsiniz.

 function Mesh (gl, geometry) { var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() }

Örnek olarak, bir modeli nasıl yükleyebileceğimiz, özelliklerini ağda nasıl depolayabileceğimiz ve ardından onu nasıl yok edebileceğimiz aşağıda açıklanmıştır:

 Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })

gölgelendiriciler

Ardından, noktaları istenen konumlara taşıma ve tüm bireysel pikselleri boyamaya ilişkin daha önce açıklanan iki adımlı süreç yer almaktadır. Bunun için ekran kartı üzerinde defalarca çalıştırılan bir program yazıyoruz. Bu program tipik olarak en az iki bölümden oluşur. İlk kısım, her köşe için çalıştırılan ve diğer şeylerin yanı sıra tepe noktasını ekranda nereye yerleştirmemiz gerektiğinin çıktısını veren bir Vertex Shader'dır . İkinci kısım, ekranda bir üçgenin kapsadığı her piksel için çalıştırılan ve pikselin boyanması gereken rengi çıkaran Fragment Shader'dır .

Köşe Gölgelendiriciler

Diyelim ki ekranda sağa sola hareket eden bir model olsun istiyorsunuz. Saf bir yaklaşımla, her bir köşenin konumunu güncelleyebilir ve GPU'ya yeniden gönderebilirsiniz. Bu süreç pahalı ve yavaştır. Alternatif olarak, GPU'nun her köşe için çalışması ve tüm bu işlemleri tam olarak bu işi yapmak için oluşturulmuş bir işlemciyle paralel olarak yapması için bir program verirsiniz. Bir köşe gölgelendiricisinin rolü budur.

Köşe gölgelendiricisi, işleme hattının tek tek köşeleri işleyen kısmıdır. Köşe gölgelendiricisine yapılan bir çağrı, tek bir tepe noktası alır ve tepe noktasına olası tüm dönüşümler uygulandıktan sonra tek bir tepe noktası verir.

Gölgelendiriciler GLSL ile yazılmıştır. Bu dilin pek çok benzersiz öğesi vardır, ancak sözdiziminin çoğu C benzeridir, bu nedenle çoğu insan için anlaşılabilir olmalıdır.

Bir köşe gölgelendiricisine giren ve çıkan üç tür değişken vardır ve bunların tümü belirli bir kullanıma hizmet eder:

  • attribute — Bunlar, bir tepe noktasının belirli özelliklerini tutan girdilerdir. Daha önce, bir tepe noktasının konumunu, üç elemanlı bir vektör biçiminde bir nitelik olarak tanımlamıştık. Niteliklere bir tepe noktasını tanımlayan değerler olarak bakabilirsiniz.
  • uniform — Bunlar, aynı işleme çağrısı içindeki her köşe için aynı olan girdilerdir. Bir dönüşüm matrisi tanımlayarak modelimizi hareket ettirmek istediğimizi varsayalım. Bunu açıklamak için uniform bir değişken kullanabilirsiniz. Dokular gibi GPU'daki kaynaklara da işaret edebilirsiniz. Üniformalara bir modeli tanımlayan değerler veya bir modelin parçası olarak bakabilirsiniz.
  • varying — Bunlar, parça gölgelendiricisine ilettiğimiz çıktılardır. Bir köşe üçgeni için potansiyel olarak binlerce piksel olduğundan, her piksel, konuma bağlı olarak bu değişken için enterpolasyonlu bir değer alacaktır. Dolayısıyla, bir tepe noktası çıktı olarak 500, diğeri ise 100 gönderirse, aralarında ortada bulunan bir piksel bu değişken için girdi olarak 300 alacaktır. Değişkenlere tepe noktaları arasındaki yüzeyleri tanımlayan değerler olarak bakabilirsiniz.

So, let's say you want to create a vertex shader that receives a position, normal, and uv coordinates for each vertex, and a position, view (inverse camera position), and projection matrix for each rendered object. Let's say you also want to paint individual pixels based on their uv coordinates and their normals. “How would that code look?” sorabilirsin.

 attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 model; uniform mat4 view; uniform mat4 projection; varying vec3 vNormal; varying vec2 vUv; void main() { vUv = uv; vNormal = (model * vec4(normal, 0.)).xyz; gl_Position = projection * view * model * vec4(position, 1.); }

Most of the elements here should be self-explanatory. The key thing to notice is the fact that there are no return values in the main function. All values that we would want to return are assigned, either to varying variables, or to special variables. Here we assign to gl_Position , which is a four-dimensional vector, whereby the last dimension should always be set to one. Another strange thing you might notice is the way we construct a vec4 out of the position vector. You can construct a vec4 by using four float s, two vec2 s, or any other combination that results in four elements. There are a lot of seemingly strange type castings which make perfect sense once you're familiar with transformation matrices.

You can also see that here we can perform matrix transformations extremely easily. GLSL is specifically made for this kind of work. The output position is calculated by multiplying the projection, view, and model matrix and applying it onto the position. The output normal is just transformed to the world space. We'll explain later why we've stopped there with the normal transformations.

For now, we will keep it simple, and move on to painting individual pixels.

Fragment Shaders

A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.

The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:

  • There are no more varying outputs, and attribute inputs have been replaced with varying inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader.
  • Our only output now is gl_FragColor , which is a vec4 . The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you're doing transparency. Transparency is a fairly advanced concept though, so we'll stick to opaque objects.
  • At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.

With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec2 clampedUv = clamp(vUv, 0., 1.); gl_FragColor = vec4(clampedUv, 1., 1.); }

The function clamp just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.

With all of this in mind, all that is left is to implement this in WebGL.

Combining Shaders into a Program

The next step is to combine the shaders into a program:

 function ShaderProgram (gl, vertSrc, fragSrc) { var vert = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vert, vertSrc) gl.compileShader(vert) if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(vert)) throw new Error('Failed to compile shader') } var frag = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(frag, fragSrc) gl.compileShader(frag) if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(frag)) throw new Error('Failed to compile shader') } var program = gl.createProgram() gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) throw new Error('Failed to link program') } this.gl = gl this.position = gl.getAttribLocation(program, 'position') this.normal = gl.getAttribLocation(program, 'normal') this.uv = gl.getAttribLocation(program, 'uv') this.model = gl.getUniformLocation(program, 'model') this.view = gl.getUniformLocation(program, 'view') this.projection = gl.getUniformLocation(program, 'projection') this.vert = vert this.frag = frag this.program = program } // Loads shader files from the given URLs, and returns a program as a promise ShaderProgram.load = function (gl, vertUrl, fragUrl) { return Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(function (files) { return new ShaderProgram(gl, files[0], files[1]) }) function loadFile (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(xhr.responseText) } } xhr.open('GET', url, true) xhr.send(null) }) } }

There isn't much to say about what's happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.

Actually Drawing the Model

Last, but not least, you draw the model.

First you pick the shader program you want to use.

 ShaderProgram.prototype.use = function () { this.gl.useProgram(this.program) }

Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.

 Transformation.prototype.sendToGpu = function (gl, uniform, transpose) { gl.uniformMatrix4fv(uniform, transpose || false, new Float32Array(this.fields)) } Camera.prototype.use = function (shaderProgram) { this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection) this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view) }

Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.

 VBO.prototype.bindToAttribute = function (attribute) { var gl = this.gl // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, this.data) // Enable this attribute in the shader gl.enableVertexAttribArray(attribute) // Define format of the attribute array. Must match parameters in shader gl.vertexAttribPointer(attribute, this.size, gl.FLOAT, false, 0, 0) }

Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call drawArrays() from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using TRIANGLES takes three by three vertices and draws a triangle for each triplet. Using POINTS would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:

 Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) }

The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.

 Renderer.prototype.setShader = function (shader) { this.shader = shader } Renderer.prototype.render = function (camera, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }

We can combine all the elements that we have to finally draw something on the screen:

 var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Geometry.loadOBJ('/assets/sphere.obj').then(function (data) { objects.push(new Mesh(gl, data)) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) loop() function loop () { renderer.render(camera, objects) requestAnimationFrame(loop) } 

Object drawn on the canvas, with colors depending on UV coordinates

This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); gl_FragColor = vec4(brown, 1.); } 

Brown object drawn on the canvas

It doesn't look very convincing. It looks like the scene needs some shading effects.

Adding Light

Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.

Sunlight sounds like it's the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.

Demonstration of angles between light rays and surface normals, for both flat and smooth shading

You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.

If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); gl_FragColor = vec4(brown * lightness, 1.); } 

Brown object with sunlight

Güneşi ileri-sol-aşağı yönde parlayacak şekilde ayarladık. Model çok pürüzlü olmasına rağmen gölgelemenin ne kadar düzgün olduğunu görebilirsiniz. Ayrıca sol alt tarafın ne kadar karanlık olduğunu da fark edebilirsiniz. Gölgedeki alanı daha parlak hale getirecek bir ortam ışığı seviyesi ekleyebiliriz.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); float ambientLight = 0.3; lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); } 

Güneş ışığı ve ortam ışığı ile kahverengi nesne

Aynı etkiyi, ışık yönünü ve ortam ışığı yoğunluğunu depolayan bir ışık sınıfı tanıtarak da elde edebilirsiniz. Ardından, bu eklemeye uyum sağlamak için parça gölgelendiriciyi değiştirebilirsiniz.

Şimdi gölgelendirici şu hale gelir:

 #ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }

Ardından ışığı tanımlayabilirsiniz:

 function Light () { this.lightDirection = new Vector3(-1, -1, -1) this.ambientLight = 0.3 } Light.prototype.use = function (shaderProgram) { var dir = this.lightDirection var gl = shaderProgram.gl gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z) gl.uniform1f(shaderProgram.ambientLight, this.ambientLight) }

Gölgelendirici program sınıfında gerekli üniformaları ekleyin:

 this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')

Programda, oluşturucudaki yeni ışığa bir çağrı ekleyin:

 Renderer.prototype.render = function (camera, light, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() light.use(shader) camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }

Döngü daha sonra biraz değişecektir:

 var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }

Her şeyi doğru yaptıysanız, oluşturulan görüntü son görüntüdekiyle aynı olmalıdır.

Dikkate alınması gereken son adım, modelimize gerçek bir doku eklemek olacaktır. Şimdi yapalım.

Doku Ekleme

HTML5, görüntüleri yüklemek için harika bir desteğe sahiptir, bu nedenle çılgın görüntü ayrıştırma yapmaya gerek yoktur. Görüntüler, gölgelendiriciye bağlı dokulardan hangisinin örnekleneceği söylenerek sampler2D olarak sampler2D geçirilir. Bağlanabilecek sınırlı sayıda doku vardır ve sınır, kullanılan donanıma bağlıdır. Belirli konumlarda renkler için bir sampler2D sorgulanabilir. UV koordinatlarının devreye girdiği yer burasıdır. İşte kahverengiyi örneklenmiş renklerle değiştirdiğimiz bir örnek.

 #ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; uniform sampler2D diffuse; varying vec3 vNormal; varying vec2 vUv; void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }

Yeni üniforma, gölgelendirici programındaki listeye eklenmelidir:

 this.diffuse = gl.getUniformLocation(program, 'diffuse')

Son olarak doku yüklemeyi uygulayacağız. Daha önce de belirtildiği gibi, HTML5, görüntüleri yüklemek için olanaklar sağlar. Tek yapmamız gereken görüntüyü GPU'ya göndermek:

 function Texture (gl, image) { var texture = gl.createTexture() // Set the newly created texture context as active texture gl.bindTexture(gl.TEXTURE_2D, texture) // Set texture parameters, and pass the image that the texture is based on gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) // Set filtering methods // Very often shaders will query the texture value between pixels, // and this is instructing how that value shall be calculated gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) this.data = texture this.gl = gl } Texture.prototype.use = function (uniform, binding) { binding = Number(binding) || 0 var gl = this.gl // We can bind multiple textures, and here we pick which of the bindings // we're setting right now gl.activeTexture(gl['TEXTURE' + binding]) // After picking the binding, we set the texture gl.bindTexture(gl.TEXTURE_2D, this.data) // Finally, we pass to the uniform the binding ID we've used gl.uniform1i(uniform, binding) // The previous 3 lines are equivalent to: // texture[i] = this.data // uniform = i } Texture.load = function (gl, url) { return new Promise(function (resolve) { var image = new Image() image.onload = function () { resolve(new Texture(gl, image)) } image.src = url }) }

İşlem, VBO'ları yüklemek ve bağlamak için kullanılan işlemden çok farklı değildir. Ana fark, artık bir özniteliğe değil, doku indeksini bir tamsayı üniformasına bağlamamızdır. sampler2D tipi, bir dokuya göre bir işaretçi ofsetinden başka bir şey değildir.

Şimdi yapılması gereken tek şey, dokuları da işlemek için Mesh sınıfını genişletmek:

 function Mesh (gl, geometry, texture) { // added texture var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.texture = texture // new this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() } Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.texture.use(shaderProgram.diffuse, 0) // new this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) } Mesh.load = function (gl, modelUrl, textureUrl) { // new var geometry = Geometry.loadOBJ(modelUrl) var texture = Texture.load(gl, textureUrl) return Promise.all([geometry, texture]).then(function (params) { return new Mesh(gl, params[0], params[1]) }) }

Ve son ana senaryo aşağıdaki gibi görünecektir:

 var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Mesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png') .then(function (mesh) { objects.push(mesh) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) } 

Işık efektli dokulu nesne

Animasyon yapmak bile bu noktada kolaylaşıyor. Kameranın nesnemiz etrafında dönmesini istiyorsanız, bunu sadece bir satır kod ekleyerek yapabilirsiniz:

 function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) } 

Kamera animasyonu sırasında döndürülen kafa

Gölgelendiricilerle oynamaktan çekinmeyin. Bir satır kod eklemek, bu gerçekçi aydınlatmayı karikatürize bir hale getirecektir.

 void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = lightness > 0.1 ? 1. : 0.; // new lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }

Belirlenen bir eşiği geçip geçmediğine bağlı olarak aydınlatmaya aşırı uçlara gitmesini söylemek kadar basit.

Çizgi film aydınlatması uygulanmış kafa

Sonraki Nereye Gidilir?

WebGL'nin tüm püf noktalarını ve inceliklerini öğrenmek için birçok bilgi kaynağı vardır. Ve en iyi yanı, WebGL ile ilgili bir yanıt bulamazsanız, bunu OpenGL'de arayabilirsiniz, çünkü WebGL büyük ölçüde OpenGL'nin bir alt kümesine dayanır ve bazı adlar değiştirilir.

Belirli bir sırayla, hem WebGL hem de OpenGL için daha ayrıntılı bilgi için bazı harika kaynaklar burada.

  • WebGL Temelleri
  • WebGL'yi Öğrenmek
  • Burada açıklanan tüm temel ilkelerde size çok yavaş ve ayrıntılı bir şekilde rehberlik eden çok ayrıntılı bir OpenGL eğitimi.
  • Ve size bilgisayar grafiklerinin ilkelerini öğretmeye adanmış birçok başka site var.
  • WebGL için MDN belgeleri
  • WebGL API'sinin tüm uç durumlarda nasıl çalışması gerektiğine ilişkin daha teknik ayrıntıları anlamakla ilgileniyorsanız, Khronos WebGL 1.0 spesifikasyonu.