Aprenda a aplicar um deslocamento de pixel/grade a uma textura no Three.js usando shaders e GPGPU com um efeito sutil de deslocamento RGB ao mover o cursor.
Neste tutorial, você aprenderá como criar um efeito de deslocamento de pixel/grade usando Three.js, aprimorado com shaders e técnicas GPGPU. O guia aborda a aplicação de um efeito de deslocamento RGB sutil que responde dinamicamente ao movimento do cursor. No final, você obterá uma sólida compreensão da manipulação de texturas e da criação de efeitos visuais interativos em WebGL, expandindo suas capacidades criativas com Three.js.
É recomendado que você tenha algum conhecimento básico de Three.js e WebGL para entender este tutorial. Vamos lá!
A configuração
Para criar esse efeito, precisaremos de duas texturas: a primeira é a imagem à qual queremos aplicar o efeito, e a segunda é uma textura contendo os dados para nosso efeito. Veja como a segunda textura ficará:
Primeiro, criaremos um plano Three.js básico com um ShaderMaterial que exibirá nossa imagem e a adicionará à nossa cena Three.js.
createGeometry() {
this.geometry = new THREE.PlaneGeometry(1, 1)
}
createMaterial() {
this.material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTexture: new THREE.Uniform(new THREE.Vector4()),
uContainerResolution: new THREE.Uniform(new THREE.Vector2(window.innerWidth, window.innerHeight)),
uImageResolution: new THREE.Uniform(new THREE.Vector2()),
},
})
}
setTexture() {
this.material.uniforms.uTexture.value = new THREE.TextureLoader().load(this.element.src, ({ image }) => {
const { naturalWidth, naturalHeight } = image
this.material.uniforms.uImageResolution.value = new THREE.Vector2(naturalWidth, naturalHeight)
})
}
createMesh() {
this.mesh = new THREE.Mesh(this.geometry, this.material)
}
Passei as dimensões da viewport para o uContainerResolution
uniforme porque minha malha ocupa todo o espaço da viewport. Se você quiser que sua imagem tenha um tamanho diferente, precisará passar a largura e a altura do elemento HTML que contém a imagem.
Aqui está o código do shader de vértices, que permanecerá inalterado, pois não vamos modificar os vértices.
varying vec2 vUv;
void main()
{
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectedPosition = projectionMatrix * viewPosition;
gl_Position = projectedPosition;
vUv=uv;
}
E aqui está o shader de fragmento inicial:
uniform sampler2D uTexture;
varying vec2 vUv;
uniform vec2 uContainerResolution;
uniform vec2 uImageResolution;
vec2 coverUvs(vec2 imageRes,vec2 containerRes)
{
float imageAspectX = imageRes.x/imageRes.y;
float imageAspectY = imageRes.y/imageRes.x;
float containerAspectX = containerRes.x/containerRes.y;
float containerAspectY = containerRes.y/containerRes.x;
vec2 ratio = vec2(
min(containerAspectX / imageAspectX, 1.0),
min(containerAspectY / imageAspectY, 1.0)
);
vec2 newUvs = vec2(
vUv.x * ratio.x + (1.0 - ratio.x) * 0.5,
vUv.y * ratio.y + (1.0 - ratio.y) * 0.5
);
return newUvs;
}
void main()
{
vec2 newUvs = coverUvs(uImageResolution,uContainerResolution);
vec4 image = texture2D(uTexture,newUvs);
gl_FragColor = image;
}
A coverUvs
função retorna um conjunto de UVs que farão o envoltório de textura da imagem se comportar como a object-fit: cover;
propriedade CSS. Aqui está o resultado:
Implementando Deslocamento com GPGPU
Agora vamos implementar a textura de deslocamento em um shader separado, e há uma razão para isso: não podemos depender dos shaders clássicos do Three.js para aplicar nosso efeito.
Como você viu no vídeo da textura de deslocamento, há uma trilha seguindo o movimento do mouse que desaparece lentamente quando o mouse sai da área. Não podemos criar esse efeito em nosso shader atual porque os dados não são persistentes. O shader roda em cada quadro usando suas entradas iniciais (uniformes e variáveis), e não há como acessar o estado anterior.
Felizmente, o Three.js fornece um utilitário chamado GPUComputationRenderer
. Ele nos permite gerar um shader de fragmento computado como uma textura e usar essa textura como entrada do nosso shader no próximo quadro. Isso é chamado de Buffer Texture. Veja como funciona:
Primeiro, vamos inicializar a GPUComputationRenderer
instância. Para isso, criarei uma classe chamada GPGPU.
import fragmentShader from '../shaders/gpgpu/gpgpu.glsl'
// the fragment shader we are going to use in the gpgpu
// ...class constructor
createGPGPURenderer() {
this.gpgpuRenderer = new GPUComputationRenderer(
this.size, //the size of the grid we want to create, in the example the size is 27
this.size,
this.renderer //the WebGLRenderer we are using for our scene
)
}
createDataTexture() {
this.dataTexture = this.gpgpuRenderer.createTexture()
}
createVariable() {
this.variable = this.gpgpuRenderer.addVariable('uGrid', fragmentShader, this.dataTexture)
this.variable.material.uniforms.uGridSize = new THREE.Uniform(this.size)
this.variable.material.uniforms.uMouse = new THREE.Uniform(new THREE.Vector2(0, 0))
this.variable.material.uniforms.uDeltaMouse = new THREE.Uniform(new THREE.Vector2(0, 0))
}
setRendererDependencies() {
this.gpgpuRenderer.setVariableDependencies(this.variable, [this.variable])
}
initiateRenderer() {
this.gpgpuRenderer.init()
}
Este é praticamente um código de instanciação genérico para uma GPUComputationRenderer
instância.
- Criamos a instância em
createGPGPURenderer
. - Criamos um
DataTexture
objeto emcreateDataTexture
, que será preenchido com o resultado do shader calculado. - Criamos uma “variável” em
createVariable
. Este termo é usado porGPUComputationRenderer
para se referir à textura que vamos produzir. Acho que é chamado assim porque nossa textura vai variar em cada quadro de acordo com nossos cálculos. - Definimos as dependências do GPGPU.
- Inicializamos nossa instância.
Agora vamos criar o shader de fragmento que nossa GPGPU usará.
void main()
{
vec2 uv = gl_FragCoord.xy/resolution.xy;
vec4 color = texture(uGrid,uv);
color.r = 1.;
gl_FragColor = color;
}
A textura atual que nossa GPGPU está criando é uma imagem vermelha simples. Observe que não tivemos que declarar uniform sampler2D uGrid
no cabeçalho do shader porque o declaramos como uma variável da GPUComputationRenderer
instância.
Agora vamos recuperar a textura e aplicá-la à nossa imagem.
Aqui está o código completo para nossa classe GPGPU.
constructor({ renderer, scene }: Props) {
this.scene = scene
this.renderer = renderer
this.params = {
size: 700,
}
this.size = Math.ceil(Math.sqrt(this.params.size))
this.time = 0
this.createGPGPURenderer()
this.createDataTexture()
this.createVariable()
this.setRendererDependencies()
this.initiateRenderer()
}
createGPGPURenderer() {
this.gpgpuRenderer = new GPUComputationRenderer(
this.size, //the size of the grid we want to create, in the example the size is 27
this.size,
this.renderer //the WebGLRenderer we are using for our scene
)
}
createDataTexture() {
this.dataTexture = this.gpgpuRenderer.createTexture()
}
createVariable() {
this.variable = this.gpgpuRenderer.addVariable('uGrid', fragmentShader, this.dataTexture)
this.variable.material.uniforms.uGridSize = new THREE.Uniform(this.size)
this.variable.material.uniforms.uMouse = new THREE.Uniform(new THREE.Vector2(0, 0))
this.variable.material.uniforms.uDeltaMouse = new THREE.Uniform(new THREE.Vector2(0, 0))
}
setRendererDependencies() {
this.gpgpuRenderer.setVariableDependencies(this.variable, [this.variable])
}
initiateRenderer() {
this.gpgpuRenderer.init()
}
getTexture() {
return this.gpgpuRenderer.getCurrentRenderTarget(this.variable).textures[0]
}
render() {
this.gpgpuRenderer.compute()
}
O render
método será chamado a cada quadro e getTexture
retornará nossa textura computada.
No material do primeiro plano que criamos, adicionaremos um uGrid
uniforme. Este uniforme conterá a textura recuperada pela GPGPU.
createMaterial() {
this.material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTexture: new THREE.Uniform(new THREE.Vector4()),
uContainerResolution: new THREE.Uniform(new THREE.Vector2(window.innerWidth, window.innerHeight)),
uImageResolution: new THREE.Uniform(new THREE.Vector2()),
//add this new Uniform
uGrid: new THREE.Uniform(new THREE.Vector4()),
},
})
}
Agora vamos atualizar esse uniforme em cada quadro após calcular a textura GPGPU,
render() {
this.gpgpu.render()
this.material.uniforms.uGrid.value = this.gpgpu.getTexture()
}
Agora, dentro do shader de fragmento do nosso primeiro plano de imagem, vamos exibir esta textura.
uniform sampler2D uGrid;
void main()
{
vec2 newUvs = coverUvs(uImageResolution,uContainerResolution);
vec4 image = texture2D(uTexture,newUvs);
vec4 displacement = texture2D(uGrid,newUvs);
gl_FragColor = displacement;
}
Você deve ver este resultado. É exatamente isso que queremos. Lembre-se, tudo o que nossa GPGPU está fazendo por enquanto é definir uma textura vazia para vermelho.
Manipulando o movimento do mouse
Agora vamos começar a trabalhar no efeito de deslocamento. Primeiro, precisamos rastrear o movimento do mouse e passá-lo como um uniforme para o shader GPGPU.
Criaremos um Raycaster e passaremos os UVs do mouse para a GPGPU. Como temos apenas uma malha em nossa cena para este exemplo, os únicos UVs que ele retornará serão aqueles do nosso plano contendo a imagem.
createRayCaster() {
this.raycaster = new THREE.Raycaster()
this.mouse = new THREE.Vector2()
}
onMouseMove(event: MouseEvent) {
this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1
this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
this.raycaster.setFromCamera(this.mouse, this.camera)
const intersects = this.raycaster.intersectObjects(this.scene.children)
const target = intersects[0]
if (target && 'material' in target.object) {
const targetMesh = intersects[0].object as THREE.Mesh
if(targetMesh && target.uv)
{
this.gpgpu.updateMouse(target.uv)
}
}
}
addEventListeners() {
window.addEventListener('mousemove', this.onMouseMove.bind(this))
}
Lembre-se de que no createVariable
método da GPGPU, atribuímos a ela um uniform uMouse
. Vamos atualizar esse uniform no updateMouse
método da classe GPGPU. Também atualizaremos o uDeltaMouse
uniform (precisaremos dele em breve).
updateMouse(uv: THREE.Vector2) {
const current = this.variable.material.uniforms.uMouse.value as THREE.Vector2
current.subVectors(uv, current)
this.variable.material.uniforms.uDeltaMouse.value = current
this.variable.material.uniforms.uMouse.value = uv
}
Agora, no shader de fragmento GPGPU, recuperaremos as coordenadas do mouse para calcular a distância entre cada pixel da textura e o mouse. Então, aplicaremos o delta do mouse à textura com base nessa distância.
uniform vec2 uMouse;
uniform vec2 uDeltaMouse;
void main()
{
vec2 uv = gl_FragCoord.xy/resolution.xy;
vec4 color = texture(uGrid,uv);
float dist = distance(uv,uMouse);
dist = 1.-(smoothstep(0.,0.22,dist));
color.rg+=uDeltaMouse*dist;
gl_FragColor = color;
}
Você deverá obter algo parecido com isto:
Observe que quando você move o cursor da esquerda para a direita, ele está colorindo, e quando você move da direita para a esquerda, você está apagando. Isso ocorre porque o delta dos UVs é negativo quando você vai da direita para a esquerda e positivo no sentido inverso.
Você pode ver onde isso vai dar. Obviamente, não vamos exibir nossa textura de deslocamento; queremos aplicá-la à nossa imagem inicial. A textura atual que temos está longe de ser perfeita, então não a usaremos ainda, mas você já pode testá-la em nossa imagem se quiser!
Tente isto no shader de fragmentos do seu plano:
void main()
{
vec2 newUvs = coverUvs(uImageResolution,uContainerResolution);
vec4 image = texture2D(uTexture,newUvs);
vec4 displacement = texture2D(uGrid,newUvs);
vec2 finalUvs = newUvs - displacement.rg*0.01;
vec4 finalImage = texture2D(uTexture,finalUvs);
gl_FragColor = finalImage;
}
Aqui está o que você deve obter:
O primeiro problema é que o formato do deslocamento não é um quadrado. Isso ocorre porque estamos usando os mesmos UVs para nosso deslocamento como para a imagem. Para consertar isso, daremos ao nosso deslocamento seus próprios UVs usando nossa coverUvs
função.
void main()
{
vec2 newUvs = coverUvs(uImageResolution,uContainerResolution);
vec2 squareUvs = coverUvs(vec2(1.),uContainerResolution);
vec4 image = texture2D(uTexture,newUvs);
vec4 displacement = texture2D(uGrid,squareUvs);
vec2 finalUvs = newUvs - displacement.rg*0.01;
vec4 finalImage = texture2D(uTexture,finalUvs);
gl_FragColor = finalImage;
}
Agora você deve ter um deslocamento em formato quadrado. Você pode exibir nossa textura novamente, já que ainda precisamos trabalhar nela. No gl_FragColor
shader do plano, defina o valor de volta para displacement
.
O maior problema que você pode ver claramente com nossa textura atual é que ela não está desaparecendo. Para consertar isso, vamos multiplicar a cor por um valor menor que 1, o que fará com que ela tenda progressivamente para 0.
//... gpgpu shader
color.rg+=uDeltaMouse*dist;
float uRelaxation = 0.965;
color.rg*=uRelaxation;
gl_FragColor = color;
Agora está um pouco melhor, mas ainda não é perfeito. Os pixels que estão mais próximos do cursor levam muito mais tempo para desaparecer. Isso ocorre porque eles acumularam muito mais cor, então demoram mais para chegar a 0. Para consertar isso, vamos adicionar um novo uniforme de flutuação.
Adicione isto na parte inferior do createVariable
método do GPGPU:
this.variable.material.uniforms.uMouseMove = new THREE.Uniform(0)
Em seguida, adicione isto no topo de updateMouse
:
updateMouse(uv: THREE.Vector2) {
this.variable.material.uniforms.uMouseMove.value = 1
// ... gpgpu.updateMouse
Em seguida, adicione isto ao método render do GPGPU:
render() {
this.variable.material.uniforms.uMouseMove.value *= 0.95
this.variable.material.uniforms.uDeltaMouse.value.multiplyScalar(0.965)
this.gpgpuRenderer.compute()
}
Agora você pode notar que as cores estão muito fracas. Isso ocorre porque o valor de uDeltaMouse
está desaparecendo muito rápido. Precisamos aumentá-lo no updateMouse
método:
updateMouse(uv: THREE.Vector2) {
this.variable.material.uniforms.uMouseMove.value = 1
const current = this.variable.material.uniforms.uMouse.value as THREE.Vector2
current.subVectors(uv, current)
current.multiplyScalar(80)
this.variable.material.uniforms.uDeltaMouse.value = current
this.variable.material.uniforms.uMouse.value = uv
}
Agora temos o efeito de deslocamento desejado:
Criando o efeito RGB Shift
Tudo o que resta fazer é o efeito de deslocamento RGB. Entender esse efeito é bem simples. Você provavelmente sabe que uma cor em GLSL é uma vec3
que contém os componentes vermelho, verde e azul de um fragmento. O que faremos é aplicar o deslocamento a cada cor individual da nossa imagem, mas com intensidades diferentes. Dessa forma, notaremos uma mudança entre as cores.
No shader de fragmento do plano, adicione este código logo antes dogl_FragColor = finalImage;
/*
* rgb shift
*/
//separate set of UVs for each color
vec2 redUvs = finalUvs;
vec2 blueUvs = finalUvs;
vec2 greenUvs = finalUvs;
//The shift will follow the displacement direction but with a reduced intensity,
//we need the effect to be subtle
vec2 shift = displacement.rg*0.001;
//The shift strength will depend on the speed of the mouse move,
//since the intensity rely on deltaMouse we just have to use the length of the (red,green) vector
float displacementStrength=length(displacement.rg);
displacementStrength = clamp(displacementStrength,0.,2.);
//We apply different strengths to each color
float redStrength = 1.+displacementStrength*0.25;
redUvs += shift*redStrength;
float blueStrength = 1.+displacementStrength*1.5;
blueUvs += shift*blueStrength;
float greenStrength = 1.+displacementStrength*2.;
greenUvs += shift*greenStrength;
float red = texture2D(uTexture,redUvs).r;
float blue = texture2D(uTexture,blueUvs).b;
float green = texture2D(uTexture,greenUvs).g;
//we apply the shift effect to our image
finalImage.r =red;
finalImage.g =green;
finalImage.b =blue;
gl_FragColor = finalImage;
E agora temos nosso efeito!