Wawa Sensei logo

Water Shader

Starter pack

L'été arrive (du moins au moment où j'écris cette leçon), il est temps d'organiser une fête à la piscine ! 🩳

Dans cette leçon, nous allons créer l'effet d'eau suivant en utilisant React Three Fiber et GLSL :

La mousse est plus dense autour des bords de la piscine et autour du canard.

Pour créer cet effet, nous découvrirons la bibliothèque de shaders Lygia pour simplifier la création de shaders et nous mettrons en pratique la technique du render target pour créer l’effet de mousse.

Pack de démarrage

Le pack de démarrage pour cette leçon inclut les ressources suivantes :

Le reste est une configuration simple d'éclairage et de caméra.

Starter pack

Une journée ensoleillée à la piscine 🏊

Water shader

L'eau est simplement une surface plane avec un <meshBasicMaterial /> appliqué dessus. Nous allons remplacer ce matériau par un shader personnalisé.

Créons un nouveau fichier WaterMaterial.jsx avec un modèle pour 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>
    }`
);

Notre matériau possède deux uniforms: uColor et uOpacity.

Pour pouvoir utiliser notre matériau personnalisé de manière déclarative, utilisons la fonction extend de @react-three/fiber dans le fichier main.jsx :

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

extend({ WaterMaterial });

// ...

Nous devons effectuer l'appel à extend depuis un fichier qui est importé avant le composant qui utilise le matériau personnalisé. De cette façon, nous pourrons utiliser le WaterMaterial de manière déclarative dans nos composants.

C'est pourquoi nous le faisons dans le fichier main.jsx plutôt que dans le fichier WaterMaterial.jsx.

Maintenant, dans Water.jsx, nous pouvons remplacer le <meshBasicMaterial /> par notre matériau personnalisé et ajuster les propriétés avec les uniforms correspondants :

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

Nous avons réussi à remplacer le matériau de base par notre shader material personnalisé.

Bibliothèque de shaders Lygia

Pour créer un effet de mousse animée, nous allons utiliser la bibliothèque de shaders Lygia. Cette bibliothèque simplifie la création de shaders en fournissant un ensemble d'utilitaires et de fonctions pour créer des shaders de manière plus déclarative.

La section qui nous intéressera est celle sur les génératifs. Elle contient un ensemble de fonctions utiles pour créer des effets génératifs comme le bruit, le curl, le fbm.

Bibliothèque de shaders Lygia

Dans la section générative, vous pouvez trouver la liste des fonctions disponibles.

En ouvrant l'une des fonctions, vous pouvez voir l'extrait de code à utiliser dans votre shader et un aperçu de l'effet.

Fonction de la bibliothèque de shaders Lygia

Page de la fonction pnoise

C'est l'effet que nous voulons utiliser. Vous pouvez voir dans l'exemple que, pour pouvoir utiliser la fonction pnoise, ils incluent le fichier shader pnoise. Nous allons faire de même.

Resolve Lygia

Pour pouvoir utiliser la bibliothèque de shaders Lygia dans notre projet, nous avons deux options :

  • Copier le contenu de la bibliothèque dans notre projet et importer les fichiers dont nous avons besoin. (Nous avons vu comment importer des fichiers GLSL dans la leçon d'introduction aux shaders)
  • Utiliser une bibliothèque nommée resolve-lygia qui résoudra la bibliothèque de shaders Lygia depuis le web et remplacera automatiquement les directives #include liées à lygia par le contenu des fichiers.

Selon votre projet, le nombre d'effets que vous souhaitez utiliser, et si vous utilisez d'autres bibliothèques de shaders, vous pouvez préférer l'une ou l'autre solution.

Dans cette leçon, nous allons utiliser la bibliothèque resolve-lygia. Pour l'installer, exécutez la commande suivante :

yarn add resolve-lygia

Ensuite, pour l'utiliser, nous devons simplement envelopper notre code fragment et/ou vertex shader avec la fonction 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>
    }`)
);

Nous n'aurons besoin d'utiliser la fonction resolveLygia que pour le fragment shader dans cette leçon.

Nous pouvons maintenant utiliser la fonction pnoise dans notre 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>
}

Nous multiplions le vUv par 10.0 pour rendre le bruit plus visible et nous utilisons la fonction pnoise pour créer l'effet de bruit. Nous mélangeons le uColor avec du noir en fonction de la valeur du bruit pour créer la couleur finale.

Lygia Shader library pnoise

Nous pouvons voir l'effet de bruit appliqué à l'eau.

⚠️ Resolve Lygia semble avoir des problèmes de temps d'arrêt de temps en temps. Si vous rencontrez des problèmes, vous pouvez utiliser la bibliothèque Glslify ou copier les fichiers de la bibliothèque de shaders Lygia dont vous avez besoin et utiliser les fichiers glsl comme nous l'avons fait dans la leçon d'introduction aux shaders.

Effet de mousse

L'effet de noise est la base de notre effet de mousse. Avant de peaufiner l'effet, créons les uniforms nécessaires pour avoir un contrôle total sur l'effet de mousse.

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

Nous avons ajouté les uniforms suivants :

  • uTime : pour animer l'effet de mousse
  • uSpeed : pour contrôler la vitesse de l'animation de l'effet
  • uRepeat : pour ajuster l'échelle de l'effet de noise
  • uNoiseType : pour passer d'une fonction de noise à l'autre
  • uFoam : pour contrôler le seuil à partir duquel l'effet de mousse commence
  • uFoamTop : pour contrôler le seuil à partir duquel la mousse devient plus dense

Nous devons maintenant appliquer ces uniforms sur le 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>
  );
};

Nous pouvons maintenant créer la logique de mousse dans le shader.

Nous calculons notre temps ajusté en multipliant uTime par uSpeed:

float adjustedTime = uTime * uSpeed;

Ensuite, nous générons l'effet de noise en utilisant la fonction pnoise:

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

Nous appliquons l'effet de mousse en utilisant la fonction smoothstep:

noise = smoothstep(uFoam, uFoamTop, noise);

Ensuite, nous créons des couleurs plus claires pour représenter la mousse. Nous créons une intermediateColor et une topColor:

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

Nous ajustons la couleur en fonction de la valeur du noise:

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

Lorsque le noise est compris entre 0.01 et 1.0, la couleur sera la couleur intermédiaire. Lorsque le noise est supérieur ou égal à 1.0, la couleur sera la couleur du haut.

Voici le code final du 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;

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

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

Nous avons maintenant un joli effet de mousse sur l'eau.

End of lesson preview

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