Gráficos 3D: un tutorial de WebGL
Publicado: 2022-03-11Entrar en el mundo de los gráficos 3D puede ser muy intimidante. Ya sea que solo desee crear un logotipo 3D interactivo o diseñar un juego completo, si no conoce los principios de la representación 3D, está atascado usando una biblioteca que abstrae muchas cosas.
El uso de una biblioteca puede ser la herramienta adecuada, y JavaScript tiene una increíble fuente abierta en forma de three.js. Sin embargo, existen algunas desventajas en el uso de soluciones prefabricadas:
- Pueden tener muchas características que no planea usar. El tamaño de las características de la base tres.js minificada es de alrededor de 500 kB, y cualquier característica adicional (la carga de archivos de modelos reales es una de ellas) hace que la carga útil sea aún mayor. Transferir tantos datos solo para mostrar un logotipo giratorio en su sitio web sería un desperdicio.
- Una capa adicional de abstracción puede hacer que las modificaciones que de otro modo serían fáciles sean difíciles de realizar. Su forma creativa de sombrear un objeto en la pantalla puede ser sencilla de implementar o requerir decenas de horas de trabajo para incorporarla a las abstracciones de la biblioteca.
- Si bien la biblioteca está muy bien optimizada en la mayoría de los escenarios, se pueden eliminar muchas campanas y silbatos para su caso de uso. El renderizador puede hacer que ciertos procedimientos se ejecuten millones de veces en la tarjeta gráfica. Cada instrucción eliminada de dicho procedimiento significa que una tarjeta gráfica más débil puede manejar su contenido sin problemas.
Incluso si decide usar una biblioteca de gráficos de alto nivel, tener un conocimiento básico de las cosas debajo del capó le permite usarla de manera más efectiva. Las bibliotecas también pueden tener funciones avanzadas, como ShaderMaterial en three.js . Conocer los principios de la representación de gráficos le permite utilizar dichas funciones.
Nuestro objetivo es brindar una breve introducción a todos los conceptos clave detrás de la representación de gráficos 3D y el uso de WebGL para implementarlos. Verás lo más común que se hace, que es mostrar y mover objetos 3D en un espacio vacío.
El código final está disponible para que lo bifurques y juegues.
Representación de modelos 3D
Lo primero que debe comprender es cómo se representan los modelos 3D. Un modelo está hecho de una malla de triángulos. Cada triángulo está representado por tres vértices, para cada uno de los vértices del triángulo. Hay tres propiedades más comunes asociadas a los vértices.
Posición del vértice
La posición es la propiedad más intuitiva de un vértice. Es la posición en el espacio 3D, representada por un vector 3D de coordenadas. Si conoces las coordenadas exactas de tres puntos en el espacio, tendrías toda la información que necesitas para dibujar un triángulo simple entre ellos. Para hacer que los modelos se vean realmente bien cuando se renderizan, hay un par de cosas más que se deben proporcionar al renderizador.
Vértice normal
Considere los dos modelos anteriores. Consisten en las mismas posiciones de vértice, pero se ven totalmente diferentes cuando se representan. ¿Cómo es eso posible?
Además de decirle al renderizador dónde queremos que se ubique un vértice, también podemos darle una pista sobre cómo se inclina la superficie en esa posición exacta. La pista tiene la forma de la normal de la superficie en ese punto específico del modelo, representada con un vector 3D. La siguiente imagen debería darle una visión más descriptiva de cómo se maneja eso.
Las superficies izquierda y derecha corresponden a la bola izquierda y derecha de la imagen anterior, respectivamente. Las flechas rojas representan las normales que se especifican para un vértice, mientras que las flechas azules representan los cálculos del renderizador de cómo debería verse la normal para todos los puntos entre los vértices. La imagen muestra una demostración para el espacio 2D, pero el mismo principio se aplica en 3D.
Lo normal es una pista de cómo las luces iluminarán la superficie. Cuanto más cerca de la normal es la dirección de un rayo de luz, más brillante es el punto. Los cambios graduales en la dirección normal provocan gradientes de luz, mientras que los cambios abruptos sin cambios intermedios provocan superficies con iluminación constante a través de ellas y cambios repentinos de iluminación entre ellas.
Coordenadas de textura
La última propiedad significativa son las coordenadas de textura, comúnmente conocidas como mapeo UV. Tienes un modelo y una textura que quieres aplicarle. La textura tiene varias áreas, que representan imágenes que queremos aplicar a diferentes partes del modelo. Tiene que haber una forma de marcar qué triángulo debe representarse con qué parte de la textura. Ahí es donde entra en juego el mapeo de texturas.
Para cada vértice, marcamos dos coordenadas, U y V. Estas coordenadas representan una posición en la textura, con U representando el eje horizontal y V el eje vertical. Los valores no están en píxeles, sino en una posición porcentual dentro de la imagen. La esquina inferior izquierda de la imagen se representa con dos ceros, mientras que la esquina superior derecha se representa con dos unos.
Un triángulo simplemente se pinta tomando las coordenadas UV de cada vértice en el triángulo y aplicando la imagen que se captura entre esas coordenadas en la textura.
Puede ver una demostración del mapeo UV en la imagen de arriba. Se tomó el modelo esférico y se cortó en partes lo suficientemente pequeñas como para aplanarse en una superficie 2D. Las costuras donde se hicieron los cortes están marcadas con líneas más gruesas. Se ha resaltado uno de los parches, por lo que puede ver muy bien cómo coinciden las cosas. También puede ver cómo una costura en el medio de la sonrisa coloca partes de la boca en dos parches diferentes.
Las estructuras alámbricas no son parte de la textura, sino que se superponen a la imagen para que pueda ver cómo se relacionan las cosas.
Cargando un modelo OBJ
Lo crea o no, esto es todo lo que necesita saber para crear su propio cargador de modelos simple. El formato de archivo OBJ es lo suficientemente simple como para implementar un analizador en unas pocas líneas de código.
El archivo enumera las posiciones de los vértices en un formato v <float> <float> <float> , con un cuarto flotante opcional, que ignoraremos para simplificar las cosas. Los vértices normales se representan de manera similar con vn <float> <float> <float> . Finalmente, las coordenadas de la textura se representan con vt <float> <float> , con un tercer float opcional que ignoraremos. En los tres casos, los flotadores representan las respectivas coordenadas. Estas tres propiedades se acumulan en tres matrices.
Las caras se representan con grupos de vértices. Cada vértice se representa con el índice de cada una de las propiedades, donde los índices comienzan en 1. Hay varias formas de representar esto, pero nos ceñiremos al formato f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 , requiriendo que se proporcionen las tres propiedades y limitando el número de vértices por cara a tres. Todas estas limitaciones se están haciendo para mantener el cargador lo más simple posible, ya que todas las demás opciones requieren un procesamiento trivial adicional antes de que estén en un formato que le guste a WebGL.
Hemos puesto muchos requisitos para nuestro cargador de archivos. Eso puede parecer limitante, pero las aplicaciones de modelado 3D tienden a brindarle la capacidad de establecer esas limitaciones al exportar un modelo como un archivo OBJ.
El siguiente código analiza una cadena que representa un archivo OBJ y crea un modelo en forma de matriz de caras.
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 } La estructura Geometry contiene los datos exactos necesarios para enviar un modelo a la tarjeta gráfica para su procesamiento. Sin embargo, antes de hacer eso, probablemente querrá tener la capacidad de mover el modelo en la pantalla.
Realizar transformaciones espaciales
Todos los puntos en el modelo que cargamos son relativos a su sistema de coordenadas. Si queremos trasladar, rotar y escalar el modelo, todo lo que necesitamos hacer es realizar esa operación en su sistema de coordenadas. El sistema de coordenadas A, relativo al sistema de coordenadas B, se define por la posición de su centro como un vector p_ab y el vector para cada uno de sus ejes, x_ab , y_ab y z_ab , que representan la dirección de ese eje. Entonces, si un punto se mueve 10 en el eje x del sistema de coordenadas A, entonces, en el sistema de coordenadas B, se moverá en la dirección de x_ab , multiplicado por 10.
Toda esta información se almacena en la siguiente forma matricial:
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 Si queremos transformar el vector 3D q , solo tenemos que multiplicar la matriz de transformación por el vector:
qx qy qz 1 Esto hace que el punto se mueva qx a lo largo del nuevo eje x , qy a lo largo del nuevo eje y y qz a lo largo del nuevo eje z . Finalmente, hace que el punto se mueva adicionalmente por el vector p , razón por la cual usamos un uno como elemento final de la multiplicación.
La gran ventaja de usar estas matrices es el hecho de que si tenemos que realizar varias transformaciones en el vértice, podemos fusionarlas en una transformación multiplicando sus matrices, antes de transformar el vértice en sí.
Hay varias transformaciones que se pueden realizar, y veremos las principales.
sin transformación
Si no ocurren transformaciones, entonces el vector p es un vector cero, el vector x es [1, 0, 0] , y es [0, 1, 0] y z es [0, 0, 1] . De ahora en adelante nos referiremos a estos valores como los valores predeterminados para estos vectores. La aplicación de estos valores nos da una matriz de identidad:
1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1Este es un buen punto de partida para encadenar transformaciones.
Traducción
Cuando realizamos la traducción, todos los vectores, excepto el vector p , tienen sus valores predeterminados. Esto da como resultado la siguiente matriz:
1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1Escalada
Escalar un modelo significa reducir la cantidad que cada coordenada contribuye a la posición de un punto. No hay un desplazamiento uniforme causado por la escala, por lo que el vector p mantiene su valor predeterminado. Los vectores de eje predeterminados deben multiplicarse por sus respectivos factores de escala, lo que da como resultado la siguiente matriz:
s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1 Aquí s_x , s_y y s_z representan la escala aplicada a cada eje.
Rotación
La imagen de arriba muestra lo que sucede cuando rotamos el marco de coordenadas alrededor del eje Z.
La rotación no produce un desplazamiento uniforme, por lo que el vector p mantiene su valor predeterminado. Ahora las cosas se ponen un poco más complicadas. Las rotaciones hacen que el movimiento a lo largo de cierto eje en el sistema de coordenadas original se mueva en una dirección diferente. Entonces, si giramos un sistema de coordenadas 45 grados alrededor del eje Z, el movimiento a lo largo del eje x del sistema de coordenadas original provoca un movimiento en dirección diagonal entre los ejes x e y en el nuevo sistema de coordenadas.
Para simplificar las cosas, solo le mostraremos cómo las matrices de transformación buscan rotaciones alrededor de los ejes principales.
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 1Implementación
Todo esto se puede implementar como una clase que almacena 16 números, almacenando matrices en un orden de columnas principales.
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) }Mirando a través de una cámara
Aquí viene la parte clave de la presentación de objetos en la pantalla: la cámara. Hay dos componentes clave para una cámara; es decir, su posición y cómo proyecta los objetos observados en la pantalla.
La posición de la cámara se maneja con un simple truco. No hay diferencia visual entre mover la cámara un metro hacia adelante y mover el mundo entero un metro hacia atrás. Entonces, naturalmente, hacemos lo último, aplicando la inversa de la matriz como una transformación.
El segundo componente clave es la forma en que los objetos observados se proyectan en la lente. En WebGL, todo lo visible en la pantalla se encuentra en un cuadro. El cuadro abarca entre -1 y 1 en cada eje. Todo lo visible está dentro de esa caja. Podemos usar el mismo enfoque de matrices de transformación para crear una matriz de proyección.
Proyección ortográfica
La proyección más simple es la proyección ortográfica. Tomas un cuadro en el espacio, denotando el ancho, la altura y la profundidad, suponiendo que su centro está en la posición cero. Luego, la proyección cambia el tamaño del cuadro para que encaje en el cuadro descrito anteriormente dentro del cual WebGL observa los objetos. Dado que queremos cambiar el tamaño de cada dimensión a dos, escalamos cada eje en 2/size , donde el size es la dimensión del eje respectivo. Una pequeña advertencia es el hecho de que estamos multiplicando el eje Z con un negativo. Esto se hace porque queremos cambiar la dirección de esa dimensión. La matriz final tiene esta forma:
2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1Proyección de perspectiva
No analizaremos los detalles de cómo se diseña esta proyección, solo usaremos la fórmula final, que ya es bastante estándar. Podemos simplificarlo colocando la proyección en la posición cero en los ejes x e y, haciendo que los límites derecho/izquierdo y superior/inferior sean iguales a width/2 y height/2 respectivamente. Los parámetros n y f representan los planos de recorte near y far , que son la distancia más pequeña y más grande que un punto puede tener para ser capturado por la cámara. Están representados por los lados paralelos del tronco en la imagen de arriba.
Una proyección en perspectiva generalmente se representa con un campo de visión (usaremos el vertical), una relación de aspecto y las distancias del plano cercano y lejano. Esa información se puede usar para calcular el width y el height , y luego se puede crear la matriz a partir de la siguiente plantilla:
2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0Para calcular el ancho y el alto, se pueden utilizar las siguientes fórmulas:
height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * heightEl FOV (campo de visión) representa el ángulo vertical que la cámara captura con su lente. La relación de aspecto representa la relación entre el ancho y el alto de la imagen, y se basa en las dimensiones de la pantalla en la que estamos renderizando.
Implementación
Ahora podemos representar una cámara como una clase que almacena la posición de la cámara y la matriz de proyección. También necesitamos saber cómo calcular transformaciones inversas. Resolver inversiones de matrices generales puede ser problemático, pero existe un enfoque simplificado para nuestro caso especial.
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) }Esta es la pieza final que necesitamos antes de que podamos comenzar a dibujar cosas en la pantalla.
Dibujar un objeto con la canalización de gráficos WebGL
La superficie más simple que puedes dibujar es un triángulo. De hecho, la mayoría de las cosas que dibujas en el espacio 3D consisten en una gran cantidad de triángulos.
Lo primero que debe comprender es cómo se representa la pantalla en WebGL. Es un espacio 3D, que abarca entre -1 y 1 en los ejes x , y y z . De forma predeterminada, este eje z no se usa, pero le interesan los gráficos 3D, por lo que querrá habilitarlo de inmediato.
Teniendo eso en cuenta, lo que sigue son tres pasos necesarios para dibujar un triángulo en esta superficie.
Puede definir tres vértices, que representarían el triángulo que desea dibujar. Usted serializa esos datos y los envía a la GPU (unidad de procesamiento de gráficos). Con un modelo completo disponible, puede hacer eso para todos los triángulos del modelo. Las posiciones de los vértices que proporciona están en el espacio de coordenadas local del modelo que ha cargado. En pocas palabras, las posiciones que proporciona son las exactas del archivo y no las que obtiene después de realizar transformaciones de matriz.
Ahora que le has dado los vértices a la GPU, le dices a la GPU qué lógica usar al colocar los vértices en la pantalla. Este paso se utilizará para aplicar nuestras transformaciones de matriz. La GPU es muy buena para multiplicar muchas matrices 4x4, así que le daremos un buen uso a esa capacidad.
En el último paso, la GPU rasterizará ese triángulo. La rasterización es el proceso de tomar gráficos vectoriales y determinar qué píxeles de la pantalla deben pintarse para que se muestre ese objeto de gráficos vectoriales. En nuestro caso, la GPU intenta determinar qué píxeles se encuentran dentro de cada triángulo. Para cada píxel, la GPU te preguntará de qué color quieres que se pinte.
Estos son los cuatro elementos necesarios para dibujar lo que quieras, y son el ejemplo más simple de una canalización de gráficos. Lo que sigue es un vistazo a cada uno de ellos y una implementación simple.
El búfer de fotogramas predeterminado
El elemento más importante para una aplicación WebGL es el contexto WebGL. Puede acceder a él con gl = canvas.getContext('webgl') , o usar 'experimental-webgl' como alternativa, en caso de que el navegador utilizado actualmente no admita todas las funciones de WebGL todavía. El canvas al que nos referimos es el elemento DOM del lienzo en el que queremos dibujar. El contexto contiene muchas cosas, entre las que se encuentra el framebuffer predeterminado.
Podría describir vagamente un framebuffer como cualquier búfer (objeto) en el que pueda dibujar. De forma predeterminada, el búfer de fotogramas predeterminado almacena el color de cada píxel del lienzo al que está vinculado el contexto WebGL. Como se describió en la sección anterior, cuando dibujamos en el framebuffer, cada píxel se ubica entre -1 y 1 en los ejes x e y . Algo que también mencionamos es el hecho de que, por defecto, WebGL no usa el eje z . Esa funcionalidad se puede habilitar ejecutando gl.enable(gl.DEPTH_TEST) . Genial, pero ¿qué es una prueba de profundidad?
Habilitar la prueba de profundidad permite que un píxel almacene tanto el color como la profundidad. La profundidad es la coordenada z de ese píxel. Después de dibujar un píxel a una cierta profundidad z , para actualizar el color de ese píxel, debe dibujar en una posición z que esté más cerca de la cámara. De lo contrario, se ignorará el intento de empate. Esto permite la ilusión de 3D, ya que dibujar objetos que están detrás de otros objetos hará que esos objetos queden tapados por los objetos que están delante de ellos.
Todos los sorteos que realice permanecerán en la pantalla hasta que les indique que se eliminen. Para hacerlo, debe llamar a gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) . Esto borra tanto el búfer de color como el de profundidad. Para elegir el color en el que se establecen los píxeles borrados, use gl.clearColor(red, green, blue, alpha) .
Vamos a crear un renderizador que use un lienzo y lo borre a pedido:
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) }Adjuntar este script al siguiente HTML le dará un rectángulo azul brillante en la pantalla
<!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html> La llamada requestAnimationFrame hace que el bucle se vuelva a llamar tan pronto como el cuadro anterior termine de renderizarse y todo el manejo de eventos finalice.
Objetos de zona de influencia de vértices
Lo primero que debe hacer es definir los vértices que desea dibujar. Puede hacerlo describiéndolos a través de vectores en el espacio 3D. Después de eso, desea mover esos datos a la GPU RAM, creando un nuevo objeto de búfer de vértice (VBO).
Un objeto de búfer en general es un objeto que almacena una matriz de fragmentos de memoria en la GPU. Ser un VBO solo indica para qué la GPU puede usar la memoria. La mayoría de las veces, los objetos de búfer que cree serán VBO.
Puede llenar el VBO tomando todos los N vértices que tenemos y creando una matriz de elementos flotantes con 3N elementos para la posición del vértice y los VBO normales del vértice, y 2N para las coordenadas de textura VBO. Cada grupo de tres flotantes, o dos flotantes para coordenadas UV, representa coordenadas individuales de un vértice. Luego, pasamos estos arreglos a la GPU y nuestros vértices están listos para el resto de la canalización.

Dado que los datos ahora están en la GPU RAM, puede eliminarlos de la RAM de uso general. Es decir, a menos que quieras modificarlo más tarde y volver a subirlo. Cada modificación debe ir seguida de una carga, ya que las modificaciones en nuestras matrices JS no se aplican a los VBO en la GPU RAM real.
A continuación se muestra un ejemplo de código que proporciona toda la funcionalidad descrita. Una nota importante a tener en cuenta es el hecho de que las variables almacenadas en la GPU no se recolectan como basura. Eso significa que tenemos que eliminarlos manualmente una vez que no queremos usarlos más. Solo le daremos un ejemplo de cómo se hace eso aquí, y no nos centraremos en ese concepto más adelante. La eliminación de variables de la GPU es necesaria solo si planea dejar de usar cierta geometría en todo el programa.
También agregamos serialización a nuestra clase Geometry y elementos dentro de ella.
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) } El tipo de datos VBO genera el VBO en el contexto WebGL pasado, en función de la matriz pasada como segundo parámetro.
Puede ver tres llamadas al contexto gl . La llamada createBuffer() crea el búfer. La llamada a bindBuffer() le dice a la máquina de estado WebGL que use esta memoria específica como el VBO actual ( ARRAY_BUFFER ) para todas las operaciones futuras, hasta que se le indique lo contrario. Después de eso, establecemos el valor del VBO actual en los datos provistos, con bufferData() .
También proporcionamos un método de destrucción que elimina nuestro objeto de búfer de la GPU RAM mediante deleteBuffer() .
Puede usar tres VBO y una transformación para describir todas las propiedades de una malla, junto con su posición.
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() }Como ejemplo, así es como podemos cargar un modelo, almacenar sus propiedades en la malla y luego destruirlo:
Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })sombreadores
Lo que sigue es el proceso de dos pasos descrito anteriormente de mover puntos a las posiciones deseadas y pintar todos los píxeles individuales. Para hacer esto, escribimos un programa que se ejecuta en la tarjeta gráfica muchas veces. Este programa normalmente consta de al menos dos partes. La primera parte es un Vertex Shader , que se ejecuta para cada vértice y muestra dónde debemos colocar el vértice en la pantalla, entre otras cosas. La segunda parte es Fragment Shader , que se ejecuta para cada píxel que cubre un triángulo en la pantalla y genera el color con el que se debe pintar ese píxel.
Sombreadores de vértices
Supongamos que desea tener un modelo que se mueva de izquierda a derecha en la pantalla. En un enfoque ingenuo, podría actualizar la posición de cada vértice y reenviarlo a la GPU. Ese proceso es costoso y lento. Alternativamente, le daría un programa para que la GPU se ejecute para cada vértice y realice todas esas operaciones en paralelo con un procesador diseñado para hacer exactamente ese trabajo. Ese es el papel de un sombreador de vértices .
Un sombreador de vértices es la parte de la canalización de representación que procesa vértices individuales. Una llamada al sombreador de vértices recibe un solo vértice y genera un solo vértice después de aplicar todas las transformaciones posibles al vértice.
Los shaders están escritos en GLSL. Hay muchos elementos únicos en este lenguaje, pero la mayor parte de la sintaxis es muy similar a C, por lo que debería ser comprensible para la mayoría de las personas.
Hay tres tipos de variables que entran y salen de un sombreador de vértices, y todas tienen un uso específico:
-
attribute— Estas son entradas que contienen propiedades específicas de un vértice. Anteriormente, describimos la posición de un vértice como un atributo, en forma de vector de tres elementos. Puede ver los atributos como valores que describen un vértice. -
uniform— Estas son entradas que son las mismas para cada vértice dentro de la misma llamada de representación. Digamos que queremos poder mover nuestro modelo, definiendo una matriz de transformación. Puede usar una variableuniformpara describir eso. También puede señalar recursos en la GPU, como texturas. Puede considerar los uniformes como valores que describen un modelo o una parte de un modelo. -
varying— Estas son salidas que pasamos al sombreador de fragmentos. Dado que hay potencialmente miles de píxeles para un triángulo de vértices, cada píxel recibirá un valor interpolado para esta variable, según la posición. Entonces, si un vértice envía 500 como salida y otro 100, un píxel que está en el medio entre ellos recibirá 300 como entrada para esa variable. Puede considerar las variaciones como valores que describen superficies entre vértices.
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?” podrías preguntar.
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
varyingoutputs, andattributeinputs have been replaced withvaryinginputs. 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 avec4. 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) } 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.); } 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.
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.); } Ponemos el sol para que brille en la dirección de adelante-izquierda-abajo. Puede ver cuán suave es el sombreado, aunque el modelo es muy irregular. También puede notar cuán oscuro es el lado inferior izquierdo. Podemos agregar un nivel de luz ambiental, que hará que el área en la sombra sea más brillante.
#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.); } Puede lograr este mismo efecto introduciendo una clase de luz, que almacena la dirección de la luz y la intensidad de la luz ambiental. Luego puede cambiar el sombreador de fragmentos para acomodar esa adición.
Ahora el shader se convierte en:
#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.); }Entonces puedes definir la luz:
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) }En la clase del programa shader, agregue los uniformes necesarios:
this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')En el programa, agregue una llamada a la nueva luz en el renderizador:
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) }) }El bucle cambiará ligeramente:
var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }Si ha hecho todo bien, la imagen renderizada debería ser la misma que en la última imagen.
Un último paso a considerar sería agregar una textura real a nuestro modelo. Hagamos eso ahora.
Agregar texturas
HTML5 tiene un gran soporte para cargar imágenes, por lo que no hay necesidad de hacer un análisis de imágenes loco. Las imágenes se pasan a GLSL como sampler2D diciéndole al sombreador cuál de las texturas enlazadas debe muestrear. Hay un número limitado de texturas que se pueden vincular y el límite se basa en el hardware utilizado. Un sampler2D puede ser consultado por colores en ciertas posiciones. Aquí es donde entran las coordenadas UV. Aquí hay un ejemplo en el que reemplazamos el marrón con colores de muestra.
#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.); }El nuevo uniforme debe agregarse a la lista en el programa de sombreado:
this.diffuse = gl.getUniformLocation(program, 'diffuse')Finalmente, implementaremos la carga de texturas. Como se dijo anteriormente, HTML5 proporciona facilidades para cargar imágenes. Todo lo que tenemos que hacer es enviar la imagen a la 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 }) } El proceso no es muy diferente del proceso utilizado para cargar y enlazar VBO. La principal diferencia es que ya no estamos vinculados a un atributo, sino que vinculamos el índice de la textura a un uniforme entero. El tipo sampler2D no es más que un puntero desplazado a una textura.
Ahora todo lo que se necesita hacer es extender la clase Mesh , para manejar texturas también:
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]) }) }Y el guión principal final se vería de la siguiente manera:
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) } Incluso animar es fácil en este punto. Si desea que la cámara gire alrededor de nuestro objeto, puede hacerlo simplemente agregando una línea de código:
function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) } Siéntete libre de jugar con shaders. Agregar una línea de código convertirá esta iluminación realista en algo caricaturesco.
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.); }Es tan simple como decirle a la iluminación que vaya a sus extremos en función de si cruzó un umbral establecido.
Dónde ir después
Hay muchas fuentes de información para aprender todos los trucos y complejidades de WebGL. Y la mejor parte es que si no puede encontrar una respuesta relacionada con WebGL, puede buscarla en OpenGL, ya que WebGL se basa en gran medida en un subconjunto de OpenGL, con algunos nombres cambiados.
Sin ningún orden en particular, aquí hay algunas excelentes fuentes para obtener información más detallada, tanto para WebGL como para OpenGL.
- Fundamentos de WebGL
- Aprendiendo WebGL
- Un tutorial de OpenGL muy detallado que lo guiará a través de todos los principios fundamentales descritos aquí, de una manera muy lenta y detallada.
- Y hay muchos, muchos otros sitios dedicados a enseñarle los principios de los gráficos por computadora.
- Documentación de MDN para WebGL
- Especificación de Khronos WebGL 1.0 si está interesado en comprender los detalles más técnicos de cómo debería funcionar la API de WebGL en todos los casos extremos.
