Deep dive into: WebGPU Gommage Effect: Dissolving MSDF Text into Dust and Petals with Three.js & TSL

Build a Three.js WebGPU scene where MSDF text dissolves with a noise-driven TSL shader while synchronized dust and spinning petal particles burst out, finished with selective bloom using MRT.

Free course recommendation: Master JavaScript animation with GSAP through 34 free video lessons, step-by-step projects, and hands-on demos. Enroll now →

We’re going to build a small WebGPU “moment”: a piece of MSDF text in a Three.js scene that disintegrates over time, shedding dust and petals as it fades away. It’s inspired by the Gommage effect from Clair Obscur: Expedition 33, but the goal here is practical: use the idea as a reason to explore modern Three.js WebGPU + TSL workflows in a project that’s visually rewarding and technically real.

The tutorial is step-by-step on purpose: we’ll start from a blank project, then add one system at a time (text, dissolve, particles, post-processing), keeping everything easy to tweak and understand. If you’d rather skip around, each section links to the matching GitHub commit so you can jump straight to the part you care about.

This tutorial is long on purpose. It’s written step by step so you can understand why each part works. If you’d rather jump around, each section links to the matching GitHub commit!

Learn modern web animation using GSAP 3 with 34 hands-on video lessons and practical projects — perfect for all skill levels.

In 2025 I played Clair Obscur: Expedition 33 and found the whole experience amazing (and apparently I wasn’t the only one). I wanted to give an homage to the game by recreating the “Gommage”, a curse that makes people disappear, leaving only a trail of flower petals once they reach a certain age (play the game, it will all make sense).

Yep, it’s very dramatic but let’s get over it and analyze it a bit. If we simplify it, we can see three things happening:

Don’t hesitate to check the demo GitHub repository to follow along, each commit match a section of this tutorial! Check out this section’s commit.

Start with a project containing just an index.html file and base.css files. First things first, let’s install Vite and Three.js:

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

Now we’re going to create an Experience class in experience.js that will contain the base code for Three.js. I won’t go into much detail for this part as it’s pretty common, just make sure to respect the camera parameters and position!

Don’t hesitate to check the demo GitHub repository to follow along, each commit matches a section of this tutorial! Check out this section’s commit.

SDF (Signed Distance Field Fonts) and its alternative MSDF (Multi-channel Signed Distance Field) are font rendering formats where glyph distances are encoded in RGB format.

Initially, I wanted to use the Troika library that uses SDF text but at the time of writing this article the lib was not compatible with WebGPU and TSL, so I had to find a replacement.

After some research I found the library Three MSDF Text by Léo Mouraire, and it was perfect for the use case, compatible with WebGPU, and it even used an MSDFTextNodeMaterial that will be perfect to use with TSL!

Now we just need a tool to convert a font to be usable with MSDF, and this library by Shen Yiming, is perfect for that. Clair Obscur uses the Cinzel font from Google Fonts, so convert it with this command after downloading the font:

When generating the MSDF, depending on your params, your final text can contain visual artifacts or not look good at every zoom level.

Keep in mind that even with good settings some typefaces convert more cleanly to MSDF than others. And that gives us the PNG and JSON files that we need to display our MSDF Text!

Also, remove everything related to our test cube (function and animation) in experience.js. Let’s create 2 new files: gommageOrchestrator.js, which will organize the different effects and msdfText.js, which will be responsible for displaying the MSDF text. We’ll start with msdfText.js, to load our PNG atlas use a texture loader and a simple fetch for our JSON file.

Notice the part where we compute the text scale depending on the variable targetLineHeight = 0.4. By default the text geometry is expressed in font pixels (based on fontData.common.lineHeight). That’s why it appears extremely large at first, often too large to even be displayed on the screen! The trick is to compute a scale factor using targetLineHeight/lineHeightPx to convert the font’s pixel metrics into the desired line height in world units.

If the text appears too big for your current screen feel free to adjust the targetLineHeight! We can check that it works well by instantiating the MSDFText entity in experience.js.

And here is our text! Before we leave experience.js, let’s do a small adjustment to the onWindowResize function to make our experience responsive.

Since the text is centered, we want the camera’s horizontal FOV to stay constant (45°) so the framing doesn’t change when the viewport resizes.

Three.js stores the camera FOV as a vertical FOV, so on resize we recompute the corresponding vertical FOV from the current aspect ratio and update the projection matrix.

Now, before we finish this part, put the logic to create our MSDF Text in our new file gommageOrchestrator.js

Don’t hesitate to check the demo GitHub repository to follow along, each commit match a section of this tutorial! Check out this section’s commit.

The process to dissolve the text is quite simple, we’ll use a Perlin texture and depending on a progress value going from 0 to 1, we’ll set a threshold that progressively hides parts of the text until it’s all gone. For the Perlin Texture I used one found on the Screaming brain studios website: https://screamingbrainstudios.com/downloads/

If you want to use the same one as me, you can also get the texture directly from the demo GitHub repository.

Analysis & Development

We’ll need to customize our text material, and that’s where TSL is going to be useful! Let’s create a function for that and use it when we instance our text mesh.

Note that we are using the glyphUv, which according to the three-msdf-text-utils doc represents the UV coordinates of each individual letter. Since the noise is very subtle we can use a power to visualize it better on the letters.

To test our dissolve effect, let’s hide parts of the text where the noise value is above a given threshold.

Now it’s a good time to introduce a great debugger tool, Tweakpane. We’ll use it to trigger the dissolving effect, let’s install it.

For my projects, I like to create a singleton file dedicated to Tweakpane that I can just import anywhere. Let’s create a debug.js file.

I won’t go into too much detail about the implementation, but thanks to this Debug class we can add debug folders and options easily and disable it altogether if needed by switching the ENABLED variable.

By playing with the progress slider we can see the effect dissolving live, but there’s a problem, the effect is way too uniform, each glyph dissolves in the exact same way.

To get a more organic effect we can use a new attribute from the MSDF lib center, that introduces an offset for each letter. We can further customize the feel of the dissolve by multiplying the center and glyphUv attributes.

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

Feel free to test with different values for uCenterScale and uGlyphScale to see how they impact the dissolve, a lower uGlyphScale will result in bigger chunks dissolving for instance. If you used the same texture and params for the noise as I did, you’ll notice that by the time progress reaches 0.7 the text has fully dissolved and that’s because Perlin textures rarely use the full 0–1 range evenly.

Let’s remap the noise so that values below uNoiseRemapMin become 0 and the values above uNoiseRemapMax become 1, and everything in between is normalized to 0–1. This makes the dissolve timing more consistent over the uProgress range:

Now for the final touch let’s use two colors for the text: the normal one and a desaturated version, so that they blend during the effect progression.

And that’s it for the Text Material ! Let’s make a little change: the uProgress uniform will be used for our other particle effects, so it’ll be more convenient to create it in gommageOrchestrator.js and pass it as a parameter.

Finally create a debug button that will trigger our Gommage (and another to reset it). GSAP will be perfect for that:

Don’t hesitate to check the demo GitHub repository to follow along, each commit match a section of this tutorial! Check out this section’s commit.

Usually in WebGL for particles such as dust which are just texture on a plane we would use a Points primitive. Unfortunately with WebGPU there is a big limitation: variable point size is not supported, so all points appear the size of 1 pixel. Not really suited for displaying a texture. So instead there are two options: Sprites or Instanced Mesh. Both would be working fine for our dust but since we are going to implement Petals in the next section, let’s keep the same logic between the two particle systems, so Instanced Mesh it is. Now let’s create a new dustParticles.js file:

Notice that we will also need our perlin texture, to avoid repeating ourselves let’s move all the texture initialization to gommageOrchestrator.js and pass it as a param for both the dust and the MSDFText, and remove everything related to texture loading in msdfText.js! Ok now we can load our textures and instantiate the dust in gommageOrchestrator.js.

Yep that’s the little white dot between the two “M” of “Gommage”, right now we have 100 instanced meshes at the exact same place! Now let’s create the function that will spawn our dust particles in dustParticles.js!

Of course lots of things are missing, back to dustParticles.js. Let’s begin with a basic horizontal movement.

Time to introduce uWindDirection and uWindStrength, those variables will be responsible for the direction and intensity of the base particle movement. For windImpulse we take the wind direction and scale it by uWindStrength to get the particle’s velocity. Then we multiply by dustAge this creates a constant, linear drift. Finally we add this offset to positionNode to move the particle.

Ok nice start, now let’s make our particles rise by updating the drift movement. Let’s create a new uniform uRiseSpeed, that will control the rise velocity.

A nice detail is to have the dust scale up quickly when it appears, and near the end of its life have it fade out. Let’s introduce a variable that will represent the lifetime of a particle from 0 (its creation) to 1 (its death).

Ok it’s already better, but too uniform all the dust behaves almost exactly the same. Let’s make use of some randomness.

Ok so let’s check what’s going on here, first we have 2 new uniforms. uNoiseScale controls how often the noise pattern repeats. A smaller value means the variations are broader and the effect feels calmer. On the contrary, a bigger value give a more turbulent look. uNoiseSpeed controls how fast the noise pattern slides over time. Higher values make the motion change faster, lower values keep it subtle and slow. To sum up, uNoiseScale changes the shape of the noise and uNoiseSpeed changes the animation rate. Also to make sure two particles don’t end up using the same noise values, we multiply the seed by arbitrary large numbers. With all that we can compute our noiseUv, which we’ll use to sample our perlinTexture. Now let’s use this sample, actually we’ll need two, one for the X axis and one for the Y axis, to add some random turbulence!

And that gives us a swirl value that’ll add small random variations on both axes! By multiplying the turbulence values by lifeInterpolation, we ensure that the swirl isn’t too strong at the birth of the particle. Now we can add the swirl to our driftMovement to add some randomness! Let’s also use it for our rise value, to make it a bit more random too, that’ll give us our final dust material!

It’s time to use our dust alongside our previous dissolve effect and synchronize them! Let’s go back to our msdfText.js file and create a function that will give us a random position inside our text, that’ll give us our spawn positions for the dust.

In the initialize function, at the end, we compute the world positionBounds. This gives us a 3D box (min, max) that encloses the text in world space, which we can use to sample random positions within its bounds. Now let’s create our getRandomPositionInMesh function.

And now, by pressing the dust debug button multiple times, we can see the particles being spawned within the text bounds at random positions! Now we need to adapt gommageOrchestrator.js to synchronize the two effects. For starter we’ll need to access MSDFTextEntity and DustParticlesEntity in the triggerGommage function, so let’s put them at class level. Then in the triggerGommage function, we’ll create a new tween, spawnDustTween, that will spawn a dust particle at a given interval. The smaller the interval value, the more particles will be spawned. Also, let’s store the tween as a class member, this way we’ll have more control over it to restart or kill the effect! The final class will look like this:

Future Impact

Phew, that was a big part! Good news is, for the petals most of the code will be directly copied from our dust!

Don’t hesitate to check the demo GitHub repository to follow along, each commit match a section of this tutorial! Check out this section’s commit.

Ok let’s go for the final part of the effect! For the petal shape, we’re going to use the geometry of a simple .glb model that I created in Blender. Put it in public/models/ and load it in gommageOrchestrator.js.

Of course it doesn’t exist yet so let’s copy most of the dust particles code into a new file petalParticles.js

At this step it’s mostly the same code, except we use the petal geometry instead of the dust texture, plus a small change to the material. I also set the petal life to 6 seconds in spawnPetal and added the DoubleSide parameter since our petals are going to spin! Let’s fix our code in gommageOrchestrator.js by importing the correct class, and let’s add a simple debug button to create some petals:

Ok it’s not much yet, but we have our geometry and the petal particles code seems to be working. Let’s start improving it, back to petalParticles.js. Since we now have 3D models, let’s bend our petals to reflect that! In createPetalMaterial, let’s start by adding 3 functions that will handle rotation on all 3 axes. For the bending we’ll only need the X rotation for now, but we’ll need the two others soon after.

Note that bendWeight depends on the UV y value so we don’t bend the whole model uniformly, the further away from the petal base, the more we bend. We also use dustAge to repeat the movement with a sin operator, and add a noise sample so our petals don’t all bend together. Now, just before computing positionNode, let’s update our local position:

That’s it for the bending! And now it’s time for the spin, that will really bring life to the petals! Again, let’s start with two new uniforms, uSpinSpeed and uSpinAmp:

First we create a random base angle using our random seed, the multiplication by different values ensures that we don’t get the same angle on all axes, and the mod ensures that we stay within a value between 0 and 1. After that we simply multiply that number by TWO_PI (a TSL constant) so we can get any value up to a full rotation.

Now we compute a spin amount that increases over time and varies with the turbulence. uSpinSpeed controls how fast the angle changes over time, and uSpinAmp controls the amount of rotation.

With all that we can build a rotation matrix that we’ll apply, along with the bending, to update the positionLocal of our mesh. Ok with all those changes your petal material should look like this:

And that was one of the most technical parts of the project, congratulations! Let’s adjust the color so it better matches the Clair Obscur theme, but feel free to use any colors. Let’s create these two color uniforms:

A small trick is used here, relying on instanceIndex (a TSL input), so that one third of the created petals are white.

We’re almost there! Our petals feel a bit flat because there’s no lighting yet, but we can compute a simple one to quickly add more depth to the effect. We’ll need a uLightPosition uniform, last one of the lesson, I swear.

We’ll need the normal for the light computation, and since we’ve updated our model’s local position we also need to update our normals! Let’s add:

Also we’ll need the world position of the petals, so let’s extract the logic currently in positionNode into a separate variable.

And that’s it for our petal material, well done! Now we just need to spawn them alongside our dust in gommageOrchestrator.js. Similar to the dust, let’s add the class members petalInterval and spawnPetalTween.

We should also update msdfText.js with a small change to getRandomPositionInMesh for the petals. It didn’t really matter for the dust, but to avoid having petals clipping into each other, let’s add a small Z offset to the position.

And we’re done with the effect, thank you for following the guide with me! Now let’s add the last details to polish the demo.

Don’t hesitate to check the demo GitHub repository to follow along, each commit match a section of this tutorial! Check out this section’s commit.

For the finishing touch let’s do two things, add a bloom post process and a HTML button to trigger the effect instead of the debug. Both tasks are fairly easy, it’ll be a short part. Let’s start with the post processing in experience.js. Let’s start by adding a #webgpuComposer class member and a setupPostprocessingGPGPU function that will contain our bloom effect. Then we call it in initialize, and we finish by calling it in the render function instead of the previous render command.

Using MRT nodes lets our materials output extra buffers in the same scene render pass. So alongside the normal color output, we write a bloomIntensity mask per material. And in our setupPostprocessing, we read this mask and multiply it with the color buffer before running BloomNode, so the bloom is applied only where bloomIntensity is non zero. Yet nothing changes since we didn’t set the MRT node in our materials, let’s do it for the text, dust and petals.

Much better now! As a bonus, let’s use a button instead of our debug panel to control the effect, you can copy this CSS file, controlUI.css.

And that’s it for real this time! I hope you learned a thing or two in this tutorial. To expand the demo a bit, you can add some options to change the petal amount or update the text dynamically, get creative!

I’m a Creative developer and like to create cool things for the web. Mostly (but not only) using Three.js, Blender and GSAP.

Source: View Original