Water Shader

Starter pack

O verão está chegando (pelo menos quando estou escrevendo esta lição), é hora de criar uma pool party! 🩳

Nesta lição, vamos criar o seguinte efeito de água usando React Three Fiber e GLSL:

A espuma é mais densa ao redor das bordas da piscina e ao redor do pato.

Para criar esse efeito, vamos descobrir a Lygia Shader library para simplificar a criação de shaders e colocaremos em prática a técnica de render target para criar o efeito de espuma.

Pacote inicial

O pacote inicial para esta lição inclui os seguintes assets:

O resto é uma configuração simples de iluminação e câmera.

Pacote inicial

Um dia ensolarado na piscina 🏊

Shader de água

A água é apenas um plano simples com um <meshBasicMaterial /> aplicado. Vamos substituir esse material com um shader customizado.

Vamos criar um novo arquivo WaterMaterial.jsx com um boilerplate para um material de shader:

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

export const WaterMaterial = shaderMaterial(
  {
    uColor: new Color("skyblue"),
    uOpacity: 0.8,
  },
  /*glsl*/ `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  /*glsl*/ ` 
    varying vec2 vUv;
    uniform vec3 uColor;
    uniform float uOpacity;

    void main() {
      gl_FragColor = vec4(uColor, uOpacity);
      #include <tonemapping_fragment>
      #include <encodings_fragment>
    }`
);

Nosso material tem dois uniforms: uColor e uOpacity.

Para podermos usar nosso material customizado de forma declarativa, vamos usar a função extend de @react-three/fiber no arquivo main.jsx:

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

extend({ WaterMaterial });

// ...

Precisamos fazer a chamada do extend de um arquivo que é importado antes do componente que usa o material customizado. Dessa forma, poderemos usar o WaterMaterial de forma declarativa em nossos componentes.

É por isso que fazemos isso no arquivo main.jsx em vez do arquivo WaterMaterial.jsx.

Agora em Water.jsx, podemos substituir o <meshBasicMaterial /> com nosso material customizado e ajustar as propriedades com os respectivos uniforms:

import { useControls } from "leva";
import { Color } from "three";

export const Water = ({ ...props }) => {
  const { waterColor, waterOpacity } = useControls({
    waterOpacity: { value: 0.8, min: 0, max: 1 },
    waterColor: "#00c3ff",
  });

  return (
    <mesh {...props}>
      <planeGeometry args={[15, 32, 22, 22]} />
      <waterMaterial
        uColor={new Color(waterColor)}
        transparent
        uOpacity={waterOpacity}
      />
    </mesh>
  );
};

Substituímos com sucesso o material básico pelo nosso material de shader customizado.

Biblioteca de Shaders Lygia

Para criar um efeito de espuma animada, usaremos a biblioteca de Shaders Lygia. Esta biblioteca simplifica a criação de shaders fornecendo um conjunto de utilitários e funções para criar shaders de uma maneira mais declarativa.

A seção que nos interessará é a generative. Ela contém um conjunto de funções úteis para criar efeitos generativos, como noise, curl, fbm.

Biblioteca de Shaders Lygia

Dentro da seção generative, você pode encontrar a lista de funções disponíveis.

Ao abrir uma das funções, você pode ver um trecho de código para usá-la no seu shader e uma pré-visualização do efeito.

Função da Biblioteca de Shaders Lygia

Página da função pnoise

Este é o efeito que queremos usar. Você pode ver no exemplo que, para poder usar a função pnoise, eles incluem o arquivo shader pnoise. Faremos o mesmo.

Resolver Lygia

Para poder usar a biblioteca de Shaders Lygia em nosso projeto, temos duas opções:

  • Copiar o conteúdo da biblioteca para o nosso projeto e importar os arquivos que precisamos. (Vimos como importar arquivos GLSL na lição de introdução a shaders)
  • Usar uma biblioteca chamada resolve-lygia que resolverá a biblioteca de Shaders Lygia da web e substituirá automaticamente as diretivas #include relacionadas a lygia pelo conteúdo dos arquivos.

Dependendo do seu projeto, de quantos efeitos você quer usar, e se você está usando outras bibliotecas de shaders, pode preferir uma solução em detrimento da outra.

Nesta lição, usaremos a biblioteca resolve-lygia. Para instalá-la, execute o seguinte comando:

yarn add resolve-lygia

Em seguida, para utilizá-la, simplesmente precisamos envolver nosso código de shader fragment e/ou vertex com a função resolveLygia:

// ...
import { resolveLygia } from "resolve-lygia";

export const WaterMaterial = shaderMaterial(
  {
    uColor: new Color("skyblue"),
    uOpacity: 0.8,
  },
  /*glsl*/ `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  resolveLygia(/*glsl*/ ` 
    varying vec2 vUv;
    uniform vec3 uColor;
    uniform float uOpacity;

    void main() {
      gl_FragColor = vec4(uColor, uOpacity);
      #include <tonemapping_fragment>
      #include <encodings_fragment>
    }`)
);

Só precisaremos usar a função resolveLygia para o shader fragment nesta lição.

Agora podemos usar a função pnoise em nosso shader:

#include "lygia/generative/pnoise.glsl"
varying vec2 vUv;
uniform vec3 uColor;
uniform float uOpacity;

void main() {
  float noise = pnoise(vec3(vUv * 10.0, 1.0), vec3(100.0, 24.0, 112.0));
  vec3 black = vec3(0.0);
  vec3 finalColor = mix(uColor, black, noise);
  gl_FragColor = vec4(finalColor, uOpacity);
  #include <tonemapping_fragment>
  #include <encodings_fragment>
}

Multiplicamos o vUv por 10.0 para tornar o noise mais visível e usamos a função pnoise para criar o efeito de noise. Misturamos a uColor com preto com base no valor do noise para criar a cor final.

Efeito de noise da Biblioteca de Shaders Lygia

Podemos ver o efeito de noise aplicado à água.

⚠️ O Resolve Lygia parece ter alguns problemas de downtime de tempos em tempos. Se você encontrar algum problema, pode usar a biblioteca Glslify ou copiar os arquivos da biblioteca de Shaders Lygia que você precisa e usar os arquivos glsl como fizemos na lição de introdução a shaders.

Efeito de espuma

O efeito de noise é a base do nosso efeito de espuma. Antes de ajustar finamente o efeito, vamos criar os uniforms que precisaremos para ter total controle sobre o efeito de espuma.

WaterMaterial.jsx:

import { shaderMaterial } from "@react-three/drei";
import { resolveLygia } from "resolve-lygia";
import { Color } from "three";

export const WaterMaterial = shaderMaterial(
  {
    uColor: new Color("skyblue"),
    uOpacity: 0.8,
    uTime: 0,
    uSpeed: 0.5,
    uRepeat: 20.0,
    uNoiseType: 0,
    uFoam: 0.4,
    uFoamTop: 0.7,
  },
  /*glsl*/ `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }`,
  resolveLygia(/*glsl*/ ` 
    #include "lygia/generative/pnoise.glsl"
    varying vec2 vUv;
    uniform vec3 uColor;
    uniform float uOpacity;
    uniform float uTime;
    uniform float uSpeed;
    uniform float uRepeat;
    uniform int uNoiseType;
    uniform float uFoam;
    uniform float uFoamTop;

    void main() {
      float noise = pnoise(vec3(vUv * 10.0, 1.0), vec3(100.0, 24.0, 112.0));
      vec3 black = vec3(0.0);
      vec3 finalColor = mix(uColor, black, noise);
      gl_FragColor = vec4(finalColor, uOpacity);
      #include <tonemapping_fragment>
      #include <encodings_fragment>
    }`)
);

Adicionamos os seguintes uniforms:

  • uTime: para animar o efeito de espuma
  • uSpeed: para controlar a velocidade da animação do efeito
  • uRepeat: para escalar o efeito de noise
  • uNoiseType: para alternar entre diferentes funções de noise
  • uFoam: para controlar o limiar quando o efeito de espuma começa
  • uFoamTop : para controlar o limiar em que a espuma fica mais densa

Agora precisamos aplicar esses uniforms no material. Water.jsx:

import { useFrame } from "@react-three/fiber";
import { useControls } from "leva";
import { useRef } from "react";
import { Color } from "three";

export const Water = ({ ...props }) => {
  const waterMaterialRef = useRef();
  const { waterColor, waterOpacity, speed, noiseType, foam, foamTop, repeat } =
    useControls({
      waterOpacity: { value: 0.8, min: 0, max: 1 },
      waterColor: "#00c3ff",
      speed: { value: 0.5, min: 0, max: 5 },
      repeat: {
        value: 30,
        min: 1,
        max: 100,
      },
      foam: {
        value: 0.4,
        min: 0,
        max: 1,
      },
      foamTop: {
        value: 0.7,
        min: 0,
        max: 1,
      },
      noiseType: {
        value: 0,
        options: {
          Perlin: 0,
          Voronoi: 1,
        },
      },
    });

  useFrame(({ clock }) => {
    if (waterMaterialRef.current) {
      waterMaterialRef.current.uniforms.uTime.value = clock.getElapsedTime();
    }
  });

  return (
    <mesh {...props}>
      <planeGeometry args={[15, 32, 22, 22]} />
      <waterMaterial
        ref={waterMaterialRef}
        uColor={new Color(waterColor)}
        transparent
        uOpacity={waterOpacity}
        uNoiseType={noiseType}
        uSpeed={speed}
        uRepeat={repeat}
        uFoam={foam}
        uFoamTop={foamTop}
      />
    </mesh>
  );
};

Agora podemos criar a lógica da espuma no shader.

Calculamos nosso tempo ajustado multiplicando o uTime por uSpeed:

float adjustedTime = uTime * uSpeed;

Então, geramos o efeito de noise usando a função pnoise:

float noise = pnoise(vec3(vUv * uRepeat, adjustedTime * 0.5), vec3(100.0, 24.0, 112.0));

Aplicamos o efeito de espuma usando a função smoothstep:

noise = smoothstep(uFoam, uFoamTop, noise);

Em seguida, criamos cores mais brilhantes para representar a espuma. Criamos uma intermediateColor e uma topColor:

vec3 intermediateColor = uColor * 1.8;
vec3 topColor = intermediateColor * 2.0;

Ajustamos a cor com base no valor do noise:

vec3 finalColor = uColor;
finalColor = mix(uColor, intermediateColor, step(0.01, noise));
finalColor = mix(finalColor, topColor, step(1.0, noise));

Quando o noise está entre 0.01 e 1.0, a cor será a cor intermediária. Quando o noise estiver acima ou igual a 1.0, a cor será a cor superior.

Aqui está o código final do shader:

#include "lygia/generative/pnoise.glsl"
varying vec2 vUv;
uniform vec3 uColor;
uniform float uOpacity;
uniform float uTime;
uniform float uSpeed;
uniform float uRepeat;
uniform int uNoiseType;
uniform float uFoam;
uniform float uFoamTop;

void main() {
  float adjustedTime = uTime * uSpeed;

  // GERAÇÃO DE NOISE
  float noise = pnoise(vec3(vUv * uRepeat, adjustedTime * 0.5), vec3(100.0, 24.0, 112.0));

  // ESPUMA
  noise = smoothstep(uFoam, uFoamTop, noise);

  // COR
  vec3 intermediateColor = uColor * 1.8;
  vec3 topColor = intermediateColor * 2.0;
  vec3 finalColor = uColor;
  finalColor = mix(uColor, intermediateColor, step(0.01, noise));
  finalColor = mix(finalColor, topColor, step(1.0, noise));

  gl_FragColor = vec4(finalColor, uOpacity);
  #include <tonemapping_fragment>
  #include <encodings_fragment>
}

Agora temos um bom efeito de espuma na água.

End of lesson preview

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