Most web games hand-roll their particle effects, then hand-roll them again when the renderer changes. I've been picking apart which parts of an effect genuinely have to be renderer-specific, and it turns out to be less than I expected.
What actually is renderer-specific: how sprites get batched, what a draw call costs, blend-mode naming, texture upload. Roughly that.
What usually gets written as if it were, but isn't:
Lifetime curves. Size, alpha and colour are nearly always a function of t = age / life. Store them as keyframe data instead of as branching inside the draw call and the same definition replays anywhere. Small demo: https://codepen.io/SonaArajyan03/details/xbqGxVm
Motion fields. If direction is sampled from a shared field each frame rather than held as per-particle velocity state, you can retune the entire look by editing the field, and per-particle memory drops. Demo: https://codepen.io/SonaArajyan03/details/jEBPOML
Emission shape and rate. Pure data.
Blending intent. Additive vs alpha is portable even though the API name for it isn't.
The practical upshot: keep those four as a serialisable description, write a thin per-renderer player, and porting an effect stops being a rewrite and becomes a config change.
Disclosure: I work on NixieFX (https://nixiefx.com/threejs-runtime/), which is built on exactly this split, so I'm not a neutral party here. The split holds even if you roll your own though. What I'd actually like to know is where it breaks: has anyone hit an effect the curve-plus-field model genuinely can't express?