Grafik 3D: Tutorial WebGL

Diterbitkan: 2022-03-11

Dunia grafis 3D bisa sangat menakutkan untuk dimasuki. Baik Anda hanya ingin membuat logo 3D interaktif, atau mendesain game yang sepenuhnya matang, jika Anda tidak mengetahui prinsip-prinsip rendering 3D, Anda terjebak menggunakan perpustakaan yang mengabstraksi banyak hal.

Menggunakan perpustakaan bisa menjadi alat yang tepat, dan JavaScript memiliki sumber terbuka yang luar biasa dalam bentuk three.js. Ada beberapa kelemahan menggunakan solusi yang sudah jadi, meskipun:

  • Mereka dapat memiliki banyak fitur yang tidak Anda rencanakan untuk digunakan. Ukuran fitur three.js dasar yang diperkecil adalah sekitar 500kB, dan fitur tambahan apa pun (memuat file model sebenarnya adalah salah satunya) membuat payload menjadi lebih besar. Mentransfer data sebanyak itu hanya untuk menampilkan logo berputar di situs web Anda akan sia-sia.
  • Lapisan abstraksi ekstra dapat membuat modifikasi yang mudah sulit dilakukan. Cara kreatif Anda untuk mengarsir objek di layar dapat langsung diterapkan atau memerlukan puluhan jam kerja untuk dimasukkan ke dalam abstraksi perpustakaan.
  • Sementara perpustakaan dioptimalkan dengan sangat baik di sebagian besar skenario, banyak lonceng dan peluit dapat dipotong untuk kasus penggunaan Anda. Penyaji dapat menyebabkan prosedur tertentu berjalan jutaan kali pada kartu grafis. Setiap instruksi yang dihapus dari prosedur seperti itu berarti bahwa kartu grafis yang lebih lemah dapat menangani konten Anda tanpa masalah.

Bahkan jika Anda memutuskan untuk menggunakan perpustakaan grafis tingkat tinggi, memiliki pengetahuan dasar tentang hal-hal di bawah tenda memungkinkan Anda untuk menggunakannya secara lebih efektif. Perpustakaan juga dapat memiliki fitur lanjutan, seperti ShaderMaterial di three.js . Mengetahui prinsip-prinsip rendering grafis memungkinkan Anda untuk menggunakan fitur tersebut.

Ilustrasi logo 3D Toptal pada kanvas WebGL

Tujuan kami adalah memberikan pengenalan singkat tentang semua konsep utama di balik rendering grafik 3D dan menggunakan WebGL untuk mengimplementasikannya. Anda akan melihat hal yang paling umum dilakukan, yaitu menampilkan dan memindahkan objek 3D di ruang kosong.

Kode terakhir tersedia untuk Anda gunakan dan mainkan.

Mewakili Model 3D

Hal pertama yang perlu Anda pahami adalah bagaimana model 3D direpresentasikan. Sebuah model terbuat dari jaring segitiga. Setiap segitiga diwakili oleh tiga simpul, untuk setiap sudut segitiga. Ada tiga properti paling umum yang melekat pada simpul.

Posisi Verteks

Posisi adalah properti paling intuitif dari sebuah simpul. Ini adalah posisi dalam ruang 3D, diwakili oleh vektor koordinat 3D. Jika Anda mengetahui koordinat yang tepat dari tiga titik di ruang angkasa, Anda akan memiliki semua informasi yang Anda butuhkan untuk menggambar segitiga sederhana di antara mereka. Untuk membuat model terlihat benar-benar bagus saat dirender, ada beberapa hal lagi yang perlu disediakan untuk perender.

Verteks Normal

Sphere dengan wireframe yang sama, yang menerapkan shading datar dan halus

Perhatikan dua model di atas. Mereka terdiri dari posisi simpul yang sama, namun terlihat sangat berbeda saat dirender. Bagaimana mungkin?

Selain memberi tahu penyaji di mana kita ingin sebuah titik ditempatkan, kita juga bisa memberikan petunjuk tentang bagaimana permukaan miring di posisi yang tepat itu. Petunjuknya berupa normal permukaan pada titik tertentu pada model, direpresentasikan dengan vektor 3D. Gambar berikut akan memberi Anda gambaran yang lebih deskriptif tentang cara penanganannya.

Perbandingan antara normal untuk bayangan datar dan halus

Permukaan kiri dan kanan masing-masing sesuai dengan bola kiri dan kanan pada gambar sebelumnya. Panah merah mewakili normal yang ditentukan untuk simpul, sedangkan panah biru mewakili perhitungan penyaji tentang bagaimana normal harus mencari semua titik di antara simpul. Gambar menunjukkan demonstrasi untuk ruang 2D, tetapi prinsip yang sama berlaku dalam 3D.

Normal adalah petunjuk bagaimana lampu akan menerangi permukaan. Semakin dekat arah sinar cahaya dengan garis normal, semakin terang titiknya. Perubahan bertahap dalam arah normal menyebabkan gradien cahaya, sementara perubahan mendadak tanpa perubahan di antaranya menyebabkan permukaan dengan iluminasi konstan di atasnya, dan perubahan iluminasi yang tiba-tiba di antara keduanya.

koordinat tekstur

Properti signifikan terakhir adalah koordinat tekstur, yang biasa disebut sebagai pemetaan UV. Anda memiliki model, dan tekstur yang ingin Anda terapkan padanya. Teksturnya memiliki berbagai area di atasnya, mewakili gambar yang ingin kita terapkan ke berbagai bagian model. Harus ada cara untuk menandai segitiga mana yang harus diwakili dengan bagian tekstur mana. Di situlah pemetaan tekstur masuk.

Untuk setiap titik, kami menandai dua koordinat, U dan V. Koordinat ini mewakili posisi pada tekstur, dengan U mewakili sumbu horizontal, dan V mewakili sumbu vertikal. Nilainya tidak dalam piksel, tetapi posisi persentase dalam gambar. Sudut kiri bawah gambar diwakili dengan dua nol, sedangkan kanan atas diwakili dengan dua angka.

Segitiga hanya dicat dengan mengambil koordinat UV dari setiap titik dalam segitiga, dan menerapkan gambar yang ditangkap di antara koordinat tersebut pada tekstur.

Demonstrasi pemetaan UV, dengan satu tambalan disorot, dan jahitan terlihat pada model

Anda dapat melihat demonstrasi pemetaan UV pada gambar di atas. Model bola diambil, dan dipotong menjadi bagian-bagian yang cukup kecil untuk diratakan ke permukaan 2D. Jahitan tempat pemotongan ditandai dengan garis yang lebih tebal. Salah satu tambalan telah disorot, sehingga Anda dapat melihat dengan baik bagaimana semuanya cocok. Anda juga dapat melihat bagaimana jahitan di tengah senyuman menempatkan bagian-bagian mulut menjadi dua bagian yang berbeda.

Gambar rangka bukan bagian dari tekstur, tetapi hanya menutupi gambar sehingga Anda dapat melihat bagaimana hal-hal dipetakan bersama.

Memuat Model OBJ

Percaya atau tidak, hanya ini yang perlu Anda ketahui untuk membuat loader model sederhana Anda sendiri. Format file OBJ cukup sederhana untuk mengimplementasikan parser dalam beberapa baris kode.

File mencantumkan posisi vertex dalam format v <float> <float> <float> , dengan float keempat opsional, yang akan kita abaikan, untuk menyederhanakannya. Normal vertex direpresentasikan dengan cara yang sama dengan vn <float> <float> <float> . Terakhir, koordinat tekstur direpresentasikan dengan vt <float> <float> , dengan float ketiga opsional yang akan kita abaikan. Dalam ketiga kasus, pelampung mewakili koordinat masing-masing. Ketiga properti ini diakumulasikan dalam tiga array.

Wajah direpresentasikan dengan kelompok simpul. Setiap simpul direpresentasikan dengan indeks dari masing-masing properti, di mana indeks dimulai dari 1. Ada berbagai cara representasi ini, tetapi kita akan tetap menggunakan format f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 , mengharuskan ketiga properti disediakan, dan membatasi jumlah simpul per wajah menjadi tiga. Semua batasan ini dilakukan untuk menjaga loader sesederhana mungkin, karena semua opsi lain memerlukan beberapa pemrosesan sepele tambahan sebelum mereka berada dalam format yang disukai WebGL.

Kami telah memasukkan banyak persyaratan untuk pemuat file kami. Itu mungkin terdengar membatasi, tetapi aplikasi pemodelan 3D cenderung memberi Anda kemampuan untuk mengatur batasan tersebut saat mengekspor model sebagai file OBJ.

Kode berikut mem-parsing string yang mewakili file OBJ, dan membuat model dalam bentuk array wajah.

 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 }

Struktur Geometry menyimpan data persis yang diperlukan untuk mengirim model ke kartu grafis untuk diproses. Sebelum Anda melakukannya, Anda mungkin ingin memiliki kemampuan untuk memindahkan model di sekitar layar.

Melakukan Transformasi Spasial

Semua titik dalam model yang kita muat adalah relatif terhadap sistem koordinatnya. Jika kita ingin menerjemahkan, memutar, dan menskalakan model, yang perlu kita lakukan hanyalah melakukan operasi tersebut pada sistem koordinatnya. Sistem koordinat A, relatif terhadap sistem koordinat B, didefinisikan oleh posisi pusatnya sebagai vektor p_ab , dan vektor untuk setiap sumbunya, x_ab , y_ab , dan z_ab , yang mewakili arah sumbu tersebut. Jadi jika sebuah titik bergerak 10 pada sumbu x sistem koordinat A, maka—dalam sistem koordinat B—akan bergerak ke arah x_ab , dikalikan 10.

Semua informasi ini disimpan dalam bentuk matriks berikut:

 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

Jika kita ingin mentransformasikan vektor 3D q , kita hanya perlu mengalikan matriks transformasi dengan vektor tersebut:

 qx qy qz 1

Hal ini menyebabkan titik bergerak qx sepanjang sumbu x baru, qy sepanjang sumbu y baru, dan qz sepanjang sumbu z baru. Akhirnya menyebabkan titik untuk bergerak tambahan oleh vektor p , yang merupakan alasan mengapa kami menggunakan satu sebagai elemen akhir dari perkalian.

Keuntungan besar menggunakan matriks ini adalah kenyataan bahwa jika kita memiliki banyak transformasi untuk dilakukan pada simpul, kita dapat menggabungkannya menjadi satu transformasi dengan mengalikan matriksnya, sebelum mentransformasikan simpul itu sendiri.

Ada berbagai transformasi yang dapat dilakukan, dan kita akan melihat yang utama.

Tidak Ada Transformasi

Jika tidak terjadi transformasi, maka vektor p adalah vektor nol, vektor x adalah [1, 0, 0] , y adalah [0, 1, 0] , dan z adalah [0, 0, 1] . Mulai sekarang kita akan mengacu pada nilai-nilai ini sebagai nilai default untuk vektor-vektor ini. Menerapkan nilai-nilai ini memberi kita matriks identitas:

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

Ini adalah titik awal yang baik untuk transformasi rantai.

Terjemahan

Transformasi bingkai untuk terjemahan

Ketika kita melakukan translasi, maka semua vektor kecuali vektor p memiliki nilai defaultnya. Ini menghasilkan matriks berikut:

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

penskalaan

Transformasi bingkai untuk penskalaan

Menskalakan model berarti mengurangi jumlah kontribusi setiap koordinat terhadap posisi suatu titik. Tidak ada offset seragam yang disebabkan oleh penskalaan, sehingga vektor p mempertahankan nilai defaultnya. Vektor sumbu default harus dikalikan dengan faktor penskalaannya masing-masing, yang menghasilkan matriks berikut:

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

Di sini s_x , s_y , dan s_z mewakili penskalaan yang diterapkan pada setiap sumbu.

Rotasi

Transformasi bingkai untuk rotasi di sekitar sumbu Z

Gambar di atas menunjukkan apa yang terjadi ketika kita memutar bingkai koordinat di sekitar sumbu Z.

Rotasi tidak menghasilkan offset yang seragam, sehingga vektor p mempertahankan nilai defaultnya. Sekarang segalanya menjadi sedikit lebih rumit. Rotasi menyebabkan gerakan sepanjang sumbu tertentu dalam sistem koordinat asli bergerak ke arah yang berbeda. Jadi jika kita memutar sistem koordinat sebesar 45 derajat di sekitar sumbu Z, bergerak sepanjang sumbu x dari sistem koordinat asli menyebabkan gerakan dalam arah diagonal antara sumbu x dan y dalam sistem koordinat yang baru.

Untuk mempermudah, kami hanya akan menunjukkan kepada Anda bagaimana matriks transformasi mencari rotasi di sekitar sumbu utama.

 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

Penerapan

Semua ini dapat diimplementasikan sebagai kelas yang menyimpan 16 angka, menyimpan matriks dalam urutan kolom-utama.

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

Melihat melalui Kamera

Inilah bagian penting dari menampilkan objek di layar: kamera. Ada dua komponen kunci untuk sebuah kamera; yaitu, posisinya, dan bagaimana ia memproyeksikan objek yang diamati ke layar.

Posisi kamera ditangani dengan satu trik sederhana. Tidak ada perbedaan visual antara menggerakkan kamera satu meter ke depan, dan menggerakkan seluruh dunia satu meter ke belakang. Jadi tentu saja, kami melakukan yang terakhir, dengan menerapkan invers matriks sebagai transformasi.

Komponen kunci kedua adalah cara objek yang diamati diproyeksikan ke lensa. Di WebGL, semua yang terlihat di layar terletak di dalam kotak. Kotak membentang antara -1 dan 1 pada setiap sumbu. Semua yang terlihat ada di dalam kotak itu. Kita dapat menggunakan pendekatan matriks transformasi yang sama untuk membuat matriks proyeksi.

Proyeksi Ortografis

Ruang persegi panjang diubah menjadi dimensi penyangga bingkai yang tepat menggunakan proyeksi ortografis

Proyeksi yang paling sederhana adalah proyeksi ortografis. Anda mengambil sebuah kotak di ruang angkasa, yang menunjukkan lebar, tinggi dan kedalaman, dengan asumsi bahwa pusatnya berada di posisi nol. Kemudian proyeksi mengubah ukuran kotak agar sesuai dengan kotak yang dijelaskan sebelumnya di mana WebGL mengamati objek. Karena kami ingin mengubah ukuran setiap dimensi menjadi dua, kami menskalakan setiap sumbu dengan 2/size , di mana size adalah dimensi dari masing-masing sumbu. Peringatan kecil adalah fakta bahwa kita mengalikan sumbu Z dengan negatif. Hal ini dilakukan karena kita ingin membalik arah dimensi tersebut. Matriks terakhir memiliki bentuk ini:

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

Proyeksi Perspektif

Frustum diubah menjadi dimensi framebuffer yang tepat menggunakan proyeksi perspektif

Kami tidak akan membahas detail bagaimana proyeksi ini dirancang, tetapi hanya menggunakan rumus akhir, yang cukup standar sekarang. Kita dapat menyederhanakannya dengan menempatkan proyeksi pada posisi nol pada sumbu x dan y, membuat batas kanan/kiri dan atas/bawah masing-masing sama dengan width/2 dan height/2 . Parameter n dan f mewakili bidang kliping near dan far , yang merupakan jarak terkecil dan terbesar suatu titik yang dapat ditangkap oleh kamera. Mereka diwakili oleh sisi paralel frustum pada gambar di atas.

Proyeksi perspektif biasanya diwakili dengan bidang pandang (kita akan menggunakan yang vertikal), rasio aspek, dan jarak bidang dekat dan jauh. Informasi tersebut dapat digunakan untuk menghitung width dan height , dan kemudian matriks dapat dibuat dari template berikut:

 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

Untuk menghitung lebar dan tinggi, rumus berikut dapat digunakan:

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

FOV (bidang pandang) mewakili sudut vertikal yang ditangkap kamera dengan lensanya. Rasio aspek mewakili rasio antara lebar dan tinggi gambar, dan didasarkan pada dimensi layar tempat kita merender.

Penerapan

Sekarang kita dapat merepresentasikan kamera sebagai kelas yang menyimpan posisi kamera dan matriks proyeksi. Kita juga perlu mengetahui cara menghitung transformasi invers. Memecahkan inversi matriks umum dapat menjadi masalah, tetapi ada pendekatan yang disederhanakan untuk kasus khusus kami.

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

Ini adalah bagian terakhir yang kita butuhkan sebelum kita bisa mulai menggambar sesuatu di layar.

Menggambar Objek dengan WebGL Graphics Pipeline

Permukaan paling sederhana yang bisa Anda gambar adalah segitiga. Faktanya, sebagian besar hal yang Anda gambar dalam ruang 3D terdiri dari banyak segitiga.

Pandangan dasar tentang langkah-langkah pipa grafis yang dilakukan

Hal pertama yang perlu Anda pahami adalah bagaimana layar direpresentasikan dalam WebGL. Ini adalah ruang 3D, membentang antara -1 dan 1 pada sumbu x , y , dan z . Secara default sumbu z ini tidak digunakan, tetapi Anda tertarik dengan grafik 3D, jadi Anda harus segera mengaktifkannya.

Dengan mengingat hal itu, berikut adalah tiga langkah yang diperlukan untuk menggambar segitiga ke permukaan ini.

Anda dapat menentukan tiga simpul, yang akan mewakili segitiga yang ingin Anda gambar. Anda membuat serial data itu dan mengirimkannya ke GPU (unit pemrosesan grafis). Dengan seluruh model yang tersedia, Anda dapat melakukannya untuk semua segitiga dalam model. Posisi simpul yang Anda berikan berada di ruang koordinat lokal dari model yang Anda muat. Sederhananya, posisi yang Anda berikan adalah posisi yang tepat dari file, dan bukan posisi yang Anda dapatkan setelah melakukan transformasi matriks.

Sekarang setelah Anda memberikan simpul ke GPU, Anda memberi tahu GPU logika apa yang digunakan saat menempatkan simpul ke layar. Langkah ini akan digunakan untuk menerapkan transformasi matriks kita. GPU sangat baik dalam mengalikan banyak matriks 4x4, jadi kami akan memanfaatkan kemampuan itu dengan baik.

Pada langkah terakhir, GPU akan meraster segitiga itu. Rasterisasi adalah proses pengambilan grafik vektor dan menentukan piksel layar mana yang perlu dicat agar objek grafik vektor tersebut ditampilkan. Dalam kasus kami, GPU mencoba menentukan piksel mana yang terletak di dalam setiap segitiga. Untuk setiap piksel, GPU akan menanyakan warna apa yang Anda inginkan untuk dicat.

Ini adalah empat elemen yang diperlukan untuk menggambar apa pun yang Anda inginkan, dan ini adalah contoh paling sederhana dari jalur grafik. Berikut ini adalah melihat masing-masing, dan implementasi sederhana.

Framebuffer Default

Elemen terpenting untuk aplikasi WebGL adalah konteks WebGL. Anda dapat mengaksesnya dengan gl = canvas.getContext('webgl') , atau menggunakan 'experimental-webgl' sebagai pengganti, jika browser yang digunakan saat ini belum mendukung semua fitur WebGL. canvas yang kita rujuk adalah elemen DOM dari kanvas yang ingin kita gambar. Konteksnya berisi banyak hal, di antaranya adalah framebuffer default.

Anda dapat secara longgar menggambarkan framebuffer sebagai buffer (objek) apa pun yang dapat Anda gambar. Secara default, framebuffer default menyimpan warna untuk setiap piksel kanvas yang terikat dengan konteks WebGL. Seperti yang dijelaskan pada bagian sebelumnya, ketika kita menggambar pada framebuffer, setiap piksel terletak antara -1 dan 1 pada sumbu x dan y . Sesuatu yang juga kami sebutkan adalah fakta bahwa, secara default, WebGL tidak menggunakan sumbu z . Fungsionalitas itu dapat diaktifkan dengan menjalankan gl.enable(gl.DEPTH_TEST) . Bagus, tapi apa itu tes kedalaman?

Mengaktifkan uji kedalaman memungkinkan piksel menyimpan warna dan kedalaman. Kedalaman adalah koordinat z dari piksel itu. Setelah Anda menggambar piksel pada kedalaman tertentu z , untuk memperbarui warna piksel tersebut, Anda perlu menggambar pada posisi z yang lebih dekat ke kamera. Jika tidak, upaya undian akan diabaikan. Hal ini memungkinkan terjadinya ilusi 3D, karena menggambar objek yang berada di belakang objek lain akan menyebabkan objek tersebut terhalang oleh objek di depannya.

Pengundian apa pun yang Anda lakukan tetap berada di layar sampai Anda memberi tahu mereka untuk dibersihkan. Untuk melakukannya, Anda harus memanggil gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) . Ini menghapus buffer warna dan kedalaman. Untuk memilih warna yang disetel untuk piksel yang dibersihkan, gunakan gl.clearColor(red, green, blue, alpha) .

Mari buat perender yang menggunakan kanvas dan menghapusnya berdasarkan permintaan:

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

Melampirkan skrip ini ke HTML berikut akan memberi Anda persegi panjang biru cerah di layar

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

Panggilan requestAnimationFrame menyebabkan loop dipanggil lagi segera setelah frame sebelumnya selesai dirender dan semua penanganan acara selesai.

Objek Penyangga Vertex

Hal pertama yang perlu Anda lakukan adalah menentukan simpul yang ingin Anda gambar. Anda dapat melakukannya dengan menggambarkannya melalui vektor dalam ruang 3D. Setelah itu, Anda ingin memindahkan data tersebut ke RAM GPU, dengan membuat Vertex Buffer Object (VBO) baru.

Objek Buffer secara umum adalah objek yang menyimpan array potongan memori pada GPU. Menjadi VBO hanya menunjukkan untuk apa GPU dapat menggunakan memori. Sebagian besar waktu, Objek Buffer yang Anda buat adalah VBO.

Anda dapat mengisi VBO dengan mengambil semua N simpul yang kita miliki dan membuat array float dengan elemen 3N untuk posisi simpul dan VBO normal simpul, dan 2N untuk koordinat tekstur VBO. Setiap kelompok tiga pelampung, atau dua pelampung untuk koordinat UV, mewakili koordinat individu dari sebuah simpul. Kemudian kami meneruskan array ini ke GPU, dan simpul kami siap untuk sisa pipa.

Karena data sekarang ada di RAM GPU, Anda dapat menghapusnya dari RAM tujuan umum. Yaitu, kecuali jika nanti Anda ingin memodifikasinya, dan mengunggahnya lagi. Setiap modifikasi harus diikuti dengan unggahan, karena modifikasi dalam susunan JS kami tidak berlaku untuk VBO di RAM GPU yang sebenarnya.

Di bawah ini adalah contoh kode yang menyediakan semua fungsi yang dijelaskan. Catatan penting yang harus dibuat adalah fakta bahwa variabel yang disimpan di GPU bukanlah sampah yang dikumpulkan. Itu berarti kita harus menghapusnya secara manual setelah kita tidak ingin menggunakannya lagi. Kami hanya akan memberi Anda contoh bagaimana hal itu dilakukan di sini, dan tidak akan fokus pada konsep itu lebih jauh. Menghapus variabel dari GPU hanya diperlukan jika Anda berencana untuk berhenti menggunakan geometri tertentu selama program.

Kami juga menambahkan serialisasi ke kelas Geometry dan elemen di dalamnya.

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

Tipe data VBO menghasilkan VBO dalam konteks WebGL yang diteruskan, berdasarkan larik yang diteruskan sebagai parameter kedua.

Anda dapat melihat tiga panggilan ke konteks gl . Panggilan createBuffer() membuat buffer. bindBuffer() memberi tahu mesin status WebGL untuk menggunakan memori khusus ini sebagai VBO saat ini ( ARRAY_BUFFER ) untuk semua operasi di masa mendatang, hingga diberi tahu sebaliknya. Setelah itu, kami menetapkan nilai VBO saat ini ke data yang disediakan, dengan bufferData() .

Kami juga menyediakan metode penghancuran yang menghapus objek buffer kami dari RAM GPU, dengan menggunakan deleteBuffer() .

Anda dapat menggunakan tiga VBO dan transformasi untuk menggambarkan semua properti mesh, bersama dengan posisinya.

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

Sebagai contoh, berikut adalah bagaimana kita dapat memuat model, menyimpan propertinya di mesh, dan kemudian menghancurkannya:

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

shader

Berikut ini adalah proses dua langkah yang dijelaskan sebelumnya untuk memindahkan titik ke posisi yang diinginkan dan mengecat semua piksel individu. Untuk melakukan ini, kami menulis program yang dijalankan pada kartu grafis berkali-kali. Program ini biasanya terdiri dari setidaknya dua bagian. Bagian pertama adalah Vertex Shader , yang dijalankan untuk setiap vertex, dan output di mana kita harus menempatkan vertex di layar, antara lain. Bagian kedua adalah Fragment Shader , yang dijalankan untuk setiap piksel yang tertutup segitiga di layar, dan menampilkan warna yang harus dicat piksel.

Vertex Shaders

Katakanlah Anda ingin memiliki model yang bergerak ke kiri dan ke kanan di layar. Dalam pendekatan naif, Anda dapat memperbarui posisi setiap simpul dan mengirimkannya kembali ke GPU. Proses itu mahal dan lambat. Atau, Anda akan memberikan program agar GPU dijalankan untuk setiap simpul, dan melakukan semua operasi tersebut secara paralel dengan prosesor yang dibuat untuk melakukan pekerjaan itu. Itulah peran shader vertex .

Sebuah vertex shader adalah bagian dari pipa rendering yang memproses setiap simpul. Panggilan ke vertex shader menerima satu vertex dan mengeluarkan satu vertex setelah semua kemungkinan transformasi ke vertex diterapkan.

Shader ditulis dalam GLSL. Ada banyak elemen unik pada bahasa ini, tetapi sebagian besar sintaksnya sangat mirip C, jadi bahasa ini harus dapat dimengerti oleh kebanyakan orang.

Ada tiga jenis variabel yang masuk dan keluar dari vertex shader, dan semuanya melayani penggunaan tertentu:

  • attribute — Ini adalah input yang memiliki properti tertentu dari sebuah simpul. Sebelumnya, kami menggambarkan posisi sebuah simpul sebagai atribut, dalam bentuk vektor tiga elemen. Anda dapat melihat atribut sebagai nilai yang menggambarkan satu simpul.
  • uniform — Ini adalah input yang sama untuk setiap vertex dalam panggilan rendering yang sama. Katakanlah kita ingin dapat memindahkan model kita, dengan mendefinisikan matriks transformasi. Anda dapat menggunakan variabel uniform untuk menggambarkan itu. Anda juga dapat menunjuk ke sumber daya pada GPU, seperti tekstur. Anda dapat melihat seragam sebagai nilai yang menggambarkan model, atau bagian dari model.
  • varying — Ini adalah output yang kami berikan ke shader fragmen. Karena ada kemungkinan ribuan piksel untuk segitiga simpul, setiap piksel akan menerima nilai interpolasi untuk variabel ini, tergantung pada posisinya. Jadi jika satu simpul mengirimkan 500 sebagai output, dan satu lagi 100, piksel yang berada di tengah antara keduanya akan menerima 300 sebagai masukan untuk variabel tersebut. Anda dapat melihat variasi sebagai nilai yang menggambarkan permukaan antara simpul.

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?” Anda mungkin bertanya.

 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

Kami mengatur matahari untuk bersinar ke arah depan-kiri-bawah. Anda bisa melihat betapa halusnya shading, meski modelnya sangat bergerigi. Anda juga dapat melihat betapa gelapnya sisi kiri bawah. Kita dapat menambahkan tingkat cahaya sekitar, yang akan membuat area dalam bayangan lebih cerah.

 #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.); } 

Objek coklat dengan sinar matahari dan cahaya sekitar

Anda dapat mencapai efek yang sama dengan memperkenalkan kelas cahaya, yang menyimpan arah cahaya dan intensitas cahaya sekitar. Kemudian Anda dapat mengubah shader fragmen untuk mengakomodasi penambahan itu.

Sekarang shader menjadi:

 #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.); }

Kemudian Anda dapat menentukan cahaya:

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

Di kelas program shader, tambahkan seragam yang dibutuhkan:

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

Dalam program, tambahkan panggilan ke cahaya baru di penyaji:

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

Loop kemudian akan berubah sedikit:

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

Jika Anda telah melakukan semuanya dengan benar, maka gambar yang dirender harus sama seperti pada gambar terakhir.

Langkah terakhir yang perlu dipertimbangkan adalah menambahkan tekstur aktual ke model kita. Ayo lakukan itu sekarang.

Menambahkan Tekstur

HTML5 memiliki dukungan hebat untuk memuat gambar, jadi tidak perlu melakukan penguraian gambar yang gila-gilaan. Gambar diteruskan ke GLSL sebagai sampler2D dengan memberi tahu shader tekstur terikat mana yang akan diambil sampelnya. Ada sejumlah tekstur yang dapat diikat, dan batasnya didasarkan pada perangkat keras yang digunakan. sampler2D dapat ditanyakan untuk warna pada posisi tertentu. Di sinilah koordinat UV masuk. Berikut adalah contoh di mana kami mengganti warna cokelat dengan warna sampel.

 #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.); }

Seragam baru harus ditambahkan ke daftar di program shader:

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

Terakhir, kami akan menerapkan pemuatan tekstur. Seperti yang dikatakan sebelumnya, HTML5 menyediakan fasilitas untuk memuat gambar. Yang perlu kita lakukan adalah mengirim gambar ke GPU:

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

Prosesnya tidak jauh berbeda dengan proses yang digunakan untuk memuat dan mengikat VBO. Perbedaan utamanya adalah kita tidak lagi mengikat atribut, melainkan mengikat indeks tekstur ke seragam integer. Tipe sampler2D tidak lebih dari sebuah pointer yang diimbangi dengan tekstur.

Sekarang yang perlu dilakukan adalah memperluas kelas Mesh , untuk menangani tekstur juga:

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

Dan skrip utama terakhir akan terlihat sebagai berikut:

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

Objek bertekstur dengan efek pencahayaan

Bahkan animasi menjadi mudah pada saat ini. Jika Anda ingin kamera berputar di sekitar objek kita, Anda dapat melakukannya hanya dengan menambahkan satu baris kode:

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

Kepala yang diputar selama animasi kamera

Jangan ragu untuk bermain-main dengan shader. Menambahkan satu baris kode akan mengubah pencahayaan realistis ini menjadi sesuatu yang kartun.

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

Ini sesederhana memberitahu pencahayaan untuk pergi ke ekstrim berdasarkan apakah itu melewati ambang batas yang ditetapkan.

Kepala dengan pencahayaan kartun diterapkan

Ke mana harus pergi selanjutnya?

Ada banyak sumber informasi untuk mempelajari semua trik dan seluk-beluk WebGL. Dan bagian terbaiknya adalah jika Anda tidak dapat menemukan jawaban yang terkait dengan WebGL, Anda dapat mencarinya di OpenGL, karena WebGL cukup banyak didasarkan pada subset OpenGL, dengan beberapa nama yang diubah.

Tanpa urutan tertentu, berikut adalah beberapa sumber yang bagus untuk informasi lebih rinci, baik untuk WebGL dan OpenGL.

  • Dasar-dasar WebGL
  • Belajar WebGL
  • Tutorial OpenGL yang sangat mendetail memandu Anda melalui semua prinsip dasar yang dijelaskan di sini, dengan cara yang sangat lambat dan mendetail.
  • Dan ada banyak, banyak situs lain yang didedikasikan untuk mengajari Anda prinsip-prinsip grafik komputer.
  • Dokumentasi MDN untuk WebGL
  • Spesifikasi Khronos WebGL 1.0 jika Anda tertarik untuk memahami detail teknis selengkapnya tentang cara kerja WebGL API di semua kasus tepi.