Skip to content

t1k:cocos:playable:particle-ui

FieldValue
Moduleplayable
Version2.14.4
Effortmedium
Tools—

Keywords: 2d, 3d to ui, canvas, invisible, node scale, render particle in ui, scale particle, tiny, too small, ui, uimeshrenderer, uitransform

/t1k:cocos:playable:particle-ui

Make a 3D cc.ParticleSystem prefab render correctly (and at a usable size) inside a Canvas / UI tree. Two independent steps, both applied by scripts/particle-3d-to-ui.cjs (pure file-based, no editor/MCP needed):

  1. UI components + UI_2D layer — a 3D particle will NOT draw under a Canvas until its node has a cc.UIMeshRenderer and sits on the UI_2D layer. Add cc.UITransform + cc.UIMeshRenderer to every particle node; add cc.UITransform to the root node; move every node to UI_2D (1 << 25 = 33554432). The components alone are not enough — the UI camera filters by layer, so a prefab left on DEFAULT (1 << 30) renders nothing and gives no error. Pass --no-layer to opt out of the layer move (e.g. a wrapper node is shared with a 3D-only sibling and must stay off UI_2D) — the script still adds the UI components and reports how many nodes remain off-layer, so you know what still needs a manual move.
  2. Scale by data, never by node.scale — UI/Canvas space is pixel-scaled (huge vs. 3D metres), so a ported effect looks microscopic. Multiply the spatial params by a factor. Do NOT set node.setScale() / _lscale — Cocos scales the simulation oddly and the particles smear/clip (this is the whole reason the skill exists).
Terminal window
# add UI comps AND scale x60 in one pass (recommended first run)
node scripts/particle-3d-to-ui.cjs --ui --scale 60 assets/.../Hit_VFX.prefab assets/.../Death_VFX.prefab
# tune only (already UI-ready): multiply another x3, or shrink /3 with --scale 0.3333
node scripts/particle-3d-to-ui.cjs --scale 3 assets/.../Hit_VFX.prefab
# add UI comps, but leave the layer alone (e.g. a shared 3D/UI wrapper node)
node scripts/particle-3d-to-ui.cjs --ui --no-layer assets/.../Shared_VFX.prefab

--ui is idempotent (never double-adds components). --scale compounds on each run and its value must be a finite number greater than 0. --no-layer only takes effect together with --ui.

A 3D effect authored in world-units (Unity/Cocos sizes ~0.1–8) must become tens of pixels in a 1080×1920 UI. Empirically:

Design resolutionGood starting factorNotes
1080×1920 (portrait playable)×50–60×60 was the sweet spot in production (CarBattle VFX).
720×1280×30–40scale roughly with design width

Tuning method: run --ui --scale 20 first, view in-editor, then step by ×3 / ÷3 (--scale 3, --scale 0.3333) until the effect fills the intended UI area. 3–4 steps converges. Judge by eye against the UI, not the 3D scene.

Scaled ×N (length / velocity / acceleration): startSize(+X/Y/Z), startSpeed, gravityModifier, shape radius/length/position/randomPositionAmount, velocity / force / limit-velocity modules, child-node local positions. rateOverDistance is divided by N (particles-per-distance → keep trail density).

Left unchanged (time / angle / color / count): lifetime, rotation, start color + alpha, burst counts, rateOverTime, the size-over-life curve (it is a normalized 0–1 multiplier — magnitude already lives in startSize), texture-sheet, renderer lengthScale/velocityScale. Also left unchanged: shape sphericalDirectionAmount / randomDirectionAmount — these are 0–1 blend ratios, not spatial lengths, and must NOT scale like radius/randomPositionAmount do.

Terminal window
node -e 'const p=JSON.parse(require("fs").readFileSync(process.argv[1]));
const psN=new Set(p.filter(o=>o.__type__=="cc.ParticleSystem").map(o=>o.node.__id__));
for(const n of psN){const c=(p[n]._components||[]).map(x=>p[x.__id__].__type__);
if(c.filter(t=>t=="cc.UITransform").length!=1||c.filter(t=>t=="cc.UIMeshRenderer").length!=1)throw"bad "+n;}
for(const o of p)if(o&&o.__type__=="cc.Node"&&o._layer!=(1<<25))throw"not UI_2D: "+o._name;
const ids=p.filter(o=>o.__type__=="cc.CompPrefabInfo").map(o=>o.fileId);
if(new Set(ids).size!=ids.length)throw"dup fileId"; console.log("OK");' your.prefab

Confirm: each PS node has exactly one cc.UITransform + one cc.UIMeshRenderer; root has cc.UITransform; every node is on UI_2D; every cc.CompPrefabInfo.fileId is unique; JSON still parses. Then open the prefab under a Canvas in the editor to reimport.

⚠️ Scope of this snippet: structure only. It reasons purely over the serialized array, which is sound for component presence and fileId uniqueness — both are always written out. It says nothing about which modules are on, and it cannot: default-valued properties are stripped from the JSON, and a scene instance can override the asset (see Gotchas). For module state, read the live component back (Cocos MCP manage_component get_all).

  • cc.UIMeshRenderer only works under a Canvas. The particle node must be in the UI/2D tree; dropped in a 3D scene node it does nothing.
  • The layer is a second, independent gate — and it fails silently. Camera._visibility masks what a camera draws, so the UI camera renders UI_2D only. A prefab carrying a correct cc.UIMeshRenderer but left on DEFAULT draws nothing: no warning, no error, no missing-reference marker — identical symptom to the missing-component case, which is why it reads as “the skill didn’t work”. --ui now sets _layer on every node; a prefab processed by an older version needs _layer: 33554432 patched in by hand. Note _layer is written out even at its default, so it is safe to assert on directly (unlike a module’s _enable — see below).
  • Shared-CurveRange double-scale trap. startSize and startSizeX usually reference the SAME CurveRange object ({"__id__":6}). Scaling by field name hits it twice → ×N². The script dedups by __id__; any hand-rolled scaler MUST too (symptom: a ×20 request producing ×400 sizes).
  • Never use node.scale to resize a Cocos particle — the emitter shape and stretch billboards distort; always scale the params. This is the skill’s core lesson. It holds even where node scale does reach the effect, because node scale is not a uniform resize: the scale uniform multiplies particle size only (particle-vs-legacy.chunk: compScale = scale.xyz * a_texCoord1), and on a world-space system emit() scales the emitter footprint but transforms velocity by rotation alone — so travel distance never scales. Bigger blobs, same cluster width. To widen a burst, raise the shape radius/length (which --scale already does).
  • scaleSpace: Local ignores the parent chain. doUpdateScale resolves node.getScale() for Local and node.getWorldScale() for World (renderer/particle-system-renderer-cpu.ts), always on the emitter’s own node. So scaling a root does not reach scaleSpace: 1 child emitters at all — another reason node scale is the wrong lever here. Full treatment in t1k:cocos:playable:unity-particle.
  • An absent _enable key means the class DEFAULT, not “disabled”. Cocos strips default-valued properties when serializing (serialization/deserialize.ts: “different Masks due to different default properties removed”), so the flag vanishes from the JSON whenever it holds its default — false for every over-lifetime module and the shape module. Two ways this misleads: the key is _enable, so a grep for "enable" misses a module explicitly turned on; and a cc.PrefabInstance in a scene applies propertyOverrides at expand time (scene-graph/prefab/utils.ts → applyPropertyOverrides), so the live component can disagree with the asset entirely. Verify module state at runtime (manage_component get_all), never from the serialized file alone.
  • --scale compounds. Two --scale 3 runs = ×9. To undo an overshoot, run the inverse (--scale 0.3333), don’t git-revert-then-redo unless you want the exact prior state.
  • Component insertion is append-only. New cc.UITransform / cc.UIMeshRenderer / cc.CompPrefabInfo objects are pushed to the array tail and their {__id__} back-refs registered in node._components — never renumber existing __id__s (breaks every reference in the prefab).
  • emitFrom / mesh render modes are untouched — this skill is about UI-readiness + size, not fidelity. For 3D-mesh particles or blend/texture fidelity see t1k:cocos:playable:unity-particle.
  • space: Local on velocity/force/limit modules is silently inert in Cocos Creator 3.8.7. IParticleModule.update() — which flips needTransform for local-space simulation — is commented out at both call sites (particle-system-renderer-cpu.ts:405, particle-culler.ts:209; upstream engine PR 17289). A Local-space jet never rotates with its node; aim/orient the effect in world space instead.
  • limitVelocityOvertimeModule + velocityOvertime compounds into a runaway. LimitVelocityOvertimeModule.animate() ends with Vec3.copy(p.velocity, p.ultimateVelocity), so stacking it with a velocityOvertime module does not clamp the jet — it compounds the per-frame velocity into a runaway of roughly jet/dampen.