# Pop-free gradient edits on GPUParticleSystem

**URL:** <https://forum.babylonjs.com/t/pop-free-gradient-edits-on-gpuparticlesystem/63917>\
**Category:** Bugs\
**Tags:** particles\
**Created:** [August 8, 2026, 5:37am UTC](https://forum.babylonjs.com/t/pop-free-gradient-edits-on-gpuparticlesystem/63917 "2026-08-08T05:37:06Z")\
**Posts on this page:** 10\
**Page:** 1

<div class="post-metadata">

**Author:** ![increasinglyHuman](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/increasinglyhuman/32/51340_2.png) [@increasinglyHuman](https://forum.babylonjs.com/u/increasinglyHuman)\
**Post date:** [August 8, 2026, 5:37am UTC](https://forum.babylonjs.com/t/pop-free-gradient-edits-on-gpuparticlesystem/63917/1 "2026-08-08T05:37:06Z")

</div>

## **TL;DR**

GPU particles in Babylon keep their size/color ramps in tiny lookup textures. Today, changing a ramp — even nudging one value with a slider — makes Babylon throw away the **whole box of live particles** and start over. On screen: every slider tick makes your 100,000-particle waterfall blink out and refill.

The obvious workaround (“just swap in a new lookup texture yourself”) is a trap: on WebGPU the simulation keeps holding the _old_ texture, decides it’s “not ready,” and **quietly stops forever** — the emitter looks dead until you rebuild it. We shipped that bug in production and spent a night rooting it out.

The actual fix is tiny: the lookup texture is always the same size no matter how many ramp points you have, so for value changes Babylon can just **repaint the pixels inside the texture it already has** — nothing thrown away, nothing re-bound, no blink, no deadlock. Throwing everything away is only genuinely needed when a ramp _appears or disappears_ (that changes the particle memory layout). We’ve been running exactly this pixel-repaint approach in production: smooth live edits on a capacity-3000 GPU system, zero measured FPS cost, verified on WebGPU (Chrome/Edge) and WebGL2 (Firefox).

[![](https://img.youtube.com/vi/HPxwHLapmKA/maxresdefault.jpg "Live editing particle solutions for any in sim object.") ](https://www.youtube.com/watch?v=HPxwHLapmKA)

Normally the drag to adjust solution would trigger popping to clear color - destroys the entire sim when we try to adjust the size etc. Thought the workaround might be useful to others - or helpful to the team.

**1. Issue text**

**Title:** GPUParticleSystem: gradient edits destroy the live particle pool — and the workaround every user reaches for deadlocks WebGPU

**Body:**

`GPUParticleSystem.addColorGradient` / `addSizeGradient` (and every other factor-gradient add) call `_releaseBuffers()` unconditionally, and `remove*Gradient` does the same via `_removeGradientAndTexture` + `_releaseBuffers`. The sim’s double buffers are torn down and rebuilt, so **the entire live population dies for a frame** on every gradient change.

For live-editing UIs (a size/color slider driving a running system), the only public path is remove-all + re-add per input event — the system visibly strobes (“pops to clear”) on every tick. The CPU `ParticleSystem` has no such cost: its per-frame processing reads the live gradient arrays, so value edits are free.

**The trap this sets on WebGPU:** users who reach past the public API to avoid the pop (mutate `_sizeGradients` values, dispose + null `_sizeGradientsTexture` so `_recreateUpdateEffect` lazily re-bakes) hit a hard deadlock on the WebGPU backend: `ComputeShaderParticleSystem` retains the old texture in the update `ComputeShader`’s bindings, `ComputeShader.isReady()` checks every bound texture, a disposed texture is never ready — and the only code that re-binds the fresh texture (`updateParticleBuffer`) is reached through `render()`, which early-returns on `isReady() === false`. The system freezes silently and permanently: started, not stopped, `getActiveCount() > 0`, `isReady()` false forever, until a full rebuild. (WebGL2 is immune — its update effect binds textures per draw.) We shipped exactly this bug and root-caused it in production; happy to share the full writeup.

**Why the release looks avoidable for value edits:** the gradient lookup textures are fixed-size (`_rawTextureWidth` × 1, RG-float for factors, RGBA8 for color) regardless of stop count. Buffer layout only depends on gradient _presence_: `_initialize()` adjusts `_attributesStrideSize` when `_colorGradientsTexture` (−4) or `_angularSpeedGradientsTexture` (−1) exist, and the update-shader defines flip on presence. So:

- **presence flip (0 ↔ n gradients), or `color2` two-row flip** → genuinely structural: stride/defines change, release is correct;
- **value/count changes within an active family** → only the texture _content_ changes; a `RawTexture.update()` re-bake with stable identity would suffice — no release, no pop, and no stale-binding hazard.

Would a PR along those lines be welcome? (Sketch attached / see PR.)

* * *

## **2. PR description**

**Title:** GPU particles: gradient edits within an active family no longer destroy the live pool

**Body:**

Fixes the per-edit population wipe described in #.

- `_refreshColorGradient` / `_refreshFactorGradient` now re-bake the existing gradient texture’s pixels in place (`RawTexture.update`) when the family stays active and the texture layout is unchanged, instead of disposing and lazily recreating it. Texture identity is stable, so retained WebGPU compute bindings remain valid.
- `add*Gradient` / `remove*Gradient` skip `_releaseBuffers()` when the re-bake handled the change. The release (and texture recreation) still happens for the genuinely structural transitions: a family appearing or disappearing, and a color gradient set gaining/losing `color2` (two-row texture, render vertex-buffer layout).
- No public API change; behavior change is that live particles now survive gradient edits — matching the CPU `ParticleSystem`, where per-frame processing already reads the live arrays and edits were always pop-free.

Tests: value-edit keeps buffer objects + texture identity; presence flips still release; visual scene in the playground (link) shows a slider-driven size gradient on a 100k GPU system with zero population loss.

* * *

## **3. Patch sketch (against 9.17.1 shapes; adapt to master)**

New private helpers on `GPUParticleSystem`:

```auto
/** Re-bakes an active factor-gradient lookup texture in place. Returns false
 * when there is nothing to re-bake (no texture yet, or family emptied) —
 * callers fall back to the dispose + release path. */
private _rebakeFactorGradientTexture(factorGradients: Nullable<FactorGradient[]>, textureName: string): boolean {
    const texture = (this as any)[textureName] as Nullable<RawTexture>;
    if (!texture || !factorGradients || !factorGradients.length) {
        return false;
    }
    const data = new Float32Array(this._rawTextureWidth * 2);
    for (let x = 0; x < this._rawTextureWidth; x++) {
        const ratio = x / this._rawTextureWidth;
        GradientHelper.GetCurrentGradient(ratio, factorGradients, (currentGradient, nextGradient, scale) => {
            const cg = currentGradient as FactorGradient;
            const ng = nextGradient as FactorGradient;
            data[x * 2] = Lerp(cg.factor1, ng.factor1, scale);
            data[x * 2 + 1] = Lerp(cg.factor2 ?? cg.factor1, ng.factor2 ?? ng.factor1, scale);
        });
    }
    texture.update(data);
    return true;
}

/** Same for the color texture. Only valid while the row layout is unchanged —
 * a color2 appearing/disappearing changes texture height and render vertex
 * buffers, which stays on the structural path. */
private _rebakeColorGradientTexture(): boolean {
    const texture = this._colorGradientsTexture;
    if (!texture || !this._colorGradients || !this._colorGradients.length) {
        return false;
    }
    const hadColor2 = texture.getSize().height === 2;
    if (hadColor2 !== this._hasColorGradientColor2) {
        return false; // row-layout flip — structural
    }
    // ...identical loop to _createColorGradientTexture, writing into a
    // Uint8Array of the same dimensions, then texture.update(data).
    return true;
}

```

Call-site shape (size shown; color/angular/velocity/limit/drag identical):

```auto
public addSizeGradient(gradient: number, factor: number): GPUParticleSystem {
    if (!this._sizeGradients) {
        this._sizeGradients = [];
    }
    const sizeGradient = new FactorGradient(gradient, factor);
    this._sizeGradients.push(sizeGradient);
    this._sizeGradients.sort((a, b) => a.gradient - b.gradient);
    if (!this._rebakeFactorGradientTexture(this._sizeGradients, "_sizeGradientsTexture")) {
        this._refreshFactorGradient(this._sizeGradients, "_sizeGradientsTexture"); // dispose + null
        this._releaseBuffers(); // presence flip: stride/defines change
    }
    return this;
}

```

Notes for the implementation pass:

- The removal path (`_removeGradientAndTexture`) currently disposes the texture before the caller nulls it; with the re-bake it should leave the texture alone when the family stays non-empty.
- `forceRefreshGradients()` on the GPU class currently calls `this.reset()` (empties the pool); with the re-bake helpers it can resync all active textures pop-free and only reset on structural change. That is a separate, more visible behavior change — maintainers may want it split out.
- The stride analysis to cite when asked “why was the release ever needed”: `_initialize()` subtracts 4 from `_attributesStrideSize` when `_colorGradientsTexture` exists and 1 when `_angularSpeedGradientsTexture` exists; the update-shader defines (`SIZEGRADIENTS`, `COLORGRADIENTS`, …) flip on texture presence in `_recreateUpdateEffect`. Value edits change neither.

* * *

## **4. Downstream context (ours)**

poqpoq World’s `ParticleDirector` currently implements the value-edit re-bake externally with version-pinned private-field reads (`updateGpuFactorGradientInPlace` / `updateGpuColorGradientInPlace`, `src/particles/ParticleDirector.ts`). If upstream lands, those collapse back onto the public `add*/remove*` API and the pinned mirror gets deleted — tracked as the follow-up on poqpoq-world #1090’s arc. Field data available for the PR discussion: three-browser verification (Chrome/Edge WebGPU, Firefox WebGL2), zero measured FPS delta on continuous slider drags against a capacity-3000 promoted system.

---

<div class="post-metadata">

**Author:** ![AlitarSemiramis](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/alitarsemiramis/32/49920_2.png) [@AlitarSemiramis](https://forum.babylonjs.com/u/AlitarSemiramis)\
**Post date:** [August 8, 2026, 6:28am UTC](https://forum.babylonjs.com/t/pop-free-gradient-edits-on-gpuparticlesystem/63917/2 "2026-08-08T06:28:58Z")

</div>

Nice investigation and showcase! Do you want to create a PR for this?

---

<div class="post-metadata">

**Author:** ![increasinglyHuman](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/increasinglyhuman/32/51340_2.png) [@increasinglyHuman](https://forum.babylonjs.com/u/increasinglyHuman)\
**Post date:** [August 8, 2026, 1:38pm UTC](https://forum.babylonjs.com/t/pop-free-gradient-edits-on-gpuparticlesystem/63917/3 "2026-08-08T13:38:27Z")

</div>

\*Gladly, PR coming. We’re running this fix in production (poqpoq World); I’ll port it against master with tests.

–Allen\*

---

<div class="post-metadata">

**Author:** ![increasinglyHuman](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/increasinglyhuman/32/51340_2.png) [@increasinglyHuman](https://forum.babylonjs.com/u/increasinglyHuman)\
**Post date:** [August 8, 2026, 2:54pm UTC](https://forum.babylonjs.com/t/pop-free-gradient-edits-on-gpuparticlesystem/63917/4 "2026-08-08T14:54:20Z")

</div>

PR is up: [GPU particles: re-bake gradient lookup textures in place for value edits by increasinglyHuman · Pull Request #18787 · BabylonJS/Babylon.js · GitHub](https://github.com/BabylonJS/Babylon.js/pull/18787) — ported against master with unit tests covering the re-bake and the structural paths (family appearing/disappearing, color2 row flip still release). Details and a Playground repro in the PR description.

---

<div class="post-metadata">

**Author:** ![AlitarSemiramis](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/alitarsemiramis/32/49920_2.png) [@AlitarSemiramis](https://forum.babylonjs.com/u/AlitarSemiramis)\
**Post date:** [August 8, 2026, 6:23pm UTC](https://forum.babylonjs.com/t/pop-free-gradient-edits-on-gpuparticlesystem/63917/5 "2026-08-08T18:23:54Z")

</div>

That was fast! I’ll check on Monday, thank you so much for contributing! 🙂

---

<div class="post-metadata">

**Author:** ![increasinglyHuman](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/increasinglyhuman/32/51340_2.png) [@increasinglyHuman](https://forum.babylonjs.com/u/increasinglyHuman)\
**Post date:** [August 8, 2026, 8:49pm UTC](https://forum.babylonjs.com/t/pop-free-gradient-edits-on-gpuparticlesystem/63917/6 "2026-08-08T20:49:40Z")

</div>

yvw - hope it’s helpful.

---

<div class="post-metadata">

**Author:** ![sebavan](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/sebavan/32/57_2.png) [@sebavan](https://forum.babylonjs.com/u/sebavan)\
**Post date:** [August 10, 2026, 2:52pm UTC](https://forum.babylonjs.com/t/pop-free-gradient-edits-on-gpuparticlesystem/63917/7 "2026-08-10T14:52:20Z")

</div>

Thanks for the PR better understand the context.

---

<div class="post-metadata">

**Author:** ![AlitarSemiramis](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/alitarsemiramis/32/49920_2.png) [@AlitarSemiramis](https://forum.babylonjs.com/u/AlitarSemiramis)\
**Post date:** [August 10, 2026, 9:26pm UTC](https://forum.babylonjs.com/t/pop-free-gradient-edits-on-gpuparticlesystem/63917/8 "2026-08-10T21:26:54Z")

</div>

There’s a comment that needs addressing, the rest looks good 🙂

---

<div class="post-metadata">

**Author:** ![AlitarSemiramis](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/alitarsemiramis/32/49920_2.png) [@AlitarSemiramis](https://forum.babylonjs.com/u/AlitarSemiramis)\
**Post date:** [August 12, 2026, 5:26pm UTC](https://forum.babylonjs.com/t/pop-free-gradient-edits-on-gpuparticlesystem/63917/9 "2026-08-12T17:26:00Z")

</div>

This has been merged, thank you for your contribution @increasinglyHuman !

---

<div class="post-metadata">

**Author:** ![increasinglyHuman](https://sea2.discourse-cdn.com/flex024/user_avatar/forum.babylonjs.com/increasinglyhuman/32/51340_2.png) [@increasinglyHuman](https://forum.babylonjs.com/u/increasinglyHuman)\
**Post date:** [August 12, 2026, 6:32pm UTC](https://forum.babylonjs.com/t/pop-free-gradient-edits-on-gpuparticlesystem/63917/10 "2026-08-12T18:32:21Z")

</div>

Yvw. You guys are amazing.
