
Aprenda o básico do GLSL ao criar um efeito de máscara distorcida nas imagens usando o Babylon.js.
Hoje em dia, é realmente difícil navegar na web e não encontrar um site maravilhoso que tenha efeitos impressionantes que pareçam magia negra.
Bem, muitas vezes essa “magia negra” é de fato WebGL, às vezes misturada com um pouco de GLSL. Você pode encontrar alguns exemplos muito bons neste resumo do Awwwards , mas há muitos outros por aí.
Recentemente, deparei-me com o site Waka Waka , um dos mais recentes trabalhos de Ben Mingo e Aristide Benoist , e a primeira coisa que notei foi o efeito de pairar nas imagens .
Era óbvio que era WebGL, mas minha pergunta era: “Como Aristide fez isso?”
Como eu amo desconstruir coisas do WebGL , tentei replicá-las e, no final, consegui.
Neste tutorial, explicarei como criar um efeito realmente semelhante ao do site Waka Waka usando a biblioteca BabylonJS da Microsoft e alguns GLSL.
É isso que vamos fazer.
A configuração
A primeira coisa que precisamos fazer é criar nossa cena; será muito básico e conterá apenas um plano ao qual aplicaremos um costume ShaderMaterial
.
Não abordarei como configurar uma cena no BabylonJS, para que você possa verificar sua documentação abrangente .
Aqui está o código que você pode copiar e colar:
import { Engine } from "@babylonjs/core/Engines/engine";
import { Scene } from "@babylonjs/core/scene";
import { Vector3 } from "@babylonjs/core/Maths/math";
import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera";
import { ShaderMaterial } from "@babylonjs/core/Materials/shaderMaterial";
import { Effect } from "@babylonjs/core/Materials/effect";
import { PlaneBuilder } from "@babylonjs/core/Meshes/Builders/planeBuilder";
class App {
constructor() {
this.canvas = null;
this.engine = null;
this.scene = null;
}
init() {
this.setup();
this.addListeners();
}
setup() {
this.canvas = document.querySelector("#app");
this.engine = new Engine(this.canvas, true, null, true);
this.scene = new Scene(this.engine);
// Adding the vertex and fragment shaders to the Babylon's ShaderStore
Effect.ShadersStore["customVertexShader"] = require("./shader/vertex.glsl");
Effect.ShadersStore[
"customFragmentShader"
] = require("./shader/fragment.glsl");
// Creating the shader material using the `custom` shaders we added to the ShaderStore
const planeMaterial = new ShaderMaterial("PlaneMaterial", this.scene, {
vertex: "custom",
fragment: "custom",
attributes: ["position", "normal", "uv"],
uniforms: ["worldViewProjection"]
});
planeMaterial.backFaceCulling = false;
// Creating a basic plane and adding the shader material to it
const plane = new PlaneBuilder.CreatePlane(
"Plane",
{ width: 1, height: 9 / 16 },
this.scene
);
plane.scaling = new Vector3(7, 7, 1);
plane.material = planeMaterial;
// Camera
const camera = new ArcRotateCamera(
"Camera",
-Math.PI / 2,
Math.PI / 2,
10,
Vector3.Zero(),
this.scene
);
this.engine.runRenderLoop(() => this.scene.render());
}
addListeners() {
window.addEventListener("resize", () => this.engine.resize());
}
}
const app = new App();
app.init();
Como você pode ver, não é tão diferente de outras bibliotecas WebGL como Three.js: configura uma cena, uma câmera e inicia o loop de renderização (caso contrário, você não veria nada).
O material do avião é um ShaderMaterial
para o qual teremos que criar seus respectivos arquivos shader.
// /src/shader/vertex.glsl
precision highp float;
// Attributes
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
// Uniforms
uniform mat4 worldViewProjection;
// Varyings
varying vec2 vUV;
void main(void) {
gl_Position = worldViewProjection * vec4(position, 1.0);
vUV = uv;
}
// /src/shader/fragment.glsl
precision highp float;
// Varyings
varying vec2 vUV;
void main() {
vec3 color = vec3(vUV.x, vUV.y, 0.0);
gl_FragColor = vec4(color, 1.0);
}
Você pode esquecer o sombreador de vértice, pois, para os fins deste tutorial, trabalharemos apenas no sombreador de fragmento.
Aqui você pode vê-lo ao vivo:
Bom, já escrevemos 80% do código JavaScript necessário para os fins deste tutorial.
A lógica
O GLSL é legal, permite criar efeitos impressionantes que seriam impossíveis de fazer apenas com HTML, CSS e JS. É um mundo completamente diferente, e se você sempre fez coisas de “web”, ficará confuso no início, porque ao trabalhar com o GLSL você precisa pensar de uma maneira completamente diferente para obter qualquer efeito.
A lógica por trás do efeito que queremos obter é bastante simples: temos duas imagens sobrepostas, e a imagem que se sobrepõe à outra tem uma máscara aplicada.
Simples, mas não funciona como máscaras SVG, por exemplo.
Ajustando o sombreador de fragmento
Antes de prosseguir, precisamos ajustar um pouco o shader do fragmento.
Por enquanto, fica assim:
// /src/shader/fragment.glsl
precision highp float;
// Varyings
varying vec2 vUV;
void main() {
vec3 color = vec3(vUV.x, vUV.y, 0.0);
gl_FragColor = vec4(color, 1.0);
}
Aqui, estamos dizendo ao sombreador para atribuir a cada pixel uma cor cujos canais são determinados pelo valor da x
coordenada do canal vermelho e da y
coordenada do canal verde.
Mas precisamos ter a origem no centro do avião, não no canto inferior esquerdo. Para fazer isso, precisamos refatorar a declaração uv
desta maneira:
// /src/shader/fragment.glsl
precision highp float;
// Varyings
varying vec2 vUV;
void main() {
vec2 uv = vUV - 0.5;
vec3 color = vec3(uv.x, uv.y, 0.0);
gl_FragColor = vec4(color, 1.0);
}
Essa alteração simples resultará no seguinte:
Isso porque movemos a origem do canto inferior esquerdo para o centro do plano, então uv
os valores vão de -0.5
para 0.5
. Como você não pode atribuir valores negativos aos canais RGB, os canais vermelho e verde ficam 0.0
em toda a área inferior esquerda.
Criando a máscara
Primeiro, vamos mudar a cor do avião para completar o preto:
// /src/shader/fragment.glsl
precision highp float;
// Varyings
varying vec2 vUV;
void main() {
vec2 uv = vUV - 0.5;
vec3 color = vec3(0.0);
gl_FragColor = vec4(color, 1.0);
}
Agora vamos adicionar um retângulo que usaremos como máscara para a imagem em primeiro plano.
Adicione este código fora da main()
função:
vec3 Rectangle(in vec2 size, in vec2 st, in vec2 p, in vec3 c) {
float top = step(1. - (p.y + size.y), 1. - st.y);
float right = step(1. - (p.x + size.x), 1. - st.x);
float bottom = step(p.y, st.y);
float left = step(p.x, st.x);
return top * right * bottom * left * c;
}
(Como criar formas está além do escopo deste tutorial. Para isso, sugiro que você leia este capítulo do “Livro dos Shaders” )
A Rectangle()
função faz exatamente o que o nome diz: cria um retângulo com base nos parâmetros que passamos para ela.
Em seguida, redefinimos o color
uso dessa Rectangle()
função:
vec2 maskSize = vec2(0.3, 0.3);
// Note that we're subtracting HALF of the width and height to position the rectangle at the center of the scene
vec2 maskPosition = vec2(-0.15, -0.15);
vec3 maskColor = vec3(1.0);
color = Rectangle(maskSize, uv, maskPosition, maskColor);
Impressionante! Agora temos o nosso plano preto com um belo retângulo branco no centro.
Mas espere! Isso não deveria ser um retângulo; definimos seu tamanho para 0,3 na largura e na altura!
Isso é devido à proporção do nosso avião, mas pode ser facilmente corrigido em duas etapas simples.
Primeiro, adicione este trecho ao arquivo JS:
this.scene.registerBeforeRender(() => {
plane.material.setFloat("uPlaneRatio", plane.scaling.x / plane.scaling.y);
});
E edite o sombreador adicionando esta linha na parte superior do arquivo:
uniform float uPlaneRatio;
… E esta linha também, logo abaixo da linha que define a uv
variável
uv.x *= uPlaneRatio;
Breve explicação
No arquivo JS, estamos enviando um uPlaneRatio
uniforme (um dos tipos de dados GLSL) para o sombreador de fragmento, cujo valor é a razão entre a largura e a altura do plano.
Fizemos o shader de fragmento aguardar esse uniforme, declarando-o na parte superior do arquivo e, em seguida, o shader o usa para ajustar o uv.x
valor.
Aqui você pode ver o resultado final: um avião preto com um quadrado branco no centro; nada muito chique (ainda), mas funciona!
Adicionando a imagem em primeiro plano
A exibição de uma imagem no GLSL é bastante simples. Primeiro, edite o código JS e adicione as seguintes linhas:
// Import the `Texture` module from BabylonJS at the top of the file
import { Texture } from '@babylonjs/core/Materials/Textures/texture'
// Add this After initializing both the plane mesh and its material
const frontTexture = new Texture('src/images/lantern.jpg')
plane.material.setTexture("u_frontTexture", frontTexture)
Dessa forma, estamos passando a imagem de primeiro plano para o sombreador de fragmentos como um Texture
elemento.
Agora, adicione as seguintes linhas ao shader de fragmento:
// Put this at the beginninng of the file, outside of the `main()` function
uniform sampler2D u_frontTexture;
// Put this at the bottom of the `main()` function, right above `gl_FragColor = ...`
vec3 frontImage = texture2D(u_frontTexture, uv * 0.5 + 0.5).rgb;
Um pouco de explicação:
Dissemos ao BabylonJS para passar a textura para o shader como a sampler2D
com o setTexture()
método e, então, fizemos com que o shader soubesse que passaríamos aquele sampler2D
cujo nome é u_frontTexture
.
Por fim, criamos uma nova variável do tipo vec3
nomeada frontImage
que contém os valores RGB de nossa textura.
Por padrão, um texture2D
é uma vec4
variável (que contém o r
, g
, b
e a
valores), mas nós não precisamos do canal alfa para que declare frontImage
como uma vec3
variável e obter explicitamente apenas os .rgb
canais.
Observe também que modificamos os UVs da textura, multiplicando-a primeiro por 0,5 e depois adicionando- 0.5
a. Isso ocorre porque, no início da main()
função, remapeei o sistema de coordenadas -0.5 -> 0.5
e também pelo fato de termos que ajustar o valor de uv.x
.
Se você agora adicionar isso ao código GLSL…
color = frontImage;
… Você verá nossa imagem, renderizada por um shader GLSL:
Mascaramento
Lembre-se sempre de que, para shaders, tudo é um número (sim, até imagens), e isso 0.0
significa completamente oculto enquanto 1.0
significa totalmente visível .
Agora podemos usar a máscara que acabamos de criar para ocultar as partes da nossa imagem onde o valor da máscara é igual 0.0
.
Com isso em mente, é muito fácil aplicar nossa máscara. A única coisa que precisamos fazer é multiplicar a color
variável pelo valor demask
:
// The mask should be a separate variable, not set as the `color` value
vec3 mask = Rectangle(maskSize, uv, maskPosition, maskColor);
// Some super magic trick
color = frontImage * mask;
Agora, agora temos um efeito de máscara totalmente funcional:
Vamos aprimorá-lo um pouco, fazendo a máscara seguir um caminho circular.
Para fazer isso, precisamos voltar ao nosso arquivo JS e adicionar algumas linhas de código.
// Add this to the class constructor
this.time = 0
// This goes inside the `registerBeforeRender` callback
this.time++;
plane.material.setFloat("u_time", this.time);
No sombreador de fragmento, primeiro declare o novo uniforme na parte superior do arquivo:
uniform float u_time;
Em seguida, edite a declaração maskPosition
como esta:
vec2 maskPosition = vec2(
cos(u_time * 0.05) * 0.2 - 0.15,
sin(u_time * 0.05) * 0.2 - 0.15
);
u_time
é simplesmente um dos uniformes que passamos para o shader do programa WebGL.
A única diferença com o u_frontTexture
uniforme é que aumentamos seu valor em cada loop de renderização e passamos seu novo valor ao shader, para que ele atualize a posição da máscara.
Aqui está uma visualização ao vivo da máscara em círculo:
Adicionando a imagem de fundo
Para adicionar a imagem de plano de fundo, faremos exatamente o oposto do que fizemos na imagem de primeiro plano.
Vamos dar um passo de cada vez.
Primeiro, na classe JS, passe ao shader a imagem de plano de fundo da mesma maneira que fizemos na imagem de primeiro plano:
const backTexture = new Texture("src/images/lantern-bw.jpg");
plane.material.setTexture("u_backTexture", backTexture);
Em seguida, diga ao shader de fragmento que estamos passando isso u_backTexture
e inicialize outra vec3
variável:
// This goes at the top of the file
uniform sampler2D backTexture;
// Add this after `vec3 frontImage = ...`
vec3 backgroundImage = texture2D(iChannel1, uv * 0.5 + 0.5).rgb;
Quando você faz um teste rápido, substituindo
color = frontImage * mask;
com
color = backImage * mask;
você verá a imagem de fundo.
Mas, para este, temos que inverter a máscara para que ela se comporte da maneira oposta.
Inverter um número é realmente fácil, a fórmula é:
invertedNumber = 1 - <number>
Então, vamos aplicar a máscara invertida à imagem de fundo:
backImage *= (1.0 - mask);
Aqui, estamos aplicando a mesma máscara que adicionamos à imagem em primeiro plano, mas como a invertemos, o efeito é o oposto.
Juntando tudo
Nesse ponto, podemos refatorar a declaração das duas imagens aplicando diretamente suas máscaras.
vec3 frontImage = texture2D(u_frontTexture, uv * 0.5 + 0.5).rgb * mask;
vec3 backImage = texture2D(u_backTexture, uv * 0.5 + 0.5).rgb * (1.0 - mask);
Agora podemos exibir as duas imagens adicionando backImage
a frontImage
:
color = backImage + frontImage;
É isso, aqui está um exemplo ao vivo do efeito desejado:
Distorcendo a máscara
Legal né? Mas ainda não acabou! Vamos ajustá-lo um pouco distorcendo a máscara.
Para fazer isso, primeiro precisamos criar uma nova vec2
variável:
vec2 maskUV = vec2(
uv.x + sin(u_time * 0.03) * sin(uv.y * 5.0) * 0.15,
uv.y + cos(u_time * 0.03) * cos(uv.x * 10.0) * 0.15
);
Em seguida, substitua uv
por maskUV
na mask
declaração
vec3 mask = Rectangle(maskSize, maskUV, maskPosition, maskColor);
Em maskUV
, estamos usando um pouco de matemática para adicionar uv
valores com base no u_time
uniforme e no atual uv
.
Tente ajustar esses valores por conta própria para ver efeitos diferentes.
Distorcendo a imagem do primeiro plano
Vamos agora distorcer a imagem do primeiro plano da mesma maneira que fizemos para a máscara, mas com valores ligeiramente diferentes.
Crie uma nova vec2
variável para armazenar as imagens em primeiro plano uv
:
vec2 frontImageUV = vec2(
(uv.x + sin(u_time * 0.04) * sin(uv.y * 10.) * 0.03),
(uv.y + sin(u_time * 0.03) * cos(uv.x * 15.) * 0.05)
);
Em seguida, use isso em frontImageUV
vez do padrão uv
ao declarar frontImage
:
vec3 frontImage = texture2D(u_frontTexture, frontImageUV * 0.5 + 0.5).rgb * mask;
Voilà! Agora, a máscara e a imagem têm um efeito de distorção aplicado.
Mais uma vez, tente ajustar esses números para ver como o efeito muda.
10 – Adicionando controle do mouse
O que fizemos até agora é muito legal, mas podemos torná-lo ainda mais legal, adicionando algum controle do mouse, como fazer com que ele desapareça quando o mouse passa / sai do avião e faz a máscara seguir o cursor.
Adicionando efeitos de desbotamento
Para detectar os eventos mouseover / mouseleave em uma malha e executar algum código quando esses eventos ocorrem, temos que usar as ações do BabylonJS .
Vamos começar importando alguns novos módulos:
import { ActionManager } from "@babylonjs/core/Actions/actionManager";
import { ExecuteCodeAction } from "@babylonjs/core/Actions/directActions";
import "@babylonjs/core/Culling/ray";
Em seguida, adicione este código após a criação do avião:
this.plane.actionManager = new ActionManager(this.scene);
this.plane.actionManager.registerAction(
new ExecuteCodeAction(ActionManager.OnPointerOverTrigger, () =>
this.onPlaneHover()
)
);
this.plane.actionManager.registerAction(
new ExecuteCodeAction(ActionManager.OnPointerOutTrigger, () =>
this.onPlaneLeave()
)
);
Aqui nós estamos dizendo ActionManager do avião para captar as PointerOver
e PointerOut
eventos e executar as onPlaneHover()
e onPlaneLeave()
métodos, que vamos acrescentar agora:
onPlaneHover() {
console.log('hover')
}
onPlaneLeave() {
console.log('leave')
}
Algumas notas sobre o código acima
Por favor, note que eu usei em this.plane
vez de apenas plane
; isso é porque teremos que acessá-lo no mousemove
retorno de chamada do evento mais tarde, então refatorei o código um pouco.
ActionManager
nos permite ouvir certos eventos em um alvo, neste caso o avião.
ExecuteCodeAction
é uma ação do BabylonJS que usaremos para executar algum código arbitrário.
ActionManager.OnPointerOverTrigger
e ActionManager.OnPointerOutTrigger
são os dois eventos que estamos ouvindo no avião. Eles se comportam exatamente como os eventos mouseenter
e mouseleave
para os elementos DOM.
Para detectar eventos de foco no WebGL, precisamos “lançar um raio” da posição do mouse para a malha que estamos verificando; se esse raio, em algum momento, cruzar com a malha, significa que o mouse está passando o mouse. É por isso que estamos importando o @babylonjs/core/Culling/ray
módulo; O BabylonJS cuidará do resto.
Agora, se você testá-lo ao passar o mouse e sair da malha, verá que ela registra hover
e leave
.
Agora, vamos adicionar o efeito fade. Para isso, usarei a biblioteca GSAP , que é a biblioteca de fato para animações complexas e de alto desempenho.
Primeiro, instale-o:
yarn add gsap
Em seguida, importe-o em nossa classe
import gsap from 'gsap
e adicione esta linha ao constructor
this.maskVisibility = { value: 0 };
Por fim, adicione esta linha à registerBeforeRender()
função de retorno de chamada da
this.plane.material.setFloat( "u_maskVisibility", this.maskVisibility.value);
Dessa forma, estamos enviando ao shader a value
propriedade atual this.maskVisibility
como um novo uniforme chamado u_maskVisibility
.
Refatore o shader de fragmento da seguinte maneira:
// Add this at the top of the file, like any other uniforms
uniform float u_maskVisibility;
// When declaring `maskColor`, replace `1.0` with the `u_maskVisibility` uniform
vec3 maskColor = vec3(u_maskVisibility);
Se você agora verificar o resultado, verá que a imagem em primeiro plano não está mais visível; o que aconteceu?
Você se lembra quando eu escrevi que “para shaders, tudo é um número” ? Essa é a razão! O u_maskVisibility
uniforme é igual 0.0
, o que significa que a máscara é invisível.
Podemos corrigi-lo em algumas linhas de código. Abra o código JS e refatorar os métodos onPlaneHover()
e onPlaneLeave()
desta maneira:
onPlaneHover() {
gsap.to(this.maskVisibility, {
duration: 0.5,
value: 1
});
}
onPlaneLeave() {
gsap.to(this.maskVisibility, {
duration: 0.5,
value: 0
});
}
Agora, quando você passa o mouse ou sai do avião, verá que a máscara desaparece e entra!
(E sim, o BabylonJS possui seu próprio mecanismo de animação , mas estou muito mais confiante com o GSAP, por isso optei por isso.)
Faça a máscara seguir o cursor do mouse
Primeiro, adicione esta linha ao constructor
this.maskPosition = { x: 0, y: 0 };
e isso para o addListeners()
método:
window.addEventListener("mousemove", () => {
const pickResult = this.scene.pick(
this.scene.pointerX,
this.scene.pointerY
);
if (pickResult.hit) {
const x = pickResult.pickedPoint.x / this.plane.scaling.x;
const y = pickResult.pickedPoint.y / this.plane.scaling.y;
this.maskPosition = { x, y };
}
});
O que o código acima faz é bem simples: em todo mousemove
evento, ele lança um raio this.scene.pick()
e atualiza os valores de this.maskPosition
se o raio está cruzando alguma coisa.
(Como temos apenas uma única malha, podemos evitar verificar qual malha está sendo atingida pelo raio.)
Novamente, em cada loop de renderização, enviamos a posição da máscara para o shader, mas desta vez como a vec2
. Primeiro, importe o Vector2
módulo junto comVector3
import { Vector2, Vector3 } from "@babylonjs/core/Maths/math";
Adicione isso na runRenderLoop
função de retorno de chamada
this.plane.material.setVector2(
"u_maskPosition",
new Vector2(this.maskPosition.x, this.maskPosition.y)
);
Adicione o u_maskPosition
uniforme na parte superior do shader de fragmento
uniform vec2 u_maskPosition;
Por fim, refatorar a maskPosition
variável dessa maneira
vec3 maskPosition = vec2(
u_maskPosition.x * uPlaneRatio - 0.15,
u_maskPosition.y - 0.15
);
Nota lateral ; Eu ajustei o valor x
usando, uPlaneRatio
porque no início da main()
função eu fazia o mesmo com o uv
s do shader
E aqui você pode ver o resultado do seu trabalho duro:
Conclusão
Como você pode ver, fazer esse tipo de coisa não envolve muito código (~ 150 linhas de JavaScript e ~ 50 linhas de GLSL, incluindo comentários e linhas vazias); a parte mais difícil do WebGL é o fato de ser complexo por natureza, e é um assunto muito vasto, tão vasto que muitas vezes nem sei o que pesquisar no Google quando fico preso.
Além disso, você precisa estudar muito, muito mais do que com o desenvolvimento de sites “padrão”. Mas no final, é realmente divertido trabalhar com isso.
Neste tutorial, tentei explicar todo o processo (e o raciocínio por trás de tudo) passo a passo, assim como quero que alguém me explique; se você alcançou este ponto deste tutorial, significa que atingi minha meta.
De qualquer forma, obrigado!