WebGPU / TSL

Starter pack

WebGPU è un nuovo standard web che fornisce un'API di basso livello per il rendering grafico e l'esecuzione di calcoli sulla GPU. È progettato come successore di WebGL, offrendo prestazioni migliori e funzionalità avanzate.

Ottime notizie, ora è possibile utilizzarlo con Three.js con modifiche minime alla base di codice.

In questa lezione, esploreremo come utilizzare WebGPU con Three.js e React Three Fiber, e come scrivere shader utilizzando il nuovo Three Shading Language (TSL).

Se sei nuovo agli shader, ti consiglio di completare prima il capitolo Shaders prima di continuare con questo.

WebGPU Renderer

Per utilizzare WebGPU API invece di WebGL, dobbiamo usare un WebGPURenderer (Nessuna sezione dedicata nella documentazione di Three.js ancora) invece di WebGLRenderer.

Con React Three Fiber, durante la creazione di un componente <Canvas>, la configurazione del renderer viene eseguita automaticamente. Tuttavia, possiamo sovrascrivere il renderer predefinito passando una funzione al prop gl del componente <Canvas>.

In App.jsx, abbiamo un componente <Canvas> che utilizza il WebGLRenderer predefinito. Modifichiamolo per usare invece il WebGPURenderer.

Innanzitutto, dobbiamo fermare il frameloop fino a quando il WebGPURenderer non è pronto. Possiamo farlo impostando il prop frameloop su never.

// ...
import { useState } from "react";

function App() {
  const [frameloop, setFrameloop] = useState("never");

  return (
    <>
      {/* ... */}
      <Canvas
        // ...
        frameloop={frameloop}
      >
        {/* ... */}
      </Canvas>
    </>
  );
}

export default App;

Successivamente, dobbiamo importare la versione WebGPU di Three.js:

import * as THREE from "three/webgpu";

Quando si utilizza WebGPU, è necessario utilizzare il modulo three/webgpu invece del modulo three predefinito. Questo perché il WebGPURenderer non è incluso nella build predefinita di Three.js.

Poi, possiamo usare il prop gl per creare una nuova istanza di WebGPURenderer:

// ...

function App() {
  const [frameloop, setFrameloop] = useState("never");

  return (
    <>
      {/* ... */}
      <Canvas
        // ...
        gl={(canvas) => {
          const renderer = new THREE.WebGPURenderer({
            canvas,
            powerPreference: "high-performance",
            antialias: true,
            alpha: false,
            stencil: false,
            shadowMap: true,
          });
          renderer.init().then(() => {
            setFrameloop("always");
          });
          return renderer;
        }}
      >
        {/* ... */}
      </Canvas>
    </>
  );
}
// ...

Creiamo una nuova istanza di WebGPURenderer e gli passiamo l'elemento canvas. Impostiamo anche alcune opzioni per il renderer, come powerPreference, antialias, alpha, stencil e shadowMap. Queste opzioni sono simili a quelle usate nel WebGLRenderer.

Infine, chiamiamo il metodo init() del renderer per inizializzarlo. Una volta completata l'inizializzazione, impostiamo lo stato frameloop su "always" per avviare il rendering.

Vediamo il risultato nel browser:

Il nostro cubo è ora renderizzato usando WebGPURenderer invece di WebGLRenderer.

È semplice così! Abbiamo configurato con successo un WebGPURenderer nella nostra applicazione React Three Fiber. Ora puoi usare la stessa API di Three.js per creare e manipolare oggetti 3D, proprio come faresti con WebGLRenderer.

Le maggiori modifiche si manifestano quando si tratta di scrivere shader. L'API WebGPU utilizza un linguaggio di shading diverso rispetto a WebGL, il che significa che dobbiamo scrivere i nostri shader in modo diverso. In WGSL invece che in GLSL.

È qui che entra in gioco il Three Shading Language (TSL).

Three Shading Language

TSL è un nuovo linguaggio di shading progettato per essere utilizzato con Three.js per scrivere shader in modo più user-friendly utilizzando un approccio basato su nodi.

Un grande vantaggio di TSL è che è agnostico rispetto al renderer, il che significa che puoi usare gli stessi shader con diversi renderer, come WebGL e WebGPU.

Questo rende più facile scrivere e mantenere shader, poiché non devi preoccuparti delle differenze tra i due linguaggi di shading.

È anche a prova di futuro poiché, se un nuovo renderer viene rilasciato, potremmo usare gli stessi shader senza nessun cambiamento fintanto che TSL lo supporta.

Il Three Shading Language è ancora in fase di sviluppo, ma è già disponibile nelle versioni più recenti di Three.js. Il modo migliore per apprenderlo e tenere traccia delle modifiche è controllare la pagina wiki di Three Shading Language. L'ho usata ampiamente per imparare come utilizzarlo.

Materiali basati su nodi

Per capire come creare shader con TSL, dobbiamo capire cosa significa essere basato su nodi.

In un approccio basato su nodi, creiamo shader collegando diversi nodi insieme per creare un grafico. Ogni nodo rappresenta un'operazione o una funzione specifica, e le connessioni tra i nodi rappresentano il flusso di dati.

Questo approccio offre molti vantaggi, come:

  • Rappresentazione visiva: È più facile capire e visualizzare il flusso di dati e operazioni in uno shader.
  • Riutilizzabilità: Possiamo creare nodi riutilizzabili che possono essere usati in diversi shader.
  • Flessibilità: Possiamo facilmente modificare e cambiare il comportamento di uno shader aggiungendo o rimuovendo nodi.
  • Estensibilità: Aggiungere/Personalizzare caratteristiche dai materiali esistenti ora è un gioco da ragazzi.
  • Agnostico: TSL genererà il codice appropriato per il renderer di destinazione, sia esso WebGL (GLSL) o WebGPU (WGSL).

Prima di iniziare a programmare il nostro primo materiale basato su nodi, possiamo utilizzare il playground online di Three.js per sperimentare il sistema di nodi in modo visivo.

Apri il playground di Three.js e in alto, clicca sul pulsante Examples, e scegli l'esempio basic > fresnel.

Three.js playground

Dovresti vedere un editor di materiali basato su nodi con due nodi color e un nodo float collegati a un nodo fresnel. (Color A, Color B e Fresnel Factor).

Il nodo fresnel è collegato al colore del Basic Material risultando nel colorare la Teapot con un effetto fresnel.

Three.js playground

Usa il pulsante Splitscreen per vedere il risultato a destra.

Supponiamo di voler influenzare l'opacità del Basic Material in base al tempo. Possiamo aggiungere un nodo Timer e collegarlo a un nodo Fract per azzerare il tempo a 0 una volta che raggiunge 1. Quindi lo colleghiamo all'input di opacity del Basic Material.

Ora la nostra teiera sta sfumando prima di scomparire e riapparire di nuovo.

Prenditi il tempo per giocare con i diversi nodi e vedere come influenzano il materiale.

Ora che abbiamo una comprensione di base di come funziona il materiale basato su nodi, vediamo come usare il nuovo materiale basato su nodi da Three.js in React Three Fiber.

Implementazione di React Three Fiber

Fino ad ora, con WebGL, abbiamo utilizzato MeshBasicMaterial, MeshStandardMaterial, o anche ShaderMaterial personalizzati per creare i nostri materiali.

Quando utilizziamo WebGPU dobbiamo usare nuovi materiali compatibili con TSL. I loro nomi sono gli stessi di quelli che usavamo prima con Node davanti a Material:

  • MeshBasicMaterial -> MeshBasicNodeMaterial
  • MeshStandardMaterial -> MeshStandardNodeMaterial
  • MeshPhysicalMaterial -> MeshPhysicalNodeMaterial
  • ...

Per utilizzarli in modo dichiarativo con React Three Fiber, dobbiamo extenderli. In App.jsx:

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

extend({
  MeshBasicNodeMaterial: THREE.MeshBasicNodeMaterial,
  MeshStandardNodeMaterial: THREE.MeshStandardNodeMaterial,
});
// ...

Nelle future versioni di React Three Fiber, questo potrebbe essere fatto automaticamente.

Ora possiamo utilizzare i nuovi MeshBasicNodeMaterial e MeshStandardNodeMaterial nei nostri componenti.

Sostituiamo il MeshStandardMaterial del cubo nel nostro componente Experience con il MeshStandardNodeMaterial:

<mesh>
  <boxGeometry args={[1, 1, 1]} />
  <meshStandardNodeMaterial color="pink" />
</mesh>

WebGPU Pink Cube

Possiamo usare il MeshStandardNodeMaterial allo stesso modo in cui utilizzeremmo il MeshStandardMaterial.

Il nostro cubo ora si basa sul MeshStandardNodeMaterial invece del MeshStandardMaterial. Ora possiamo usare i nodi per personalizzare il materiale.

Nodo Colore

Impariamo a creare nodi personalizzati per personalizzare i nostri materiali con TSL.

Per prima cosa, creiamo un nuovo componente chiamato PracticeNodeMaterial.jsx nella cartella src/components.

export const PracticeNodeMaterial = ({
  colorA = "white",
  colorB = "orange",
}) => {
  return <meshStandardNodeMaterial color={colorA} />;
};

E in Experience.jsx, sostituiamo il nostro cubo con un piano utilizzando il PracticeNodeMaterial:

// ...
import { PracticeNodeMaterial } from "./PracticeNodeMaterial";

export const Experience = () => {
  return (
    <>
      {/* ... */}

      <mesh rotation-x={-Math.PI / 2}>
        <planeGeometry args={[2, 2, 200, 200]} />
        <PracticeNodeMaterial />
      </mesh>
    </>
  );
};

WebGPU Plane

Ora abbiamo un piano con il PracticeNodeMaterial.

Per personalizzare il nostro materiale, possiamo ora alterare i diversi nodi a nostra disposizione usando vari nodi. La lista di quelli disponibili si trova nella pagina wiki.

Iniziamo semplicemente con il nodo colorNode per cambiare il colore del nostro materiale. In PracticeNodeMaterial.jsx:

import { color } from "three/tsl";

export const PracticeNodeMaterial = ({
  colorA = "white",
  colorB = "orange",
}) => {
  return <meshStandardNodeMaterial colorNode={color(colorA)} />;
};

Impostiamo la prop colorNode usando il nodo color dal modulo three/tsl. Il nodo color prende un colore come argomento e restituisce un nodo colore che può essere utilizzato nel materiale.

Questo ci dà lo stesso risultato di prima, ma ora possiamo aggiungere più nodi per personalizzare il nostro materiale.

Importiamo i nodi mix e uv dal modulo three/tsl e li usiamo per mescolare due colori basati sulle coordinate UV del piano.

import { color, mix, uv } from "three/tsl";

export const PracticeNodeMaterial = ({
  colorA = "white",
  colorB = "orange",
}) => {
  return (
    <meshStandardNodeMaterial
      colorNode={mix(color(colorA), color(colorB), uv())}
    />
  );
};

Esso eseguirà i diversi nodi per ottenere il colore finale del materiale. Il nodo mix prende due colori e un fattore (in questo caso, le coordinate UV) e restituisce un colore che è una miscela dei due colori basata sul fattore.

È esattamente lo stesso che usare la funzione mix in GLSL, ma ora possiamo usarla in un approccio basato su nodi. (Molto più leggibile!)

WebGPU Plane with Mix

Ora possiamo vedere i due colori mescolati basati sulle coordinate UV del piano.

La cosa incredibile è che non stiamo partendo da zero. Stiamo usando il MeshStandardNodeMaterial esistente e aggiungendo solo i nostri nodi personalizzati. Ciò significa che le ombre, le luci e tutte le altre caratteristiche del MeshStandardNodeMaterial sono ancora disponibili.

Dichiarare nodi inline va bene per una logica dei nodi molto semplice, ma per una logica più complessa, ti consiglio di dichiarare i nodi (e più tardi uniformi, e altro) in un hook useMemo:

// ...
import { useMemo } from "react";

export const PracticeNodeMaterial = ({
  colorA = "white",
  colorB = "orange",
}) => {
  const { nodes } = useMemo(() => {
    return {
      nodes: {
        colorNode: mix(color(colorA), color(colorB), uv()),
      },
    };
  }, []);

  return <meshStandardNodeMaterial {...nodes} />;
};

Questo fa esattamente la stessa cosa di prima, ma ora possiamo aggiungere più nodi all'oggetto nodes e passarli al meshStandardNodeMaterial in modo più organizzato/generico.

Cambiando le props colorA e colorB, non causerà una ricompilazione dello shader grazie all'hook useMemo.

Aggiungiamo i controlli per cambiare i colori del materiale. In Experience.jsx:

// ...
import { useControls } from "leva";

export const Experience = () => {
  const { colorA, colorB } = useControls({
    colorA: { value: "skyblue" },
    colorB: { value: "blueviolet" },
  });
  return (
    <>
      {/* ... */}

      <mesh rotation-x={-Math.PI / 2}>
        <planeGeometry args={[2, 2, 200, 200]} />
        <PracticeNodeMaterial colorA={colorA} colorB={colorB} />
      </mesh>
    </>
  );
};

Il colore di default funziona correttamente, ma aggiornare i colori non ha effetto.

Questo perché dobbiamo passare i colori come uniforms al meshStandardNodeMaterial.

Uniforms

Per dichiarare gli uniforms in TSL, possiamo usare il nodo uniform dal modulo three/tsl. Il nodo uniform prende un valore come argomento (può essere di diversi tipi come float, vec3, vec4, ecc.) e restituisce un nodo uniform che può essere utilizzato nei diversi nodi mentre viene aggiornato dal nostro codice componente.

Passiamo i colori hardcoded a uniforms in PracticeNodeMaterial.jsx:

// ...
import { uniform } from "three/tsl";

export const PracticeNodeMaterial = ({
  colorA = "white",
  colorB = "orange",
}) => {
  const { nodes, uniforms } = useMemo(() => {
    const uniforms = {
      colorA: uniform(color(colorA)),
      colorB: uniform(color(colorB)),
    };

    return {
      nodes: {
        colorNode: mix(uniforms.colorA, uniforms.colorB, uv()),
      },
      uniforms,
    };
  }, []);

  return <meshStandardNodeMaterial {...nodes} />;
};

Dichiariamo un oggetto uniforms per una migliore organizzazione del codice, e usiamo i valori uniform al posto del valore di default che abbiamo ottenuto alla creazione dei nostri nodi.

Restituendoli in useMemo ora abbiamo accesso agli uniforms nel nostro componente.

In un useFrame possiamo aggiornare gli uniforms:

// ...
import { useFrame } from "@react-three/fiber";

export const PracticeNodeMaterial = ({
  colorA = "white",
  colorB = "orange",
}) => {
  // ...

  useFrame(() => {
    uniforms.colorA.value.set(colorA);
    uniforms.colorB.value.set(colorB);
  });

  return <meshStandardNodeMaterial {...nodes} />;
};

Usa il metodo value.set quando aggiorni un object uniform. Ad esempio, uniforms color o vec3. Per gli uniforms float, devi impostare il valore direttamente: uniforms.opacity.value = opacity;

Ora i colori si aggiornano correttamente in tempo reale.

Prima di fare di più con il colore, vediamo come possiamo influenzare la posizione dei vertici del nostro piano utilizzando il positionNode.

Nodo di Posizione

Il nodo positionNode ci consente di influenzare la posizione dei vertici della nostra geometria.

Grazie al sistema di nodi, non abbiamo bisogno di riscrivere l'intero vertex shader. Possiamo semplicemente aggiungere un nodo positionNode al nostro material e questo modificherà le posizioni dei vertici nello spazio locale.

Fai riferimento alla pagina Wiki ogni volta che hai bisogno di controllare i nodi disponibili o cosa fanno.

Aggiungiamo un positionNode al nostro oggetto nodes:

// ...
import { randFloat } from "three/src/math/MathUtils.js";
import { positionLocal } from "three/tsl";

export const PracticeNodeMaterial = ({
  colorA = "white",
  colorB = "orange",
}) => {
  const { nodes, uniforms } = useMemo(() => {
    // ...

    const randHeight = randFloat(0, 1);

    const finalPosition = positionLocal.add(vec3(0, 0, randHeight));

    return {
      nodes: {
        // ...
        positionNode: finalPosition,
      },
      uniforms,
    };
  }, []);

  // ...

  return <meshStandardNodeMaterial {...nodes} />;
};

Importiamo il nodo positionLocal dal modulo three/tsl per ottenere la posizione locale del vertice. Quindi aggiungiamo un'altezza casuale ad esso usando il nodo vec3.

Questo modificherà la posizione dei vertici del nostro piano in base all'altezza casuale.

Piano WebGPU con Nodo di Posizione

Se ricarichi la pagina, vedrai che il piano ora sta modificando casualmente l'intero piano, ma ti aspettavi che fosse così?

Ti aspettavi che ogni vertice fosse modificato di una quantità diversa? Il nostro codice non è scritto per farlo. Stiamo usando randFloat per generare un numero casuale tra 0 e 1, ma viene chiamato solo una volta quando il componente viene montato. Questo significa che tutti i vertici sono spostati della stessa quantità.

Non possiamo eseguire il codice js nello shader. Dobbiamo utilizzare i nodi per generare un numero casuale per ciascun vertice.

La sezione random nel wiki ha una funzione chiamata hash che utilizza un valore di seed per generare un numero casuale. Possiamo usarlo per generare un numero casuale per ciascun vertice:

// ...
import { hash, vertexIndex } from "three/tsl";

export const PracticeNodeMaterial = ({
  colorA = "white",
  colorB = "orange",
}) => {
  const { nodes, uniforms } = useMemo(() => {
    // ...

    const randHeight = hash(vertexIndex);

    // ...
  }, []);

  // ...
};

Abbiamo bisogno di un valore univoco per il parametro seed. (Altrimenti, otterremo lo stesso numero casuale per tutti i vertici.) Il nodo vertexIndex è un buon candidato in quanto restituisce l'indice del vertice nella geometria.

Piano WebGPU con Nodo di Posizione

Il piano ora sposta ogni vertice di una quantità diversa.

Possiamo ridurre l'effetto moltiplicando randHeight per un valore:

const randHeight = hash(vertexIndex);
const offset = randHeight.mul(0.3);

const finalPosition = positionLocal.add(vec3(0, 0, offset));

Possiamo anche regolare il colore basandoci sull'altezza e non sulle coordinate UV. Possiamo usare il nodo mix per mescolare i due colori in base all'altezza del vertice.

// ...

export const PracticeNodeMaterial = ({
  colorA = "white",
  colorB = "orange",
}) => {
  const { nodes, uniforms } = useMemo(() => {
    // ...

    const randHeight = hash(vertexIndex);
    // ...

    const finalColor = mix(uniforms.colorA, uniforms.colorB, randHeight);

    return {
      nodes: {
        colorNode: finalColor,
        // ...
      },
      uniforms,
    };
  }, []);

  // ...
};

Piano WebGPU con Nodo di Posizione e Nodo di Colore

Il colore è ora basato sull'altezza del vertice e l'altezza è meno pronunciata. (La renderemo meno casuale in seguito.)

Funzioni Nodo Personalizzate

Come abbiamo fatto per finalPosition e finalColor, possiamo combinare i nodi concatenandoli insieme. Ma possiamo anche creare le nostre funzioni nodo personalizzate per una migliore organizzazione del codice e anche per funzionalità aggiuntive come le condizioni.

Facciamo in modo che il nostro colore del material lampeggi nel tempo. Per prima cosa, creiamo una funzione blink che prende come parametri time e speed:

// ...
import { Fn, time } from "three/tsl";

export const PracticeNodeMaterial = ({
  // ...
  blinkSpeed = 1,
}) => {
  const { nodes, uniforms } = useMemo(() => {
    const uniforms = {
      // ...
      blinkSpeed: uniform(blinkSpeed),
    };

    // ...

    const blink = Fn(([t, speed]) => {
      return t.mul(speed).sin().mul(0.5).add(0.5);
    });

    const finalColor = mix(uniforms.colorA, uniforms.colorB, randHeight).mul(
      blink(time, uniforms.blinkSpeed)
    );

    return {
      nodes: {
        colorNode: finalColor,
        positionNode: finalPosition,
      },
      uniforms,
    };
  }, []);

  useFrame(() => {
    // ...
    uniforms.blinkSpeed.value = blinkSpeed;
  });

  // ...
};

Creiamo una funzione blink utilizzando il nodo Fn dal modulo three/tsl. Il nodo Fn prende una funzione come argomento e restituisce un nodo che può essere utilizzato nel materiale.

Restituisce un valore tra 0 e 1 utilizzando la funzione sin.

Non dimenticare di aggiungere la variabile uniforme blinkSpeed nel hook useFrame. time è un nodo incorporato che restituisce il tempo trascorso.

Moltiplichiamo il colore finale per il valore blink per farlo lampeggiare nel tempo.

La superficie ora lampeggia nel tempo.

Ora supponiamo di voler far lampeggiare solo una parte della superficie. Potremmo usare la funzione step e il nodo uv per creare una maschera per l'effetto lampeggiante.

Ma possiamo anche usare una condizione che ci permette di avere un comportamento completamente diverso.

// ...
import { If, max, uv } from "three/tsl";

export const PracticeNodeMaterial = ({
  colorA = "white",
  colorB = "orange",
  blinkSpeed = 1,
}) => {
  const { nodes, uniforms } = useMemo(() => {
    // ...

    const blink = Fn(([t, speed]) => {
      const blinkValue = t.mul(speed).sin().mul(0.5).add(0.5).toVar();
      If(uv().x.greaterThan(0.5), () => {
        blinkValue.assign(t.mul(speed).cos().mul(0.5).add(0.5));
      });
      return blinkValue;
    });

    const finalColor = mix(uniforms.colorA, uniforms.colorB, randHeight).mul(
      max(0.15, blink(time, uniforms.blinkSpeed))
    );

    return {
      nodes: {
        colorNode: finalColor,
        positionNode: finalPosition,
      },
      uniforms,
    };
  }, []);

  // ...

  return <meshStandardNodeMaterial {...nodes} />;
};

Ricorda, non possiamo usare istruzioni if nello shader. Dobbiamo usare il nodo If dal modulo three/tsl.

Il nodo If prende una condizione come argomento e una funzione da eseguire se la condizione è vera. Possiamo usarlo per cambiare il valore della variabile blinkValue in base alle coordinate UV del vertice.

In questo caso, verifichiamo se la coordinata uv().x è maggiore di 0.5. Se lo è, assegniamo un nuovo valore alla variabile blinkValue utilizzando il metodo assign. Questo farà lampeggiare diversamente la parte sinistra della superficie rispetto alla parte destra.

Il nodo max viene utilizzato per impostare un valore minimo per l'effetto di lampeggio.

La parte sinistra del piano ora lampeggia in modo asincrono rispetto alla parte destra e il piano non diventerà completamente nero.

Va bene, questo non è il miglior effetto che potremmo fare. Ma è solo per mostrare come utilizzare il nodo If.

Regoliamo il nostro materiale per generare un effetto più interessante usando il rumore.

Rumore

Sebbene potremmo creare la nostra funzione personalizzata di rumore riproducendo le funzioni che abbiamo imparato in passato, possiamo anche utilizzare i nodi noise integrati disponibili in Three.js.

Non sono ancora documentati, ma esistono. Li ho scoperti navigando attraverso la sezione degli esempi del sito di Three.js.

WebGPU Noise Example

Poiché il codice sorgente è disponibile, possiamo vedere gli import necessari e come utilizzarli.

import { mx_worley_noise_vec3 } from "three/tsl";

export const PracticeNodeMaterial = ({
  colorA = "white",
  colorB = "orange",
  blinkSpeed = 1,
  scalingFactor = 5,
}) => {
  const { nodes, uniforms } = useMemo(() => {
    const uniforms = {
      // ...
      scalingFactor: uniform(scalingFactor),
    };

    const randHeight = mx_worley_noise_vec3(uv().mul(uniforms.scalingFactor)).x;
    // ...
  }, []);

  useFrame(() => {
    uniforms.colorA.value.set(colorA);
    uniforms.colorB.value.set(colorB);
    uniforms.blinkSpeed.value = blinkSpeed;
    uniforms.scalingFactor.value = scalingFactor;
  });

  return <meshStandardNodeMaterial {...nodes} />;
};

Utilizzando il nodo mx_worley_noise_vec3, possiamo generare una texture di rumore 3D basata sulle coordinate UV del piano. Dichiariamo un uniform scalingFactor per controllare la scala del rumore.

Sostituiamo la nostra variabile randHeight con il valore del rumore e possiamo usarla per influenzare il colore e la posizione dei vertici.

WebGPU Plane with Noise

L'aspetto del piano ora è decisamente più interessante.

Possiamo farlo muovere aggiungendo il time alle coordinate uv:

// ...

export const PracticeNodeMaterial = ({
  colorA = "white",
  colorB = "orange",
  blinkSpeed = 1,
  scalingFactor = 5,
  movementSpeed = 0.5,
}) => {
  const { nodes, uniforms } = useMemo(() => {
    const uniforms = {
      // ...

      movementSpeed: uniform(movementSpeed),
    };

    const randHeight = mx_worley_noise_vec3(
      uv().mul(uniforms.scalingFactor).add(time.mul(uniforms.movementSpeed))
    ).x;
    // ...

    const blink = Fn(([t, speed]) => {
      return t.mul(speed).sin().mul(0.5).add(0.5).toVar();
    });

    // ...

    return {
      nodes: {
        colorNode: finalColor,
        positionNode: finalPosition,
      },
      uniforms,
    };
  }, []);

  useFrame(() => {
    // ...
    uniforms.movementSpeed.value = movementSpeed;
  });

  return <meshStandardNodeMaterial {...nodes} />;
};

Utilizziamo un uniform movementSpeed per controllare la velocità del movimento. Aggiungiamo il time alle coordinate uv per far muovere il rumore nel tempo.

Ho anche rimosso il nodo If per far lampeggiare l'intero piano.

Aggiungiamo controlli per gestire gli uniform al di fuori del componente:

// ...

export const Experience = () => {
  const materialProps = useControls({
    colorA: { value: "skyblue" },
    colorB: { value: "blueviolet" },
    blinkSpeed: { value: 1, min: 0, max: 10 },
    scalingFactor: { value: 5, min: 1, max: 10 },
    movementSpeed: { value: 0.5, min: -5, max: 5, step: 0.01 },
  });
  return (
    <>
      {/* ... */}

      <mesh rotation-x={-Math.PI / 2}>
        <planeGeometry args={[2, 2, 200, 200]} />
        <PracticeNodeMaterial {...materialProps} />
      </mesh>
    </>
  );
};

Dato che ora abbiamo molti uniform, è più conveniente destrutturare l'oggetto materialProps invece di passare ciascun uniform uno per uno.

Il nostro piano ora si muove e possiamo controllare la velocità del movimento, il fattore di scala e la velocità del lampeggio oltre ai colori!

Mettiamo in pausa questo componente per ora, lo ricicleremo più tardi.

Webgl fallback

Ok, stiamo usando WebGPU ma cosa succede se l'utente non ha un browser compatibile? Ho visto che è disponibile solo su Chrome e Edge per ora nella Browser compatibility section della pagina API WebGPU.

WebGPU Not Supported

Grazie a TSL e all'implementazione di Three.js del WebGPURenderer, non dobbiamo fare nulla. Il WebGPURenderer tornerà automaticamente a WebGLRenderer se il browser non supporta WebGPU. (Con un piccolo avviso nella console.)

Vediamo cosa succede se apriamo il nostro progetto su Safari:

WebGPU Fallback

La nostra esperienza è renderizzata correttamente ma utilizzando il WebGLRenderer invece del WebGPURenderer.

Nella console abbiamo il seguente avviso:

THREE.WebGPURenderer: WebGPU is not available, running under WebGL2 backend.

In alcuni casi potresti dover ridurre la qualità dell'esperienza o il numero di particelle per mantenere buone prestazioni.

Puoi farlo controllando la proprietà renderer.backend.isWebGPUBackend:

// ...
function App() {
  // ...

  return (
    <>
      <Stats />
      <Canvas
        shadows
        camera={{ position: [3, 3, 3], fov: 30 }}
        frameloop={frameloop}
        gl={(canvas) => {
          // ...
          renderer.init().then(() => {
            console.log("WebGPU?", !!renderer.backend.isWebGPUBackend);
            setFrameloop("always");
          });
          return renderer;
        }}
      >
        {/* ... */}
      </Canvas>
    </>
  );
}

export default App;

WebGPU Fallback

Safari mostra false mentre Chrome mostra true.

Impostalo in una variabile per utilizzarlo nei tuoi componenti se mai avrai bisogno di verificarlo.

Effetto Dissolvenza

Continuiamo ad esplorare TSL per creare effetti di shader. Ricordi l'effetto dissolvenza che abbiamo creato nella lezione Shader transitions? Estendere lo shader esistente era un po' tedioso.

Riprodurremo un effetto simile utilizzando TSL.

Avviso spoiler: è molto più facile da fare. 😎

Creiamo una nuova cartella chiamata materials in src/components. Possiamo trascinare e rilasciare il file PracticeNodeMaterial.jsx al suo interno. In base al tuo editor di codice, potresti dover aggiornare il percorso dell'importazione in Experience.jsx. In Visual Studio Code, lo farà automaticamente.

Crea un nuovo file chiamato DissolveMaterial.jsx al suo interno:

import { useMemo } from "react";

import { color } from "three/tsl";

export const DissolveMaterial = ({ ...props }) => {
  const { nodes, uniforms } = useMemo(() => {
    const uniforms = {};

    const finalColor = color("white");
    return {
      uniforms,
      nodes: {
        colorNode: finalColor,
      },
    };
  }, []);

  return (
    <meshStandardNodeMaterial
      {...props}
      {...nodes}
      transparent
      toneMapped={false}
    />
  );
};

Utilizziamo la stessa struttura di prima. Dichiariamo un nodo finalColor utilizzando il nodo color dal modulo three/tsl. È un buon punto di partenza per il nostro effetto di dissolvenza.

Prima di personalizzarlo, colleghiamo questo materiale a un mesh. Nella cartella public/models, troverai un modello racer-opt.glb. È un modello di pilota da corsa trovato su mixamo con un'animazione allegata.

Ho ottimizzato il modello e ridotto le sue dimensioni usando questo strumento, una versione online di glTF Transform.

Carichiamo il modello in un componente dedicato components/Racer.jsx.

Trasciniamo e rilasciamo il modello nella versione online di gltfjsx o utilizziamo la versione da riga di comando per ottenere quanto segue:

/*
Auto-generato da: https://github.com/pmndrs/gltfjsx
*/

import React, { useRef } from "react";
import { useGLTF, useAnimations } from "@react-three/drei";

export function Model(props) {
  const group = useRef();
  const { nodes, materials, animations } = useGLTF("/racer-opt.glb");
  const { actions } = useAnimations(animations, group);
  return (
    <group ref={group} {...props} dispose={null}>
      <group>
        <group name="Armature" rotation={[Math.PI / 2, 0, 0]} scale={0.01}>
          <primitive object={nodes.mixamorig6Hips} />
        </group>
        <skinnedMesh
          name="Ch20"
          geometry={nodes.Ch20.geometry}
          material={materials.Ch20_body}
          skeleton={nodes.Ch20.skeleton}
          rotation={[Math.PI / 2, 0, 0]}
          scale={0.01}
        />
      </group>
    </group>
  );
}

useGLTF.preload("/racer-opt.glb");

Sostituisci i percorsi per includere la cartella models e rinomina il componente in Racer:

// ...

export function Racer(props) {
  // ...
  const { nodes, materials, animations } = useGLTF("/models/racer-opt.glb");
  // ...
}

useGLTF.preload("/models/racer-opt.glb");

Ora carichiamo il modello, aggiungiamo un pavimento e nascondiamo la mesh con il <PracticeNodeMaterial />. Nel nostro componente Experience.jsx:

// ...
import { Racer } from "./Racer";

export const Experience = () => {
  // ...
  return (
    <>
      {/* ... */}
      <mesh rotation-x={-Math.PI / 2} visible={false}>
        <planeGeometry args={[2, 2, 200, 200]} />
        <PracticeNodeMaterial {...materialProps} />
      </mesh>

      <Racer />
      {/* Pavimento */}
      <mesh receiveShadow rotation-x={-Math.PI / 2} position={[0, 0, 0]}>
        <planeGeometry args={[100, 100]} />
        <meshStandardNodeMaterial color="red" roughness={0.9} />
      </mesh>
    </>
  );
};

Abbiamo impostato la prop visible su false per nascondere la mesh.

WebGPU Racer

Possiamo ora vedere il nostro modello di pilota nella scena in T-pose.

Per riprodurre la sua animazione allegata, registriamo l'oggetto actions nella console. Torniamo a Racer.jsx:

// ...

export function Racer(props) {
  // ...
  const { actions } = useAnimations(animations, group);

  console.log("actions", actions);
  // ...
}

// ...

Possiamo vedere che l'animazione si chiama Armature|mixamo.com|Layer0. Riproduciamola in un hook useEffect:

// ...
import { useEffect } from "react";

export function Racer(props) {
  // ...
  const { actions } = useAnimations(animations, group);

  useEffect(() => {
    actions?.["Armature|mixamo.com|Layer0"]?.play();
  }, [actions]);
  // ...
}

// ...

E impostiamo la skinned mesh per proiettare ombre e il suo materiale su DissolveMaterial:

// ...
import { DissolveMaterial } from "./materials/DissolveMaterial";

export function Racer(props) {
  // ...
  return (
    <group ref={group} {...props} dispose={null}>
      <group>
        {/* ... */}
        <skinnedMesh
          name="Ch20"
          geometry={nodes.Ch20.geometry}
          // material={materials.Ch20_body}
          skeleton={nodes.Ch20.skeleton}
          rotation={[Math.PI / 2, 0, 0]}
          scale={0.01}
          castShadow
          receiveShadow
        >
          <DissolveMaterial {...materials.Ch20_body} />
        </skinnedMesh>
      </group>
    </group>
  );
}
// ...

Il nostro avatar ora sta ballando e utilizzando il DissolveMaterial. Ecco perché appare bianco.

Prima di creare l'effetto, dobbiamo utilizzare la texture allegata al nostro modello. Poiché abbiamo destrutturato l'oggetto materials.Ch20_body, la mappa è passata correttamente al DissolveMaterial.

Gestiamolo nei nostri nodi in DissolveMaterial.jsx:

// ...
import { texture } from "three/tsl";

export const DissolveMaterial = ({ ...props }) => {
  const { nodes, uniforms } = useMemo(() => {
    // ...

    const baseColor = texture(props.map);
    const finalColor = baseColor;
    // ...
  }, []);

  // ...
};

WebGPU Racer con Texture

Possiamo ora vedere la texture del nostro modello.

È ora di aggiungere una prop visible per determinare se il modello è visibile o meno. La useremo per sapere quando applicare l'effetto dissolvenza. In Racer.jsx:

// ...
import { useControls } from "leva";

export function Racer(props) {
  // ...
  const { visible } = useControls({
    visible: { value: true },
  });

  return (
    <group ref={group} {...props} dispose={null}>
      <group>
        {/* ... */}
        <skinnedMesh
        // ...
        >
          <DissolveMaterial {...materials.Ch20_body} visible={visible} />
        </skinnedMesh>
      </group>
    </group>
  );
}
// ...

E in DissolveMaterial.jsx:

// ...
import { useFrame } from "@react-three/fiber";
import { lerp } from "three/src/math/MathUtils.js";

export const DissolveMaterial = ({ visible = true, ...props }) => {
  const { nodes, uniforms } = useMemo(() => {
    const uniforms = {
      progress: uniform(0),
    };

    // ...
    return {
      uniforms,
      nodes: {
        // ...
        opacityNode: uniforms.progress,
      },
    };
  }, []);

  useFrame((_, delta) => {
    uniforms.progress.value = lerp(
      uniforms.progress.value,
      visible ? 1 : 0,
      delta * 2
    );
  });

  // ...
};

Dichiariamo un uniforme progress per controllare l'opacità del materiale. Usiamo la funzione lerp per passare gradualmente da 0 a 1 in base alla prop visible.

Impostiamo il opacityNode sull'uniforme progress per controllare l'opacità del materiale. Sarà il nostro punto di partenza per l'effetto di dissolvenza.

Sembra che il nostro modello sia diventato un fantasma. 👻

Ora, invece di scomparire uniformemente, vogliamo creare un effetto di dissolvenza. Utilizzeremo una texture di rumore per creare l'effetto.

Utilizzeremo il nodo mx_noise_float per generare una texture di rumore basata sulla posizione nel mondo dei vertici:

// ...

import { mx_noise_float, positionWorld, step } from "three/tsl";

export const DissolveMaterial = ({ visible = true, ...props }) => {
  const { nodes, uniforms } = useMemo(() => {
    const uniforms = {
      // ...
      size: uniform(12),
    };

    const noise = mx_noise_float(positionWorld.mul(uniforms.size));
    const alpha = step(noise, uniforms.progress);

    // ...
    return {
      uniforms,
      nodes: {
        colorNode: finalColor,
        opacityNode: alpha,
      },
    };
  }, []);

  // ...
};

Moltiplichiamo il nodo positionWorld per un nuovo uniforme size per controllare la scala del rumore. Poi utilizziamo il nodo step per creare una maschera basata sul rumore e l'uniforme progress.

Effetto Dissolvenza WebGPU

La nostra texture di rumore è ora applicata durante la dissolvenza, ma si ferma a metà lasciando il modello a metà dissolvenza. 🧀

Questo accade perché il valore noise è tra -1 e 1. Mappiamolo nuovamente per essere tra 0 e 1 usando il nodo remap:

// ...
import { remap } from "three/tsl";

export const DissolveMaterial = ({ visible = true, ...props }) => {
  const { nodes, uniforms } = useMemo(() => {
    // ...
    const dissolve = remap(noise, -1, 1, 0, 1);
    const alpha = step(dissolve, uniforms.progress);

    // ...
  }, []);

  // ...
};

Il nostro pilota adesso è completamente dissolto.

Per un effetto migliore, aggiungiamo un bordo all'effetto di dissolvenza che faremo risplendere più tardi:

// ...
import { color, mix } from "three/tsl";

export const DissolveMaterial = ({ visible = true, ...props }) => {
  const { nodes, uniforms } = useMemo(() => {
    const uniforms = {
      // ...
      thickness: uniform(0.1),
      borderColor: uniform(color("orange")),
    };

    // ...
    const alpha = step(dissolve, uniforms.progress);
    const border = step(
      dissolve,
      uniforms.progress.add(uniforms.thickness)
    ).sub(alpha);

    // ...
    const finalColor = mix(baseColor, uniforms.borderColor, border);
    return {
      uniforms,
      nodes: {
        colorNode: finalColor,
        opacityNode: alpha.add(border),
      },
    };
  }, []);

  // ...
};

Dichiariamo un nuovo uniforme thickness per controllare lo spessore del bordo. Utilizziamo il nodo step per creare una maschera per il bordo basata sul rumore e l'uniforme progress.

Poi mescoliamo il colore di base con il colore del bordo usando il nodo mix.

Infine, aggiungiamo il bordo al nodo opacità per renderlo visibile.

L'effetto di dissolvenza ora ha un bel bordo, ma alcuni artefatti sono visibili.

Questo perché quando il nostro progresso è intorno a 0, il bordo avrà le dimensioni dello spessore. Possiamo sistemarlo utilizzando una progressione personalizzata chiamata smoothProgress che va da -thickness a 1:

// ...
import { negate } from "three/tsl";

export const DissolveMaterial = ({ visible = true, ...props }) => {
  const { nodes, uniforms } = useMemo(() => {
    // ...
    const smoothProgress = uniforms.progress.remap(
      0,
      1,
      negate(uniforms.thickness),
      1
    );
    const alpha = step(dissolve, smoothProgress);
    const border = step(dissolve, smoothProgress.add(uniforms.thickness)).sub(
      alpha
    );

    // ...
  }, []);

  // ...
};

Non ci sono più artefatti! L'effetto dissolvenza è ora fluido e il bordo è applicato correttamente.

Per far risplendere questo effetto, dobbiamo far brillare il bordo.

Il pacchetto @react-three/postprocessing non supporta ancora WebGPU, ma aggiungere il post-processing ora è più facile con il renderer WebGPU.

Post-elaborazione

La post-elaborazione è una tecnica utilizzata nella grafica computerizzata per applicare effetti all'immagine finale dopo che è stata renderizzata. Può essere utilizzata per creare una varietà di effetti come bloom, profondità di campo, motion blur e altro. Controlla la lezione dedicata alla post-elaborazione per maggiori dettagli.

Anche se potremmo usare <EffectComposer /> da @react-three/postprocessing per aggiungere effetti di post-elaborazione, non supporta ancora WebGPU. Quindi, dobbiamo creare il nostro componente di post-elaborazione.

Passaggi

Creeremo un nuovo componente PostProcessing.jsx nella cartella src/components. Questo componente gestirà gli effetti di post-elaborazione:

import { useThree } from "@react-three/fiber";
import { useEffect, useRef } from "react";
import { pass } from "three/tsl";
import * as THREE from "three/webgpu";

export const PostProcessing = ({}) => {
  const { gl: renderer, scene, camera } = useThree();
  const postProcessingRef = useRef(null);

  useEffect(() => {
    if (!renderer || !scene || !camera) {
      return;
    }

    const scenePass = pass(scene, camera);

    // Ottenere i texture nodes
    const outputPass = scenePass.getTextureNode("output");

    // Impostare la post-elaborazione
    const postProcessing = new THREE.PostProcessing(renderer);

    const outputNode = outputPass;
    postProcessing.outputNode = outputNode;
    postProcessingRef.current = postProcessing;

    return () => {
      postProcessingRef.current = null;
    };
  }, [renderer, scene, camera]);

  return null;
};

Nel hook useEffect, quando il componente viene montato, creiamo un passaggio utilizzando il nodo pass dal modulo three/tsl. Questa funzione prende la scena e la telecamera come argomenti e restituisce un oggetto pass.

Utilizza internamente un render-target per renderizzare la scena su più texture nodes (output, depth, ecc.) e quindi possiamo usare queste texture per applicare effetti di post-elaborazione.

Usiamo la classe PostProcessing dal modulo three/webgpu per creare un oggetto di post-elaborazione. Questo modulo è responsabile di gestire la configurazione di post-elaborazione nel nostro progetto. Ci permetterà di aggiungere passaggi e nodi alla nostra pipeline di post-elaborazione.

Impostiamo outputNode al nodo outputPass che abbiamo ottenuto dall'oggetto pass. Questo sarà il nostro nodo di output finale.

In un hook useFrame, dobbiamo aggiornare l'oggetto di post-elaborazione:

// ...
import { useFrame } from "@react-three/fiber";

export const PostProcessing = ({}) => {
  // ...

  useFrame(() => {
    if (postProcessingRef.current) {
      postProcessingRef.current.render();
    }
  }, 1);

  // ...
};

Chiamiamo il metodo render dell'oggetto di post-elaborazione per renderizzare la scena con gli effetti di post-elaborazione. Usando 1 come secondo argomento del hook useFrame, ci assicuriamo che la post-elaborazione sia renderizzata dopo che la scena è stata renderizzata.

Proviamo il nostro componente di post-elaborazione nel componente App.jsx:

// ...
import { PostProcessing } from "./components/PostProcessing";

// ...

function App() {
  const [frameloop, setFrameloop] = useState("never");

  return (
    <>
      <Stats />
      <Canvas
      // ...
      >
        {/* ... */}
        <PostProcessing />
      </Canvas>
    </>
  );
}

export default App;

WebGPU PostProcessing

Il nostro pilota sta ancora felicemente ballando. Il nostro componente di post-elaborazione funziona.

Poiché non stiamo ancora utilizzando alcun effetto di post-elaborazione, non vediamo alcuna differenza. Aggiungiamo un effetto di bloom al nostro componente di post-elaborazione.

Bloom pass

Poiché i pass che vogliamo aggiungere non sono ancora documentati, il modo più semplice per scoprirli e utilizzarli è controllare il codice sorgente degli esempi sul sito di Three.js.

Per l'effetto bloom, possiamo controllare l'esempio di bloom e vedere come funziona.

Implementiamo l'effetto bloom nel nostro componente PostProcessing.jsx.

Vogliamo applicare bloom quando rileviamo il colore emissive nella nostra scena.

Per il nostro scenePass per gestire entrambi i pass output ed emissive, dobbiamo creare più render target.

// ...
import { emissive, mrt, output } from "three/tsl";

export const PostProcessing = ({}) => {
  // ...

  useEffect(() => {
    if (!renderer || !scene || !camera) {
      return;
    }

    const scenePass = pass(scene, camera);

    // Create MRT (Multiple Render Targets)
    scenePass.setMRT(
      mrt({
        output,
        emissive,
      })
    );

    // ...
  }, [renderer, scene, camera]);

  // ...
};

Importiamo il nodo mrt dal modulo three/tsl e lo utilizziamo per creare un multiple render target. Passiamo un oggetto con i nodi output ed emissive alla funzione mrt.

Ora, possiamo accedere al pass emissive dall'oggetto scenePass:

// Get texture nodes
const outputPass = scenePass.getTextureNode("output");
const emissivePass = scenePass.getTextureNode("emissive");

Aggiungiamo delle props al nostro componente e creiamo un bloom pass utilizzando il nodo emissivePass:

// ...
import { bloom } from "three/examples/jsm/tsl/display/BloomNode.js";

export const PostProcessing = ({
  strength = 2.5,
  radius = 0.5,
  threshold = 0.25,
}) => {
  const { gl: renderer, scene, camera } = useThree();
  const postProcessingRef = useRef(null);
  const bloomPassRef = useRef(null);

  useEffect(() => {
    // ...
    // Create bloom pass
    const bloomPass = bloom(emissivePass, strength, radius, threshold);
    bloomPassRef.current = bloomPass;

    // ...
  }, [renderer, scene, camera]);

  useFrame(() => {
    if (bloomPassRef.current) {
      bloomPassRef.current.strength.value = strength;
      bloomPassRef.current.radius.value = radius;
      bloomPassRef.current.threshold.value = threshold;
    }
    // ...
  }, 1);
  // ...
};

Utilizziamo la funzione bloom dal modulo three/examples/jsm/tsl/display/BloomNode.js per creare un bloom pass. Prende il nodo emissivePass e i parametri del bloom come argomenti.

Memorizziamo anche un riferimento al bloom pass in un hook useRef in modo da poter aggiornare i suoi uniforms nell'hook useFrame.

Possiamo ora aggiungere il bloomPass al outputPass:

// ...

export const PostProcessing = (
  {
    // ...
  }
) => {
  // ...

  useEffect(() => {
    // ...
    const outputNode = outputPass.add(bloomPass);
    postProcessing.outputNode = outputNode;
    // ...

    return () => {
      postProcessingRef.current = null;
    };
  }, [renderer, scene, camera]);
  // ...
};

Per poter regolare l'effetto bloom, aggiungiamo alcuni controlli in App.jsx:

// ...
import { useControls } from "leva";

// ...

function App() {
  // ...

  const ppSettings = useControls("Post Processing", {
    strength: {
      value: 1.2,
      min: 0,
      max: 10,
      step: 0.1,
    },
    radius: {
      value: 0.5,
      min: 0,
      max: 10,
      step: 0.1,
    },
    threshold: {
      value: 0.25,
      min: 0,
      max: 1,
      step: 0.01,
    },
  });

  return (
    <>
      <Stats />
      <Canvas
      // ...
      >
        {/* ... */}
        <PostProcessing {...ppSettings} />
      </Canvas>
    </>
  );
}

// ...

E per provare se l'effetto bloom funziona, impostiamo il emissiveNode del DissolveMaterial su un colore:

// ...

export const DissolveMaterial = ({ visible = true, ...props }) => {
  const { nodes, uniforms } = useMemo(() => {
    const uniforms = {
      // ...
      intensity: uniform(2),
    };

    // ...
    const borderColor = uniforms.borderColor.mul(uniforms.intensity);
    const baseColor = texture(props.map);
    const finalColor = mix(baseColor, borderColor, border);
    return {
      uniforms,
      nodes: {
        // ...
        emissiveNode: borderColor.mul(border),
      },
    };
  }, []);
  // ...
};

Dichiariamo un nuovo uniform intensity per controllare l'intensità del colore emissive. Moltiplichiamo il borderColor per l'uniform intensity per farlo brillare.

Poi, impostiamo il emissiveNode sul borderColor moltiplicato per la maschera border.

L'effetto bloom è ora applicato al bordo dell'effetto dissolve. ✨

Se desideri aggiungere altri effetti, dai un'occhiata agli altri esempi nella sezione Three.js examples. Assicurati di essere nella sezione webgpu.

Ritocco finale

Per finalizzare questo progetto e mostrarlo con orgoglio nel tuo portfolio, lo rifiniremo ulteriormente.

Ombre

Attualmente, le ombre non scompaiono quando il modello si dissolve. Nella pagina Wiki di Three Shading Language puoi trovare castShadowNode nella sezione Shadows.

È definito come segue:

Controlla il colore e l'opacità dell'ombra che verrà proiettata dal materiale.

Questo esempio aiuta anche a capire come utilizzare il nodo castShadowNode.

Proviamo a farlo seguire l'opacità del materiale. In DissolveMaterial.jsx:

// ...
import { vec4 } from "three/tsl";

export const DissolveMaterial = ({ visible = true, ...props }) => {
  const { nodes, uniforms } = useMemo(() => {
    // ...
    const shadowColor = mix(color("white"), color("black"), alpha);

    return {
      uniforms,
      nodes: {
        // ...
        castShadowNode: vec4(shadowColor, 1.0),
      },
    };
  }, []);

  // ...
};

Creiamo una variabile shadowColor utilizzando il nodo mix per mescolare tra bianco e nero basandoci sul valore alpha. Impostiamo quindi il castShadowNode su un vec4 con il colore dell'ombra e un valore alpha di 1.0.

Non sono riuscito a far scomparire completamente le ombre utilizzando il 4° parametro del nodo vec4. Quindi ho usato un mix tra bianco e nero per far sembrare che l'ombra stia svanendo. Forse un problema con il nodo castShadowNode o con la mia implementazione.

Osserva come le ombre svaniscono insieme al modello seguendo l'effetto dissolve.

Messa in scena

Aggiungiamo la F1 2004 Racing Car di CarlosCG31 situata nella cartella public/models alla nostra scena.

Poiché non intendiamo apportarvi modifiche, possiamo utilizzare il componente <Gltf /> di @react-three/drei per caricarla. In Experience.jsx:

// ...
import { Gltf } from "@react-three/drei";

export const Experience = () => {
  // ...

  return (
    <>
      {/* ... */}

      {/* "F1 2004 Racing Car" (https://skfb.ly/pr78R) di CarlosCG31 è concessa in licenza sotto Creative Commons Attribution (http://creativecommons.org/licenses/by/4.0/). */}
      <Gltf
        src="/models/f1_2004_racing_car.glb"
        position={[0, 0.44, -4]}
        scale={0.5}
        rotation-y={Math.PI / 4}
        castShadow
      />
      <Racer />
      {/* ... */}
    </>
  );
};

Regoliamo anche il colore dello sfondo e della nebbia per adattarlo al tema delle corse, spostiamo la posizione della telecamera e l'intera scena un po':

// ...

function App() {
  // ...

  return (
    <>
      <Stats />
      <Canvas
        shadows
        camera={{ position: [0, 2, 8], fov: 30 }}
        // ...
      >
        <color attach="background" args={["#942132"]} />
        <fog attach="fog" args={["#942132", 20, 30]} />
        <Suspense>
          <group position-y={-1}>
            <Experience />
          </group>
        </Suspense>
        {/* ... */}
      </Canvas>
    </>
  );
}

export default App;

Molto meglio, vero?

Riciclando il piano

Non abbiamo creato il piano per niente. Utilizziamolo per creare un bel sfondo per la nostra scena:

// ...

export const Experience = () => {
  const materialProps = useControls("Background Circle", {
    colorA: { value: "skyblue" },
    colorB: { value: "blueviolet" },
    blinkSpeed: { value: 1, min: 0, max: 10 },
    scalingFactor: { value: 5, min: 1, max: 10 },
    movementSpeed: { value: 0.5, min: -5, max: 5, step: 0.01 },
  });

  return (
    <>
      {/* ... */}
      <mesh position={[0, 1, -6]}>
        <circleGeometry args={[2, 200]} />
        <PracticeNodeMaterial {...materialProps} />
      </mesh>

      {/* ... */}
    </>
  );
};

Semplicemente lo stiamo spostando e sostituendo la planeGeometry con una circleGeometry.

Ho aggiunto la cartella Background Circle ai controlli per differenziarlo dai controlli Post Processing.

WebGPU Background Circle

Interessante, ma il cerchio non si fonde bene con il resto.

Prima di regolare il colore, aggiungiamo alcune luci emissive sul bordo del cerchio. Possiamo usare il emissiveNode per farlo:

// ...
import { length, smoothstep } from "three/tsl";

export const PracticeNodeMaterial = ({
  // ...
  emissiveColor = "orange",
}) => {
  const { nodes, uniforms } = useMemo(() => {
    const uniforms = {
      // ...
      emissiveColor: uniform(color(emissiveColor)),
    };

    // ...

    let uvCentered = uv().sub(0.5).mul(2.0); // Centra UV in (0,0)
    let radialDist = length(uvCentered); // Distanza dal centro
    let edgeGlow = smoothstep(0.8, 1, radialDist); // Regola le soglie per la larghezza del bagliore

    return {
      nodes: {
        // ...
        emissiveNode: vec4(
          mix(finalColor, uniforms.emissiveColor.mul(2), edgeGlow),
          edgeGlow
        ),
      },
      uniforms,
    };
  }, []);

  useFrame(() => {
    // ...
    uniforms.emissiveColor.value.set(emissiveColor);
  });

  return <meshStandardNodeMaterial {...nodes} />;
};

Utilizziamo il nodo length per calcolare la distanza dal centro del cerchio. Poi usiamo il nodo smoothstep per creare una transizione morbida tra i bordi interno ed esterno del cerchio. Mescoliamo il finalColor con il emissiveColor basandoci sul valore di edgeGlow per creare un effetto di bagliore sul bordo del cerchio.

Sembra meglio, ma l'effetto bloom è applicato all'intero cerchio quando non dovrebbe esserlo.

Aggiungiamo un controllo per il emissiveColor e aggiustiamo i valori di colorA e colorB per adattarli al tema della corsa:

const materialProps = useControls("Background Circle", {
  colorA: { value: "#000000" },
  colorB: { value: "#942132" },
  emissiveColor: { value: "orange" },
  blinkSpeed: { value: 1, min: 0, max: 10 },
  scalingFactor: { value: 5, min: 1, max: 10 },
  movementSpeed: { value: 0.5, min: -5, max: 5, step: 0.01 },
});

Aggiustiamo i valori di threshold e strength dell'effetto bloom per renderlo più selettivo. In App.jsx:

const ppSettings = useControls("Post Processing", {
  strength: {
    value: 0.8,
    min: 0,
    max: 10,
    step: 0.1,
  },
  radius: {
    value: 0.42,
    min: 0,
    max: 10,
    step: 0.1,
  },
  threshold: {
    value: 0.75,
    min: 0,
    max: 1,
    step: 0.01,
  },
});

WebGPU Bloom Effect

Il risultato è più naturale e l'effetto bloom è applicato solo al colore emissivo.

Controlli

Per poter giocare con l'effetto di dissolvenza, aggiungiamo alcuni controlli nel componente Racer.jsx:

// ...

export function Racer(props) {
  // ...

  const dissolveMaterialProps = useControls("Dissolve Effect", {
    visible: { value: true },
    size: { value: 12, min: 0, max: 20 },
    thickness: { value: 0.1, min: 0, max: 1 },
    dissolveColor: { value: "orange" },
    intensity: { value: 2, min: 0, max: 10 },
  });
  return (
    <group ref={group} {...props} dispose={null}>
      <group>
        {/* ... */}
        <skinnedMesh
        // ...
        >
          <DissolveMaterial
            {...materials.Ch20_body}
            {...dissolveMaterialProps}
          />
        </skinnedMesh>
      </group>
    </group>
  );
}

// ...

E aggiorniamo il componente DissolveMaterial.jsx per utilizzare i nuovi props invece dei valori hardcoded:

// ...

export const DissolveMaterial = ({
  visible = true,
  size = 12,
  thickness = 0.1,
  dissolveColor = "orange",
  intensity = 2,
  ...props
}) => {
  const { nodes, uniforms } = useMemo(() => {
    const uniforms = {
      progress: uniform(0),
      size: uniform(size),
      thickness: uniform(thickness),
      borderColor: uniform(color(dissolveColor)),
      intensity: uniform(intensity),
    };

    // ...
  }, []);

  useFrame((_, delta) => {
    uniforms.progress.value = lerp(
      uniforms.progress.value,
      visible ? 1 : 0,
      delta * 2
    );
    uniforms.size.value = size;
    uniforms.thickness.value = thickness;
    uniforms.intensity.value = intensity;
    uniforms.borderColor.value.set(dissolveColor);
  });

  // ...
};

Possiamo ora giocare con l'effetto di dissolvenza e vedere come influenza la transizione.

Conclusione

Congratulazioni! Hai fatto i tuoi primi passi nel Three Shading Language e nel renderer WebGPU.

Hai imparato come personalizzare i materiali utilizzando TSL, come utilizzare il nuovo WebGPU renderer e come aggiungere effetti di post-processing.

Come hai visto, il WebGL fallback rende la transizione fluida senza preoccuparti troppo se un dispositivo/browser supporta o meno WebGPU. Puoi utilizzare la stessa base di codice per entrambi i renderer.

Ma fai attenzione, l'API WebGPU e anche l'implementazione di Three.js non sono ancora nella loro forma definitiva. Alcune funzionalità potrebbero cambiare in futuro e portare a cambiamenti che interrompono la compatibilità.

Non consiglierei di utilizzarlo in produzione ancora, o almeno non su progetti che non puoi permetterti di interrompere. Ma è un ottimo modo per sperimentare le nuove funzionalità e vedere come possono migliorare i tuoi progetti.

Anche se abbiamo visto che React Three Fiber è compatibile con WebGPU, non tutti i componenti Drei sono ancora portati. In realtà, tutti quelli che si basano su shader personalizzati. Il più notevole è il Html component.

Poiché è in continua evoluzione, ti consiglio vivamente di controllare:

  • Three.js Shading Language Wiki: Sì, l'ho menzionato molte volte in questa lezione, ma è il modo centrale in cui documentano TSL.
  • Three.js examples (i progetti WebGPU): Molti shader dei progetti di Three.js journey sono stati convertiti a WebGPU/TSL.
  • Three.js release notes. Più importante di prima, poiché molti componenti possono diventare deprecati o rimossi.
  • Three.js source code: Siamo fortunati che Three.js sia open source. Puoi controllare il codice sorgente dei componenti che vuoi utilizzare e vedere cosa succede sotto il cofano.
  • Three.js discourse: Il forum ufficiale per Three.js. Molte discussioni riguardo a WebGPU e TSL si svolgono lì. Puoi fare domande se non è già stata data una risposta.
  • Three.js issues: Poiché siamo nelle prime fasi di WebGPU, molti problemi vengono aperti e discussi lì. Puoi controllare se il tuo problema è già stato segnalato o segnalarne uno nuovo.
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.