Water Shader

Starter pack

Musim panas akan datang (setidaknya saat saya menulis pelajaran ini), saatnya membuat pesta di kolam! 🩳

Dalam pelajaran ini, kita akan membuat efek air berikut menggunakan React Three Fiber dan GLSL:

Busa lebih padat di sekitar tepi kolam dan di sekitar bebek.

Untuk membuat efek ini kita akan mempelajari Lygia Shader library untuk menyederhanakan pembuatan shader dan kita akan mempraktikkan render target technique untuk menciptakan efek busa.

Paket Awal

Paket awal untuk pelajaran ini mencakup aset-aset berikut:

Sisanya adalah pencahayaan sederhana dan pengaturan kamera.

Paket Awal

Hari yang cerah di kolam 🏊

Shader air

Air hanya berupa bidang datar sederhana dengan <meshBasicMaterial /> yang diterapkan padanya. Kita akan mengganti material ini dengan custom shader.

Mari kita buat file baru WaterMaterial.jsx dengan boilerplate untuk 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>
    }`
);

Material kita memiliki dua uniforms: uColor dan uOpacity.

Agar kita bisa menggunakan material kustom kita secara deklaratif, mari gunakan fungsi extend dari @react-three/fiber di file main.jsx:

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

extend({ WaterMaterial });

// ...

Kita perlu melakukan pemanggilan extend dari file yang diimpor sebelum komponen yang menggunakan material kustom. Dengan cara ini, kita dapat menggunakan WaterMaterial secara deklaratif di dalam komponen kita.

Itulah sebabnya kita melakukannya di file main.jsx dan bukan di file WaterMaterial.jsx.

Sekarang di Water.jsx, kita dapat mengganti <meshBasicMaterial /> dengan material kustom kita dan menyesuaikan properti dengan uniforms yang sesuai:

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

Kita berhasil mengganti material dasar dengan shader material kustom kita.

Lygia Shader Library

Untuk membuat efek busa yang animasi, kita akan menggunakan Lygia Shader library. Library ini mempermudah pembuatan shaders dengan menyediakan serangkaian utilitas dan fungsi untuk membuat shaders dengan cara yang lebih deklaratif.

Bagian yang akan menarik bagi kita adalah bagian generative. Bagian ini berisi serangkaian fungsi berguna untuk membuat efek generatif seperti noise, curl, fbm.

Lygia Shader library

Di dalam bagian generative, Anda dapat menemukan daftar fungsi yang tersedia.

Dengan membuka salah satu fungsinya, Anda dapat melihat potongan kode untuk menggunakannya dalam shader Anda dan pratinjau efeknya.

Lygia Shader library function

Halaman fungsi pnoise

Ini adalah efek yang ingin kita gunakan. Anda dapat melihat dalam contoh bahwa untuk bisa menggunakan fungsi pnoise, mereka menyertakan file shader pnoise. Kita akan melakukan hal yang sama.

Resolve Lygia

Agar dapat menggunakan Lygia Shader library dalam proyek kita, kita memiliki dua opsi:

  • Menyalin konten dari library ke dalam proyek kita dan mengimpor file yang kita butuhkan. (Kita telah melihat cara mengimpor file GLSL di pelajaran pengantar shaders)
  • Menggunakan library bernama resolve-lygia yang akan mengambil Lygia Shader library dari web dan secara otomatis menggantikan direktif #include terkait lygia dengan konten file tersebut.

Bergantung pada proyek Anda, seberapa banyak efek yang ingin Anda gunakan, dan jika Anda menggunakan library shader lainnya, Anda mungkin lebih memilih satu solusi daripada yang lainnya.

Dalam pelajaran ini, kita akan menggunakan library resolve-lygia. Untuk menginstalnya, jalankan perintah berikut:

yarn add resolve-lygia

Kemudian, untuk menggunakannya kita hanya perlu membungkus kode fragment dan/atau vertex shader kita dengan fungsi 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>
    }`)
);

Kita hanya perlu menggunakan fungsi resolveLygia untuk fragment shader dalam pelajaran ini.

Kita sekarang dapat menggunakan fungsi pnoise dalam shader kita:

#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>
}

Kita mengalikan vUv dengan 10.0 untuk membuat noise lebih terlihat dan kita menggunakan fungsi pnoise untuk menciptakan efek noise. Kita mencampur uColor dengan hitam berdasarkan nilai noise untuk menciptakan warna akhir.

Lygia Shader library pnoise

Kita dapat melihat efek noise diterapkan pada air.

⚠️ Resolve Lygia tampaknya mengalami beberapa masalah downtime dari waktu ke waktu. Jika Anda mengalami masalah, Anda dapat menggunakan Glslify library atau menyalin file dari Lygia Shader library yang Anda butuhkan dan menggunakan file glsl seperti yang kita lakukan di pelajaran pengantar shaders.

Efek Busa

Efek noise adalah dasar dari efek busa kita. Sebelum menyempurnakan efek ini, mari kita buat beberapa uniform yang akan kita butuhkan untuk memiliki kendali penuh atas efek busa tersebut.

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

Kami menambahkan uniform berikut:

  • uTime: untuk menganimasikan efek busa
  • uSpeed: untuk mengendalikan kecepatan animasi efek
  • uRepeat: untuk menskalakan efek noise
  • uNoiseType: untuk beralih antara berbagai fungsi noise
  • uFoam: untuk mengontrol ambang saat efek busa dimulai
  • uFoamTop: untuk mengontrol ambang di mana busa menjadi lebih pekat

Sekarang kita perlu menerapkan uniform ini pada 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>
  );
};

Sekarang kita dapat membuat logika busa dalam shader.

Kita menghitung waktu yang disesuaikan dengan mengalikan uTime dengan uSpeed:

float adjustedTime = uTime * uSpeed;

Kemudian kita menghasilkan efek noise menggunakan fungsi pnoise:

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

Kita menerapkan efek busa menggunakan fungsi smoothstep:

noise = smoothstep(uFoam, uFoamTop, noise);

Kemudian, kita menciptakan warna yang lebih terang untuk merepresentasikan busa. Kita menciptakan intermediateColor dan topColor:

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

Kita menyesuaikan warna berdasarkan nilai noise:

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

Ketika noise berada di antara 0.01 dan 1.0, warnanya akan menjadi warna perantara. Ketika noise berada di atas atau sama dengan 1.0, warnanya akan menjadi warna puncak.

Berikut adalah kode shader akhirnya:

#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>
}

Kita sekarang memiliki efek busa yang indah di air.

Voronoi noise

Kita dapat beralih antara fungsi noise yang berbeda dengan mengubah uniform uNoiseType. Mari tambahkan fungsi Voronoi noise ke shader kita:

  #include "lygia/generative/pnoise.glsl"
  #include "lygia/generative/voronoise.glsl"

  // ...

Untuk mendapatkan contoh yang berfungsi dari fungsi Voronoi noise, anda dapat mengunjungi halaman Voronoise di situs web perpustakaan Lygia Shader. Kemudian sesuaikan kode dengan uniform dan logika Anda.

Mari terapkan secara kondisional salah satu fungsi noise berdasarkan uniform uNoiseType:

// ...
// NOISE GENERATION
float noise = 0.0;
if (uNoiseType == 0) {
  noise = pnoise(vec3(vUv * uRepeat, adjustedTime * 0.5), vec3(100.0, 24.0, 112.0));
} else if (uNoiseType == 1) {
  vec2 p = 0.5 - 0.5*cos(adjustedTime *vec2(1.0,0.5));
  p = p*p*(3.0-2.0*p);
  p = p*p*(3.0-2.0*p);
  p = p*p*(3.0-2.0*p);
  noise = voronoise(vec3(vUv * uRepeat, adjustedTime), p.x, 1.0);
}
// ...

Kita sekarang memiliki efek busa alternatif yang bagus menggunakan fungsi Voronoi noise. Berkat kontrol kita dapat menyesuaikan efek ini secara real-time.

Kepadatan Busa di Sekitar Tepi

Untuk membuatnya lebih realistis, kita akan membuat busa lebih padat di sekitar tepi kolam dan benda lain yang berpotongan dengan air.

Untuk mencapai ini, kita akan menggunakan render target untuk menghitung jarak suatu objek ke kamera kita. Kemudian kita akan menggunakan jarak ini untuk menyesuaikan kepadatan busa.

Tekstur Kedalaman

Untuk menyimpan informasi kedalaman, kita perlu membuat MeshDepthMaterial dan me-render scene ke dalam render target.

Mari deklarasikan material kedalaman kita dalam Water.jsx:

// ...
import {
  // ...
  FloatType,
  MeshDepthMaterial,
  NoBlending,
  RGBADepthPacking,
} from "three";

const depthMaterial = new MeshDepthMaterial();
depthMaterial.depthPacking = RGBADepthPacking;
depthMaterial.blending = NoBlending;
// ...

Kami mengatur depthPacking ke RGBADepthPacking untuk menyimpan kedalaman dalam saluran RGB dalam tekstur. Kami mengatur blending ke NoBlending untuk menghindari penggabungan nilai kedalaman.

Kemudian kita perlu membuat render target, referensi dari mesh air kita untuk dapat menyembunyikannya saat menghitung kedalaman, dan kontrol maxDepth untuk menyesuaikan hingga kedalaman mana kita ingin menghitung jaraknya:

// ...

export const Water = ({ ...props }) => {
  // ...
  const {
    // ...
    maxDepth,
  } = useControls({
    // ...
    maxDepth: { value: 2, min: 0, max: 5 },
  });

  const renderTarget = useFBO({
    depth: true,
    type: FloatType,
  });

  const waterRef = useRef();

  // ...
  return (
    <mesh {...props} ref={waterRef}>
      <planeGeometry args={[15, 32, 22, 22]} />
      <waterCellShading
        // ...
        uMaxDepth={maxDepth}
      />
    </mesh>
  );
};

Dan di dalam useFrame kita akan me-render scene ke render target dan mengatur uniform yang kita butuhkan dalam shader kita:

// ...

export const Water = ({ ...props }) => {
  // ...

  useFrame(({ gl, scene, camera, clock }) => {
    // Kita sembunyikan mesh air dan me-render scene ke render target
    waterRef.current.visible = false;
    gl.setRenderTarget(renderTarget);
    scene.overrideMaterial = depthMaterial; // Ini menggantikan material semua mesh dalam scene untuk menyimpan nilai kedalaman di dalam render target
    gl.render(scene, camera);

    // Kita reset scene dan menampilkan mesh air
    scene.overrideMaterial = null; // Komentari baris ini jika Anda ingin melihat apa yang terjadi di render target
    waterRef.current.visible = true;
    gl.setRenderTarget(null);

    // Kita atur uniform
    if (waterMaterialRef.current) {
      // ...
      waterMaterialRef.current.uniforms.uDepth.value = renderTarget.texture;
      const pixelRatio = gl.getPixelRatio();
      waterMaterialRef.current.uniforms.uResolution.value = [
        window.innerWidth * pixelRatio,
        window.innerHeight * pixelRatio,
      ];
      waterMaterialRef.current.uniforms.uCameraNear.value = camera.near;
      waterMaterialRef.current.uniforms.uCameraFar.value = camera.far;
    }
  });

  // ...
};

Kita mengatur uniform uDepth, uMaxDepth, uResolution, uCameraNear, dan uCameraFar dalam shader yang akan kita gunakan untuk menghitung jaraknya. Mari deklarasikan uniform ini dalam shader:

WaterMaterial.jsx:

// ...
export const WaterMaterial = shaderMaterial(
  {
    // ...
    uDepth: null,
    uMaxDepth: 1.0,
    uResolution: [0, 0],
    uCameraNear: 0,
    uCameraFar: 0,
  },
  /*glsl*/ `
  // ...
  `,
  resolveLygia(/*glsl*/ ` 
    // ...
    uniform sampler2D uDepth;
    uniform float uMaxDepth;
    uniform vec2 uResolution;
    uniform float uCameraNear;
    uniform float uCameraFar;
    // ...
    
    `)
);

Sempurna, kita memiliki semua yang kita butuhkan untuk mendapatkan nilai kedalaman dari render target.

Dalam fragment shader kita, kita akan menghitung jarak dari fragmen saat ini ke kamera:

// ...
#include <packing>

float getViewZ(const in float depth) {
  return perspectiveDepthToViewZ(depth, uCameraNear, uCameraFar);
}

float getDepth(const in vec2 screenPosition ) {
  return unpackRGBAToDepth(texture2D(uDepth, screenPosition));
}

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

  // PEMBUATAN NOISE
  // ...


  // KEDALAMAN
  vec2 screenUV = gl_FragCoord.xy / uResolution;
  float fragmentLinearEyeDepth = getViewZ(gl_FragCoord.z);
  float linearEyeDepth = getViewZ(getDepth(screenUV));

  float depth = fragmentLinearEyeDepth - linearEyeDepth;
  // ...

  if (depth > uMaxDepth) {
    finalColor = vec3(1.0, 0.0, 0.0);
  } else {
    finalColor = vec3(depth);
  }
  gl_FragColor = vec4(finalColor, uOpacity);
}

Dalam contoh ini, kita mewarnai fragmen merah jika kedalaman di atas uMaxDepth, sebaliknya, kita mewarnai fragmennya berdasarkan nilai kedalaman untuk memvisualisasikan nilainya.

Kita dapat melihat nilai kedalaman divisualisasikan dalam air.

Ketika kedalaman melebihi uMaxDepth, kita akan mengabaikan nilai-nilai tersebut tetapi ketika kita berada antara 0 (hitam) dan uMaxDepth, kita akan menyesuaikan kepadatan busa dari sangat tinggi ke halus:

// KEDALAMAN
// ...

float depth = fragmentLinearEyeDepth - linearEyeDepth;
noise += smoothstep(uMaxDepth, 0.0, depth);

Kita dapat menghapus visualisasi warna kedalaman dan kita sudah siap:

// if (depth > uMaxDepth) {
//   finalColor = vec3(1.0, 0.0, 0.0);
// } else {
//   finalColor = vec3(depth);
// }

Sekarang kita memiliki efek kepadatan busa yang bagus di sekitar tepi bebek dan kolam 🎉

Kesimpulan

Kita telah mempelajari cara membuat shader air canggih menggunakan React Three Fiber dan GLSL.

Kami menggunakan perpustakaan Lygia Shader untuk memudahkan pembuatan shader dan mempraktikkan teknik render target untuk menciptakan efek yang disesuaikan dengan baik.

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.