Maybaygiare.org

Blog Network

Shaders

Como se mencionó en el capítulo Triángulo de Hola, los shaders son pequeños programas que descansan en la GPU. Estos programas se ejecutan para cada sección específica de la canalización de gráficos. En un sentido básico, los sombreadores no son más que programas que transforman entradas en salidas. Los sombreadores también son programas muy aislados, ya que no se les permite comunicarse entre sí; la única comunicación que tienen es a través de sus entradas y salidas.

En el capítulo anterior tocamos brevemente la superficie de los sombreadores y cómo usarlos correctamente. Ahora explicaremos los sombreadores, y específicamente el lenguaje de sombreado OpenGL, de una manera más general.

GLSL

Los sombreadores están escritos en el lenguaje similar a C GLSL. GLSL está diseñado para su uso con gráficos y contiene características útiles específicamente dirigidas a la manipulación de vectores y matrices.

Los sombreadores siempre comienzan con una declaración de versión, seguida de una lista de variables de entrada y salida, uniformes y su función principal. El punto de entrada de cada sombreador está en su función principal donde procesamos cualquier variable de entrada y generamos los resultados en sus variables de salida. No te preocupes si no sabes lo que son los uniformes, llegaremos a ellos en breve.

Un sombreador normalmente tiene la siguiente estructura:

#version version_numberin type in_variable_name;in type in_variable_name;out type out_variable_name; uniform type uniform_name; void main(){ // process input(s) and do some weird graphics stuff ... // output processed stuff to output variable out_variable_name = weird_stuff_we_processed;}

Cuando hablamos específicamente del sombreador de vértices, cada variable de entrada también se conoce como atributo de vértice. Hay un número máximo de atributos de vértices que podemos declarar limitados por el hardware. OpenGL garantiza que siempre hay al menos 16 atributos de vértices de 4 componentes disponibles, pero algunos hardware pueden permitir más que puede recuperar consultando GL_MAX_VERTEX_ATTRIBS:

int nrAttributes;glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;

Esto a menudo devuelve el mínimo de 16 que debería ser más que suficiente para la mayoría de los propósitos.

Types

GLSL tiene, como cualquier otro lenguaje de programación, tipos de datos para especificar con qué tipo de variable queremos trabajar. GLSL tiene la mayoría de los predeterminada tipos básicos sabemos de lenguajes como C: intfloatdoubleuint y bool. GLSL también cuenta con dos tipos de contenedores que usaremos mucho, a saber, vectors y matrices. Hablaremos de matrices en un capítulo posterior.

Vectores

Un vector en GLSL es un contenedor de 1,2,3 o 4 componentes para cualquiera de los tipos básicos mencionados anteriormente. Se puede tomar la siguiente forma (n representa el número de componentes):

  • vecn: por defecto el vector de n carrozas.
  • bvecn: un vector de n booleanos.
  • ivecn: un vector de n números enteros.
  • uvecn: un vector de n enteros sin signo.
  • dvecn: un vector de n doble componentes.

La mayor parte del tiempo usaremos el vecn ya que los flotadores son suficientes para la mayoría de nuestros propósitos.

Se puede acceder a los componentes de un vector a través de vec.x donde x es el primer componente del vector. Usted puede usar .x.y.z y .w para acceder a su primer, segundo, tercer y cuarto componente, respectivamente. GLSL también le permite usar rgba para colores o stpq para coordenadas de textura, accediendo a los mismos componentes.

El tipo de datos vectorial permite una selección de componentes interesante y flexible llamada swizzling. Swizzling nos permite usar sintaxis como esta:

vec2 someVec;vec4 differentVec = someVec.xyxx;vec3 anotherVec = differentVec.zyw;vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

Puede usar cualquier combinación de hasta 4 letras para crear un nuevo vector (del mismo tipo) siempre que el vector original tenga esos componentes; no está permitido acceder al componente .zde un vec2, por ejemplo. También podemos pasar vectores como argumentos a diferentes llamadas de constructores de vectores, reduciendo el número de argumentos requeridos:

vec2 vect = vec2(0.5, 0.7);vec4 result = vec4(vect, 0.0, 0.0);vec4 otherResult = vec4(result.xyz, 1.0);

Los vectores son, por lo tanto, un tipo de datos flexible que podemos usar para todo tipo de entradas y salidas. A lo largo del libro, verá muchos ejemplos de cómo podemos administrar vectores de forma creativa.

Entradas y salidas

Los sombreadores son pequeños y agradables programas por sí solos, pero son parte de un todo y por esa razón queremos tener entradas y salidas en los sombreadores individuales para que podamos mover cosas. GLSL definió las palabras clave in y out específicamente para ese propósito. Cada sombreador puede especificar entradas y salidas utilizando esas palabras clave y siempre que una variable de salida coincida con una variable de entrada de la siguiente etapa del sombreador, se pasan. Sin embargo, el sombreador de vértices y fragmentos difiere un poco.

El sombreador de vértices debería recibir algún tipo de entrada, de lo contrario sería bastante ineficaz. El sombreador de vértices difiere en su entrada, en que recibe su entrada directamente de los datos de vértices. Para definir cómo se organizan los datos de vértices, especificamos las variables de entrada con metadatos de ubicación para que podamos configurar los atributos de vértices en la CPU. Hemos visto esto en el capítulo anterior como layout (location = 0). Por lo tanto, el sombreador de vértices requiere una especificación de diseño adicional para sus entradas para que podamos vincularlo con los datos de vértices.

También es posible omitir el especificadorlayout (location = 0)y consultar las ubicaciones de atributos en su código OpenGL a través de glGetAttribLocation, pero preferiría establecerlos en el sombreador de vértices. Es más fácil de entender y le ahorra a usted (y a OpenGL) algo de trabajo.

La otra excepción es que el sombreador de fragmentos requiere una variable de salida de color vec4, ya que los sombreadores de fragmentos necesitan generar un color de salida final. Si no especifica un color de salida en el sombreador de fragmentos, la salida del búfer de color para esos fragmentos no estará definida (lo que generalmente significa que OpenGL los renderizará en blanco o negro).

Así que si queremos enviar datos de un sombreador a otro tendríamos que declarar una salida en el sombreador de envío y una entrada similar en el sombreador de recepción. Cuando los tipos y los nombres son iguales en ambos lados, OpenGL enlazará esas variables y luego es posible enviar datos entre sombreadores (esto se hace al vincular un objeto de programa). Para mostrarte cómo funciona esto en la práctica, vamos a alterar los sombreadores del capítulo anterior para que el sombreador de vértices decida el color para el sombreador de fragmentos.

Sombreador de vértices

#version 330 corelayout (location = 0) in vec3 aPos; // the position variable has attribute position 0 out vec4 vertexColor; // specify a color output to the fragment shadervoid main(){ gl_Position = vec4(aPos, 1.0); // see how we directly give a vec3 to vec4's constructor vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // set the output variable to a dark-red color}

Sombreador de fragmentos

#version 330 coreout vec4 FragColor; in vec4 vertexColor; // the input variable from the vertex shader (same name and same type) void main(){ FragColor = vertexColor;} 

Puede ver que declaramos una variable de vértice como vec4 salida que configuramos en el sombreador de vértices y declaramos una entrada de vértice similar en el sombreador de fragmentos. Dado que ambos tienen el mismo tipo y nombre, el color de vértice en el sombreador de fragmentos está vinculado al color de vértice en el sombreador de vértices. Debido a que establecemos el color a un color rojo oscuro en el sombreador de vértices, los fragmentos resultantes también deben ser de color rojo oscuro. La siguiente imagen muestra la salida:

¡Ahí vamos! Acabamos de enviar un valor desde el sombreador de vértices al sombreador de fragmentos. Vamos a darle un poco de sabor y ver si podemos enviar un color desde nuestra aplicación al sombreador de fragmentos.

Uniformes

Los uniformes son otra forma de pasar datos de nuestra aplicación en la CPU a los sombreadores en la GPU. Sin embargo, los uniformes son ligeramente diferentes en comparación con los atributos de vértice. En primer lugar, los uniformes son globales. Global, lo que significa que una variable uniforme es única por objeto de programa de sombreado, y se puede acceder desde cualquier sombreador en cualquier etapa del programa de sombreadores. En segundo lugar, sea cual sea el valor del uniforme, los uniformes mantendrán sus valores hasta que se restablezcan o actualicen.

Para declarar un uniforme en GLSL, simplemente agregamos la palabra clave uniform a un sombreador con un tipo y un nombre. A partir de ese momento podemos usar el uniforme recién declarado en el sombreador. Veamos si esta vez podemos establecer el color del triángulo a través de un uniforme:

#version 330 coreout vec4 FragColor; uniform vec4 ourColor; // we set this variable in the OpenGL code.void main(){ FragColor = ourColor;} 

Declaramos un uniforme vec4 Nuestro color en el sombreador de fragmentos y establecemos el color de salida del fragmento al contenido de este valor uniforme. Dado que los uniformes son variables globales, podemos definirlos en cualquier etapa de sombreado que queramos, por lo que no es necesario pasar por el sombreador de vértices de nuevo para obtener algo del sombreador de fragmentos. No estamos usando este uniforme en el sombreador de vértices, por lo que no es necesario definirlo allí.

Si declara un uniforme que no se usa en ningún lugar de su código GLSL, el compilador eliminará silenciosamente la variable de la versión compilada, lo que es la causa de varios errores frustrantes; ¡tenga esto en cuenta!

El uniforme está vacío actualmente; aún no hemos agregado ningún dato al uniforme, así que intentémoslo. Primero necesitamos encontrar el índice / ubicación del atributo uniform en nuestro sombreador. Una vez que tengamos el índice/ubicación del uniforme, podremos actualizar sus valores. En lugar de pasar un solo color al sombreador de fragmentos, vamos a darle vida a las cosas cambiando de color gradualmente con el tiempo:

float timeValue = glfwGetTime();float greenValue = (sin(timeValue) / 2.0f) + 0.5f;int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");glUseProgram(shaderProgram);glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

Primero, recuperamos el tiempo de ejecución en segundos a través de glfwGetTime (). Luego variamos el color en el rango de 0.01.0 utilizando la función sin y almacenamos el resultado en greenValue.

Luego consultamos la ubicación del uniforme ourColor usando glGetUniformLocation. Suministramos el programa de sombreado y el nombre del uniforme (del que queremos recuperar la ubicación) a la función de consulta. Si glGetUniformLocation devuelve -1, no pudo encontrar la ubicación. Por último, podemos establecer el valor uniforme utilizando la función glUniform4f. Tenga en cuenta que encontrar la ubicación uniforme no requiere que use primero el programa de sombreado, pero actualizar un uniforme requiere que primero use el programa (llamando a glUseProgram), porque establece el uniforme en el programa de sombreado activo actualmente.

Debido a que OpenGL es en su núcleo una biblioteca de C, no tiene soporte nativo para la sobrecarga de funciones, por lo que cuando se puede llamar a una función con diferentes tipos, OpenGL define nuevas funciones para cada tipo requerido; Gluniforme es un ejemplo perfecto de esto. La función requiere un postfijo específico para el tipo de uniforme que desea establecer. Algunas de las posibles postfixes son:

  • f: la función espera un float como su valor.
  • i: la función espera un int como su valor.
  • ui: la función espera un unsigned int como su valor.
  • 3f: la función de espera 3 floats, así como su valor.
  • fv: la función espera un vector/array float como su valor.

Siempre que desee configurar una opción de OpenGL, simplemente elija la función sobrecargada que corresponda a su tipo. En nuestro caso, queremos establecer 4 flotadores del uniforme individualmente para que pasemos nuestros datos a través de glUniform4f (tenga en cuenta que también podríamos haber utilizado la versiónfv).

Ahora que sabemos cómo establecer los valores de variables uniformes, podemos usarlos para renderizar. Si queremos que el color cambie gradualmente, queremos actualizar este uniforme en cada fotograma, de lo contrario, el triángulo mantendría un solo color sólido si solo lo configuramos una vez. Así que calculamos el valor verde y actualizamos la iteración uniforme de cada renderizado:

while(!glfwWindowShouldClose(window)){ // input processInput(window); // render // clear the colorbuffer glClearColor(0.2f, 0.3f, 0.3f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); // be sure to activate the shader glUseProgram(shaderProgram); // update the uniform color float timeValue = glfwGetTime(); float greenValue = sin(timeValue) / 2.0f + 0.5f; int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor"); glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f); // now render the triangle glBindVertexArray(VAO); glDrawArrays(GL_TRIANGLES, 0, 3); // swap buffers and poll IO events glfwSwapBuffers(window); glfwPollEvents();}

El código es una adaptación relativamente sencilla del código anterior. Esta vez, actualizamos un valor uniforme para cada fotograma antes de dibujar el triángulo. Si actualiza el uniforme correctamente, verá que el color de su triángulo cambia gradualmente de verde a negro y vuelve a verde.

Echa un vistazo al código fuente aquí si estás atascado.

Como puede ver, los uniformes son una herramienta útil para establecer atributos que pueden cambiar cada fotograma, o para intercambiar datos entre su aplicación y sus sombreadores, pero ¿qué pasa si queremos establecer un color para cada vértice? En ese caso tendríamos que declarar tantos uniformes como vértices tengamos. Una mejor solución sería incluir más datos en los atributos de vértices, que es lo que vamos a hacer ahora.

Más atributos!

Vimos en el capítulo anterior cómo podemos llenar un VBO, configurar punteros de atributos de vértices y almacenarlo todo en un VAO. Esta vez, también queremos agregar datos de color a los datos de vértices. Vamos a agregar datos de color como 3 float s a la matriz de vértices. Asignamos un color rojo, verde y azul a cada una de las esquinas de nuestro triángulo respectivamente:

float vertices = { // positions // colors 0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // bottom right -0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // bottom left 0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // top }; 

Dado que ahora tenemos más datos para enviar al sombreador de vértices, es necesario ajustar el sombreador de vértices para recibir también nuestro valor de color como entrada de atributo de vértice. Tenga en cuenta que establecemos la ubicación del atributo Color a 1 con el especificador de diseño:

#version 330 corelayout (location = 0) in vec3 aPos; // the position variable has attribute position 0layout (location = 1) in vec3 aColor; // the color variable has attribute position 1 out vec3 ourColor; // output a color to the fragment shadervoid main(){ gl_Position = vec4(aPos, 1.0); ourColor = aColor; // set ourColor to the input color we got from the vertex data} 

Dado que ya no usamos un uniforme para el color del fragmento, sino que ahora usamos la variable de salida ourColor, también tendremos que cambiar el sombreador del fragmento:

#version 330 coreout vec4 FragColor; in vec3 ourColor; void main(){ FragColor = vec4(ourColor, 1.0);}

Porque agregamos otro atributo de vértice y actualizamos el VBO memoria tenemos que volver a configurar los punteros de atributos de vértices. Los datos actualizados en la memoria del VBO ahora se parecen un poco a esto:

Datos intercalados de posición y color dentro de VBO que se configurarán con la función Lvertexattribpointer/función

Conociendo el diseño actual podemos actualizar el formato de vértice con glVertexAttribPointer:

// position attributeglVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);glEnableVertexAttribArray(0);// color attributeglVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3* sizeof(float)));glEnableVertexAttribArray(1);

Los primeros argumentos de glVertexAttribPointer son relativamente sencillos. Esta vez estamos configurando el atributo vertex en la ubicación del atributo 1. Los valores de color tienen un tamaño de 3floats y no nos normalizar los valores.

Dado que ahora tenemos dos atributos de vértice, tenemos que volver a calcular el valor de zancada. Para obtener el siguiente valor de atributo (por ejemplo, el siguiente componente x del vector de posición) en la matriz de datos, tenemos que mover 6floats a la derecha, tres para los valores de posición y tres para los valores de color. Esto nos da una zancada valor de 6 veces el tamaño de un float en bytes (= 24 bytes).
También, esta vez tenemos que especificar un desplazamiento. Para cada vértice, el atributo de vértice de posición es el primero, por lo que declaramos un desplazamiento de 0. El atributo color comienza después de los datos de posición, por lo que el desplazamiento es 3 * sizeof(float) en bytes (=12 bytes).

Ejecutar la aplicación debería dar como resultado la siguiente imagen:

Echa un vistazo al código fuente aquí si estás atascado.

La imagen puede no ser exactamente lo que esperarías, ya que solo suministramos 3 colores, no la enorme paleta de colores que estamos viendo en este momento. Todo esto es el resultado de algo llamado interpolación de fragmentos en el sombreador de fragmentos. Cuando se renderiza un triángulo, la etapa de rasterización generalmente resulta en muchos más fragmentos que vértices especificados originalmente. El rasterizador determina las posiciones de cada uno de esos fragmentos en función de dónde residen en la forma de triángulo.Basado en estas posiciones, interpola todas las variables de entrada del sombreador de fragmentos. Digamos, por ejemplo, que tenemos una línea donde el punto superior tiene un color verde y el punto inferior un color azul. Si el sombreador de fragmentos se ejecuta en un fragmento que reside alrededor de una posición en 70% de la línea, su atributo de entrada de color resultante sería una combinación lineal de verde y azul; para ser más precisos: 30% azul y 70% verde.

Esto es exactamente lo que pasó en el triángulo. Tenemos 3 vértices y, por lo tanto, 3 colores, y a juzgar por los píxeles del triángulo, probablemente contenga alrededor de 50000 fragmentos, donde el sombreador de fragmentos interpoló los colores entre esos píxeles. Si echas un buen vistazo a los colores, verás que todo tiene sentido: el rojo al azul primero se convierte en púrpura y luego en azul. La interpolación de fragmentos se aplica a todos los atributos de entrada del sombreador de fragmentos.

Nuestra propia clase de sombreador

Escribir, compilar y administrar sombreadores puede ser bastante engorroso. Como toque final en el tema del sombreador, vamos a hacer nuestra vida un poco más fácil construyendo una clase de sombreador que lea sombreadores desde el disco, los compile y vincule, verifique los errores y sea fácil de usar. Esto también te da una idea de cómo podemos encapsular parte del conocimiento que hemos aprendido hasta ahora en objetos abstractos útiles.

Crearemos la clase de sombreado completamente en un archivo de encabezado, principalmente con fines de aprendizaje y portabilidad. Comencemos agregando las inclusiones necesarias y definiendo la estructura de clases:

#ifndef SHADER_H#define SHADER_H#include <glad/glad.h> // include glad to get all the required OpenGL headers #include <string>#include <fstream>#include <sstream>#include <iostream> class Shader{public: // the program ID unsigned int ID; // constructor reads and builds the shader Shader(const char* vertexPath, const char* fragmentPath); // use/activate the shader void use(); // utility uniform functions void setBool(const std::string &name, bool value) const; void setInt(const std::string &name, int value) const; void setFloat(const std::string &name, float value) const;}; #endif

Usamos varias directivas de preprocesador en la parte superior del archivo de encabezado. El uso de estas pequeñas líneas de código informa a su compilador que solo incluya y compile este archivo de encabezado si aún no se ha incluido, incluso si varios archivos incluyen el encabezado del sombreador. Esto evita conflictos de vinculación.

La clase de sombreado contiene el ID del programa de sombreado. Su constructor requiere las rutas de archivo del código fuente del sombreador de vértices y fragmentos, respectivamente, que podemos almacenar en el disco como archivos de texto simples. Para agregar un poco más, también agregamos varias funciones de utilidad para facilitar un poco nuestras vidas: use activa el programa de sombreado, y todo listo… las funciones consultan una ubicación uniforme y establecen su valor.

Lectura del archivo

Estamos utilizando ficheros C++ para leer el contenido del archivo en varios objetos string:

Shader(const char* vertexPath, const char* fragmentPath){ // 1. retrieve the vertex/fragment source code from filePath std::string vertexCode; std::string fragmentCode; std::ifstream vShaderFile; std::ifstream fShaderFile; // ensure ifstream objects can throw exceptions: vShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit); fShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit); try { // open files vShaderFile.open(vertexPath); fShaderFile.open(fragmentPath); std::stringstream vShaderStream, fShaderStream; // read file's buffer contents into streams vShaderStream << vShaderFile.rdbuf(); fShaderStream << fShaderFile.rdbuf(); // close file handlers vShaderFile.close(); fShaderFile.close(); // convert stream into string vertexCode = vShaderStream.str(); fragmentCode = fShaderStream.str(); } catch(std::ifstream::failure e) { std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl; } const char* vShaderCode = vertexCode.c_str(); const char* fShaderCode = fragmentCode.c_str(); 

A continuación necesitamos compilar y vincular los sombreadores. Tenga en cuenta que también estamos revisando si la compilación/enlace falló y, de ser así, imprima los errores en tiempo de compilación. Esto es extremadamente útil al depurar (eventualmente necesitará esos registros de errores):

// 2. compile shadersunsigned int vertex, fragment;int success;char infoLog; // vertex Shadervertex = glCreateShader(GL_VERTEX_SHADER);glShaderSource(vertex, 1, &vShaderCode, NULL);glCompileShader(vertex);// print compile errors if anyglGetShaderiv(vertex, GL_COMPILE_STATUS, &success);if(!success){ glGetShaderInfoLog(vertex, 512, NULL, infoLog); std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;}; // similiar for Fragment Shader // shader ProgramID = glCreateProgram();glAttachShader(ID, vertex);glAttachShader(ID, fragment);glLinkProgram(ID);// print linking errors if anyglGetProgramiv(ID, GL_LINK_STATUS, &success);if(!success){ glGetProgramInfoLog(ID, 512, NULL, infoLog); std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;} // delete the shaders as they're linked into our program now and no longer necessaryglDeleteShader(vertex);glDeleteShader(fragment);

La función de uso es sencilla:

void use() { glUseProgram(ID);} 

De manera similar para cualquiera de las funciones de configuración uniforme:

void setBool(const std::string &name, bool value) const{ glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value); }void setInt(const std::string &name, int value) const{ glUniform1i(glGetUniformLocation(ID, name.c_str()), value); }void setFloat(const std::string &name, float value) const{ glUniform1f(glGetUniformLocation(ID, name.c_str()), value); } 

Y ahí lo tenemos, una clase de sombreado completada. Usar la clase shader es bastante fácil; creamos un objeto shader una vez y a partir de ese momento simplemente comenzamos a usarlo:

Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.fs");while(...){ ourShader.use(); ourShader.setFloat("someUniform", 1.0f); DrawStuff();}

Aquí almacenamos el código fuente del sombreador de vértices y fragmentos en dos archivos llamados shader.vs y shader.fs. Eres libre de nombrar tus archivos de sombreado como quieras; Personalmente considero que las extensiones .vs y .fs bastante intuitiva.

Puedes encontrar el código fuente aquí usando nuestra clase de sombreado recién creada. Tenga en cuenta que puede hacer clic en las rutas del archivo de sombreado para encontrar el código fuente de los sombreadores.

Ejercicios

  1. Ajuste el sombreador de vértices para que el triángulo esté al revés: solución.
  2. Especifique un desplazamiento horizontal a través de un uniforme y mueva el triángulo hacia el lado derecho de la pantalla en el sombreador de vértices utilizando este valor de desplazamiento: solución.
  3. Envíe la posición del vértice al sombreador de fragmentos utilizando la palabra clave out y establezca el color del fragmento igual a esta posición del vértice (vea cómo incluso los valores de posición del vértice se interpolan a través del triángulo). Una vez que haya logrado hacer esto, intente responder a la siguiente pregunta: ¿por qué el lado inferior izquierdo de nuestro triángulo es negro?: solución.

Deja una respuesta

Tu dirección de correo electrónico no será publicada.