3D faça você mesmo. Parte 1: pixels e linhas



Quero dedicar esta série de artigos aos leitores que desejam explorar o mundo da programação 3D do zero, às pessoas que desejam aprender o básico da criação do componente 3D de jogos e aplicativos. Implementaremos cada operação do zero para entender todos os aspectos, mesmo que já exista uma função pronta que a torne mais rápida. Tendo aprendido, mudaremos para as ferramentas internas para trabalhar com 3D. Depois de ler a série de artigos, você entenderá como criar cenas tridimensionais complexas com luz, sombras, texturas e efeitos, como fazer tudo isso sem profundo conhecimento de matemática e muito mais. Você pode fazer tudo isso de forma independente e com a ajuda de ferramentas prontas.

Na primeira parte, consideraremos:

  • Conceitos de renderização (software, hardware)
  • O que é um pixel / superfície?
  • Análise detalhada da saída de linha

Para não perder seu precioso tempo lendo artigos, que podem ser incompreensíveis para uma pessoa despreparada, voltarei imediatamente para os requisitos. Você pode começar a ler artigos em 3D com segurança, se conhece os conceitos básicos de programação em qualquer idioma, porque Vou me concentrar apenas no estudo da programação 3D, e não no estudo dos recursos da linguagem e dos conceitos básicos da programação. Quanto à preparação matemática, você não deve se preocupar aqui, embora muitos não desejem estudar 3D, porque eles ficam assustados com cálculos complexos e fórmulas furiosas por causa dos quais os pesadelos mais tarde sonham, mas, na verdade, não há com o que se preocupar. Vou tentar explicar da forma mais clara possível tudo o que é necessário para 3D, você só precisa multiplicar, dividir, adicionar e subtrair. Portanto, se você passou nos critérios de seleção, pode começar a ler.

Antes de começar a explorar o mundo interessante do 3D, vamos escolher uma linguagem de programação para exemplos, bem como um ambiente de desenvolvimento. Qual idioma devo escolher para programar gráficos 3D? Qualquer um, você pode trabalhar onde estiver mais confortável, a matemática será a mesma em todos os lugares. Neste artigo, todos os exemplos serão mostrados no contexto de JS (aqui os tomates voam para mim). Por que js? É simples - ultimamente tenho trabalhado principalmente com ele e, portanto, posso transmitir com mais eficácia a essência a você. Vou ignorar todos os recursos de JS nos exemplos, porque precisamos apenas dos recursos mais básicos que qualquer idioma possui, portanto, prestaremos atenção especificamente ao 3D. Mas você escolhe o que ama, porque nos artigos, todas as fórmulas não estarão vinculadas aos recursos de qualquer linguagem de programação. Qual ambiente escolher? Isso não importa,no caso de JS, qualquer editor de texto é adequado, você pode usar o que estiver mais perto de você.

Todos os exemplos usarão telas para pintura, como com ele, você pode começar a desenhar muito rapidamente, sem análise detalhada. O Canvas é uma ferramenta poderosa, com muitos métodos prontos para desenhar, mas com todas as suas características, pela primeira vez, usaremos apenas a saída de pixels! 

Todas as tridimensionais são exibidas na tela usando pixels. Mais adiante, nos artigos, você verá como isso acontece. Será que vai desacelerar? Sem aceleração de hardware (por exemplo, aceleração por uma placa de vídeo) - será. No primeiro artigo, não usaremos acelerações, escreveremos tudo do zero para entender os aspectos básicos do 3D. Vejamos alguns termos que serão mencionados em artigos futuros:

  • (Rendering) — 3D- . , 3D- , , .
  • (Software Rendering) — . , , , - . , . 3D- , — .
  • Renderização de hardware - Um processo de renderização assistida por hardware. Eu uso jogos e aplicativos. Tudo funciona muito rápido, porque muita computação de rotina assume a placa de vídeo, projetada para isso.

Não aspiro ao título "definição do ano" e tento declarar todas as descrições de termos da maneira mais clara possível. O principal é entender a idéia, que pode ser desenvolvida de forma independente. Também quero chamar a atenção para o fato de que todos os exemplos de código que serão mostrados nos artigos geralmente não são otimizados para velocidade, a fim de manter a facilidade de entendimento. Quando você entende o principal - como os gráficos 3D funcionam, você pode otimizar tudo sozinho.

Primeiro, crie um projeto, para mim é apenas um arquivo index.html de texto , com o seguinte conteúdo:

<!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>

Não vou me concentrar muito em JS e canvas agora - esses não são os personagens principais deste artigo. Mas, para uma compreensão geral, vou esclarecer que <canvas ...> é um retângulo (no meu caso, tamanho de 800 x 600 pixels) no qual exibirei todos os gráficos. Registrei a tela uma vez e não a alterarei mais.

<script></script> 

Script - um elemento no qual escreveremos toda a lógica para renderizar gráficos 3D com nossas próprias mãos (em JavaScript). 

Quando analisamos a estrutura do arquivo index.html do projeto recém-criado, começaremos a lidar com gráficos 3D.

Quando desenhamos algo na janela, isso na contagem final se transforma em pixels, porque são eles que o monitor exibe. Quanto mais pixels, mais nítida a imagem, mas o computador também carrega mais. Como é armazenado o que desenhamos na janela? Os gráficos em qualquer janela podem ser representados como uma matriz de pixels, e o próprio pixel é apenas uma cor. Ou seja, uma resolução de tela de 800x600 significa que nossa janela contém 600 linhas de 800 pixels cada, ou seja, 800 * 600 = 480000 pixels, muito, não é? Os pixels são armazenados em uma matriz. Vamos pensar em qual matriz armazenaríamos os pixels. Se tivermos 800 por 600 pixels, a opção mais óbvia é uma matriz bidimensional de 800 por 600. E essa é quase a opção certa, ou melhor, a opção completamente correta. Mas, como os pixels da janela, é melhor armazenar em uma matriz unidimensional de 480.000 elementos (se a resolução for de 800 por 600),só porque é mais rápido trabalhar com uma matriz unidimensional, porque Ele é armazenado na memória em uma seqüência contínua de bytes (tudo fica próximo e, portanto, é fácil obtê-lo). Em uma matriz bidimensional (por exemplo, no caso de JS), cada linha pode ser espalhada em diferentes locais da memória, portanto, o acesso aos elementos dessa matriz levará mais tempo. Além disso, para iterar em uma matriz unidimensional, é necessário apenas 1 ciclo e, para números inteiros bidimensionais 2, dada a necessidade de fazer dezenas de milhares de iterações do ciclo, a velocidade é importante aqui. O que é um pixel nessa matriz? Como mencionado acima - esta é apenas uma cor, ou melhor, 3 de seus componentes (vermelho, verde, azul). Qualquer imagem, mesmo a mais colorida, é apenas uma matriz de pixels de cores diferentes. Um pixel na memória pode ser armazenado como desejar, em uma matriz de 3 elementos ou em uma estrutura em que vermelho, verde,azul; ou alguma outra coisa. Uma imagem que consiste em uma matriz de pixels que acabamos de analisar, continuarei chamando a superfície. Acontece que, como tudo o que é exibido na tela é armazenado em uma matriz de pixels, alterando os elementos (pixels) nessa matriz - alteraremos pixel por pixel a imagem na tela. É exatamente isso que faremos neste artigo.

Não há função de desenho de pixel na tela, mas é possível acessar uma matriz unidimensional de pixels, que discutimos acima. Como fazer isso é mostrado no exemplo abaixo (este e todos os exemplos no futuro estarão apenas dentro do elemento de script):

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

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

No exemplo, imageData é um objeto no qual existem 3 propriedades:

  • height and width - números inteiros que armazenam a altura e a largura da janela para desenhar
  • data - array inteiro não assinado de 8 bits (você pode armazenar números no intervalo de 0 a 255)

A matriz de dados possui uma estrutura simples, mas explicativa. Essa matriz unidimensional armazena dados de cada pixel, que exibiremos na tela no seguinte formato:
Os 4 primeiros elementos da matriz (índices 0,1,2,3) são os dados do primeiro pixel na primeira linha. Os segundos 4 elementos (índices 4, 5, 6, 7) são os dados do segundo pixel da primeira linha. Quando chegamos ao 800º pixel da primeira linha, desde que a janela tenha 800 pixels de largura - o 801º pixel já pertencerá à segunda linha. Se mudarmos, na tela veremos que o 1º pixel da 2ª linha foi alterado (embora pela contagem na matriz seja o 801º pixel). Por que existem 4 elementos para cada pixel na matriz? Isso ocorre porque, na tela, além de alocar 1 elemento para cada cor - vermelho, verde, azul (são 3 elementos), mais 1 elemento para transparência (eles também dizem canal alfa ou opacidade). O canal alfa, como a cor, é definido no intervalo de 0 (transparente) a 255 (opaco). Com essa estrutura, obtemos uma imagem de 32 bits,porque cada pixel consiste em 4 elementos de 8 bits. Para resumir: cada pixel contém: cores vermelha, verde, azul e canal alfa (transparência). Esse esquema de cores é chamado ARGB (Alpha Red Green Blue). E o fato de cada pixel ocupar 32 bits diz que temos uma imagem de 32 bits (eles também dizem uma imagem com uma profundidade de cor de 32 bits).

Por padrão, toda a matriz de pixels imageData.data (data é uma propriedade na qual a matriz de pixels e imageData é apenas um objeto) é preenchida com os valores 0 e, se tentássemos produzir essa matriz, não veríamos nada de interessante na tela, porque 0 , 0, 0 é preto, mas como a transparência aqui também será 0, e essa é uma cor completamente transparente, nem veremos preto na tela!

É inconveniente trabalhar diretamente com uma matriz unidimensional; portanto, escreveremos uma classe na qual criaremos métodos para desenhar. Vou nomear a classe - Gaveta. Essa classe armazenará apenas os dados necessários e executará os cálculos necessários, abstraindo o máximo possível da ferramenta usada para renderização. É por isso que colocaremos todos os cálculos e trabalharemos com a matriz. E a própria chamada para o método de exibição na tela, colocaremos fora da classe, porque pode haver algo mais em vez de tela. Nesse caso, nossa classe não precisará ser alterada. Para trabalhar com uma matriz de pixels (superfície), é mais conveniente salvá-lo na classe Drawer, bem como na largura e altura da imagem, para que possamos acessar corretamente o pixel desejado. Portanto, a classe Drawer, preservando os dados mínimos necessários para desenhar, fica assim para mim:

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

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

Como você pode ver no construtor, a classe Drawer pega todos os dados necessários e os salva. Agora você pode criar uma instância dessa classe e passar uma matriz de pixels, largura e altura para ela (já temos todos esses dados, porque os criamos acima e os armazenamos em imageData):

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

Na classe Drawer, escreveremos várias funções de desenho, para facilitar o trabalho no futuro. Teremos uma função para desenhar um pixel, uma função para desenhar uma linha e, em outros artigos, funções para desenhar um triângulo e outras formas aparecerão. Mas vamos começar com o método de desenho de pixels. Vou chamá-lo de drawPixel. Se desenharmos um pixel, ele deverá ter coordenadas e cores:

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

Observe que a função drawPixel não aceita o parâmetro alfa (transparência) e, acima, descobrimos que a matriz de pixels consiste em 3 parâmetros de cor e 1 parâmetro de transparência. Não indiquei especificamente transparência, pois absolutamente não precisamos dela para obter exemplos. Por padrão, definiremos 255 (ou seja, tudo será opaco). Agora vamos pensar em como escrever a cor desejada em uma matriz de pixels em coordenadas x, y. Como temos todas as informações sobre a imagem, elas são armazenadas em uma matriz unidimensional, na qual 1 pixel (8 bits) é alocado para cada pixel. Para acessar o pixel desejado na matriz, primeiro precisamos determinar o índice de localização vermelho, porque qualquer pixel começa com ele (por exemplo, [r, g, b, a]). Uma pequena explicação da estrutura da matriz:



A tabela em verde indica como os componentes de cores são armazenados em uma matriz de superfície unidimensional. Seus índices na mesma matriz são indicados em azul e as coordenadas do pixel que aceita as funções drawPixel, que precisamos converter em índices na matriz unidimensional, indicam r, g, b, a para o pixel em azul. Assim, a partir da tabela, pode-se ver que, para cada pixel, o componente vermelho da cor vem primeiro, vamos começar com ele. Suponha que desejemos alterar o componente vermelho da cor do pixel nas coordenadas X1Y1 com um tamanho de imagem de 2 por 2 pixels. Na tabela, vemos que esse é o índice 12, mas como calculá-lo? Primeiro, encontramos o índice da linha que precisamos, para isso multiplicamos a largura da imagem por Y e por 4 (o número de valores por pixel) - este será:

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

Vemos que a segunda linha começa com o índice 8. Se compararmos com a placa, o resultado converge.

Agora você precisa adicionar um deslocamento de coluna ao índice de linha encontrado para obter o índice vermelho desejado. Para fazer isso, adicione X vezes 4. ao índice de linhas.A fórmula completa será:

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

Agora comparamos 12 com a tabela e vemos que o pixel X1Y1 realmente começa com o índice 12.

Para encontrar os índices de outros componentes de cores, você precisa adicionar um deslocamento de cor ao índice vermelho: +1 (verde), +2 (azul), +3 (alfa) . Agora podemos implementar o método drawPixel dentro da classe Drawer usando a fórmula acima:

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
}

Nesse método drawPixel, renderizei a parte repetitiva da fórmula na constante de deslocamento. Também é visto que em alpha eu apenas escrevo 255, porque está na estrutura, mas agora não precisamos gerar pixels.

É hora de testar o código e finalmente ver o primeiro pixel na tela. Aqui está um exemplo usando o método de renderização de pixel:

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

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

No exemplo acima, eu desenho 2 pixels, um vermelho 255, 0, 0 e o outro azul 0, 0, 255. Mas as alterações na matriz imageData.data (também é a superfície dentro da classe Drawer) não aparecerão na tela. Para desenhar, você precisa chamar ctx.putImageData (imageData, 0, 0), em que imageData é o objeto no qual a matriz de pixels e a largura / altura da área de desenho e 0, 0 é o ponto relativo ao qual a matriz de pixels será exibida (sempre deixe 0, 0 ) Se você fez tudo corretamente, terá a seguinte imagem no canto superior esquerdo do elemento de tela na janela do navegador: Você viu os



pixels? Eles são tão pequenos e quanto trabalho foi feito.

Agora, vamos tentar adicionar um pouco de dinâmica ao exemplo, por exemplo, para que a cada 10 milissegundos nosso pixel mude para a direita (alteramos X pixels por +1 a cada 10 milissegundos), corrigimos o código de desenho de pixel por um de cada vez:

let x = 10
setInterval(() => {

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

}, 10)

Neste exemplo, deixei apenas a saída do pixel azul e envolvi a função setInterval com o parâmetro 10. Em JavaScript, o que significa que o código será chamado aproximadamente a cada 10 milissegundos. Se você seguir esse exemplo, verá que, em vez de um pixel se deslocar para a direita, terá algo assim:



Uma faixa (ou traço) tão longa permanece porque não limpamos a cor do pixel anterior na matriz de superfície, portanto, a cada chamada para o intervalo que adicionamos um pixel. Vamos escrever um método que limpe a superfície ao seu estado original. Em outras palavras, preencha a matriz com zeros. Adicione o método clearSurface à classe Drawer:

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

Não há lógica nessa matriz, apenas preenchendo com zeros. É recomendável que você chame esse método todas as vezes antes de desenhar uma nova imagem. No caso de animação de pixel, antes de desenhar esse pixel:

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

Agora, se você executar este exemplo, o pixel será deslocado para a direita, um por um - sem um rastreamento desnecessário das coordenadas anteriores.

A última coisa que implementamos no primeiro artigo é o método de desenho de linha. Adicione-o, é claro, à classe Drawer. O método que chamarei de drawLine. O que ele vai levar? Ao contrário de um ponto, a linha ainda tem as coordenadas nas quais termina. Em outras palavras, a linha tem um começo, fim e cor, que passaremos para o método:

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

Qualquer linha consiste em pixels, resta apenas preenchê-la corretamente com pixels de x1, y1 a x2, y2. Para começar, como a linha consiste em pixels, então a produziremos pixel por pixel no loop, mas como calcular quantos pixels produzir? Por exemplo, para desenhar uma linha de [0, 0] a [3, 0], é intuitivamente claro que você precisa de 4 pixels ([0, 0], [1, 0], [2, 0], [3, 0]) . Mas de [12, 6] a [43, 14], ainda não está claro quanto tempo a linha será (quantos pixels exibir) e quais coordenadas terão. Para fazer isso, lembre-se de um pouco de geometria. Portanto, temos uma linha que começa em x1, y1 e termina em x2, y2.


Vamos desenhar uma linha pontilhada do começo e do fim para obter um triângulo (figura acima). Veremos que na junção das linhas desenhadas um ângulo de 90 graus se formou. Se o triângulo tiver esse ângulo, o triângulo será chamado de retangular, e seus lados, entre os quais o ângulo é de 90 graus, serão chamados de pernas. A terceira linha sólida (que estamos tentando desenhar) é chamada hipotenusa em um triângulo. Usando essas duas pernas introduzidas (c1 e c2 na figura), podemos calcular o comprimento da hipotenusa usando o teorema de Pitágoras. Vamos ver como fazer isso. A fórmula para o comprimento da hipotenusa (ou comprimento da linha) será a seguinte: 

=12+22


Como obter as duas pernas também é visto a partir do triângulo. Agora, usando a fórmula acima, encontramos a hipotenusa, que será a linha longa (o número de pixels):

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

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

Já sabemos quantos pixels desenhar para desenhar uma linha. Mas ainda não sabemos como os pixels são deslocados. Ou seja, precisamos desenhar uma linha de x1, y1 a x2, y2, sabemos que o comprimento da linha será, por exemplo, 20 pixels. Podemos desenhar o primeiro pixel em x1, y1 e o último em x2, y2, mas como encontrar as coordenadas dos pixels intermediários? Para fazer isso, precisamos saber como mudar cada próximo pixel em relação a x1, y1 para obter a linha desejada. Vou dar mais um exemplo para entender melhor de que tipo de deslocamento estamos falando. Temos pontos [0, 0] e [0, 3], precisamos desenhar uma linha neles. A partir do exemplo, é claramente visto que o próximo ponto após [0, 0] será [0, 1] e, em seguida, [0, 2] e, finalmente, [0, 3]. Ou seja, X de cada ponto não foi deslocado, ou podemos dizer que foi deslocado por 0 pixels, e Y foi deslocado por 1 pixel, esse é o deslocamento,pode ser escrito como [0, 1]. Outro exemplo: temos um ponto [0, 0] e um ponto [3, 6], vamos tentar calcular em nossa mente como eles mudam, o primeiro será [0, 0], depois [0,5, 1] ​​e depois [1, 2] depois [1,5, 3] e assim por diante a [3, 6]; neste exemplo, o deslocamento será [0,5, 1]. Como calcular? 

Você pode usar a seguinte fórmula:

   = 2 /  
  Y = 1 /   

No código do programa, teremos o seguinte:

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

Todos os dados já estão lá: comprimento da linha, deslocamento de pixels ao longo de X e Y. Começamos no ciclo para desenhar:

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

Como coordenada X da função Pixel, transferimos o início da linha X + deslocamento X * i, obtendo assim a coordenada do i-ésimo pixel, calculamos também a coordenada Y. Math.trunc é um método em JS que permite descartar a parte fracionária de um número. Todo o código do método fica assim:

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

A primeira parte chegou ao fim, um longo mas emocionante caminho para compreender o mundo 3D. Ainda não havia nada tridimensional, mas realizamos operações preparatórias para desenhar: implementamos as funções de desenhar um pixel, uma linha, limpar uma janela e aprendemos alguns termos. Todo o código da classe Drawer pode ser visualizado no spoiler:

Código de classe da gaveta
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
    }
  }
}

Qual é o próximo?


No próximo artigo, veremos como uma operação tão simples como a saída de um pixel e uma linha pode se transformar em objetos 3D interessantes. Vamos nos familiarizar com matrizes e operações nelas, exibir um objeto tridimensional em uma janela e até adicionar animação.

All Articles