Pop-free gradient edits on GPUParticleSystem

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).

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:

/** 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):

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.

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

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

–Allen*

PR is up: GPU particles: re-bake gradient lookup textures in place for value edits by increasinglyHuman · Pull Request #18787 · BabylonJS/Babylon.js · GitHub — 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.

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

yvw - hope it’s helpful.

Thanks for the PR better understand the context.

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

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

Yvw. You guys are amazing.