3D hágalo usted mismo. Parte 1: píxeles y líneas



Quiero dedicar esta serie de artículos a lectores que quieran explorar el mundo de la programación 3D desde cero, a personas que quieran aprender los conceptos básicos de la creación del componente 3D de juegos y aplicaciones. Implementaremos cada operación desde cero para comprender cada aspecto, incluso si ya hay una función preparada que lo hace más rápido. Una vez aprendido, cambiaremos a las herramientas integradas para trabajar con 3D. Después de leer la serie de artículos, comprenderá cómo crear escenas tridimensionales complejas con luces, sombras, texturas y efectos, cómo hacer todo esto sin un conocimiento profundo de las matemáticas y mucho más. Puede hacer todo esto de forma independiente y con la ayuda de herramientas listas para usar.

En la primera parte consideraremos:

  • Conceptos de renderizado (software, hardware)
  • ¿Qué es un píxel / superficie?
  • Análisis detallado de salida de línea.

Para no perder su valioso tiempo leyendo artículos, que pueden ser incomprensibles para una persona no preparada, me referiré de inmediato a los requisitos. Puede comenzar a leer artículos en 3D de manera segura, si conoce los conceptos básicos de programación en cualquier lenguaje, porque Me enfocaré solo en el estudio de la programación 3D, y no en el estudio de las características del lenguaje y los fundamentos de la programación. En cuanto a la preparación matemática, no debe preocuparse aquí, aunque muchos no desean estudiar 3D, porque están asustados por cálculos complejos y fórmulas furiosas por las cuales las pesadillas sueñan más tarde, pero en realidad no hay nada de qué preocuparse. Trataré de explicar lo más claramente posible todo lo necesario para 3D, solo tienes que poder multiplicar, dividir, sumar y restar. Entonces, si ha pasado los criterios de selección, puede comenzar a leer.

Antes de comenzar a explorar el interesante mundo del 3D, elija un lenguaje de programación para ejemplos, así como un entorno de desarrollo. ¿Qué idioma debo elegir para programar gráficos 3D? Cualquiera, puede trabajar donde se sienta más cómodo, las matemáticas serán las mismas en todas partes. En este artículo, todos los ejemplos se mostrarán en el contexto de JS (aquí los tomates vuelan hacia mí). ¿Por qué js? Es simple: últimamente he estado trabajando principalmente con él y, por lo tanto, puedo transmitirle la esencia de manera más efectiva. Omitiré todas las características de JS en los ejemplos, porque solo necesitamos las funciones más básicas que tiene cualquier idioma, por lo que prestaremos atención específicamente al 3D. Pero eliges lo que amas, porque en los artículos, todas las fórmulas no estarán vinculadas a las características de ningún lenguaje de programación. ¿Qué ambiente elegir? No importa,en el caso de JS, cualquier editor de texto es adecuado, puede usar el que esté más cerca de usted.

Todos los ejemplos usarán lienzo para pintar, como con él, puede comenzar a dibujar muy rápidamente, sin un análisis detallado. Canvas es una herramienta poderosa, con muchos métodos listos para dibujar, pero de todas sus características, ¡por primera vez, solo usaremos la salida de píxeles! 

Todas las pantallas tridimensionales en la pantalla con píxeles, más adelante en los artículos verá cómo sucede esto. ¿Se ralentizará? Sin aceleración de hardware (por ejemplo, aceleración por una tarjeta de video) - será. En el primer artículo, no usaremos aceleraciones, escribiremos todo desde cero para comprender los aspectos básicos de 3D. Veamos algunos términos que se mencionarán en futuros artículos:

  • (Rendering) — 3D- . , 3D- , , .
  • (Software Rendering) — . , , , - . , . 3D- , — .
  • Representación de hardware : un proceso de representación asistido por hardware. Lo uso juegos y aplicaciones. Todo funciona muy rápido, porque una gran cantidad de informática de rutina se hace cargo de la tarjeta de video, que está diseñada para esto.

No aspiro al título "definición del año" e intento exponer todas las descripciones de los términos con la mayor claridad posible. Lo principal es entender la idea, que luego se puede desarrollar de forma independiente. También quiero llamar la atención sobre el hecho de que todos los ejemplos de código que se mostrarán en los artículos a menudo no están optimizados para la velocidad, a fin de mantener la facilidad de comprensión. Cuando comprenda lo principal: cómo funcionan los gráficos 3D, puede optimizar todo usted mismo.

Primero, cree un proyecto, para mí es solo un archivo de texto index.html , con el siguiente contenido:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>3D it’s easy. Part 1</title>
</head>

<body>
    <!--         -->
    <canvas id="surface" width="800" height="600"></canvas>

    <script>
        //    
    </script>
</body>

</html>

No me centraré demasiado en JS y el lienzo ahora; estos no son los personajes principales de este artículo. Pero para una comprensión general, aclararé que <lienzo ...> es un rectángulo (en mi caso, de 800 por 600 píxeles de tamaño) en el que mostraré todos los gráficos. Registré el lienzo una vez y ya no lo cambiaré.

<script></script> 

Script: un elemento dentro del cual escribiremos toda la lógica para renderizar gráficos 3D con nuestras propias manos (en JavaScript). 

Cuando acabamos de revisar la estructura del archivo index.html del proyecto recién creado, comenzaremos a tratar con gráficos 3D.

Cuando dibujamos algo en la ventana, esto en el conteo final se convierte en píxeles, porque son ellos los que muestra el monitor. Cuantos más píxeles, más nítida es la imagen, pero la computadora también carga más. ¿Cómo se almacena lo que dibujamos en la ventana? Los gráficos en cualquier ventana se pueden representar como una matriz de píxeles, y el píxel en sí mismo es solo un color. Es decir, una resolución de pantalla de 800x600 significa que nuestra ventana contiene 600 líneas de 800 píxeles cada una, es decir, 800 * 600 = 480000 píxeles, mucho, ¿no? Los píxeles se almacenan en una matriz. Pensemos en qué matriz almacenaríamos los píxeles. Si deberíamos tener 800 por 600 píxeles, entonces la opción más obvia es en una matriz bidimensional de 800 por 600. Y esta es casi la opción correcta, o más bien, la opción completamente correcta. Pero los píxeles de la ventana se almacenan mejor en una matriz unidimensional de 480,000 elementos (si la resolución es de 800 por 600),solo porque es más rápido trabajar con una matriz unidimensional, porque se almacena en la memoria en una secuencia continua de bytes (todo se encuentra cerca y, por lo tanto, es fácil de obtener). En una matriz bidimensional (por ejemplo, en el caso de JS), cada línea se puede dispersar en diferentes lugares de la memoria, por lo que acceder a los elementos de dicha matriz llevará más tiempo. Además, para iterar sobre una matriz unidimensional, solo se necesita 1 ciclo, y para los enteros bidimensionales 2, dada la necesidad de hacer decenas de miles de iteraciones del ciclo, la velocidad es importante aquí. ¿Qué es un píxel en tal matriz? Como se mencionó anteriormente, este es solo un color, o más bien 3 de sus componentes (rojo, verde, azul). Cualquiera, incluso la imagen más colorida es solo una matriz de píxeles de diferentes colores. Un píxel en la memoria se puede almacenar a su gusto, ya sea una matriz de 3 elementos, o en una estructura donde rojo, verde,azul; o algo mas. Una imagen que consiste en una matriz de píxeles que acabamos de analizar, continuaré llamando a la superficie. Resulta que, dado que todo lo que se muestra en la pantalla se almacena en una matriz de píxeles, luego de cambiar los elementos (píxeles) en esta matriz, cambiaremos la imagen en la pantalla píxel por píxel. Esto es exactamente lo que haremos en este artículo.

No hay una función de dibujo de píxeles en el lienzo, pero es posible acceder a una matriz unidimensional de píxeles, que discutimos anteriormente. El siguiente ejemplo muestra cómo hacerlo (este y todos los ejemplos en el futuro solo estarán dentro del elemento del script):

//     ()    
const ctx = document
.getElementById('surface')
.getContext('2d')

//     ,    
// +       
const imageData = ctx.createImageData(800, 600)

En el ejemplo, imageData es un objeto en el que hay 3 propiedades:

  • alto y ancho : enteros que almacenan el alto y el ancho de la ventana para dibujar
  • datos : matriz de enteros sin signo de 8 bits (puede almacenar números en el rango de 0 a 255)

La matriz de datos tiene una estructura simple pero explicativa. Esta matriz unidimensional almacena datos de cada píxel, que mostraremos en la pantalla en el siguiente formato:
Los primeros 4 elementos de la matriz (índices 0,1,2,3) son los datos del primer píxel en la primera fila. Los segundos 4 elementos (índices 4, 5, 6, 7) son los datos del segundo píxel de la primera fila. Cuando lleguemos al 800º píxel de la primera línea, siempre que la ventana tenga 800 píxeles de ancho, el 801º píxel ya pertenecerá a la segunda línea. Si lo cambiamos, en la pantalla veremos que el primer píxel de la segunda fila ha cambiado (aunque según el recuento en la matriz será el 801º píxel). ¿Por qué hay 4 elementos para cada píxel en la matriz? Esto se debe a que en el lienzo, además de asignar 1 elemento para cada color: rojo, verde, azul (estos son 3 elementos), 1 elemento más para la transparencia (también dicen el canal alfa u opacidad). El canal alfa, como el color, se establece en el rango de 0 (transparente) a 255 (opaco). Con esta estructura, obtenemos una imagen de 32 bits,porque cada píxel consta de 4 elementos de 8 bits. Para resumir: cada píxel contiene: colores rojo, verde, azul y canal alfa (transparencia). Este esquema de color se llama ARGB (Alpha Red Green Blue). Y el hecho de que cada píxel ocupe 32 bits dice que tenemos una imagen de 32 bits (también dicen una imagen con una profundidad de color de 32 bits).

De forma predeterminada, toda la matriz de píxeles imageData.data (los datos son una propiedad en la que la matriz de píxeles, y imageData es solo un objeto) se llena con los valores 0, y si intentamos generar dicha matriz, no veríamos nada interesante en la pantalla, porque 0 , 0, 0 es negro, pero como la transparencia aquí también será 0, y este es un color completamente transparente, ¡ni siquiera veremos negro en la pantalla!

No es conveniente trabajar directamente con una matriz unidimensional, por lo que escribiremos una clase para ella en la que crearemos métodos para dibujar. Voy a nombrar la clase - Cajón. Esta clase almacenará solo los datos necesarios y realizará los cálculos necesarios, abstrayendo tanto como sea posible de la herramienta utilizada para la representación. Es por eso que colocaremos todos los cálculos y trabajaremos con la matriz. Y la misma llamada al método de visualización en lienzo, lo colocaremos fuera de la clase, porque Puede haber algo más en lugar de lienzo. En este caso, nuestra clase no tendrá que ser cambiada. Para trabajar con una matriz de píxeles (superficie), es más conveniente para nosotros guardarlo en la clase Drawer, así como el ancho y la altura de la imagen, para que podamos acceder correctamente al píxel deseado. Entonces, la clase Drawer, mientras conserva los datos mínimos necesarios para dibujar, se ve así para mí:

class Drawer {
    surface = null
    width = 0
    height = 0

    constructor(surface, width, height) {
        this.surface = surface
        this.width = width
        this.height = height
    }
}

Como puede ver en el constructor, la clase Drawer toma todos los datos necesarios y los guarda. Ahora puede crear una instancia de esta clase y pasarle una matriz de píxeles, ancho y alto (ya tenemos todos estos datos, porque los creamos arriba y los almacenamos en imageData):

const drawer = new Drawer(
    imageData.data,
    imageData.width,
    imageData.height
)

En la clase Drawer, escribiremos varias funciones de dibujo, para facilitar el trabajo en el futuro. Tendremos una función para dibujar un píxel, una función para dibujar una línea, y en otros artículos aparecerán funciones para dibujar un triángulo y otras formas. Pero comencemos con el método de dibujo de píxeles. Lo llamaré drawPixel. Si dibujamos un píxel, entonces debe tener coordenadas, así como color:

drawPixel(x, y, r, g, b)  { }

Tenga en cuenta que la función drawPixel no acepta el parámetro alfa (transparencia), y arriba descubrimos que la matriz de píxeles consta de 3 parámetros de color y 1 parámetro de transparencia. No indiqué específicamente la transparencia, ya que no la necesitamos para ejemplos. Por defecto, estableceremos 255 (es decir, todo será opaco). Ahora pensemos en cómo escribir el color deseado en una matriz de píxeles en coordenadas x, y. Dado que tenemos toda la información sobre la imagen, se almacena en una matriz unidimensional, en la que se asigna 1 píxel (8 bits) para cada píxel. Para acceder al píxel deseado en la matriz, primero debemos determinar el índice de ubicación roja, porque cualquier píxel comienza con él (por ejemplo, [r, g, b, a]). Una pequeña explicación de la estructura de la matriz:



La tabla en verde indica cómo se almacenan los componentes de color en una matriz de superficie unidimensional. Sus índices en la misma matriz se indican en azul, y las coordenadas del píxel que acepta las funciones drawPixel, que necesitamos convertir en índices en la matriz unidimensional, indican r, g, b, a para el píxel en azul. Entonces, de la tabla se puede ver que para cada píxel el componente rojo del color es lo primero, comencemos con él. Supongamos que queremos cambiar el componente rojo del color del píxel en las coordenadas X1Y1 con un tamaño de imagen de 2 por 2 píxeles. En la tabla vemos que este es el índice 12, pero ¿cómo calcularlo? Primero encontramos el índice de la fila que necesitamos, para esto multiplicamos el ancho de la imagen por Y y por 4 (el número de valores por píxel); esto será:

width * y * 4 
//  :
2 * 1 * 4 = 8

Vemos que la segunda línea comienza con el índice 8. Si comparamos con la placa, el resultado converge.

Ahora necesita agregar un desplazamiento de columna al índice de fila encontrado para obtener el índice rojo deseado. Para hacer esto, agregue X veces 4 al índice de la fila. La fórmula completa será:

width * y * 4 + x * 4 
//     :
(width * y + x) * 4
//  :
(2 * 1 + 1) * 4 = 12

Ahora comparamos 12 con la tabla y vemos que el píxel X1Y1 realmente comienza con el índice 12.

Para encontrar los índices de otros componentes de color, debe agregar el desplazamiento de color al índice rojo: +1 (verde), +2 (azul), +3 (alfa) . Ahora podemos implementar el método drawPixel dentro de la clase Drawer usando la fórmula anterior:

drawPixel(x, y, r, g, b) {
    const offset = (this.width * y + x) * 4

    this.surface[offset] = r
    this.surface[offset + 1] = g
    this.surface[offset + 2] = b
    this.surface[offset + 3] = 255
}

En este método drawPixel, rendericé la parte repetitiva de la fórmula en el desplazamiento constante. También se ve que en alfa solo escribo 255, porque está en la estructura, pero ahora no necesitamos generar píxeles.

Es hora de probar el código y finalmente ver el primer píxel en la pantalla. Aquí hay un ejemplo usando el método de renderizado de píxeles:

//     Drawer
drawer.drawPixel(10, 10, 255, 0, 0)
drawer.drawPixel(10, 20, 0, 0, 255)

//         canvas
ctx.putImageData(imageData, 0, 0)

En el ejemplo anterior, dibujo 2 píxeles, uno rojo 255, 0, 0, el otro azul 0, 0, 255. Pero los cambios en la matriz imageData.data (también es la superficie dentro de la clase Drawer) no aparecerán en la pantalla. Para dibujar, debe llamar a ctx.putImageData (imageData, 0, 0), donde imageData es el objeto en el que la matriz de píxeles y el ancho / alto del área de dibujo, y 0, 0 es el punto en relación con el cual se mostrará la matriz de píxeles (siempre deje 0, 0 ) Si hizo todo correctamente, tendrá la siguiente imagen en la esquina superior izquierda del elemento de lienzo en la ventana del navegador: ¿Vio los



píxeles? Son tan pequeños y cuánto trabajo se ha hecho.

Ahora intentemos agregar un poco de dinámica al ejemplo, por ejemplo, de modo que cada 10 milisegundos nuestro píxel se desplace a la derecha (cambiaremos X píxeles en +1 cada 10 milisegundos), corregiremos el código de dibujo de píxeles en uno a intervalos:

let x = 10
setInterval(() => {

    drawer.drawPixel(x++, 20, 0, 0, 255)
    ctx.putImageData(imageData, 0, 0)

}, 10)

En este ejemplo, dejé solo la salida del píxel azul y envolví la función setInterval con el parámetro 10 en JavaScript. Esto significa que el código se llamará aproximadamente cada 10 milisegundos. Si ejecuta este ejemplo, verá que en lugar de que un píxel se desplace a la derecha, tendrá algo como esto:



una tira (o rastro) tan larga permanece porque no borramos el color del píxel anterior en la matriz de superficie, por lo que con cada llamada al intervalo agregamos un píxel Escribamos un método que limpie la superficie a su estado original. En otras palabras, llene la matriz con ceros. Agregue el método clearSurface a la clase Drawer:

clearSurface() {
    const surfaceSize = this.width * this.height * 4
    for (let i = 0; i < surfaceSize; i++) {
        this.surface[i] = 0
    }
}

No hay lógica en esta matriz, solo rellena con ceros. Se recomienda que llame a este método cada vez antes de dibujar una nueva imagen. En el caso de la animación de píxeles, antes de dibujar este píxel:

let x = 10
setInterval(() => {
    drawer.clearSurface()
    drawer.drawPixel(x++, 20, 0, 0, 255)
    ctx.putImageData(imageData, 0, 0)
}, 10)

Ahora, si ejecuta este ejemplo, el píxel se desplazará a la derecha, uno por uno, sin una traza innecesaria de las coordenadas anteriores.

Lo último que implementamos en el primer artículo es el método de dibujo lineal. Agréguelo, por supuesto, a la clase Drawer. El método que llamaré drawLine. ¿Qué tomará él? A diferencia de un punto, la línea todavía tiene las coordenadas en las que termina. En otras palabras, la línea tiene un comienzo, un final y un color, que pasaremos al método:

drawLine(x1, y1, x2, y2, r, g, b) { }

Cualquier línea consta de píxeles, solo queda rellenarla correctamente con píxeles de x1, y1 a x2, y2. Para empezar, dado que la línea consiste en píxeles, la sacaremos píxel por píxel en el bucle, pero ¿cómo calcular cuántos píxeles emitir? Por ejemplo, para dibujar una línea desde [0, 0] a [3, 0] es intuitivamente claro que necesita 4 píxeles ([0, 0], [1, 0], [2, 0], [3, 0],) . Pero de [12, 6] a [43, 14], ya no está claro cuánto durará la línea (cuántos píxeles se mostrarán) y qué coordenadas tendrán. Para hacer esto, recuerda un poco de geometría. Entonces, tenemos una línea que comienza en x1, y1 y termina en x2, y2.


Dibujemos una línea punteada desde el principio y el final para obtener un triángulo (imagen de arriba). Veremos que en la unión de las líneas dibujadas se ha formado un ángulo de 90 grados. Si el triángulo tiene tal ángulo, entonces el triángulo se llama rectangular, y sus lados, entre los cuales el ángulo es de 90 grados, se llaman patas. La tercera línea continua (que estamos tratando de dibujar) se llama hipotenusa en un triángulo. Usando estas dos patas introducidas (c1 y c2 en la figura), podemos calcular la longitud de la hipotenusa usando el teorema de Pitágoras. Veamos como hacerlo. La fórmula para la longitud de la hipotenusa (o longitud de la línea) será la siguiente: 

=12+22


Cómo obtener ambas piernas también se ve desde el triángulo. Ahora, usando la fórmula anterior, encontramos la hipotenusa, que será la línea larga (el número de píxeles):

 drawLine(x1, y1, x2, y2, r, g, b) {
         const c1 = y2 - y1
         const c2 = x2 - x1

         const length = Math.sqrt(c1 * c1 + c2 * c2)

Ya sabemos cuántos píxeles dibujar para dibujar una línea. Pero aún no sabemos cómo se desplazan los píxeles. Es decir, necesitamos dibujar una línea desde x1, y1 hasta x2, y2, sabemos que la longitud de la línea será, por ejemplo, de 20 píxeles. Podemos dibujar el primer píxel en x1, y1 y el último en x2, y2, pero ¿cómo encontrar las coordenadas de los píxeles intermedios? Para hacer esto, necesitamos saber cómo cambiar cada píxel siguiente con respecto a x1, y1 para obtener la línea deseada. Daré un ejemplo más para comprender mejor de qué tipo de desplazamiento estamos hablando. Tenemos puntos [0, 0] y [0, 3], necesitamos dibujar una línea sobre ellos. Del ejemplo se ve claramente que el siguiente punto después de [0, 0] será [0, 1], y luego [0, 2] y finalmente [0, 3]. Es decir, X de cada punto no se desplazó, bueno, o podemos decir que se desplazó en 0 píxeles e Y se desplazó en 1 píxel, este es el desplazamiento,se puede escribir como [0, 1]. Otro ejemplo: tenemos un punto [0, 0] y un punto [3, 6], intentemos calcular en nuestra mente cómo cambian, el primero será [0, 0], luego [0.5, 1], luego [1, 2] luego [1.5, 3] y así sucesivamente a [3, 6], en este ejemplo el desplazamiento será [0.5, 1]. ¿Cómo calcularlo? 

Puedes usar la siguiente fórmula:

   = 2 /  
  Y = 1 /   

En el código del programa, tendremos esto:

const xStep = c2 / length
const yStep = c1 / length

Todos los datos ya están allí: la longitud de la línea, el desplazamiento de los píxeles a lo largo de X e Y. Comenzamos en el ciclo para dibujar:

for (let i = 0; i < length; i++) {
    this.drawPixel(
        Math.trunc(x1 + xStep * i),
        Math.trunc(y1 + yStep * i),
        r, g, b,
    )
}

Como la coordenada X de la función Píxel, transferimos el comienzo de la línea X + desplazamiento X * i, por lo tanto, al obtener la coordenada del i-ésimo píxel, también calculamos la coordenada Y. Math.trunc es un método en JS que le permite descartar la parte fraccionaria de un número. Todo el código del método se ve así:

drawLine(x1, y1, x2, y2, r, g, b) {
    const c1 = y2 - y1
    const c2 = x2 - x1

    const length = Math.sqrt(c1 * c1 + c2 * c2)

    const xStep = c2 / length
    const yStep = c1 / length

    for (let i = 0; i < length; i++) {
        this.drawPixel(
            Math.trunc(x1 + xStep * i),
            Math.trunc(y1 + yStep * i),
            r, g, b,
        )
    }
}

La primera parte ha llegado a su fin, un camino largo pero emocionante para comprender el mundo 3D. Todavía no había nada tridimensional, pero realizamos operaciones preparatorias para dibujar: implementamos las funciones de dibujar un píxel, una línea, limpiar una ventana y aprendimos algunos términos. Todo el código de la clase Drawer se puede ver debajo del spoiler:

Código de clase del cajón
class Drawer {
  surface = null
  width = 0
  height = 0

  constructor(surface, width, height) {
    this.surface = surface
    this.width = width
    this.height = height
  }

  drawPixel(x, y, r, g, b)  {
    const offset = (this.width * y + x) * 4

    this.surface[offset] = r
    this.surface[offset + 1] = g
    this.surface[offset + 2] = b
    this.surface[offset + 3] = 255
  }

  drawLine(x1, y1, x2, y2, r, g, b) {
    const c1 = y2 - y1
    const c2 = x2 - x1

    const length = Math.sqrt(c1 * c1 + c2 * c2)

    const xStep = c2 / length
    const yStep = c1 / length

    for (let i = 0 ; i < length ; i++) {
        this.drawPixel(
          Math.trunc(x1 + xStep * i),
          Math.trunc(y1 + yStep * i),
          r, g, b,
        )
    }
  }

  clearSurface() {
    const surfaceSize = this.width * this.height * 4
    for (let i = 0; i < surfaceSize; i++) {
      this.surface[i] = 0
    }
  }
}

¿Que sigue?


En el próximo artículo, examinaremos cómo una operación tan simple como la salida de un píxel y una línea puede convertirse en interesantes objetos 3D. Nos familiarizaremos con las matrices y las operaciones en ellas, mostraremos un objeto tridimensional en una ventana e incluso agregaremos animación.

All Articles