Introdução aos Shaders

Starter pack

Finalmente é hora de mergulhar no mundo dos shaders. Eles são essenciais para criar todos os tipos de efeitos visuais. Neste capítulo, vamos aprender sobre shaders, o que podemos conseguir com eles e como usá-los no React Three Fiber.

Prelúdio

Antes de começarmos, quero mencionar que shaders podem levar algum tempo para se acostumar. Eles funcionam de maneira diferente do restante do código que escrevemos até agora. É uma nova forma de pensar e criar efeitos visuais. Mas não se preocupe, vou guiá-lo pelo processo, começaremos com o básico e gradualmente avançaremos para tópicos mais avançados.

Não se desanime se não entender tudo de primeira. Isso é normal. É um novo conceito, e requer prática para se sentir mais confortável com ele. Recomendo que você tire o seu tempo, experimente e pratique, mas prometo que vale a pena! Shaders são incrivelmente poderosos e lhe darão o controle para criar qualquer efeito visual que você possa imaginar.

Além disso, todos temos estilos de aprendizado diferentes. Algumas pessoas aprendem melhor lendo, outras assistindo vídeos, e outras fazendo. Para este tópico em particular, cruzar referências de diferentes fontes pode ser muito útil. Compartilharei recursos no final deste capítulo para consolidar seu conhecimento e ir além do que cobrimos aqui.

Espero não ter assustado você e que esteja animado para aprender sobre shaders. Vamos começar!

O que são Shaders?

Shaders são pequenos programas que rodam na GPU (Unidade de Processamento Gráfico). Eles são escritos em uma linguagem chamada GLSL (OpenGL Shading Language), que é semelhante ao C.

Eles são usados para posicionar os vértices de um mesh (Vertex Shader) e para colorir cada pixel das faces (Fragment Shader).

Na verdade, temos usado shaders o tempo todo. Quando criamos um material, estamos usando um shader. Por exemplo, quando criamos um MeshBasicMaterial, estamos usando um shader que colore o mesh com uma cor única. Quando criamos um MeshStandardMaterial, estamos usando um shader que simula iluminação, sombras e reflexos.

Vertex Shader

Um vertex shader é um programa executado para cada vértice de uma geometria. Sua principal responsabilidade é transformar os vértices do espaço 3D (nosso mundo 3D) para o espaço 2D (nossa tela ou viewport). Ele realiza essa transformação utilizando várias matrizes:

  • View Matrix: Esta matriz representa a posição e orientação da câmera na cena. Ela transforma os vértices do espaço do mundo para o espaço da câmera.
  • Projection Matrix: Esta matriz, que pode ser perspectiva ou ortográfica, converte os vértices do espaço da câmera para coordenadas normalizadas de dispositivo (NDC), preparando-os para a projeção final na tela 2D.
  • Model Matrix: Esta matriz encapsula a posição, rotação e escala de cada objeto individual na cena. Ela transforma os vértices do espaço do objeto para o espaço do mundo.

Além disso, o vertex shader também incorpora a posição original do vértice e quaisquer outros atributos associados a ele.

Schema of the vertex shader

Para cada vértice da geometria, o vertex shader será executado.

Finalmente, a posição transformada do vértice no espaço 2D é retornada via a variável predefinida gl_Position. Depois que todos os vértices são transformados, a GPU interpola os valores entre eles para gerar as faces da geometria, que são então rasterizadas e renderizadas na tela.

Fragment Shader

Um fragment shader, também conhecido como pixel shader, é um programa executado para cada fragmento (ou pixel) gerado pelo processo de rasterização. Sua principal tarefa é determinar a cor final de cada pixel na tela.

Schema of the fragment shader

Para cada fragmento gerado durante a rasterização, o fragment shader será executado.

O fragment shader recebe valores interpolados do vertex shader, como cores, coordenadas de textura, normais e quaisquer outros atributos associados aos vértices da geometria. Esses valores interpolados são chamados de varyings e fornecem informações sobre as propriedades da superfície em cada localização de fragmento.

Além dos valores interpolados, o fragment shader também pode amostrar texturas e acessar variáveis uniformes, que são constantes em todos os fragmentos. Essas variáveis uniformes podem representar parâmetros como posições de luz, propriedades de material ou quaisquer outros dados necessários para os cálculos de sombreamento.

Vamos falar sobre attributes e uniforms mais tarde nesta lição.

Usando os dados de entrada, o fragment shader realiza vários cálculos para determinar a cor final do fragmento. Isso pode envolver cálculos complexos de iluminação, mapeamento de textura, efeitos de sombreamento ou quaisquer outros efeitos visuais desejados na cena.

Uma vez que o cálculo de cor está completo, o fragment shader exibe a cor final do fragmento usando a variável predefinida gl_FragColor.

Tentei ao máximo explicar shaders de uma forma simples e omiti de propósito alguns detalhes técnicos, mas entendo que ainda pode ser um pouco abstrato. Vamos criar um shader simples para ver como ele funciona na prática.

Seu Primeiro Shader

Vamos rodar o starter pack. Você deve ver este frame com um plano preto no meio da tela:

A frame with a black plane

Abra o arquivo ShaderPlane.jsx, que contém uma mesh simples com uma geometria de plano e um material básico. Vamos substituir este material por um shader material personalizado.

shaderMaterial

Para criar um shader material, usamos a função shaderMaterial da Drei library.

Ela recebe 3 parâmetros:

  • uniforms: Um objeto contendo as variáveis uniformes usadas no shader. Deixe vazio por enquanto.
  • vertexShader: Uma string contendo o código GLSL para o vertex shader.
  • fragmentShader: Uma string contendo o código GLSL para o fragment shader.

No topo do nosso arquivo, vamos declarar um novo shader material chamado MyShaderMaterial:

import { shaderMaterial } from "@react-three/drei";

const MyShaderMaterial = shaderMaterial(
  {},
  `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  `
  void main() {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
  }
  `
);

Vamos mergulhar no código do shader em um momento.

Para podermos usá-lo de forma declarativa com React Three Fiber, usamos o método extend:

import { extend } from "@react-three/fiber";
// ...

extend({ MyShaderMaterial });

Agora podemos substituir o <meshBasicMaterial> pelo nosso novo shader material:

import { shaderMaterial } from "@react-three/drei";
import { extend } from "@react-three/fiber";

const MyShaderMaterial = shaderMaterial(
  {},
  `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  `
  void main() {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
  }
  `
);

extend({ MyShaderMaterial });

export const ShaderPlane = ({ ...props }) => {
  return (
    <mesh {...props}>
      <planeGeometry args={[1, 1]} />
      <myShaderMaterial />
    </mesh>
  );
};

Você deve ver o mesmo plano preto de antes. Não mudamos nada ainda, mas agora estamos usando um shader material personalizado.

Para verificar se está funcionando, vamos mudar a cor que estamos retornando no fragment shader. Substitua a linha gl_FragColor pelo seguinte:

gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);

gl_FragColor é uma variável predefinida que representa a cor do fragmento. É um vec4 (um vetor com 4 componentes) que representa os canais de vermelho, verde, azul e alfa da cor. Cada componente é um float entre 0 e 1.

Definindo o primeiro componente como 1.0, estamos definindo o canal vermelho para seu valor máximo, que resultará em uma cor vermelha.

Você deve ver um plano vermelho no meio da tela:

A frame with a red plane

Parabéns! Você acabou de criar seu primeiro shader material. É um simples, mas é um começo.

Código Shader

Antes de prosseguirmos, vamos configurar nosso ambiente de desenvolvimento para escrever shaders de maneira mais confortável.

Você tem duas opções para escrever o código shader:

  • Inline: Você pode escrever o código shader diretamente no arquivo JavaScript.
  • External: Você pode escrever o código shader em um arquivo separado com a extensão .glsl e importá-lo no seu arquivo JavaScript.

Eu geralmente prefiro a abordagem inline dentro de arquivos de material apropriados, dessa forma, o código shader está próximo à declaração do material.

Mas para facilitar a escrita e leitura, eu recomendo usar um realçador de sintaxe para GLSL. Você pode usar a extensão Comment tagget templates para o Visual Studio Code. Ela vai realçar o código GLSL dentro das strings de template.

Realçador de sintaxe GLSL

Uma vez instalada, para habilitar o realçador de sintaxe, você precisa adicionar o seguinte comentário no início do código shader:

const MyShaderMaterial = shaderMaterial(
  {},
  /* glsl */ `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  /* glsl */ `
  void main() {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }
  `
);

Você deve ver o código GLSL destacado nas strings de template:

Realçador de sintaxe GLSL em ação

O shader de vértice no topo agora tem o realce de sintaxe correto e está mais fácil de ler.

Isso é tudo o que você precisa para código shader inline. Você ainda pode optar por usar um arquivo externo se preferir manter seu código shader separado. Vamos ver como fazer isso.

Importar Arquivos GLSL

Primeiro, crie uma nova pasta chamada shaders na pasta src. Dentro dessa pasta, crie dois arquivos: myshader.vertex.glsl e myshader.fragment.glsl e copie o respectivo código shader em cada arquivo.

myshader.vertex.glsl:

void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

myshader.fragment.glsl:

void main() {
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

Sinta-se à vontade para usar a convenção de nomenclatura que preferir e agrupar seus shaders em subpastas se você tiver muitos.

Então, para poder importar esses arquivos em nosso arquivo JavaScript, precisamos instalar o plugin vite-plugin-glsl como uma dependência de desenvolvimento:

yarn add vite-plugin-glsl --dev

Então, em seu arquivo vite.config.js, importe o plugin e adicione-o ao array de plugins:

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import glsl from "vite-plugin-glsl";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), glsl()],
});

Agora você pode importar os arquivos GLSL em seu arquivo JavaScript e usá-los como código shader:

import myShaderFragment from "./shaders/myshader.fragment.glsl";
import myShaderVertex from "./shaders/myshader.vertex.glsl";

const MyShaderMaterial = shaderMaterial({}, myShaderVertex, myShaderFragment);

Agora que temos uma maneira confortável de escrever e importar código shader, podemos começar a explorar as diferentes partes do código shader.

GLSL

O código do shader é escrito em GLSL (OpenGL Shading Language). É uma linguagem semelhante ao C, vamos ver o básico.

Tipos

GLSL possui vários tipos, mas os mais comuns são:

  • bool: Um valor booleano (true ou false).
  • int: Um número inteiro.
  • float: Um número de ponto flutuante.
  • vectores: Uma coleção de números. vec2 é uma coleção de 2 números de ponto flutuante (x e y), vec3 é uma coleção de 3 números de ponto flutuante (x, y e z), e vec4 é uma coleção de 4 números de ponto flutuante (x, y, z e w). Em vez de usar x, y, z e w, você também pode usar r, g, b e a para cores, eles são intercambiáveis.
  • matrizes: Uma coleção de vectores. Por exemplo, mat2 é uma coleção de 2 vectores, mat3 é uma coleção de 3 vectores, e mat4 é uma coleção de 4 vectores.

Swizzling e manipulações

Você pode acessar os componentes de um vector usando swizzling. Por exemplo, você pode criar um novo vector usando os componentes de outro vector:

vec3 a = vec3(1.0, 2.0, 3.0);
vec2 b = a.xy;

Neste exemplo, b será um vector com os componentes x e y de a.

Você também pode usar swizzling para alterar a ordem dos componentes:

vec3 a = vec3(1.0, 2.0, 3.0);
vec3 b = a.zyx;

Neste exemplo, b será igual a vec3(3.0, 2.0, 1.0).

Para criar um novo vector com todos os mesmos componentes, você pode usar o construtor:

vec3 a = vec3(1.0);

Neste exemplo, a será igual a vec3(1.0, 1.0, 1.0).

Operadores

GLSL possui os operadores aritméticos comuns: +, -, *, /, +=, /=, *= e operadores de comparação comuns: ==, !=, >, <, >=, <=.

Eles precisam ser usados ​​com os tipos corretos. Por exemplo, você não pode adicionar um inteiro a um float; você precisa converter o inteiro para um float primeiro:

int a = 1;
float b = 2.0;
float c = float(a) + b;

Você também pode realizar operações em vetores e matrizes:

vec3 a = vec3(1.0, 2.0, 3.0);
vec3 b = vec3(4.0, 5.0, 6.0);
vec3 c = a + b;

Que é o mesmo que:

vec3 c = vec3(a.x + b.x, a.y + b.y, a.z + b.z);

Funções

O ponto de entrada dos shaders de vértice e fragmento é a função main. É a função que será executada quando o shader for chamado.

void main() {
  // Seu código aqui
}

void é o tipo de retorno da função. Significa que a função não retorna nada.

Você também pode definir suas próprias funções:

float add(float a, float b) {
  return a + b;
}

Você pode então chamar essa função na função main:

void main() {
  float result = add(1.0, 2.0);
  // ...
}

GLSL fornece muitas funções embutidas para operações comuns como sin, cos, max, min, abs, round, floor, ceil, e muitas outras úteis como mix, step, length, distance, e mais.

Descobriremos as principais e praticaremos com elas na próxima lição.

Loops e Condições

GLSL suporta loops for e instruções if. Eles funcionam de forma semelhante ao JavaScript:

for (int i = 0; i < 10; i++) {
  // Seu código aqui
}

if (condition) {
  // Seu código aqui
} else {
  // Seu código aqui
}

Logging / Depuração

Como os programas de shader são executados para cada vértice e fragmento em paralelo, não é possível usar console.log para depurar seu código, nem adicionar breakpoints. Isso é o que torna difícil depurar shaders.

Uma maneira comum de depurar shaders é usar o gl_FragColor para visualizar os valores de suas variáveis.

Erros de Compilação

Se você cometer um erro no seu código de shader, verá um erro de compilação no console. Ele informará a linha e o tipo de erro. Não é sempre fácil de entender, mas é uma boa maneira de saber onde procurar o problema.

Vamos remover o canal alpha do gl_FragColor e ver o que acontece:

void main() {
  gl_FragColor = vec4(1.0, 0.0, 0.0);
}

Você deve ver um erro de compilação no console:

Compilation error

Informando-nos que o gl_FragColor espera 4 componentes, mas fornecemos apenas 3.

Não esqueça de restaurar o canal alpha para 1.0 para remover o erro.

Uniforms

Para passar dados do código JavaScript para o shader, usamos uniforms. Eles são constantes em todos os vértices e fragmentos.

O projectionMatrix, modelViewMatrix e position são exemplos de uniforms embutidos que são automaticamente passados para o shader.

Vamos criar um uniform personalizado para passar uma cor para o shader. Nós o usaremos para colorir o plano. Vamos chamá-lo de uColor. É uma boa prática prefixar o nome do uniform com u para deixar claro que ele é um uniform em nosso código.

Primeiro, vamos declará-lo no objeto uniforms do shaderMaterial:

import { Color } from "three";
// ...
const MyShaderMaterial = shaderMaterial(
  {
    uColor: new Color("pink"),
  }
  // ...
);

// ...

Depois, podemos usá-lo no fragment shader:

uniform vec3 uColor;

void main() {
  gl_FragColor = vec4(uColor, 1.0);
}

Você deve ver o plano colorido de rosa:

A frame with a pink plane

Aqui, a cor rosa é o valor padrão do uniform. Podemos mudá-lo diretamente no material:

<MyShaderMaterial uColor={"lightblue"} />

A frame with a light blue plane

O plano agora está colorido de azul claro.

Tanto os vertex como os fragment shaders podem acessar os uniforms. Vamos adicionar o tempo como um segundo uniform para o vertex shader para mover o plano para cima e para baixo:

End of lesson preview

To get access to the entire lesson, you need to purchase the course.