Water Shader

Starter pack

L'estate sta arrivando (almeno quando sto scrivendo questa lezione), è tempo di organizzare una piscina party! 🩳

In questa lezione, creeremo il seguente effetto acqua usando React Three Fiber e GLSL:

La schiuma è più densa intorno ai bordi della piscina e intorno alla papera.

Per creare questo effetto scopriremo la Lygia Shader library per semplificare la creazione di shader e metteremo in pratica la tecnica del render target per creare l'effetto schiuma.

Pacchetto iniziale

Il pacchetto iniziale per questa lezione include i seguenti asset:

Il resto è un semplice setup di illuminazione e camera.

Pacchetto iniziale

Una giornata di sole in piscina 🏊

Shader dell'acqua

L'acqua è semplicemente un piano con un <meshBasicMaterial /> applicato. Sostituiremo questo materiale con uno shader personalizzato.

Creiamo un nuovo file WaterMaterial.jsx con un boilerplate per uno 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>
    }`
);

Il nostro materiale ha due uniform: uColor e uOpacity.

Per poter utilizzare il nostro materiale personalizzato in modo dichiarativo, utilizziamo la funzione extend da @react-three/fiber nel file main.jsx:

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

extend({ WaterMaterial });

// ...

Dobbiamo eseguire la chiamata extend da un file che è importato prima del componente che utilizza il materiale personalizzato. In questo modo, saremo in grado di usare WaterMaterial in modo dichiarativo nei nostri componenti.

Ecco perché lo facciamo nel file main.jsx invece che nel file WaterMaterial.jsx.

Ora in Water.jsx, possiamo sostituire il <meshBasicMaterial /> con il nostro materiale personalizzato e regolare le proprietà con le uniform corrispondenti:

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

Siamo riusciti a sostituire il materiale base con il nostro shader material personalizzato.

Biblioteca Shader Lygia

Per creare un effetto di schiuma animata, useremo la biblioteca Lygia Shader. Questa biblioteca semplifica la creazione di shader fornendo una serie di utilità e funzioni per creare shader in modo più dichiarativo.

La sezione che ci interesserà è quella generativa. Contiene una serie di funzioni utili per creare effetti generativi come noise, curl, fbm.

Lygia Shader library

All'interno della sezione generativa, puoi trovare l'elenco delle funzioni disponibili.

Aprendo una delle funzioni, puoi vedere il frammento di codice per utilizzarla nel tuo shader e un'anteprima dell'effetto.

Lygia Shader library function

Pagina della funzione pnoise

Questo è l'effetto che vogliamo utilizzare. Puoi vedere nell'esempio che per poter utilizzare la funzione pnoise, includono il file shader pnoise. Faremo lo stesso.

Risolvere Lygia

Per poter utilizzare la biblioteca Lygia Shader nel nostro progetto, abbiamo due opzioni:

  • Copiare il contenuto della biblioteca nel nostro progetto e importare i file di cui abbiamo bisogno. (Abbiamo visto come importare file GLSL nella lezione introduttiva sugli shader)
  • Utilizzare una libreria chiamata resolve-lygia che risolverà la biblioteca Lygia Shader dal web e sostituirà automaticamente le direttive #include relative a lygia con il contenuto dei file.

A seconda del tuo progetto, di quanti effetti vuoi utilizzare e se stai usando altre librerie shader, potresti preferire una soluzione rispetto all'altra.

In questa lezione, utilizzeremo la libreria resolve-lygia. Per installarla, esegui il seguente comando:

yarn add resolve-lygia

Poi, per utilizzarla dobbiamo semplicemente avvolgere il nostro codice fragment e/o vertex shader con la funzione 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>
    }`)
);

In questa lezione avremo bisogno di utilizzare la funzione resolveLygia solo per lo shader fragment.

Possiamo ora utilizzare la funzione pnoise nel nostro 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>
}

Moltiplichiamo vUv per 10.0 per rendere il noise più visibile e utilizziamo la funzione pnoise per creare l'effetto noise. Mescoliamo uColor con il nero sulla base del valore del noise per creare il colore finale.

Lygia Shader library pnoise

Possiamo vedere l'effetto noise applicato all'acqua.

⚠️ Resolve Lygia sembra avere problemi di downtime di tanto in tanto. Se incontri problemi, puoi usare la libreria Glslify o copiare i file della biblioteca Lygia Shader di cui hai bisogno e utilizzare i file glsl come abbiamo fatto nella lezione introduttiva sugli shader.

Effetto Schiuma

L'effetto rumore è la base del nostro effetto schiuma. Prima di perfezionare l'effetto, creiamo gli uniforms necessari per avere il pieno controllo sull'effetto schiuma.

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

Abbiamo aggiunto i seguenti uniforms:

  • uTime: per animare l'effetto schiuma
  • uSpeed: per controllare la velocità dell'animazione dell'effetto
  • uRepeat: per scalare l'effetto rumore
  • uNoiseType: per passare tra diverse funzioni di rumore
  • uFoam: per controllare la soglia quando l'effetto schiuma inizia
  • uFoamTop: per controllare la soglia a cui la schiuma diventa più densa

Ora dobbiamo applicare questi uniforms sul 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>
  );
};

Possiamo ora creare la logica della schiuma nello shader.

Calcoliamo il nostro tempo corretto moltiplicando uTime per uSpeed:

float adjustedTime = uTime * uSpeed;

Poi generiamo l'effetto rumore utilizzando la funzione pnoise:

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

Applichiamo l'effetto schiuma utilizzando la funzione smoothstep:

noise = smoothstep(uFoam, uFoamTop, noise);

Successivamente, creiamo colori più brillanti per rappresentare la schiuma. Creiamo un intermediateColor e un topColor:

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

Adattiamo il colore in base al valore del rumore:

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

Quando il rumore è tra 0.01 e 1.0, il colore sarà il colore intermedio. Quando il rumore è superiore o uguale a 1.0, il colore sarà il colore superiore.

Ecco il codice finale dello 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;

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

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

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

Ora abbiamo un bel effetto schiuma sull'acqua.

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.