WebGL mínimo en 75 líneas de código

El OpenGL moderno, y más ampliamente WebGL, es muy diferente del OpenGL más antiguo que he estudiado en el pasado. Entiendo cómo funciona la rasterización, así que estoy bastante familiarizado con los conceptos. Sin embargo, cada tutorial que leí ofrecía abstracciones y funciones auxiliares que me dificultaban entender qué partes pertenecen a las API de OpenGL.



Para aclarar, abstracciones como dividir estas posiciones y representar la funcionalidad en clases separadas son importantes en las aplicaciones del mundo real. Sin embargo, estas abstracciones dispersan el código en diferentes áreas y agregan redundancia debido a la repetición y la transferencia de datos entre unidades lógicas. Me parece más conveniente estudiar un tema en un flujo lineal de código, en el que cada línea está directamente relacionada con este tema.



Primero, debo agradecer al creador del tutorial que utilicé . Tomándolo como base, me deshice de todas las abstracciones hasta que obtuve el "programa mínimo viable". Con suerte, le ayudará a empezar con OpenGL moderno. Esto es lo que haremos:





Triángulo equilátero, verde en la parte superior, negro en la parte inferior izquierda y rojo en la parte inferior derecha, con colores interpolados entre los puntos. Una versión ligeramente más brillante del triángulo negro [ traducción en Habré].



Inicialización



En WebGL, necesitamos canvasdibujar. Por supuesto, definitivamente necesitará agregar todo el texto estándar HTML, estilos, etc., pero el lienzo es lo más importante. Después de cargar el DOM, podemos acceder al lienzo usando Javascript.



<canvas id="container" width="500" height="500"></canvas>

<script>
  document.addEventListener('DOMContentLoaded', () => {
    // All the Javascript code below goes here
  });
</script>


Al acceder al lienzo, podemos obtener el contexto de representación de WebGL e inicializar su color claro. Los colores en el mundo OpenGL se almacenan como RGBA y cada componente tiene un valor de 0a 1. El color claro es el color que se usa para dibujar el lienzo al comienzo de cada cuadro, redibujando la escena.



const canvas = document.getElementById('container');
const gl = canvas.getContext('webgl');

gl.clearColor(1, 1, 1, 1);


En programas reales, la inicialización puede y debe ser más detallada. En particular, se debe mencionar la inclusión de un búfer de profundidad que le permite ordenar la geometría según las coordenadas Z. No haremos esto para un programa simple que consta de un solo triángulo.



Compilar sombreadores



En esencia, OpenGL es un marco de rasterización en el que tenemos que tomar decisiones sobre cómo implementar todo lo que no sea la rasterización. Por lo tanto, se deben ejecutar al menos dos etapas de código en la GPU:



  1. Un sombreador de vértices que procesa todos los datos de entrada y genera una posición 3D (en realidad, una posición 4D en coordenadas uniformes ) para cada entrada.
  2. Un sombreador de fragmentos que procesa cada píxel en la pantalla, representando el color con el que se debe pintar el píxel.


Entre estas dos etapas, OpenGL obtiene la geometría del sombreador de vértices y determina qué píxeles de la pantalla están cubiertos por esa geometría. Esta es la etapa de rasterización.



Ambos sombreadores generalmente se escriben en GLSL (OpenGL Shading Language), que luego se compila en código de máquina para la GPU. Luego, el código de la máquina se pasa a la GPU para que se pueda ejecutar durante el proceso de renderizado. No entraré en GLSL en detalle porque solo quiero mostrar los conceptos básicos, pero el lenguaje está lo suficientemente cerca de C como para ser familiar para la mayoría de los programadores.



Primero, compilamos y pasamos el sombreador de vértices a la GPU. En el fragmento que se muestra a continuación, el código fuente del sombreador se almacena como una cadena, pero se puede cargar desde otros lugares. Finalmente, la cadena se pasa a la API de WebGL.



const sourceV = `
  attribute vec3 position;
  varying vec4 color;

  void main() {
    gl_Position = vec4(position, 1);
    color = gl_Position * 0.5 + 0.5;
  }
`;

const shaderV = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(shaderV, sourceV);
gl.compileShader(shaderV);

if (!gl.getShaderParameter(shaderV, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shaderV));
  throw new Error('Failed to compile vertex shader');
}


Vale la pena explicar algunas de las variables en el código GLSL aquí:



  1. (attribute) position. , , .
  2. Varying color. ( ) . .
  3. gl_Position. , , varying-. , ,


También hay un tipo de variable uniforme , que es una constante en todas las llamadas al sombreador de vértices. Estos uniformes se utilizan para propiedades como una matriz de transformación, que será constante para todos los vértices de un elemento geométrico.



A continuación, hacemos lo mismo con el sombreador de fragmentos: lo compilamos y lo transferimos a la GPU. Tenga en cuenta que la variable colordel sombreador de vértices ahora es leída por el sombreador de fragmentos.



const sourceF = `
  precision mediump float;
  varying vec4 color;

  void main() {
    gl_FragColor = color;
  }
`;

const shaderF = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(shaderF, sourceF);
gl.compileShader(shaderF);

if (!gl.getShaderParameter(shaderF, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shaderF));
  throw new Error('Failed to compile fragment shader');
}


Además, tanto los sombreadores de vértices como los de fragmentos están vinculados en un programa OpenGL.



const program = gl.createProgram();
gl.attachShader(program, shaderV);
gl.attachShader(program, shaderF);
gl.linkProgram(program);

if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
  console.error(gl.getProgramInfoLog(program));
  throw new Error('Failed to link program');
}

gl.useProgram(program);


Le decimos a la GPU que queremos ejecutar los sombreadores anteriores. Ahora solo tenemos que crear los datos de entrada y dejar que la GPU procese estos datos.



Envío de datos entrantes a GPU



Los datos entrantes se almacenarán en la memoria de la GPU y se procesarán desde allí. En lugar de realizar llamadas de extracción separadas para cada pieza de datos entrantes, que transfieren los datos correspondientes un fragmento a la vez, todos los datos entrantes se pasan y se leen desde la GPU en su totalidad. (El antiguo OpenGL pasaba datos sobre elementos individuales, lo que ralentizaba el rendimiento).



OpenGL proporciona una abstracción denominada Vertex Buffer Object (VBO). Todavía estoy averiguando cómo funciona, pero terminaremos haciendo lo siguiente para usarlo:



  1. Almacene la secuencia de datos en la memoria de la unidad central de procesamiento (CPU).
  2. Transferencia de bytes de memoria de la GPU a través de un búfer único creado con gl.createBuffer()y puntos de anclaje gl.ARRAY_BUFFER .


Para cada variable de datos de entrada (atributo) en el sombreador de vértices, tendremos un VBO, aunque es posible usar un VBO para varios elementos de los datos de entrada.



const positionsData = new Float32Array([
  -0.75, -0.65, -1,
   0.75, -0.65, -1,
   0   ,  0.65, -1,
]);

const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, positionsData, gl.STATIC_DRAW);


Por lo general, definimos la geometría con las coordenadas que comprenda nuestra aplicación y luego usamos un conjunto de transformaciones en el sombreador de vértices para mapearlas en el espacio del clip OpenGL. No entraré en detalles sobre el espacio de truncamiento (está asociado con coordenadas homogéneas), mientras que solo necesita saber que X e Y cambian en el rango de -1 a +1. Dado que el sombreador de vértices simplemente pasa la entrada tal como está, podemos establecer nuestras coordenadas directamente en el espacio de recorte.



Luego también vincularemos el búfer a una de las variables en el sombreador de vértices. En el código, hacemos lo siguiente:



  1. Obtenemos el descriptor positionde la variable del programa creado anteriormente.
  2. Le indicamos a OpenGL que lea los datos desde el punto de anclaje gl.ARRAY_BUFFERen grupos de 3 con ciertos parámetros, por ejemplo, con un desplazamiento y un paso de 0.




const attribute = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(attribute);
gl.vertexAttribPointer(attribute, 3, gl.FLOAT, false, 0, 0);


Vale la pena señalar que podemos crear un VBO de esta manera y vincularlo a un atributo de sombreador de vértices porque ejecutamos estas funciones una tras otra. Si tuviéramos que separar las dos funciones (por ejemplo, crear todos los VBO en una pasada y luego vincularlos a atributos separados), entonces, antes de asignar cada VBO al atributo correspondiente, necesitaríamos llamar cada vez gl.bindBuffer(...).



¡Representación!



Finalmente, cuando todos los datos en la memoria de la GPU estén preparados adecuadamente, podemos decirle a OpenGL que borre la pantalla y ejecute el programa para procesar las matrices que hemos preparado. Como parte del paso de rasterización (determinar qué píxeles están cubiertos por los vértices), le decimos a OpenGL que trate los vértices en grupos de 3 como triángulos.



gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);


Con tal esquema lineal, el programa se ejecutará de una vez. En cualquier aplicación práctica, almacenaríamos datos de una manera estructurada, los enviaríamos a la GPU a medida que cambia y los renderizaríamos en cada fotograma.






Para resumir, a continuación se muestra un diagrama con un conjunto mínimo de conceptos que se requieren para mostrar nuestro primer triángulo en la pantalla. Pero incluso este esquema está muy simplificado, por lo que es mejor escribir las 75 líneas de código que se presentan en este artículo y estudiarlas.





La secuencia final altamente simplificada de pasos necesarios para mostrar un triángulo



Para mí, la parte más difícil de aprender OpenGL fue la gran cantidad de repetición requerida para mostrar la imagen más simple en la pantalla. Dado que el marco de rasterización requiere que proporcionemos funcionalidad de renderizado 3D, y la comunicación con la GPU es muy grande, muchos conceptos deben estudiarse directamente. Con suerte, este artículo le ha mostrado los conceptos básicos de una manera más sencilla de lo que aparecen en otros tutoriales.



Ver también:








Ver también:






All Articles