Skip to content

t1k:cocos:playable:unity-particle

FieldValue
Moduleplayable
Version2.14.4
Efforthigh
Tools—

Keywords: billboard, bubble, convert, curverange, emitter, gradientrange, mat, particle, particlesystem, ripped, shapemodule, sizeovertime, vfx

/t1k:cocos:playable:unity-particle

Unity ParticleSystem → Cocos 3.8.7 ParticleSystem

Section titled “Unity ParticleSystem → Cocos 3.8.7 ParticleSystem”

Port a Unity ParticleSystem prefab (e.g. from a ripped/exported project) into a Cocos Creator 3.8.7 cc.ParticleSystem prefab. Cocos has no editor importer for Unity particles, so the prefab is hand-authored as JSON by cloning the serialization shape of a known-good Cocos particle prefab and rewriting the module values from the Unity source.

Inputs — what to provide when you invoke this skill

Section titled “Inputs — what to provide when you invoke this skill”

Provide at least the absolute path to the Unity ParticleSystem prefab. Everything else (its material .mat + texture) is resolved from that file.

InputRequiredDefaultExample
Unity .prefab absolute path✅ yes—C:\Users\me\Downloads\Rip\ExportedProject\Assets\GameObject\BubbleInPipe.prefab
Cocos output folder (where to write the new .prefab)optionalassets/game-assets/vfx/Prefabs/3D/assets/game-assets/vfx/Prefabs/3D/
Output prefab nameoptionalsame as the Unity prefabBubbleInPipe
Blend overrideoptionalinfer from Unity .mat (transparent → alpha-blend, else additive)alpha-blend | additive
Import the source texture?optionalyesyes | reuse <existing texture uuid>

Copy-paste invocation:

Convert the Unity VFX at C:\…\BubbleInPipe.prefab into a Cocos particle prefab under assets/game-assets/vfx/Prefabs/3D/, name BubbleInPipe, alpha-blend.

⛔ If the absolute .prefab path is missing, STOP and ask for it before doing anything else — never guess a path. If the output folder is omitted, use the default above and say so.

  • A Unity .prefab whose GameObject has a ParticleSystem (!u!198) + ParticleSystemRenderer (!u!199).
  • You need it as a Cocos .prefab referencing a Cocos cc.Material + cc.Texture2D.

Not for: Unity VFX Graph / Shuriken sub-emitter trees with mesh particles (only the common billboard case is covered here — flag the gaps).

  1. Read the Unity source — the .prefab (ParticleSystem + ParticleSystemRenderer blocks) and the material it references (m_Materials[0] guid → .mat) to get the texture guid and blend (_Blend / _SrcBlend / _DstBlend / render queue).
  2. Find a Cocos skeleton — locate an existing project particle prefab with a single cc.ParticleSystem to clone the exact __id__ graph (grep '"__type__": "cc.ParticleSystem"' and pick the file with one match). Never author the id-graph from memory — clone it.
  3. Map the values — use the tables below to translate every module. Keep the skeleton’s __id__ layout identical; only change scalar values, curve keys, enum ints, and material/texture uuids.
  4. Author .prefab + .meta — write the JSON, generate a fresh v4 uuid for the .meta (importer: "prefab", syncNodeName).
  5. Import texture + material — copy the Unity texture PNG into the project, hand-write its image .meta (clone a sibling PNG .meta, new uuid, same 6c48a texture sub-id), and make a builtin-particle material (see Material section).
  6. Validate — node -e "JSON.parse(fs.readFileSync(...))" on every file; grep the new uuids project-wide to confirm zero collisions; confirm the prefab no longer references the skeleton’s original material/texture uuids.

Cannot visually verify without the editor — say so. The editor reimports the new assets on focus; the prefab renders only after import.

Full field-by-field lookup tables (emitter/main module, simulation-space & scale-space enums, minMaxState → CurveRange.mode, shape-type enum, size/color-over-life, renderer mode): see references/parameter-mapping.md. The operative safety warnings from those tables are condensed below in ## Gotchas — read that first, since several enum pairs share the same int range with different meanings and a literal copy corrupts the port silently.

The particle material must use the builtin-particle effect — reuse the effect uuid an existing project particle material already references (it is a builtin, not a local .effect file). The blend is chosen by _techIdx:

_techIdxBlendUse for
0Additiveglows, sparks, light orbs, neon
1Alpha-blendtranslucent/soft particles — smoke, glass bubbles, dust

Match the Unity material: transparent render queue + alpha _Blend → techIdx 1; additive → techIdx 0. Clone a sibling .mtl of the right techIdx, swap _props[0].mainTexture to the imported texture’s @6c48a uuid, set tintColor, and write a .meta (importer: "material", new uuid).

The builtin-particle tinted fragment shader multiplies 2.0 * vertexColor * tintColor * texture. Therefore the neutral/no-op material tint is 0.5 grey: { r: 128, g: 128, b: 128, a: 128 } in 0–255 color values (equivalently 0.5, 0.5, 0.5, 0.5), not white. A white {255,255,255,255} tint makes the 2.0 factor double both RGB and alpha, which can turn soft/translucent particles bright, opaque, and hard-edged.

Texture import: copy the PNG, clone a sibling image .meta, give it a fresh top-level uuid and the same 6c48a texture sub-id (<uuid>@6c48a is what the renderer references), keep redirect → <uuid>@6c48a.

Cocos cc.ParticleSystem id-graph (skeleton)

Section titled “Cocos cc.ParticleSystem id-graph (skeleton)”

A single-PS prefab is a flat array of objects referenced by __id__. Canonical layout (clone, don’t memorize):

0 cc.Prefab 1 cc.Node 2 cc.ParticleSystem 3 cc.CompPrefabInfo
4 startColor GradientRange 5..7 startSize{,Y,Z} CurveRange
8 startSpeed 9..11 startRotation{X,Y,Z} 12 startDelay 13 startLifetime
14 gravityModifier 15 rateOverTime 16 rateOverDistance 17 cc.Burst 18 burst count CurveRange
19 ColorOvertimeModule 20 GradientRange 21 cc.Gradient 22..25 cc.AlphaKey
26 ShapeModule 27 arcSpeed 28 SizeOvertimeModule 29 size CurveRange 30 cc.RealCurve 31..33 x/y/z
34 VelocityOvertimeModule 35..38 · 39 ForceOvertimeModule 40..42 · 43 LimitVelocityOvertimeModule 44..47
48 RotationOvertimeModule 49..51 · 52 TextureAnimationModule 53 CurveRange 54 RealCurve 55 CurveRange
56 NoiseModule · 57 TrailModule 58..61 · 62 cc.ParticleSystemRenderer · 63 cc.PrefabInfo

Each tunable lives in its own cc.CurveRange object (mode 0 constant / mode 3 min-max / mode 1 spline). Disabled modules keep _enable: false with zeroed CurveRanges — leave them intact from the skeleton.

See references/conversion-checklist.md for the step-by-step file-by-file checklist.

  • Clone the id-graph; never hand-number it. A wrong __id__ reference silently corrupts the prefab.
  • Unity and Cocos order the simulation-space enum oppositely (Unity Local 0 / World 1; Cocos World 0 / Local 1). Never pass moveWithTransform through — invert it. A wrong value is silent: the prefab loads fine, but a world-space trail becomes emitter-locked and a moving emitter (rocket, projectile) drags its smoke along instead of leaving it behind. scalingMode is a third enum again — translate it separately.
  • Shape ints are not interchangeable either. Unity ShapeModule.type and Cocos shapeType overlap on 0–4 with different meanings — a copied Unity Cone (4) becomes a Cocos Hemisphere (4) and loses its direction. Always go through the shape table.
  • Renderer mode IS a straight pass-through, unlike simulation-space and shape. m_RenderMode 0–4 (Billboard/Stretch/HorizontalBillboard/VerticalBillboard/Mesh) map identity to _renderMode — copy the int directly. The one exception is Unity 5 (None, a disabled renderer): it has no Cocos render-mode value, so map it to disabling the renderer component, never to 4 (Mesh) with a null mesh.
  • randomDirectionAmount is inert in Cocos 3.8.7 — declared on the shape module, never read by emit(). Any Unity direction randomisation is dropped; the ported spread looks tighter than the source. Don’t hunt cone angle or start-speed for it.
  • Box emitFrom MUST be 3 (Volume), never 0 (Base). Base is auto-corrected to Volume only in the editor shapeType setter, not on prefab load — a hand-authored emitFrom: 0 on a Box leaves emit positions uninitialized → particles scatter outside the box (the “spawns everywhere / infinite” bug). Same trap: Sphere/Hemisphere need 3 (Volume), not 0/1.
  • _mainTexture needs the @6c48a sub-uuid, not the bare image uuid.
  • techIdx is the #1 fidelity bug: additive vs alpha-blend completely changes the look; a glass bubble on additive becomes a glowing ring.
  • Unity particle sorting is a hierarchy conversion step, not a field mapping. Make competing emitters siblings and order them back-to-front; move a highest-sorted parent emitter onto its own last child when necessary.
  • uuid uniqueness: grep every generated uuid across assets/ before finishing.
  • Tiny Unity sizes (0.1–0.15) stay faithful but may look invisible at the project’s scene scale — keep the authored values, don’t silently inflate. Make it visible by scaling in data, not on the transform: run t1k:cocos:playable:particle-ui’s scripts/particle-3d-to-ui.cjs --scale N, which multiplies the spatial params. Under a Canvas, node.setScale() is the wrong tool outright — that skill exists because node-scaling smears stretched billboards and distorts the emitter shape. Even in a pure 3D scene it only earns its keep on a single-emitter prefab, and it multiplies particle size, not burst extent (references/parameter-mapping.md § “Node scale scales SIZE, not travel”).
  • scaleSpace: 1 (Local) ignores the parent chain — it resolves node.getScale() on the emitter’s own node, so scaling a scaled root never reaches child emitters and they render sub-pixel with no error. Use scaleSpace: 0 (World) on every child, or scale each child node. Looks fine on single-emitter prefabs, which is why it survives review.
  • scaleSpace (SIZE) and _simulationSpace (POSITION) resolve scale through different paths, and a compensating-scale child falls into a variant of the trap above. Particle SIZE never touches cc_matWorld — it comes straight from the scale uniform (scale.xyz * a_texCoord1, i.e. scale_uniform * startSize), where Cocos ParticleSpace is World = 0, Local = 1 (the inverse of the intuitive Unity-first reading). Emit POSITION on a Local-space node, by contrast, does pass through the full cc_matWorld. So a child node holding a deliberate counter/compensating scale (to keep some other sibling visually stable under a scaled parent) still emits from the geometrically-correct position under scaleSpace: 1 — but its particles render at that node’s own small compensating scale, i.e. silently shrunk, because SIZE bypassed the matrix that would have folded the compensation back out. Set scaleSpace: 0 (World) on that child so SIZE reads getWorldScale() instead, agreeing with where POSITION already lands. See references/parameter-mapping.md § “What scaleSpace actually reads” for the full engine-call table.
  • The serialized key is _enable, not enable — and an absent key means the class DEFAULT, not “off”. Cocos strips properties that equal their default when serializing (serialization/deserialize.ts: “Instances of the same class may have different Masks due to different default properties removed”), so a module’s flag is simply missing from the JSON whenever it holds the default. For every over-lifetime module and the shape module that default is false (animator/*-overtime.ts, emitter/shape-module.ts), so absent does mean off for those — but a grep for "enable" finds nothing even on a module explicitly turned on ("_enable": true), which reads identically to disabled. Never conclude module state from a key search; check _enable explicitly and know the class default.
  • The .prefab asset is not authoritative for a scene instance. A node placed in a scene is a cc.PrefabInstance whose propertyOverrides are applied at expand time (scene-graph/prefab/utils.ts → applyPropertyOverrides), so the live component can differ from every value in the asset file. When the answer matters, read it back at runtime (Cocos MCP manage_component get_all) instead of trusting the serialized file.
  • startSpeed 0 means particles don’t move (grow/fade in place) — preserve it; don’t “helpfully” add velocity unless asked.
  • Dispatch every MinMaxCurve field on minMaxState — Unity serializes all four slots (scalar, minScalar, maxCurve, minCurve) whatever the mode, so the slots the mode does not name are stale leftovers from earlier edits, not hidden “real” values. Take only the one the state names: 0 constant → scalar, 1 curve → maxCurve, 2 two curves → minCurve/maxCurve, 3 random between two constants → minScalar..scalar. Under minMaxState: 0 the scalar is authoritative even when it is 0 — a non-zero minScalar beside it does not override it. Holds for countCurve, rateOverTime, startSpeed, startSize, startLifetime alike (references/parameter-mapping.md § “Unity minMaxState → Cocos CurveRange.mode”).
  • cc.RealInterpolationMode is LINEAR = 0, CONSTANT = 1, CUBIC = 2 (cc.d.ts, export enum RealInterpolationMode). Hand-authoring a cc.RealCurve keyframe for a size/color-over-lifetime module and writing interpolationMode: 1 intending “linear” instead selects CONSTANT, which holds each keyframe’s value until the next key. A 0→1 ramp then evaluates to 0 for the entire 0..1 range, reaching 1 only at t = 1, past the particle’s life — so startSize * curve is 0 every frame and the emitter renders nothing. There is no error, no warning, and no missing-reference marker; the symptom is simply an invisible sub-emitter while its siblings render fine, which reads as a material or texture problem and sends you debugging the wrong thing. Author linear ramps as interpolationMode: 0. Note fx_dongnap-style prefabs commonly cloned as a serialization skeleton use interpolationMode: 2 (CUBIC) with explicit tangents, so copying the skeleton and editing only the value fields is safe — the trap is hand-writing a fresh keyframe.
  • When deriving a UI scale factor by measuring an existing converted prefab against its Unity source, multiply in the reference prefab’s node _lscale chain — not just its serialized spatial values. Measured case: ItemSparkleYellow.prefab serialized values suggested a ×60 factor (Unity size 0.7 → 42, radius 0.66 → 39.6, speed 0.1–0.3 → 6–18, gravity −0.05 → −3), but its root node also carries _lscale: 7, so the real end-to-end factor is ~420. Using 60 produced particles 12–24 px wide on a 1080-wide canvas (under 2% of screen width) for 0.6 s — effectively invisible, and with no error anywhere. Effective factor = serialized-value ratio × node-scale chain of the reference.
  • A looping emitter is the wrong control for verifying a burst-only one-shot effect. ItemSparkleYellow is loop: true, rateOverTime: 25/4, bursts: [] so it renders whenever you look at it; a loop: false + single-burst prefab plays once on node creation and is over in well under a second, and moving the node does not retrigger it. Pick a non-looping burst prefab as the structural control (e.g. game-assets/vfx/Prefabs/3D/ExplodeBlue.prefab), and document the replay procedure for the user: the ParticleSystem component’s Play/Stop buttons in the Inspector, or toggling the node’s active checkbox.
  • Prefer multiplying spatial params in data over setting node _lscale when any emitter uses _renderMode: 1 (stretched billboard) — node scale smears and distorts stretched particles. This is why particle-3d-to-ui.cjs --scale N scales data rather than the transform.
  • A Unity emitter with rateOverTime: 0, m_BurstCount: 0, m_Bursts: [], looping: 1, and a non-zero rateOverDistance under moveWithTransform: 1 (World) is a deliberate distance-driven trail emitter — not a lossy or truncated export. It is correctly silent while the node is static and emits only as it travels; do not “repair” it by restoring bursts from the upstream original. Measured case: one session read exactly this signature as a damaged regeneration, imported burst counts from the Epic Toon FX original, set loop: false, and switched simulation space to Local — three changes that each independently destroyed the trail.
  • rateOverDistance scales INVERSELY with the spatial scale factor — the one spatial parameter that does. It is particles per world unit of distance, so the Cocos value is unityRate / N (N = px per Unity unit), not unityRate × N like size/speed/etc. Measured case: N=420, so Unity rates 2 and 10 became 0.004761905 and 0.023809524 — one particle every 210 px and every 42 px of travel. Porting the raw 2 and 10 as particles-per-pixel would request 2,160 and 10,800 particles over a 1080 px drag — instantly past _capacity (1000) and choking the frame. Sanity-check every distance rate: compute the particle count over a full-screen drag and reject the conversion if it is absurd in either direction.
  • loop: true is mandatory for any distance-driven emitter. In cocos/particle/particle-system.ts _emit() (3.8.7, lines ≈1446–1471), when _time > duration + startDelay and loop is false, _isEmitting is set false and the function returns before the rateOverDistance block is reached. A non-looping distance emitter therefore dies after duration seconds even with every rate converted correctly.
  • The simulation-space enum is INVERTED between the engines, and for a trail the off-by-one is load-bearing, not cosmetic. Cocos Space is World = 0, Local = 1, Custom = 2; Unity moveWithTransform is 0 = Local, 1 = World — so Unity moveWithTransform: 1 maps to Cocos _simulationSpace: 0. In Local space every particle rides along with the emitter: nothing stays behind and you get a clump stuck to the node instead of a streak. Off-by-one here is silent, unlike shape enums which visibly break.
  • Run from the parent project root, not inside a submodule.