Water Shader

Starter pack

El verano se acerca (al menos cuando estoy escribiendo esta lección), ¡es hora de una fiesta en la piscina! 🩳

En esta lección, crearemos el siguiente efecto de agua usando React Three Fiber y GLSL:

La espuma es más densa alrededor de los bordes de la piscina y alrededor del pato.

Para crear este efecto descubriremos la Biblioteca de shaders Lygia para simplificar la creación de shaders y pondremos en práctica la técnica de render target para crear el efecto de espuma.

Paquete inicial

El paquete inicial para esta lección incluye los siguientes assets:

El resto es una configuración simple de iluminación y cámara.

Paquete inicial

Un día soleado en la piscina 🏊

Water shader

El agua es simplemente un plano con un <meshBasicMaterial /> aplicado. Vamos a reemplazar este material con un shader personalizado.

Vamos a crear un nuevo archivo WaterMaterial.jsx con una plantilla para un shader material:

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>
    }`
);

Nuestro material tiene dos uniforms: uColor y uOpacity.

Para poder usar nuestro material personalizado de manera declarativa, vamos a usar la función extend de @react-three/fiber en el archivo main.jsx:

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

extend({ WaterMaterial });

// ...

Necesitamos hacer la llamada a extend desde un archivo que se importe antes del componente que usa el material personalizado. De esta manera, podremos usar el WaterMaterial de manera declarativa en nuestros componentes.

Es por eso que lo hacemos en el archivo main.jsx en lugar del archivo WaterMaterial.jsx.

Ahora en Water.jsx, podemos reemplazar el <meshBasicMaterial /> con nuestro material personalizado y ajustar las propiedades con los uniforms correspondientes:

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

Hemos reemplazado exitosamente el material básico con nuestro shader material personalizado.

Biblioteca de Shaders Lygia

Para crear un efecto de espuma animada, utilizaremos la biblioteca de Shaders Lygia. Esta biblioteca simplifica la creación de shaders proporcionando un conjunto de utilidades y funciones para crear shaders de una manera más declarativa.

La sección que nos interesará es la generativa. Contiene un conjunto de funciones útiles para crear efectos generativos como ruido, curl, fbm.

Lygia Shader library

En la sección generativa, puedes encontrar la lista de funciones disponibles.

Al abrir una de las funciones, puedes ver el fragmento de código para usarla en tu shader y una vista previa del efecto.

Lygia Shader library function

pnoise página de función

Este es el efecto que queremos usar. Puedes ver en el ejemplo que para poder usar la función pnoise, incluyen el archivo shader pnoise. Nosotros haremos lo mismo.

Resolver Lygia

Para poder usar la biblioteca de Shaders Lygia en nuestro proyecto, tenemos dos opciones:

  • Copiar el contenido de la biblioteca en nuestro proyecto e importar los archivos que necesitamos. (Hemos visto cómo importar archivos GLSL en la lección de introducción a shaders)
  • Usar una biblioteca llamada resolve-lygia que resolverá la biblioteca de Shaders Lygia desde la web y reemplazará automáticamente las directivas #include relacionadas con lygia con el contenido de los archivos.

Dependiendo de tu proyecto, cuántos efectos quieras usar y si estás usando otras bibliotecas de shaders, puede que prefieras una solución sobre la otra.

En esta lección, utilizaremos la biblioteca resolve-lygia. Para instalarla, ejecuta el siguiente comando:

yarn add resolve-lygia

Luego, para usarla simplemente necesitamos envolver nuestro código shader fragment y/o vertex con la función 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>
    }`)
);

Solo necesitaremos usar la función resolveLygia para el shader fragment en esta lección.

Ahora podemos usar la función pnoise en nuestro 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 el vUv por 10.0 para hacer el ruido más visible y usamos la función pnoise para crear el efecto de ruido. Mezclamos el uColor con negro basado en el valor del ruido para crear el color final.

Lygia Shader library pnoise

Vemos el efecto de ruido aplicado al agua.

⚠️ Resolve Lygia parece tener algunos problemas de disponibilidad de vez en cuando. Si encuentras algún problema, puedes usar la biblioteca Glslify o copiar los archivos de la biblioteca de Shaders Lygia que necesitas y usar los archivos glsl como lo hicimos en la lección de introducción a shaders.

Efecto de espuma

El efecto de ruido es la base de nuestro efecto de espuma. Antes de afinar el efecto, creemos los uniformes que necesitaremos para tener control total sobre el efecto 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>
    }`)
);

Hemos añadido los siguientes uniformes:

  • uTime: para animar el efecto de espuma
  • uSpeed: para controlar la velocidad de la animación del efecto
  • uRepeat: para escalar el efecto de ruido
  • uNoiseType: para cambiar entre diferentes funciones de ruido
  • uFoam: para controlar el umbral cuando comienza el efecto de espuma
  • uFoamTop : para controlar el umbral en el que la espuma se vuelve más densa

Ahora necesitamos aplicar estos uniformes en el 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>
  );
};

Ahora podemos crear la lógica de la espuma en el shader.

Calculamos nuestro tiempo ajustado multiplicando el uTime por uSpeed:

float adjustedTime = uTime * uSpeed;

Luego generamos el efecto de ruido usando la función pnoise:

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

Aplicamos el efecto de espuma usando la función smoothstep:

noise = smoothstep(uFoam, uFoamTop, noise);

Luego, creamos colores más brillantes para representar la espuma. Creamos un intermediateColor y un topColor:

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

Ajustamos el color basado en el valor del ruido:

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

Cuando el ruido está entre 0.01 y 1.0, el color será el color intermedio. Cuando el ruido es mayor o igual a 1.0, el color será el color superior.

Aquí está el código final del 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;

  // GENERACIÓN DE RUIDO
  float noise = pnoise(vec3(vUv * uRepeat, adjustedTime * 0.5), vec3(100.0, 24.0, 112.0));

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

  //  COLOR
  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>
}

Ahora tenemos un bonito efecto de espuma en el agua.

Three.js logoReact logo

React Three Fiber: The Ultimate Guide to 3D Web Development

✨ You have reached the end of the preview ✨

Go to the next level with Three.js and React Three Fiber!

Get full access to this lesson and the complete course when you enroll:

  • 🔓 Full lesson videos with no limits
  • 💻 Access to the final source code
  • 🎓 Course progress tracking & completion
  • 💬 Invite to our private Discord community
Unlock the Full Course – Just $85

One-time payment. Lifetime updates included.