t1k:cocos:playable:particle-ui
| Field | Value |
|---|---|
| Module | playable |
| Version | 2.14.4 |
| Effort | medium |
| Tools | — |
Keywords: 2d, 3d to ui, canvas, invisible, node scale, render particle in ui, scale particle, tiny, too small, ui, uimeshrenderer, uitransform
How to invoke
Section titled “How to invoke”/t1k:cocos:playable:particle-uiCocos 3D ParticleSystem → 2D / UI
Section titled “Cocos 3D ParticleSystem → 2D / 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):
- UI components + UI_2D layer — a 3D particle will NOT draw under a Canvas until its node has a
cc.UIMeshRendererand sits on theUI_2Dlayer. Addcc.UITransform+cc.UIMeshRendererto every particle node; addcc.UITransformto the root node; move every node toUI_2D(1 << 25= 33554432). The components alone are not enough — the UI camera filters by layer, so a prefab left onDEFAULT(1 << 30) renders nothing and gives no error. Pass--no-layerto opt out of the layer move (e.g. a wrapper node is shared with a 3D-only sibling and must stay offUI_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. - 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).
# 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.3333node 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.
How big? — the scale factor
Section titled “How big? — the scale factor”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 resolution | Good starting factor | Notes |
|---|---|---|
| 1080×1920 (portrait playable) | ×50–60 | ×60 was the sweet spot in production (CarBattle VFX). |
| 720×1280 | ×30–40 | scale 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.
What gets scaled (and what must NOT)
Section titled “What gets scaled (and what must NOT)”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.
Verify after running
Section titled “Verify after running”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.prefabConfirm: 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).
Gotchas
Section titled “Gotchas”cc.UIMeshRendereronly 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._visibilitymasks what a camera draws, so the UI camera rendersUI_2Donly. A prefab carrying a correctcc.UIMeshRendererbut left onDEFAULTdraws 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”.--uinow sets_layeron every node; a prefab processed by an older version needs_layer: 33554432patched in by hand. Note_layeris 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.
startSizeandstartSizeXusually reference the SAMECurveRangeobject ({"__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.scaleto 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: thescaleuniform multiplies particle size only (particle-vs-legacy.chunk:compScale = scale.xyz * a_texCoord1), and on a world-space systememit()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 shaperadius/length(which--scalealready does). scaleSpace: Localignores the parent chain.doUpdateScaleresolvesnode.getScale()forLocalandnode.getWorldScale()forWorld(renderer/particle-system-renderer-cpu.ts), always on the emitter’s own node. So scaling a root does not reachscaleSpace: 1child emitters at all — another reason node scale is the wrong lever here. Full treatment int1k:cocos:playable:unity-particle.- An absent
_enablekey 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 —falsefor 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 acc.PrefabInstancein a scene appliespropertyOverridesat 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. --scalecompounds. Two--scale 3runs = ×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.CompPrefabInfoobjects are pushed to the array tail and their{__id__}back-refs registered innode._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 seet1k:cocos:playable:unity-particle.space: Localon velocity/force/limit modules is silently inert in Cocos Creator 3.8.7.IParticleModule.update()— which flipsneedTransformfor local-space simulation — is commented out at both call sites (particle-system-renderer-cpu.ts:405,particle-culler.ts:209; upstream engine PR 17289). ALocal-space jet never rotates with its node; aim/orient the effect in world space instead.limitVelocityOvertimeModule+velocityOvertimecompounds into a runaway.LimitVelocityOvertimeModule.animate()ends withVec3.copy(p.velocity, p.ultimateVelocity), so stacking it with avelocityOvertimemodule does not clamp the jet — it compounds the per-frame velocity into a runaway of roughlyjet/dampen.