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 objective right here is sensible: use the thought as a purpose to discover trendy Three.js WebGPU + TSL workflows in a challenge that’s visually rewarding and technically actual.
The tutorial is step-by-step on objective: we’ll begin from a clean challenge, then add one system at a time (textual content, dissolve, particles, post-processing), conserving all the things simple to tweak and perceive. In case you’d reasonably 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 be taught:
- 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 reasonably soar round, every part hyperlinks to the matching GitHub commit!
Right here is the ultimate consequence:
0. Inspiration
In 2025 I performed Clair Obscur: Expedition 33 and located the entire expertise wonderful (and apparently I wasn’t the one one). I wished to present an homage to the sport by recreating the “Gommage”, a curse that makes individuals disappear, leaving solely a path of flower petals as soon as they attain a sure age (play the sport, it’s going to all make sense).
Yep, it’s very dramatic however let’s recover from it and analyze it a bit. If we simplify it, we will see three issues occurring:
- A disintegration/dissolve impact.
- Small specks of mud flying out.
- Crimson petals flowing out (we may also use white petals in our expertise to carry some selection).
Let’s implement all that!
1. Base Setup for Three.js
Don’t hesitate to test the demo GitHub repository to observe alongside, every commit match a piece of this tutorial! Try this part’s commit.
Begin with a challenge 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 elegance the canvas in base.css.
//index.html
// base.css
canvas {
place: fastened;
high: 0;
left: 0;
width: 100%;
top: 100%;
}
Now we’re going to create an Expertise class in expertise.js that may include 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 to respect the digicam parameters and place!
Word that I additionally added a check dice to test 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 facet = window.innerWidth / window.innerHeight;
const close to = 0.1;
const far = 25;
this.#digicam = new THREE.PerspectiveCamera(fov, facet, 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({ coloration: 0x00ff00 });
this.#dice = new THREE.Mesh(geometry, materials);
this.#scene.add(this.#dice);
}
#onWindowResize_() {
this.#digicam.facet = 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 test the demo GitHub repository to observe alongside, every commit matches a piece of this tutorial! Try this part’s commit.
Now a little bit of context about find out how to show textual content in a Three.js scene.
SDF (Signed Distance Discipline Fonts) and its different MSDF (Multi-channel Signed Distance Discipline) 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 wished 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 alternative.
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 can be excellent to make use of with TSL!
Now we simply want a instrument 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 just a few moments on the choices right here.
When producing the MSDF, relying in your params, your ultimate textual content can include visible artifacts or not look good at each zoom stage.
A greater high quality atlas additionally means a heavier PNG file, so it’s essential to discover a stability.
- font-size: Larger font measurement means extra element for the glyphs, but it surely’ll take extra space within the atlas (once more, a heavier file).
- distance-range: The larger the vary, the extra we will improve/cut back the fonts with out artifacts.
- texture-padding: Empty area between the glyphs — some of the essential params to keep away from artifacts and bleeding.
- border: Provides some area 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/
Needless to say 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 check dice (perform and animation) in expertise.js. Let’s create 2 new information: gommageOrchestrator.js, which can arrange the completely different results and msdfText.js, which can be chargeable 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 information
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: "heart",
});
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 items
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 giant at first, usually too giant to even be displayed on the display screen! The trick is to compute a scale issue utilizing targetLineHeight/lineHeightPx to transform the font’s pixel metrics into the specified line top in world items.
If the textual content seems too large in your present display screen be at liberty to regulate the targetLineHeight! We will test 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 go away 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.facet = window.innerWidth / window.innerHeight;
const verticalFov = 2 * Math.atan(Math.tan(HORIZONTAL_FOV_TARGET / 2) / this.#digicam.facet);
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 would like 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 facet ratio and replace the projection matrix.
And let’s not overlook 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 screen, nice job 🔥
3. Dissolving the textual content
Don’t hesitate to test the demo GitHub repository to observe alongside, every commit match a piece of this tutorial! Try 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 elements 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, it's also possible to 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 once 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;
}
...

Word that we're utilizing the glyphUv, which in line with the three-msdf-text-utils doc represents the UV coordinates of every particular person letter. For the reason that noise could be very refined we will use an influence to visualise it higher on the letters.
To check our dissolve impact, let’s conceal elements 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 fundamental logic behind the dissolve impact!
Now it’s a very good time to introduce an important debugger instrument, Tweakpane. We’ll use it to set off the dissolving impact, let’s set up it.
npm i tweakpane
For my initiatives, I prefer to create a singleton file devoted to Tweakpane that I can simply import anyplace. 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(identify) {
if (!Debug.ENABLED) {
return this.createNoOpProxy();
}
const current = this.#folders.get(identify);
if (current) {
return current;
}
const folder = this.#baseFolder.addFolder({ title: identify });
this.#folders.set(identify, 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 will 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 regulate 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 will see the impact dissolving stay, however there’s an issue, the impact is approach too uniform, every glyph dissolves in the very same approach.
To get a extra natural impact we will use a brand new attribute from the MSDF lib heart, that introduces an offset for every letter. We will additional customise the texture of the dissolve by multiplying the heart and glyphUv attributes.
To raised 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 heart = attribute("heart", "vec2");
const uProgress = uniform(0.0);
const uCenterScale = uniform(0.05);
const uGlyphScale = uniform(0.75);
const customUv = heart.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 completely different values for uCenterScale and uGlyphScale to see how they impression the dissolve, a decrease uGlyphScale will lead to larger chunks dissolving as an 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 hardly ever use the total 0–1 vary evenly.
Let’s remap the noise in order that values under uNoiseRemapMin turn into 0 and the values above uNoiseRemapMax turn into 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 heart = attribute("heart", "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 = heart.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 conventional one and a desaturated model, in order that they mix through the impact development.
//msdfText.js
...
createTextMaterial(fontAtlasTexture, perlinTexture) {
const textMaterial = new MSDFTextNodeMaterial({
map: fontAtlasTexture,
clear: true,
});
const glyphUv = attribute("glyphUv", "vec2");
const heart = attribute("heart", "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.Colour("#5E5E5E"));
const uDesatComplete = uniform(0.45);
const uBaseColor = uniform(new THREE.Colour("#ECCFA3"));
const customUv = heart.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 can 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 will additionally take away the opposite debug params
}
Lastly create a debug button that may set off our Gommage (and one other to reset it). GSAP can be excellent for that:
npm i gsap
Now we will 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 test the demo GitHub repository to observe alongside, every commit match a piece of this tutorial! Try this part’s commit.
Normally in WebGL for particles corresponding to mud that are simply texture on a airplane we might use a Factors primitive. Sadly with WebGPU there's a large limitation: variable level measurement will not be supported, so all factors seem the scale of 1 pixel. Not likely fitted to displaying a texture. So as an alternative there are two choices: Sprites or Instanced Mesh. Each could be working superb for our mud however since we're going to implement Petals within the subsequent part, let’s hold 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 just a few attributes for our mud, let’s go rapidly over them:
aSpawnPos, would be the beginning place of a brand new mud particle.aBirthLifeSeedScale, we pack 4 values into one instanced attribute to cut back WebGPU vertex inputs (InstancedMesh already consumes a number of). This avoids hitting WebGPU’s vertex buffer attribute limits and breaking the shader (occurred through the growth of this impact 🥲).- Delivery, will include 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 are going to 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 will 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();
// /! Go 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 when 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 may 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.Colour("#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 plenty 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.Colour("#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 can be chargeable for the path and depth of the bottom particle motion. For windImpulse we take the wind path 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 may 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 rapidly when it seems, and close to the top of its life have it fade out. Let’s introduce a variable that may symbolize the lifetime of a particle from 0 (its creation) to 1 (its dying).
//dustParticles.js
...
createDustMaterial(perlinTexture, dustTexture) {
...
const driftMovement = windImpulse.add(rise);
// 0 at creation, 1 at dying
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 test what’s happening 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. Greater values make the movement change sooner, decrease values hold it refined and sluggish. To sum up, uNoiseScale adjustments the form of the noise and uNoiseSpeed adjustments the animation charge. Additionally to verify two particles don’t find yourself utilizing the identical noise values, we multiply the seed by arbitrary giant numbers. With all that we will compute our noiseUv, which we’ll use to pattern our perlinTexture. Now let’s use this pattern, really 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 sturdy on the delivery of the particle. Now we will 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 ultimate 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.Colour("#8A8A8A"));
const uWindDirection = uniform(new THREE.Vector3(-1, 0, 0).normalize());
const uWindStrength = uniform(0.3);
const uRiseSpeed = uniform(0.1); // fixed raise
const uNoiseScale = uniform(30.0); // begin small (frequency)
const uNoiseSpeed = uniform(0.015); // scroll pace
const uWobbleAmp = uniform(0.6); // vertical wobble amplitude
// Age of the mud in seconds
const dustAge = time.sub(aBirth);
// 0 at creation, 1 at dying
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 may 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 provides us a 3D field (min, max) that encloses the textual content in world area, which we will 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 will 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 stage. Then within the triggerGommage perform, we’ll create a brand new tween, spawnDustTween, that may spawn a mud particle at a given interval. The smaller the interval worth, the extra particles can 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 to be 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 operating
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 can be immediately copied from our mud!
4. Petal particles
Don’t hesitate to test the demo GitHub repository to observe alongside, every commit match a piece of this tutorial! Try 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,
aspect: 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.Colour("#8A8A8A"));
const uWindDirection = uniform(new THREE.Vector3(-1, 0, 0).normalize());
const uWindStrength = uniform(0.3);
const uRiseSpeed = uniform(0.1); // fixed raise
const uNoiseScale = uniform(30.0); // begin small (frequency)
const uNoiseSpeed = uniform(0.015); // scroll pace
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 dying
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 largely 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 replicate that! In createPetalMaterial, let’s begin by including 3 features that may 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,
aspect: 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);
...
}
}
Word that bendWeight is dependent 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 may actually carry 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 will 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 completely 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 will 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 adjustments 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 will construct a rotation matrix that we’ll apply, together with the bending, to replace the positionLocal of our mesh. Comfortable with all these adjustments your petal materials ought to appear to be this:
//petalParticles.js
...
export default class PetalParticles {
...
createPetalMaterial(perlinTexture) {
const materials = new THREE.MeshBasicNodeMaterial({
clear: true,
aspect: 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.Colour("#8A8A8A"));
const uWindDirection = uniform(new THREE.Vector3(-1, 0, 0).normalize());
const uWindStrength = uniform(0.3);
const uRiseSpeed = uniform(0.1); // fixed raise
const uNoiseScale = uniform(30.0); // begin small (frequency)
const uNoiseSpeed = uniform(0.015); // scroll pace
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); // general 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 dying
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 some of the technical elements of the challenge, congratulations! Let’s modify the colour so it higher matches the Clair Obscur theme, however be at liberty to make use of any colours. Let’s create these two coloration uniforms:
//petalParticles.js
...
export default class PetalParticles {
...
createPetalMaterial(perlinTexture) {
...
const uSpinSpeed = uniform(2.0);
const uSpinAmp = uniform(0.45);
const uRedColor = uniform(new THREE.Colour("#9B0000"));
const uWhiteColor = uniform(new THREE.Colour("#EEEEEE"));
...
}
}
And we will 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 will compute a easy one to rapidly 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.Colour("#9B0000"));
const uWhiteColor = uniform(new THREE.Colour("#EEEEEE"));
const uLightPosition = uniform(new THREE.Vector3(0, 0, 5));
...
}
}
We’ll want the conventional 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 presently 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 eventually, 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 accomplished! Now we simply have to spawn them alongside our mud in gommageOrchestrator.js. Much 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 accomplished 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 test the demo GitHub repository to observe alongside, every commit match a piece of this tutorial! Try this part’s commit.
For the completion let’s do two issues, add a bloom put up course of and a HTML button to set off the impact as an alternative of the debug. Each duties are pretty simple, it’ll be a brief half. Let’s begin with the put up processing in expertise.js. Let’s begin by including a #webgpuComposer class member and a setupPostprocessingGPGPU perform that may include 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 facet = window.innerWidth / window.innerHeight;
const close to = 0.1;
const far = 25;
this.#digicam = new THREE.PerspectiveCamera(fov, facet, close to, far);
this.#digicam.place.set(0, 0, 5);
// Name window resize to compute FOV
this.#onWindowResize_();
this.#scene = new THREE.Scene();
// Check 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.facet = window.innerWidth / window.innerHeight;
const verticalFov = 2 * Math.atan(Math.tan(HORIZONTAL_FOV_TARGET / 2) / this.#digicam.facet);
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 further buffers in the identical scene render move. So alongside the conventional coloration 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 operating BloomNode, so the bloom is utilized solely the place bloomIntensity is non zero. But nothing adjustments 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,
aspect: 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 regulate 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: fastened;
backside: 200px;
z-index: 9999;
width: 100%;
left: 50%;
show: flex;
align-items: heart;
justify-content: heart;
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);
coloration: 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:
...
...
...









