-5.3 C
New York
Wednesday, January 28, 2026

WebGPU Gommage Impact: Dissolving MSDF Textual content into Mud and Petals with Three.js & TSL



We’re going to construct a small WebGPU “second”: a bit of MSDF textual content in a Three.js scene that disintegrates over time, shedding mud and petals because it fades away. It’s impressed by the Gommage impact from Clair Obscur: Expedition 33, however the purpose right here is sensible: use the thought as a cause to discover fashionable Three.js WebGPU + TSL workflows in a mission that’s visually rewarding and technically actual.

The tutorial is step-by-step on objective: we’ll begin from a clean mission, then add one system at a time (textual content, dissolve, particles, post-processing), holding all the things straightforward to tweak and perceive. In case you’d somewhat skip round, every part hyperlinks to the matching GitHub commit so you’ll be able to soar straight to the half you care about.

What you’ll study:

  • Use TSL to construct shader logic and post-processing
  • Render MSDF textual content in a Three.js scene
  • Create a noise-driven dissolve impact
  • Spawn and animate mud particles with InstancedMesh
  • Spawn petal particles with bend + spin
  • Add selective bloom with MRT nodes

This tutorial is lengthy on objective. It’s written step-by-step so you’ll be able to perceive why every half works. In case you’d somewhat soar round, every part hyperlinks to the matching GitHub commit!

Right here is the ultimate end result:


Free GSAP 3 Express Course


Be taught fashionable internet animation utilizing GSAP 3 with 34 hands-on video classes and sensible tasks — excellent for all ability ranges.


Test it out

0. Inspiration

In 2025 I performed Clair Obscur: Expedition 33 and located the entire expertise superb (and apparently I wasn’t the one one). I needed to provide an homage to the sport by recreating the “Gommage”, a curse that makes folks disappear, leaving solely a path of flower petals as soon as they attain a sure age (play the sport, it can all make sense).

Yep, it’s very dramatic however let’s recover from it and analyze it a bit. If we simplify it, we are able to see three issues taking place:

  • A disintegration/dissolve impact.
  • Small specks of mud flying out.
  • Crimson petals flowing out (we can even use white petals in our expertise to deliver some selection).

Let’s implement all that!

1. Base Setup for Three.js

Don’t hesitate to examine the demo GitHub repository to observe alongside, every commit match a bit of this tutorial! Take a look at this part’s commit.

Begin with a mission containing simply an index.html file and base.css information. First issues first, let’s set up Vite and Three.js:

npm set up -D vite
npm i three@0.181.0

Now create a /src folder and inside it create an expertise.js file, reference it in index.html and magnificence the canvas in base.css.

//index.html

<script sort="module" src="/src/expertise.js"></script>
// base.css

canvas {
  place: mounted;
  prime: 0;
  left: 0;
  width: 100%;
  peak: 100%;
}

Now we’re going to create an Expertise class in expertise.js that can comprise the bottom code for Three.js. I received’t go into a lot element for this half because it’s fairly frequent, simply be sure that to respect the digicam parameters and place!

Observe that I additionally added a take a look at dice to examine that all the things is working effectively.

//expertise.js

import * as THREE from "three/webgpu";

export class Expertise {

  #threejs = null;
  #scene = null;
  #digicam = null;
  #dice = null;

  constructor() {}

  async initialize(container) {
    await this.#setupProject(container);
    window.addEventListener("resize",  this.#onWindowResize_.bind(this), false);
    this.#raf();
  }

  async #setupProject(container) {
    this.#threejs = new THREE.WebGPURenderer({ antialias: true });
    await this.#threejs.init();

    this.#threejs.shadowMap.enabled = false;
    this.#threejs.toneMapping = THREE.ACESFilmicToneMapping;
    this.#threejs.setClearColor(0x111111, 1);
    this.#threejs.setSize(window.innerWidth, window.innerHeight);
    this.#threejs.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    container.appendChild(this.#threejs.domElement);

    // Digicam Setup !
    const fov = 45;
    const side = window.innerWidth / window.innerHeight;
    const close to = 0.1;
    const far = 25;
    this.#digicam = new THREE.PerspectiveCamera(fov, side, close to, far);
    this.#digicam.place.set(0, 0, 5);
    this.#scene = new THREE.Scene();

    this.createCube();
  }

  createCube() {
    const geometry = new THREE.BoxGeometry(1, 1, 1);
    const materials = new THREE.MeshBasicMaterial({ shade: 0x00ff00 });
    this.#dice = new THREE.Mesh(geometry, materials);
    this.#scene.add(this.#dice);
  }

  #onWindowResize_() {
    this.#digicam.side = window.innerWidth / window.innerHeight;
    this.#digicam.updateProjectionMatrix();
    this.#threejs.setSize(window.innerWidth, window.innerHeight);
  }


  #render() {
    this.#threejs.render(this.#scene, this.#digicam);
  }


  #raf() {
    requestAnimationFrame(t => {
      this.#dice.rotation.x += 0.001;
      this.#dice.rotation.y += 0.001;
      this.#render();
      this.#raf();
    });
  }
}

new Expertise().initialize(doc.querySelector("#canvas-container"));

2. Displaying the MSDF textual content

Don’t hesitate to examine the demo GitHub repository to observe alongside, every commit matches a bit of this tutorial! Take a look at this part’s commit.

Now a little bit of context about easy methods to show textual content in a Three.js scene.

SDF (Signed Distance Subject Fonts) and its various MSDF (Multi-channel Signed Distance Subject) are font rendering codecs the place glyph distances are encoded in RGB format.

So mainly to make use of an MSDF font you want 3 issues:

  • A glyph atlas texture, normally a .png file
  • A font metadata file, normally a .json
  • A shader/materials to render the ultimate font

Initially, I needed to make use of the Troika library that makes use of SDF textual content however on the time of writing this text the lib was not appropriate with WebGPU and TSL, so I needed to discover a substitute.

After some analysis I discovered the library Three MSDF Textual content by Léo Mouraire, and it was excellent for the use case, appropriate with WebGPU, and it even used an MSDFTextNodeMaterial that will probably be excellent to make use of with TSL!

Now we simply want a device to transform a font to be usable with MSDF, and this library by Shen Yiming, is ideal for that. Clair Obscur makes use of the Cinzel font from Google Fonts, so convert it with this command after downloading the font:

msdf-bmfont Cinzel-Common.ttf 
-f json 
-o Cinzel.png 
--font-size 64 
--distance-range 16 
--texture-padding 8 
--border 2 
--smart-size

Let’s simply spend a number of moments on the choices right here.

When producing the MSDF, relying in your params, your last textual content can comprise visible artifacts or not look good at each zoom degree.

A greater high quality atlas additionally means a heavier PNG file, so it’s essential to discover a stability.

  • font-size: Larger font dimension means extra element for the glyphs, nevertheless it’ll take more room within the atlas (once more, a heavier file).
  • distance-range: The larger the vary, the extra we are able to improve/scale back the fonts with out artifacts.
  • texture-padding: Empty house between the glyphs — one of the crucial essential params to keep away from artifacts and bleeding.
  • border: Provides some house between the glyphs and the border of the feel.
  • smart-size: Shrinks the atlas to the smallest attainable sq..

Put the ultimate information in /public/fonts/Cinzel/

Remember that even with good settings some typefaces convert extra cleanly to MSDF than others. And that provides us the PNG and JSON information that we have to show our MSDF Textual content!

In case you desire, you will get the transformed font immediately from the demo GitHub repository.

Now set up the three-msdf-text-utils library that we’ll use for displaying the textual content:

npm i three-msdf-text-utils@^1.2.1

Additionally, take away all the things associated to our take a look at dice (perform and animation) in expertise.js. Let’s create 2 new information: gommageOrchestrator.js, which can manage the totally different results and msdfText.js, which will probably be answerable for displaying the MSDF textual content. We’ll begin with msdfText.js, to load our PNG atlas use a texture loader and a easy fetch for our JSON file.

//msdfText.js

import * as THREE from "three/webgpu";
import { MSDFTextGeometry, MSDFTextNodeMaterial } from "three-msdf-text-utils";

export default class MSDFText {
constructor() {
}

async initialize(textual content = "WebGPU Gommage Impact", place = new THREE.Vector3(0, 0, 0)) {
  // Load font knowledge
  const response = await fetch("/fonts/Cinzel/Cinzel.json");
  const fontData = await response.json();

  // Load font atlas
  const textureLoader = new THREE.TextureLoader();
  const fontAtlasTexture = await textureLoader.loadAsync("/fonts/Cinzel/Cinzel.png");
  fontAtlasTexture.colorSpace = THREE.NoColorSpace;
  fontAtlasTexture.minFilter = THREE.LinearFilter;
  fontAtlasTexture.magFilter = THREE.LinearFilter;
  fontAtlasTexture.wrapS = THREE.ClampToEdgeWrapping;
  fontAtlasTexture.wrapT = THREE.ClampToEdgeWrapping;
  fontAtlasTexture.generateMipmaps = false;

  // Create textual content geometry
  const textGeometry = new MSDFTextGeometry({
      textual content,
      font: fontData,
      width: 1000,
      align: "middle",
  });

  const textMaterial = new MSDFTextNodeMaterial({
      map: fontAtlasTexture,
  });
  // Regulate to take away visible artifacts
  textMaterial.alphaTest = 0.1;
  const mesh = new THREE.Mesh(textGeometry, textMaterial);

  // With this we make the peak of lineHeight 0.3 world models
  const targetLineHeight = 0.3;
  const lineHeightPx = fontData.frequent.lineHeight;
  let textScale = targetLineHeight / lineHeightPx;

  mesh.scale.set(textScale, textScale, textScale);
  const meshOffset = -(textGeometry.structure.width / 2) * textScale;
  mesh.place.set(place.x + meshOffset, place.y, place.z);
  mesh.rotation.x = Math.PI;
  return mesh;

  }
}

Discover the half the place we compute the textual content scale relying on the variable targetLineHeight = 0.4. By default the textual content geometry is expressed in font pixels (primarily based on fontData.frequent.lineHeight). That’s why it seems extraordinarily massive at first, usually too massive to even be displayed on the display! The trick is to compute a scale issue utilizing targetLineHeight/lineHeightPx to transform the font’s pixel metrics into the specified line peak in world models.

If the textual content seems too huge in your present display be happy to regulate the targetLineHeight! We are able to examine that it really works effectively by instantiating the MSDFText entity in expertise.js.

//expertise.js

...
async #setupProject(container) {
  ...
  const MSDFTextEntity = new MSDFText();
  const msdfText = await MSDFTextEntity.initialize();
  this.#scene.add(msdfText);
}
...

And right here is our textual content! Earlier than we depart expertise.js, let’s do a small adjustment to the onWindowResize perform to make our expertise responsive.

//expertise.js
...  
#onWindowResize_() {
  const HORIZONTAL_FOV_TARGET = THREE.MathUtils.degToRad(45);
  this.#digicam.side = window.innerWidth / window.innerHeight;
  const verticalFov = 2 * Math.atan(Math.tan(HORIZONTAL_FOV_TARGET / 2) / this.#digicam.side);
  this.#digicam.fov = THREE.MathUtils.radToDeg(verticalFov);
  this.#digicam.updateProjectionMatrix();
  this.#threejs.setSize(window.innerWidth, window.innerHeight);
}

For the reason that textual content is centered, we wish the digicam’s horizontal FOV to remain fixed (45°) so the framing doesn’t change when the viewport resizes.

Three.js shops the digicam FOV as a vertical FOV, so on resize we recompute the corresponding vertical FOV from the present side ratio and replace the projection matrix.

And let’s not neglect to name it within the setupProject for the preliminary load.

//expertise.js

...
async #setupProject(container) {
  ...
  this.#onWindowResize_();
  this.#scene = new THREE.Scene();
  ...
}
...

Now, earlier than we end this half, put the logic to create our MSDF Textual content in our new file gommageOrchestrator.js

//gommageOrchestrator.js

import * as THREE from "three/webgpu";
import MSDFText from "./msdfText.js";
export default class GommageOrchestrator {
  constructor() {
  }

  async initialize(scene) {
      const MSDFTextEntity = new MSDFText();
      const msdfText = await MSDFTextEntity.initialize("WebGPU Gommage Impact", new THREE.Vector3(0, 0, 0));
      scene.add(msdfText);
  }
}

And use it in expertise.js:

//expertise.js

...
async #setupProject(container) {
  ...
  // const MSDFTextEntity = new MSDFText();
  // const msdfText = await MSDFTextEntity.initialize();
  // this.#scene.add(msdfText);
  const gommageOrchestratorEntity = new GommageOrchestrator();
  await gommageOrchestratorEntity.initialize(this.#scene)
}
...

Okay we’ve our textual content on the display, nice job 🔥

3. Dissolving the textual content

Don’t hesitate to examine the demo GitHub repository to observe alongside, every commit match a bit of this tutorial! Take a look at this part’s commit.

Okay, now let’s get our first TSL impact down!

The method to dissolve the textual content is kind of easy, we’ll use a Perlin texture and relying on a progress worth going from 0 to 1, we’ll set a threshold that progressively hides components of the textual content till it’s all gone. For the Perlin Texture I used one discovered on the Screaming mind studios web site: https://screamingbrainstudios.com/downloads/

If you wish to use the identical one as me, you can too get the feel immediately from the demo GitHub repository.

Put it in /public/textures/perlin.webp and cargo it in msdfText.js

//msdfText.js

...
async initialize(textual content = "WebGPU Gommage Impact", place = new THREE.Vector3(0, 0, 0)) {
  ...
  const perlinTexture = await textureLoader.loadAsync("/textures/perlin.webp");
  perlinTexture.colorSpace = THREE.NoColorSpace;
  perlinTexture.minFilter = THREE.LinearFilter;
  perlinTexture.magFilter = THREE.LinearFilter;
  perlinTexture.wrapS = THREE.RepeatWrapping;
  perlinTexture.wrapT = THREE.RepeatWrapping;
  perlinTexture.generateMipmaps = false;
  
  // Create textual content geometry
  ...

We’ll have to customise our textual content materials, and that’s the place TSL goes to be helpful! Let’s create a perform for that and use it after we occasion our textual content mesh.

//msdfText.js
 
...
async initialize(textual content = "WebGPU Gommage Impact", place = new THREE.Vector3(0, 0, 0)) {
    ...
    const textMaterial = this.createTextMaterial(fontAtlasTexture, perlinTexture)
    ...
  }
...
createTextMaterial(fontAtlasTexture, perlinTexture) {
    const textMaterial = new MSDFTextNodeMaterial({
        map: fontAtlasTexture,
    });

    return textMaterial;
}
...

To see our Perlin Texture, let’s show it on our textual content!

//msdfText.js

...
createTextMaterial(fontAtlasTexture, perlinTexture) {
    const textMaterial = new MSDFTextNodeMaterial({
        map: fontAtlasTexture,
    });

    const glyphUv = attribute("glyphUv", "vec2");

    const perlinTextureNode = texture(perlinTexture, glyphUv);
    const boostedPerlin = pow(perlinTextureNode, 4);

    textMaterial.colorNode = boostedPerlin;

    return textMaterial;
}
...

Observe that we’re utilizing the glyphUv, which in response to the three-msdf-text-utils doc represents the UV coordinates of every particular person letter. For the reason that noise could be very delicate we are able to use an influence to visualise it higher on the letters.

To check our dissolve impact, let’s conceal components of the textual content the place the noise worth is above a given threshold.

//msdfText.js

...
createTextMaterial(fontAtlasTexture, perlinTexture) {
    const textMaterial = new MSDFTextNodeMaterial({
        map: fontAtlasTexture,
        clear: true,
    });

    const glyphUv = attribute("glyphUv", "vec2");

    const perlinTextureNode = texture(perlinTexture, glyphUv);
    const boostedPerlin = pow(perlinTextureNode, 2);
    const dissolve = step(boostedPerlin, 0.5);

    textMaterial.colorNode = boostedPerlin;
    const msdfOpacity = textMaterial.opacityNode;
    textMaterial.opacityNode = msdfOpacity.mul(dissolve);


    return textMaterial;
}
...

And that’s the essential logic behind the dissolve impact!

Now it’s an excellent time to introduce a fantastic debugger device, Tweakpane. We’ll use it to set off the dissolving impact, let’s set up it.

npm i tweakpane

For my tasks, I wish to create a singleton file devoted to Tweakpane that I can simply import wherever. Let’s create a debug.js file.

// debug.js

import { Pane } from "tweakpane";

export const DEBUG_FOLDERS = {
  MSDF_TEXT: "MSDFText",
};

class Debug {
  static occasion = null;
  static ENABLED = true;

  #pane = null;
  #baseFolder = null;
  #folders = new Map();

  static getInstance() {
      if (Debug.occasion === null) {
          Debug.occasion = new Debug();
      }
      return Debug.occasion;
  }
  constructor() {
    if (Debug.ENABLED) {
      this.#pane = new Pane();
      this.#baseFolder = this.#pane.addFolder({ title: "Debug" });
      this.#baseFolder.expanded = false;
    }
  }
  createNoOpProxy() {
    const handler = {
      get: () => (..._args) => this.createNoOpProxy(),
    };
    return new Proxy({}, handler);
  }

  getFolder(title) {
    if (!Debug.ENABLED) {
      return this.createNoOpProxy();
    }
    const present = this.#folders.get(title);
    if (present) {
      return present;
    }
    const folder = this.#baseFolder.addFolder({ title: title });
    this.#folders.set(title, folder);
    return folder;
  }
}

export default Debug;

I received’t go into an excessive amount of element in regards to the implementation, however because of this Debug class we are able to add debug folders and choices simply and disable it altogether if wanted by switching the ENABLED variable.

Let’s use it in our MSDF materials to manage the progress of the impact:

//msdfText.js
...
import Debug, { DEBUG_FOLDERS } from "./debug.js";
...

createTextMaterial(fontAtlasTexture, perlinTexture) {
  const debugFolder = Debug.getInstance().getFolder(DEBUG_FOLDERS.MSDF_TEXT);

  const textMaterial = new MSDFTextNodeMaterial({
      map: fontAtlasTexture,
      clear: true,
  });

  const glyphUv = attribute("glyphUv", "vec2");

  const uProgress = uniform(0.0);

  debugFolder.addBinding(uProgress, "worth", {
      min: 0,
      max: 1,
      label: "progress",
  });
  
  const perlinTextureNode = texture(perlinTexture, glyphUv);
  const boostedPerlin = pow(perlinTextureNode, 2);
  const dissolve = step(uProgress, boostedPerlin);

  textMaterial.colorNode = boostedPerlin;
  const msdfOpacity = textMaterial.opacityNode;
  textMaterial.opacityNode = msdfOpacity.mul(dissolve);


  return textMaterial;
}

By enjoying with the progress slider we are able to see the impact dissolving stay, however there’s an issue, the impact is means too uniform, every glyph dissolves in the very same means.

To get a extra natural impact we are able to use a brand new attribute from the MSDF lib middle, that introduces an offset for every letter. We are able to additional customise the texture of the dissolve by multiplying the middle and glyphUv attributes.

To higher visualize the change let’s create two uniforms, uCenterScale and uGlyphScale, and add them to our debug folder.

//msdfText.js
...
createTextMaterial(fontAtlasTexture, perlinTexture) {
  const textMaterial = new MSDFTextNodeMaterial({
      map: fontAtlasTexture,
      clear: true,
  });

  const glyphUv = attribute("glyphUv", "vec2");
  const middle = attribute("middle", "vec2");

  const uProgress = uniform(0.0);
  const uCenterScale = uniform(0.05);
  const uGlyphScale  = uniform(0.75);
  
  const customUv = middle.mul(uCenterScale).add(glyphUv.mul(uGlyphScale));
  
  const debugFolder = Debug.getInstance().getFolder(DEBUG_FOLDERS.MSDF_TEXT);
  debugFolder.addBinding(uProgress, "worth", {
      min: 0,
      max: 1,
      label: "progress",
  });
  debugFolder.addBinding(uCenterScale, "worth", {
      min: 0,
      max: 1,
      label: "centerScale",
  });
  debugFolder.addBinding(uGlyphScale, "worth", {
      min: 0,
      max: 1,
      label: "glyphScale",
  });

  const perlinTextureNode = texture(perlinTexture, customUv);
  const dissolve = step(uProgress, perlinTextureNode);

  textMaterial.colorNode = perlinTextureNode;
  const msdfOpacity = textMaterial.opacityNode;
  textMaterial.opacityNode = msdfOpacity.mul(dissolve);


  return textMaterial;
}
...

Be at liberty to check with totally different values for uCenterScale and uGlyphScale to see how they influence the dissolve, a decrease uGlyphScale will end in larger chunks dissolving for example. In case you used the identical texture and params for the noise as I did, you’ll discover that by the point progress reaches 0.7 the textual content has totally dissolved and that’s as a result of Perlin textures not often use the total 0–1 vary evenly.

Let’s remap the noise in order that values beneath uNoiseRemapMin turn out to be 0 and the values above uNoiseRemapMax turn out to be 1, and all the things in between is normalized to 0–1. This makes the dissolve timing extra constant over the uProgress vary:

//msdfText.js
...
createTextMaterial(fontAtlasTexture, perlinTexture) {
  const textMaterial = new MSDFTextNodeMaterial({
      map: fontAtlasTexture,
      clear: true,
  });

  const glyphUv = attribute("glyphUv", "vec2");
  const middle = attribute("middle", "vec2");

  const uProgress = uniform(0.0);
  const uNoiseRemapMin = uniform(0.4);
  const uNoiseRemapMax = uniform(0.87);
  const uCenterScale = uniform(0.05);
  const uGlyphScale  = uniform(0.75);
  
  const customUv = middle.mul(uCenterScale).add(glyphUv.mul(uGlyphScale));
  
  const debugFolder = Debug.getInstance().getFolder(DEBUG_FOLDERS.MSDF_TEXT);
  debugFolder.addBinding(uProgress, "worth", {
      min: 0,
      max: 1,
      label: "progress",
  });
  debugFolder.addBinding(uCenterScale, "worth", {
      min: 0,
      max: 1,
      label: "centerScale",
  });
  debugFolder.addBinding(uGlyphScale, "worth", {
      min: 0,
      max: 1,
      label: "glyphScale",
  });

  const perlinTextureNode = texture(perlinTexture, customUv);
  const perlinRemap = clamp(
      perlinTextureNode.sub(uNoiseRemapMin).div(uNoiseRemapMax.sub(uNoiseRemapMin)),
      0,
      1
    );
  const dissolve = step(uProgress, perlinRemap);

  textMaterial.colorNode = perlinRemap;
  const msdfOpacity = textMaterial.opacityNode;
  textMaterial.opacityNode = msdfOpacity.mul(dissolve);


  return textMaterial;
}
...

Now for the ultimate contact let’s use two colours for the textual content: the traditional one and a desaturated model, in order that they mix in the course of the impact development.

//msdfText.js
...
createTextMaterial(fontAtlasTexture, perlinTexture) {
  const textMaterial = new MSDFTextNodeMaterial({
      map: fontAtlasTexture,
      clear: true,
  });

  const glyphUv = attribute("glyphUv", "vec2");
  const middle = attribute("middle", "vec2");

  const uProgress = uniform(0.0);
  const uNoiseRemapMin = uniform(0.4);
  const uNoiseRemapMax = uniform(0.87);
  const uCenterScale = uniform(0.05);
  const uGlyphScale = uniform(0.75);
  const uDissolvedColor = uniform(new THREE.Shade("#5E5E5E"));
  const uDesatComplete = uniform(0.45);
  const uBaseColor = uniform(new THREE.Shade("#ECCFA3"));

  const customUv = middle.mul(uCenterScale).add(glyphUv.mul(uGlyphScale));

  const debugFolder = Debug.getInstance().getFolder(DEBUG_FOLDERS.MSDF_TEXT);
  debugFolder.addBinding(uProgress, "worth", {
      min: 0,
      max: 1,
      label: "progress",
  });
  debugFolder.addBinding(uCenterScale, "worth", {
      min: 0,
      max: 1,
      label: "centerScale",
  });
  debugFolder.addBinding(uGlyphScale, "worth", {
      min: 0,
      max: 1,
      label: "glyphScale",
  });

  const perlinTextureNode = texture(perlinTexture, customUv).x;
  const perlinRemap = clamp(
      perlinTextureNode.sub(uNoiseRemapMin).div(uNoiseRemapMax.sub(uNoiseRemapMin)),
      0,
      1
  );
  const dissolve = step(uProgress, perlinRemap);
  const desaturationProgress = smoothstep(float(0.0), uDesatComplete, uProgress);

  const colorMix = combine(uBaseColor, uDissolvedColor, desaturationProgress);
  textMaterial.colorNode = colorMix;
  const msdfOpacity = textMaterial.opacityNode;
  textMaterial.opacityNode = msdfOpacity.mul(dissolve);
  return textMaterial;
}

...

And that’s it for the Textual content Materials ! Let’s make slightly change: the uProgress uniform will probably be used for our different particle results, so it’ll be extra handy to create it in gommageOrchestrator.js and move it as a parameter.

//gommageOrchestrator.js
...
export default class GommageOrchestrator {
  constructor() {
  }

  async initialize(scene) {
      const uProgress = uniform(0.0);
      const msdfText = await MSDFTextEntity.initialize("WebGPU Gommage Impact", new THREE.Vector3(0, 0, 0), uProgress);
      scene.add(msdfText);
  }
}
//msdfText.js
...
export default class MSDFText {
  constructor() {
  }

  async initialize(textual content = "WebGPU Gommage Impact", place = new THREE.Vector3(0, 0, 0), uProgress) {
    ....
    const textMaterial = this.createTextMaterial(fontAtlasTexture, perlinTexture, uProgress);
    ...
  }
  createTextMaterial(fontAtlasTexture, perlinTexture, uProgress) {
    // Delete the uProgress declaration contained in the perform
    // We are able to additionally take away the opposite debug params
  }

Lastly create a debug button that can set off our Gommage (and one other to reset it). GSAP will probably be excellent for that:

npm i gsap

Now we are able to add the debug buttons in gommageOrchestrator.js.

//gommageOrchestrator.js
...
async initialize(scene) {
    ...
  const GommageButton = debugFolder.addButton({
      title: "GOMMAGE",
  });
  const ResetButton = debugFolder.addButton({
      title: "RESET",
  });
  GommageButton.on("click on", () => {
      this.triggerGommage();
  });
  ResetButton.on("click on", () => {
      this.resetGommage();
  });
}

triggerGommage() {
    gsap.to(this.#uProgress, {
        worth: 1,
        length: 4,
        ease: "linear",
    });
}

resetGommage() {
    this.#uProgress.worth = 0;
}

4. Including the Mud particles

Don’t hesitate to examine the demo GitHub repository to observe alongside, every commit match a bit of this tutorial! Take a look at this part’s commit.

Often in WebGL for particles akin to mud that are simply texture on a aircraft we might use a Factors primitive. Sadly with WebGPU there’s a huge limitation: variable level dimension just isn’t supported, so all factors seem the scale of 1 pixel. Probably not fitted to displaying a texture. So as an alternative there are two choices: Sprites or Instanced Mesh. Each can be working advantageous for our mud however since we’re going to implement Petals within the subsequent part, let’s maintain the identical logic between the 2 particle methods, so Instanced Mesh it’s. Now let’s create a brand new dustParticles.js file:

//dustParticles.js

import * as THREE from "three/webgpu";

export default class DustParticles {
  constructor() { }

  #spawnPos;
  #birthLifeSeedScale;
  #currentDustIndex = 0;
  #dustMesh;
  #MAX_DUST = 100;

  async initialize(perlinTexture, dustParticleTexture) {
    
    const dustGeometry = new THREE.PlaneGeometry(0.02, 0.02);
    this.#spawnPos = new Float32Array(this.#MAX_DUST * 3);
    // Mixed 4 attributes into one to not go above the 9 attribute restrict for webgpu
    this.#birthLifeSeedScale = new Float32Array(this.#MAX_DUST * 4);
    this.#currentDustIndex = 0;

    dustGeometry.setAttribute(
        "aSpawnPos",
        new THREE.InstancedBufferAttribute(this.#spawnPos, 3)
    );
    dustGeometry.setAttribute(
        "aBirthLifeSeedScale",
        new THREE.InstancedBufferAttribute(this.#birthLifeSeedScale, 4)
    );

    const materials = this.createDustMaterial(perlinTexture, dustParticleTexture);
    this.#dustMesh = new THREE.InstancedMesh(dustGeometry, materials, this.#MAX_DUST);
    return this.#dustMesh;
  }

  createDustMaterial(perlinTexture, dustTexture) {
    const materials = new THREE.MeshBasicMaterial({
      map: dustTexture,
      clear: true,
      depthWrite: false,
      depthTest: false,
    });

    return materials;
  }
}

As you’ll be able to see we’ve fairly a number of attributes for our mud, let’s go shortly over them:

  • aSpawnPos, would be the beginning place of a brand new mud particle.
  • aBirthLifeSeedScale, we pack 4 values into one instanced attribute to scale back WebGPU vertex inputs (InstancedMesh already consumes a number of). This avoids hitting WebGPU’s vertex buffer attribute limits and breaking the shader (occurred in the course of the improvement of this impact 🥲).
    • Delivery, will comprise the timestamp of the particle creation.
    • Life, is the time in seconds earlier than the particle disappear.
    • Seed, a random quantity between 0 and 1 to induce some randomness.
    • Scale, merely the scale of the particle.

Discover that we’ll additionally want our perlin texture, to keep away from repeating ourselves let’s transfer all the feel initialization to gommageOrchestrator.js and move it as a param for each the mud and the MSDFText, and take away all the things associated to texture loading in msdfText.js! Okay now we are able to load our textures and instantiate the mud in gommageOrchestrator.js.

//gommageOrchestrator.js
...
export default class GommageOrchestrator {
  ...
  async initialize(scene) {
    const { perlinTexture, dustParticleTexture, fontAtlasTexture } = await this.loadTextures();

    const debugFolder = Debug.getInstance().getFolder(DEBUG_FOLDERS.MSDF_TEXT);
    const MSDFTextEntity = new MSDFText();
    // /! Move the perlinTexture as parameters and take away the earlier texture load
    const msdfText = await MSDFTextEntity.initialize("WebGPU Gommage Impact", new THREE.Vector3(0, 0, 0), this.#uProgress, perlinTexture, fontAtlasTexture);
    scene.add(msdfText);

    const DustParticlesEntity = new DustParticles();
    const dustParticles = await DustParticlesEntity.initialize(perlinTexture, dustParticleTexture);
    scene.add(dustParticles);

    const GommageButton = debugFolder.addButton({
        title: "GOMMAGE",
    });
    const ResetButton = debugFolder.addButton({
        title: "RESET",
    });
    GommageButton.on("click on", () => {
        this.triggerGommage();
    });
    ResetButton.on("click on", () => {
        this.resetGommage();
    });
  }
  ...
  async loadTextures() {
    const textureLoader = new THREE.TextureLoader();

    const dustParticleTexture = await textureLoader.loadAsync("/textures/dustParticle.png");
    dustParticleTexture.colorSpace = THREE.NoColorSpace;
    dustParticleTexture.minFilter = THREE.LinearFilter;
    dustParticleTexture.magFilter = THREE.LinearFilter;
    dustParticleTexture.generateMipmaps = false;

    const perlinTexture = await textureLoader.loadAsync("/textures/perlin.webp");
    perlinTexture.colorSpace = THREE.NoColorSpace;
    perlinTexture.minFilter = THREE.LinearFilter;
    perlinTexture.magFilter = THREE.LinearFilter;
    perlinTexture.wrapS = THREE.RepeatWrapping;
    perlinTexture.wrapT = THREE.RepeatWrapping;
    perlinTexture.generateMipmaps = false;

    const fontAtlasTexture = await textureLoader.loadAsync("/fonts/Cinzel/Cinzel.png");
    fontAtlasTexture.colorSpace = THREE.NoColorSpace;
    fontAtlasTexture.minFilter = THREE.LinearFilter;
    fontAtlasTexture.magFilter = THREE.LinearFilter;
    fontAtlasTexture.wrapS = THREE.ClampToEdgeWrapping;
    fontAtlasTexture.wrapT = THREE.ClampToEdgeWrapping;
    fontAtlasTexture.generateMipmaps = false;

    return { perlinTexture, dustParticleTexture, fontAtlasTexture };
  }
  ...
}

Now I don’t know in the event you seen however our mud particles are within the scene!

Yep that’s the little white dot between the 2 “M” of “Gommage”, proper now we’ve 100 instanced meshes at the very same place! Now let’s create the perform that can spawn our mud particles in dustParticles.js!

//dustParticles.js
...
spawnDust(spawnPos) {
  if (this.#currentDustIndex === this.#MAX_DUST) this.#currentDustIndex = 0;
  const id = this.#currentDustIndex;
  this.#currentDustIndex = this.#currentDustIndex + 1;
  this.#spawnPos[id * 3 + 0] = spawnPos.x;
  this.#spawnPos[id * 3 + 1] = spawnPos.y;
  this.#spawnPos[id * 3 + 2] = spawnPos.z;
  this.#birthLifeSeedScale[id * 4 + 0] = efficiency.now() * 0.001; // Delivery time
  this.#birthLifeSeedScale[id * 4 + 1] = 4; // Life length
  this.#birthLifeSeedScale[id * 4 + 2] = Math.random(); // Random seed
  this.#birthLifeSeedScale[id * 4 + 3] = Math.random() * 0.5 + 0.5; // Random Scale

  this.#dustMesh.geometry.attributes.aSpawnPos.needsUpdate = true;
  this.#dustMesh.geometry.attributes.aBirthLifeSeedScale.needsUpdate = true;
}
...

Let’s adapt our materials a bit to account for the parameters:

//dustParticles.js
...
createDustMaterial(perlinTexture, dustTexture) {
  const materials = new THREE.MeshBasicMaterial({
      clear: true,
      depthWrite: false,
      depthTest: false,
  });

  const aSpawnPos = attribute("aSpawnPos", "vec3");
  const aBirthLifeSeedScale = attribute("aBirthLifeSeedScale", "vec4");
  const aBirth = aBirthLifeSeedScale.x;
  const aLife = aBirthLifeSeedScale.y;
  const aSeed = aBirthLifeSeedScale.z;
  const aScale = aBirthLifeSeedScale.w;

  const dustSample = texture(dustTexture, uv());


  const uDustColor = uniform(new THREE.Shade("#8A8A8A"));
  materials.colorNode = vec4(uDustColor, dustSample.a);
  materials.positionNode = aSpawnPos.add(positionLocal);

  return materials;
}
...

Now, we also needs to create a debug button to spawn some mud:

    //dustParticles.js
...
debugSpawnDust() {
  for (let i = 0; i < 10; i++) {
    this.spawnDust(
      new THREE.Vector3(
        (Math.random() * 2 - 1) * 0.5,
        (Math.random() * 2 - 1) * 0.5,
        0,
      )
    );
  }
}
...

And add it to our debug choices in gommageOrchestrator.js.

//gommageOrchestrator.js
...
export default class GommageOrchestrator {
  ...
  async initialize(scene) {
    ...
    const GommageButton = debugFolder.addButton({
        title: "GOMMAGE",
    });
    const ResetButton = debugFolder.addButton({
        title: "RESET",
    });
    const DustButton = debugFolder.addButton({
        title: "DUST",
    });
    GommageButton.on("click on", () => {
        this.triggerGommage();
    });
    ResetButton.on("click on", () => {
        this.resetGommage();
    });
    DustButton.on("click on", () => {
        DustParticlesEntity.debugSpawnDust();
    });
  }
  ...
}

After all a number of issues are lacking, again to dustParticles.js. Let’s start with a primary horizontal motion.

//dustParticles.js
...
createDustMaterial(perlinTexture, dustTexture) {
  const materials = new THREE.MeshBasicMaterial({
    clear: true,
    depthWrite: false,
    depthTest: false,
  });

  const aSpawnPos = attribute("aSpawnPos", "vec3");
  const aBirthLifeSeedScale = attribute("aBirthLifeSeedScale", "vec4");
  const aBirth = aBirthLifeSeedScale.x;
  const aLife = aBirthLifeSeedScale.y;
  const aSeed = aBirthLifeSeedScale.z;
  const aScale = aBirthLifeSeedScale.w;
  
  const uDustColor = uniform(new THREE.Shade("#8A8A8A"));
  const uWindDirection = uniform(new THREE.Vector3(-1, 0, 0).normalize());
  const uWindStrength = uniform(0.3);
  
  // Age of the mud in seconds
  const dustAge = time.sub(aBirth);
  
  const windImpulse = uWindDirection.mul(uWindStrength).mul(dustAge);
  const driftMovement = windImpulse;

  const dustSample = texture(dustTexture, uv());
  materials.colorNode = vec4(uDustColor, dustSample.a);
  materials.positionNode = aSpawnPos
  .add(driftMovement)
  .add(positionLocal);

  return materials;
}
...

Time to introduce uWindDirection and uWindStrength, these variables will probably be answerable for the course and depth of the bottom particle motion. For windImpulse we take the wind course and scale it by uWindStrength to get the particle’s velocity. Then we multiply by dustAge this creates a continuing, linear drift. Lastly we add this offset to positionNode to maneuver the particle.

Okay good begin, now let’s make our particles rise by updating the drift motion. Let’s create a brand new uniform uRiseSpeed, that can management the rise velocity.

//dustParticles.js
...
createDustMaterial(perlinTexture, dustTexture) {
  ...
  const uRiseSpeed = uniform(0.1);
  ...
  const windImpulse = uWindDirection.mul(uWindStrength).mul(dustAge);
  const rise = vec3(0.0, dustAge.mul(uRiseSpeed), 0.0);
  const driftMovement = windImpulse.add(rise);
  ...
}
...

Additionally let’s apply the dimensions attribute.

//dustParticles.js
...
createDustMaterial(perlinTexture, dustTexture) {
  ...
  materials.positionNode = aSpawnPos
    .add(driftMovement)
    .add(positionLocal.mul(aScale));
  ...
}
...

A pleasant element is to have the mud scale up shortly when it seems, and close to the top of its life have it fade out. Let’s introduce a variable that can symbolize the lifetime of a particle from 0 (its creation) to 1 (its demise).

//dustParticles.js
...
createDustMaterial(perlinTexture, dustTexture) {
  ...
  const driftMovement = windImpulse.add(rise);
  // 0 at creation, 1 at demise
  const lifeInterpolation = clamp(dustAge.div(aLife), 0, 1);
  ...
}
...

Let’s use it to scale up and fade out the particle.

//dustParticles.js
...
createDustMaterial(perlinTexture, dustTexture) {
  ...
  const lifeInterpolation = clamp(dustAge.div(aLife), 0, 1);
  const scaleFactor = smoothstep(float(0), float(0.05), lifeInterpolation);
  const fadingOut = float(1.0).sub(
    smoothstep(float(0.8), float(1.0), lifeInterpolation)
  );
  ...
  materials.positionNode = aSpawnPos
    .add(driftMovement)
    .add(positionLocal.mul(aScale.mul(scaleFactor)));
  materials.opacityNode = fadingOut;
  ...
}
...

Okay it’s already higher, however too uniform all of the mud behaves nearly precisely the identical. Let’s make use of some randomness.

//dustParticles.js
...
createDustMaterial(perlinTexture, dustTexture) {
  ...
  const uNoiseScale = uniform(30.0);
  const uNoiseSpeed = uniform(0.015);
  ...
  const randomSeed = vec2(aSeed.mul(1230.4), aSeed.mul(5670.8));
  
  const noiseUv = aSpawnPos.xz
    .add(randomSeed)
    .add(uWindDirection.xz.mul(dustAge.mul(uNoiseSpeed)));
  const noiseSample = texture(perlinTexture, noiseUv).x;
  ...
}
...

Okay so let’s examine what’s occurring right here, first we’ve 2 new uniforms. uNoiseScale controls how usually the noise sample repeats. A smaller worth means the variations are broader and the impact feels calmer. Quite the opposite, a much bigger worth give a extra turbulent look. uNoiseSpeed controls how briskly the noise sample slides over time. Larger values make the movement change quicker, decrease values maintain it delicate and gradual. To sum up, uNoiseScale modifications the form of the noise and uNoiseSpeed modifications the animation fee. Additionally to ensure two particles don’t find yourself utilizing the identical noise values, we multiply the seed by arbitrary massive numbers. With all that we are able to compute our noiseUv, which we’ll use to pattern our perlinTexture. Now let’s use this pattern, truly we’ll want two, one for the X axis and one for the Y axis, so as to add some random turbulence!

//dustParticles.js
...
createDustMaterial(perlinTexture, dustTexture) {
  ...
  const uWobbleAmp = uniform(0.6);
  ...
  const noiseSample = texture(perlinTexture, noiseUv).x;
  const noiseSampleBis = texture(perlinTexture, noiseUv.add(vec2(13.37, 7.77))).x;
  
  // Convert to turbulence values between -1 and 1.
  const turbulenceX = noiseSample.sub(0.5).mul(2);
  const turbulenceY = noiseSampleBis.sub(0.5).mul(2);
  
  const swirl = vec3(clamp(turbulenceX.mul(lifeInterpolation), 0, 1.0), turbulenceY.mul(lifeInterpolation), 0.0).mul(uWobbleAmp);
  ...
}
...

And that provides us a swirl worth that’ll add small random variations on each axes! By multiplying the turbulence values by lifeInterpolation, we be certain that the swirl isn’t too robust on the start of the particle. Now we are able to add the swirl to our driftMovement so as to add some randomness! Let’s additionally use it for our rise worth, to make it a bit extra random too, that’ll give us our last mud materials!

//dustParticles.js
...    
createDustMaterial(perlinTexture, dustTexture) {
const materials = new THREE.MeshBasicMaterial({
  clear: true,
  depthWrite: false,
  depthTest: false,
});

const aSpawnPos = attribute("aSpawnPos", "vec3");
const aBirthLifeSeedScale = attribute("aBirthLifeSeedScale", "vec4");
const aBirth = aBirthLifeSeedScale.x;
const aLife = aBirthLifeSeedScale.y;
const aSeed = aBirthLifeSeedScale.z;
const aScale = aBirthLifeSeedScale.w;

const uDustColor = uniform(new THREE.Shade("#8A8A8A"));
const uWindDirection = uniform(new THREE.Vector3(-1, 0, 0).normalize());
const uWindStrength = uniform(0.3);
const uRiseSpeed = uniform(0.1); // fixed elevate
const uNoiseScale = uniform(30.0); // begin small (frequency)
const uNoiseSpeed = uniform(0.015); // scroll velocity
const uWobbleAmp = uniform(0.6); // vertical wobble amplitude

// Age of the mud in seconds
const dustAge = time.sub(aBirth);
// 0 at creation, 1 at demise
const lifeInterpolation = clamp(dustAge.div(aLife), 0, 1);

// Use noise
const randomSeed = vec2(aSeed.mul(123.4), aSeed.mul(567.8));
const noiseUv = aSpawnPos.xz
  .mul(uNoiseScale)
  .add(randomSeed)
  .add(uWindDirection.xz.mul(dustAge.mul(uNoiseSpeed)));

  // Return a worth between 0 and 1.
const noiseSample = texture(perlinTexture, noiseUv).x;
const noiseSampleBis = texture(perlinTexture, noiseUv.add(vec2(13.37, 7.77))).x;

// Convert to turbulence values between -1 and 1.
const turbulenceX = noiseSample.sub(0.5).mul(2);
const turbulenceY = noiseSampleBis.sub(0.5).mul(2);

const swirl = vec3(clamp(turbulenceX.mul(lifeInterpolation), 0., 1.0), turbulenceY.mul(lifeInterpolation), 0.0).mul(uWobbleAmp);

const windImpulse = uWindDirection.mul(uWindStrength).mul(dustAge);
const riseFactor = clamp(noiseSample, 0.3, 1.0);
const rise = vec3(0.0, dustAge.mul(uRiseSpeed).mul(riseFactor), 0.0);
const driftMovement = windImpulse.add(rise).add(swirl);

const scaleFactor = smoothstep(float(0), float(0.05), lifeInterpolation);
const fadingOut = float(1.0).sub(
  smoothstep(float(0.8), float(1.0), lifeInterpolation)
);

const dustSample = texture(dustTexture, uv());
materials.colorNode = vec4(uDustColor, dustSample.a);
materials.positionNode = aSpawnPos
.add(driftMovement)
.add(positionLocal.mul(aScale.mul(scaleFactor)));
materials.opacityNode = fadingOut;

return materials;
}
...

It’s time to make use of our mud alongside our earlier dissolve impact and synchronize them! Let’s return to our msdfText.js file and create a perform that can give us a random place inside our textual content, that’ll give us our spawn positions for the mud.

//msdfText.js
...
export default class MSDFText {
  ...
  #worldPositionBounds;
  ...
  async initialize(textual content = "WebGPU Gommage Impact", place = new THREE.Vector3(0, 0, 0), uProgress, perlinTexture, fontAtlasTexture) {
    ....
    // Compute the world place bounds of our textual content
    textGeometry.computeBoundingBox();
    mesh.updateWorldMatrix(true, false);
    this.#worldPositionBounds = new THREE.Box3().setFromObject(mesh);
    return mesh;
  }
  ...
}

Within the initialize perform, on the finish, we compute the world positionBounds. This offers us a 3D field (min, max) that encloses the textual content in world house, which we are able to use to pattern random positions inside its bounds. Now let’s create our getRandomPositionInMesh perform.

//msdfText.js
...
export default class MSDFText {
  ...
  getRandomPositionInMesh() {
      const min = this.#worldPositionBounds.min;
      const max = this.#worldPositionBounds.max;
      const x = Math.random() * (max.x - min.x) + min.x;
      const y = Math.random() * (max.y - min.y) + min.y;
      const z = Math.random() * 0.5;
      return new THREE.Vector3(x, y, z);
    }
  ...
}

Okay, let’s replace our mud debug button to make use of these new bounds in gommageOrchestrator.js.

//gommageOrchestrator.js
...
export default class GommageOrchestrator {
  ...
  async initialize(scene) {
  ...
    DustButton.on("click on", () => {
      const randomPosition = MSDFTextEntity.getRandomPositionInMesh();
      DustParticlesEntity.spawnDust(randomPosition);
    });
  }
  ...
}

And now, by urgent the mud debug button a number of instances, we are able to see the particles being spawned inside the textual content bounds at random positions! Now we have to adapt gommageOrchestrator.js to synchronize the 2 results. For starter we’ll have to entry MSDFTextEntity and DustParticlesEntity within the triggerGommage perform, so let’s put them at class degree. Then within the triggerGommage perform, we’ll create a brand new tween, spawnDustTween, that can spawn a mud particle at a given interval. The smaller the interval worth, the extra particles will probably be spawned. Additionally, let’s retailer the tween as a category member, this fashion we’ll have extra management over it to restart or kill the impact! The ultimate class will appear like this:

//gommageOrchestrator.js

import * as THREE from "three/webgpu";
import MSDFText from "./msdfText.js";
import { uniform } from "three/tsl";
import DustParticles from "./dustParticles.js";
import Debug, { DEBUG_FOLDERS } from "./debug.js";
import gsap from "gsap";

export default class GommageOrchestrator {
  #uProgress = uniform(0.0);

  #MSDFTextEntity = null;
  #DustParticlesEntity = null;

  #dustInterval = 0.125;
  #gommageTween = null;
  #spawnDustTween = null;

  constructor() {
  }

  async initialize(scene) {
    const { perlinTexture, dustParticleTexture, fontAtlasTexture } = await this.loadTextures();

    const debugFolder = Debug.getInstance().getFolder(DEBUG_FOLDERS.MSDF_TEXT);
    this.#MSDFTextEntity = new MSDFText();
    const msdfText = await this.#MSDFTextEntity.initialize("WebGPU Gommage Impact", new THREE.Vector3(0, 0, 0), this.#uProgress, perlinTexture, fontAtlasTexture);
    scene.add(msdfText);

    this.#DustParticlesEntity = new DustParticles();
    const dustParticles = await this.#DustParticlesEntity.initialize(perlinTexture, dustParticleTexture);
    scene.add(dustParticles);

    const GommageButton = debugFolder.addButton({
        title: "GOMMAGE",
    });
    const ResetButton = debugFolder.addButton({
        title: "RESET",
    });
    const DustButton = debugFolder.addButton({
        title: "DUST",
    });
    GommageButton.on("click on", () => {
        this.triggerGommage();
    });
    ResetButton.on("click on", () => {
        this.resetGommage();
    });
    DustButton.on("click on", () => {
        const randomPosition = this.#MSDFTextEntity.getRandomPositionInMesh();
        this.#DustParticlesEntity.spawnDust(randomPosition);
    });
  }

  async loadTextures() {
    const textureLoader = new THREE.TextureLoader();

    const dustParticleTexture = await textureLoader.loadAsync("/textures/dustParticle.png");
    dustParticleTexture.colorSpace = THREE.NoColorSpace;
    dustParticleTexture.minFilter = THREE.LinearFilter;
    dustParticleTexture.magFilter = THREE.LinearFilter;
    dustParticleTexture.generateMipmaps = false;

    const perlinTexture = await textureLoader.loadAsync("/textures/perlin.webp");
    perlinTexture.colorSpace = THREE.NoColorSpace;
    perlinTexture.minFilter = THREE.LinearFilter;
    perlinTexture.magFilter = THREE.LinearFilter;
    perlinTexture.wrapS = THREE.RepeatWrapping;
    perlinTexture.wrapT = THREE.RepeatWrapping;
    perlinTexture.generateMipmaps = false;

    const fontAtlasTexture = await textureLoader.loadAsync("/fonts/Cinzel/Cinzel.png");
    fontAtlasTexture.colorSpace = THREE.NoColorSpace;
    fontAtlasTexture.minFilter = THREE.LinearFilter;
    fontAtlasTexture.magFilter = THREE.LinearFilter;
    fontAtlasTexture.wrapS = THREE.ClampToEdgeWrapping;
    fontAtlasTexture.wrapT = THREE.ClampToEdgeWrapping;
    fontAtlasTexture.generateMipmaps = false;

    return { perlinTexture, dustParticleTexture, fontAtlasTexture };
  }

  triggerGommage() {
    // Do not begin if already working
    if(this.#gommageTween || this.#spawnDustTween) return;

    this.#spawnDustTween = gsap.to({}, {
        length: this.#dustInterval,
        repeat: -1,
        onRepeat: () => {
            const p = this.#MSDFTextEntity.getRandomPositionInMesh();
            this.#DustParticlesEntity.spawnDust(p);
        },
    });

    this.#gommageTween = gsap.to(this.#uProgress, {
        worth: 1,
        length: 5,
        ease: "linear",
        onComplete: () => {
            this.#spawnDustTween?.kill();
            this.#spawnDustTween = null;
            this.#gommageTween = null;
        },
    });
  }

  resetGommage() {
    this.#gommageTween?.kill();
    this.#spawnDustTween?.kill();

    this.#gommageTween = null;
    this.#spawnDustTween = null;

    this.#uProgress.worth = 0;
  }
}

Phew, that was an enormous half! Excellent news is, for the petals a lot of the code will probably be immediately copied from our mud!

4. Petal particles

Don’t hesitate to examine the demo GitHub repository to observe alongside, every commit match a bit of this tutorial! Take a look at this part’s commit.

Okay let’s go for the ultimate a part of the impact! For the petal form, we’re going to make use of the geometry of a easy .glb mannequin that I created in Blender. Put it in public/fashions/ and cargo it in gommageOrchestrator.js.

You may seize the petal mannequin from the demo GitHub repository right here.

//gommageOrchestrator.js
...
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
...
export default class GommageOrchestrator {
  ...
  async initialize(scene) {
    const { perlinTexture, dustParticleTexture, fontAtlasTexture } = await this.loadTextures();
    const petalGeometry = await this.loadPetalGeometry();
    ...
  }
  ...
  async loadPetalGeometry() {
    const modelLoader = new GLTFLoader();
    const petalScene = await modelLoader.loadAsync("/fashions/petal.glb");
    const petalMesh = petalScene.scene.getObjectByName("PetalV2");
    return petalMesh.geometry;
  }
  ...
}

Let’s already put together the creation of our petal particles in initialize:

//gommageOrchestrator.js
...
export default class GommageOrchestrator {
  ...
  #PetalParticlesEntity = null;
  ...
  async initialize(scene) {
    ...
    this.#PetalParticlesEntity = new PetalParticles();
    const petalParticles = await this.#PetalParticlesEntity.initialize(perlinTexture, petalGeometry);
    scene.add(petalParticles);
    ...
  }
  ...
}

After all it doesn’t exist but so let’s copy a lot of the mud particles code into a brand new file petalParticles.js

//petalParticles.js

import * as THREE from "three/webgpu";
import { attribute, uniform, positionLocal, texture, vec4, uv, time, vec2, vec3, clamp, sin, smoothstep, float } from "three/tsl";

export default class PetalParticles {
  constructor() { }

  #spawnPos;
  #birthLifeSeedScale;
  #currentPetalIndex = 0;
  #petalMesh;
  #MAX_PETAL = 400;

  async initialize(perlinTexture, petalGeometry) {

    const petalGeo = petalGeometry.clone();
    const scale = 0.15;
    petalGeo.scale(scale, scale, scale);

    this.#spawnPos = new Float32Array(this.#MAX_PETAL * 3);
    // Mixed 4 attributes into one to not go above the 9 attribute restrict for webgpu
    this.#birthLifeSeedScale = new Float32Array(this.#MAX_PETAL * 4);
    this.#currentPetalIndex = 0;

    petalGeo.setAttribute(
        "aSpawnPos",
        new THREE.InstancedBufferAttribute(this.#spawnPos, 3)
    );
    petalGeo.setAttribute(
        "aBirthLifeSeedScale",
        new THREE.InstancedBufferAttribute(this.#birthLifeSeedScale, 4)
    );
    const materials = this.createPetalMaterial(perlinTexture);
    this.#petalMesh = new THREE.InstancedMesh(petalGeo, materials, this.#MAX_PETAL);
    return this.#petalMesh;
  }

  debugSpawnPetal() {
    for (let i = 0; i < 10; i++) {
      this.spawnPetal(
        new THREE.Vector3(
          (Math.random() * 2 - 1) * 0.5,
          (Math.random() * 2 - 1) * 0.5,
          0,
        )
      );
    }
  }

  spawnPetal(spawnPos) {
    if (this.#currentPetalIndex === this.#MAX_PETAL) this.#currentPetalIndex = 0;
    const id = this.#currentPetalIndex;
    this.#currentPetalIndex = this.#currentPetalIndex + 1;
    this.#spawnPos[id * 3 + 0] = spawnPos.x;
    this.#spawnPos[id * 3 + 1] = spawnPos.y;
    this.#spawnPos[id * 3 + 2] = spawnPos.z;
    this.#birthLifeSeedScale[id * 4 + 0] = efficiency.now() * 0.001; // Delivery time
    this.#birthLifeSeedScale[id * 4 + 1] = 6; // Life time
    this.#birthLifeSeedScale[id * 4 + 2] = Math.random(); // Random seed
    this.#birthLifeSeedScale[id * 4 + 3] = Math.random() * 0.5 + 0.5; // Scale

    this.#petalMesh.geometry.attributes.aSpawnPos.needsUpdate = true;
    this.#petalMesh.geometry.attributes.aBirthLifeSeedScale.needsUpdate = true;
  }

  createPetalMaterial(perlinTexture) {
    const materials = new THREE.MeshBasicMaterial({
        clear: true,
        facet: THREE.DoubleSide,
    });

    const aSpawnPos = attribute("aSpawnPos", "vec3");
    const aBirthLifeSeedScale = attribute("aBirthLifeSeedScale", "vec4");
    const aBirth = aBirthLifeSeedScale.x;
    const aLife = aBirthLifeSeedScale.y;
    const aSeed = aBirthLifeSeedScale.z;
    const aScale = aBirthLifeSeedScale.w;

    const uDustColor = uniform(new THREE.Shade("#8A8A8A"));
    const uWindDirection = uniform(new THREE.Vector3(-1, 0, 0).normalize());
    const uWindStrength = uniform(0.3);
    const uRiseSpeed = uniform(0.1); // fixed elevate
    const uNoiseScale = uniform(30.0); // begin small (frequency)
    const uNoiseSpeed = uniform(0.015); // scroll velocity
    const uWobbleAmp = uniform(0.6); // vertical wobble amplitude

    // Age of the mud in seconds
    const dustAge = time.sub(aBirth);
    const lifeInterpolation = clamp(dustAge.div(aLife), 0, 1);

    // Use noise
    const randomSeed = vec2(aSeed.mul(123.4), aSeed.mul(567.8));
    const noiseUv = aSpawnPos.xz
        .mul(uNoiseScale)
        .add(randomSeed)
        .add(uWindDirection.xz.mul(dustAge.mul(uNoiseSpeed)));

    // Return a worth between 0 and 1.
    const noiseSample = texture(perlinTexture, noiseUv).x;
    const noiseSammpleBis = texture(perlinTexture, noiseUv.add(vec2(13.37, 7.77))).x;

    // Convert to turbulence values between -1 and 1.
    const turbulenceX = noiseSample.sub(0.5).mul(2);
    const turbulenceY = noiseSammpleBis.sub(0.5).mul(2);

    const swirl = vec3(clamp(turbulenceX.mul(lifeInterpolation), 0., 1.0), turbulenceY.mul(lifeInterpolation), 0.0).mul(uWobbleAmp);

    const windImpulse = uWindDirection.mul(uWindStrength).mul(dustAge);

    const riseFactor = clamp(noiseSample, 0.3, 1.0);
    const rise = vec3(0.0, dustAge.mul(uRiseSpeed).mul(riseFactor), 0.0);
    const driftMovement = windImpulse.add(rise).add(swirl);

    // 0 at creation, 1 at demise
    const scaleFactor = smoothstep(float(0), float(0.05), lifeInterpolation);
    const fadingOut = float(1.0).sub(
        smoothstep(float(0.8), float(1.0), lifeInterpolation)
    );

    materials.colorNode = vec4(uDustColor, 1);
    materials.positionNode = aSpawnPos
        .add(driftMovement)
        .add(positionLocal.mul(aScale.mul(scaleFactor)));
    materials.opacityNode = fadingOut;

    return materials;
  }
}

At this step it’s principally the identical code, besides we use the petal geometry as an alternative of the mud texture, plus a small change to the fabric. I additionally set the petal life to six seconds in spawnPetal and added the DoubleSide parameter since our petals are going to spin! Let’s repair our code in gommageOrchestrator.js by importing the right class, and let’s add a easy debug button to create some petals:

//gommageOrchestrator.js
...
export default class GommageOrchestrator {
  ...
  #PetalParticlesEntity = null;
  ...
  async initialize(scene) {
    ...
    const PetalButton = debugFolder.addButton({
      title: "PETAL",
    });
    ...
    PetalButton.on("click on", () => {
      this.#PetalParticlesEntity.debugSpawnPetal();
    });
    ...
  }
  ...
}

Okay it’s not a lot but, however we’ve our geometry and the petal particles code appears to be working. Let’s begin enhancing it, again to petalParticles.js. Since we now have 3D fashions, let’s bend our petals to mirror that! In createPetalMaterial, let’s begin by including 3 features that can deal with rotation on all 3 axes. For the bending we’ll solely want the X rotation for now, however we’ll want the 2 others quickly after.

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    const materials = new THREE.MeshBasicNodeMaterial({
      clear: true,
      facet: THREE.DoubleSide,
    });

    perform rotX(a) {
      const c = cos(a);
      const s = sin(a);
      const ns = s.mul(-1.0);
      return mat3(1.0, 0.0, 0.0, 0.0, c, ns, 0.0, s, c);
    }
    perform rotY(a) {
      const c = cos(a);
      const s = sin(a);
      const ns = s.mul(-1.0);
      return mat3(c, 0.0, s, 0.0, 1.0, 0.0, ns, 0.0, c);
    }

    perform rotZ(a) {
      const c = cos(a);
      const s = sin(a);
      const ns = s.mul(-1.0);
      return mat3(c, ns, 0.0, s, c, 0.0, 0.0, 0.0, 1.0);
    }

    const aSpawnPos = attribute("aSpawnPos", "vec3");
    ...
  }
}

Now let’s create two new uniforms for the bending, uBendAmount and uBendSpeed:

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    ...
    const uNoiseSpeed = uniform(0.015);
    const uWobbleAmp = uniform(0.6);

    const uBendAmount = uniform(2.5);
    const uBendSpeed = uniform(1.0);
    ...
  }
}

And let’s compute the bending proper after the swirl definition:

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    ...
    const swirl = vec3(clamp(turbulenceX.mul(lifeInterpolation), 0, 1.0), turbulenceY.mul(lifeInterpolation), 0.0).mul(uWobbleAmp);

    // Bending
    const y = uv().y;
    const bendWeight = pow(y, float(3.0));

    const bend = bendWeight.mul(uBendAmount).mul(sin(dustAge.mul(uBendSpeed.mul(noiseSample))));

    const B = rotX(bend);
    ...
  }
}

Observe that bendWeight depends upon the UV y worth so we don’t bend the entire mannequin uniformly, the additional away from the petal base, the extra we bend. We additionally use dustAge to repeat the motion with a sin operator, and add a noise pattern so our petals don’t all bend collectively. Now, simply earlier than computing positionNode, let’s replace our native place:

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    ...
    const positionLocalUpdated = B.mul(positionLocal);
    materials.colorNode = vec4(uDustColor, 1);
    materials.positionNode = aSpawnPos
      .add(driftMovement)
      .add(positionLocalUpdated.mul(aScale.mul(scaleFactor)));
    materials.opacityNode = fadingOut;

    return materials;
  }
}

That’s it for the bending! And now it’s time for the spin, that can actually deliver life to the petals! Once more, let’s begin with two new uniforms, uSpinSpeed and uSpinAmp:

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    ...
    const uBendAmount = uniform(2.5);
    const uBendSpeed = uniform(1.0);
    const uSpinSpeed = uniform(2.0);
    const uSpinAmp = uniform(0.45);
    ...
  }
}

Let’s begin by including turbulenceZ, we’ll want it shortly after.

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    ...
    const turbulenceX = noiseSample.sub(0.5).mul(2);
    const turbulenceY = noiseSammpleBis.sub(0.5).mul(2);
    const turbulenceZ = noiseSample.sub(0.5).mul(2);
    ...
  }
}

And now we are able to compute the spin proper after the bending!

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    ...
    const baseX = aSeed.mul(1.13).mod(1.0).mul(TWO_PI);
    const baseY = aSeed.mul(2.17).mod(1.0).mul(TWO_PI);
    const baseZ = aSeed.mul(3.31).mod(1.0).mul(TWO_PI);

    const spin = dustAge.mul(uSpinSpeed).mul(uSpinAmp);
    const rx = baseX.add(spin.mul(0.9).mul(turbulenceX.add(1.5)));
    const ry = baseY.add(spin.mul(1.2).mul(turbulenceY.add(1.5)));
    const rz = baseZ.add(spin.mul(0.7).mul(turbulenceZ.add(1.5)));

    const R = rotY(ry).mul(rotX(rx)).mul(rotZ(rz));
    ...
    const positionLocalUpdated = R.mul(B.mul(positionLocal));
    ...
  }
}

Okay, earlier than we transfer on, let’s clarify what occurs on this code.

const baseX = aSeed.mul(1.13).mod(1.0).mul(TWO_PI);
const baseY = aSeed.mul(2.17).mod(1.0).mul(TWO_PI);
const baseZ = aSeed.mul(3.31).mod(1.0).mul(TWO_PI);

First we create a random base angle utilizing our random seed, the multiplication by totally different values ensures that we don’t get the identical angle on all axes, and the mod ensures that we keep inside a worth between 0 and 1. After that we merely multiply that quantity by TWO_PI (a TSL fixed) so we are able to get any worth as much as a full rotation.

const spin = dustAge.mul(uSpinSpeed).mul(uSpinAmp);
const rx = baseX.add(spin.mul(0.9).mul(turbulenceX.add(1.5)));
const ry = baseY.add(spin.mul(1.2).mul(turbulenceY.add(1.5)));
const rz = baseZ.add(spin.mul(0.7).mul(turbulenceZ.add(1.5)));

Now we compute a spin quantity that will increase over time and varies with the turbulence. uSpinSpeed controls how briskly the angle modifications over time, and uSpinAmp controls the quantity of rotation.

const R = rotY(ry).mul(rotX(rx)).mul(rotZ(rz));
...
const positionLocalUpdated = R.mul(B.mul(positionLocal));

With all that we are able to construct a rotation matrix that we’ll apply, together with the bending, to replace the positionLocal of our mesh. Happy with all these modifications your petal materials ought to appear like this:

//petalParticles.js
...
export default class PetalParticles {
...
createPetalMaterial(perlinTexture) {
  const materials = new THREE.MeshBasicNodeMaterial({
    clear: true,
    facet: THREE.DoubleSide,
  });

  perform rotX(a) {
    const c = cos(a);
    const s = sin(a);
    const ns = s.mul(-1.0);
    return mat3(1.0, 0.0, 0.0, 0.0, c, ns, 0.0, s, c);
  }
  perform rotY(a) {
    const c = cos(a);
    const s = sin(a);
    const ns = s.mul(-1.0);
    return mat3(c, 0.0, s, 0.0, 1.0, 0.0, ns, 0.0, c);
  }

  perform rotZ(a) {
    const c = cos(a);
    const s = sin(a);
    const ns = s.mul(-1.0);
    return mat3(c, ns, 0.0, s, c, 0.0, 0.0, 0.0, 1.0);
  }

  const aSpawnPos = attribute("aSpawnPos", "vec3");
  const aBirthLifeSeedScale = attribute("aBirthLifeSeedScale", "vec4");
  const aBirth = aBirthLifeSeedScale.x;
  const aLife = aBirthLifeSeedScale.y;
  const aSeed = aBirthLifeSeedScale.z;
  const aScale = aBirthLifeSeedScale.w;

  const uDustColor = uniform(new THREE.Shade("#8A8A8A"));
  const uWindDirection = uniform(new THREE.Vector3(-1, 0, 0).normalize());
  const uWindStrength = uniform(0.3);
  const uRiseSpeed = uniform(0.1); // fixed elevate
  const uNoiseScale = uniform(30.0); // begin small (frequency)
  const uNoiseSpeed = uniform(0.015); // scroll velocity
  const uWobbleAmp = uniform(0.6); // vertical wobble amplitude

  const uBendAmount = uniform(2.5);
  const uBendSpeed = uniform(1.0);
  const uSpinSpeed = uniform(2.0);
  const uSpinAmp = uniform(0.45); // total rotation quantity

  // Age of the mud in seconds
  const dustAge = time.sub(aBirth);
  const lifeInterpolation = clamp(dustAge.div(aLife), 0, 1);

  // Use noise
  const randomSeed = vec2(aSeed.mul(123.4), aSeed.mul(567.8));
  const noiseUv = aSpawnPos.xz
      .mul(uNoiseScale)
      .add(randomSeed)
      .add(uWindDirection.xz.mul(dustAge.mul(uNoiseSpeed)));

  // Return a worth between 0 and 1.
  const noiseSample = texture(perlinTexture, noiseUv).x;
  const noiseSammpleBis = texture(perlinTexture, noiseUv.add(vec2(13.37, 7.77))).x;

  // Convert to turbulence values between -1 and 1.
  const turbulenceX = noiseSample.sub(0.5).mul(2);
  const turbulenceY = noiseSammpleBis.sub(0.5).mul(2);
  const turbulenceZ = noiseSample.sub(0.5).mul(2);

  const swirl = vec3(clamp(turbulenceX.mul(lifeInterpolation), 0, 1.0), turbulenceY.mul(lifeInterpolation), 0.0).mul(uWobbleAmp);

  // Bending
  const y = uv().y;
  const bendWeight = pow(y, float(3.0));

  const bend = bendWeight.mul(uBendAmount).mul(sin(dustAge.mul(uBendSpeed.mul(noiseSample))));

  const B = rotX(bend);

  const windImpulse = uWindDirection.mul(uWindStrength).mul(dustAge);

  const riseFactor = clamp(noiseSample, 0.3, 1.0);
  const rise = vec3(0.0, dustAge.mul(uRiseSpeed).mul(riseFactor), 0.0);
  const driftMovement = windImpulse.add(rise).add(swirl);

  // Spin
  const baseX = aSeed.mul(1.13).mod(1.0).mul(TWO_PI);
  const baseY = aSeed.mul(2.17).mod(1.0).mul(TWO_PI);
  const baseZ = aSeed.mul(3.31).mod(1.0).mul(TWO_PI);

  const spin = dustAge.mul(uSpinSpeed).mul(uSpinAmp);
  const rx = baseX.add(spin.mul(0.9).mul(turbulenceX.add(1.5)));
  const ry = baseY.add(spin.mul(1.2).mul(turbulenceY.add(1.5)));
  const rz = baseZ.add(spin.mul(0.7).mul(turbulenceZ.add(1.5)));

  const R = rotY(ry).mul(rotX(rx)).mul(rotZ(rz));

  // 0 at creation, 1 at demise
  const scaleFactor = smoothstep(float(0), float(0.05), lifeInterpolation);
  const fadingOut = float(1.0).sub(
      smoothstep(float(0.8), float(1.0), lifeInterpolation)
  );

  // Replace native place
  const positionLocalUpdated = R.mul(B.mul(positionLocal));

  materials.colorNode = vec4(uDustColor, 1);
  materials.positionNode = aSpawnPos
      .add(driftMovement)
      .add(positionLocalUpdated.mul(aScale.mul(scaleFactor)));
  materials.opacityNode = fadingOut;

  return materials;
  }
}

And that was one of the crucial technical components of the mission, congratulations! Let’s alter the colour so it higher matches the Clair Obscur theme, however be happy to make use of any colours. Let’s create these two shade uniforms:

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    ...
    const uSpinSpeed = uniform(2.0);
    const uSpinAmp = uniform(0.45);
    const uRedColor = uniform(new THREE.Shade("#9B0000"));
    const uWhiteColor = uniform(new THREE.Shade("#EEEEEE"));
    ...
  }
}

And we are able to apply them to our colorNode.

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    ...
    const petalColor = combine(
      uRedColor,
      uWhiteColor,
      instanceIndex.mod(3).equal(0)
    );

    materials.colorNode = petalColor;
    ...
  }
}

A small trick is used right here, counting on instanceIndex (a TSL enter), in order that one third of the created petals are white.

We’re nearly there! Our petals really feel a bit flat as a result of there’s no lighting but, however we are able to compute a easy one to shortly add extra depth to the impact. We’ll want a uLightPosition uniform, final one of many lesson, I swear.

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    ...
    const uRedColor = uniform(new THREE.Shade("#9B0000"));
    const uWhiteColor = uniform(new THREE.Shade("#EEEEEE"));
    const uLightPosition = uniform(new THREE.Vector3(0, 0, 5));
    ...
  }
}

We’ll want the traditional for the sunshine computation, and since we’ve up to date our mannequin’s native place we additionally have to replace our normals! Let’s add:

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    ...
    const positionLocalUpdated = R.mul(B.mul(positionLocal));
    const normalUpdate = normalize(R.mul(B.mul(normalLocal)));
    ...
    materials.normalNode = normalUpdate;
    ...
  }
}

Additionally we’ll want the world place of the petals, so let’s extract the logic at present in positionNode right into a separate variable.

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    ...
    const positionLocalUpdated = R.mul(B.mul(positionLocal));
    const normalUpdate = normalize(R.mul(B.mul(normalLocal)));
    const worldPosition = aSpawnPos
      .add(driftMovement)
      .add(positionLocalUpdated.mul(aScale.mul(scaleFactor)));
    ...
    materials.positionNode = worldPosition;
    ...
  }
}

And at last, let’s compute whether or not our mannequin is dealing with the sunshine with:

//petalParticles.js
...
export default class PetalParticles {
  ...
  createPetalMaterial(perlinTexture) {
    ...
    const petalColor = combine(
      uRedColor,
      uWhiteColor,
      instanceIndex.mod(3).equal(0)
    );

    const lightDirection = normalize(uLightPosition.sub(worldPosition));
    const dealing with = clamp(abs(dot(normalUpdate, lightDirection)), 0.4, 1);

    materials.colorNode = petalColor.mul(dealing with);
    ...
  }
}

And that’s it for our petal materials, effectively achieved! Now we simply have to spawn them alongside our mud in gommageOrchestrator.js. Just like the mud, let’s add the category members petalInterval and spawnPetalTween.

//gommageOrchestrator.js
...
export default class GommageOrchestrator {
  ...
  #dustInterval = 0.125;
  #petalInterval = 0.05;
  #gommageTween = null;
  #spawnDustTween = null;
  #spawnPetalTween = null;
  ...
}

And within the triggerGommage perform, let’s add the tween for the petals:

We also needs to replace msdfText.js with a small change to getRandomPositionInMesh for the petals. It didn’t actually matter for the mud, however to keep away from having petals clipping into one another, let’s add a small Z offset to the place.

//msdfText.js
...
getRandomPositionInMesh() {
  const min = this.#worldPositionBounds.min;
  const max = this.#worldPositionBounds.max;
  const x = Math.random() * (max.x - min.x) + min.x;
  const y = Math.random() * (max.y - min.y) + min.y;
  const z = Math.random() * 0.5;
  return new THREE.Vector3(x, y, z);
}
...

And we’re achieved with the impact, thanks for following the information with me! Now let’s add the final particulars to shine the demo.

5. Final particulars

Don’t hesitate to examine the demo GitHub repository to observe alongside, every commit match a bit of this tutorial! Take a look at this part’s commit.

For the crowning glory let’s do two issues, add a bloom submit course of and a HTML button to set off the impact as an alternative of the debug. Each duties are pretty straightforward, it’ll be a brief half. Let’s begin with the submit processing in expertise.js. Let’s begin by including a #webgpuComposer class member and a setupPostprocessingGPGPU perform that can comprise our bloom impact. Then we name it in initialize, and we end by calling it within the render perform as an alternative of the earlier render command.

//expertise.js

import * as THREE from "three/webgpu";
import GommageOrchestrator from "./gommageOrchestrator.js";
import { float, mrt, move, output } from "three/tsl";
import { bloom } from "three/examples/jsm/tsl/show/BloomNode.js";

export class Expertise {

  #threejs = null;
  #scene = null;
  #digicam = null;
  #webgpuComposer = null;

  constructor() {}

  async initialize(container) {
    await this.#setupProject(container);
    window.addEventListener("resize",  this.#onWindowResize_.bind(this), false);
    await this.#setupPostprocessing();
    this.#raf();
  }

  async #setupProject(container) {
    this.#threejs = new THREE.WebGPURenderer({ antialias: true });
    await this.#threejs.init();

    this.#threejs.shadowMap.enabled = false;
    this.#threejs.toneMapping = THREE.ACESFilmicToneMapping;
    this.#threejs.setClearColor(0x111111, 1);
    this.#threejs.setSize(window.innerWidth, window.innerHeight);
    this.#threejs.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    container.appendChild(this.#threejs.domElement);

    // Digicam Setup !
    const fov = 45;
    const side = window.innerWidth / window.innerHeight;
    const close to = 0.1;
    const far = 25;
    this.#digicam = new THREE.PerspectiveCamera(fov, side, close to, far);
    this.#digicam.place.set(0, 0, 5);
    // Name window resize to compute FOV
    this.#onWindowResize_();
    this.#scene = new THREE.Scene();
    // Take a look at MSDF Textual content
    const gommageOrchestratorEntity = new GommageOrchestrator();
    await gommageOrchestratorEntity.initialize(this.#scene);
  }

  async #setupPostprocessing() {
    this.#webgpuComposer = new THREE.PostProcessing(this.#threejs);
    const scenePass = move(this.#scene, this.#digicam);

    scenePass.setMRT(
      mrt({
        output,
        bloomIntensity: float(0),
      })
    );
    let outNode = scenePass;

    const outputPass = scenePass.getTextureNode();
    const bloomIntensityPass = scenePass.getTextureNode('bloomIntensity');
    const bloomPass = bloom(outputPass.mul(bloomIntensityPass), 0.8);
    outNode = outNode.add(bloomPass);

    this.#webgpuComposer.outputNode = outNode.renderOutput();
    this.#webgpuComposer.needsUpdate = true;
  }

  #onWindowResize_() {
    const HORIZONTAL_FOV_TARGET = THREE.MathUtils.degToRad(45);
    this.#digicam.side = window.innerWidth / window.innerHeight;
    const verticalFov = 2 * Math.atan(Math.tan(HORIZONTAL_FOV_TARGET / 2) / this.#digicam.side);
    this.#digicam.fov = THREE.MathUtils.radToDeg(verticalFov);
    this.#digicam.updateProjectionMatrix();
    this.#threejs.setSize(window.innerWidth, window.innerHeight);
  }

  #render() {
    //this.#threejs.render(this.#scene, this.#digicam);
    this.#webgpuComposer.render();
  }

  #raf() {
    requestAnimationFrame(t => {
      this.#render();
      this.#raf();
    });
  }
}

new Expertise().initialize(doc.querySelector("#canvas-container"));

Utilizing MRT nodes lets our supplies output additional buffers in the identical scene render move. So alongside the traditional shade output, we write a bloomIntensity masks per materials. And in our setupPostprocessing, we learn this masks and multiply it with the colour buffer earlier than working BloomNode, so the bloom is utilized solely the place bloomIntensity is non zero. But nothing modifications since we didn’t set the MRT node in our supplies, let’s do it for the textual content, mud and petals.

//msdfText.js
...
createTextMaterial(fontAtlasTexture, perlinTexture, uProgress) {
  const textMaterial = new MSDFTextNodeMaterial({
    map: fontAtlasTexture,
    clear: true,
  });
  
  ...
  
  textMaterial.mrtNode = mrt({
    bloomIntensity: float(0.4).mul(dissolve),
  });

  return textMaterial;
}
//petalParticles.js
...
createPetalMaterial(perlinTexture) {
  const materials = new THREE.MeshBasicNodeMaterial({
      clear: true,
      facet: THREE.DoubleSide,
  });

  ....

  materials.mrtNode = mrt({
      bloomIntensity: float(0.7).mul(fadingOut),
    });

  return materials;
}
//dustParticles.js
...
createDustMaterial(perlinTexture, dustTexture) {
  const materials = new THREE.MeshBasicMaterial({
      clear: true,
      depthWrite: false,
      depthTest: false,
  });
  
  ...

  materials.mrtNode = mrt({
    bloomIntensity: float(0.5).mul(fadingOut),
  });

  return materials;
}

A lot better now! As a bonus, let’s use a button as an alternative of our debug panel to manage the impact, you’ll be able to copy this CSS file, controlUI.css.

@font-face {
  font-family: 'Cinzel';
  src: url('/fonts/Cinzel/Cinzel-Common.ttf') format('truetype');
  font-weight: regular;
  font-style: regular;
}

#control-ui-container {
  place: mounted;
  backside: 200px;
  z-index: 9999;
  width: 100%;
  left: 50%;
  show: flex;
  align-items: middle;
  justify-content: middle;
  hole: 24px;
  remodel: translateX(-50%);
  --e33-color: #D5CBB2;
}

.E33-button {
  font-family: 'Cinzel', serif;
  padding: 12px 30px;
  cursor: pointer;
  background-color: rgba(0, 0, 0, 0.7);
  shade: var(--e33-color);
  border: none;
  place: relative;
  clip-path: polygon(0% 50%,
          15px 0%,
          calc(100% - 15px) 0%,
          100% 50%,
          calc(100% - 15px) 100%,
          15px 100%);
  transition: remodel 0.15s ease-out 0.05s;
  font-size: 2rem;
  transition: opacity 0.15s ease-out 0.05s;

  &.disabled {
      opacity: 0.4;
      cursor: default;
  }
}

.E33-button::earlier than {
  content material: '';
  place: absolute;
  inset: 0;
  background: var(--e33-color);
  --borderSize: 1px;
  clip-path: polygon(0% 50%,
          15px 0%,
          calc(100% - 15px) 0%,
          100% 50%,
          calc(100% - 15px) 100%,
          15px 100%,
          0% 50%,

          var(--borderSize) 50%,
          calc(15px + 0.5px) calc(100% - var(--borderSize)),
          calc(100% - 15px - 0.5px) calc(100% - var(--borderSize)),
          calc(100% - var(--borderSize)) 50%,
          calc(100% - 15px - 0.5px) var(--borderSize),
          calc(15px + 0.5px) var(--borderSize),
          var(--borderSize) 50%);
  z-index: -1;
}

Now use it in your HTML:

<!DOCTYPE html>
<html lang="en" class="no-js">
  <head>
	  ...
    <hyperlink rel="stylesheet" sort="textual content/css" href="css/controlUI.css" />
    ...
    </head>
      <div id="canvas-container"></div>
      <div id="control-ui-container">
         <button class="E33-button" id="gommage-button">Begin</button>
      </div>
      ...
  </physique>
</html>

We are able to now hearken to the button interplay in gommageOrchestrator.js.

//gommageOrchestrator.js
...
async initialize(scene) {
  ...
  // Use HTML buttons
  const gommageButton = doc.getElementById("gommage-button");
  gommageButton.addEventListener("click on", () => {
      this.triggerGommage();
  });
  ...
}
...

And let’s end by disabling our Debug class in debug.js.

//debug.js
...
class Debug {
  static occasion = null;
  static ENABLED = false;
...

And that’s it for actual this time! I hope you realized a factor or two on this tutorial. To develop the demo a bit, you’ll be able to add some choices to alter the petal quantity or replace the textual content dynamically, get inventive!



Supply hyperlink

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles